Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor a3fbf02952 Merge pull request #1592 from aiongg/docs-tidy
docs: tidy agent, contributor and roadmap docs
2026-10-31 01:06:52 -04:00
Dylan Taylor 92bf7fc67d fix(xtask): update SHARED_REV for craft-libs update 2026-10-31 01:05:27 -04:00
Dylan M. Taylor 0ed4b394ac Merge pull request #1599 from aiongg/update-result-dialog
Updates: shared check-result dialog from craft-update
2026-10-31 01:05:19 -04:00
Brandon Thomas f7a7fd7f5f Merge pull request #1584
Animations: animation.add and animation.set take the points of a custom motion path
2026-10-31 00:59:38 -04:00
Brandon ThomasandClaude Opus 5.5 800ffeaa59 Motion paths: check raw path strings without deckcraft-anim, which the engine only has as a dev-dependency
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:59:38 -04:00
Brandon Thomas d080a23dc0 Merge pull request #1584
Animations: animation.add and animation.set take the points of a custom motion path
2026-10-31 00:56:36 -04:00
Brandon Thomas 243c1a269f Merge pull request #1582
Ogg: bound the work and the warnings of damage recovery
2026-10-31 00:56:35 -04:00
Brandon Thomas 06a1ca4672 Merge pull request #1521
Slideshow click targets follow animated shapes
2026-10-31 00:56:35 -04:00
Brandon Thomas 40728b5362 Merge pull request #1591
Preserve gradient flip mode in PPTX

# Conflicts:
#	crates/pptx/src/read/dml.rs
#	crates/pptx/src/write/dml.rs
#	crates/pptx/tests/it/roundtrip.rs
2026-10-31 00:56:34 -04:00
Brandon ThomasandClaude Opus 5.5 d2504e5f8b Motion paths: a raw path string must parse to at least two finite points
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:56:17 -04:00
Brandon Thomas d92874f541 Merge pull request #1574
Add read-only slide action inspection
2026-10-31 00:56:17 -04:00
Brandon ThomasandClaude Opus 5.5 6a34f13808 Ogg: the budget-spent warning goes last, after the capped warnings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:54:52 -04:00
Brandon ThomasandClaude Opus 5.5 6058fc7882 gaps.md: drop the stray 10-28 click-target row from among the 10-13 rows
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:54:38 -04:00
Brandon ThomasandClaude Opus 5.5 04b0958bee Gradient flip: drop the blank lines before the new flip fields
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:54:16 -04:00
Brandon ThomasandClaude Opus 5.5 acf054852c slide.actions refuses a bad index instead of using the selected slide
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:53:14 -04:00
Brandon Thomas 861879f8ee Merge pull request #1519
Use private directories for print previews
2026-10-31 00:51:49 -04:00
Brandon Thomas e626625d40 Merge pull request #1514
Reject picture slices in codec configuration records
2026-10-31 00:51:48 -04:00
Brandon Thomas 4a0f48a04c Merge pull request #1590
Preserve inset line alignment in PPTX

# Conflicts:
#	crates/model/src/style.rs
#	crates/pptx/tests/it/roundtrip.rs
2026-10-31 00:51:48 -04:00
Brandon Thomas 8f6e1b420a Merge pull request #1589
Preserve bullet Follow Text settings

# Conflicts:
#	crates/pptx/tests/it/roundtrip.rs
2026-10-31 00:51:37 -04:00
Brandon Thomas c0db6ebeba Merge pull request #1585
Preserve explicit line miter limits
2026-10-31 00:51:37 -04:00
Brandon Thomas a667e13a03 Merge pull request #1594
Parse uppercase exponent in custom geometry path numbers
2026-10-31 00:51:36 -04:00
Brandon ThomasandClaude Opus 5.5 3bf35c801f Custom geometry: 'E' is an exponent only when digits follow, so 'M 0 0E' keeps its end command
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 00:51:36 -04:00
Brandon Thomas 47eb6732fa Merge pull request #1581
contributors: add names for marshiyar
2026-10-31 00:49:38 -04:00
Brandon Thomas 8a2eccc863 Merge pull request #1583
MCP: insert_chart takes x, y, w and h
2026-10-31 00:49:38 -04:00
Brandon Thomas fb05eaa30f Merge pull request #1490
Make layout/master placeholder formatting reach slides

# Conflicts:
#	crates/engine/src/tests.rs
2026-10-31 00:49:37 -04:00
Brandon Thomas 231a52438c Merge pull request #1587
Reject invalid colour in format.highlight instead of clearing it
2026-10-31 00:49:36 -04:00
Brandon Thomas a00b7e8a51 Merge pull request #1577
Reject invalid index in transition commands instead of using selection
2026-10-31 00:49:36 -04:00
aiongg 9678ed7d7f updates: show check results with craft-update's CheckResultDialog 2026-10-31 04:42:16 +00:00
NetterSilverandClaude Haiku 5.5 1de7b44a8d Parse uppercase exponent in custom geometry path numbers
Found while testing crates/pptx/src/custgeom.rs:334

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 05:33:56 +01:00
Harrison Booth 5a235590dd Move gradient editor test module to file end 2026-10-30 23:26:55 -05:00
aiongg a96dcdbe8f docs: tidy wording 2026-10-31 04:25:45 +00:00
Harrison Booth 0e3f4cd353 Preserve explicit bullets over follow-text resets 2026-10-30 23:20:43 -05:00
Harrison Booth 72c8621358 Keep gradient flip when editing a fill 2026-10-30 23:17:32 -05:00
aiongg cccc9064ca docs: tidy agent, contributor and roadmap docs 2026-10-31 04:17:24 +00:00
Harrison Booth 1de0101ced Honor miter percentage schema bounds 2026-10-30 23:11:12 -05:00
Harrison Booth f5c2aca644 Preserve bullet follow-text inheritance 2026-10-30 23:05:16 -05:00
Harrison Booth 4408ff6153 Restrict line alignment to schema values 2026-10-30 23:00:20 -05:00
Harrison Booth ad97533aec Preserve gradient flip mode in PPTX 2026-10-30 22:57:14 -05:00
Harrison Booth 727b682d17 Preserve inset line alignment in PPTX 2026-10-30 22:48:31 -05:00
Harrison Booth 8770fb90c3 Preserve bullet follow-text resets in PPTX 2026-10-30 22:45:55 -05:00
NetterSilverandClaude Haiku 5.5 9a716be59e Reject invalid colour in format.highlight instead of clearing it
Found while testing crates/engine/src/cmd/format/font.rs:228-231 (highlight), color_param at crates/engine/src/cmd.rs:381

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 04:41:09 +01:00
Harrison Booth 479c3cc5ea Preserve explicit miter limits in PPTX 2026-10-30 22:24:08 -05:00
Brandon ThomasandClaude Opus 5.5 4ce2944630 Release 0.13.1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:21:58 -04:00
Brandon ThomasandClaude Opus 5.5 255df314da FreeBSD packaging: compare ELF header bytes without od's column padding
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:21:55 -04:00
marshiyar b7482dd392 engine: animation.add and animation.set take the points of a custom motion path
What was wrong: animation.add ignored a `path` parameter, so a customPath
animation always got the default straight path of a quarter of the slide
height, and no command could set or change the points of one.

What changed: animation.add, animation.set and animation.options accept `path`
for a motion path: either points [[x, y], ...] in slide points, as offsets from
where the shape sits (converted to the stored slide-fraction path; `closed`
returns to the first point), or the OOXML path string animation.get answers.
At most 1000 points, finite numbers within 1e6 points, and strings of at most
16 KiB made of path commands and numbers are accepted; anything else is
refused before the deck changes, as is `path` on an animation that is not a
motion path. animation.add answers the stored path.

How it was tested: three new tests in cmd/animation.rs: points become the
expected path (add, set with `closed`, back to the default without `path`), a
raw string is kept and animation.options replaces it, and malformed paths are
refused with nothing added. The animation tests pass, and clippy is clean with
-D warnings.

Fixes #1576
2026-10-30 20:13:05 -07:00
marshiyar e83f964077 mcp: insert_chart takes x, y, w and h
What was wrong: the insert_chart tool had no x, y, w or h in its schema, so the
server refused them as unknown arguments, while insert_picture and insert_table
take them. A chart could only be placed at the default box, [144, 81, 672, 378]
on a 960x540 slide, and then had to be moved with a second command.

What changed: insert_chart declares x, y, w and h and folds them into the
engine's `rect` when all four are given, as insert_picture and insert_table do.
Without all four the chart keeps its default place.

How it was tested: insert_chart_takes_a_box failed before the change (the
arguments were refused) and passes after it. It checks the box of the placed
chart, that a partial box leaves the default place, and that an extreme box is
answered without a panic. The rest of the deckcraft-mcp tests pass, and clippy
is clean with -D warnings.

Fixes #1151
2026-10-30 20:13:00 -07:00
Brandon ThomasandClaude Opus 5.5 eaeb06c655 Release 0.13.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 22:59:19 -04:00
marshiyar ede7a7ead7 ogg: bound damage recovery work and the warnings list
What was wrong: when a page failed its CRC the demuxer had already read the
whole page (up to about 64 KiB), then moved on by one byte and tried again.
Nothing was charged for it, and only the scan for the next capture pattern had
a budget, which a candidate that matches "OggS" at the current position never
reaches. A file made of capture patterns 64 bytes apart, each claiming a full
page, made a 1 MB file read about 957 MB and compute a CRC over all of it. The
warnings list also grew with every damaged page, without limit.

What changed: the per-file recovery budget (32 MiB, the scan's existing one) is
now also charged with the length of every page read only to find its CRC wrong.
When it is spent the pages found so far are returned with a warning saying the
rest was not searched. At most 256 warnings are kept, plus one saying the rest
were dropped.

How it was tested: two new tests failed before the fix and pass after it, a
counting ByteSource over 1 MB of dense fake pages (bytes read stay within the
budget; before, 957 MB were read) and 100,000 bad-CRC pages (before, 199,999
warnings). A file of good pages is not charged and has no warnings. The other
ogg tests still pass, and clippy is clean with -D warnings.

Completes the Ogg part of storytold/filmcraft#2794
2026-10-30 18:27:08 -07:00
marshiyar 6113f4e1a2 contributors: add names for marshiyar 2026-10-30 17:57:10 -07:00
Dylan M. Taylor 4c1d77ddce fix(flatpak): allow reading launcher version pins (#1575)
* fix(flatpak): allow reading launcher version pins

* fix(flatpak): allow reading launcher version pins
2026-10-30 20:12:24 -04:00
NetterSilverandClaude Haiku 5.5 443f503407 Reject invalid index in transition commands instead of using selection
Found while testing crates/engine/src/cmd/transition.rs:47-54 (slide_indices, via usize_param)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 23:16:32 +00:00
Harrison Booth ee4bdbcd7d fix: initialize slide action test run props 2026-10-30 17:23:00 -05:00
NetterSilverandClaude Sonnet 5.5 adaa4e6fa1 fix: keep per-level formatting when formatting layout/master placeholders
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 23:22:33 +01:00
NetterSilverandClaude Opus 5.5 446bc528ad Make layout/master placeholder formatting reach slides
Fixes #1487

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 23:17:36 +01:00
Harrison Booth 3c068df628 test(h264): complete picture slice fixture 2026-10-30 17:13:38 -05:00
Harrison Booth f8165751e5 Merge origin/main into dc-slide-actions-list 2026-10-30 17:12:14 -05:00
Harrison Booth 7190c85860 test(h264): avoid singleton fixture loop 2026-10-30 17:06:36 -05:00
Harrison Booth ddd39249ba test(codec): strengthen configuration slice regressions 2026-10-30 17:03:36 -05:00
Harrison Booth b4c1905d78 dc-fix-1519: address print preview review 2026-10-30 16:51:34 -05:00
Harrison Booth 2c11865b2d codec: accept hvcC SEI arrays without allocation 2026-10-30 16:49:21 -05:00
Harrison Booth b92a7ddbd5 Merge remote-tracking branch 'origin/main' into dc-fix-1514 2026-10-30 16:37:40 -05:00
Harrison Booth 0768e95591 Print preview: merge origin/main 2026-10-30 16:36:30 -05:00
Harrison Booth ef6fd29928 DC-1521: hit-test each shape animation independently 2026-10-30 16:00:43 -05:00
Harrison Booth a3449185ac DC-1521: merge origin/main 2026-10-30 15:59:33 -05:00
Harrison Booth 90c8f65f5e Merge remote-tracking branch 'origin/main' into dc-slide-actions-list 2026-10-30 14:01:06 -05:00
Harrison Booth b082f12f70 #129: inspect slide actions 2026-10-30 14:01:04 -05:00
Harrison Booth 14eef3aceb dc-show-click-targets: follow animated show click targets 2026-10-28 16:09:41 -05:00
Harrison Booth 33b6e11726 Use private directories for print previews 2026-10-28 15:48:37 -05:00
Harrison Booth 8c2560f31c codec: validate configuration NAL types 2026-10-28 15:06:23 -05:00
57 changed files with 1691 additions and 251 deletions
+3 -12
View File
@@ -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 PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCraft
(`../<app>`). (`../<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, 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 instruction or deadline. `CLAUDE.md` holds day-to-day working notes; if the two disagree, **this
file wins.** 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. or `docs/brand/` has no row.
- **`NOTICE`** keeps the notices the licences require. - **`NOTICE`** keeps the notices the licences require.
- Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this - 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 `fonts/manifest.txt` when built with `CRAFT_FONTS_DIR` (local dev: `.cargo/config.toml` points at
`../craft-fonts`). `../craft-fonts`).
@@ -99,7 +94,6 @@ once and tell the user.
## 3. Never crash ## 3. Never crash
People trust DeckCraft with their talks; a crash loses work. **This outranks feature work.** 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 - No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in non-test
code; no `unsafe` (`unsafe_code = "forbid"`). Every production crate root carries 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)]`. `#![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`, `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 `STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
docs follow craftrules `standards/progress-docs.md`: docs:
| File | Holds | | 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 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 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 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 (copy the template at the top of the file)
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
and include it in their PR, committed as them. If no, change nothing: they are credited as and include it in their PR, committed as them. If no, change nothing: they are credited as
`@username` anyway. `@username` anyway.
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or - 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`. 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 ## 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. - 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
View File
@@ -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-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.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/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 | | `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.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") | | `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") |
+1 -2
View File
@@ -9,8 +9,7 @@ before modules or other crate items; crate-level `//!` documentation may precede
## Start every session ## Start every session
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`. 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. 2. Run `cargo xtask ci` before you change anything, so you know the tree is green.
3. Run `cargo xtask ci` before you change anything, so you know the tree is green.
## Habits ## Habits
- Every user-visible action is a command with tests (`crates/engine/src/cmd/*`). - Every user-visible action is a command with tests (`crates/engine/src/cmd/*`).
Generated
+35 -35
View File
@@ -823,12 +823,12 @@ dependencies = [
[[package]] [[package]]
name = "craft-fonts-build" name = "craft-fonts-build"
version = "0.1.0" 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]] [[package]]
name = "craft-i18n" name = "craft-i18n"
version = "0.1.0" 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 = [ dependencies = [
"fixed_decimal", "fixed_decimal",
"icu_decimal", "icu_decimal",
@@ -851,7 +851,7 @@ dependencies = [
[[package]] [[package]]
name = "craft-media" name = "craft-media"
version = "0.1.0" 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 = [ dependencies = [
"thiserror 2.0.21", "thiserror 2.0.21",
] ]
@@ -859,7 +859,7 @@ dependencies = [
[[package]] [[package]]
name = "craft-pdf" name = "craft-pdf"
version = "0.1.0" 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 = [ dependencies = [
"krilla", "krilla",
"thiserror 2.0.21", "thiserror 2.0.21",
@@ -868,7 +868,7 @@ dependencies = [
[[package]] [[package]]
name = "craft-ui" name = "craft-ui"
version = "0.1.0" 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 = [ dependencies = [
"craft-fonts-build", "craft-fonts-build",
"craft-i18n", "craft-i18n",
@@ -888,7 +888,7 @@ dependencies = [
[[package]] [[package]]
name = "craft-update" name = "craft-update"
version = "0.1.0" 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 = [ dependencies = [
"chrono", "chrono",
"dunce", "dunce",
@@ -984,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]] [[package]]
name = "deckcraft" name = "deckcraft"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -1006,7 +1006,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-anim" name = "deckcraft-anim"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-geom", "deckcraft-geom",
"deckcraft-model", "deckcraft-model",
@@ -1015,7 +1015,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-av1" name = "deckcraft-av1"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-bitstream", "deckcraft-bitstream",
"rayon", "rayon",
@@ -1024,14 +1024,14 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-bitstream" name = "deckcraft-bitstream"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"craft-media", "craft-media",
] ]
[[package]] [[package]]
name = "deckcraft-cli" name = "deckcraft-cli"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-engine", "deckcraft-engine",
"deckcraft-mcp", "deckcraft-mcp",
@@ -1041,14 +1041,14 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-color" name = "deckcraft-color"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"serde", "serde",
] ]
[[package]] [[package]]
name = "deckcraft-engine" name = "deckcraft-engine"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"deckcraft-anim", "deckcraft-anim",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-fonts" name = "deckcraft-fonts"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"epaint_default_fonts", "epaint_default_fonts",
"harfrust 0.13.3", "harfrust 0.13.3",
@@ -1086,7 +1086,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-format" name = "deckcraft-format"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-model", "deckcraft-model",
"serde", "serde",
@@ -1097,7 +1097,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-geom" name = "deckcraft-geom"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"kurbo", "kurbo",
"serde", "serde",
@@ -1105,7 +1105,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-h264" name = "deckcraft-h264"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-bitstream", "deckcraft-bitstream",
"rayon", "rayon",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-hevc" name = "deckcraft-hevc"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-bitstream", "deckcraft-bitstream",
"rayon", "rayon",
@@ -1123,21 +1123,21 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-isobmff" name = "deckcraft-isobmff"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"craft-media", "craft-media",
] ]
[[package]] [[package]]
name = "deckcraft-matroska" name = "deckcraft-matroska"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"craft-media", "craft-media",
] ]
[[package]] [[package]]
name = "deckcraft-mcp" name = "deckcraft-mcp"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-engine", "deckcraft-engine",
"deckcraft-render", "deckcraft-render",
@@ -1147,7 +1147,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-media" name = "deckcraft-media"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-av1", "deckcraft-av1",
"deckcraft-h264", "deckcraft-h264",
@@ -1165,7 +1165,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-metafile" name = "deckcraft-metafile"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"image", "image",
"kurbo", "kurbo",
@@ -1174,7 +1174,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-model" name = "deckcraft-model"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-color", "deckcraft-color",
"deckcraft-geom", "deckcraft-geom",
@@ -1186,15 +1186,15 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-ogg" name = "deckcraft-ogg"
version = "0.12.0" version = "0.13.1"
[[package]] [[package]]
name = "deckcraft-opus" name = "deckcraft-opus"
version = "0.12.0" version = "0.13.1"
[[package]] [[package]]
name = "deckcraft-pdf" name = "deckcraft-pdf"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"craft-pdf", "craft-pdf",
"deckcraft-fonts", "deckcraft-fonts",
@@ -1212,7 +1212,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-pptx" name = "deckcraft-pptx"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-color", "deckcraft-color",
"deckcraft-engine", "deckcraft-engine",
@@ -1228,7 +1228,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-render" name = "deckcraft-render"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-anim", "deckcraft-anim",
"deckcraft-color", "deckcraft-color",
@@ -1247,7 +1247,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-text" name = "deckcraft-text"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"chrono", "chrono",
"deckcraft-color", "deckcraft-color",
@@ -1261,7 +1261,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-ui-egui" name = "deckcraft-ui-egui"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"craft-ui", "craft-ui",
"craft-update", "craft-update",
@@ -1286,7 +1286,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-vp9" name = "deckcraft-vp9"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-bitstream", "deckcraft-bitstream",
"rayon", "rayon",
@@ -1295,7 +1295,7 @@ dependencies = [
[[package]] [[package]]
name = "deckcraft-web" name = "deckcraft-web"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"deckcraft-engine", "deckcraft-engine",
"deckcraft-ui-egui", "deckcraft-ui-egui",
@@ -2708,7 +2708,7 @@ dependencies = [
[[package]] [[package]]
name = "krilla" name = "krilla"
version = "0.8.2" 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 = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bumpalo", "bumpalo",
@@ -6177,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]] [[package]]
name = "xtask" name = "xtask"
version = "0.12.0" version = "0.13.1"
dependencies = [ dependencies = [
"serde_json", "serde_json",
] ]
+6 -6
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"] default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package] [workspace.package]
version = "0.12.0" version = "0.13.1"
edition = "2024" edition = "2024"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
rust-version = "1.95" rust-version = "1.95"
@@ -59,11 +59,11 @@ harfrust = "0.13"
vello_cpu = "0.2" vello_cpu = "0.2"
vello_common = { version = "0.2", default-features = false } vello_common = { version = "0.2", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } 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-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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] } 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: 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-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 = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" } craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" }
i_overlay = "9" i_overlay = "9"
quick-xml = "0.41" quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] } zip = { version = "3", default-features = false, features = ["deflate"] }
@@ -109,7 +109,7 @@ codegen-units = 16
debug = 0 debug = 0
[patch.crates-io] [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" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+7 -2
View File
@@ -2,6 +2,8 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away **Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (print-preview tests use egui's emitted URL, and macOS removes its previews before exit; parity 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-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-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-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 +148,8 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed | | Date | What landed |
|---|---| |---|---|
| 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-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-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) |
| 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) | | 2026-10-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 +307,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 | 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-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-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-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-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 | | 2026-10-07 | v0.1.0 released, signed and notarized; About window with contributor credits (#3); PrintCraft renamed PdfCraft |
@@ -314,6 +318,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 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: 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-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
| 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged | | 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged |
@@ -413,5 +418,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 | 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 | 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 | 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 | | 2026-10-07 | major | Alpha checklist (~80% to a self-defined alpha), breadth ~80%, overall ~62%, ~190 h to full parity |
+2
View File
@@ -60,6 +60,8 @@ impl eframe::App for App {
// nothing after it is needed: the preferences are saved and the log file is unbuffered. // 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. // `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117.
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
self.0.cleanup_print_previews();
#[cfg(target_os = "macos")]
std::process::exit(0); std::process::exit(0);
} }
} }
+7 -5
View File
@@ -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 # 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 # 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. # maintainer may add a person only when that person asked, recording where in consent_source.
# #
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ # Copy this block, replacing your-github-username (every field is optional):
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
#
# Or copy this block, replacing your-github-username (every field is optional):
# #
# [people.your-github-username] # [people.your-github-username]
# real_name = "Your Name" # shown when the viewer picks "Real name" # real_name = "Your Name" # shown when the viewer picks "Real name"
@@ -71,3 +68,8 @@ real_name = "Sofia Alves Figueredo"
[people.dylanmtaylor] [people.dylanmtaylor]
sync_github_name = true sync_github_name = true
[people.marshiyar]
display_name = "Arshiya"
real_name = "Arshiya Rahgozar"
sync_github_name = false
+188 -9
View File
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Animation Styles", "Animation Styles",
["Animations", "Animation"], ["Animations", "Animation"],
None, 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, has_selection,
set set
), ),
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add Animation", "Add Animation",
["Animations", "Advanced Animation"], ["Animations", "Advanced Animation"],
None, 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, has_selection,
add add
), ),
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Effect Options", "Effect Options",
["Animations", "Animation"], ["Animations", "Animation"],
None, 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, has_slide,
options options
), ),
@@ -129,7 +129,7 @@ fn resolve_option(allowed: &[&str], given: &str) -> Option<String> {
cands.into_iter().find(|c| allowed.contains(&c.as_str())) 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 effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?;
let class = class_of(str_param(p, "class")); let class = class_of(str_param(p, "class"));
let info = match class { let info = match class {
@@ -155,7 +155,9 @@ fn build(p: &Value) -> Result<Animation> {
a.start = start_of(st); a.start = start_of(st);
} }
if a.class == AnimClass::Path { 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" { if a.effect == "spin" {
a.amount = Some(360.0); a.amount = Some(360.0);
@@ -166,6 +168,97 @@ fn build(p: &Value) -> Result<Animation> {
Ok(a) 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. /// Default motion paths, in slide-fraction units relative to the shape's position.
pub fn default_path(effect: &str, option: &str) -> String { pub fn default_path(effect: &str, option: &str) -> String {
let (dx, dy) = match option { 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> { fn set(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?; let ids = targets(s, p)?;
let a = build(p)?; let a = build(p, slide_extent(s)?)?;
s.edit(|doc, sel| { s.edit(|doc, sel| {
let sl = doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.set", "no slide"))?; let sl = doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.set", "no slide"))?;
let sl = Arc::make_mut(sl); 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> { fn add(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?; 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| { s.edit(|doc, sel| {
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.add", "no slide"))?); 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() { for (k, id) in ids.iter().enumerate() {
@@ -227,7 +327,10 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
} }
sl.animations.push(x); 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, "byLetter" => TextBuild::ByLetter,
_ => TextBuild::AsOne, _ => TextBuild::AsOne,
}); });
let path = path_param(p, slide_extent(s)?)?;
each_target(s, p, |a| { each_target(s, p, |a| {
if let (Some(path), AnimClass::Path) = (&path, a.class) {
a.path = Some(path.clone());
}
if let Some(o) = &option { if let Some(o) = &option {
// Each target takes the option in its own effect's terms; one it has no such // Each target takes the option in its own effect's terms; one it has no such
// option for keeps its own. // option for keeps its own.
let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]); let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]);
if let Some(o) = resolve_option(allowed, o) { 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.path = Some(default_path(&a.effect, &o));
} }
a.option = o; a.option = o;
@@ -606,4 +713,76 @@ mod tests {
assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1); 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 -1
View File
@@ -8,7 +8,7 @@ use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot,
use serde_json::{Value, json}; use serde_json::{Value, json};
use std::sync::Arc; 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::{ use super::{
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, color_param, edit_shapes, f64_or, f64_param, has_doc, has_text, 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, has_text_or_shapes, ids_param, ok, str_param,
+16 -5
View File
@@ -1,6 +1,6 @@
use super::{ use super::{
Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or, 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> { 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 ctx_sizes = current_run(s).size.unwrap_or(18.0);
let _ = st; let _ = st;
let base = ctx_sizes; let base = ctx_sizes;
format_run(s, &move |r| { // A layout/master level steps from its own effective size, not the first run's.
let cur = r.size.unwrap_or(base); format_run_levels(
r.size = Some(step(cur, up)); 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> { 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> { pub(super) fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
let c = color_param(p, "color"); 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()) format_run(s, &move |r| r.highlight = c.clone())
} }
+8 -1
View File
@@ -45,12 +45,17 @@ pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
format_para(s, &move |para| { format_para(s, &move |para| {
if on { if on {
para.props.bullet = Some(Bullet::Char { char: ch.clone() }); 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() { if col.is_some() {
para.props.bullet_color = col.clone(); para.props.bullet_color = col.clone();
para.props.bullet_color_follow_text = None;
} }
if size.is_some() { if size.is_some() {
para.props.bullet_size = size; 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) { if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
para.props.margin_left = Some(18.0 + 36.0 * para.level as f64); para.props.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 }); para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start });
if col.is_some() { if col.is_some() {
para.props.bullet_color = col.clone(); para.props.bullet_color = col.clone();
para.props.bullet_color_follow_text = None;
} }
if size.is_some() { if size.is_some() {
para.props.bullet_size = size; 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.margin_left = Some(27.0 + 36.0 * para.level as f64);
para.props.indent = Some(-27.0); para.props.indent = Some(-27.0);
+146 -1
View File
@@ -1,16 +1,18 @@
//! Inspection for agents: the deck, slides and shapes as JSON (verify work without screenshots). //! Inspection for agents: the deck, slides and shapes as JSON (verify work without screenshots).
use deckcraft_model::resolve::{self, Ctx}; use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::Hyperlink;
use deckcraft_model::{Shape, ShapeKind}; use deckcraft_model::{Shape, ShapeKind};
use serde_json::{Value, json}; 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}; use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> { pub fn specs() -> Vec<CommandSpec> {
vec![ vec![
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document), 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.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 "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), 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> { fn shape(s: &mut Session, p: &Value) -> Result<Value> {
let st = s.doc()?; let st = s.doc()?;
let id = let id =
@@ -154,3 +220,82 @@ fn commands(s: &mut Session, p: &Value) -> Result<Value> {
.collect(), .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}");
}
}
}
+18 -1
View File
@@ -108,7 +108,13 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
scaled: false, 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 /// 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(()) 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));
}
}
+1
View File
@@ -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 }, shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true, rotate_with_shape: true,
tile: [0.0; 4], tile: [0.0; 4],
flip: None,
})); }));
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0)); sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0));
sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() }); sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() });
+1 -1
View File
@@ -30,7 +30,7 @@ use editing::{edit, exit};
pub(crate) use editing::{insert_field, insert_text, paste_body}; pub(crate) use editing::{insert_field, insert_text, paste_body};
pub use formatting::format_range; pub use formatting::format_range;
use formatting::{fonts_used, replace_font}; 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}; pub use ime::{IME_SURROUNDING_MAX_BYTES, ImeSurrounding, cell_range_underlines, composition_underlines, ime_surrounding, range_underlines};
use ime::{commit, compose}; use ime::{commit, compose};
pub(crate) use layout::ctx_for; pub(crate) use layout::ctx_for;
+123 -9
View File
@@ -8,22 +8,34 @@ use super::{
/// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour), /// 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. /// 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> { 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()?; let st = s.doc()?;
if let Some(t) = st.selection.text.clone() { if let Some(t) = st.selection.text.clone() {
let (a, b) = format_range(st, &t); 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, _| { edit_text(s, |body, _| {
if collapsed { if levels.is_empty() {
// Next typed text: put the props on an empty paragraph's end mark or keep pending.
ed::format(body, a, b, f); ed::format(body, a, b, f);
} else { } 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(); return ok();
} }
let ids = st.selection.shapes.clone(); 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)); return Err(bad("format", SELECT_TEXT_OR_SHAPE));
} }
let cells = st.selection.cells; 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| { edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
let id = sh.id; let id = sh.id;
match &mut sh.kind { 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); 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(()) Ok(())
@@ -58,6 +98,55 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
ok() 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 /// 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 /// (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. /// 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()?; let st = s.doc()?;
if let Some(t) = st.selection.text.clone() { if let Some(t) = st.selection.text.clone() {
let (a, b) = t.ordered(); 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, _| { edit_text(s, |body, _| {
if body.paragraphs.is_empty() { if body.paragraphs.is_empty() {
body.paragraphs.push(Default::default()); 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); f(p);
} }
} }
for l in levels {
format_list_level(body, l, f);
}
})?; })?;
return ok(); 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)); return Err(bad("format", SELECT_TEXT_OR_SHAPE));
} }
let cells = st.selection.cells; let cells = st.selection.cells;
let on_master = editing_master(st);
edit_shapes(s, &json!({"ids": ids}), "format", |sh| { edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
let id = sh.id; let id = sh.id;
let inherited = on_master && sh.ph.is_some();
match &mut sh.kind { match &mut sh.kind {
ShapeKind::Table(t) => { ShapeKind::Table(t) => {
let range = crate::cmd::table::selected_range(cells, id, 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.push(Default::default());
} }
body.paragraphs.iter_mut().for_each(f); 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(()) Ok(())
@@ -157,6 +259,18 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
ok() 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. /// 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> { pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::BodyProps) + Send + Sync)) -> Result<Value> {
let st = s.doc()?; let st = s.doc()?;
+33 -7
View File
@@ -6,7 +6,7 @@ use deckcraft_model::Transition;
use deckcraft_model::anim::TRANSITIONS; use deckcraft_model::anim::TRANSITIONS;
use serde_json::{Value, json}; 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}; use crate::{Result, Session};
pub fn specs() -> Vec<CommandSpec> { 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()?; 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]); return Ok(vec![i]);
} }
let v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect(); 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}`")))?; 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 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 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, _| { s.edit(|doc, _| {
for i in list { for i in list {
if let Some(sl) = doc.slides.get_mut(i) { 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> { 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 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, _| { s.edit(|doc, _| {
for i in list { for i in list {
if let Some(sl) = doc.slides.get_mut(i) 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> { 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, _| { s.edit(|doc, _| {
for i in list { for i in list {
if let Some(sl) = doc.slides.get_mut(i) { 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. /// 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> { 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 looping = bool_param(p, "loop");
let edit_all = |s: &mut Session, f: &dyn Fn(&mut Transition)| { let edit_all = |s: &mut Session, f: &dyn Fn(&mut Transition)| {
s.edit(|doc, _| { 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> { 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())) 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 -1
View File
@@ -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 //! 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 //! 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. //! when a recovery directory is set). It is a safety net: commands still must not panic.
+108
View File
@@ -3198,6 +3198,15 @@ fn motion_path_presets_have_paths_ids_and_round_trip() {
fn numbering_takes_color_and_size() { fn numbering_takes_color_and_size() {
let mut s = Session::with_new(); let mut s = Session::with_new();
s.execute("insert.textBox", &json!({"rect": [10, 10, 300, 100], "text": "One\nTwo"})).unwrap(); 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(); s.execute("format.numbering", &json!({"on": true, "scheme": "romanUcPeriod", "start": 3, "color": "accent2", "size": 150})).unwrap();
let para = |s: &Session| { let para = |s: &Session| {
let st = s.doc().unwrap(); let st = s.doc().unwrap();
@@ -3207,9 +3216,13 @@ fn numbering_takes_color_and_size() {
let pp = para(&s); let pp = para(&s);
assert!(matches!(&pp.bullet, Some(deckcraft_model::text::Bullet::AutoNum { scheme, start_at: 3 }) if scheme == "romanUcPeriod")); 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_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))); 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(); s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap();
assert_eq!(para(&s).bullet_size, Some(4.0)); assert_eq!(para(&s).bullet_size, Some(4.0));
assert_eq!(para(&s).bullet_font_follow_text, None);
} }
#[test] #[test]
@@ -5956,3 +5969,98 @@ fn invalid_slide_index_never_falls_back_to_the_selection_in_export_commands() {
} }
let _ = std::fs::remove_dir_all(&dir); 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)]);
}
+16 -5
View File
@@ -216,26 +216,37 @@ impl Decoder {
pub fn configure_avcc(&mut self, avcc: &[u8]) -> Result<()> { pub fn configure_avcc(&mut self, avcc: &[u8]) -> Result<()> {
ensure!(avcc.len() >= 7, "avcC too short"); ensure!(avcc.len() >= 7, "avcC too short");
ensure!(avcc[0] == 1, "unsupported avcC version {}", avcc[0]); 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 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 { for _ in 0..count {
ensure!(*pos + 2 <= avcc.len(), "avcC truncated"); ensure!(*pos + 2 <= avcc.len(), "avcC truncated");
let len = u16::from_be_bytes([avcc[*pos], avcc[*pos + 1]]) as usize; let len = u16::from_be_bytes([avcc[*pos], avcc[*pos + 1]]) as usize;
*pos += 2; *pos += 2;
ensure!(*pos + len <= avcc.len(), "avcC truncated"); ensure!(*pos + len <= avcc.len(), "avcC truncated");
d.handle_nal(&avcc[*pos..*pos + len], 0)?; sets.push((*pos, len));
*pos += len; *pos += len;
} }
Ok(()) Ok(())
}; };
let nsps = (avcc[pos] & 0x1f) as usize; let nsps = (avcc[pos] & 0x1f) as usize;
pos += 1; pos += 1;
read_sets(&mut pos, nsps, self)?; read_sets(&mut pos, nsps, &mut sets)?;
ensure!(pos < avcc.len(), "avcC truncated"); ensure!(pos < avcc.len(), "avcC truncated");
let npps = avcc[pos] as usize; let npps = avcc[pos] as usize;
pos += 1; 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. /// Length of NAL length prefixes (from avcC), or None for Annex-B input.
+50
View File
@@ -82,6 +82,56 @@ fn pps(cabac: bool, weighted: bool) -> Vec<u8> {
nal(3, 8, &w.finish()) 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)] #[derive(Default, Clone)]
struct Pic { struct Pic {
idr: bool, idr: bool,
+43 -13
View File
@@ -160,22 +160,35 @@ impl Decoder {
pub fn configure_hvcc(&mut self, hvcc: &[u8]) -> Result<()> { pub fn configure_hvcc(&mut self, hvcc: &[u8]) -> Result<()> {
ensure!(hvcc.len() >= 23, "hvcC too short"); ensure!(hvcc.len() >= 23, "hvcC too short");
ensure!(hvcc[0] == 1, "unsupported hvcC version {}", hvcc[0]); 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 num_arrays = hvcc[22] as usize;
let mut pos = 23; for pass in 0..2 {
for _ in 0..num_arrays { let mut pos = 23;
ensure!(pos + 3 <= hvcc.len(), "hvcC truncated"); for _ in 0..num_arrays {
let n = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize; ensure!(pos <= hvcc.len() && hvcc.len() - pos >= 3, "hvcC truncated");
pos += 3; let array_type = hvcc[pos] & 0x3f;
for _ in 0..n { let n = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize;
ensure!(pos + 2 <= hvcc.len(), "hvcC truncated"); pos += 3;
let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize; for _ in 0..n {
pos += 2; ensure!(pos <= hvcc.len() && hvcc.len() - pos >= 2, "hvcC truncated");
ensure!(pos + len <= hvcc.len(), "hvcC truncated"); let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize;
self.handle_nal(&hvcc[pos..pos + len], 0)?; pos += 2;
pos += len; 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(()) Ok(())
} }
@@ -653,3 +666,20 @@ fn make_picture(o: &Output) -> Picture {
draft: o.meta.draft, 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());
}
}
+59
View File
@@ -644,3 +644,62 @@ fn picture_store_is_checked_against_the_memory_budget() {
assert!(!decodes_within(&stream, pictures * one - 1)); assert!(!decodes_within(&stream, pictures * one - 1));
assert!(decodes_within(&stream, deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET)); 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);
}
+34
View File
@@ -62,6 +62,40 @@ fn handshake_and_tool_list() {
assert!(s.handle_line("{}").is_some()); 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] #[test]
fn agent_builds_a_deck() { fn agent_builds_a_deck() {
let mut s = Server::new(Box::new(Headless::new())); let mut s = Server::new(Box::new(Headless::new()));
+9 -3
View File
@@ -234,9 +234,9 @@ pub fn tool_definitions() -> Vec<Value> {
tool( tool(
"insert_chart", "insert_chart",
"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( 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, false,
@@ -444,7 +444,13 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
} }
wrap(exec(b, "insert.table", p)) 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)), "select" => wrap(exec(b, "edit.select", a)),
"pointer" => wrap(b.call("ui.pointer", a)), "pointer" => wrap(b.call("ui.pointer", a)),
"select_tool" => wrap(b.call("ui.tool.select", a)), "select_tool" => wrap(b.call("ui.tool.select", a)),
+61
View File
@@ -72,6 +72,36 @@ pub struct GradientStop {
pub color: ColorRef, 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)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")] #[serde(rename_all = "camelCase", tag = "kind")]
pub enum GradientShape { pub enum GradientShape {
@@ -90,6 +120,8 @@ pub enum GradientShape {
pub struct Gradient { pub struct Gradient {
pub stops: Vec<GradientStop>, pub stops: Vec<GradientStop>,
pub shape: GradientShape, pub shape: GradientShape,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub flip: Option<GradientFlip>,
#[serde(default = "crate::yes")] #[serde(default = "crate::yes")]
pub rotate_with_shape: bool, pub rotate_with_shape: bool,
/// `a:tileRect`: the box the gradient is laid out in, as insets from the shape's box /// `a:tileRect`: the box the gradient is laid out in, as insets from the shape's box
@@ -232,6 +264,31 @@ pub enum LineJoin {
Miter, 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)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub enum Compound { pub enum Compound {
@@ -324,6 +381,8 @@ pub struct Line {
pub dash: Option<Dash>, pub dash: Option<Dash>,
pub cap: Option<LineCap>, pub cap: Option<LineCap>,
pub join: Option<LineJoin>, pub join: Option<LineJoin>,
pub miter_limit: Option<u32>,
pub align: Option<LineAlign>,
pub compound: Option<Compound>, pub compound: Option<Compound>,
pub head: Option<LineEnd>, pub head: Option<LineEnd>,
pub tail: Option<LineEnd>, pub tail: Option<LineEnd>,
@@ -347,6 +406,8 @@ impl Line {
} }
self.cap = self.cap.or(parent.cap); self.cap = self.cap.or(parent.cap);
self.join = self.join.or(parent.join); 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); self.compound = self.compound.or(parent.compound);
if self.head.is_none() { if self.head.is_none() {
self.head.clone_from(&parent.head); self.head.clone_from(&parent.head);
+7 -2
View File
@@ -73,8 +73,13 @@ fn a_gradient_without_stops_takes_them_from_its_style() {
let p = Presentation::default(); let p = Presentation::default();
let s = &p.slides[0]; let s = &p.slides[0];
let ctx = Ctx::for_slide(&p, s).unwrap(); let ctx = Ctx::for_slide(&p, s).unwrap();
let own = let own = style::Gradient {
style::Gradient { tile: [0.0; 4], stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true }; 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); let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
st.fill_ref.0 = 3; st.fill_ref.0 = 3;
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() }; let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
+81 -5
View File
@@ -236,10 +236,13 @@ pub struct ParaProps {
pub space_after: Option<Spacing>, pub space_after: Option<Spacing>,
pub bullet: Option<Bullet>, pub bullet: Option<Bullet>,
pub bullet_font: Option<String>, pub bullet_font: Option<String>,
pub bullet_font_follow_text: Option<bool>,
/// `None` = follow text colour. /// `None` = follow text colour.
pub bullet_color: Option<ColorRef>, pub bullet_color: Option<ColorRef>,
pub bullet_color_follow_text: Option<bool>,
/// Bullet size as a fraction of the text size. /// Bullet size as a fraction of the text size.
pub bullet_size: Option<f64>, pub bullet_size: Option<f64>,
pub bullet_size_follow_text: Option<bool>,
pub tabs: Option<Vec<TabStop>>, pub tabs: Option<Vec<TabStop>>,
pub default_tab: Option<f64>, pub default_tab: Option<f64>,
pub rtl: Option<bool>, pub rtl: Option<bool>,
@@ -263,13 +266,36 @@ impl ParaProps {
if self.bullet.is_none() { if self.bullet.is_none() {
self.bullet.clone_from(&p.bullet); self.bullet.clone_from(&p.bullet);
} }
if self.bullet_font.is_none() { if self.bullet_font_follow_text == Some(false) || self.bullet_font.is_some() {
self.bullet_font.clone_from(&p.bullet_font); self.bullet_font_follow_text = None;
} }
if self.bullet_color.is_none() { if self.bullet_font.is_none() && self.bullet_font_follow_text.is_none() {
self.bullet_color.clone_from(&p.bullet_color); 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() { if self.tabs.is_none() {
self.tabs.clone_from(&p.tabs); self.tabs.clone_from(&p.tabs);
} }
@@ -632,3 +658,53 @@ impl TextBody {
n + self.paragraphs.len().saturating_sub(1) 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);
}
}
+1
View File
@@ -115,6 +115,7 @@ pub fn default_format() -> FormatScheme {
], ],
shape: GradientShape::Linear { angle: 90.0, scaled: false }, shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true, rotate_with_shape: true,
flip: None,
}) })
}; };
FormatScheme { FormatScheme {
+45 -11
View File
@@ -196,6 +196,21 @@ pub struct OggFile {
pub warnings: Vec<String>, 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 { struct Partial {
parts: Vec<(u64, u32)>, parts: Vec<(u64, u32)>,
size: u32, size: u32,
@@ -219,7 +234,7 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
match scanner.find(src, pos + 1, len)? { match scanner.find(src, pos + 1, len)? {
Some(p) => { Some(p) => {
if found_any { 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; pos = p;
continue; continue;
@@ -229,14 +244,14 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
} }
let nseg = hdr[26] as usize; let nseg = hdr[26] as usize;
if pos + 27 + nseg as u64 > len { 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; break;
} }
src.read_at(pos + 27, &mut hdr[27..27 + nseg])?; 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 body_len: u64 = hdr[27..27 + nseg].iter().map(|&b| b as u64).sum();
let page_len = 27 + nseg as u64 + body_len; let page_len = 27 + nseg as u64 + body_len;
if pos + page_len > len { if pos + page_len > len {
warnings.push(format!("truncated page at byte {pos}")); warn(&mut warnings, format!("truncated page at byte {pos}"));
break; break;
} }
let mut page = vec![0u8; page_len as usize]; 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]]); let stored_crc = u32::from_le_bytes([page[22], page[23], page[24], page[25]]);
page[22..26].fill(0); page[22..26].fill(0);
if crc32(&page) != stored_crc { 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; pos += 1;
continue; 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 // a continued page without a pending packet (lost a page): drop the fragment
let continued = flags & 0x01 != 0; let continued = flags & 0x01 != 0;
if !continued && partial.remove(&serial).is_some() { 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 completed: Vec<Packet> = Vec::new();
let mut off = 0u64; let mut off = 0u64;
@@ -332,7 +351,11 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
return Err(Error::NotOgg); return Err(Error::NotOgg);
} }
if !partial.is_empty() { 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 }) Ok(OggFile { streams, warnings })
} }
@@ -340,12 +363,23 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
struct CaptureScanner { struct CaptureScanner {
buf: Vec<u8>, buf: Vec<u8>,
buf_start: u64, buf_start: u64,
budget: usize, budget: u64,
} }
impl CaptureScanner { impl CaptureScanner {
fn new() -> Self { 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>> { 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); at = buf_end.saturating_sub(3);
continue; continue;
} }
if self.budget == 0 { if self.spent() {
return Ok(None); 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 { if n < 4 {
break; break;
} }
self.buf.resize(n, 0); self.buf.resize(n, 0);
src.read_at(at, &mut self.buf)?; src.read_at(at, &mut self.buf)?;
self.buf_start = at; 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") { if let Some(i) = self.buf.windows(4).position(|w| w == b"OggS") {
return Ok(Some(at + i as u64)); return Ok(Some(at + i as u64));
} }
+79 -1
View File
@@ -1,6 +1,9 @@
//! Synthetic Ogg streams: a small page writer (RFC 3533) and Opus-like packets. //! 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`). /// 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]) { 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_eq!(ok.total, 480);
assert!(OpusTiming::of(&packet(i64::MIN), 0).is_err()); 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);
}
+22 -2
View File
@@ -329,15 +329,26 @@ pub fn parse_path(d: &str) -> Vec<(char, Vec<f64>)> {
token.clear(); token.clear();
} }
}; };
for ch in d.chars() { let cs: Vec<char> = d.chars().collect();
for (i, &ch) in cs.iter().enumerate() {
match ch { 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' => { 'M' | 'L' | 'C' | 'Q' | 'Z' | 'm' | 'l' | 'c' | 'q' | 'z' | 'E' => {
push_tok(&mut token, &mut nums); push_tok(&mut token, &mut nums);
flush(cmd, &mut nums, &mut out); flush(cmd, &mut nums, &mut out);
cmd = Some(ch.to_ascii_uppercase()); cmd = Some(ch.to_ascii_uppercase());
} }
' ' | ',' | '\t' | '\n' => push_tok(&mut token, &mut nums), ' ' | ',' | '\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); push_tok(&mut token, &mut nums);
token.push(ch); 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"); 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] #[test]
fn parse_path_tokens() { fn parse_path_tokens() {
let v = parse_path("M0,0 L10-5 Z"); let v = parse_path("M0,0 L10-5 Z");
+38 -4
View File
@@ -3,8 +3,8 @@
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot}; use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_geom::{Xfrm, emu_to_pt}; use deckcraft_geom::{Xfrm, emu_to_pt};
use deckcraft_model::style::{ use deckcraft_model::style::{
ColorBase, ColorRef, Compound, Dash, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, LineCap, LineEnd, LineJoin, PatternFill, ColorBase, ColorRef, Compound, Dash, Effects, Fill, Glow, Gradient, GradientFlip, GradientShape, GradientStop, Line, LineAlign, LineCap, LineEnd,
PictureFill, PictureMode, Reflection, Shadow, LineJoin, PatternFill, PictureFill, PictureMode, Reflection, Shadow,
}; };
use deckcraft_model::text::{ use deckcraft_model::text::{
Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, LevelStyle, ListStyle, ParaProps, Paragraph, Run, RunKind, RunProps, Spacing, 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 } 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))); 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. /// `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, _ => LineCap::Flat,
}); });
l.compound = ln.attr("cmpd").map(Compound::from_xml); 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); l.fill = fill_of(imp, part, ln);
for e in ln.elements() { for e in ln.elements() {
match e.local() { match e.local() {
@@ -232,7 +239,10 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line {
} }
"round" => l.join = Some(LineJoin::Round), "round" => l.join = Some(LineJoin::Round),
"bevel" => l.join = Some(LineJoin::Bevel), "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), "headEnd" => l.head = line_end(e),
"tailEnd" => l.tail = 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 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> { fn line_end(e: &El) -> Option<LineEnd> {
let kind = e.attr("type").unwrap_or("none"); 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() }) 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), "buFont" => pp.bullet_font = e.attr("typeface").map(String::from),
"buFontTx" => pp.bullet_font_follow_text = Some(true),
"buClr" => pp.bullet_color = color(e), "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)), "buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)),
"buSzTx" => pp.bullet_size_follow_text = Some(true),
"tabLst" => { "tabLst" => {
pp.tabs = Some( pp.tabs = Some(
e.children_named("tab") 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) }); 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] #[test]
fn body_props() { 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(); 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();
+21 -6
View File
@@ -1,7 +1,9 @@
//! DrawingML writing: colours, fills, lines, effects, transforms and text. //! DrawingML writing: colours, fills, lines, effects, transforms and text.
use deckcraft_geom::{Xfrm, pt_to_emu}; 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::{ use deckcraft_model::text::{
Action, AutoFit, BodyProps, Bullet, Caps, Hyperlink, ListStyle, ParaProps, Paragraph, RunKind, RunProps, Spacing, Strike, TextBody, 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", LineCap::Flat => "flat",
}), }),
) )
.o("algn", l.align.map(LineAlign::xml))
.o("cmpd", l.compound.map(Compound::xml)); .o("cmpd", l.compound.map(Compound::xml));
w.open(tag, a); w.open(tag, a);
if let Some(f) = &l.fill { 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 { match l.join {
Some(LineJoin::Round) => w.empty0("a:round"), Some(LineJoin::Round) => w.empty0("a:round"),
Some(LineJoin::Bevel) => w.empty0("a:bevel"), 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 => {} None => {}
} }
for (t, e) in [("a:headEnd", &l.head), ("a:tailEnd", &l.tail)] { 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) { 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. // No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
let mut stops = g.stops.clone(); let mut stops = g.stops.clone();
if stops.len() == 1 { 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.space_after.is_some()
|| p.bullet.is_some() || p.bullet.is_some()
|| p.bullet_font.is_some() || p.bullet_font.is_some()
|| p.bullet_font_follow_text.is_some()
|| p.bullet_color.is_some() || p.bullet_color.is_some()
|| p.bullet_color_follow_text.is_some()
|| p.bullet_size.is_some() || p.bullet_size.is_some()
|| p.bullet_size_follow_text.is_some()
|| p.tabs.is_some() || p.tabs.is_some()
|| def.is_some(); || def.is_some();
if !has_kids { 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 { if let Some(s) = &p.space_after {
spacing(w, "a:spcAft", s); 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"); w.open0("a:buClr");
color(w, c); color(w, c);
w.close("a:buClr"); 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))); 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)); w.empty("a:buFont", A::new().a("typeface", f));
} }
match &p.bullet { match &p.bullet {
+2
View File
@@ -46,6 +46,7 @@ fn gradient(angle: f64) -> Fill {
shape: GradientShape::Linear { angle, scaled: true }, shape: GradientShape::Linear { angle, scaled: true },
rotate_with_shape: true, rotate_with_shape: true,
tile: [0.0; 4], 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] }, shape: GradientShape::Path { path: "circle".into(), focus: [0.2, 0.2, 0.2, 0.2] },
rotate_with_shape: false, rotate_with_shape: false,
tile: [0.0; 4], tile: [0.0; 4],
flip: None,
})); }));
let mut patt = shape(&mut p, "Pattern", 200.0, 260.0, 120.0, 80.0); let mut patt = shape(&mut p, "Pattern", 200.0, 260.0, 120.0, 80.0);
+4 -1
View File
@@ -88,7 +88,8 @@ fn a_gradient_without_stops_is_saved_without_stops() {
let mut p = defaults::new_presentation(None); let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id().expect("ids")); let id = ShapeId(p.alloc_id().expect("ids"));
let first = p.slides[0].id; 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 { p.slide_mut(first).expect("slide").shapes.push(Shape {
id, id,
name: "Styled".into(), name: "Styled".into(),
@@ -119,6 +120,7 @@ fn a_fill_overlay_round_trips() {
], ],
shape: GradientShape::Linear { angle: 4.5, scaled: true }, shape: GradientShape::Linear { angle: 4.5, scaled: true },
rotate_with_shape: true, rotate_with_shape: true,
flip: None,
}), }),
}; };
p.slide_mut(first).expect("slide").shapes.push(Shape { 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] }, shape: GradientShape::Path { path: "circle".into(), focus: [0.0, 0.0, 1.0, 1.0] },
rotate_with_shape: true, rotate_with_shape: true,
tile: [-1.0, -1.0, 0.0, 0.0], tile: [-1.0, -1.0, 0.0, 0.0],
flip: None,
}; };
let mut p = defaults::new_presentation(None); let mut p = defaults::new_presentation(None);
let id = ShapeId(p.alloc_id().expect("ids")); let id = ShapeId(p.alloc_id().expect("ids"));
+100 -2
View File
@@ -7,8 +7,8 @@ use deckcraft_geom::Xfrm;
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition}; use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
use deckcraft_model::chart::{Chart, ChartType}; use deckcraft_model::chart::{Chart, ChartType};
use deckcraft_model::style::{ use deckcraft_model::style::{
ColorRef, Dash, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PatternFill, PictureAdjust, PictureFill, PictureMode, ColorRef, Dash, Effects, Fill, Glow, Gradient, GradientFlip, GradientShape, GradientStop, Line, LineEnd, PatternFill, PictureAdjust, PictureFill,
Shadow, PictureMode, Shadow,
}; };
use deckcraft_model::table::Table; use deckcraft_model::table::Table;
use deckcraft_model::text::{ use deckcraft_model::text::{
@@ -115,6 +115,8 @@ pub fn rich_deck() -> Presentation {
], ],
shape: GradientShape::Linear { angle: 45.0, scaled: false }, shape: GradientShape::Linear { angle: 45.0, scaled: false },
rotate_with_shape: true, 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.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 { 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); 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] #[test]
fn pictures_media_and_ink_round_trip() { fn pictures_media_and_ink_round_trip() {
let p = rich_deck(); let p = rich_deck();
+5
View File
@@ -978,6 +978,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))], 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 }, shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true, rotate_with_shape: true,
flip: None,
})), })),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default() ..Default::default()
@@ -1003,6 +1004,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))], 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 }, shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true, rotate_with_shape: true,
flip: None,
})), })),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default() ..Default::default()
@@ -1044,6 +1046,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)], stops: vec![stop(0.0, 0), stop(0.5, 0), stop(1.0, 200)],
shape: GradientShape::Linear { angle: 0.0, scaled: false }, shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape: true, rotate_with_shape: true,
flip: None,
}), }),
}); });
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() }); let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
@@ -1145,6 +1148,7 @@ fn a_text_gradient_starts_over_on_every_line() {
stops: vec![stop(0.0, 0), stop(1.0, 255)], stops: vec![stop(0.0, 0), stop(1.0, 255)],
shape: GradientShape::Linear { angle: 90.0, scaled: false }, shape: GradientShape::Linear { angle: 90.0, scaled: false },
rotate_with_shape: true, rotate_with_shape: true,
flip: None,
})); }));
} }
sh.text = Some(t); sh.text = Some(t);
@@ -1306,6 +1310,7 @@ fn rect_path_gradients_draw_x_shaped_contours() {
stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)], 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] }, shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] },
rotate_with_shape: true, rotate_with_shape: true,
flip: None,
})), })),
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }), line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
..Default::default() ..Default::default()
+17 -3
View File
@@ -186,6 +186,7 @@ fn radial(focus: [f64; 4]) -> Shape {
stops, stops,
shape: GradientShape::Path { path: "circle".into(), focus }, shape: GradientShape::Path { path: "circle".into(), focus },
rotate_with_shape: true, rotate_with_shape: true,
flip: None,
})), })),
line: no_line(), line: no_line(),
..Default::default() ..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() { fn children_filled_with_the_group_fill_show_one_fill_across_the_group() {
use deckcraft_model::style::{Gradient, GradientShape, GradientStop}; 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 stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgb(0, 0, 255)) }];
let grad = let grad = Fill::Gradient(Gradient {
Fill::Gradient(Gradient { tile: [0.0; 4], stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true }); 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 { let child = |id: u32, x: f64| Shape {
id: ShapeId(id), id: ShapeId(id),
xfrm: Some(Xfrm::new(x, 0.0, 90.0, 100.0)), 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}; use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
// Tile rect the left half of the box: the ramp ends half way and stays black after. // 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 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 { let sh = Shape {
id: ShapeId(20), id: ShapeId(20),
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 100.0)), 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 }, shape: GradientShape::Linear { angle, scaled: false },
rotate_with_shape: true, rotate_with_shape: true,
tile: [0.0; 4], 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 // 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 }, shape: GradientShape::Linear { angle: 0.0, scaled: false },
rotate_with_shape, rotate_with_shape,
tile: [0.0; 4], tile: [0.0; 4],
flip: None,
}; };
// 200 × 100 turned a quarter: it covers 150..250 across and 50..250 down. // 200 × 100 turned a quarter: it covers 150..250 across and 50..250 down.
let sh = Shape { let sh = Shape {
+3 -4
View File
@@ -1,8 +1,7 @@
//! Bake the contributor credits into the binary as static Rust tables (craftrules //! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
//! standards/contributors.md). Nothing is read at run time.
//! //!
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules //! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose
//! scripts/contributors.py, which also marks whose people.toml entry it verified. //! people.toml entry is verified.
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the //! - contributors/people.toml: the names people chose for themselves. A name is used only when the
//! JSON says the entry was verified (`people_entry`). //! JSON says the entry was verified (`people_entry`).
//! //!
+7 -1
View File
@@ -13,7 +13,7 @@ use super::{
mod clipboard; mod clipboard;
mod commands; mod commands;
mod control; mod control;
mod files; pub(crate) mod files;
mod frame; mod frame;
mod ime; mod ime;
mod input; mod input;
@@ -30,6 +30,11 @@ pub use keys::key_name;
const STATUS_LINGER_MS: f64 = 6000.0; const STATUS_LINGER_MS: f64 = 6000.0;
impl SlideApp { 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, /// Build application UI state, taking ownership of audio output and initialising media, dialogs,
/// synthetic input, and timing fields. /// synthetic input, and timing fields.
pub fn new(session: Session, mut services: Services) -> Self { pub fn new(session: Session, mut services: Services) -> Self {
@@ -77,6 +82,7 @@ impl SlideApp {
integrated_titlebar: false, integrated_titlebar: false,
backstage: None, backstage: None,
pending_urls: vec![], pending_urls: vec![],
print_previews: files::PrintPreviews::default(),
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
updates_rt: updates_ui::Runtime::default(), updates_rt: updates_ui::Runtime::default(),
pending_copy: None, pending_copy: None,
+118 -3
View File
@@ -3,6 +3,66 @@ use super::{
json, media, 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 { fn purpose_of(purpose: &str) -> FilePurpose {
match purpose { match purpose {
"open" => FilePurpose::Open, "open" => FilePurpose::Open,
@@ -207,8 +267,14 @@ impl SlideApp {
let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, &params); let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, &params);
return self.download(&format!("{title}.pdf"), bytes); return self.download(&format!("{title}.pdf"), bytes);
} }
let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect(); let preview_path = match self.print_previews.create(&title) {
let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string(); 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() { if let Some(o) = params.as_object_mut() {
o.insert("path".into(), json!(path)); o.insert("path".into(), json!(path));
o.insert("format".into(), json!("pdf")); o.insert("format".into(), json!("pdf"));
@@ -219,7 +285,10 @@ impl SlideApp {
self.set_status("Opened the presentation as a PDF to print"); self.set_status("Opened the presentation as a PDF to print");
true true
} }
Err(_) => false, Err(_) => {
self.print_previews.discard(&preview_path);
false
}
} }
} }
@@ -665,3 +734,49 @@ pub(crate) fn web_image_files(
}) })
.collect() .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();
}
}
+19 -7
View File
@@ -634,13 +634,25 @@ fn print_previews_pages_and_prints_a_pdf() {
h.state_mut().backstage.as_mut().unwrap().print_range = "1-2".into(); h.state_mut().backstage.as_mut().unwrap().print_range = "1-2".into();
h.run_steps(2); h.run_steps(2);
click_on_page(&h, "Print"); click_on_page(&h, "Print");
h.run_steps(2); h.step();
let url = h.state().pending_urls.first().cloned().unwrap_or_default(); let url = h
// The URL was handed to egui at the end of the frame; check what it pointed at. .output()
let printed = std::env::temp_dir().join(format!("{} (print).pdf", h.state().session.active().unwrap().title())); .platform_output
assert!(printed.is_file(), "the PDF to print was written: {url}"); .commands
assert!(std::fs::read(&printed).unwrap().starts_with(b"%PDF")); .iter()
let _ = std::fs::remove_file(printed); .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); let _ = std::fs::remove_dir_all(&dir);
} }
+1 -1
View File
@@ -1,6 +1,6 @@
//! The About window's credits: every contributor (GitHub login always; display and real names only //! 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 //! 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. //! read at run time.
use std::cmp::Ordering; use std::cmp::Ordering;
+13 -11
View File
@@ -2,6 +2,7 @@
//! prompt that offers to update and restart. The flow itself is `craft_update::updater::Updater`. //! 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::updater::{Action, Status, Updater};
use craft_update::{CheckResultDialog, CheckResultResponse};
use crate::SlideApp; 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. /// The desktop host turns the check at start on; tests and the web build leave it off.
enabled: bool, enabled: bool,
check_requested: 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 { impl Default for Runtime {
fn default() -> Self { 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) { pub(crate) fn request_check(app: &mut SlideApp) {
if !matches!(app.updates_rt.updater.status(), Status::Checking) { if !matches!(app.updates_rt.updater.status(), Status::Checking) {
app.updates_rt.check_requested = true; 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.check_at_start(ctx);
} }
rt.updater.tick(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) { 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::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::None => {} 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)] #[cfg(test)]
+1
View File
@@ -293,6 +293,7 @@ pub struct SlideApp {
/// creating or closing a presentation leaves it. /// creating or closing a presentation leaves it.
pub backstage: Option<backstage::Backstage>, pub backstage: Option<backstage::Backstage>,
pending_urls: Vec<String>, pending_urls: Vec<String>,
print_previews: app::files::PrintPreviews,
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
updates_rt: updates_ui::Runtime, updates_rt: updates_ui::Runtime,
/// Text for the system clipboard from a Copy or Cut command, handed over on the next frame. /// Text for the system clipboard from a Copy or Cut command, handed over on the next frame.
+25 -3
View File
@@ -4,7 +4,7 @@
use deckcraft_color::{ColorScheme, ColorTransform, SchemeSlot}; use deckcraft_color::{ColorScheme, ColorTransform, SchemeSlot};
use deckcraft_model::ColorRef; 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 egui::{Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
use serde_json::{Value, json}; 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 }], stops: vec![GradientStop { pos: 0.0, color: base }, GradientStop { pos: 1.0, color: dark }],
shape: GradientShape::Linear { angle, scaled: false }, shape: GradientShape::Linear { angle, scaled: false },
rotate_with_shape: true, rotate_with_shape: true,
flip: None,
} }
} }
/// The command parameter for `g` (`shape.fill {gradient}` / `design.background {gradient}`). /// The command parameter for `g` (`shape.fill {gradient}` / `design.background {gradient}`).
pub fn gradient_json(g: &Gradient) -> Value { 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 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 { 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 } => { GradientShape::Path { path, focus } => {
let kind = match path.as_str() { let kind = match path.as_str() {
"rect" => "rectangular", "rect" => "rectangular",
"shape" => "path", "shape" => "path",
_ => "radial", _ => "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 }], 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] }, shape: GradientShape::Path { path: "circle".into(), focus: [0.5, 0.5, 0.5, 0.5] },
rotate_with_shape: true, rotate_with_shape: true,
flip: None,
}, },
Gradient { Gradient {
stops: vec![GradientStop { pos: 0.0, color: base.clone() }, GradientStop { pos: 1.0, color: with_alpha(base, 0.0) }], 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 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")));
}
}
+74 -17
View File
@@ -1,5 +1,6 @@
use super::render::fit; 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). /// The media-host voice for slide transition sounds (no shape has this id).
pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX); pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX);
@@ -152,6 +153,22 @@ pub(super) struct Under {
pub(super) pick: Option<Pick>, 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 { impl Under {
fn visible(&self, id: ShapeId) -> bool { fn visible(&self, id: ShapeId) -> bool {
self.shapes.iter().zip(&self.visible).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v) 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); show.timeline(doc);
let (Some((_, tl)), Some((_, links, shapes))) = (&show.timeline, &show.links) else { return None }; 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 (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 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() .iter()
.map(|s| { .enumerate()
let a = tl.state_ordered(s.id, None, step, t, &plays, &stamps); .map(|(i, s)| {
a.visible && a.opacity > 0.0 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(); .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 mut under = Under { shapes: hits, visible, pick: None };
let shown = |id: ShapeId| under.visible(id); let shown = |id: ShapeId| under.visible(id);
// A hyperlink in text applies when its shape is the one on top. // A hyperlink in text applies when its shape is the one on top.
let top = show.state.top(doc, tl, &under.shapes, &shown); 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; let mut at = top;
(0..=under.shapes.len()).any(|_| match at.and_then(|k| under.shapes.get(k)) { (0..=under.shapes.len()).any(|_| match at.and_then(|k| under.shapes.get(k)) {
Some(h) if h.id == l.shape => true, 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); 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) 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)));
}
}
+4 -21
View File
@@ -1,16 +1,14 @@
# Contributors in the About window # Contributors in the About window
**About ▸ Contributors** credits everyone who contributed to DeckCraft, and **About ▸ Models** credits **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 the AI models named in `Co-Authored-By` trailers.
[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md);
this page is the local copy of the decision.
## Decision ## Decision
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from - 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): disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - [`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 - [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR. 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 - 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 sync_github_name = true # use the name on your public GitHub profile as display name
``` ```
or, with craftrules checked out next to `craft-apps/`: 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.
```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.
+1
View File
@@ -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.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 | | `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason |
| `document.inspect` | — | Slides, sections, layouts, shapes, selection | | `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 (`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.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` | | `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
+9 -10
View File
@@ -1,8 +1,6 @@
# Where DeckCraft falls short of PowerPoint # 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-30 · **Last updated:** 2026-10-30 · **Change:** minor (slide-show hit testing follows each shape’s own animation state; scale and rotation covered; previous: trivial, AutoFormat lists run independently of Replace text as you type; previous: minor, slide-show click targets follow animated shapes and text links; 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)
Every known shortfall, one entry each, ranked by how much it stops someone who presents with 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 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. outside this repo.
- **Impact:** the main reason to switch is "open my PowerPoint decks". Fidelity on real decks - **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. (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 - **Estimate:** 20–35 h: grow the owned corpus in `storytold/photocraft-corpus` style, add render checks, have the owner open outputs in PowerPoint, fix
`standards/test-corpora.md`), add render checks, have the owner open outputs in PowerPoint, fix
what breaks. **Beta blocker.** what breaks. **Beta blocker.**
### Round trip loses media playback options (#74) ### 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 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 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 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 read as triggers; click boxes are the shape's bounding box (a hexagon's corners count).
not follow a shape that a motion path has moved. Trigger sequences now keep their separate 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`). 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 - **Impact:** games built around sound effects on buttons or audio-triggered effects do not play
clickable shapes do not play as authored. as authored.
- **Estimate:** 4–8 h. - **Estimate:** 4–8 h.
### Notes and Handout masters ### 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. 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). - **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark).
2–4 h. 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 - **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 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 Position, Date and Time, Photo Album (Edit Photo Album reopens it), Create New Theme Colors and
@@ -370,6 +368,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 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-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-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 | | 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
View File
@@ -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) | | `screenshot` | The whole window, UI included (connected mode) |
| `call_app` | Any [control protocol](control-protocol.md) method (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, 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}`, rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`,
+4 -6
View File
@@ -8,8 +8,7 @@ Forward-looking: where DeckCraft is going and in what order. The one-page summar
## Alpha gate: PowerPoint's core workflows ## Alpha gate: PowerPoint's core workflows
What a typical PowerPoint user does every day, checked end to end on macOS, the main platform What a typical PowerPoint user does every day, checked end to end on macOS, the main platform. Every workflow passes, so
(craftrules `standards/progress-docs.md`, "The core-workflow gate"). Every workflow passes, so
DeckCraft stays **alpha**. The "partial" rows don't block the workflow, but they are its weakest DeckCraft stays **alpha**. The "partial" rows don't block the workflow, but they are its weakest
points and lead the beta plan. 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) ## 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 Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx` reliably. Ranked:
(craftrules `standards/progress-docs.md`). Ranked:
| # | Work | Hours | Moves | | # | 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 ## 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 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 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: 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 | | 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 | | 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. plan above.
## Revision history ## Revision history
+2 -3
View File
@@ -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) | | **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) | | **Mainstream practitioner** | **~53%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) |
| **Essentials user** | **~62%** | **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 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 | | 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 ## Mainstream practitioner and essentials user
Two narrower readings of "ready", computed the same way in every Craft app (craftrules 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.
`standards/progress-docs.md`). The stage still follows the full number above and the alpha gate.
### Mainstream practitioner: ~53% ### Mainstream practitioner: ~53%
+8 -5
View File
@@ -45,14 +45,17 @@ fi
# Refuse placeholder, wrong-arch or non-FreeBSD binaries (also with --skip-build). Bytes: ELF magic, # Refuse placeholder, wrong-arch or non-FreeBSD binaries (also with --skip-build). Bytes: ELF magic,
# 64-bit little-endian, OSABI SYSV or FreeBSD, e_machine. # 64-bit little-endian, OSABI SYSV or FreeBSD, e_machine.
case "$ARCH" in case "$ARCH" in
x86_64) MACHINE=" 3e 00" ;; x86_64) MACHINE=3e00 ;;
aarch64) MACHINE=" b7 00" ;; aarch64) MACHINE=b700 ;;
*) echo "error: no ELF machine check for arch '$ARCH'" >&2; exit 1 ;; *) echo "error: no ELF machine check for arch '$ARCH'" >&2; exit 1 ;;
esac esac
# FreeBSD's od pads its columns differently from GNU od, so compare the hex digits alone.
hex() { od -An -tx1 "$@" | tr -d ' \n'; }
for f in "$BIN/deckcraft" "$BIN/deckcraft-cli"; do for f in "$BIN/deckcraft" "$BIN/deckcraft-cli"; do
[[ "$(od -An -tx1 -N6 "$f" 2>/dev/null)" == " 7f 45 4c 46 02 01" ]] || { echo "error: $f is not a 64-bit little-endian ELF" >&2; exit 1; } [[ -f "$f" ]] || { echo "error: $f was not built" >&2; exit 1; }
[[ "$(od -An -tx1 -j7 -N1 "$f")" =~ ^\ (00|09)$ ]] || { echo "error: $f is not a FreeBSD ELF (OSABI)" >&2; exit 1; } [[ "$(hex -N6 "$f")" == 7f454c460201 ]] || { echo "error: $f is not a 64-bit little-endian ELF (starts $(hex -N6 "$f"))" >&2; exit 1; }
[[ "$(od -An -tx1 -j18 -N2 "$f")" == "$MACHINE" ]] || { echo "error: $f is not an ELF for $ARCH" >&2; exit 1; } [[ "$(hex -j7 -N1 "$f")" =~ ^(00|09)$ ]] || { echo "error: $f is not a FreeBSD ELF (OSABI $(hex -j7 -N1 "$f"))" >&2; exit 1; }
[[ "$(hex -j18 -N2 "$f")" == "$MACHINE" ]] || { echo "error: $f is not an ELF for $ARCH (e_machine $(hex -j18 -N2 "$f"))" >&2; exit 1; }
done done
NAME="deckcraft-$VERSION-freebsd-$ARCH" NAME="deckcraft-$VERSION-freebsd-$ARCH"
+1 -1
View File
@@ -6,7 +6,7 @@ use std::process::Command;
use serde_json::Value; use serde_json::Value;
const SHARED_REV: &str = "92d392cff132eaf53e13afff77f38b5c11b73aa0"; const SHARED_REV: &str = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7";
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index"; const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
pub fn run(root: &Path) -> Result<(), String> { pub fn run(root: &Path) -> Result<(), String> {