mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-03 00:41:28 -06:00
Compare commits
128
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b39e287a4 | ||
|
|
3e178d8f5a | ||
|
|
f68150ee10 | ||
|
|
0d45a64db2 | ||
|
|
e832a373f5 | ||
|
|
6c63345615 | ||
|
|
1f2c75a288 | ||
|
|
fd4c533425 | ||
|
|
d4d99c7627 | ||
|
|
9fce04f0b4 | ||
|
|
ffd77c7db0 | ||
|
|
1f6587b60d | ||
|
|
d09f4c1284 | ||
|
|
37a6aed679 | ||
|
|
02ea528e94 | ||
|
|
bcc4fc97e6 | ||
|
|
f4fcac9f2d | ||
|
|
babc950f81 | ||
|
|
468394b3c8 | ||
|
|
5eb0b7d2c0 | ||
|
|
01220df579 | ||
|
|
b0b87c5cad | ||
|
|
befa4cd69f | ||
|
|
cc00a17dfe | ||
|
|
90058395b7 | ||
|
|
ee39ff6781 | ||
|
|
bbd579813f | ||
|
|
eaf8d1ce3f | ||
|
|
f2f0c4373d | ||
|
|
14cc73101b | ||
|
|
650c4f9497 | ||
|
|
9f952550b2 | ||
|
|
5fb694d991 | ||
|
|
7f742c9d4f | ||
|
|
b5033b6213 | ||
|
|
b6c1d87cf0 | ||
|
|
c37b150950 | ||
|
|
f3d8ba85a6 | ||
|
|
e690829543 | ||
|
|
c0468b59cd | ||
|
|
5d27461fa6 | ||
|
|
773133ec42 | ||
|
|
8ec4f29059 | ||
|
|
87a10fc8e2 | ||
|
|
3fb6c43136 | ||
|
|
82573331a8 | ||
|
|
d990678b32 | ||
|
|
cd19752bb5 | ||
|
|
2ed2ce18d8 | ||
|
|
c5ac1dd44b | ||
|
|
84ac51a439 | ||
|
|
c3e9bd07e5 | ||
|
|
a3fbf02952 | ||
|
|
92bf7fc67d | ||
|
|
0ed4b394ac | ||
|
|
e747bff425 | ||
|
|
f7a7fd7f5f | ||
|
|
800ffeaa59 | ||
|
|
d080a23dc0 | ||
|
|
243c1a269f | ||
|
|
06a1ca4672 | ||
|
|
40728b5362 | ||
|
|
d2504e5f8b | ||
|
|
d92874f541 | ||
|
|
6a34f13808 | ||
|
|
6058fc7882 | ||
|
|
04b0958bee | ||
|
|
acf054852c | ||
|
|
861879f8ee | ||
|
|
e626625d40 | ||
|
|
4a0f48a04c | ||
|
|
8f6e1b420a | ||
|
|
c0db6ebeba | ||
|
|
a667e13a03 | ||
|
|
3bf35c801f | ||
|
|
47eb6732fa | ||
|
|
8a2eccc863 | ||
|
|
fb05eaa30f | ||
|
|
231a52438c | ||
|
|
a00b7e8a51 | ||
|
|
9678ed7d7f | ||
|
|
1de7b44a8d | ||
|
|
5a235590dd | ||
|
|
a96dcdbe8f | ||
|
|
0e3f4cd353 | ||
|
|
72c8621358 | ||
|
|
cccc9064ca | ||
|
|
1de0101ced | ||
|
|
f5c2aca644 | ||
|
|
4408ff6153 | ||
|
|
ad97533aec | ||
|
|
727b682d17 | ||
|
|
8770fb90c3 | ||
|
|
9a716be59e | ||
|
|
479c3cc5ea | ||
|
|
b7482dd392 | ||
|
|
e83f964077 | ||
|
|
ede7a7ead7 | ||
|
|
68dda6f7d8 | ||
|
|
9aef8cc5ab | ||
|
|
6113f4e1a2 | ||
|
|
77e3a444b8 | ||
|
|
09bbfc7217 | ||
|
|
443f503407 | ||
|
|
e812da58df | ||
|
|
ee4bdbcd7d | ||
|
|
adaa4e6fa1 | ||
|
|
446bc528ad | ||
|
|
3c068df628 | ||
|
|
f8165751e5 | ||
|
|
7190c85860 | ||
|
|
ddd39249ba | ||
|
|
b4c1905d78 | ||
|
|
2c11865b2d | ||
|
|
b92a7ddbd5 | ||
|
|
0768e95591 | ||
|
|
82adf7326e | ||
|
|
ef6fd29928 | ||
|
|
a3449185ac | ||
|
|
90c8f65f5e | ||
|
|
b082f12f70 | ||
|
|
32ef926c46 | ||
|
|
4c1916466b | ||
|
|
14eef3aceb | ||
|
|
33b6e11726 | ||
|
|
8c2560f31c | ||
|
|
48c5ff67bf | ||
|
|
78ab8f0626 |
@@ -12,6 +12,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
|
||||
@@ -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,14 +178,11 @@ 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.
|
||||
|
||||
+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/*`).
|
||||
|
||||
Generated
+35
-35
@@ -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,7 +888,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -984,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1006,7 +1006,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -1015,7 +1015,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1024,14 +1024,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -1041,14 +1041,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
@@ -1074,7 +1074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1086,7 +1086,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1097,7 +1097,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1105,7 +1105,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1114,7 +1114,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1123,21 +1123,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1147,7 +1147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1165,7 +1165,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1174,7 +1174,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1186,15 +1186,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1212,7 +1212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1228,7 +1228,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1247,7 +1247,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1261,7 +1261,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -1286,7 +1286,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1295,7 +1295,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -2708,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",
|
||||
@@ -6177,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+6
-6
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.1"
|
||||
version = "0.15.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -59,11 +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-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 = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" }
|
||||
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"] }
|
||||
@@ -109,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.
|
||||
|
||||
|
||||
+12
-2
@@ -2,6 +2,9 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -146,6 +149,10 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences |
|
||||
| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) |
|
||||
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
|
||||
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
|
||||
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
|
||||
| 2026-10-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) |
|
||||
| 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) |
|
||||
@@ -303,7 +310,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
|
||||
| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-10 | Progress docs restructured; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
| 2026-10-07 | v0.1.0 released, signed and notarized; About window with contributor credits (#3); PrintCraft renamed PdfCraft |
|
||||
@@ -314,6 +321,9 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
|
||||
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
|
||||
| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged |
|
||||
@@ -413,5 +423,5 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
| 2026-10-10 | major | Restructured to craftrules `standards/progress-docs.md`: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
|
||||
| 2026-10-10 | major | Restructured: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
|
||||
| 2026-10-07 | major | Alpha checklist (~80% to a self-defined alpha), breadth ~80%, overall ~62%, ~190 h to full parity |
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -71,3 +68,8 @@ real_name = "Sofia Alves Figueredo"
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marshiyar]
|
||||
display_name = "Arshiya"
|
||||
real_name = "Arshiya Rahgozar"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -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;
|
||||
@@ -606,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,17 @@ 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_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 +81,11 @@ 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_follow_text = None;
|
||||
}
|
||||
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
|
||||
para.props.indent = Some(-27.0);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Fill",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | 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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
@@ -555,3 +561,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -709,7 +709,8 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Wor
|
||||
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 == '-';
|
||||
// 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() {
|
||||
@@ -725,8 +726,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) {
|
||||
@@ -819,6 +826,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());
|
||||
|
||||
@@ -3198,6 +3198,15 @@ 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);
|
||||
}
|
||||
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();
|
||||
@@ -3207,9 +3216,13 @@ 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_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_font_follow_text, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4281,6 +4294,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() {
|
||||
@@ -5559,6 +5604,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]
|
||||
@@ -5632,6 +5841,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]
|
||||
@@ -5956,3 +6363,98 @@ fn invalid_slide_index_never_falls_back_to_the_selection_in_export_commands() {
|
||||
}
|
||||
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)]);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
@@ -558,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
|
||||
|
||||
+217
-20
@@ -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()))
|
||||
@@ -626,6 +726,10 @@ const MAX_COLLECTION_FACES: u32 = 256;
|
||||
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();
|
||||
@@ -662,21 +766,30 @@ fn enumerate_faces(data: &[u8]) -> Vec<Found> {
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
// 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();
|
||||
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)));
|
||||
@@ -837,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,
|
||||
@@ -1080,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) {
|
||||
@@ -1088,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)
|
||||
}
|
||||
|
||||
@@ -1441,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1503,6 +1651,55 @@ mod font_dir_tests {
|
||||
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;
|
||||
|
||||
@@ -216,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}");
|
||||
}
|
||||
@@ -237,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]
|
||||
|
||||
+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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
+43
-13
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -653,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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -62,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)),
|
||||
|
||||
@@ -469,6 +469,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 {
|
||||
@@ -479,10 +482,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());
|
||||
}
|
||||
@@ -490,6 +497,7 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
||||
start = true;
|
||||
}
|
||||
}
|
||||
base += s.chars().count();
|
||||
out
|
||||
}
|
||||
})
|
||||
@@ -504,6 +512,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");
|
||||
|
||||
@@ -55,10 +55,12 @@ impl ColorRef {
|
||||
};
|
||||
deckcraft_color::apply(base, &self.mods)
|
||||
}
|
||||
/// Alpha (0–1) from an `alpha` transform, if any.
|
||||
/// Alpha (0–1) from the `alpha`, `alphaMod` and `alphaOff` transforms, applied in order.
|
||||
pub fn alpha(&self) -> f64 {
|
||||
self.mods.iter().fold(1.0, |a, m| match m {
|
||||
ColorTransform::Alpha(v) => (*v as f64 / 100_000.0).clamp(0.0, 1.0),
|
||||
ColorTransform::AlphaMod(v) => (a * *v as f64 / 100_000.0).clamp(0.0, 1.0),
|
||||
ColorTransform::AlphaOff(v) => (a + *v as f64 / 100_000.0).clamp(0.0, 1.0),
|
||||
_ => a,
|
||||
})
|
||||
}
|
||||
@@ -72,6 +74,36 @@ pub struct GradientStop {
|
||||
pub color: ColorRef,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GradientFlip {
|
||||
None,
|
||||
X,
|
||||
Y,
|
||||
Xy,
|
||||
}
|
||||
|
||||
impl GradientFlip {
|
||||
pub fn xml(self) -> &'static str {
|
||||
match self {
|
||||
GradientFlip::None => "none",
|
||||
GradientFlip::X => "x",
|
||||
GradientFlip::Y => "y",
|
||||
GradientFlip::Xy => "xy",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_xml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"none" => Some(GradientFlip::None),
|
||||
"x" => Some(GradientFlip::X),
|
||||
"y" => Some(GradientFlip::Y),
|
||||
"xy" => Some(GradientFlip::Xy),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum GradientShape {
|
||||
@@ -90,6 +122,8 @@ pub enum GradientShape {
|
||||
pub struct Gradient {
|
||||
pub stops: Vec<GradientStop>,
|
||||
pub shape: GradientShape,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flip: Option<GradientFlip>,
|
||||
#[serde(default = "crate::yes")]
|
||||
pub rotate_with_shape: bool,
|
||||
/// `a:tileRect`: the box the gradient is laid out in, as insets from the shape's box
|
||||
@@ -232,6 +266,31 @@ pub enum LineJoin {
|
||||
Miter,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LineAlign {
|
||||
#[default]
|
||||
Center,
|
||||
Inside,
|
||||
}
|
||||
|
||||
impl LineAlign {
|
||||
pub fn xml(self) -> &'static str {
|
||||
match self {
|
||||
LineAlign::Center => "ctr",
|
||||
LineAlign::Inside => "in",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_xml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"ctr" => Some(LineAlign::Center),
|
||||
"in" => Some(LineAlign::Inside),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Compound {
|
||||
@@ -324,6 +383,8 @@ pub struct Line {
|
||||
pub dash: Option<Dash>,
|
||||
pub cap: Option<LineCap>,
|
||||
pub join: Option<LineJoin>,
|
||||
pub miter_limit: Option<u32>,
|
||||
pub align: Option<LineAlign>,
|
||||
pub compound: Option<Compound>,
|
||||
pub head: Option<LineEnd>,
|
||||
pub tail: Option<LineEnd>,
|
||||
@@ -347,6 +408,8 @@ impl Line {
|
||||
}
|
||||
self.cap = self.cap.or(parent.cap);
|
||||
self.join = self.join.or(parent.join);
|
||||
self.miter_limit = self.miter_limit.or(parent.miter_limit);
|
||||
self.align = self.align.or(parent.align);
|
||||
self.compound = self.compound.or(parent.compound);
|
||||
if self.head.is_none() {
|
||||
self.head.clone_from(&parent.head);
|
||||
|
||||
@@ -73,8 +73,13 @@ fn a_gradient_without_stops_takes_them_from_its_style() {
|
||||
let p = Presentation::default();
|
||||
let s = &p.slides[0];
|
||||
let ctx = Ctx::for_slide(&p, s).unwrap();
|
||||
let own =
|
||||
style::Gradient { tile: [0.0; 4], stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
let own = style::Gradient {
|
||||
tile: [0.0; 4],
|
||||
stops: vec![],
|
||||
shape: style::GradientShape::Linear { angle: 45.0, scaled: true },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
};
|
||||
let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
|
||||
st.fill_ref.0 = 3;
|
||||
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||
@@ -322,3 +327,15 @@ fn only_web_and_mail_links_are_safe_to_open() {
|
||||
assert!(!safe_url(url), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_alpha_follows_alpha_mod_and_off() {
|
||||
use deckcraft_color::{ColorTransform, Rgba};
|
||||
let base = || ColorRef::rgb(Rgba::rgb(1, 2, 3));
|
||||
let mod_half = base().with(ColorTransform::AlphaMod(50000));
|
||||
assert!((mod_half.alpha() - 0.5).abs() < 1e-9, "{}", mod_half.alpha());
|
||||
let off = base().with(ColorTransform::AlphaOff(-50000));
|
||||
assert!((off.alpha() - 0.5).abs() < 1e-9, "{}", off.alpha());
|
||||
let both = base().with(ColorTransform::Alpha(40000)).with(ColorTransform::AlphaMod(50000));
|
||||
assert!((both.alpha() - 0.2).abs() < 1e-9, "{}", both.alpha());
|
||||
}
|
||||
|
||||
@@ -236,10 +236,13 @@ pub struct ParaProps {
|
||||
pub space_after: Option<Spacing>,
|
||||
pub bullet: Option<Bullet>,
|
||||
pub bullet_font: Option<String>,
|
||||
pub bullet_font_follow_text: Option<bool>,
|
||||
/// `None` = follow text colour.
|
||||
pub bullet_color: Option<ColorRef>,
|
||||
pub bullet_color_follow_text: Option<bool>,
|
||||
/// Bullet size as a fraction of the text size.
|
||||
pub bullet_size: Option<f64>,
|
||||
pub bullet_size_follow_text: Option<bool>,
|
||||
pub tabs: Option<Vec<TabStop>>,
|
||||
pub default_tab: Option<f64>,
|
||||
pub rtl: Option<bool>,
|
||||
@@ -263,13 +266,36 @@ impl ParaProps {
|
||||
if self.bullet.is_none() {
|
||||
self.bullet.clone_from(&p.bullet);
|
||||
}
|
||||
if self.bullet_font.is_none() {
|
||||
self.bullet_font.clone_from(&p.bullet_font);
|
||||
if self.bullet_font_follow_text == Some(false) || self.bullet_font.is_some() {
|
||||
self.bullet_font_follow_text = None;
|
||||
}
|
||||
if self.bullet_color.is_none() {
|
||||
self.bullet_color.clone_from(&p.bullet_color);
|
||||
if self.bullet_font.is_none() && self.bullet_font_follow_text.is_none() {
|
||||
if p.bullet_font_follow_text == Some(true) {
|
||||
self.bullet_font_follow_text = Some(true);
|
||||
} else {
|
||||
self.bullet_font.clone_from(&p.bullet_font);
|
||||
}
|
||||
}
|
||||
if self.bullet_color_follow_text == Some(false) || self.bullet_color.is_some() {
|
||||
self.bullet_color_follow_text = None;
|
||||
}
|
||||
if self.bullet_color.is_none() && self.bullet_color_follow_text.is_none() {
|
||||
if p.bullet_color_follow_text == Some(true) {
|
||||
self.bullet_color_follow_text = Some(true);
|
||||
} else {
|
||||
self.bullet_color.clone_from(&p.bullet_color);
|
||||
}
|
||||
}
|
||||
if self.bullet_size_follow_text == Some(false) || self.bullet_size.is_some() {
|
||||
self.bullet_size_follow_text = None;
|
||||
}
|
||||
if self.bullet_size.is_none() && self.bullet_size_follow_text.is_none() {
|
||||
if p.bullet_size_follow_text == Some(true) {
|
||||
self.bullet_size_follow_text = Some(true);
|
||||
} else {
|
||||
self.bullet_size = p.bullet_size;
|
||||
}
|
||||
}
|
||||
self.bullet_size = self.bullet_size.or(p.bullet_size);
|
||||
if self.tabs.is_none() {
|
||||
self.tabs.clone_from(&p.tabs);
|
||||
}
|
||||
@@ -632,3 +658,53 @@ impl TextBody {
|
||||
n + self.paragraphs.len().saturating_sub(1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_color::Rgba;
|
||||
|
||||
#[test]
|
||||
fn explicit_bullet_follow_text_resets_block_inheritance() {
|
||||
let parent = ParaProps {
|
||||
bullet_font: Some("Inter".into()),
|
||||
bullet_color: Some(ColorRef::rgb(Rgba::rgb(0, 0, 0))),
|
||||
bullet_size: Some(2.0),
|
||||
..Default::default()
|
||||
};
|
||||
let mut child = ParaProps {
|
||||
bullet_font_follow_text: Some(true),
|
||||
bullet_color_follow_text: Some(true),
|
||||
bullet_size_follow_text: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
child.inherit(&parent);
|
||||
|
||||
assert_eq!(child.bullet_font, None);
|
||||
assert_eq!(child.bullet_color, None);
|
||||
assert_eq!(child.bullet_size, None);
|
||||
|
||||
let mut inherited_reset = ParaProps::default();
|
||||
inherited_reset.inherit(&ParaProps {
|
||||
bullet_font_follow_text: Some(true),
|
||||
bullet_color_follow_text: Some(true),
|
||||
bullet_size_follow_text: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(inherited_reset.bullet_font_follow_text, Some(true));
|
||||
assert_eq!(inherited_reset.bullet_color_follow_text, Some(true));
|
||||
assert_eq!(inherited_reset.bullet_size_follow_text, Some(true));
|
||||
|
||||
let mut explicit_false = ParaProps {
|
||||
bullet_font_follow_text: Some(false),
|
||||
bullet_color_follow_text: Some(false),
|
||||
bullet_size_follow_text: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
explicit_false.inherit(&parent);
|
||||
assert_eq!(explicit_false.bullet_font, parent.bullet_font);
|
||||
assert_eq!(explicit_false.bullet_color, parent.bullet_color);
|
||||
assert_eq!(explicit_false.bullet_size, parent.bullet_size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ pub fn default_format() -> FormatScheme {
|
||||
],
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
})
|
||||
};
|
||||
FormatScheme {
|
||||
|
||||
@@ -90,11 +90,16 @@ fn base_class(c: char) -> Class {
|
||||
c if c.is_alphanumeric() => Class::Word,
|
||||
c if is_symbol(c) => Class::Symbol,
|
||||
// A combining mark with no base (or any other mark) stays with letters.
|
||||
'\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}' => Class::Word,
|
||||
c if is_combining_mark(c) => Class::Word,
|
||||
_ => Class::Punct,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `c` is a combining mark (a decomposed accent), which belongs to the letter before it.
|
||||
pub fn is_combining_mark(c: char) -> bool {
|
||||
matches!(c, '\u{300}'..='\u{36f}' | '\u{1ab0}'..='\u{1aff}' | '\u{1dc0}'..='\u{1dff}' | '\u{20d0}'..='\u{20ff}' | '\u{fe20}'..='\u{fe2f}')
|
||||
}
|
||||
|
||||
fn clusters(text: &str) -> Vec<Cluster> {
|
||||
let mut out: Vec<Cluster> = Vec::new();
|
||||
let mut firsts: Vec<char> = Vec::new();
|
||||
|
||||
+45
-11
@@ -196,6 +196,21 @@ pub struct OggFile {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Source bytes damage recovery may read per file: the windows the scan for the next capture
|
||||
/// pattern fills and the pages read only to find their CRC wrong.
|
||||
const RECOVERY_BUDGET: u64 = 32 << 20;
|
||||
/// Warnings kept per file (not counting the one about the budget); the rest are counted in one last entry.
|
||||
const MAX_WARNINGS: usize = 256;
|
||||
|
||||
/// Record a warning, keeping at most `MAX_WARNINGS` and one entry saying the rest were dropped.
|
||||
fn warn(warnings: &mut Vec<String>, msg: String) {
|
||||
match warnings.len().cmp(&MAX_WARNINGS) {
|
||||
std::cmp::Ordering::Less => warnings.push(msg),
|
||||
std::cmp::Ordering::Equal => warnings.push("further warnings omitted".into()),
|
||||
std::cmp::Ordering::Greater => {}
|
||||
}
|
||||
}
|
||||
|
||||
struct Partial {
|
||||
parts: Vec<(u64, u32)>,
|
||||
size: u32,
|
||||
@@ -219,7 +234,7 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
match scanner.find(src, pos + 1, len)? {
|
||||
Some(p) => {
|
||||
if found_any {
|
||||
warnings.push(format!("lost sync at byte {pos}; resumed at {p}"));
|
||||
warn(&mut warnings, format!("lost sync at byte {pos}; resumed at {p}"));
|
||||
}
|
||||
pos = p;
|
||||
continue;
|
||||
@@ -229,14 +244,14 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
}
|
||||
let nseg = hdr[26] as usize;
|
||||
if pos + 27 + nseg as u64 > len {
|
||||
warnings.push(format!("truncated page header at byte {pos}"));
|
||||
warn(&mut warnings, format!("truncated page header at byte {pos}"));
|
||||
break;
|
||||
}
|
||||
src.read_at(pos + 27, &mut hdr[27..27 + nseg])?;
|
||||
let body_len: u64 = hdr[27..27 + nseg].iter().map(|&b| b as u64).sum();
|
||||
let page_len = 27 + nseg as u64 + body_len;
|
||||
if pos + page_len > len {
|
||||
warnings.push(format!("truncated page at byte {pos}"));
|
||||
warn(&mut warnings, format!("truncated page at byte {pos}"));
|
||||
break;
|
||||
}
|
||||
let mut page = vec![0u8; page_len as usize];
|
||||
@@ -244,7 +259,11 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
let stored_crc = u32::from_le_bytes([page[22], page[23], page[24], page[25]]);
|
||||
page[22..26].fill(0);
|
||||
if crc32(&page) != stored_crc {
|
||||
warnings.push(format!("bad CRC on page at byte {pos}"));
|
||||
warn(&mut warnings, format!("bad CRC on page at byte {pos}"));
|
||||
// a page read only to find it damaged is recovery work, charged like the scan's windows
|
||||
if scanner.charge(page_len) {
|
||||
break;
|
||||
}
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -275,7 +294,7 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
// a continued page without a pending packet (lost a page): drop the fragment
|
||||
let continued = flags & 0x01 != 0;
|
||||
if !continued && partial.remove(&serial).is_some() {
|
||||
warnings.push(format!("incomplete packet before page at byte {pos}"));
|
||||
warn(&mut warnings, format!("incomplete packet before page at byte {pos}"));
|
||||
}
|
||||
let mut completed: Vec<Packet> = Vec::new();
|
||||
let mut off = 0u64;
|
||||
@@ -332,7 +351,11 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
return Err(Error::NotOgg);
|
||||
}
|
||||
if !partial.is_empty() {
|
||||
warnings.push("file ends inside a packet".into());
|
||||
warn(&mut warnings, "file ends inside a packet".into());
|
||||
}
|
||||
if scanner.spent() {
|
||||
// once per file and last, so it is not subject to the cap on the others
|
||||
warnings.push("recovery budget spent; the rest of the damaged data was not searched".into());
|
||||
}
|
||||
Ok(OggFile { streams, warnings })
|
||||
}
|
||||
@@ -340,12 +363,23 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
struct CaptureScanner {
|
||||
buf: Vec<u8>,
|
||||
buf_start: u64,
|
||||
budget: usize,
|
||||
budget: u64,
|
||||
}
|
||||
|
||||
impl CaptureScanner {
|
||||
fn new() -> Self {
|
||||
Self { buf: Vec::new(), buf_start: 0, budget: 32 * 1024 * 1024 }
|
||||
Self { buf: Vec::new(), buf_start: 0, budget: RECOVERY_BUDGET }
|
||||
}
|
||||
|
||||
/// Charge `bytes` of recovery work; true once the budget is spent.
|
||||
fn charge(&mut self, bytes: u64) -> bool {
|
||||
self.budget = self.budget.saturating_sub(bytes);
|
||||
self.spent()
|
||||
}
|
||||
|
||||
/// Nothing left to search with: fewer bytes than a capture pattern can still be charged for.
|
||||
fn spent(&self) -> bool {
|
||||
self.budget < 4
|
||||
}
|
||||
|
||||
fn find(&mut self, src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result<Option<u64>> {
|
||||
@@ -360,17 +394,17 @@ impl CaptureScanner {
|
||||
at = buf_end.saturating_sub(3);
|
||||
continue;
|
||||
}
|
||||
if self.budget == 0 {
|
||||
if self.spent() {
|
||||
return Ok(None);
|
||||
}
|
||||
let n = ((len - at) as usize).min(64 * 1024).min(self.budget);
|
||||
let n = ((len - at) as usize).min(64 * 1024).min(usize::try_from(self.budget).unwrap_or(usize::MAX));
|
||||
if n < 4 {
|
||||
break;
|
||||
}
|
||||
self.buf.resize(n, 0);
|
||||
src.read_at(at, &mut self.buf)?;
|
||||
self.buf_start = at;
|
||||
self.budget = self.budget.saturating_sub(n);
|
||||
self.budget = self.budget.saturating_sub(n as u64);
|
||||
if let Some(i) = self.buf.windows(4).position(|w| w == b"OggS") {
|
||||
return Ok(Some(at + i as u64));
|
||||
}
|
||||
|
||||
+79
-1
@@ -1,6 +1,9 @@
|
||||
//! Synthetic Ogg streams: a small page writer (RFC 3533) and Opus-like packets.
|
||||
|
||||
use super::{Codec, Error, OpusTiming, Packet, Stream, crc32, open, opus_packet_samples, sniff};
|
||||
use std::cell::Cell;
|
||||
use std::io;
|
||||
|
||||
use super::{ByteSource, CaptureScanner, Codec, Error, OpusTiming, Packet, Stream, crc32, open, opus_packet_samples, sniff};
|
||||
|
||||
/// Write one page holding `segments` (lacing already computed by `lace`).
|
||||
fn page(out: &mut Vec<u8>, flags: u8, granule: i64, serial: u32, seq: u32, lacing: &[u8], body: &[u8]) {
|
||||
@@ -196,3 +199,78 @@ fn opus_timing_rejects_a_granule_that_overflows() {
|
||||
assert_eq!(ok.total, 480);
|
||||
assert!(OpusTiming::of(&packet(i64::MIN), 0).is_err());
|
||||
}
|
||||
|
||||
/// A source that counts the bytes read from it.
|
||||
struct Counting<'a> {
|
||||
data: &'a [u8],
|
||||
read: Cell<u64>,
|
||||
}
|
||||
|
||||
impl ByteSource for Counting<'_> {
|
||||
fn len(&self) -> u64 {
|
||||
self.data.len() as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
self.read.set(self.read.get() + buf.len() as u64);
|
||||
self.data.read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid file followed by `len` bytes of 0xFF with a capture pattern every 64 bytes. Each one
|
||||
/// claims 255 full lacing values, so it reads as a page of about 64 KiB that fails its CRC.
|
||||
fn dense_fake_pages(len: usize) -> Vec<u8> {
|
||||
let mut f = opus_file(6, 312, 0);
|
||||
let start = f.len();
|
||||
f.resize(start + len, 0xFF);
|
||||
for at in (start..start + len - 64).step_by(64) {
|
||||
f[at..at + 5].copy_from_slice(b"OggS\0");
|
||||
f[at + 26] = 255;
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_reads_a_bounded_amount_of_a_damaged_file() {
|
||||
let f = dense_fake_pages(1 << 20);
|
||||
let src = Counting { data: &f, read: Cell::new(0) };
|
||||
let o = open(&src).unwrap();
|
||||
assert_eq!(o.streams[0].packets.len(), 6, "the good pages are kept");
|
||||
let read = src.read.get();
|
||||
assert!(read <= 40 << 20, "read {read} bytes of a {} byte file", f.len());
|
||||
assert!(o.warnings.iter().any(|w| w.contains("recovery budget")), "{:?}", o.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warnings_are_capped() {
|
||||
// 100,000 pages with no segments and a wrong CRC, 27 bytes each
|
||||
let mut f = opus_file(6, 312, 0);
|
||||
for _ in 0..100_000 {
|
||||
let at = f.len();
|
||||
f.extend_from_slice(b"OggS\0\0");
|
||||
f.resize(at + 27, 0);
|
||||
f[at + 22] = 1;
|
||||
}
|
||||
let o = open(&f).unwrap();
|
||||
assert!(o.warnings.len() <= 257, "{} warnings", o.warnings.len());
|
||||
assert!(o.warnings.iter().any(|w| w.contains("omitted")), "{:?}", o.warnings.last());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_of_good_pages_is_not_charged_for_recovery() {
|
||||
let f = opus_file(2000, 312, 0);
|
||||
let src = Counting { data: &f, read: Cell::new(0) };
|
||||
let o = open(&src).unwrap();
|
||||
assert_eq!(o.streams[0].packets.len(), 2000);
|
||||
assert!(o.warnings.is_empty(), "{:?}", o.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_budget_too_small_to_search_is_spent() {
|
||||
// 1 to 3 bytes left cannot hold a capture pattern: the scan would stop without saying why
|
||||
let mut scanner = CaptureScanner::new();
|
||||
assert!(!scanner.spent());
|
||||
assert!(scanner.charge(super::RECOVERY_BUDGET - 3));
|
||||
assert!(scanner.spent());
|
||||
let f = opus_file(6, 312, 0);
|
||||
assert_eq!(scanner.find(&f, 0, f.len() as u64).unwrap(), None);
|
||||
}
|
||||
|
||||
@@ -202,7 +202,8 @@ fn unsafe_url_links_are_not_exported() {
|
||||
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
|
||||
/// embedding. No font bytes or vendor assets are stored as test fixtures.
|
||||
fn font_without_head() -> Vec<u8> {
|
||||
let mut bytes = FontDb::global().face("Ubuntu", "Regular").data().to_vec();
|
||||
// The final bundled face is always Ubuntu Light; a system lookup may select a variable font.
|
||||
let mut bytes = deckcraft_fonts::bundled().last().unwrap().to_vec();
|
||||
for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] {
|
||||
let mut offset = 0;
|
||||
while offset + old.len() <= bytes.len() {
|
||||
|
||||
@@ -329,15 +329,26 @@ pub fn parse_path(d: &str) -> Vec<(char, Vec<f64>)> {
|
||||
token.clear();
|
||||
}
|
||||
};
|
||||
for ch in d.chars() {
|
||||
let cs: Vec<char> = d.chars().collect();
|
||||
for (i, &ch) in cs.iter().enumerate() {
|
||||
match ch {
|
||||
// An 'E' inside a number followed by a digit (or a sign and a digit) is an exponent,
|
||||
// not the end command.
|
||||
'E' if !token.is_empty() && {
|
||||
let n1 = cs.get(i + 1).copied().unwrap_or(' ');
|
||||
let n2 = cs.get(i + 2).copied().unwrap_or(' ');
|
||||
n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit())
|
||||
} =>
|
||||
{
|
||||
token.push(ch)
|
||||
}
|
||||
'M' | 'L' | 'C' | 'Q' | 'Z' | 'm' | 'l' | 'c' | 'q' | 'z' | 'E' => {
|
||||
push_tok(&mut token, &mut nums);
|
||||
flush(cmd, &mut nums, &mut out);
|
||||
cmd = Some(ch.to_ascii_uppercase());
|
||||
}
|
||||
' ' | ',' | '\t' | '\n' => push_tok(&mut token, &mut nums),
|
||||
'-' if !token.is_empty() && !token.ends_with('e') => {
|
||||
'-' if !token.is_empty() && !token.ends_with(['e', 'E']) => {
|
||||
push_tok(&mut token, &mut nums);
|
||||
token.push(ch);
|
||||
}
|
||||
@@ -472,6 +483,15 @@ mod tests {
|
||||
assert_eq!(back[0].d, "M 0 0 L 100000 0 C 100000 10000 90000 50000 50000 50000 Q 0 50000 0 0 Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_path_uppercase_exponent() {
|
||||
assert_eq!(parse_path("M 1E-5 2 L 3 4"), vec![('M', vec![1e-5, 2.0]), ('L', vec![3.0, 4.0])]);
|
||||
assert_eq!(parse_path("M 1.5E3 2"), vec![('M', vec![1500.0, 2.0])]);
|
||||
assert_eq!(parse_path("M 1e-5 2"), vec![('M', vec![1e-5, 2.0])]);
|
||||
// An 'E' right after a number with no exponent digits is the end command.
|
||||
assert_eq!(parse_path("M 0 0E"), vec![('M', vec![0.0, 0.0]), ('E', vec![])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_path_tokens() {
|
||||
let v = parse_path("M0,0 L10-5 Z");
|
||||
|
||||
@@ -14,6 +14,7 @@ mod maps;
|
||||
mod opc;
|
||||
mod png;
|
||||
pub(crate) mod read;
|
||||
pub use read::MAX_TAG_BYTES;
|
||||
mod tables;
|
||||
mod write;
|
||||
mod xml;
|
||||
|
||||
+66
-6
@@ -40,6 +40,12 @@ pub struct Imp<'a> {
|
||||
pub kept_sidecar_bytes: usize,
|
||||
/// Cached kept sidecars by normalized chart part name.
|
||||
pub kept_charts: HashMap<String, deckcraft_model::chart::ChartKept>,
|
||||
/// Add-in tags parts, parsed once each, by normalized part name.
|
||||
pub tags_by_part: HashMap<String, Arc<[(String, String)]>>,
|
||||
/// Bytes of tags copied into the model so far (see [`MAX_TAG_BYTES`]).
|
||||
pub tag_bytes: usize,
|
||||
/// The tag budget ran out and the warning was given.
|
||||
pub tags_capped: bool,
|
||||
}
|
||||
|
||||
impl Imp<'_> {
|
||||
@@ -196,16 +202,64 @@ fn choice_has_model3d(el: &El, rels: &Rels) -> bool {
|
||||
rec(el, rels, 0)
|
||||
}
|
||||
|
||||
/// Bytes of tags one import copies into the model, every tags part and reference together: a
|
||||
/// small compressed tags part listed by many shapes, or many times by one, must not grow without
|
||||
/// bound (#1164). The package budgets (`opc::MAX_XML_PART`, `MAX_TOTAL`, #1283) bound what is
|
||||
/// parsed; this bounds what is copied from it.
|
||||
pub const MAX_TAG_BYTES: usize = 64 << 20;
|
||||
/// What each copied pair costs besides its text, so empty pairs count too.
|
||||
const TAG_PAIR_COST: usize = std::mem::size_of::<(String, String)>();
|
||||
|
||||
/// Add-in tags named by `holder`'s `p:custDataLst`: the name/value pairs of each tags part.
|
||||
pub fn tags(imp: &Imp, part: &Part, holder: &El) -> Vec<(String, String)> {
|
||||
/// Each part is parsed once per import and counted once per holder. Once [`MAX_TAG_BYTES`] is
|
||||
/// reached, no further tag parts are parsed or cached (#1164).
|
||||
pub fn tags(imp: &mut Imp, part: &Part, holder: &El) -> Vec<(String, String)> {
|
||||
if imp.tags_capped {
|
||||
return vec![];
|
||||
}
|
||||
let Some(list) = holder.child("custDataLst") else { return vec![] };
|
||||
let mut out = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for t in list.children_named("tags").take(100) {
|
||||
let Some(doc) = t.attr("r:id").and_then(|id| part.rels.get(id)).and_then(|r| imp.pkg.xml(&r.target)) else { continue };
|
||||
for tag in doc.root.children_named("tag").take(10_000) {
|
||||
if let Some(name) = tag.attr("name") {
|
||||
out.push((name.to_string(), tag.attr("val").unwrap_or("").to_string()));
|
||||
if imp.tags_capped {
|
||||
return out;
|
||||
}
|
||||
let Some(target) = t.attr("r:id").and_then(|id| part.rels.get(id)).map(|r| opc::norm(&r.target)) else { continue };
|
||||
if !seen.insert(target.clone()) {
|
||||
continue;
|
||||
}
|
||||
let parsed = match imp.tags_by_part.get(&target) {
|
||||
Some(p) => p.clone(),
|
||||
None => {
|
||||
if imp.tags_capped {
|
||||
return out;
|
||||
}
|
||||
let p: Arc<[(String, String)]> = imp
|
||||
.pkg
|
||||
.xml(&target)
|
||||
.map(|doc| {
|
||||
doc.root
|
||||
.children_named("tag")
|
||||
.take(10_000)
|
||||
.filter_map(|tag| Some((tag.attr("name")?.to_string(), tag.attr("val").unwrap_or("").to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
imp.tags_by_part.insert(target, p.clone());
|
||||
p
|
||||
}
|
||||
};
|
||||
for (name, val) in parsed.iter() {
|
||||
let n = imp.tag_bytes.saturating_add(TAG_PAIR_COST + name.len() + val.len());
|
||||
if n > MAX_TAG_BYTES {
|
||||
if !imp.tags_capped {
|
||||
log::warn!("pptx: add-in tags over {MAX_TAG_BYTES} bytes; the rest are dropped");
|
||||
imp.tags_capped = true;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
imp.tag_bytes = n;
|
||||
out.push((name.clone(), val.clone()));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -389,6 +443,9 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
|
||||
vml_previews: HashMap::new(),
|
||||
kept_sidecar_bytes: 0,
|
||||
kept_charts: HashMap::new(),
|
||||
tags_by_part: HashMap::new(),
|
||||
tag_bytes: 0,
|
||||
tags_capped: false,
|
||||
};
|
||||
let part = Part::new(&pkg, &theme_path, &doc);
|
||||
let theme = theme::read_theme(&mut imp, &part, &doc.root);
|
||||
@@ -427,6 +484,9 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
vml_previews: HashMap::new(),
|
||||
kept_sidecar_bytes: 0,
|
||||
kept_charts: HashMap::new(),
|
||||
tags_by_part: HashMap::new(),
|
||||
tag_bytes: 0,
|
||||
tags_capped: false,
|
||||
};
|
||||
// Slide ids first so hyperlinks anywhere can point at slides.
|
||||
let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides);
|
||||
@@ -441,7 +501,7 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
let layout_by_part = read_masters(&mut imp, &mut p, &master_paths, &docs);
|
||||
let file_slide_ids = read_slides(&mut imp, &mut p, &pres_part, &docs, &slide_ids, &layout_by_part);
|
||||
read_sections(&mut p, &pres_part, pres_el, &file_slide_ids);
|
||||
p.tags = tags(&imp, &pres_part, pres_el);
|
||||
p.tags = tags(&mut imp, &pres_part, pres_el);
|
||||
read_custom_shows(&mut p, &imp, &pres_part, pres_el);
|
||||
read_guides_and_show(&mut p, &imp, &pres_part);
|
||||
read_notes_master(&mut imp, &mut p, &pres_part);
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_geom::{Xfrm, emu_to_pt};
|
||||
use deckcraft_model::style::{
|
||||
ColorBase, ColorRef, Compound, Dash, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, LineCap, LineEnd, LineJoin, PatternFill,
|
||||
PictureFill, PictureMode, Reflection, Shadow,
|
||||
ColorBase, ColorRef, Compound, Dash, Effects, Fill, Glow, Gradient, GradientFlip, GradientShape, GradientStop, Line, LineAlign, LineCap, LineEnd,
|
||||
LineJoin, PatternFill, PictureFill, PictureMode, Reflection, Shadow,
|
||||
};
|
||||
use deckcraft_model::text::{
|
||||
Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, LevelStyle, ListStyle, ParaProps, Paragraph, Run, RunKind, RunProps, Spacing,
|
||||
@@ -122,7 +122,13 @@ pub fn gradient(f: &El) -> Gradient {
|
||||
GradientShape::Linear { angle: 90.0, scaled: false }
|
||||
};
|
||||
let tile = f.child("tileRect").map(|r| [pct(r, "l"), pct(r, "t"), pct(r, "r"), pct(r, "b")].map(|v| v.unwrap_or(0.0).clamp(-10.0, 10.0)));
|
||||
Gradient { tile: tile.unwrap_or_default(), stops, shape, rotate_with_shape: f.bool("rotWithShape").unwrap_or(true) }
|
||||
Gradient {
|
||||
tile: tile.unwrap_or_default(),
|
||||
stops,
|
||||
shape,
|
||||
flip: f.attr("flip").and_then(GradientFlip::from_xml),
|
||||
rotate_with_shape: f.bool("rotWithShape").unwrap_or(true),
|
||||
}
|
||||
}
|
||||
|
||||
/// `blipFill` (either `a:` or `p:`) → picture fill, registering the image as media.
|
||||
@@ -222,6 +228,7 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line {
|
||||
_ => LineCap::Flat,
|
||||
});
|
||||
l.compound = ln.attr("cmpd").map(Compound::from_xml);
|
||||
l.align = ln.attr("algn").and_then(LineAlign::from_xml);
|
||||
l.fill = fill_of(imp, part, ln);
|
||||
for e in ln.elements() {
|
||||
match e.local() {
|
||||
@@ -232,7 +239,10 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line {
|
||||
}
|
||||
"round" => l.join = Some(LineJoin::Round),
|
||||
"bevel" => l.join = Some(LineJoin::Bevel),
|
||||
"miter" => l.join = Some(LineJoin::Miter),
|
||||
"miter" => {
|
||||
l.join = Some(LineJoin::Miter);
|
||||
l.miter_limit = e.attr("lim").and_then(parse_miter_limit);
|
||||
}
|
||||
"headEnd" => l.head = line_end(e),
|
||||
"tailEnd" => l.tail = line_end(e),
|
||||
_ => {}
|
||||
@@ -241,6 +251,17 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line {
|
||||
l
|
||||
}
|
||||
|
||||
fn parse_miter_limit(value: &str) -> Option<u32> {
|
||||
const MAX: u32 = i32::MAX as u32;
|
||||
let value = value.trim();
|
||||
if let Some(percent) = value.strip_suffix('%') {
|
||||
let units = percent.trim().parse::<f64>().ok()? * 1000.0;
|
||||
(units.is_finite() && (0.0..=MAX as f64).contains(&units)).then(|| units.round() as u32)
|
||||
} else {
|
||||
value.parse::<u32>().ok().filter(|limit| *limit <= MAX)
|
||||
}
|
||||
}
|
||||
|
||||
fn line_end(e: &El) -> Option<LineEnd> {
|
||||
let kind = e.attr("type").unwrap_or("none");
|
||||
Some(LineEnd { kind: kind.to_string(), w: e.attr("w").unwrap_or("med").to_string(), len: e.attr("len").unwrap_or("med").to_string() })
|
||||
@@ -431,8 +452,11 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
|
||||
}
|
||||
}
|
||||
"buFont" => pp.bullet_font = e.attr("typeface").map(String::from),
|
||||
"buFontTx" => pp.bullet_font_follow_text = Some(true),
|
||||
"buClr" => pp.bullet_color = color(e),
|
||||
"buClrTx" => pp.bullet_color_follow_text = Some(true),
|
||||
"buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)),
|
||||
"buSzTx" => pp.bullet_size_follow_text = Some(true),
|
||||
"tabLst" => {
|
||||
pp.tabs = Some(
|
||||
e.children_named("tab")
|
||||
@@ -668,6 +692,16 @@ mod tests {
|
||||
assert_eq!(color(&d.root).unwrap().base, ColorBase::Rgb { rgb: Rgba::rgb(255, 0, 0) });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miter_limit_accepts_both_percentage_forms_with_schema_bounds() {
|
||||
assert_eq!(parse_miter_limit("200000"), Some(200_000));
|
||||
assert_eq!(parse_miter_limit("200%"), Some(200_000));
|
||||
assert_eq!(parse_miter_limit("1000001"), Some(1_000_001));
|
||||
assert_eq!(parse_miter_limit("malformed"), None);
|
||||
assert_eq!(parse_miter_limit("2147483648"), None);
|
||||
assert_eq!(parse_miter_limit("2147483.648%"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_props() {
|
||||
let d = parse(br#"<a:bodyPr lIns="91440" anchor="ctr" wrap="none" vert="vert270" numCol="2"><a:normAutofit fontScale="62500" lnSpcReduction="20000"/></a:bodyPr>"#).unwrap();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! DrawingML writing: colours, fills, lines, effects, transforms and text.
|
||||
|
||||
use deckcraft_geom::{Xfrm, pt_to_emu};
|
||||
use deckcraft_model::style::{ColorBase, ColorRef, Compound, Dash, Effects, Fill, GradientShape, Line, LineCap, LineJoin, PictureFill, PictureMode};
|
||||
use deckcraft_model::style::{
|
||||
ColorBase, ColorRef, Compound, Dash, Effects, Fill, GradientFlip, GradientShape, Line, LineAlign, LineCap, LineJoin, PictureFill, PictureMode,
|
||||
};
|
||||
use deckcraft_model::text::{
|
||||
Action, AutoFit, BodyProps, Bullet, Caps, Hyperlink, ListStyle, ParaProps, Paragraph, RunKind, RunProps, Spacing, Strike, TextBody,
|
||||
};
|
||||
@@ -208,6 +210,7 @@ pub fn line(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, l: &Line) {
|
||||
LineCap::Flat => "flat",
|
||||
}),
|
||||
)
|
||||
.o("algn", l.align.map(LineAlign::xml))
|
||||
.o("cmpd", l.compound.map(Compound::xml));
|
||||
w.open(tag, a);
|
||||
if let Some(f) = &l.fill {
|
||||
@@ -233,7 +236,10 @@ pub fn line(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, l: &Line) {
|
||||
match l.join {
|
||||
Some(LineJoin::Round) => w.empty0("a:round"),
|
||||
Some(LineJoin::Bevel) => w.empty0("a:bevel"),
|
||||
Some(LineJoin::Miter) => w.empty0("a:miter"),
|
||||
Some(LineJoin::Miter) => {
|
||||
let limit = l.miter_limit.filter(|limit| *limit <= i32::MAX as u32);
|
||||
w.empty("a:miter", A::new().o("lim", limit));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
for (t, e) in [("a:headEnd", &l.head), ("a:tailEnd", &l.tail)] {
|
||||
@@ -279,7 +285,7 @@ fn shadow(w: &mut W, tag: &str, s: &deckcraft_model::style::Shadow) {
|
||||
}
|
||||
|
||||
fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) {
|
||||
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
w.open("a:gradFill", A::new().o("flip", g.flip.map(GradientFlip::xml)).a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
|
||||
let mut stops = g.stops.clone();
|
||||
if stops.len() == 1 {
|
||||
@@ -533,8 +539,11 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|
||||
|| p.space_after.is_some()
|
||||
|| p.bullet.is_some()
|
||||
|| p.bullet_font.is_some()
|
||||
|| p.bullet_font_follow_text.is_some()
|
||||
|| p.bullet_color.is_some()
|
||||
|| p.bullet_color_follow_text.is_some()
|
||||
|| p.bullet_size.is_some()
|
||||
|| p.bullet_size_follow_text.is_some()
|
||||
|| p.tabs.is_some()
|
||||
|| def.is_some();
|
||||
if !has_kids {
|
||||
@@ -551,15 +560,21 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|
||||
if let Some(s) = &p.space_after {
|
||||
spacing(w, "a:spcAft", s);
|
||||
}
|
||||
if let Some(c) = &p.bullet_color {
|
||||
if p.bullet_color.is_none() && p.bullet_color_follow_text == Some(true) {
|
||||
w.empty0("a:buClrTx");
|
||||
} else if let Some(c) = &p.bullet_color {
|
||||
w.open0("a:buClr");
|
||||
color(w, c);
|
||||
w.close("a:buClr");
|
||||
}
|
||||
if let Some(s) = p.bullet_size {
|
||||
if p.bullet_size.is_none() && p.bullet_size_follow_text == Some(true) {
|
||||
w.empty0("a:buSzTx");
|
||||
} else if let Some(s) = p.bullet_size {
|
||||
w.val("a:buSzPct", pct(s.clamp(0.25, 4.0)));
|
||||
}
|
||||
if let Some(f) = &p.bullet_font {
|
||||
if p.bullet_font.is_none() && p.bullet_font_follow_text == Some(true) {
|
||||
w.empty0("a:buFontTx");
|
||||
} else if let Some(f) = &p.bullet_font {
|
||||
w.empty("a:buFont", A::new().a("typeface", f));
|
||||
}
|
||||
match &p.bullet {
|
||||
|
||||
@@ -46,6 +46,7 @@ fn gradient(angle: f64) -> Fill {
|
||||
shape: GradientShape::Linear { angle, scaled: true },
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0; 4],
|
||||
flip: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,6 +117,7 @@ fn style_deck() -> Presentation {
|
||||
shape: GradientShape::Path { path: "circle".into(), focus: [0.2, 0.2, 0.2, 0.2] },
|
||||
rotate_with_shape: false,
|
||||
tile: [0.0; 4],
|
||||
flip: None,
|
||||
}));
|
||||
|
||||
let mut patt = shape(&mut p, "Pattern", 200.0, 260.0, 120.0, 80.0);
|
||||
|
||||
@@ -88,7 +88,8 @@ fn a_gradient_without_stops_is_saved_without_stops() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id().expect("ids"));
|
||||
let first = p.slides[0].id;
|
||||
let own = Gradient { tile: [0.0; 4], stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
let own =
|
||||
Gradient { tile: [0.0; 4], stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true, flip: None };
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Styled".into(),
|
||||
@@ -119,6 +120,7 @@ fn a_fill_overlay_round_trips() {
|
||||
],
|
||||
shape: GradientShape::Linear { angle: 4.5, scaled: true },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
}),
|
||||
};
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
@@ -148,6 +150,7 @@ fn a_gradient_tile_rect_is_read_and_round_trips() {
|
||||
shape: GradientShape::Path { path: "circle".into(), focus: [0.0, 0.0, 1.0, 1.0] },
|
||||
rotate_with_shape: true,
|
||||
tile: [-1.0, -1.0, 0.0, 0.0],
|
||||
flip: None,
|
||||
};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id().expect("ids"));
|
||||
|
||||
@@ -7,8 +7,8 @@ use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
use deckcraft_model::style::{
|
||||
ColorRef, Dash, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PatternFill, PictureAdjust, PictureFill, PictureMode,
|
||||
Shadow,
|
||||
ColorRef, Dash, Effects, Fill, Glow, Gradient, GradientFlip, GradientShape, GradientStop, Line, LineEnd, PatternFill, PictureAdjust, PictureFill,
|
||||
PictureMode, Shadow,
|
||||
};
|
||||
use deckcraft_model::table::Table;
|
||||
use deckcraft_model::text::{
|
||||
@@ -115,6 +115,8 @@ pub fn rich_deck() -> Presentation {
|
||||
],
|
||||
shape: GradientShape::Linear { angle: 45.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
|
||||
flip: Some(GradientFlip::Xy),
|
||||
}));
|
||||
a.line = Some(Line { width: Some(3.0), dash: Some(Dash::Preset { name: "dash".into() }), ..Line::solid(ColorRef::rgb(Rgba::rgb(0, 0, 0)), 3.0) });
|
||||
a.effects = Some(Effects {
|
||||
@@ -488,6 +490,102 @@ fn shapes_fills_lines_effects_round_trip() {
|
||||
assert_eq!(by_name(&q.slides[1], "Group").id, by_name(&p.slides[1], "Group").id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_miter_limit_survives_two_pptx_round_trips() {
|
||||
let mut p = Presentation::default();
|
||||
let mut limited = shape(&mut p, "Limited miter", 20.0, 20.0, 100.0, 100.0);
|
||||
limited.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), miter_limit: Some(200_000), ..Default::default() });
|
||||
let mut defaulted = shape(&mut p, "Default miter", 140.0, 20.0, 100.0, 100.0);
|
||||
defaulted.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..Default::default() });
|
||||
let mut large = shape(&mut p, "Large miter", 260.0, 20.0, 100.0, 100.0);
|
||||
large.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), miter_limit: Some(1_000_001), ..Default::default() });
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.extend([limited, defaulted, large]);
|
||||
|
||||
let q = round(&round(&p));
|
||||
assert_eq!(by_name(&q.slides[0], "Limited miter").line.as_ref().and_then(|line| line.miter_limit), Some(200_000));
|
||||
assert_eq!(by_name(&q.slides[0], "Default miter").line.as_ref().and_then(|line| line.miter_limit), None);
|
||||
assert_eq!(by_name(&q.slides[0], "Large miter").line.as_ref().and_then(|line| line.miter_limit), Some(1_000_001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn bullet_follow_text_resets_survive_two_pptx_round_trips() {
|
||||
let mut p = Presentation::default();
|
||||
let mut textbox = shape(&mut p, "Follow text bullets", 20.0, 20.0, 400.0, 100.0);
|
||||
textbox.text = Some(TextBody {
|
||||
paragraphs: vec![Paragraph {
|
||||
props: deckcraft_model::text::ParaProps {
|
||||
bullet: Some(Bullet::Char { char: "•".into() }),
|
||||
bullet_color_follow_text: Some(true),
|
||||
bullet_size_follow_text: Some(true),
|
||||
bullet_font_follow_text: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
runs: vec![Run::with("Bullet text", RunProps::default())],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(textbox);
|
||||
let mut conflicting = shape(&mut p, "Explicit bullet values", 20.0, 140.0, 400.0, 100.0);
|
||||
conflicting.text = Some(TextBody {
|
||||
paragraphs: vec![Paragraph {
|
||||
props: deckcraft_model::text::ParaProps {
|
||||
bullet: Some(Bullet::Char { char: "•".into() }),
|
||||
bullet_color: Some(deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))),
|
||||
bullet_color_follow_text: Some(true),
|
||||
bullet_size: Some(1.5),
|
||||
bullet_size_follow_text: Some(true),
|
||||
bullet_font: Some("Inter".into()),
|
||||
bullet_font_follow_text: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
runs: vec![Run::with("Explicit bullet text", RunProps::default())],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(conflicting);
|
||||
|
||||
let q = round(&round(&p));
|
||||
let props = &by_name(&q.slides[0], "Follow text bullets").text.as_ref().expect("text").paragraphs[0].props;
|
||||
assert_eq!(props.bullet_color_follow_text, Some(true));
|
||||
assert_eq!(props.bullet_size_follow_text, Some(true));
|
||||
assert_eq!(props.bullet_font_follow_text, Some(true));
|
||||
let explicit = &by_name(&q.slides[0], "Explicit bullet values").text.as_ref().expect("text").paragraphs[0].props;
|
||||
assert_eq!(explicit.bullet_color_follow_text, None);
|
||||
assert_eq!(explicit.bullet_size_follow_text, None);
|
||||
assert_eq!(explicit.bullet_font_follow_text, None);
|
||||
assert_eq!(explicit.bullet_size, Some(1.5));
|
||||
assert_eq!(explicit.bullet_font.as_deref(), Some("Inter"));
|
||||
assert_eq!(explicit.bullet_color, Some(deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn line_alignment_survives_two_pptx_round_trips() {
|
||||
let mut p = Presentation::default();
|
||||
let mut inside = shape(&mut p, "Inset line", 20.0, 20.0, 100.0, 100.0);
|
||||
inside.line = Some(Line { align: Some(deckcraft_model::style::LineAlign::Inside), ..Default::default() });
|
||||
let mut centered = shape(&mut p, "Centered line", 140.0, 20.0, 100.0, 100.0);
|
||||
centered.line = Some(Line { align: Some(deckcraft_model::style::LineAlign::Center), ..Default::default() });
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.extend([inside, centered]);
|
||||
|
||||
let q = round(&round(&p));
|
||||
assert_eq!(by_name(&q.slides[0], "Inset line").line.as_ref().and_then(|line| line.align), Some(deckcraft_model::style::LineAlign::Inside));
|
||||
assert_eq!(by_name(&q.slides[0], "Centered line").line.as_ref().and_then(|line| line.align), Some(deckcraft_model::style::LineAlign::Center));
|
||||
assert_eq!(deckcraft_model::style::LineAlign::from_xml("out"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn gradient_flip_mode_survives_two_pptx_round_trips() {
|
||||
let p = rich_deck();
|
||||
let q = round(&round(&p));
|
||||
let gradient = by_name(&q.slides[1], "Gradient Star").fill.as_ref();
|
||||
assert!(matches!(gradient, Some(Fill::Gradient(g)) if g.flip == Some(GradientFlip::Xy)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pictures_media_and_ink_round_trip() {
|
||||
let p = rich_deck();
|
||||
|
||||
@@ -36,3 +36,111 @@ fn tags_survive_round_trip() {
|
||||
assert_eq!(first.tags, vec![t("THINKCELLSHAPEDONOTDELETE", "t!abc")]);
|
||||
assert!(back.slides.iter().skip(1).all(|s| s.tags.is_empty()));
|
||||
}
|
||||
|
||||
/// #1164: the same tags part listed over and over is read once, and the tags one import copies
|
||||
/// into the model are capped however often a shape lists them.
|
||||
#[test]
|
||||
fn a_tags_part_listed_many_times_is_copied_once_and_capped() {
|
||||
let mut s = Session::new();
|
||||
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
|
||||
for _ in 0..6 {
|
||||
s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [10, 10, 50, 50]})).expect("shape");
|
||||
}
|
||||
let mut p = s.doc().expect("doc").doc.as_ref().clone();
|
||||
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||
// A quarter of the budget with its name and pair cost: the fifth shape listing it goes over.
|
||||
let pair = std::mem::size_of::<(String, String)>();
|
||||
slide.shapes[0].tags = vec![t("BIG", &"x".repeat(deckcraft_pptx::MAX_TAG_BYTES / 4 - 3 - pair))];
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
|
||||
// Every shape on the first slide lists that one tags part 100 times.
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
zip.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("read");
|
||||
let start = xml.find("<p:custDataLst>").expect("tags listed");
|
||||
let end = xml[start..].find("</p:custDataLst>").expect("list ends") + start + "</p:custDataLst>".len();
|
||||
let list = &xml[start..end];
|
||||
let inner = list.trim_start_matches("<p:custDataLst>").trim_end_matches("</p:custDataLst>");
|
||||
let many = format!("<p:custDataLst>{}</p:custDataLst>", inner.repeat(100));
|
||||
let xml = xml.replace(list, "").replace("<p:nvPr/>", "<p:nvPr></p:nvPr>").replace("<p:nvPr>", &format!("<p:nvPr>{many}"));
|
||||
assert!(xml.matches(&many).count() >= 5, "enough shapes to go over the budget");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..zip.len() {
|
||||
let mut f = zip.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut data = vec![];
|
||||
f.read_to_end(&mut data).expect("read");
|
||||
out.start_file(&name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||
std::io::Write::write_all(&mut out, if name == "ppt/slides/slide1.xml" { xml.as_bytes() } else { &data }).expect("write");
|
||||
}
|
||||
let bytes = out.finish().expect("finish").into_inner();
|
||||
|
||||
let back = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let tagged: Vec<_> = back.slides[0].shapes.iter().filter(|x| !x.tags.is_empty()).collect();
|
||||
assert_eq!(tagged.len(), 4, "four copies fit the budget");
|
||||
assert!(tagged.iter().all(|x| x.tags.len() == 1), "each shape keeps the part's tag once, not 100 times");
|
||||
}
|
||||
|
||||
/// #1164: empty tags cost something too, or a part of 10,000 empty pairs listed by every shape
|
||||
/// would be copied without ever reaching the budget.
|
||||
#[test]
|
||||
fn empty_tags_count_against_the_budget() {
|
||||
let pair = std::mem::size_of::<(String, String)>();
|
||||
let per_shape = 10_000 * pair;
|
||||
let shapes = deckcraft_pptx::MAX_TAG_BYTES / per_shape + 2;
|
||||
let mut s = Session::new();
|
||||
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
|
||||
let mut p = s.doc().expect("doc").doc.as_ref().clone();
|
||||
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||
let template = slide.shapes[0].clone();
|
||||
slide.shapes.clear();
|
||||
for i in 0..shapes {
|
||||
let mut sh = template.clone();
|
||||
sh.id = deckcraft_model::ShapeId(10_000 + i as u32);
|
||||
sh.ph = None;
|
||||
sh.tags = vec![t("", ""); 10_000];
|
||||
slide.shapes.push(sh);
|
||||
}
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let back = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let pairs: usize = back.slides[0].shapes.iter().map(|x| x.tags.len()).sum();
|
||||
assert!(pairs * pair <= deckcraft_pptx::MAX_TAG_BYTES, "{pairs} pairs copied");
|
||||
assert!(pairs < shapes * 10_000, "the budget stopped the copying");
|
||||
}
|
||||
|
||||
/// #1164: once the budget is exhausted, further tags parts are not parsed or cached into
|
||||
/// `tags_by_part`.
|
||||
#[test]
|
||||
fn new_tag_parts_are_not_parsed_or_cached_after_the_budget_is_exhausted() {
|
||||
let mut s = Session::new();
|
||||
deckcraft_engine::sample::open_sample(&mut s).expect("sample");
|
||||
s.execute("shape.insert", &serde_json::json!({"preset": "rect", "rect": [10, 10, 50, 50]})).expect("shape");
|
||||
let mut p = s.doc().expect("doc").doc.as_ref().clone();
|
||||
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||
assert!(slide.shapes.len() >= 2);
|
||||
// The first shape's tag fits the budget, and a second pair pushes it over the cap.
|
||||
let pair = std::mem::size_of::<(String, String)>();
|
||||
slide.shapes[0].tags = vec![t("BIG", &"x".repeat(deckcraft_pptx::MAX_TAG_BYTES - 3 - pair)), t("OVER", "1")];
|
||||
// The second shape has its own separate tag part.
|
||||
slide.shapes[1].tags = vec![t("SHAPE2_TAG", "dropped")];
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
|
||||
// Corrupt the second shape's tag part (`tag2.xml`) with invalid XML in the zip.
|
||||
// If it were parsed, XML loading would fail. Because the budget was capped, it is never read.
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..zip.len() {
|
||||
let mut f = zip.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut data = vec![];
|
||||
f.read_to_end(&mut data).expect("read");
|
||||
out.start_file(&name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||
let payload = if name == "ppt/tags/tag2.xml" { b"<not valid xml<<<<" as &[u8] } else { &data };
|
||||
std::io::Write::write_all(&mut out, payload).expect("write");
|
||||
}
|
||||
let corrupted = out.finish().expect("finish").into_inner();
|
||||
|
||||
let back = deckcraft_pptx::import(&corrupted).expect("import succeeds because tag2.xml is not parsed");
|
||||
assert!(!back.slides[0].shapes[0].tags.is_empty(), "first shape tag was loaded");
|
||||
assert!(back.slides[0].shapes[1].tags.is_empty(), "second shape tag was dropped and never parsed");
|
||||
}
|
||||
|
||||
@@ -94,6 +94,9 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_wild;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests_effect_holes;
|
||||
|
||||
#[cfg(test)]
|
||||
mod mt_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1125,12 +1125,15 @@ impl Renderer {
|
||||
// see-through as its fill (none for an outline-only shape) …
|
||||
None => {
|
||||
if let Some(a) = fill_alpha {
|
||||
let mut filled = BezPath::new();
|
||||
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) {
|
||||
filled.extend(sp.path.iter());
|
||||
}
|
||||
ctx.set_paint(color(c.with_alpha(255), a));
|
||||
ctx.fill_path(&filled);
|
||||
// Each subpath under its own fill rule, as the shape itself is
|
||||
// drawn: one merged path filled NonZero fills the hole of an
|
||||
// even-odd shape (a donut, a frame) (#1596).
|
||||
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None) {
|
||||
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
ctx.fill_path(&sp.path);
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1161,7 +1164,19 @@ impl Renderer {
|
||||
ctx.stroke_path(&silhouette);
|
||||
// A shape without a fill (an outline, a frame) glows along its line only (#225).
|
||||
if fill_alpha.is_some() || content.is_some() {
|
||||
ctx.fill_path(&silhouette);
|
||||
// Per subpath under its own fill rule, as in the shadow above: the
|
||||
// merged silhouette filled NonZero fills an even-odd hole (#1596).
|
||||
// When no subpath takes part the silhouette is the outline fallback.
|
||||
let mut filled_any = false;
|
||||
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None || fill.is_none()) {
|
||||
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
ctx.fill_path(&sp.path);
|
||||
filled_any = true;
|
||||
}
|
||||
if !filled_any {
|
||||
ctx.fill_path(&silhouette);
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
@@ -1180,7 +1195,20 @@ impl Renderer {
|
||||
}
|
||||
None => {
|
||||
if let Some(fl) = fill {
|
||||
paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
|
||||
// Per subpath under its own fill rule, as in the shadow above:
|
||||
// the merged silhouette filled NonZero fills an even-odd hole
|
||||
// (#1596). The outline fallback covers shapes with no
|
||||
// participating subpath.
|
||||
let mut filled_any = false;
|
||||
for sp in geo.paths.iter().filter(|sp| sp.fill != FillMode::None || fill.is_none()) {
|
||||
ctx.set_fill_rule(if sp.even_odd { peniko::Fill::EvenOdd } else { peniko::Fill::NonZero });
|
||||
paint::fill_path(ctx, rctx, fl, ph, &sp.path, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
|
||||
filled_any = true;
|
||||
}
|
||||
if !filled_any {
|
||||
paint::fill_path(ctx, rctx, fl, ph, &silhouette, Rect::new(0.0, 0.0, w, h), mm, FillMode::Norm, 1.0, rctx.pres);
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -515,6 +515,56 @@ fn glow_of_a_shape_with_no_fill_and_no_line_is_not_drawn() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadow_and_glow_keep_an_even_odd_shapes_hole() {
|
||||
// #1596: a donut is one even-odd subpath — its hole exists only under the
|
||||
// even-odd rule. The effects were cast from a merged silhouette filled
|
||||
// NonZero, so the shadow and the glow filled the hole solid. The hole's
|
||||
// centre must stay the slide's white in both, while the effects still draw.
|
||||
let donut = |effects: deckcraft_model::Effects| {
|
||||
let mut sh = Shape {
|
||||
id: ShapeId(612),
|
||||
xfrm: Some(Xfrm::new(50.0, 50.0, 200.0, 200.0)),
|
||||
geom: Geom::preset("donut"),
|
||||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 160, 0)))),
|
||||
..Default::default()
|
||||
};
|
||||
sh.effects = Some(effects);
|
||||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
};
|
||||
let img = donut(deckcraft_model::Effects {
|
||||
outer_shadow: Some(deckcraft_model::style::Shadow {
|
||||
color: ColorRef::rgb(deckcraft_color::Rgba::BLACK),
|
||||
blur: 8.0,
|
||||
dist: 0.0,
|
||||
dir: 45.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
let hole = img.pixel(150, 150);
|
||||
assert!(hole[0] > 245 && hole[1] > 245 && hole[2] > 245, "shadow in the hole: {hole:?}");
|
||||
// The shadow still falls around the shape (just below its bottom edge).
|
||||
let below = img.pixel(150, 251);
|
||||
assert!(below[0] < 200 && below[0] == below[2], "no shadow below: {below:?}");
|
||||
|
||||
let img = donut(deckcraft_model::Effects {
|
||||
glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0)), radius: 8.0 }),
|
||||
..Default::default()
|
||||
});
|
||||
let hole = img.pixel(150, 150);
|
||||
assert!(hole[0] > 245 && hole[1] > 245 && hole[2] > 245, "glow in the hole: {hole:?}");
|
||||
// The ring itself is the shape's orange.
|
||||
let ring = img.pixel(150, 75);
|
||||
assert!(ring[0] > 200 && ring[1] > 100 && ring[2] < 100, "ring: {ring:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_prompts_only_in_edit_view() {
|
||||
let p = Presentation::default();
|
||||
@@ -978,6 +1028,7 @@ fn a_linear_gradient_ramps_like_powerpoint() {
|
||||
stops: vec![stop(0.0, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
})),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
@@ -1003,6 +1054,7 @@ fn a_gradient_with_a_stop_off_the_end_stays_linear() {
|
||||
stops: vec![stop(0.2, deckcraft_color::Rgba::rgb(0, 0, 0)), stop(1.0, deckcraft_color::Rgba::rgb(255, 255, 255))],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
})),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
@@ -1044,6 +1096,7 @@ fn a_shape_filled_with_the_background_shows_the_slide_background() {
|
||||
stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
}),
|
||||
});
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
@@ -1145,6 +1198,7 @@ fn a_text_gradient_starts_over_on_every_line() {
|
||||
stops: vec![stop(0.0, 0), stop(1.0, 255)],
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
}));
|
||||
}
|
||||
sh.text = Some(t);
|
||||
@@ -1306,6 +1360,7 @@ fn rect_path_gradients_draw_x_shaped_contours() {
|
||||
stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)],
|
||||
shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
})),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
//! Contributor-original pixel goldens for #1596 and the review of #1626.
|
||||
//! The fixed samples cover background, hole/centre, ring, visible halos and reflected coverage.
|
||||
|
||||
use super::{Fill, Image, RenderOpts, Rgba, render_slide};
|
||||
use deckcraft_model::style::{ColorRef, Effects, Glow, Line, Reflection, Shadow};
|
||||
use deckcraft_model::{Geom, Presentation, Shape, ShapeId, Xfrm};
|
||||
|
||||
const PROBES: [(u32, u32); 9] = [
|
||||
(20, 20), // Clear background.
|
||||
(130, 130), // Clear donut hole; filled ellipse centre.
|
||||
(130, 70), // Original ring/ellipse fill.
|
||||
(130, 47), // Visible shadow/glow above the outer edge.
|
||||
(130, 213), // Visible shadow/glow below the outer edge.
|
||||
(130, 93), // Shadow/glow along the inner rim, inside the hole.
|
||||
(130, 230), // Reflected upper ring.
|
||||
(130, 300), // Reflected hole/ellipse centre.
|
||||
(70, 300), // Reflected ring at the same fade as the hole.
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Effect {
|
||||
Shadow,
|
||||
Glow,
|
||||
Reflection,
|
||||
}
|
||||
|
||||
fn scene(shape: &str, effect: Effect) -> Presentation {
|
||||
let mut p = Presentation::default();
|
||||
let mut effects = Effects::default();
|
||||
match effect {
|
||||
Effect::Shadow => {
|
||||
effects.outer_shadow = Some(Shadow {
|
||||
color: ColorRef::rgb(Rgba::BLACK),
|
||||
blur: 8.0,
|
||||
dist: 0.0,
|
||||
dir: 45.0,
|
||||
inner: false,
|
||||
sx: 1.0,
|
||||
sy: 1.0,
|
||||
kx: 0.0,
|
||||
ky: 0.0,
|
||||
align: String::new(),
|
||||
rotate_with_shape: false,
|
||||
})
|
||||
}
|
||||
Effect::Glow => effects.glow = Some(Glow { color: ColorRef::rgb(Rgba::rgb(255, 0, 0)), radius: 8.0 }),
|
||||
Effect::Reflection => effects.reflection = Some(Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 1.0, dist: 10.0 }),
|
||||
}
|
||||
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
slide.background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(ColorRef::rgb(Rgba::WHITE)) });
|
||||
slide.shapes = vec![Shape {
|
||||
id: ShapeId(1596),
|
||||
xfrm: Some(Xfrm::new(50.0, 50.0, 160.0, 160.0)),
|
||||
geom: Geom::preset(shape),
|
||||
fill: Some(Fill::solid(ColorRef::rgb(Rgba::rgb(32, 96, 224)))),
|
||||
line: Some(Line::none()),
|
||||
effects: Some(effects),
|
||||
..Default::default()
|
||||
}];
|
||||
p
|
||||
}
|
||||
|
||||
fn draw(shape: &str, effect: Effect) -> Image {
|
||||
let p = scene(shape, effect);
|
||||
let image = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
if let Ok(dir) = std::env::var("DECKCRAFT_EFFECT_HOLES_QA") {
|
||||
// Overwrite, so a QA directory can be reused across runs.
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(std::path::Path::new(&dir).join(format!("{shape}-{effect:?}.png")), image.to_png()).unwrap();
|
||||
}
|
||||
image
|
||||
}
|
||||
|
||||
// Fixed contributor-original RGBA goldens; tolerate one final quantization step.
|
||||
// The nonwhite halo/reflection samples also fail if an effect is disabled outright.
|
||||
fn assert_golden(shape: &str, effect: Effect, expected: [[u8; 4]; 9]) {
|
||||
let image = draw(shape, effect);
|
||||
for ((x, y), want) in PROBES.into_iter().zip(expected) {
|
||||
let got = image.pixel(x, y);
|
||||
assert!(got.iter().zip(want).all(|(a, b)| (*a as i32 - b as i32).abs() <= 1), "{shape} {effect:?} at {x},{y}: {got:?} vs golden {want:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn donut_shadow_matches_golden() {
|
||||
assert_golden(
|
||||
"donut",
|
||||
Effect::Shadow,
|
||||
[
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
[32, 96, 224, 255],
|
||||
[214, 214, 214, 255],
|
||||
[233, 233, 233, 255],
|
||||
[227, 227, 227, 255],
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn donut_glow_matches_golden() {
|
||||
assert_golden(
|
||||
"donut",
|
||||
Effect::Glow,
|
||||
[
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
[32, 96, 224, 255],
|
||||
[255, 49, 49, 255],
|
||||
[255, 111, 111, 255],
|
||||
[255, 96, 96, 255],
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn donut_reflection_matches_golden() {
|
||||
assert_golden(
|
||||
"donut",
|
||||
Effect::Reflection,
|
||||
[
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
[32, 96, 224, 255],
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
[130, 166, 238, 255],
|
||||
[255, 255, 255, 255],
|
||||
[189, 208, 246, 255],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_shadow_matches_golden() {
|
||||
assert_golden(
|
||||
"ellipse",
|
||||
Effect::Shadow,
|
||||
[
|
||||
[255, 255, 255, 255],
|
||||
[32, 96, 224, 255],
|
||||
[32, 96, 224, 255],
|
||||
[214, 214, 214, 255],
|
||||
[233, 233, 233, 255],
|
||||
[32, 96, 224, 255],
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_glow_matches_golden() {
|
||||
assert_golden(
|
||||
"ellipse",
|
||||
Effect::Glow,
|
||||
[
|
||||
[255, 255, 255, 255],
|
||||
[32, 96, 224, 255],
|
||||
[32, 96, 224, 255],
|
||||
[255, 49, 49, 255],
|
||||
[255, 111, 111, 255],
|
||||
[32, 96, 224, 255],
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_reflection_matches_golden() {
|
||||
assert_golden(
|
||||
"ellipse",
|
||||
Effect::Reflection,
|
||||
[
|
||||
[255, 255, 255, 255],
|
||||
[32, 96, 224, 255],
|
||||
[32, 96, 224, 255],
|
||||
[255, 255, 255, 255],
|
||||
[255, 255, 255, 255],
|
||||
[32, 96, 224, 255],
|
||||
[130, 166, 238, 255],
|
||||
[189, 208, 246, 255],
|
||||
[189, 208, 246, 255],
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -186,6 +186,7 @@ fn radial(focus: [f64; 4]) -> Shape {
|
||||
stops,
|
||||
shape: GradientShape::Path { path: "circle".into(), focus },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
})),
|
||||
line: no_line(),
|
||||
..Default::default()
|
||||
@@ -310,8 +311,13 @@ fn a_pictures_border_lies_outside_the_picture() {
|
||||
fn children_filled_with_the_group_fill_show_one_fill_across_the_group() {
|
||||
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
|
||||
let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgb(0, 0, 255)) }];
|
||||
let grad =
|
||||
Fill::Gradient(Gradient { tile: [0.0; 4], stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true });
|
||||
let grad = Fill::Gradient(Gradient {
|
||||
tile: [0.0; 4],
|
||||
stops,
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
});
|
||||
let child = |id: u32, x: f64| Shape {
|
||||
id: ShapeId(id),
|
||||
xfrm: Some(Xfrm::new(x, 0.0, 90.0, 100.0)),
|
||||
@@ -372,7 +378,13 @@ fn a_linear_gradient_spans_its_tile_rect() {
|
||||
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
|
||||
// Tile rect the left half of the box: the ramp ends half way and stays black after.
|
||||
let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(WHITE) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::BLACK) }];
|
||||
let g = Gradient { stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true, tile: [0.0, 0.0, 0.5, 0.0] };
|
||||
let g = Gradient {
|
||||
stops,
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0, 0.0, 0.5, 0.0],
|
||||
flip: None,
|
||||
};
|
||||
let sh = Shape {
|
||||
id: ShapeId(20),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 100.0)),
|
||||
@@ -502,6 +514,7 @@ fn gradient_lines_and_their_heads_take_the_gradient() {
|
||||
shape: GradientShape::Linear { angle, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0; 4],
|
||||
flip: None,
|
||||
})
|
||||
};
|
||||
// Left to right along a 20 pt line: red at its start, blue at its end, and the arrowhead at
|
||||
@@ -656,6 +669,7 @@ fn a_gradient_not_rotating_with_its_shape_keeps_to_the_slide() {
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape,
|
||||
tile: [0.0; 4],
|
||||
flip: None,
|
||||
};
|
||||
// 200 × 100 turned a quarter: it covers 150..250 across and 50..250 down.
|
||||
let sh = Shape {
|
||||
|
||||
@@ -82,9 +82,18 @@ fn is_cj_nonstarter(c: char) -> bool {
|
||||
)
|
||||
}
|
||||
|
||||
/// A character that continues a Latin-style word: a letter or digit that Unicode does not break
|
||||
/// between, as opposed to CJK ideographs, kana, spaces, punctuation and symbols such as `&` or `×`
|
||||
/// (which share the AL and AI line-break classes with letters).
|
||||
fn is_word_char(c: char) -> bool {
|
||||
use unicode_linebreak::BreakClass::{Alphabetic, Ambiguous, HebrewLetter, Numeric};
|
||||
matches!(unicode_linebreak::break_property(c as u32), Alphabetic | Ambiguous | HebrewLetter | Numeric) && c.is_alphanumeric()
|
||||
}
|
||||
|
||||
/// Break opportunities: allowed[i] = a line may start at char i. Unicode line breaking, then
|
||||
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/` or `\`, and
|
||||
/// Korean breaks only between words.
|
||||
/// PowerPoint's differences: without `latinLnBrk` a word doesn't break after `/`, `\`, `?`, `!`, `}`
|
||||
/// or `+` inside a word (measured in PowerPoint 2016 for Windows; before an ideograph or a space
|
||||
/// the Unicode break stays), and Korean breaks only between words.
|
||||
pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>, Vec<bool>) {
|
||||
let n = text.chars().count();
|
||||
let mut allowed = vec![false; n + 1];
|
||||
@@ -113,7 +122,7 @@ pub(crate) fn break_opportunities(text: &str, latin_break: bool) -> (Vec<bool>,
|
||||
let keep = if (is_hangul(prev) && is_hangul(next)) || prev == '\u{200B}' || next == '\u{2014}' || is_cj_nonstarter(next) {
|
||||
false
|
||||
} else {
|
||||
latin_break || !matches!(prev, '/' | '\\') || next.is_whitespace()
|
||||
latin_break || !matches!(prev, '/' | '\\' | '?' | '!' | '}' | '+') || !is_word_char(next)
|
||||
};
|
||||
if !keep
|
||||
&& !mandatory.get(i + 1).copied().unwrap_or(false)
|
||||
|
||||
@@ -93,12 +93,24 @@ impl BoxGeom {
|
||||
} else {
|
||||
text_rect
|
||||
};
|
||||
let inner = Rect::new(
|
||||
r.x0 + bp.inset_l.unwrap_or(7.2),
|
||||
r.y0 + bp.inset_t.unwrap_or(3.6),
|
||||
(r.x1 - bp.inset_r.unwrap_or(7.2)).max(r.x0 + bp.inset_l.unwrap_or(7.2)),
|
||||
(r.y1 - bp.inset_b.unwrap_or(3.6)).max(r.y0 + bp.inset_t.unwrap_or(3.6)),
|
||||
);
|
||||
// Insets wider than the shape scale down proportionally to fit inside it (#1632).
|
||||
let (mut ins_l, mut ins_r) = (bp.inset_l.unwrap_or(7.2), bp.inset_r.unwrap_or(7.2));
|
||||
let (mut ins_t, mut ins_b) = (bp.inset_t.unwrap_or(3.6), bp.inset_b.unwrap_or(3.6));
|
||||
let w = r.width().max(0.0);
|
||||
let total_x = ins_l + ins_r;
|
||||
if ins_l >= 0.0 && ins_r >= 0.0 && total_x > w && total_x > 0.0 {
|
||||
let s = w / total_x;
|
||||
ins_l *= s;
|
||||
ins_r *= s;
|
||||
}
|
||||
let h = r.height().max(0.0);
|
||||
let total_y = ins_t + ins_b;
|
||||
if ins_t >= 0.0 && ins_b >= 0.0 && total_y > h && total_y > 0.0 {
|
||||
let s = h / total_y;
|
||||
ins_t *= s;
|
||||
ins_b *= s;
|
||||
}
|
||||
let inner = Rect::new(r.x0 + ins_l, r.y0 + ins_t, (r.x1 - ins_r).max(r.x0 + ins_l), (r.y1 - ins_b).max(r.y0 + ins_t));
|
||||
// Text that follows a WordArt arch or circle is set on one line per paragraph.
|
||||
let follow = bp.warp.as_deref().is_some_and(deckcraft_geom::warp::is_follow);
|
||||
let wrap = bp.wrap.unwrap_or(true) && !follow && !matches!(bp.autofit, Some(AutoFit::Shape) if shape.text_box && !bp.wrap.unwrap_or(true));
|
||||
|
||||
@@ -857,19 +857,37 @@ fn one_line(text: &str) -> f64 {
|
||||
#[test]
|
||||
fn a_word_does_not_break_after_a_slash_or_backslash() {
|
||||
// PowerPoint (latinLnBrk off, its default) keeps "alpha/beta/gamma" whole and only breaks it
|
||||
// where it no longer fits; Unicode line breaking would allow a break after each slash.
|
||||
for sep in ["/", "\\"] {
|
||||
// where it no longer fits; Unicode line breaking would allow a break after each slash. The
|
||||
// same holds after ?, !, } and + (a URL's query, measured in PowerPoint 2016 for Windows).
|
||||
for sep in ["/", "\\", "?", "!", "}", "+"] {
|
||||
let text = ["alpha", "beta", "gamma", "delta", "epsilon"].join(sep);
|
||||
let w = one_line(&text) * 0.6;
|
||||
let lines = wrapped(&text, w, |_| {});
|
||||
assert!(lines.len() >= 2, "{lines:?}");
|
||||
assert!(lines[0].chars().count() > "alpha/beta".len(), "filled to the edge, not cut at a separator: {lines:?}");
|
||||
// The line breaks where the next character no longer fits, not at an earlier separator
|
||||
// (it may still end with one when that is where the width runs out).
|
||||
let next: String = lines[0].chars().chain(lines[1].chars().take(1)).collect();
|
||||
assert!(one_line(&next) > w, "no break right after {sep:?} while more fits: {lines:?}");
|
||||
}
|
||||
// latinLnBrk on: Unicode's break after a slash is allowed again.
|
||||
let text = "alpha/beta/gamma/delta/epsilon";
|
||||
let lines = wrapped(text, one_line(text) * 0.6, |p| p.latin_line_break = Some(true));
|
||||
assert!(lines[0].ends_with('/'), "{lines:?}");
|
||||
// A slash followed by a space still breaks, and hyphens still break.
|
||||
// Outside a Latin word the Unicode break stays: "日本!" fits, "語" moves to the next line.
|
||||
let lines = wrapped("日本!語", one_line("日本!") + 0.5, |_| {});
|
||||
assert_eq!(lines[0], "日本!", "{lines:?}");
|
||||
// Nor before a symbol that only shares the letters' line-break class.
|
||||
let (allowed, _) = break_opportunities("ab!&cd", false);
|
||||
assert!(allowed[3], "break after ! before &: {allowed:?}");
|
||||
// Latin text before CJK still breaks after the mark: "ab?" fits, "日本" moves down.
|
||||
let lines = wrapped("ab?日本", one_line("ab?") + 0.5, |_| {});
|
||||
assert_eq!(lines[0], "ab?", "{lines:?}");
|
||||
// A separator followed by a space still breaks, and hyphens still break.
|
||||
for sep in ["/", "?", "!"] {
|
||||
let lines = wrapped(&format!("alpha{sep} beta"), one_line(&format!("alpha{sep} b")) + 0.5, |_| {});
|
||||
assert_eq!(lines.iter().map(|l| l.trim_end()).collect::<Vec<_>>(), [format!("alpha{sep}"), "beta".into()], "{lines:?}");
|
||||
}
|
||||
let lines = wrapped("alpha-beta-gamma-delta-epsilon", one_line("alpha-beta-gamma-") + 1.0, |_| {});
|
||||
assert_eq!(lines[0], "alpha-beta-gamma-");
|
||||
}
|
||||
@@ -1928,3 +1946,39 @@ fn a_paragraph_with_its_own_start_at_restarts_the_count_there() {
|
||||
// A later paragraph with a new start begins a fresh run.
|
||||
check(&[1, 1, 7, 7], &[1, 2, 7, 8]);
|
||||
}
|
||||
|
||||
/// #1632: when insets are wider than the shape, they scale down proportionally so the text
|
||||
/// rectangle stays inside the shape bounds and unwrapped text does not project outside it.
|
||||
#[test]
|
||||
fn insets_wider_than_the_shape_scale_down_within_the_shape_bounds() {
|
||||
let (p, mut sh) = setup("Collaboration Hub", 14.17, 14.17);
|
||||
sh.ph = None;
|
||||
let bp = &mut sh.text.as_mut().unwrap().body;
|
||||
bp.wrap = Some(false);
|
||||
bp.inset_l = Some(127.5);
|
||||
bp.inset_r = Some(11.33);
|
||||
bp.inset_t = Some(11.33);
|
||||
bp.inset_b = Some(11.33);
|
||||
sh.text.as_mut().unwrap().paragraphs[0].props = deckcraft_model::text::ParaProps { align: Some(Align::Center), ..Default::default() };
|
||||
let l = lay(&p, &sh, 14.17, 14.17);
|
||||
assert_eq!(l.lines.len(), 1);
|
||||
let line = &l.lines[0];
|
||||
let mid = (line.caret_x[0] + *line.caret_x.last().unwrap()) / 2.0;
|
||||
// The text center must remain inside the 14.17 pt wide shape, not pushed ~127.5 pt to the right.
|
||||
assert!((0.0..=14.17).contains(&mid), "line center {mid} should be within [0, 14.17]");
|
||||
}
|
||||
|
||||
/// #1632: signed insets (e.g. negative left inset with positive right inset) are preserved when
|
||||
/// their sum fits within the shape bounds.
|
||||
#[test]
|
||||
fn signed_insets_are_preserved_when_they_fit() {
|
||||
let (p, mut sh) = setup("Text", 100.0, 50.0);
|
||||
sh.ph = None;
|
||||
let bp = &mut sh.text.as_mut().unwrap().body;
|
||||
bp.inset_l = Some(-10.0);
|
||||
bp.inset_r = Some(10.0);
|
||||
sh.text.as_mut().unwrap().paragraphs[0].props = deckcraft_model::text::ParaProps { align: Some(Align::Left), ..Default::default() };
|
||||
let l = lay(&p, &sh, 100.0, 50.0);
|
||||
assert_eq!(l.lines.len(), 1);
|
||||
assert_eq!(l.inner.x0, -10.0);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Bake the contributor credits into the binary as static Rust tables (craftrules
|
||||
//! standards/contributors.md). Nothing is read at run time.
|
||||
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
|
||||
//!
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
|
||||
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose
|
||||
//! people.toml entry is verified.
|
||||
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
||||
//! JSON says the entry was verified (`people_entry`).
|
||||
//!
|
||||
|
||||
@@ -13,7 +13,7 @@ use super::{
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod control;
|
||||
mod files;
|
||||
pub(crate) mod files;
|
||||
mod frame;
|
||||
mod ime;
|
||||
mod input;
|
||||
@@ -30,6 +30,11 @@ pub use keys::key_name;
|
||||
const STATUS_LINGER_MS: f64 = 6000.0;
|
||||
|
||||
impl SlideApp {
|
||||
/// Remove temporary PDFs created for printing.
|
||||
pub fn cleanup_print_previews(&mut self) {
|
||||
self.print_previews.cleanup();
|
||||
}
|
||||
|
||||
/// Build application UI state, taking ownership of audio output and initialising media, dialogs,
|
||||
/// synthetic input, and timing fields.
|
||||
pub fn new(session: Session, mut services: Services) -> Self {
|
||||
@@ -77,6 +82,7 @@ impl SlideApp {
|
||||
integrated_titlebar: false,
|
||||
backstage: None,
|
||||
pending_urls: vec![],
|
||||
print_previews: files::PrintPreviews::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: updates_ui::Runtime::default(),
|
||||
pending_copy: None,
|
||||
|
||||
@@ -3,6 +3,66 @@ use super::{
|
||||
json, media,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PrintPreviews(Vec<std::path::PathBuf>);
|
||||
|
||||
impl PrintPreviews {
|
||||
fn create(&mut self, title: &str) -> std::io::Result<std::path::PathBuf> {
|
||||
self.create_in(&std::env::temp_dir(), title)
|
||||
}
|
||||
|
||||
fn create_in(&mut self, base: &std::path::Path, title: &str) -> std::io::Result<std::path::PathBuf> {
|
||||
let dir = create_private_print_dir(base)?;
|
||||
self.0.push(dir.clone());
|
||||
let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect();
|
||||
Ok(dir.join(format!("{safe} (print).pdf")))
|
||||
}
|
||||
|
||||
fn discard(&mut self, path: &std::path::Path) {
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
self.0.retain(|owned| owned != dir);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cleanup(&mut self) {
|
||||
for dir in self.0.drain(..) {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PrintPreviews {
|
||||
fn drop(&mut self) {
|
||||
self.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
fn create_private_print_dir(base: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static NEXT_DIR: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
for _ in 0..128 {
|
||||
let id = NEXT_DIR.fetch_add(1, Ordering::Relaxed);
|
||||
let path = base.join(format!("deckcraft-print-{}-{id}", std::process::id()));
|
||||
#[cfg(unix)]
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
#[cfg(not(unix))]
|
||||
let builder = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
match builder.create(&path) {
|
||||
Ok(()) => return Ok(path),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not allocate a unique print preview directory"))
|
||||
}
|
||||
|
||||
fn purpose_of(purpose: &str) -> FilePurpose {
|
||||
match purpose {
|
||||
"open" => FilePurpose::Open,
|
||||
@@ -207,8 +267,14 @@ impl SlideApp {
|
||||
let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms);
|
||||
return self.download(&format!("{title}.pdf"), bytes);
|
||||
}
|
||||
let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect();
|
||||
let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string();
|
||||
let preview_path = match self.print_previews.create(&title) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
self.set_status(format!("Couldn't create a private print preview: {e}"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let path = preview_path.to_string_lossy().to_string();
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
o.insert("format".into(), json!("pdf"));
|
||||
@@ -219,7 +285,10 @@ impl SlideApp {
|
||||
self.set_status("Opened the presentation as a PDF to print");
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
Err(_) => {
|
||||
self.print_previews.discard(&preview_path);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,3 +734,49 @@ pub(crate) fn web_image_files(
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod print_preview_tests {
|
||||
use super::PrintPreviews;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn same_title_gets_distinct_private_paths_and_drop_cleans_them() {
|
||||
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
|
||||
let base = std::env::temp_dir().join(format!("deckcraft-print-test-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&base).unwrap();
|
||||
let (first, second) = {
|
||||
let mut previews = PrintPreviews::default();
|
||||
let first = previews.create_in(&base, "Same title").unwrap();
|
||||
let second = previews.create_in(&base, "Same title").unwrap();
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(first.file_name(), second.file_name());
|
||||
assert!(first.parent().is_some_and(|dir| dir.is_dir()));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(first.parent().unwrap()).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o700);
|
||||
}
|
||||
(first, second)
|
||||
};
|
||||
assert!(!first.parent().is_some_and(|dir| dir.exists()));
|
||||
assert!(!second.parent().is_some_and(|dir| dir.exists()));
|
||||
std::fs::remove_dir(base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_cleanup_removes_previews() {
|
||||
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
|
||||
let base = std::env::temp_dir().join(format!("deckcraft-print-cleanup-test-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&base).unwrap();
|
||||
let mut previews = PrintPreviews::default();
|
||||
let path = previews.create_in(&base, "Explicit cleanup").unwrap();
|
||||
let dir = path.parent().unwrap().to_path_buf();
|
||||
|
||||
previews.cleanup();
|
||||
|
||||
assert!(!dir.exists());
|
||||
std::fs::remove_dir(base).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! bar until Esc or the back arrow returns to the presentation. With nothing open it is the start
|
||||
//! screen: PowerPoint's start screen and File › Home are the same page.
|
||||
//!
|
||||
//! A side menu on the left picks a page (Home, New, Open, Info, Save As, Print, Export, Account);
|
||||
//! A side menu on the left picks a page (Home, New, Open, Info, Save As, Print, Export, About);
|
||||
//! Save, Close and Options act at once. Every action runs an existing command through
|
||||
//! [`SlideApp::run`] or one of the app's file helpers ([`SlideApp::export_as`],
|
||||
//! [`SlideApp::print_pdf`], [`SlideApp::save_as_dialog`]); the state here is UI-only and never
|
||||
@@ -24,7 +24,7 @@ mod print;
|
||||
|
||||
pub use files::split_path;
|
||||
use files::{home, new_page, open_page};
|
||||
use info::{PROPERTIES, account, commit_props, info, save_as};
|
||||
use info::{PROPERTIES, about, commit_props, info, save_as};
|
||||
pub use info::{SAVE_TYPES, format_bytes};
|
||||
use print::{export, print};
|
||||
pub use print::{print_page_size, print_params, print_slides};
|
||||
@@ -47,11 +47,11 @@ pub enum Page {
|
||||
SaveAs,
|
||||
Print,
|
||||
Export,
|
||||
Account,
|
||||
About,
|
||||
}
|
||||
|
||||
impl Page {
|
||||
pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::Account];
|
||||
pub const ALL: [Page; 8] = [Page::Home, Page::New, Page::Open, Page::Info, Page::SaveAs, Page::Print, Page::Export, Page::About];
|
||||
|
||||
/// The id `app.backstage {page}` takes.
|
||||
pub fn id(self) -> &'static str {
|
||||
@@ -63,12 +63,16 @@ impl Page {
|
||||
Page::SaveAs => "saveAs",
|
||||
Page::Print => "print",
|
||||
Page::Export => "export",
|
||||
Page::Account => "account",
|
||||
Page::About => "about",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(id: &str) -> Option<Page> {
|
||||
let id = id.trim();
|
||||
// Keep existing control-channel callers working after the visible page rename.
|
||||
if id.eq_ignore_ascii_case("account") {
|
||||
return Some(Page::About);
|
||||
}
|
||||
Page::ALL.into_iter().find(|p| p.id().eq_ignore_ascii_case(id))
|
||||
}
|
||||
|
||||
@@ -82,7 +86,7 @@ impl Page {
|
||||
Page::SaveAs => "Save As",
|
||||
Page::Print => "Print",
|
||||
Page::Export => "Export",
|
||||
Page::Account => "Account",
|
||||
Page::About => "About",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +99,7 @@ impl Page {
|
||||
Page::SaveAs => Icon::SaveAs,
|
||||
Page::Print => Icon::Print,
|
||||
Page::Export => Icon::Export,
|
||||
Page::Account => Icon::Account,
|
||||
Page::About => Icon::Help,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +144,7 @@ pub fn entries(has_doc: bool) -> (Vec<Nav>, Vec<Nav>) {
|
||||
if has_doc {
|
||||
top.extend([Nav::Page(Page::Info), Nav::Save, Nav::Page(Page::SaveAs), Nav::Page(Page::Print), Nav::Page(Page::Export), Nav::Close]);
|
||||
}
|
||||
(top, vec![Nav::Page(Page::Account), Nav::Options])
|
||||
(top, vec![Nav::Page(Page::About), Nav::Options])
|
||||
}
|
||||
|
||||
/// Print page: which slides.
|
||||
@@ -412,7 +416,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
Page::SaveAs => save_as(app, ui, &mut bs, &mut out),
|
||||
Page::Print => print(app, ui, &mut bs),
|
||||
Page::Export => export(app, ui, &mut bs),
|
||||
Page::Account => account(app, ui),
|
||||
Page::About => about(app, ui),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -189,11 +189,30 @@ pub(super) fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out:
|
||||
}
|
||||
}
|
||||
|
||||
/// Account: the interface's appearance and what DeckCraft is. DeckCraft has no accounts, so
|
||||
/// there is nothing to sign in to.
|
||||
pub(super) fn account(app: &mut SlideApp, ui: &mut Ui) {
|
||||
/// Product information, project links, credits and appearance preferences.
|
||||
pub(super) fn about(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal_top(|ui| {
|
||||
// Side by side needs 480 + 40 + 340 points; stack the columns when the page is narrower.
|
||||
let layout = if ui.available_width() >= 860.0 { egui::Layout::left_to_right(egui::Align::Min) } else { egui::Layout::top_down(egui::Align::Min) };
|
||||
ui.with_layout(layout, |ui| {
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(480.0);
|
||||
heading(ui, "Product Information");
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover());
|
||||
crate::dialogs::paint_logo(ui.painter(), r);
|
||||
ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text));
|
||||
});
|
||||
dim(ui, &crate::dialogs::version_line());
|
||||
dim(ui, "Open source under MIT OR Apache-2.0. Not affiliated with Microsoft.");
|
||||
ui.add_space(14.0);
|
||||
if task(ui, Icon::Help, "Credits", "Credits", "The people and models who built DeckCraft.", true) {
|
||||
let _ = app.run("app.about", json!({}));
|
||||
}
|
||||
ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD);
|
||||
ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB);
|
||||
});
|
||||
ui.add_space(40.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_width(340.0);
|
||||
heading(ui, "Appearance");
|
||||
@@ -210,33 +229,6 @@ pub(super) fn account(app: &mut SlideApp, ui: &mut Ui) {
|
||||
})
|
||||
.response
|
||||
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Appearance"));
|
||||
ui.add_space(16.0);
|
||||
heading(ui, "User Information");
|
||||
dim(ui, "DeckCraft has no accounts: there is nothing to sign in to, and your presentations stay on your device.");
|
||||
});
|
||||
ui.add_space(40.0);
|
||||
ui.vertical(|ui| {
|
||||
heading(ui, "Product Information");
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(40.0, 40.0), Sense::hover());
|
||||
crate::dialogs::paint_logo(ui.painter(), r);
|
||||
ui.label(RichText::new("DeckCraft").font(theme::bold(24.0)).color(t.text));
|
||||
});
|
||||
dim(ui, &crate::dialogs::version_line());
|
||||
dim(ui, "Open source under MIT OR Apache-2.0. Not affiliated with Microsoft.");
|
||||
ui.add_space(14.0);
|
||||
if task(
|
||||
ui,
|
||||
Icon::Help,
|
||||
"About DeckCraft",
|
||||
"About DeckCraft",
|
||||
"The version, the people and models who built it, and where to find us.",
|
||||
true,
|
||||
) {
|
||||
let _ = app.run("app.about", json!({}));
|
||||
}
|
||||
ui.hyperlink_to("Join our community on Discord", deckcraft_engine::links::DISCORD);
|
||||
ui.hyperlink_to("Source on GitHub", deckcraft_engine::links::GITHUB);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -85,11 +85,13 @@ fn page_ids_round_trip_and_unknown_ids_are_refused() {
|
||||
assert_eq!(Page::parse(p.id()), Some(p));
|
||||
}
|
||||
assert_eq!(Page::parse(" SAVEAS "), Some(Page::SaveAs));
|
||||
assert_eq!(Page::parse("account"), Some(Page::About));
|
||||
assert_eq!(Page::parse("about"), Some(Page::About));
|
||||
assert_eq!(Page::parse("share"), None);
|
||||
assert_eq!(Page::parse(""), None);
|
||||
let (top, bottom) = entries(false);
|
||||
assert_eq!(top, vec![Nav::Page(Page::Home), Nav::Page(Page::New), Nav::Page(Page::Open)], "the start screen has no presentation pages");
|
||||
assert_eq!(bottom, vec![Nav::Page(Page::Account), Nav::Options]);
|
||||
assert_eq!(bottom, vec![Nav::Page(Page::About), Nav::Options]);
|
||||
assert_eq!(entries(true).0.len(), 9);
|
||||
assert_eq!(split_path("/home/me/Talk.pptx"), ("Talk.pptx".into(), "/home/me".into()));
|
||||
assert_eq!(format_bytes(512), "512 bytes");
|
||||
@@ -166,7 +168,7 @@ fn every_page_renders() {
|
||||
(Page::SaveAs, "Not saved yet"),
|
||||
(Page::Print, "Print Hidden Slides"),
|
||||
(Page::Export, "Create PDF Document"),
|
||||
(Page::Account, "About DeckCraft"),
|
||||
(Page::About, "Credits"),
|
||||
];
|
||||
for (p, marker) in checks {
|
||||
h.state_mut().run("app.backstage", json!({"page": p.id()})).unwrap();
|
||||
@@ -634,13 +636,25 @@ fn print_previews_pages_and_prints_a_pdf() {
|
||||
h.state_mut().backstage.as_mut().unwrap().print_range = "1-2".into();
|
||||
h.run_steps(2);
|
||||
click_on_page(&h, "Print");
|
||||
h.run_steps(2);
|
||||
let url = h.state().pending_urls.first().cloned().unwrap_or_default();
|
||||
// The URL was handed to egui at the end of the frame; check what it pointed at.
|
||||
let printed = std::env::temp_dir().join(format!("{} (print).pdf", h.state().session.active().unwrap().title()));
|
||||
assert!(printed.is_file(), "the PDF to print was written: {url}");
|
||||
assert!(std::fs::read(&printed).unwrap().starts_with(b"%PDF"));
|
||||
let _ = std::fs::remove_file(printed);
|
||||
h.step();
|
||||
let url = h
|
||||
.output()
|
||||
.platform_output
|
||||
.commands
|
||||
.iter()
|
||||
.find_map(|command| match command {
|
||||
egui::OutputCommand::OpenUrl(open) => Some(open.url.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("Print emits an egui OpenUrl command");
|
||||
assert!(url.contains("deckcraft-print-"), "print preview uses its private directory: {url}");
|
||||
let path = url.strip_prefix("file://").expect("print preview URL is a file URL");
|
||||
#[cfg(windows)]
|
||||
let path = path.strip_prefix('/').unwrap_or(path);
|
||||
let printed = std::path::Path::new(path);
|
||||
assert!(printed.is_file(), "the PDF at the emitted URL was written: {url}");
|
||||
assert!(std::fs::read(printed).unwrap().starts_with(b"%PDF"));
|
||||
let _ = std::fs::remove_dir_all(printed.parent().unwrap());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -692,3 +706,40 @@ fn options_in_backstage_opens_preferences_and_cancel_closes_it() {
|
||||
assert!(h.state().dialog.is_none(), "Cancel closes Preferences dialog");
|
||||
assert!(h.state().backstage_open(), "Backstage remains open");
|
||||
}
|
||||
|
||||
/// About replaces the account entry and still opens the credits dialog.
|
||||
#[test]
|
||||
fn about_has_product_links_and_credits_without_account_controls() {
|
||||
let mut h = harness(new_app());
|
||||
h.get_by_label("File").click();
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Account").is_none());
|
||||
click_nav(&h, "About");
|
||||
h.run_steps(3);
|
||||
assert_eq!(page(&h), Some(Page::About));
|
||||
assert!(h.query_by_label("User Information").is_none());
|
||||
assert!(h.query_by_label("Source on GitHub").is_some());
|
||||
assert!(h.query_by_label("Join our community on Discord").is_some());
|
||||
assert!(h.query_all_by_label("Appearance").next().is_some());
|
||||
click_on_page(&h, "Credits");
|
||||
h.run_steps(3);
|
||||
assert!(h.state().dialog.is_some());
|
||||
assert!(h.query_by_label("Contributors").is_some());
|
||||
assert!(h.query_by_label("Models").is_some());
|
||||
}
|
||||
|
||||
/// At the 900-point minimum window width the About columns stack and Appearance stays on the page.
|
||||
#[test]
|
||||
fn about_appearance_stays_on_the_page_at_minimum_width() {
|
||||
let mut h = Harness::builder().with_size(egui::vec2(900.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
new_app(),
|
||||
);
|
||||
h.state_mut().run("app.backstage", json!({"page": "about"})).unwrap();
|
||||
h.run_steps(3);
|
||||
let r = h.query_all_by_role_and_label(Role::ComboBox, "Appearance").next().expect("Appearance selector").rect();
|
||||
assert!(r.right() <= 900.0, "Appearance selector ends at {}, inside the window", r.right());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The About window's credits: every contributor (GitHub login always; display and real names only
|
||||
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
|
||||
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
|
||||
//! build.rs from contributors/contributors.json; nothing is
|
||||
//! read at run time.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! prompt that offers to update and restart. The flow itself is `craft_update::updater::Updater`.
|
||||
|
||||
use craft_update::updater::{Action, Status, Updater};
|
||||
use craft_update::{CheckResultDialog, CheckResultResponse};
|
||||
|
||||
use crate::SlideApp;
|
||||
|
||||
@@ -12,11 +13,13 @@ pub(crate) struct Runtime {
|
||||
/// The desktop host turns the check at start on; tests and the web build leave it off.
|
||||
enabled: bool,
|
||||
check_requested: bool,
|
||||
/// A manual check's result dialog is showing (checking, up to date or failed).
|
||||
result_open: bool,
|
||||
}
|
||||
|
||||
impl Default for Runtime {
|
||||
fn default() -> Self {
|
||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false }
|
||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false, result_open: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +31,7 @@ pub(crate) fn enable(app: &mut SlideApp) {
|
||||
pub(crate) fn request_check(app: &mut SlideApp) {
|
||||
if !matches!(app.updates_rt.updater.status(), Status::Checking) {
|
||||
app.updates_rt.check_requested = true;
|
||||
app.notify("Checking for updates…");
|
||||
app.updates_rt.result_open = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,15 +43,6 @@ pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
rt.updater.check_at_start(ctx);
|
||||
}
|
||||
rt.updater.tick(ctx);
|
||||
let outcome = match rt.updater.status() {
|
||||
Status::UpToDate => Some(format!("DeckCraft {VERSION} is up to date.")),
|
||||
Status::Failed(e) => Some(format!("Could not check for updates: {e}")),
|
||||
Status::Idle | Status::Checking => None,
|
||||
};
|
||||
if let Some(message) = outcome {
|
||||
rt.updater.dismiss_status();
|
||||
app.notify(message);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
@@ -60,6 +54,14 @@ pub(crate) fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
Action::None => {}
|
||||
}
|
||||
let rt = &mut app.updates_rt;
|
||||
if rt.result_open {
|
||||
match CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) {
|
||||
CheckResultResponse::Close => rt.result_open = false,
|
||||
CheckResultResponse::CheckAgain => rt.check_requested = true,
|
||||
CheckResultResponse::None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -26,6 +26,7 @@ pub mod icons;
|
||||
pub mod media;
|
||||
pub mod menus;
|
||||
pub mod opening;
|
||||
pub mod presenter;
|
||||
pub mod ribbon;
|
||||
pub mod settings;
|
||||
pub mod show;
|
||||
@@ -128,7 +129,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("show.end", "End Show", None, "{}"),
|
||||
("animation.preview", "Preview Animations", None, "{on?: bool, from?: effect index}"),
|
||||
("app.home", "Home", None, "{on?}"),
|
||||
("app.backstage", "File", Some("Alt+F"), "{page?: home|new|open|info|saveAs|print|export|account, on?: bool}"),
|
||||
("app.backstage", "File", Some("Alt+F"), "{page?: home|new|open|info|saveAs|print|export|about, on?: bool}"),
|
||||
("app.closePresentation", "Close", Some("Cmd+W"), "{}"),
|
||||
(
|
||||
"app.print",
|
||||
@@ -293,6 +294,7 @@ pub struct SlideApp {
|
||||
/// creating or closing a presentation leaves it.
|
||||
pub backstage: Option<backstage::Backstage>,
|
||||
pending_urls: Vec<String>,
|
||||
print_previews: app::files::PrintPreviews,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: updates_ui::Runtime,
|
||||
/// Text for the system clipboard from a Copy or Cut command, handed over on the next frame.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use deckcraft_color::{ColorScheme, ColorTransform, SchemeSlot};
|
||||
use deckcraft_model::ColorRef;
|
||||
use deckcraft_model::style::{Gradient, GradientShape, GradientStop, PatternFill};
|
||||
use deckcraft_model::style::{Gradient, GradientFlip, GradientShape, GradientStop, PatternFill};
|
||||
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -21,21 +21,25 @@ pub fn default_gradient(base: ColorRef, angle: f64) -> Gradient {
|
||||
stops: vec![GradientStop { pos: 0.0, color: base }, GradientStop { pos: 1.0, color: dark }],
|
||||
shape: GradientShape::Linear { angle, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The command parameter for `g` (`shape.fill {gradient}` / `design.background {gradient}`).
|
||||
pub fn gradient_json(g: &Gradient) -> Value {
|
||||
let stops: Vec<Value> = g.stops.iter().map(|s| json!([s.pos, cref_param(&s.color)])).collect();
|
||||
let flip = g.flip.map(GradientFlip::xml);
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, .. } => json!({"stops": stops, "kind": "linear", "angle": angle, "rotateWithShape": g.rotate_with_shape}),
|
||||
GradientShape::Linear { angle, .. } => {
|
||||
json!({"stops": stops, "kind": "linear", "angle": angle, "rotateWithShape": g.rotate_with_shape, "flip": flip})
|
||||
}
|
||||
GradientShape::Path { path, focus } => {
|
||||
let kind = match path.as_str() {
|
||||
"rect" => "rectangular",
|
||||
"shape" => "path",
|
||||
_ => "radial",
|
||||
};
|
||||
json!({"stops": stops, "kind": kind, "focus": focus, "rotateWithShape": g.rotate_with_shape})
|
||||
json!({"stops": stops, "kind": kind, "focus": focus, "rotateWithShape": g.rotate_with_shape, "flip": flip})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,6 +146,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
|
||||
stops: vec![GradientStop { pos: 0.0, color: light }, GradientStop { pos: 1.0, color: dark }],
|
||||
shape: GradientShape::Path { path: "circle".into(), focus: [0.5, 0.5, 0.5, 0.5] },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
},
|
||||
Gradient {
|
||||
stops: vec![GradientStop { pos: 0.0, color: base.clone() }, GradientStop { pos: 1.0, color: with_alpha(base, 0.0) }],
|
||||
@@ -508,3 +513,20 @@ pub fn pattern_editor(ui: &mut Ui, pt: &PatternFill, sc: &ColorScheme) -> Option
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gradient_json_retains_flip_mode() {
|
||||
let gradient = Gradient {
|
||||
stops: vec![],
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
flip: Some(GradientFlip::Xy),
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0; 4],
|
||||
};
|
||||
assert_eq!(gradient_json(&gradient).get("flip"), Some(&json!("xy")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
//! Bounded presenter-window messages. The audience owns the show; the remote window
|
||||
//! receives previews and sends input, never a second independently advancing show.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const MAX_PREVIEW_SIDE: usize = 640;
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Preview {
|
||||
pub width: usize,
|
||||
pub height: usize,
|
||||
pub rgba: Vec<u8>,
|
||||
}
|
||||
impl Preview {
|
||||
pub fn from_image(image: &egui::ColorImage) -> Option<Self> {
|
||||
let [w, h] = image.size;
|
||||
if w == 0 || h == 0 || w.checked_mul(h)? != image.pixels.len() {
|
||||
return None;
|
||||
}
|
||||
let scale = (MAX_PREVIEW_SIDE as f64 / w.max(h) as f64).min(1.0);
|
||||
let width = ((w as f64 * scale) as usize).max(1);
|
||||
let height = ((h as f64 * scale) as usize).max(1);
|
||||
let mut rgba = Vec::with_capacity(width * height * 4);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel = image.pixels.get((y * h / height) * w + x * w / width)?;
|
||||
rgba.extend_from_slice(&pixel.to_srgba_unmultiplied());
|
||||
}
|
||||
}
|
||||
Some(Self { width, height, rgba })
|
||||
}
|
||||
pub fn image(&self) -> Option<egui::ColorImage> {
|
||||
if self.width == 0
|
||||
|| self.height == 0
|
||||
|| self.width > MAX_PREVIEW_SIDE
|
||||
|| self.height > MAX_PREVIEW_SIDE
|
||||
|| self.width.checked_mul(self.height)?.checked_mul(4)? != self.rgba.len()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(egui::ColorImage::from_rgba_unmultiplied([self.width, self.height], &self.rgba))
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct Snapshot {
|
||||
pub slide: usize,
|
||||
pub total: usize,
|
||||
pub ended: bool,
|
||||
pub notes: String,
|
||||
pub elapsed: String,
|
||||
pub paused: bool,
|
||||
pub notes_px: f32,
|
||||
pub blank: Option<String>,
|
||||
pub grid: bool,
|
||||
pub zoom_armed: bool,
|
||||
pub current: Option<Preview>,
|
||||
pub next: Option<Preview>,
|
||||
}
|
||||
|
||||
/// Images acknowledged by a successful transport send, never merely attempted images.
|
||||
#[derive(Default)]
|
||||
pub struct DeltaCache {
|
||||
current: Option<Preview>,
|
||||
next: Option<Preview>,
|
||||
token: String,
|
||||
}
|
||||
impl DeltaCache {
|
||||
pub fn send<E>(
|
||||
&mut self,
|
||||
mut snapshot: Snapshot,
|
||||
token: &str,
|
||||
refresh: bool,
|
||||
send: impl FnOnce(Snapshot, bool, bool) -> Result<(), E>,
|
||||
) -> Result<(), E> {
|
||||
let fresh = refresh || self.token != token;
|
||||
let current_changed = fresh || self.current != snapshot.current;
|
||||
let next_changed = fresh || self.next != snapshot.next;
|
||||
let current = snapshot.current.clone();
|
||||
let next = snapshot.next.clone();
|
||||
if !current_changed {
|
||||
snapshot.current = None;
|
||||
}
|
||||
if !next_changed {
|
||||
snapshot.next = None;
|
||||
}
|
||||
send(snapshot, current_changed, next_changed)?;
|
||||
self.current = current;
|
||||
self.next = next;
|
||||
self.token = token.to_owned();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Command {
|
||||
Key { name: String, command: bool, shift: bool, alt: bool, ctrl: bool },
|
||||
Zoom { x: f32, y: f32 },
|
||||
End,
|
||||
}
|
||||
/// Implemented by the browser host. Native hosts retain the existing egui viewport.
|
||||
pub trait Host {
|
||||
fn open(&mut self) -> Result<(), String>;
|
||||
fn is_open(&self) -> bool;
|
||||
fn commands(&mut self) -> Vec<Command>;
|
||||
fn update(&mut self, snapshot: Snapshot);
|
||||
fn close(&mut self);
|
||||
}
|
||||
|
||||
/// The popup's independent egui renderer. It never advances a local show clock.
|
||||
#[derive(Default)]
|
||||
pub struct View {
|
||||
fonts: crate::uifonts::UiFonts,
|
||||
styled: bool,
|
||||
current: Option<egui::TextureHandle>,
|
||||
next: Option<egui::TextureHandle>,
|
||||
previous_current: Option<Preview>,
|
||||
previous_next: Option<Preview>,
|
||||
}
|
||||
impl View {
|
||||
/// Install the same interface and dynamic fallback fonts as SlideApp before layout.
|
||||
/// Font definitions take effect on the next pass; never forward input on a discarded pass.
|
||||
pub fn prepare(&mut self, ctx: &egui::Context, notes: &str) -> bool {
|
||||
if !self.styled {
|
||||
crate::theme::install_fonts_with(ctx, &self.fonts.faces);
|
||||
crate::theme::apply(ctx, &crate::theme::Tokens::dark());
|
||||
self.styled = true;
|
||||
ctx.request_discard("install presenter interface fonts before layout");
|
||||
return false;
|
||||
}
|
||||
if ctx.will_discard() {
|
||||
return false;
|
||||
}
|
||||
self.fonts.note(ctx, notes);
|
||||
if self.fonts.apply(ctx) {
|
||||
ctx.request_discard("install presenter note fallback fonts before layout");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot) -> Vec<Command> {
|
||||
if !self.prepare(ui.ctx(), &snapshot.notes) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut commands = Vec::new();
|
||||
let keys = ui.input(|i| {
|
||||
i.events
|
||||
.iter()
|
||||
.filter_map(|event| if let egui::Event::Key { key, pressed: true, modifiers, .. } = event { Some((*key, *modifiers)) } else { None })
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
for (key, modifiers) in keys {
|
||||
// Consume once before widgets see Space/Enter, avoiding a second button activation.
|
||||
ui.input_mut(|i| i.consume_key(modifiers, key));
|
||||
commands.push(Command::Key {
|
||||
name: key.name().into(),
|
||||
command: modifiers.command,
|
||||
shift: modifiers.shift,
|
||||
alt: modifiers.alt,
|
||||
ctrl: modifiers.ctrl,
|
||||
});
|
||||
}
|
||||
fn update(
|
||||
ctx: &egui::Context,
|
||||
name: &str,
|
||||
previous: &mut Option<Preview>,
|
||||
texture: &mut Option<egui::TextureHandle>,
|
||||
image: &Option<Preview>,
|
||||
) {
|
||||
if previous == image {
|
||||
return;
|
||||
}
|
||||
*previous = image.clone();
|
||||
*texture = image.as_ref().and_then(Preview::image).map(|image| ctx.load_texture(name, image, egui::TextureOptions::LINEAR));
|
||||
}
|
||||
update(ui.ctx(), "remote-current", &mut self.previous_current, &mut self.current, &snapshot.current);
|
||||
update(ui.ctx(), "remote-next", &mut self.previous_next, &mut self.next, &snapshot.next);
|
||||
let key = |name: &str| Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false };
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.heading(&snapshot.elapsed);
|
||||
ui.label(format!("Slide {} of {}", snapshot.slide.saturating_add(1), snapshot.total));
|
||||
for (label, name) in [("◀ Previous", "ArrowLeft"), ("Next ▶", "ArrowRight")] {
|
||||
if ui.button(label).clicked() {
|
||||
commands.push(key(name));
|
||||
}
|
||||
}
|
||||
if ui.button("End Show").clicked() {
|
||||
commands.push(Command::End);
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (label, name) in [
|
||||
(if snapshot.paused { "Resume" } else { "Pause" }, "T"),
|
||||
("Reset", "R"),
|
||||
("Black", "B"),
|
||||
("White", "W"),
|
||||
("All slides", "G"),
|
||||
("Zoom", "Z"),
|
||||
] {
|
||||
if ui.button(label).clicked() {
|
||||
commands.push(key(name));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
ui.columns(2, |columns| {
|
||||
let left = &mut columns[0];
|
||||
if snapshot.ended {
|
||||
left.heading("End of slide show");
|
||||
} else if let Some(texture) = &self.current {
|
||||
let response = left.add(
|
||||
egui::Image::new(texture)
|
||||
.fit_to_exact_size(fit_preview(texture.size_vec2(), left.available_width(), left.available_height() * 0.8))
|
||||
.sense(egui::Sense::click()),
|
||||
);
|
||||
if let Some(blank) = &snapshot.blank {
|
||||
let color = if blank == "white" { egui::Color32::WHITE } else { egui::Color32::BLACK };
|
||||
left.painter().rect_filled(response.rect, 0.0, color);
|
||||
}
|
||||
if snapshot.zoom_armed
|
||||
&& response.clicked()
|
||||
&& let Some(point) = response.interact_pointer_pos()
|
||||
{
|
||||
commands.push(Command::Zoom {
|
||||
x: (point.x - response.rect.left()) / response.rect.width().max(1.0),
|
||||
y: (point.y - response.rect.top()) / response.rect.height().max(1.0),
|
||||
});
|
||||
}
|
||||
}
|
||||
if snapshot.zoom_armed {
|
||||
left.label("Click the preview to zoom.");
|
||||
}
|
||||
if snapshot.grid {
|
||||
left.label("Choose a slide in the audience window, or press G to return.");
|
||||
}
|
||||
let right = &mut columns[1];
|
||||
right.label("Next");
|
||||
if let Some(texture) = &self.next {
|
||||
right.add(egui::Image::new(texture).fit_to_exact_size(fit_preview(
|
||||
texture.size_vec2(),
|
||||
right.available_width(),
|
||||
right.available_height() * 0.45,
|
||||
)));
|
||||
} else {
|
||||
right.label("End of slide show");
|
||||
}
|
||||
right.horizontal(|ui| {
|
||||
ui.label("Notes");
|
||||
if ui.button("A−").clicked() {
|
||||
commands.push(key(egui::Key::OpenBracket.name()));
|
||||
}
|
||||
if ui.button("A+").clicked() {
|
||||
commands.push(key(egui::Key::CloseBracket.name()));
|
||||
}
|
||||
});
|
||||
egui::ScrollArea::vertical().show(right, |ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(if snapshot.notes.is_empty() { "No notes." } else { &snapshot.notes })
|
||||
.size(snapshot.notes_px.clamp(10.0, 72.0)),
|
||||
);
|
||||
});
|
||||
});
|
||||
commands
|
||||
}
|
||||
}
|
||||
fn fit_preview(size: egui::Vec2, width: f32, height: f32) -> egui::Vec2 {
|
||||
size * (width.max(1.0) / size.x.max(1.0)).min(height.max(1.0) / size.y.max(1.0))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn snapshot() -> Snapshot {
|
||||
Snapshot {
|
||||
slide: 0,
|
||||
total: 2,
|
||||
ended: false,
|
||||
notes: String::new(),
|
||||
elapsed: "00:00:00".into(),
|
||||
paused: false,
|
||||
notes_px: 18.0,
|
||||
blank: None,
|
||||
grid: false,
|
||||
zoom_armed: false,
|
||||
current: Some(Preview { width: 1, height: 1, rgba: vec![255; 4] }),
|
||||
next: Some(Preview { width: 1, height: 1, rgba: vec![0; 4] }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_transport_retries_images_and_only_success_deduplicates() {
|
||||
let mut cache = DeltaCache::default();
|
||||
let first = snapshot();
|
||||
let failed = cache.send(first.clone(), "session", false, |delta, current, next| {
|
||||
assert!(current && next);
|
||||
assert_eq!(delta.current, first.current);
|
||||
assert_eq!(delta.next, first.next);
|
||||
Err("serialization or postMessage failed")
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
let sent = cache.send(first.clone(), "session", false, |delta, current, next| {
|
||||
assert!(current && next, "a failed initial send must not suppress either image");
|
||||
assert_eq!(delta.current, first.current);
|
||||
assert_eq!(delta.next, first.next);
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
let sent = cache.send(first.clone(), "session", false, |delta, current, next| {
|
||||
assert!(!current && !next);
|
||||
assert!(delta.current.is_none() && delta.next.is_none());
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
let mut changed = first.clone();
|
||||
changed.current.as_mut().unwrap().rgba = vec![42; 4];
|
||||
let failed = cache.send(changed.clone(), "session", false, |_, current, next| {
|
||||
assert!(current && !next);
|
||||
Err(())
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
let sent = cache.send(changed.clone(), "session", false, |delta, current, next| {
|
||||
assert!(current && !next, "unchanged retry after a failed changed-image send");
|
||||
assert_eq!(delta.current, changed.current);
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_refreshes_new_sessions_reloads_and_clears_previews() {
|
||||
let mut cache = DeltaCache::default();
|
||||
for (token, refresh) in [("first", false), ("second", false), ("second", true)] {
|
||||
let sent = cache.send(snapshot(), token, refresh, |delta, current, next| {
|
||||
assert!(current && next);
|
||||
assert!(delta.current.is_some() && delta.next.is_some());
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
}
|
||||
let failed = cache.send(snapshot(), "second", true, |_, current, next| {
|
||||
assert!(current && next);
|
||||
Err(())
|
||||
});
|
||||
// BrowserHost retains refresh_images after failure, even when the token is unchanged.
|
||||
let refresh_images = failed.is_err();
|
||||
let sent = cache.send(snapshot(), "second", refresh_images, |_, current, next| {
|
||||
assert!(current && next, "reload refresh must survive a failed send");
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
let mut cleared = snapshot();
|
||||
cleared.current = None;
|
||||
cleared.next = None;
|
||||
let failed = cache.send(cleared.clone(), "second", false, |_, current, next| {
|
||||
assert!(current && next);
|
||||
Err(())
|
||||
});
|
||||
assert!(failed.is_err());
|
||||
let sent = cache.send(cleared.clone(), "second", false, |delta, current, next| {
|
||||
assert!(current && next, "clearing images is retried too");
|
||||
assert!(delta.current.is_none() && delta.next.is_none());
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
let sent = cache.send(cleared, "second", false, |_, current, next| {
|
||||
assert!(!current && !next);
|
||||
Ok::<_, ()>(())
|
||||
});
|
||||
assert!(sent.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_installs_interface_fonts_before_non_latin_note_layout() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut view = View::default();
|
||||
let mut data = snapshot();
|
||||
let arabic = "\u{645}\u{631}\u{62d}\u{628}\u{627}";
|
||||
let japanese = "\u{65e5}\u{672c}\u{8a9e}";
|
||||
let korean = "\u{d55c}\u{ae00}";
|
||||
data.notes = format!("{arabic} {japanese} {korean}");
|
||||
let mut installing = 0;
|
||||
let mut layouts = 0;
|
||||
for _ in 0..4 {
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
if !view.prepare(ui.ctx(), &data.notes) {
|
||||
installing += 1;
|
||||
assert!(view.ui(ui, &data).is_empty(), "no commands during a discarded font-install pass");
|
||||
return;
|
||||
}
|
||||
assert!(!ui.ctx().will_discard());
|
||||
layouts += 1;
|
||||
for (family, text) in [("Noto Sans Arabic", arabic), ("BIZ UDPGothic", japanese), ("NanumGothic", korean)] {
|
||||
if deckcraft_fonts::CRAFT_FONTS.iter().any(|face| face.family == family) {
|
||||
ui.ctx().fonts_mut(|fonts| {
|
||||
for c in text.chars() {
|
||||
assert!(fonts.has_glyph(&egui::FontId::proportional(18.0), c), "missing bundled {family} glyph {c:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let _ = view.ui(ui, &data);
|
||||
});
|
||||
output.textures_delta.clear();
|
||||
}
|
||||
assert!(installing > 0 && layouts > 0, "font installation must precede an actual notes layout");
|
||||
}
|
||||
#[test]
|
||||
fn popup_input_is_forwarded_once_without_advancing_its_own_state() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let snapshot = Snapshot {
|
||||
slide: 0,
|
||||
total: 3,
|
||||
ended: false,
|
||||
notes: "Synthetic notes".into(),
|
||||
elapsed: "00:00:00".into(),
|
||||
paused: false,
|
||||
notes_px: 18.0,
|
||||
blank: None,
|
||||
grid: false,
|
||||
zoom_armed: false,
|
||||
current: None,
|
||||
next: None,
|
||||
};
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
|
||||
sent.extend(view.ui(ui, snapshot));
|
||||
},
|
||||
(View::default(), snapshot, Vec::new()),
|
||||
);
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Next ▶").click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().2.len(), 1);
|
||||
h.key_press(egui::Key::Space);
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().2.len(), 2, "Space must not also activate the focused Next button");
|
||||
assert_eq!(h.state().0.previous_current, None);
|
||||
assert_eq!(h.state().1.slide, 0, "only an audience snapshot changes the displayed position");
|
||||
assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previews_are_bounded_and_bad_dimensions_are_rejected() {
|
||||
let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED);
|
||||
let p = Preview::from_image(&image).unwrap();
|
||||
assert_eq!((p.width, p.height), (640, 360));
|
||||
assert_eq!(p.image().unwrap().pixels[0], egui::Color32::RED);
|
||||
for (width, height, rgba) in [(usize::MAX, 1, vec![]), (0, 1, vec![]), (1, 1, vec![0; 3]), (641, 1, vec![0; 2564])] {
|
||||
assert!(Preview { width, height, rgba }.image().is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ pub type Inbox = std::sync::Arc<std::sync::Mutex<Vec<InboxEvent>>>;
|
||||
/// Platform services injected by the host (desktop or web).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
/// Browser-owned presenter popup; absent on native hosts.
|
||||
pub presenter: Option<Box<dyn crate::presenter::Host>>,
|
||||
/// Open-file dialog for a purpose (`open`, `picture`, `audio`, `video`) → path.
|
||||
pub pick_open: Option<PickFn>,
|
||||
/// Save dialog with a suggested name → path.
|
||||
|
||||
@@ -63,6 +63,10 @@ pub struct Show {
|
||||
/// Preview renders started on another thread.
|
||||
#[cfg(test)]
|
||||
preview_spawns: usize,
|
||||
remote_preview: Option<crate::presenter::Preview>,
|
||||
remote_next: Option<(usize, crate::presenter::Preview)>,
|
||||
remote_sent: f64,
|
||||
remote_opened: bool,
|
||||
/// The next slide's first frame, rendered in the background while this one is shown so
|
||||
/// advancing only uploads it (#240).
|
||||
prefetch: Option<Prefetch>,
|
||||
@@ -231,6 +235,10 @@ impl Show {
|
||||
next_job: None,
|
||||
#[cfg(test)]
|
||||
preview_spawns: 0,
|
||||
remote_preview: None,
|
||||
remote_next: None,
|
||||
remote_sent: f64::NEG_INFINITY,
|
||||
remote_opened: false,
|
||||
prefetch: None,
|
||||
backdrop: None,
|
||||
blank: None,
|
||||
|
||||
@@ -12,6 +12,22 @@ pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation,
|
||||
.filter_map(|e| if let egui::Event::Key { key, pressed: true, modifiers, .. } = e { Some((*key, *modifiers)) } else { None })
|
||||
.collect()
|
||||
});
|
||||
let (keep, consumed) = handle_keys(show, doc, now, keys, presenter);
|
||||
for (key, modifiers) in consumed {
|
||||
ctx.input_mut(|i| i.consume_key(modifiers, key));
|
||||
ctx.request_repaint_of(egui::ViewportId::ROOT);
|
||||
}
|
||||
keep
|
||||
}
|
||||
|
||||
pub(super) fn handle_keys(
|
||||
show: &mut Show,
|
||||
doc: &Presentation,
|
||||
now: f64,
|
||||
keys: Vec<(egui::Key, egui::Modifiers)>,
|
||||
presenter: bool,
|
||||
) -> (bool, Vec<(egui::Key, egui::Modifiers)>) {
|
||||
let mut consumed = vec![];
|
||||
let mut keep = true;
|
||||
let mut actions = vec![];
|
||||
for (k, m) in keys {
|
||||
@@ -31,15 +47,14 @@ pub(super) fn keyboard(ctx: &egui::Context, show: &mut Show, doc: &Presentation,
|
||||
}
|
||||
// The same key must not also activate a focused presenter button or be handled again
|
||||
// by an embedded presenter viewport.
|
||||
ctx.input_mut(|i| i.consume_key(m, k));
|
||||
ctx.request_repaint_of(egui::ViewportId::ROOT);
|
||||
consumed.push((k, m));
|
||||
}
|
||||
for action in actions {
|
||||
if !show.apply(action, doc, now) {
|
||||
keep = false;
|
||||
}
|
||||
}
|
||||
keep
|
||||
(keep, consumed)
|
||||
}
|
||||
|
||||
/// Whether the grid that the presenter key G opens is showing in this viewport.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::render::fit;
|
||||
use super::{Click, HitShape, Hyperlink, Presentation, Rect, ShapeId, Show, SlideApp, Ui, pos2, vec2};
|
||||
use super::{Click, HitShape, Hyperlink, PlacedShape, Presentation, Rect, ShapeId, Show, SlideApp, Ui, pos2, vec2};
|
||||
use deckcraft_anim::AnimState;
|
||||
|
||||
/// The media-host voice for slide transition sounds (no shape has this id).
|
||||
pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX);
|
||||
@@ -152,6 +153,22 @@ pub(super) struct Under {
|
||||
pub(super) pick: Option<Pick>,
|
||||
}
|
||||
|
||||
/// Convert a slide-space point back into a shape's own coordinates, undoing its animation.
|
||||
fn hit_point(point: (f64, f64), shapes: &[PlacedShape], states: &[AnimState], index: usize) -> Option<(f64, f64)> {
|
||||
let shape = shapes.get(index)?;
|
||||
inverse_transform_point(point, (shape.rect.center().x, shape.rect.center().y), *states.get(index)?)
|
||||
}
|
||||
|
||||
fn inverse_transform_point(p: (f64, f64), center: (f64, f64), state: AnimState) -> Option<(f64, f64)> {
|
||||
if state.scale_x.abs() <= f64::EPSILON || state.scale_y.abs() <= f64::EPSILON {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = (p.0 - center.0 - state.offset_x, p.1 - center.1 - state.offset_y);
|
||||
let angle = -state.rotate.to_radians();
|
||||
let (x, y) = (x * angle.cos() - y * angle.sin(), x * angle.sin() + y * angle.cos());
|
||||
Some((center.0 + x / state.scale_x, center.1 + y / state.scale_y))
|
||||
}
|
||||
|
||||
impl Under {
|
||||
fn visible(&self, id: ShapeId) -> bool {
|
||||
self.shapes.iter().zip(&self.visible).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v)
|
||||
@@ -175,31 +192,29 @@ pub(super) fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show
|
||||
}
|
||||
show.timeline(doc);
|
||||
let (Some((_, tl)), Some((_, links, shapes))) = (&show.timeline, &show.links) else { return None };
|
||||
let inside = |x0: f64, y0: f64, x1: f64, y1: f64| x0 <= x && x <= x1 && y0 <= y && y <= y1;
|
||||
let hits: Vec<HitShape> = shapes
|
||||
.iter()
|
||||
.map(|s| HitShape {
|
||||
id: s.id,
|
||||
parent: s.parent,
|
||||
is_group: s.is_group,
|
||||
link: s.link.as_ref().map(|l| l.action.clone()),
|
||||
under_pointer: inside(s.rect.x0, s.rect.y0, s.rect.x1, s.rect.y1),
|
||||
})
|
||||
.collect();
|
||||
let (step, plays, stamps) = (show.state.step, show.state.trigger_plays(), show.state.trigger_stamps());
|
||||
let t = if show.state.playing { now - show.step_start } else { 0.0 };
|
||||
let visible: Vec<bool> = shapes
|
||||
let states: Vec<AnimState> = shapes.iter().map(|s| tl.state_ordered(s.id, None, step, t, &plays, &stamps)).collect();
|
||||
let point_for = |i: usize| hit_point((x, y), shapes, &states, i);
|
||||
let hits: Vec<HitShape> = shapes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let a = tl.state_ordered(s.id, None, step, t, &plays, &stamps);
|
||||
a.visible && a.opacity > 0.0
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let under_pointer = point_for(i).is_some_and(|(x, y)| s.rect.x0 <= x && x <= s.rect.x1 && s.rect.y0 <= y && y <= s.rect.y1);
|
||||
HitShape { id: s.id, parent: s.parent, is_group: s.is_group, link: s.link.as_ref().map(|l| l.action.clone()), under_pointer }
|
||||
})
|
||||
.collect();
|
||||
let visible: Vec<bool> = states.iter().map(|a| a.visible && a.opacity > 0.0).collect();
|
||||
let mut under = Under { shapes: hits, visible, pick: None };
|
||||
let shown = |id: ShapeId| under.visible(id);
|
||||
// A hyperlink in text applies when its shape is the one on top.
|
||||
let top = show.state.top(doc, tl, &under.shapes, &shown);
|
||||
let text = links.iter().rev().filter(|l| l.text && inside(l.rect.x0, l.rect.y0, l.rect.x1, l.rect.y1)).find(|l| {
|
||||
let text = links.iter().rev().filter(|l| l.text).find(|l| {
|
||||
let Some(i) = shapes.iter().position(|s| s.id == l.shape) else { return false };
|
||||
let Some((x, y)) = point_for(i) else { return false };
|
||||
if !(l.rect.x0 <= x && x <= l.rect.x1 && l.rect.y0 <= y && y <= l.rect.y1) {
|
||||
return false;
|
||||
}
|
||||
let mut at = top;
|
||||
(0..=under.shapes.len()).any(|_| match at.and_then(|k| under.shapes.get(k)) {
|
||||
Some(h) if h.id == l.shape => true,
|
||||
@@ -252,3 +267,45 @@ pub(super) fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc_uid: u64, d
|
||||
let gifs = app.media.gifs(doc_uid, doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused);
|
||||
gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hit_tests {
|
||||
use super::*;
|
||||
|
||||
fn shape(id: u32, parent: Option<usize>, rect: deckcraft_geom::Rect) -> PlacedShape {
|
||||
PlacedShape { id: ShapeId(id), parent, is_group: parent.is_none(), rect, link: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_moves_the_hit_box() {
|
||||
let shapes = [shape(1, None, deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))];
|
||||
let states = [AnimState { offset_x: 50.0, scale_x: 2.0, ..Default::default() }];
|
||||
|
||||
let moved = hit_point((65.0, 20.0), &shapes, &states, 0);
|
||||
let abandoned = hit_point((15.0, 20.0), &shapes, &states, 0);
|
||||
assert!(moved.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
|
||||
assert!(!abandoned.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_and_rotation_move_the_hit_box() {
|
||||
let shapes = [shape(1, None, deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))];
|
||||
let states = [AnimState { scale_x: 2.0, scale_y: 1.0, rotate: 90.0, ..Default::default() }];
|
||||
|
||||
// The shape center is (20, 20); local (25, 25) scales and rotates to (15, 30).
|
||||
let transformed = hit_point((15.0, 30.0), &shapes, &states, 0);
|
||||
assert!(transformed.is_some_and(|(x, y)| (24.9..=25.1).contains(&x) && (24.9..=25.1).contains(&y)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_animation_does_not_move_child_hit_box() {
|
||||
let shapes =
|
||||
[shape(1, None, deckcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0)), shape(2, Some(0), deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))];
|
||||
let states = [AnimState { offset_x: 40.0, ..Default::default() }, AnimState::default()];
|
||||
|
||||
let unchanged = hit_point((15.0, 20.0), &shapes, &states, 1);
|
||||
let displaced = hit_point((55.0, 20.0), &shapes, &states, 1);
|
||||
assert!(unchanged.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
|
||||
assert!(!displaced.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1123,6 +1123,66 @@ fn input_state_tracks_the_modes_that_move_show_click_targets() {
|
||||
assert_ne!(show.input_state(), base, "a blank screen has no slide click targets");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
|
||||
use crate::presenter::{Command, Host, Snapshot};
|
||||
use std::{cell::RefCell, rc::Rc};
|
||||
#[derive(Default)]
|
||||
struct Bridge {
|
||||
opens: usize,
|
||||
closes: usize,
|
||||
commands: Vec<Command>,
|
||||
}
|
||||
struct Mock(Rc<RefCell<Bridge>>);
|
||||
impl Host for Mock {
|
||||
fn open(&mut self) -> Result<(), String> {
|
||||
self.0.borrow_mut().opens += 1;
|
||||
Ok(())
|
||||
}
|
||||
fn is_open(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn commands(&mut self) -> Vec<Command> {
|
||||
std::mem::take(&mut self.0.borrow_mut().commands)
|
||||
}
|
||||
fn update(&mut self, _: Snapshot) {}
|
||||
fn close(&mut self) {
|
||||
self.0.borrow_mut().closes += 1;
|
||||
}
|
||||
}
|
||||
let bridge = Rc::new(RefCell::new(Bridge::default()));
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
session.execute("slide.new", &json!({})).unwrap();
|
||||
session.execute("slide.new", &json!({})).unwrap();
|
||||
let services = crate::Services { presenter: Some(Box::new(Mock(bridge.clone()))), ..Default::default() };
|
||||
let mut app = SlideApp::new(session, services);
|
||||
app.ui.presenter_view = true;
|
||||
let mut h = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h.state_mut().start_show(0, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(bridge.borrow().opens, 1);
|
||||
for (key, slide) in [(egui::Key::ArrowRight, 1), (egui::Key::ArrowLeft, 0), (egui::Key::End, 2), (egui::Key::Home, 0)] {
|
||||
bridge.borrow_mut().commands.push(Command::Key { name: key.name().into(), command: false, shift: false, alt: false, ctrl: false });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide);
|
||||
}
|
||||
bridge.borrow_mut().commands.push(Command::End);
|
||||
h.run_steps(2);
|
||||
assert!(h.state().show.is_none());
|
||||
assert_eq!(bridge.borrow().closes, 1);
|
||||
h.state_mut().start_show(1, false);
|
||||
h.run_steps(2);
|
||||
assert_eq!(bridge.borrow().opens, 2);
|
||||
assert_eq!(h.state().show.as_ref().unwrap().state.slide, 1);
|
||||
}
|
||||
|
||||
/// #964: the next-slide preview renders on another thread; the presenter frame that asks for
|
||||
/// it doesn't wait, and the preview arrives on a later frame.
|
||||
#[test]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
use super::keys::keyboard;
|
||||
use super::keys::{handle_keys, keyboard};
|
||||
use super::media::{
|
||||
Pick, Under, gif_hit, media_bar_target, media_hit, on_screen, pointer_on_media_bar, run_media_cues, shapes_under_pointer, show_media,
|
||||
};
|
||||
use super::presenter::{PresenterTimer, presenter_window};
|
||||
use super::presenter::{PresenterTimer, format_elapsed, presenter_window};
|
||||
use super::render::{below_playing_video, fit, part_textures, quad_mesh, render_frame, render_overlay, render_threads, start_prefetch};
|
||||
use super::zoom::{
|
||||
Zoom, grid_indices, norm_on_slide, paint_grid, see_all_hit, slide_aspect, slide_box, slide_to_screen, toggle_blank, tool_bar_rect, unit_uv,
|
||||
@@ -58,6 +58,9 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let Some((doc_uid, doc)) = app.session.active().map(|d| (d.uid, d.doc.clone())) else {
|
||||
app.show = None;
|
||||
if let Some(host) = app.services.presenter.as_mut() {
|
||||
host.close();
|
||||
}
|
||||
return;
|
||||
};
|
||||
let now = ctx.input(|i| i.time);
|
||||
@@ -71,8 +74,35 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
return;
|
||||
}
|
||||
show.observe_visible_frame(now, web_time::Instant::now());
|
||||
begin_show(&ctx, &mut show, &doc, now);
|
||||
begin_show(&ctx, &mut show, &doc, now, app.services.presenter.is_some());
|
||||
let mut out = Out { keep: keyboard(&ctx, &mut show, &doc, now, false), actions: vec![] };
|
||||
if show.presenter
|
||||
&& !show.reading
|
||||
&& let Some(host) = app.services.presenter.as_mut()
|
||||
{
|
||||
if !show.remote_opened {
|
||||
show.remote_opened = true;
|
||||
if let Err(message) = host.open() {
|
||||
app.status = Some((message, crate::now_ms()));
|
||||
}
|
||||
}
|
||||
for command in host.commands() {
|
||||
match command {
|
||||
crate::presenter::Command::End => out.keep = false,
|
||||
crate::presenter::Command::Zoom { x, y } if x.is_finite() && y.is_finite() && show.zoom_arm => {
|
||||
show.zoom = Some(Zoom::at(x.clamp(0.0, 1.0), y.clamp(0.0, 1.0)));
|
||||
show.zoom_arm = false;
|
||||
}
|
||||
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
|
||||
if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) {
|
||||
let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl };
|
||||
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let (clicked, secondary, moved) = ctx.input(|i| (i.pointer.primary_clicked(), i.pointer.secondary_clicked(), i.pointer.delta().length() > 0.0));
|
||||
if moved {
|
||||
show.last_move = now;
|
||||
@@ -94,6 +124,43 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
draw_pen_and_hints(ui, &mut show, &f, &painter, pointing);
|
||||
draw_bars(ui, &mut show, &f, &painter, &mut out);
|
||||
draw_extras(&mut show, &f, &painter, &mut out);
|
||||
if out.keep
|
||||
&& show.presenter
|
||||
&& !show.reading
|
||||
&& let Some(host) = app.services.presenter.as_mut()
|
||||
{
|
||||
if now - show.remote_sent >= 0.2 {
|
||||
let next = show.state.peek_next(&doc);
|
||||
if let Some(n) = next
|
||||
&& show.remote_next.as_ref().is_none_or(|(old, _)| *old != n)
|
||||
{
|
||||
let size =
|
||||
crate::textures::fit_size((480, (480.0 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32), 640);
|
||||
let image = deckcraft_render::render_slide(
|
||||
&doc,
|
||||
n,
|
||||
&RenderOpts { scale: size.0 as f64 / doc.slide_size.width.max(1.0), size: Some(size), ..Default::default() },
|
||||
);
|
||||
show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image));
|
||||
}
|
||||
host.update(crate::presenter::Snapshot {
|
||||
slide: show.state.slide,
|
||||
total: doc.slides.len(),
|
||||
ended: show.ended,
|
||||
notes: doc.slides.get(show.state.slide).map(|s| s.notes_text()).unwrap_or_default(),
|
||||
elapsed: format_elapsed(show.timer.elapsed(now)),
|
||||
paused: show.timer.paused(),
|
||||
notes_px: show.notes_px,
|
||||
blank: show.blank_name().map(str::to_owned),
|
||||
grid: show.grid,
|
||||
zoom_armed: show.zoom_arm,
|
||||
current: show.remote_preview.clone(),
|
||||
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()),
|
||||
});
|
||||
show.remote_sent = now;
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(200));
|
||||
}
|
||||
finish(app, show, &f, out.keep);
|
||||
}
|
||||
|
||||
@@ -110,7 +177,7 @@ fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) {
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64) {
|
||||
fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64, remote: bool) {
|
||||
if !show.started {
|
||||
show.started = true;
|
||||
show.slide_start = now;
|
||||
@@ -121,7 +188,7 @@ fn begin_show(ctx: &egui::Context, show: &mut Show, doc: &Presentation, now: f64
|
||||
show.show_start = now;
|
||||
show.timer = PresenterTimer::start(now);
|
||||
show.last_move = now;
|
||||
if show_fills_screen(show.reading, &doc.show.show_type) {
|
||||
if show_fills_screen(show.reading, &doc.show.show_type) && !(show.presenter && remote) {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(true));
|
||||
}
|
||||
show.after_enter(doc, now);
|
||||
@@ -303,6 +370,9 @@ fn frame_texture(show: &mut Show, f: &Fr, sl: &Sl) -> Option<TextureHandle> {
|
||||
render_frame(doc, idx, tl, step, sl.t, size, sl.hidden.clone(), &sl.times, render_threads(), &sl.plays, &sl.stamps, back.as_deref())
|
||||
}
|
||||
};
|
||||
if show.remote_opened && show.presenter {
|
||||
show.remote_preview = crate::presenter::Preview::from_image(&ci);
|
||||
}
|
||||
let tex = match show.tex.take() {
|
||||
Some((_, mut h)) => {
|
||||
crate::textures::replace(ctx, &mut h, ci);
|
||||
@@ -730,7 +800,7 @@ fn draw_extras(show: &mut Show, f: &Fr, painter: &egui::Painter, out: &mut Out)
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
// Presenter view in a second window.
|
||||
if out.keep && show.presenter && !show.reading {
|
||||
if out.keep && show.presenter && !show.reading && !show.remote_opened {
|
||||
presenter_window(ctx, show, doc, now, &mut out.keep);
|
||||
}
|
||||
}
|
||||
@@ -740,6 +810,9 @@ fn finish(app: &mut SlideApp, mut show: Show, f: &Fr, keep: bool) {
|
||||
if keep {
|
||||
app.show = Some(show);
|
||||
} else {
|
||||
if let Some(host) = app.services.presenter.as_mut() {
|
||||
host.close();
|
||||
}
|
||||
app.media.stop_owned(crate::media::Owner::Show);
|
||||
if !show.reading {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(false));
|
||||
|
||||
@@ -999,7 +999,8 @@ fn the_language_dialog_marks_the_selected_text() {
|
||||
harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap();
|
||||
// The anchored window settles over its first frames; click once it stays put.
|
||||
harness.run_steps(4);
|
||||
harness.get_by_label("Français (France)").click();
|
||||
// Installed dictionaries append a marker to the language's accessible label.
|
||||
harness.get_by_label_contains("Français (France)").click();
|
||||
harness.run_steps(2);
|
||||
harness.get_by_label("OK").click();
|
||||
harness.run_steps(2);
|
||||
|
||||
+4
-21
@@ -1,16 +1,14 @@
|
||||
# Contributors in the About window
|
||||
|
||||
**About ▸ Contributors** credits everyone who contributed to DeckCraft, and **About ▸ Models** credits
|
||||
the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard
|
||||
[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md);
|
||||
this page is the local copy of the decision.
|
||||
the AI models named in `Co-Authored-By` trailers.
|
||||
|
||||
## Decision
|
||||
|
||||
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
|
||||
disk or the network at run time; the web build has them too):
|
||||
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
|
||||
username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit.
|
||||
username. Never hand-edit.
|
||||
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
|
||||
themselves. **Each contributor adds only their own entry**, in their own PR.
|
||||
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
|
||||
@@ -36,20 +34,5 @@ display_name = "Nickname"
|
||||
sync_github_name = true # use the name on your public GitHub profile as display name
|
||||
```
|
||||
|
||||
or, with craftrules checked out next to `craft-apps/`:
|
||||
|
||||
```sh
|
||||
python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name
|
||||
```
|
||||
|
||||
Names show after the next refresh: the script checks that each entry was committed by its own
|
||||
user (or by a maintainer with a `consent_source`). Delete your entry to remove your names.
|
||||
|
||||
## Refreshing (maintainers)
|
||||
|
||||
```sh
|
||||
python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh
|
||||
python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only
|
||||
```
|
||||
|
||||
Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded.
|
||||
Names show for entries committed by their own user (or by a maintainer with a `consent_source`).
|
||||
Delete your entry to remove your names. Git author names and emails are never recorded.
|
||||
|
||||
@@ -32,6 +32,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe COMMAND` documents one) |
|
||||
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason |
|
||||
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
|
||||
| `slide.actions` | `{index?}` | Actions on one slide; returns `{index, slideId, actions}`. Each action has an `owner` (shape id/name and group path; text actions also include paragraph/run indexes and run text, plus table row/cell indexes for table text), `event` (`click`, `hover`, `textLink`, or `textHover`), action `type`, `target`, and the full `hyperlink`. Listing is read-only and never executes an action. An empty slide returns `"actions": []`. |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`, `custom`, `goto`; `cells` while See All Slides is open), documents, perf (fps, render ms) |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, `animPreview` (canvas playback: slide, time, duration), documents, perf (fps, render ms) |
|
||||
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
|
||||
|
||||
+10
-10
@@ -1,8 +1,6 @@
|
||||
# Where DeckCraft falls short of PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-28 · **Change:** trivial (AutoFormat lists run independently of Replace text as you type; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **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: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** minor (docs refresh against merged PRs: #67 and #77 landed, #75 closed unmerged, #53 closed, Windows image paste is #1414; one header line; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-30 · **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)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -95,8 +93,7 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
outside this repo.
|
||||
- **Impact:** the main reason to switch is "open my PowerPoint decks". Fidelity on real decks
|
||||
(corporate templates, SmartArt, `chartex` charts, embedded fonts, EMF) is still mostly unknown.
|
||||
- **Estimate:** 20–35 h: grow the owned corpus in `storytold/photocraft-corpus` style (craftrules
|
||||
`standards/test-corpora.md`), add render checks, have the owner open outputs in PowerPoint, fix
|
||||
- **Estimate:** 20–35 h: grow the owned corpus in `storytold/photocraft-corpus` style, add render checks, have the owner open outputs in PowerPoint, fix
|
||||
what breaks. **Beta blocker.**
|
||||
|
||||
### Round trip loses media playback options (#74)
|
||||
@@ -219,11 +216,12 @@ and starts at that effect. The pane has an advanced timeline with draggable bars
|
||||
found by running three classroom game decks (Jeopardy-style boards, hide-and-reveal, score
|
||||
counters): a click on a shape whose action is "play sound" (`ppaction://media`, 126 and 486
|
||||
shapes in two decks) does nothing; triggers that start on `onStopAudio` or `begin` conditions are
|
||||
not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count) and do
|
||||
not follow a shape that a motion path has moved. Trigger sequences now keep their separate
|
||||
not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count).
|
||||
Slide-show click targets now follow animated translations and scales, including text links.
|
||||
Trigger sequences now keep their separate
|
||||
click steps on PPTX save, including paragraph builds (`crates/pptx/tests/it/trigger_steps.rs`).
|
||||
- **Impact:** games built around sound effects on buttons, audio-triggered effects or moving
|
||||
clickable shapes do not play as authored.
|
||||
- **Impact:** games built around sound effects on buttons or audio-triggered effects do not play
|
||||
as authored.
|
||||
- **Estimate:** 4–8 h.
|
||||
|
||||
### Notes and Handout masters
|
||||
@@ -305,7 +303,7 @@ Detail in [ui-parity.md](ui-parity.md).
|
||||
choice, video/GIF/CD exports or document protection. 4–8 h for the parts that need no cloud.
|
||||
- **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark).
|
||||
2–4 h.
|
||||
- **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules.
|
||||
- **Icon consistency across Craft apps** (#73, #56). 2–4 h.
|
||||
- **Dialogs**: 42 dialogs vs PowerPoint's ~60. In since the d556ff9 count of 27: Font, Paragraph
|
||||
and Tabs, Columns, Bullets and Numbering, Find and Replace, Replace Fonts, Action Settings, Size and
|
||||
Position, Date and Time, Photo Album (Edit Photo Album reopens it), Create New Theme Colors and
|
||||
@@ -370,6 +368,8 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
|
||||
| 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added |
|
||||
| 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated |
|
||||
| 2026-10-27 | minor | Refreshed against merged PRs: removed the Morph alignment entry (#67 merged 2026-10-14); #77 (animation rewind, PR #83) is fixed, leaving #74 in the round-trip gap; #75 was closed unmerged, so #60 stays open with no fix PR; #53 is closed and the image-paste gap is now Windows #1414; Reuse Slides (#1103) and Compare reject (#850) noted in the smaller rows; the duplicate header line is gone |
|
||||
| 2026-10-25 | trivial | New entries: copying text and cell ranges (no media, no table), a control-server timing test that may fail under load; Proofing notes that automatic lists follow Replace text as you type and that Backspace puts the typed lead back |
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ files it was given rather than guarding against other software on the computer.
|
||||
| `screenshot` | The whole window, UI included (connected mode) |
|
||||
| `call_app` | Any [control protocol](control-protocol.md) method (connected mode) |
|
||||
|
||||
A tool refuses an argument that is not in its schema (`insert_chart: unknown argument "data" (expected: categories, series, title, type)`). Keys inside `run_command`'s `params` are that command's parameters.
|
||||
A tool refuses an argument that is not in its schema (`insert_chart: unknown argument "data" (expected: categories, h, series, title, type, w, x, y)`). Keys inside `run_command`'s `params` are that command's parameters.
|
||||
|
||||
Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset,
|
||||
rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`,
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
> **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
> **Last updated:** 2026-11-01 · Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged.
|
||||
|
||||
The summary between `<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 84%** over 191 features.
|
||||
|
||||
@@ -118,7 +120,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Shapes | Text warp transforms | P2 | P |
|
||||
| Shapes | Shape quick styles | P1 | D |
|
||||
| Shapes | Set as default shape | P2 | D |
|
||||
| Format | Solid fill and transparency | P0 | D |
|
||||
| Format | Solid fill and transparency (No Fill interiors pass clicks through, #1621) | P0 | D |
|
||||
| Format | Gradient fill (linear, radial, rectangular, path) | P0 | D |
|
||||
| Format | Picture and texture fill | P1 | P |
|
||||
| Format | Pattern fill | P2 | P |
|
||||
@@ -233,3 +235,9 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Platforms (✱) | macOS, Windows, Linux builds | P0 | P |
|
||||
| Platforms (✱) | Web (WASM) build | P1 | D |
|
||||
| Platforms (✱) | BSD build | P2 | P |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged |
|
||||
|
||||
+4
-6
@@ -8,8 +8,7 @@ Forward-looking: where DeckCraft is going and in what order. The one-page summar
|
||||
|
||||
## Alpha gate: PowerPoint's core workflows
|
||||
|
||||
What a typical PowerPoint user does every day, checked end to end on macOS, the main platform
|
||||
(craftrules `standards/progress-docs.md`, "The core-workflow gate"). Every workflow passes, so
|
||||
What a typical PowerPoint user does every day, checked end to end on macOS, the main platform. Every workflow passes, so
|
||||
DeckCraft stays **alpha**. The "partial" rows don't block the workflow, but they are its weakest
|
||||
points and lead the beta plan.
|
||||
|
||||
@@ -38,8 +37,7 @@ points and lead the beta plan.
|
||||
|
||||
## Plan to beta (~120–210 h; ~40–70 h wall clock with 3–4 agents)
|
||||
|
||||
Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx` reliably
|
||||
(craftrules `standards/progress-docs.md`). Ranked:
|
||||
Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx` reliably. Ranked:
|
||||
|
||||
| # | Work | Hours | Moves |
|
||||
|---|---|---|---|
|
||||
@@ -94,7 +92,7 @@ From the local execution plan (`plan/execution-plan.md`, gitignored). Status as
|
||||
|
||||
## History: the 2026-10-07 alpha checklist
|
||||
|
||||
Before the craftrules stages existed, DeckCraft defined "alpha" as: install a signed build on
|
||||
Before the stages existed, DeckCraft defined "alpha" as: install a signed build on
|
||||
macOS, Windows or Linux, make a real deck from scratch or from a PowerPoint file, present it with
|
||||
transitions, animations and media, save back to `.pptx`/PDF without losing work, and hit no crashes
|
||||
on common paths. On 2026-10-07 it was ~80% there with these blockers:
|
||||
@@ -108,7 +106,7 @@ on common paths. On 2026-10-07 it was ~80% there with these blockers:
|
||||
| UI fidelity pass on the most-used ribbon groups and dialogs; first run | Partly (start-screen fixes #20, #49); beta item 11 |
|
||||
| Mascot app icon, README screenshots, user docs | App icon (with a scalable SVG, #29) and README screenshots in place; user docs open |
|
||||
|
||||
By the standard's definitions DeckCraft is **alpha** (~55% ready); these blockers now feed the beta
|
||||
DeckCraft is **alpha** (~55% ready); these blockers now feed the beta
|
||||
plan above.
|
||||
|
||||
## Revision history
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# DeckCraft vs Microsoft PowerPoint: parity assessment
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (Animations shortfall: preview from the selected effect is in; previous: major, community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; 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 (no break after `?`, `!`, `}`, `+` inside a word; previous: trivial, Animations shortfall: preview from the selected effect is in; before that: major, community PR wave 2026-10-14: breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) after `cargo xtask parity`; area table and What works today folded to one row or bullet each; 320 engine commands; ready ~55% and stage alpha unchanged; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
The authoritative answer to "how close is DeckCraft to PowerPoint, and how much work is left?".
|
||||
[ROADMAP.md](../ROADMAP.md) summarizes it; [gaps.md](gaps.md) itemizes every shortfall;
|
||||
@@ -16,7 +16,7 @@ number.
|
||||
| **Ready for real work** | **~55%** | **estimated**: weighted dimension table below (was ~56% until the features row was recomputed from written area weights) |
|
||||
| **Mainstream practitioner** | **~53%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) |
|
||||
| **Essentials user** | **~62%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) |
|
||||
| Stage | **alpha** (beta is ~20 points and ~120–210 h away) | judgement against craftrules `standards/progress-docs.md`: ~55% is in the alpha band and all six core workflows pass the [alpha gate](roadmap.md#alpha-gate-powerpoints-core-workflows) |
|
||||
| Stage | **alpha** (beta is ~20 points and ~120–210 h away) | judgement: ~55% is in the alpha band and all six core workflows pass the [alpha gate](roadmap.md#alpha-gate-powerpoints-core-workflows) |
|
||||
| Remaining effort to beta | **~120–210 Opus 5.5 agent hours** | estimated, itemized in [roadmap.md](roadmap.md) |
|
||||
| Remaining effort to full parity | **~365–650 Opus 5.5 agent hours** | estimated, by dimension below |
|
||||
|
||||
@@ -85,8 +85,7 @@ multi-display rigs).
|
||||
|
||||
## Mainstream practitioner and essentials user
|
||||
|
||||
Two narrower readings of "ready", computed the same way in every Craft app (craftrules
|
||||
`standards/progress-docs.md`). The stage still follows the full number above and the alpha gate.
|
||||
Two narrower readings of "ready", computed the same way in every Craft app. The stage still follows the full number above and the alpha gate.
|
||||
|
||||
### Mainstream practitioner: ~53%
|
||||
|
||||
@@ -290,6 +289,7 @@ scored done (16 open issues).
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | trivial | Animations shortfall: preview from the selected effect (Play From) is in. Percentages unchanged |
|
||||
| 2026-10-17 | trivial | A word no longer breaks after `?`, `!`, `}` or `+` (a URL's query wraps at the edge as in PowerPoint 2016 for Windows), as it already didn't after `/` and `\`. Headline percentages unchanged |
|
||||
| 2026-10-16 | trivial | Arrange shortfall: Align to Slide and Align Selected Objects are in the Align menu. Percentages unchanged |
|
||||
| 2026-10-14 | minor | Community PR wave folded: one headline breadth row (84% over 191 features after `cargo xtask parity`; duplicate checklist rows removed), one row per feature area with the latest breadth and shortfalls, one Themes / Slide show / Views / Review / Automation bullet each (320 engine commands). SmartArt ready ~35% (Text Pane, 6e40335) moves the area mean to 62.8%; dimension and headline percentages unchanged |
|
||||
| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed to adjust handles: the Transform gallery applies a preset |
|
||||
|
||||
+9
-5
@@ -1,6 +1,7 @@
|
||||
# UI parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (Picture Format: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **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-26 · **Change:** trivial (dragging several selected slides moves them all; previous: trivial, Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (group fill and outline edits reach members, #1079; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (Edit Points: Shift, Ctrl/Cmd or Alt/Option on a handle drag sets the point type; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: major, community PR wave 2026-10-14: 14 new dialogs (41 in all), Eyedropper, Export button, New Window, Grid Options, WordArt gallery and handles, Slide Sorter sections, Quick Access Toolbar, Format Background and Format Shape depth, rich-text clipboard; duplicated rows and headers folded; previous: minor, File tab / Backstage, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -28,7 +29,7 @@ ends and are omitted from thumbnails, slide shows and exports.
|
||||
| Colour menus: theme and standard colours, hex field, Eyedropper | yes (plus a More Colors dialog) | yes: theme and standard colours, hex field and an Eyedropper that takes the colour drawn under the pointer on the slide (#367) | More Colors dialog; Eyedropper only takes colours from the slide, not the rest of the screen |
|
||||
| Contextual tabs: Shape Format, Picture Format, Table Design, Table Layout, Chart Design, Chart Format, Video/Audio Format and Playback | yes | most; checklist P0 row partial | Chart Format, media tabs thinner |
|
||||
| File tab (Backstage): Home, New, Open, Info, Save, Save As, Print, Share, Export, Close, Account, Options | yes | yes (#76), see [File tab](#file-tab-backstage); no Share (cloud) | Video/GIF/CD exports, templates beyond the built-in themes |
|
||||
| Start screen: new, themes, templates, recent, open | yes, the same page as File › Home | yes: File › Home with Home, New, Open, Account and Options (#76) | |
|
||||
| Start screen: new, themes, templates, recent, open | yes, the same page as File › Home | yes: File › Home with Home, New, Open, About and Options (#76) | |
|
||||
| Status bar: slide counter, language, notes, comments, views, zoom slider | yes | yes | |
|
||||
| Command search (Tell Me) | yes | command palette | |
|
||||
| Light / dark appearance | follows the system | light or dark; doesn't follow the system (#41) | 1–2 h |
|
||||
@@ -45,7 +46,8 @@ Close and, at the bottom under a rule, Account and Options. Entries are 40 point
|
||||
outline icon; the current page is drawn in the accent colour and outlined. Esc goes back. Alt+F opens
|
||||
on Home with the keyboard in the side menu; Up and Down move through it and a page shows as soon as
|
||||
its entry has the focus. PowerPoint's start screen is the same Home page, with only Home, New and
|
||||
Open at the top. DeckCraft follows all of this (`crates/ui-egui/src/backstage.rs`), with the page and
|
||||
Open at the top. DeckCraft follows all of this (`crates/ui-egui/src/backstage.rs`), except that
|
||||
Account is About, with the page and
|
||||
menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
|
||||
| Page | PowerPoint | DeckCraft |
|
||||
@@ -60,7 +62,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Share | Cloud sharing | Not offered |
|
||||
| Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type (PNG with an optional transparent background, #784). No video, GIF or CD package |
|
||||
| Close | Closes; asks Save / Don't Save / Cancel about unsaved changes | Same (`file.close` after the prompt); Ctrl+W asks too |
|
||||
| Account | User information, sign-in, Office Background and Office Theme, product information, About | Appearance (light/dark, `view.dark`), product information, About DeckCraft. No accounts to sign in to |
|
||||
| Account | User information, sign-in, Office Background and Office Theme, product information, About | About: product information, project links, contributor/model credits and appearance (light/dark, `view.dark`). No Account entry |
|
||||
| Options | Opens the PowerPoint Options dialog | Opens Preferences |
|
||||
|
||||
## Panes and dialogs
|
||||
@@ -81,7 +83,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
|
||||
| Behaviour | PowerPoint | DeckCraft |
|
||||
|---|---|---|
|
||||
| Selection handles, rotation handle, yellow adjustment handles | yes | yes |
|
||||
| Selection handles, rotation handle, yellow adjustment handles | yes | yes; an empty No Fill shape passes interior clicks to underlying objects while its outline and handles remain selectable. Explicit, style and theme fills resolve as rendered; tests also cover locked outline targets and empty interiors without an outline (#1621) |
|
||||
| Marquee, Shift/Cmd multi-select, Tab to cycle | yes | yes |
|
||||
| Objects off the slide: drawn on the area around it in Normal view (full strength, master graphics too), selectable and movable there; clipped in the show, exports and thumbnails | yes | yes (#270) |
|
||||
| Scroll bars on the slide pane when the zoomed slide is larger than the window | yes | yes, on each axis that overflows: drag the thumb or click the track; the wheel and trackpad still pan. The vertical bar does not turn the page (#1157) |
|
||||
@@ -171,6 +173,8 @@ consistency is open (#73, #56).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
|
||||
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
|
||||
| 2026-10-29 | trivial | Picture Format: the Picture Styles row shows as many styles as fit beside the groups after it (at least two) and a More arrow with all 24, as the Transitions and Animations galleries do; Crop and Size are in view in the default window (#1232) |
|
||||
| 2026-10-26 | trivial | Thumbnail pane and Slide Sorter: dragging one of several selected slides moves the whole selection |
|
||||
| 2026-10-24 | trivial | Table cells: dragging across cells selects a range of cells |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user