mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 05:56:55 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62bb745560 | ||
|
|
a3fbf02952 | ||
|
|
92bf7fc67d | ||
|
|
0ed4b394ac | ||
|
|
f7a7fd7f5f | ||
|
|
800ffeaa59 | ||
|
|
d080a23dc0 | ||
|
|
243c1a269f | ||
|
|
06a1ca4672 | ||
|
|
40728b5362 | ||
|
|
d2504e5f8b | ||
|
|
d92874f541 | ||
|
|
6a34f13808 | ||
|
|
6058fc7882 | ||
|
|
04b0958bee | ||
|
|
acf054852c | ||
|
|
861879f8ee | ||
|
|
e626625d40 | ||
|
|
4a0f48a04c | ||
|
|
8f6e1b420a | ||
|
|
c0db6ebeba | ||
|
|
a667e13a03 | ||
|
|
3bf35c801f | ||
|
|
47eb6732fa | ||
|
|
8a2eccc863 | ||
|
|
fb05eaa30f | ||
|
|
231a52438c | ||
|
|
a00b7e8a51 | ||
|
|
9678ed7d7f | ||
|
|
1de7b44a8d | ||
|
|
5a235590dd | ||
|
|
a96dcdbe8f | ||
|
|
0e3f4cd353 | ||
|
|
72c8621358 | ||
|
|
cccc9064ca | ||
|
|
1de0101ced | ||
|
|
f5c2aca644 | ||
|
|
4408ff6153 | ||
|
|
ad97533aec | ||
|
|
727b682d17 | ||
|
|
8770fb90c3 | ||
|
|
9a716be59e | ||
|
|
479c3cc5ea | ||
|
|
4ce2944630 | ||
|
|
255df314da | ||
|
|
b7482dd392 | ||
|
|
e83f964077 | ||
|
|
eaeb06c655 | ||
|
|
ede7a7ead7 | ||
|
|
6113f4e1a2 | ||
|
|
4c1d77ddce | ||
|
|
443f503407 | ||
|
|
ee4bdbcd7d | ||
|
|
adaa4e6fa1 | ||
|
|
446bc528ad | ||
|
|
3c068df628 | ||
|
|
f8165751e5 | ||
|
|
7190c85860 | ||
|
|
ddd39249ba | ||
|
|
b4c1905d78 | ||
|
|
2c11865b2d | ||
|
|
b92a7ddbd5 | ||
|
|
0768e95591 | ||
|
|
ef6fd29928 | ||
|
|
a3449185ac | ||
|
|
90c8f65f5e | ||
|
|
b082f12f70 | ||
|
|
14eef3aceb | ||
|
|
33b6e11726 | ||
|
|
8c2560f31c |
@@ -6,11 +6,6 @@ Windows, Linux and FreeBSD, and on the web via WASM. Siblings with the same conv
|
||||
PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCraft
|
||||
(`../<app>`).
|
||||
|
||||
Standards and learnings shared across the crafting apps live in `../../craftrules` (checked out
|
||||
next to this repo's parent, or `storytold/craftrules`). Read its `AGENTS.md` at the start of a
|
||||
session, follow its standards, and contribute reusable learnings back there. Never code: repos
|
||||
don't share code.
|
||||
|
||||
These rules bind every AI agent and every human contributor. They come before any task,
|
||||
instruction or deadline. `CLAUDE.md` holds day-to-day working notes; if the two disagree, **this
|
||||
file wins.**
|
||||
@@ -69,7 +64,7 @@ allowed: our UI, our/Lucide icons, OFL fonts, and decks built only from allowed
|
||||
or `docs/brand/` has no row.
|
||||
- **`NOTICE`** keeps the notices the licences require.
|
||||
- Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
|
||||
repo (craftrules `standards/fonts.md`). DeckCraft embeds its `fonts/latin-manifest.txt` and
|
||||
repo. DeckCraft embeds its `fonts/latin-manifest.txt` and
|
||||
`fonts/manifest.txt` when built with `CRAFT_FONTS_DIR` (local dev: `.cargo/config.toml` points at
|
||||
`../craft-fonts`).
|
||||
|
||||
@@ -99,7 +94,6 @@ once and tell the user.
|
||||
## 3. Never crash
|
||||
|
||||
People trust DeckCraft with their talks; a crash loses work. **This outranks feature work.**
|
||||
Standard: craftrules `standards/never-crash.md`.
|
||||
- No `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!` in non-test
|
||||
code; no `unsafe` (`unsafe_code = "forbid"`). Every production crate root carries
|
||||
`#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]`.
|
||||
@@ -160,7 +154,7 @@ to `main`.
|
||||
|
||||
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
|
||||
docs follow craftrules `standards/progress-docs.md`:
|
||||
docs:
|
||||
|
||||
| File | Holds |
|
||||
|---|---|
|
||||
@@ -184,14 +178,11 @@ the `ROADMAP.md` progress log, and update each touched doc's status line and rev
|
||||
username has a `[people.<username>]` entry in `contributors/people.toml`. If not, ask them once
|
||||
whether they want to be credited by more than their username: a real name, a display name, and/or
|
||||
their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry
|
||||
(copy the template at the top of the file, or run
|
||||
`python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`)
|
||||
(copy the template at the top of the file)
|
||||
and include it in their PR, committed as them. If no, change nothing: they are credited as
|
||||
`@username` anyway.
|
||||
- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ artwork. The ribbon and menu icons stay original drawings.
|
||||
| `docs/brand/artcraft-logo-white.svg`, `docs/brand/artcraft-logo-white.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README header (dark mode) |
|
||||
| `docs/brand/artcraft-mark.svg`, `docs/brand/artcraft-mark.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | README footer, About |
|
||||
| `docs/brand/artcraft-mark-black.svg`, `docs/brand/artcraft-mark-black.png` | ArtCraft Team | ArtCraft brand kit | ArtCraft trademark, `docs/brand/LICENSE-brand.txt` | Mark for light backgrounds |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | craftrules `standards/license-files` | — (licence text) | Terms for the ArtCraft marks |
|
||||
| `docs/brand/LICENSE-brand.txt` | ArtCraft Team | ArtCraft brand kit | — (licence text) | Terms for the ArtCraft marks |
|
||||
| `assets/app-icon/deckcraft-1024.png`, `assets/app-icon/hicolor/*/apps/ai.storyteller.deckcraft.png` | DeckCraft contributors | rendered by DeckCraft from `assets/app-icon/icon-source.deckcraft` (shapes only) | MIT OR Apache-2.0 | Placeholder app icon (presentation board) until the mascot icon is drawn |
|
||||
| `packaging/macos/dmg/background.svg` | @XusBadia | DeckCraft macOS DMG window background, original work for this repository (2026-10-08), derived from the DeckCraft app icon `assets/app-icon/deckcraft-1024.png` (referenced via `<image>`, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | Finder window of the macOS DMG; `packaging/macos/dmg/generate.py` renders it to `background.tiff` |
|
||||
| `packaging/macos/dmg/background.tiff` | @XusBadia | Rendered from `packaging/macos/dmg/background.svg` by `packaging/macos/dmg/generate.py` | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 1x + 2x HiDPI TIFF for the macOS DMG window; includes the app icon (see its row) and LittleCMS's built-in sRGB profile ("No copyright, use freely") |
|
||||
|
||||
@@ -9,8 +9,7 @@ before modules or other crate items; crate-level `//!` documentation may precede
|
||||
|
||||
## Start every session
|
||||
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`.
|
||||
2. Read `../../craftrules/AGENTS.md` and the standards it lists for your task.
|
||||
3. Run `cargo xtask ci` before you change anything, so you know the tree is green.
|
||||
2. Run `cargo xtask ci` before you change anything, so you know the tree is green.
|
||||
|
||||
## Habits
|
||||
- Every user-visible action is a command with tests (`crates/engine/src/cmd/*`).
|
||||
|
||||
Generated
+254
-354
File diff suppressed because it is too large
Load Diff
+12
-12
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.12.0"
|
||||
version = "0.13.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -54,19 +54,19 @@ unicode-linebreak = "0.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||
|
||||
# text / rendering / formats
|
||||
skrifa = "0.47"
|
||||
harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
skrifa = "0.49"
|
||||
harfrust = "0.15"
|
||||
vello_cpu = "0.3"
|
||||
vello_common = { version = "0.3", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
quick-xml = "0.42"
|
||||
zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
|
||||
# ui
|
||||
egui = { version = "0.37", default-features = false }
|
||||
@@ -109,7 +109,7 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.8.2" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
+7
-2
@@ -2,6 +2,8 @@
|
||||
|
||||
**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-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -146,6 +148,8 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| 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-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) |
|
||||
| 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) |
|
||||
@@ -303,7 +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 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
|
||||
| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-10 | Progress docs restructured; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
| 2026-10-07 | v0.1.0 released, signed and notarized; About window with contributor credits (#3); PrintCraft renamed PdfCraft |
|
||||
@@ -314,6 +318,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| 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: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged |
|
||||
@@ -413,5 +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 | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
| 2026-10-10 | major | Restructured to craftrules `standards/progress-docs.md`: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
|
||||
| 2026-10-10 | major | Restructured: stage, two numbers, dimensions, features, languages; parity estimate moved to docs/target-app-parity.md, "Working today" to its evidence section, the alpha checklist to docs/roadmap.md |
|
||||
| 2026-10-07 | major | Alpha checklist (~80% to a self-defined alpha), breadth ~80%, overall ~62%, ~190 h to full parity |
|
||||
|
||||
@@ -29,7 +29,7 @@ craft-update = { workspace = true }
|
||||
rfd = { workspace = true }
|
||||
arboard = { workspace = true, features = ["image-data"] }
|
||||
image = { workspace = true }
|
||||
cpal = "0.16"
|
||||
cpal = "0.18"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Finder double-click, Open With, Dock drops and `open -a DeckCraft deck.pptx` arrive as a
|
||||
|
||||
@@ -60,6 +60,8 @@ impl eframe::App for App {
|
||||
// nothing after it is needed: the preferences are saved and the log file is unbuffered.
|
||||
// `on_exit` only runs once the app exists, never on a failed start. Same as GridCraft #117.
|
||||
#[cfg(target_os = "macos")]
|
||||
self.0.cleanup_print_previews();
|
||||
#[cfg(target_os = "macos")]
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
# Contributor names for the About window (craftrules standards/contributors.md).
|
||||
# Contributor names for the About window.
|
||||
#
|
||||
# Everyone who contributes is credited by GitHub username automatically. Anything more is up to
|
||||
# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a
|
||||
# maintainer may add a person only when that person asked, recording where in consent_source.
|
||||
#
|
||||
# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \
|
||||
# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name]
|
||||
#
|
||||
# Or copy this block, replacing your-github-username (every field is optional):
|
||||
# Copy this block, replacing your-github-username (every field is optional):
|
||||
#
|
||||
# [people.your-github-username]
|
||||
# real_name = "Your Name" # shown when the viewer picks "Real name"
|
||||
@@ -71,3 +68,8 @@ real_name = "Sofia Alves Figueredo"
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
[people.marshiyar]
|
||||
display_name = "Arshiya"
|
||||
real_name = "Arshiya Rahgozar"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -17,7 +17,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Animation Styles",
|
||||
["Animations", "Animation"],
|
||||
None,
|
||||
"{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, ids?} — replaces the shapes' animations",
|
||||
"{effect: fade|fly|zoom|wipe|appear|spin|pulse|fadeOut|lines|… (animation.list), class?: entrance|emphasis|exit|path, option?, path?: [[x,y],…] or OOXML string, closed?: bool, ids?} — replaces the shapes' animations. path (motion paths): points in slide points, as offsets from where the shape sits, or the OOXML path string (slide fractions) animation.get answers",
|
||||
has_selection,
|
||||
set
|
||||
),
|
||||
@@ -26,7 +26,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Add Animation",
|
||||
["Animations", "Advanced Animation"],
|
||||
None,
|
||||
"{effect, class?, option?, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?}",
|
||||
"{effect, class?, option?, path?: [[x,y],…] or OOXML string, closed?: bool, start?: onClick|withPrevious|afterPrevious, duration?: ms, delay?: ms, ids?} → {count, path?}",
|
||||
has_selection,
|
||||
add
|
||||
),
|
||||
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Effect Options",
|
||||
["Animations", "Animation"],
|
||||
None,
|
||||
"{option?, textBuild?: asOne|byParagraph|byWord|byLetter, index?}",
|
||||
"{option?, textBuild?: asOne|byParagraph|byWord|byLetter, path?: [[x,y],…] or OOXML string, closed?: bool, index?}",
|
||||
has_slide,
|
||||
options
|
||||
),
|
||||
@@ -129,7 +129,7 @@ fn resolve_option(allowed: &[&str], given: &str) -> Option<String> {
|
||||
cands.into_iter().find(|c| allowed.contains(&c.as_str()))
|
||||
}
|
||||
|
||||
fn build(p: &Value) -> Result<Animation> {
|
||||
fn build(p: &Value, slide: (f64, f64)) -> Result<Animation> {
|
||||
let effect = str_param(p, "effect").ok_or_else(|| bad("animation", "missing `effect`"))?;
|
||||
let class = class_of(str_param(p, "class"));
|
||||
let info = match class {
|
||||
@@ -155,7 +155,9 @@ fn build(p: &Value) -> Result<Animation> {
|
||||
a.start = start_of(st);
|
||||
}
|
||||
if a.class == AnimClass::Path {
|
||||
a.path = Some(default_path(&a.effect, &a.option));
|
||||
a.path = Some(path_param(p, slide)?.unwrap_or_else(|| default_path(&a.effect, &a.option)));
|
||||
} else if p.get("path").is_some() {
|
||||
return Err(bad("animation", "`path` only fits a motion path (class `path`)"));
|
||||
}
|
||||
if a.effect == "spin" {
|
||||
a.amount = Some(360.0);
|
||||
@@ -166,6 +168,97 @@ fn build(p: &Value) -> Result<Animation> {
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
/// Most points of a motion path given as an array, and longest path string given as text.
|
||||
const MAX_PATH_POINTS: usize = 1_000;
|
||||
const MAX_PATH_TEXT: usize = 16 * 1024;
|
||||
/// Largest offset of a point, in slide points; anything beyond is not a place on any slide.
|
||||
const MAX_PATH_OFFSET: f64 = 1.0e6;
|
||||
|
||||
/// A number of a path string: slide fractions, rounded to 6 places.
|
||||
fn path_num(v: f64) -> String {
|
||||
let t = format!("{:.6}", v);
|
||||
let t = t.trim_end_matches('0').trim_end_matches('.');
|
||||
if t == "-0" || t.is_empty() { "0".into() } else { t.into() }
|
||||
}
|
||||
|
||||
/// Does a raw motion path string draw: only commands and numbers, every number finite, and at
|
||||
/// least two points? (A lone `e`, or `1e999`, would play as an empty or partial path.)
|
||||
fn raw_path_draws(text: &str) -> bool {
|
||||
let cs: Vec<char> = text.chars().collect();
|
||||
let (mut i, mut nums) = (0, 0usize);
|
||||
while let Some(&c) = cs.get(i) {
|
||||
if c.is_ascii_digit() || c == '.' || ((c == '-' || c == '+') && cs.get(i + 1).is_some_and(|d| d.is_ascii_digit() || *d == '.')) {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while cs.get(i).is_some_and(|d| d.is_ascii_digit() || *d == '.') {
|
||||
i += 1;
|
||||
}
|
||||
if cs.get(i).is_some_and(|d| *d == 'e' || *d == 'E') {
|
||||
let n1 = cs.get(i + 1).copied().unwrap_or(' ');
|
||||
let n2 = cs.get(i + 2).copied().unwrap_or(' ');
|
||||
if n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit()) {
|
||||
i += 2;
|
||||
while cs.get(i).is_some_and(char::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let tok: String = cs.get(start..i).map(|t| t.iter().collect()).unwrap_or_default();
|
||||
match tok.parse::<f64>() {
|
||||
Ok(v) if v.is_finite() => nums += 1,
|
||||
_ => return false,
|
||||
}
|
||||
} else if " ,MmLlCcQqHhVvZzE".contains(c) {
|
||||
i += 1;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
nums >= 4
|
||||
}
|
||||
|
||||
/// The motion path `path` asks for, as the OOXML path string animations store: either points
|
||||
/// (`[[x, y], …]`, slide points as offsets from where the shape sits, joined by lines, `closed?`
|
||||
/// to return to the first) or that string itself.
|
||||
fn path_param(p: &Value, slide: (f64, f64)) -> Result<Option<String>> {
|
||||
let Some(v) = p.get("path") else { return Ok(None) };
|
||||
let refuse = |why: &str| bad("animation", format!("`path` {why}"));
|
||||
match v {
|
||||
Value::String(text) => {
|
||||
let ok = text.len() <= MAX_PATH_TEXT
|
||||
&& text.contains(|c: char| c.is_ascii_digit())
|
||||
&& text.chars().all(|c| c.is_ascii_digit() || " .+-eE,MmLlCcQqHhVvZz".contains(c))
|
||||
&& raw_path_draws(text);
|
||||
if !ok {
|
||||
return Err(refuse("string must be an OOXML motion path such as `M 0 0 L 0.25 0 E` (at most 16 KiB)"));
|
||||
}
|
||||
Ok(Some(text.clone()))
|
||||
}
|
||||
Value::Array(points) => {
|
||||
if !(2..=MAX_PATH_POINTS).contains(&points.len()) {
|
||||
return Err(refuse(&format!("needs 2 to {MAX_PATH_POINTS} points")));
|
||||
}
|
||||
if !(slide.0.is_finite() && slide.1.is_finite() && slide.0 > 0.0 && slide.1 > 0.0) {
|
||||
return Err(refuse("can't be placed: the slide has no size"));
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (k, pt) in points.iter().enumerate() {
|
||||
let xy = pt.as_array().filter(|a| a.len() == 2).and_then(|a| Some((a.first()?.as_f64()?, a.get(1)?.as_f64()?)));
|
||||
let Some((x, y)) = xy.filter(|(x, y)| x.abs() <= MAX_PATH_OFFSET && y.abs() <= MAX_PATH_OFFSET) else {
|
||||
return Err(refuse(&format!("point {k} must be [x, y], two finite numbers within ±{MAX_PATH_OFFSET} points")));
|
||||
};
|
||||
out.push_str(&format!("{} {} {} ", if k == 0 { "M" } else { "L" }, path_num(x / slide.0), path_num(y / slide.1)));
|
||||
}
|
||||
if p.get("closed").and_then(Value::as_bool) == Some(true) {
|
||||
out.push_str("Z ");
|
||||
}
|
||||
out.push('E');
|
||||
Ok(Some(out))
|
||||
}
|
||||
_ => Err(refuse("must be [[x, y], …] or an OOXML path string")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Default motion paths, in slide-fraction units relative to the shape's position.
|
||||
pub fn default_path(effect: &str, option: &str) -> String {
|
||||
let (dx, dy) = match option {
|
||||
@@ -185,7 +278,7 @@ pub fn default_path(effect: &str, option: &str) -> String {
|
||||
|
||||
fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let a = build(p)?;
|
||||
let a = build(p, slide_extent(s)?)?;
|
||||
s.edit(|doc, sel| {
|
||||
let sl = doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.set", "no slide"))?;
|
||||
let sl = Arc::make_mut(sl);
|
||||
@@ -215,9 +308,16 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The slide's width and height in points.
|
||||
fn slide_extent(s: &mut Session) -> Result<(f64, f64)> {
|
||||
let size = &s.doc()?.doc.slide_size;
|
||||
Ok((size.width, size.height))
|
||||
}
|
||||
|
||||
fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let a = build(p)?;
|
||||
let a = build(p, slide_extent(s)?)?;
|
||||
let path = a.path.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(sel.slide).ok_or_else(|| bad("animation.add", "no slide"))?);
|
||||
for (k, id) in ids.iter().enumerate() {
|
||||
@@ -227,7 +327,10 @@ fn add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
sl.animations.push(x);
|
||||
}
|
||||
Ok(json!({"count": sl.animations.len()}))
|
||||
Ok(match &path {
|
||||
Some(path) => json!({"count": sl.animations.len(), "path": path}),
|
||||
None => json!({"count": sl.animations.len()}),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -320,13 +423,17 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"byLetter" => TextBuild::ByLetter,
|
||||
_ => TextBuild::AsOne,
|
||||
});
|
||||
let path = path_param(p, slide_extent(s)?)?;
|
||||
each_target(s, p, |a| {
|
||||
if let (Some(path), AnimClass::Path) = (&path, a.class) {
|
||||
a.path = Some(path.clone());
|
||||
}
|
||||
if let Some(o) = &option {
|
||||
// Each target takes the option in its own effect's terms; one it has no such
|
||||
// option for keeps its own.
|
||||
let allowed = animation_info(&a.effect, a.class).map(|i| i.5).unwrap_or(&[]);
|
||||
if let Some(o) = resolve_option(allowed, o) {
|
||||
if a.class == AnimClass::Path {
|
||||
if a.class == AnimClass::Path && path.is_none() {
|
||||
a.path = Some(default_path(&a.effect, &o));
|
||||
}
|
||||
a.option = o;
|
||||
@@ -606,4 +713,76 @@ mod tests {
|
||||
assert_eq!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn path_of(s: &mut Session) -> Option<String> {
|
||||
let all = s.execute("animation.get", &json!({})).unwrap();
|
||||
all.as_array().unwrap().last().and_then(|a| a.get("path")).and_then(Value::as_str).map(String::from)
|
||||
}
|
||||
|
||||
fn shape(s: &mut Session) -> u64 {
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [100.0, 400.0, 20.0, 20.0]})).unwrap().get("id").unwrap().as_u64().unwrap()
|
||||
}
|
||||
|
||||
/// #1576: points in slide points, as offsets from the shape, become the slide-fraction path.
|
||||
#[test]
|
||||
fn a_custom_path_takes_its_points() {
|
||||
let mut s = Session::with_new();
|
||||
let id = shape(&mut s);
|
||||
let w = s.doc().unwrap().doc.slide_size.width;
|
||||
let h = s.doc().unwrap().doc.slide_size.height;
|
||||
let r = s
|
||||
.execute(
|
||||
"animation.add",
|
||||
&json!({"effect": "customPath", "class": "path", "path": [[0, 0], [w / 2.0, -h / 4.0], [w, -h / 2.0]], "ids": [id]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(r["path"], "M 0 0 L 0.5 -0.25 L 1 -0.5 E");
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.5 -0.25 L 1 -0.5 E"));
|
||||
// closed shapes end back at the start; set replaces; no path keeps the default
|
||||
s.execute("animation.set", &json!({"effect": "customPath", "path": [[0, 0], [w / 4.0, 0], [w / 4.0, h / 4.0]], "closed": true, "ids": [id]}))
|
||||
.unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.25 0 L 0.25 0.25 Z E"));
|
||||
s.execute("animation.set", &json!({"effect": "customPath", "ids": [id]})).unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0 0.25 E"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_raw_path_string_is_kept_and_options_edits_it() {
|
||||
let mut s = Session::with_new();
|
||||
let id = shape(&mut s);
|
||||
s.execute("animation.add", &json!({"effect": "customPath", "path": "M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E", "ids": [id]})).unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 C 0.1 0 0.2 0.1 0.3 0.1 E"));
|
||||
s.execute("animation.options", &json!({"index": 0, "path": [[0, 0], [96, 54]]})).unwrap();
|
||||
assert_eq!(path_of(&mut s).as_deref(), Some("M 0 0 L 0.1 0.1 E"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_path_is_refused_and_changes_nothing() {
|
||||
let mut s = Session::with_new();
|
||||
let id = shape(&mut s);
|
||||
let many: Vec<Value> = (0..2_000).map(|i| json!([i, i])).collect();
|
||||
for bad_path in [
|
||||
json!([[0, 0]]),
|
||||
json!([]),
|
||||
json!([[0, 0], [1]]),
|
||||
json!([[0, 0], ["a", 1]]),
|
||||
json!([[0, 0], [1e300, 1]]),
|
||||
json!([[0, 0], [null, 1]]),
|
||||
json!("M 0 0 L <script> E"),
|
||||
json!("not a path"),
|
||||
json!("eeee1"),
|
||||
json!("M 0 0 L 1e999 0 E"),
|
||||
json!(5),
|
||||
Value::Array(many),
|
||||
Value::String("M 0 0 L 0.1 0.1 ".repeat(10_000)),
|
||||
] {
|
||||
let r = s.execute("animation.add", &json!({"effect": "customPath", "path": bad_path, "ids": [id]}));
|
||||
assert!(r.is_err(), "{bad_path:.60}");
|
||||
}
|
||||
assert!(
|
||||
s.execute("animation.add", &json!({"effect": "fade", "path": [[0, 0], [1, 1]], "ids": [id]})).is_err(),
|
||||
"path only fits a motion path"
|
||||
);
|
||||
assert!(s.execute("animation.get", &json!({})).unwrap().as_array().unwrap().is_empty(), "a refused add left an animation behind");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot,
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::text::{format_body, format_para, format_run};
|
||||
use super::text::{format_body, format_para, format_run, format_run_levels};
|
||||
use super::{
|
||||
CommandSpec, always, bad, base64_decode, bool_or, bool_param, cmd, color_param, edit_shapes, f64_or, f64_param, has_doc, has_text,
|
||||
has_text_or_shapes, ids_param, ok, str_param,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::{
|
||||
Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or,
|
||||
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param,
|
||||
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, format_run_levels, json, ok, on_param, resolve, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn bold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -72,10 +72,18 @@ fn grow_shrink(s: &mut Session, up: bool) -> Result<Value> {
|
||||
let ctx_sizes = current_run(s).size.unwrap_or(18.0);
|
||||
let _ = st;
|
||||
let base = ctx_sizes;
|
||||
format_run(s, &move |r| {
|
||||
let cur = r.size.unwrap_or(base);
|
||||
r.size = Some(step(cur, up));
|
||||
})
|
||||
// A layout/master level steps from its own effective size, not the first run's.
|
||||
format_run_levels(
|
||||
s,
|
||||
&move |r| {
|
||||
let cur = r.size.unwrap_or(base);
|
||||
r.size = Some(step(cur, up));
|
||||
},
|
||||
&move |r, eff| {
|
||||
let cur = r.size.or(eff.size).unwrap_or(18.0);
|
||||
r.size = Some(step(cur, up));
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn grow(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
@@ -227,6 +235,9 @@ pub(super) fn language_all(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
pub(super) fn highlight(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color");
|
||||
if c.is_none() && p.get("color").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad("format.highlight", "`color` is not a valid colour"));
|
||||
}
|
||||
format_run(s, &move |r| r.highlight = c.clone())
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,17 @@ pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
format_para(s, &move |para| {
|
||||
if on {
|
||||
para.props.bullet = Some(Bullet::Char { char: ch.clone() });
|
||||
para.props.bullet_font = if explicit_char { Some("Arial".into()) } else { para.props.bullet_font.clone() };
|
||||
if explicit_char {
|
||||
para.props.bullet_font = Some("Arial".into());
|
||||
para.props.bullet_font_follow_text = None;
|
||||
}
|
||||
if col.is_some() {
|
||||
para.props.bullet_color = col.clone();
|
||||
para.props.bullet_color_follow_text = None;
|
||||
}
|
||||
if size.is_some() {
|
||||
para.props.bullet_size = size;
|
||||
para.props.bullet_size_follow_text = None;
|
||||
}
|
||||
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
|
||||
para.props.margin_left = Some(18.0 + 36.0 * para.level as f64);
|
||||
@@ -76,9 +81,11 @@ pub(super) fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
para.props.bullet = Some(Bullet::AutoNum { scheme: scheme.clone(), start_at: start });
|
||||
if col.is_some() {
|
||||
para.props.bullet_color = col.clone();
|
||||
para.props.bullet_color_follow_text = None;
|
||||
}
|
||||
if size.is_some() {
|
||||
para.props.bullet_size = size;
|
||||
para.props.bullet_size_follow_text = None;
|
||||
}
|
||||
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
|
||||
para.props.indent = Some(-27.0);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
//! Inspection for agents: the deck, slides and shapes as JSON (verify work without screenshots).
|
||||
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::Hyperlink;
|
||||
use deckcraft_model::{Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, str_param, usize_param, xfrm_of};
|
||||
use super::{CommandSpec, always, bad, cmd, has_doc, id_param, opt_usize_param, str_param, usize_param, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
|
||||
cmd!(query "slide.actions", "Inspect Slide Actions", [], None, "{index?} → {index, slideId, actions: [{owner, event, type, target, hyperlink}]}; lists shape click/hover actions and text-run links/hover actions (including table cells) without executing them", has_doc, actions),
|
||||
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
|
||||
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
|
||||
]
|
||||
@@ -135,6 +137,70 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}))
|
||||
}
|
||||
|
||||
fn actions(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = opt_usize_param(p, "index", "slide.actions")?.unwrap_or(st.selection.slide);
|
||||
let sl = st.doc.slides.get(i).ok_or_else(|| bad("slide.actions", format!("no slide {i}")))?;
|
||||
let mut found = Vec::new();
|
||||
for sh in &sl.shapes {
|
||||
collect_actions(sh, &mut Vec::new(), &mut found);
|
||||
}
|
||||
Ok(json!({"index": i, "slideId": sl.id, "actions": found}))
|
||||
}
|
||||
|
||||
fn collect_actions(sh: &Shape, path: &mut Vec<Value>, found: &mut Vec<Value>) {
|
||||
path.push(json!({"id": sh.id, "name": sh.name}));
|
||||
let owner = json!({"shapeId": sh.id, "shapeName": sh.name, "path": path});
|
||||
if let Some(link) = &sh.click {
|
||||
found.push(action_entry(owner.clone(), "click", link));
|
||||
}
|
||||
if let Some(link) = &sh.hover {
|
||||
found.push(action_entry(owner.clone(), "hover", link));
|
||||
}
|
||||
if let Some(text) = &sh.text {
|
||||
collect_text_actions(text, &owner, found);
|
||||
}
|
||||
if let ShapeKind::Table(table) = &sh.kind {
|
||||
for (row_index, row) in table.rows.iter().enumerate() {
|
||||
for (cell_index, cell) in row.cells.iter().enumerate() {
|
||||
let mut cell_owner = owner.clone();
|
||||
cell_owner["tableRowIndex"] = json!(row_index);
|
||||
cell_owner["tableCellIndex"] = json!(cell_index);
|
||||
collect_text_actions(&cell.text, &cell_owner, found);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let ShapeKind::Group { children, .. } = &sh.kind {
|
||||
for child in children {
|
||||
collect_actions(child, path, found);
|
||||
}
|
||||
}
|
||||
path.pop();
|
||||
}
|
||||
|
||||
fn collect_text_actions(text: &deckcraft_model::text::TextBody, owner: &Value, found: &mut Vec<Value>) {
|
||||
for (paragraph_index, paragraph) in text.paragraphs.iter().enumerate() {
|
||||
for (run_index, run) in paragraph.runs.iter().enumerate() {
|
||||
for (event, link) in [("textLink", run.props.link.as_ref()), ("textHover", run.props.hover.as_ref())] {
|
||||
if let Some(link) = link {
|
||||
let mut text_owner = owner.clone();
|
||||
text_owner["paragraphIndex"] = json!(paragraph_index);
|
||||
text_owner["runIndex"] = json!(run_index);
|
||||
text_owner["text"] = json!(run.content());
|
||||
found.push(action_entry(text_owner, event, link));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_entry(owner: Value, event: &str, link: &Hyperlink) -> Value {
|
||||
let action = serde_json::to_value(&link.action).unwrap_or_default();
|
||||
let kind = action.get("kind").cloned().unwrap_or(Value::Null);
|
||||
let target = ["url", "slide", "name", "path"].iter().find_map(|key| action.get(key).cloned()).unwrap_or(Value::Null);
|
||||
json!({"owner": owner, "event": event, "type": kind, "target": target, "hyperlink": link})
|
||||
}
|
||||
|
||||
fn shape(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id =
|
||||
@@ -154,3 +220,82 @@ fn commands(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::table::Row;
|
||||
use deckcraft_model::text::{Action, Paragraph, Run, RunProps, TextBody};
|
||||
use deckcraft_model::{Cell, Shape, ShapeId, ShapeKind, Table};
|
||||
|
||||
fn link(action: Action) -> Hyperlink {
|
||||
Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_actions_lists_group_shape_and_text_actions() {
|
||||
let mut s = Session::with_new();
|
||||
let mut child = Shape { id: ShapeId(12), name: "Child".into(), ..Default::default() };
|
||||
child.click = Some(link(Action::NextSlide));
|
||||
child.hover = Some(link(Action::Url { url: "https://example.com/hover".into() }));
|
||||
let run_props = RunProps { link: Some(link(Action::Slide { slide: deckcraft_model::SlideId(7) })), ..Default::default() };
|
||||
let mut paragraph = Paragraph::new("linked");
|
||||
paragraph.runs = vec![Run::with("linked", run_props)];
|
||||
child.text = Some(TextBody { paragraphs: vec![paragraph], ..Default::default() });
|
||||
let group = Shape {
|
||||
id: ShapeId(11),
|
||||
name: "Group".into(),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Xfrm::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide_index = st.selection.slide;
|
||||
let presentation = std::sync::Arc::make_mut(&mut st.doc);
|
||||
std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes = vec![group];
|
||||
|
||||
let v = s.execute("slide.actions", &json!({})).unwrap();
|
||||
let actions = v["actions"].as_array().unwrap();
|
||||
assert_eq!(actions.len(), 3, "{v}");
|
||||
assert_eq!(actions[0]["event"], "click");
|
||||
assert_eq!(actions[0]["type"], "nextSlide");
|
||||
assert_eq!(actions[0]["owner"]["path"][0]["name"], "Group");
|
||||
assert_eq!(actions[0]["owner"]["path"][1]["id"], 12);
|
||||
assert_eq!(actions[1]["event"], "hover");
|
||||
assert_eq!(actions[1]["target"], "https://example.com/hover");
|
||||
assert_eq!(actions[2]["event"], "textLink");
|
||||
assert_eq!(actions[2]["owner"]["runIndex"], 0);
|
||||
assert_eq!(actions[2]["target"], 7);
|
||||
|
||||
let cell_props = RunProps { hover: Some(link(Action::Url { url: "https://example.com/cell".into() })), ..Default::default() };
|
||||
let mut cell_text = TextBody::from_text("cell link");
|
||||
cell_text.paragraphs[0].runs = vec![Run::with("cell link", cell_props)];
|
||||
let table =
|
||||
Table { rows: vec![Row { cells: vec![Cell { text: cell_text, ..Default::default() }], ..Default::default() }], ..Default::default() };
|
||||
let table_shape = Shape { id: ShapeId(13), name: "Table".into(), kind: ShapeKind::Table(table), ..Default::default() };
|
||||
let st = s.doc_mut().unwrap();
|
||||
let presentation = std::sync::Arc::make_mut(&mut st.doc);
|
||||
std::sync::Arc::make_mut(&mut presentation.slides[slide_index]).shapes.push(table_shape);
|
||||
let v = s.execute("slide.actions", &json!({})).unwrap();
|
||||
let table_action = v["actions"].as_array().unwrap().last().cloned().unwrap();
|
||||
assert_eq!(table_action["event"], "textHover");
|
||||
assert_eq!(table_action["owner"]["tableRowIndex"], 0);
|
||||
assert_eq!(table_action["owner"]["tableCellIndex"], 0);
|
||||
assert_eq!(table_action["target"], "https://example.com/cell");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_actions_returns_empty_array_for_slide_without_actions() {
|
||||
let mut s = Session::with_new();
|
||||
let v = s.execute("slide.actions", &json!({})).unwrap();
|
||||
assert_eq!(v["actions"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_actions_refuses_a_bad_index_instead_of_using_the_selection() {
|
||||
let mut s = Session::with_new();
|
||||
for index in [json!(-1), json!("0"), json!(1.5)] {
|
||||
assert!(s.execute("slide.actions", &json!({"index": index})).is_err(), "{index}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,13 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
|
||||
scaled: false,
|
||||
},
|
||||
};
|
||||
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
|
||||
Some(Gradient {
|
||||
tile: [0.0; 4],
|
||||
stops,
|
||||
shape,
|
||||
rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true),
|
||||
flip: v.get("flip").and_then(Value::as_str).and_then(deckcraft_model::style::GradientFlip::from_xml),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fill and outline apply to a group's visible members as well as its container. Other edits
|
||||
@@ -555,3 +561,14 @@ pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gradient_parameters_keep_flip_mode() {
|
||||
let gradient = gradient_param(&json!({"stops": [[0, "#ff0000"], [1, "#0000ff"]], "flip": "xy"})).expect("gradient");
|
||||
assert_eq!(gradient.flip, Some(deckcraft_model::style::GradientFlip::Xy));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ pub(super) fn paint(d: &Diagram, sh: &mut Shape, i: usize, light: bool) {
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0; 4],
|
||||
flip: None,
|
||||
}));
|
||||
sh.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Lt1), 1.0));
|
||||
sh.effects = Some(Effects { outer_shadow: Some(shadow(8.0, 5.0)), ..Default::default() });
|
||||
|
||||
@@ -30,7 +30,7 @@ use editing::{edit, exit};
|
||||
pub(crate) use editing::{insert_field, insert_text, paste_body};
|
||||
pub use formatting::format_range;
|
||||
use formatting::{fonts_used, replace_font};
|
||||
pub(crate) use formatting::{format_body, format_para, format_run, link_text};
|
||||
pub(crate) use formatting::{format_body, format_para, format_run, format_run_levels, link_text};
|
||||
pub use ime::{IME_SURROUNDING_MAX_BYTES, ImeSurrounding, cell_range_underlines, composition_underlines, ime_surrounding, range_underlines};
|
||||
use ime::{commit, compose};
|
||||
pub(crate) use layout::ctx_for;
|
||||
|
||||
@@ -8,22 +8,34 @@ use super::{
|
||||
/// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour),
|
||||
/// or sets the formatting for what is typed next at a word boundary.
|
||||
pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sync)) -> Result<Value> {
|
||||
format_run_levels(s, f, &|r, _| f(r))
|
||||
}
|
||||
|
||||
/// Like [`format_run`], with a separate change for the list-style levels of a layout or master
|
||||
/// placeholder: `lf` gets the level's own run props and the level's effective ones (what a run at
|
||||
/// that level resolves to), so a relative change (grow/shrink) steps each level from its own value.
|
||||
pub(crate) fn format_run_levels(
|
||||
s: &mut Session,
|
||||
f: &(dyn Fn(&mut RunProps) + Send + Sync),
|
||||
lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync),
|
||||
) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let (a, b) = format_range(st, &t);
|
||||
let collapsed = a == b;
|
||||
// Only the levels of the paragraphs the selected range touches.
|
||||
let levels = if a == b { Vec::new() } else { master_levels(st, &t, t.ordered()) };
|
||||
edit_text(s, |body, _| {
|
||||
if collapsed {
|
||||
// Next typed text: put the props on an empty paragraph's end mark or keep pending.
|
||||
if levels.is_empty() {
|
||||
ed::format(body, a, b, f);
|
||||
} else {
|
||||
ed::format(body, a, b, f);
|
||||
format_by_level(body, a, b, lf, &levels);
|
||||
}
|
||||
for (l, eff) in &levels {
|
||||
let mut lvl = body.list_style.level(*l).cloned().unwrap_or_default();
|
||||
lf(&mut lvl.run, eff);
|
||||
body.list_style.set(*l, lvl);
|
||||
}
|
||||
})?;
|
||||
if collapsed {
|
||||
// Remember as pending typing props by inserting nothing: handled via end mark for
|
||||
// empty paragraphs; mid-paragraph carets keep surrounding formatting.
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
let ids = st.selection.shapes.clone();
|
||||
@@ -31,6 +43,19 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
let cells = st.selection.cells;
|
||||
// Effective run props of every level of the selected placeholders, resolved before editing.
|
||||
let effective: Vec<(ShapeId, Vec<RunProps>)> = if editing_master(st) {
|
||||
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection);
|
||||
ids.iter()
|
||||
.filter_map(|id| {
|
||||
let sh = st.shape(*id).filter(|sh| sh.ph.is_some())?;
|
||||
let ctx = ctx.as_ref()?;
|
||||
Some((*id, (0..LIST_LEVELS).map(|l| deckcraft_model::resolve::level_style(ctx, sh, l).run).collect()))
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
let id = sh.id;
|
||||
match &mut sh.kind {
|
||||
@@ -47,7 +72,22 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
}
|
||||
_ => {
|
||||
let body = sh.text.get_or_insert_with(TextBody::default);
|
||||
ed::format_all(body, f);
|
||||
let eff = effective.iter().find(|(i, _)| *i == id).map(|(_, e)| e);
|
||||
match eff {
|
||||
Some(eff) => {
|
||||
let levels: Vec<(u8, RunProps)> = (0..LIST_LEVELS).zip(eff.iter().cloned()).collect();
|
||||
format_by_level(body, (0, 0), (usize::MAX, usize::MAX), lf, &levels);
|
||||
}
|
||||
None => ed::format_all(body, f),
|
||||
}
|
||||
// The whole shape is selected: every level of its list style.
|
||||
if let Some(eff) = eff {
|
||||
for (l, eff) in (0..LIST_LEVELS).zip(eff) {
|
||||
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
|
||||
lf(&mut lvl.run, eff);
|
||||
body.list_style.set(l, lvl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -58,6 +98,55 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Format the runs of [a, b) paragraph by paragraph, each with its own level's effective props, so
|
||||
/// a relative change (grow/shrink) steps every paragraph from its own size instead of one shared
|
||||
/// value. `a`/`b` are clamped to the body.
|
||||
fn format_by_level(body: &mut TextBody, a: Pos, b: Pos, lf: &(dyn Fn(&mut RunProps, &RunProps) + Send + Sync), levels: &[(u8, RunProps)]) {
|
||||
let Some(last) = body.paragraphs.len().checked_sub(1) else { return };
|
||||
let (first, end) = (a.0.min(last), b.0.min(last));
|
||||
for pi in first..=end {
|
||||
let p = &body.paragraphs[pi];
|
||||
let from = if pi == first { a.1.min(p.char_len()) } else { 0 };
|
||||
let to = if pi == end { b.1.min(p.char_len()) } else { p.char_len() };
|
||||
if from == to && p.char_len() > 0 {
|
||||
continue;
|
||||
}
|
||||
let lvl = p.level.min(LIST_LEVELS - 1);
|
||||
let Some((_, eff)) = levels.iter().find(|(l, _)| *l == lvl) else { continue };
|
||||
ed::format(body, (pi, from), (pi, to), &|r| lf(r, eff));
|
||||
}
|
||||
}
|
||||
|
||||
/// List-style levels (`lvl1pPr`–`lvl9pPr`) a placeholder passes on to the slides that use it.
|
||||
const LIST_LEVELS: u8 = 9;
|
||||
|
||||
/// Formatting a placeholder on a layout or master (Slide Master view) also sets its list style,
|
||||
/// which is what the placeholders on slides inherit; its own runs may be few or none.
|
||||
fn editing_master(st: &DocState) -> bool {
|
||||
matches!(st.selection.target, crate::Target::Layout { .. } | crate::Target::Master { .. })
|
||||
}
|
||||
|
||||
/// The distinct list-style levels of the paragraphs `range` touches in a layout or master
|
||||
/// placeholder, with each level's effective run props. Empty anywhere else.
|
||||
fn master_levels(st: &DocState, t: &TextSel, range: (Pos, Pos)) -> Vec<(u8, RunProps)> {
|
||||
if !editing_master(st) || t.notes || t.cell.is_some() {
|
||||
return Vec::new();
|
||||
}
|
||||
let (Some(sh), Some(body), Some(ctx)) =
|
||||
(st.shape(t.shape).filter(|sh| sh.ph.is_some()), body_of(st, t), crate::cmd::text::ctx_for(&st.doc, &st.selection))
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut levels: Vec<u8> =
|
||||
body.paragraphs.iter().enumerate().filter(|(i, _)| (range.0.0..=range.1.0).contains(i)).map(|(_, p)| p.level.min(LIST_LEVELS - 1)).collect();
|
||||
if body.paragraphs.is_empty() {
|
||||
levels.push(0);
|
||||
}
|
||||
levels.sort_unstable();
|
||||
levels.dedup();
|
||||
levels.into_iter().map(|l| (l, deckcraft_model::resolve::level_style(&ctx, sh, l).run)).collect()
|
||||
}
|
||||
|
||||
/// What `format_run` formats while editing: the selection, or for a bare caret the word it's in
|
||||
/// (without its trailing space). Empty when the caret is at a word boundary. Insert Hyperlink
|
||||
/// edits this same span, so the dialog reads it too.
|
||||
@@ -110,6 +199,8 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
let st = s.doc()?;
|
||||
if let Some(t) = st.selection.text.clone() {
|
||||
let (a, b) = t.ordered();
|
||||
// Only the list-style levels of the paragraphs touched, on a layout or master placeholder.
|
||||
let levels: Vec<u8> = master_levels(st, &t, (a, b)).into_iter().map(|(l, _)| l).collect();
|
||||
edit_text(s, |body, _| {
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Default::default());
|
||||
@@ -120,6 +211,9 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
f(p);
|
||||
}
|
||||
}
|
||||
for l in levels {
|
||||
format_list_level(body, l, f);
|
||||
}
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
@@ -128,8 +222,10 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
let cells = st.selection.cells;
|
||||
let on_master = editing_master(st);
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
let id = sh.id;
|
||||
let inherited = on_master && sh.ph.is_some();
|
||||
match &mut sh.kind {
|
||||
ShapeKind::Table(t) => {
|
||||
let range = crate::cmd::table::selected_range(cells, id, t);
|
||||
@@ -147,6 +243,12 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
body.paragraphs.push(Default::default());
|
||||
}
|
||||
body.paragraphs.iter_mut().for_each(f);
|
||||
// The whole shape is selected: every level of its list style.
|
||||
if inherited {
|
||||
for l in 0..LIST_LEVELS {
|
||||
format_list_level(body, l, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -157,6 +259,18 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
ok()
|
||||
}
|
||||
|
||||
/// Apply a paragraph change to one level of a body's list style.
|
||||
fn format_list_level(body: &mut TextBody, l: u8, f: &(dyn Fn(&mut deckcraft_model::text::Paragraph) + Send + Sync)) {
|
||||
let mut lvl = body.list_style.level(l).cloned().unwrap_or_default();
|
||||
let mut p = deckcraft_model::text::Paragraph { level: l, props: lvl.para, ..Default::default() };
|
||||
f(&mut p);
|
||||
// A level change (indent/outdent) means nothing for a list-style level.
|
||||
if p.level == l {
|
||||
lvl.para = p.props;
|
||||
body.list_style.set(l, lvl);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply body (text box) changes to the edited or selected shapes.
|
||||
pub(crate) fn format_body(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::text::BodyProps) + Send + Sync)) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::Transition;
|
||||
use deckcraft_model::anim::TRANSITIONS;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, str_param, usize_param};
|
||||
use super::{CommandSpec, NO_ID_SPACE, always, bad, bool_param, cmd, has_slide, ok, opt_usize_param, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -44,9 +44,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
fn slide_indices(s: &Session, p: &Value) -> Result<Vec<usize>> {
|
||||
fn slide_indices(s: &Session, p: &Value, cmd: &str) -> Result<Vec<usize>> {
|
||||
let st = s.doc()?;
|
||||
if let Some(i) = usize_param(p, "index") {
|
||||
if let Some(i) = opt_usize_param(p, "index", cmd)? {
|
||||
return Ok(vec![i]);
|
||||
}
|
||||
let v: Vec<usize> = st.selection.slides.iter().filter_map(|id| st.doc.slide_index(*id)).collect();
|
||||
@@ -59,7 +59,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
TRANSITIONS.iter().find(|t| t.0.eq_ignore_ascii_case(kind)).ok_or_else(|| bad("transition.set", format!("unknown transition `{kind}`")))?;
|
||||
let option = str_param(p, "option").map(String::from).or_else(|| info.4.first().map(|o| o.to_string())).unwrap_or_default();
|
||||
let duration = p.get("duration").and_then(Value::as_u64).map(|v| v.min(59_000) as u32);
|
||||
let list = slide_indices(s, p)?;
|
||||
let list = slide_indices(s, p, "transition.set")?;
|
||||
s.edit(|doc, _| {
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
@@ -83,7 +83,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let option = str_param(p, "option").ok_or_else(|| bad("transition.options", "missing `option`"))?.to_string();
|
||||
let list = slide_indices(s, p)?;
|
||||
let list = slide_indices(s, p, "transition.options")?;
|
||||
s.edit(|doc, _| {
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i)
|
||||
@@ -98,7 +98,7 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list = slide_indices(s, p)?;
|
||||
let list = slide_indices(s, p, "transition.timing")?;
|
||||
s.edit(|doc, _| {
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
@@ -127,7 +127,7 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Transitions › Sound: embed an audio file as the slides' transition sound, or `none` to remove it.
|
||||
fn sound(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let list = slide_indices(s, p)?;
|
||||
let list = slide_indices(s, p, "transition.sound")?;
|
||||
let looping = bool_param(p, "loop");
|
||||
let edit_all = |s: &mut Session, f: &dyn Fn(&mut Transition)| {
|
||||
s.edit(|doc, _| {
|
||||
@@ -215,3 +215,29 @@ fn apply_all(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
fn list(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
Ok(Value::Array(TRANSITIONS.iter().map(|t| json!({"id": t.0, "label": t.1, "category": t.2, "duration": t.3, "options": t.4})).collect()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < 3 {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
for (cmd, p) in [
|
||||
("transition.set", json!({"kind": "fade", "index": bad})),
|
||||
("transition.options", json!({"option": "x", "index": bad})),
|
||||
("transition.timing", json!({"duration": 500, "index": bad})),
|
||||
("transition.sound", json!({"none": true, "index": bad})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
|
||||
assert!(s.doc().expect("doc").doc.slides.iter().all(|sl| sl.transition.is_none()), "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Last-resort guard (craftrules `standards/never-crash.md`): a panic that escapes a command —
|
||||
//! Last-resort guard: a panic that escapes a command —
|
||||
//! any edit, import or export, from the UI, the CLI, MCP or the control channel — becomes an
|
||||
//! error, and the document is kept as it was before the command (and saved for crash recovery
|
||||
//! when a recovery directory is set). It is a safety net: commands still must not panic.
|
||||
|
||||
@@ -3198,6 +3198,15 @@ fn motion_path_presets_have_paths_ids_and_round_trip() {
|
||||
fn numbering_takes_color_and_size() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("insert.textBox", &json!({"rect": [10, 10, 300, 100], "text": "One\nTwo"})).unwrap();
|
||||
{
|
||||
let id = s.doc().unwrap().selection.shapes[0];
|
||||
let d = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let shape = deckcraft_model::find_shape_mut(&mut std::sync::Arc::make_mut(&mut d.slides[0]).shapes, id).unwrap();
|
||||
let props = &mut shape.text.as_mut().unwrap().paragraphs[0].props;
|
||||
props.bullet_font_follow_text = Some(true);
|
||||
props.bullet_color_follow_text = Some(true);
|
||||
props.bullet_size_follow_text = Some(true);
|
||||
}
|
||||
s.execute("format.numbering", &json!({"on": true, "scheme": "romanUcPeriod", "start": 3, "color": "accent2", "size": 150})).unwrap();
|
||||
let para = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
@@ -3207,9 +3216,13 @@ fn numbering_takes_color_and_size() {
|
||||
let pp = para(&s);
|
||||
assert!(matches!(&pp.bullet, Some(deckcraft_model::text::Bullet::AutoNum { scheme, start_at: 3 }) if scheme == "romanUcPeriod"));
|
||||
assert_eq!(pp.bullet_size, Some(1.5));
|
||||
assert_eq!(pp.bullet_font_follow_text, Some(true));
|
||||
assert_eq!(pp.bullet_color_follow_text, None);
|
||||
assert_eq!(pp.bullet_size_follow_text, None);
|
||||
assert_eq!(pp.bullet_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2)));
|
||||
s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap();
|
||||
assert_eq!(para(&s).bullet_size, Some(4.0));
|
||||
assert_eq!(para(&s).bullet_font_follow_text, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5956,3 +5969,98 @@ fn invalid_slide_index_never_falls_back_to_the_selection_in_export_commands() {
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn highlight_with_an_invalid_colour_is_rejected_and_keeps_the_highlight() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "ARCH"})).unwrap();
|
||||
s.execute("text.selectAll", &json!({})).unwrap();
|
||||
s.execute("format.highlight", &json!({"color": "yellow"})).unwrap();
|
||||
let has_highlight = |s: &mut Session| {
|
||||
let t = s.doc().unwrap().shape(ShapeId(title)).unwrap().text.clone().unwrap();
|
||||
t.paragraphs.iter().flat_map(|p| p.runs.iter()).any(|r| r.props.highlight.is_some())
|
||||
};
|
||||
assert!(has_highlight(&mut s));
|
||||
let r = s.execute("format.highlight", &json!({"color": "notacolour"}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "{r:?}");
|
||||
assert!(has_highlight(&mut s), "an invalid colour must not clear the highlight");
|
||||
s.execute("format.highlight", &json!({})).unwrap();
|
||||
assert!(!has_highlight(&mut s), "an absent colour clears the highlight");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_layout_placeholder_format_inherits() {
|
||||
let mut s = Session::new();
|
||||
sample::open_sample(&mut s).unwrap();
|
||||
let title_of = |shapes: &[model::Shape]| shapes.iter().find(|sh| sh.ph.as_ref().is_some_and(|p| p.kind.is_title())).map(|sh| sh.id.0).unwrap();
|
||||
let existing = title_of(&s.doc().unwrap().doc.slides[0].shapes);
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": 0})).unwrap();
|
||||
let layout_title = title_of(&s.doc().unwrap().doc.masters[0].layouts[0].shapes);
|
||||
s.execute("edit.select", &json!({"ids": [layout_title]})).unwrap();
|
||||
s.execute("format.font", &json!({"family": "Montserrat"})).unwrap();
|
||||
s.execute("format.align", &json!({"align": "right"})).unwrap();
|
||||
let st = s.execute("format.state", &json!({})).unwrap();
|
||||
assert_eq!(st["fontRef"], "Montserrat", "layout placeholder: {st}");
|
||||
s.execute("view.closeMaster", &json!({})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "title"})).unwrap();
|
||||
let slide = s.doc().unwrap().selection.slide;
|
||||
let fresh = title_of(&s.doc().unwrap().doc.slides[slide].shapes);
|
||||
for (index, id) in [(slide, fresh), (0, existing)] {
|
||||
s.execute("slide.go", &json!({"index": index})).unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
let st = s.execute("format.state", &json!({})).unwrap();
|
||||
assert_eq!(st["fontRef"], "Montserrat", "slide placeholder {id}: {st}");
|
||||
assert_eq!(st["align"], "r", "slide placeholder {id}: {st}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_body_placeholder_format_keeps_per_level_formatting() {
|
||||
let mut s = Session::new();
|
||||
sample::open_sample(&mut s).unwrap();
|
||||
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
|
||||
let body_idx =
|
||||
s.doc().unwrap().doc.masters[0].shapes.iter().position(|sh| sh.ph.as_ref().is_some_and(|p| p.kind == model::PhType::Body)).unwrap();
|
||||
let id = {
|
||||
let st = s.doc_mut().unwrap();
|
||||
let sh = &mut std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).masters[0]).shapes[body_idx];
|
||||
let mut body = model::TextBody::from_text("Alpha\nBeta");
|
||||
body.paragraphs[1].level = 1;
|
||||
for (l, size) in [(0u8, 32.0), (1, 28.0)] {
|
||||
let mut lvl = model::text::LevelStyle::default();
|
||||
lvl.run.size = Some(size);
|
||||
body.list_style.set(l, lvl);
|
||||
}
|
||||
sh.text = Some(body);
|
||||
sh.id
|
||||
};
|
||||
let level =
|
||||
|s: &Session, l: u8| s.doc().unwrap().doc.masters[0].shapes[body_idx].text.as_ref().unwrap().list_style.level(l).cloned().unwrap_or_default();
|
||||
// Bolding the level-2 paragraph reaches level 2 only.
|
||||
s.doc_mut().unwrap().selection.shapes = vec![id];
|
||||
s.doc_mut().unwrap().selection.text = Some(TextSel { shape: id, anchor: (1, 0), caret: (1, 4), cell: None, notes: false, line_end: None });
|
||||
s.execute("format.bold", &json!({"on": true})).unwrap();
|
||||
assert_eq!(level(&s, 1).run.bold, Some(true));
|
||||
for l in [0, 2, 3, 8] {
|
||||
assert_eq!(level(&s, l).run.bold, None, "level {}", l + 1);
|
||||
}
|
||||
// Grow and shrink step each level from its own size.
|
||||
s.doc_mut().unwrap().selection.text = None;
|
||||
// The text itself: a run's explicit size, else its level's effective size.
|
||||
let shown = |s: &Session| -> Vec<Option<f64>> {
|
||||
let st = s.doc().unwrap();
|
||||
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection).unwrap();
|
||||
let sh = &st.doc.masters[0].shapes[body_idx];
|
||||
let body = sh.text.as_ref().unwrap();
|
||||
body.paragraphs.iter().map(|p| p.runs[0].props.size.or(deckcraft_model::resolve::level_style(&ctx, sh, p.level).run.size)).collect()
|
||||
};
|
||||
s.execute("format.grow", &json!({})).unwrap();
|
||||
assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(36.0), Some(32.0)));
|
||||
assert_eq!(shown(&s), vec![Some(36.0), Some(32.0)]);
|
||||
s.execute("format.shrink", &json!({})).unwrap();
|
||||
s.execute("format.shrink", &json!({})).unwrap();
|
||||
assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0)));
|
||||
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]);
|
||||
}
|
||||
|
||||
@@ -216,26 +216,37 @@ impl Decoder {
|
||||
pub fn configure_avcc(&mut self, avcc: &[u8]) -> Result<()> {
|
||||
ensure!(avcc.len() >= 7, "avcC too short");
|
||||
ensure!(avcc[0] == 1, "unsupported avcC version {}", avcc[0]);
|
||||
self.nal_length_size = Some((avcc[4] & 3) as usize + 1);
|
||||
let mut pos = 5;
|
||||
let read_sets = |pos: &mut usize, count: usize, d: &mut Decoder| -> Result<()> {
|
||||
let mut sets = Vec::new();
|
||||
let read_sets = |pos: &mut usize, count: usize, sets: &mut Vec<(usize, usize)>| -> Result<()> {
|
||||
for _ in 0..count {
|
||||
ensure!(*pos + 2 <= avcc.len(), "avcC truncated");
|
||||
let len = u16::from_be_bytes([avcc[*pos], avcc[*pos + 1]]) as usize;
|
||||
*pos += 2;
|
||||
ensure!(*pos + len <= avcc.len(), "avcC truncated");
|
||||
d.handle_nal(&avcc[*pos..*pos + len], 0)?;
|
||||
sets.push((*pos, len));
|
||||
*pos += len;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
let nsps = (avcc[pos] & 0x1f) as usize;
|
||||
pos += 1;
|
||||
read_sets(&mut pos, nsps, self)?;
|
||||
read_sets(&mut pos, nsps, &mut sets)?;
|
||||
ensure!(pos < avcc.len(), "avcC truncated");
|
||||
let npps = avcc[pos] as usize;
|
||||
pos += 1;
|
||||
read_sets(&mut pos, npps, self)
|
||||
read_sets(&mut pos, npps, &mut sets)?;
|
||||
for (i, (start, len)) in sets.iter().enumerate() {
|
||||
let nal = &avcc[*start..*start + *len];
|
||||
let header = nal.first().copied().ok_or_else(|| Error::Invalid("empty avcC parameter set".into()))?;
|
||||
let expected = if i < nsps { nal_type::SPS } else { nal_type::PPS };
|
||||
ensure!(header & 0x1f == expected, "avcC parameter set has NAL type {}, expected {expected}", header & 0x1f);
|
||||
}
|
||||
for (start, len) in sets {
|
||||
self.handle_nal(&avcc[start..start + len], 0)?;
|
||||
}
|
||||
self.nal_length_size = Some((avcc[4] & 3) as usize + 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Length of NAL length prefixes (from avcC), or None for Annex-B input.
|
||||
|
||||
@@ -82,6 +82,56 @@ fn pps(cabac: bool, weighted: bool) -> Vec<u8> {
|
||||
nal(3, 8, &w.finish())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avcc_accepts_generated_sps_and_pps() {
|
||||
let sps = sps(66, 4, false, PocType::T2);
|
||||
let pps = pps(false, false);
|
||||
let (sps, pps) = (&sps[4..], &pps[4..]);
|
||||
let mut avcc = vec![1, 66, 0, 30, 0xff, 0xe1];
|
||||
avcc.extend_from_slice(&(sps.len() as u16).to_be_bytes());
|
||||
avcc.extend_from_slice(sps);
|
||||
avcc.push(1);
|
||||
avcc.extend_from_slice(&(pps.len() as u16).to_be_bytes());
|
||||
avcc.extend_from_slice(pps);
|
||||
assert!(Decoder::from_avcc(&avcc).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn avcc_rejects_a_valid_picture_slice_without_starting_a_picture() {
|
||||
let sequence = sps(66, 4, false, PocType::T0);
|
||||
let picture = pps(false, false);
|
||||
let slice = slice_nal(
|
||||
&Pic {
|
||||
idr: true,
|
||||
frame_num: 0,
|
||||
ref_idc: 3,
|
||||
long_term_ref: false,
|
||||
mods: vec![],
|
||||
mmcos: vec![],
|
||||
mbs: vec![Some(vec![128; 384]); NMB],
|
||||
b: false,
|
||||
poc_lsb: Some(0),
|
||||
poc_delta: None,
|
||||
wt: None,
|
||||
},
|
||||
false,
|
||||
);
|
||||
let mut avcc = vec![1, 66, 0, 30, 0xff, 0xe1];
|
||||
let nal = &sequence[4..];
|
||||
avcc.extend_from_slice(&(nal.len() as u16).to_be_bytes());
|
||||
avcc.extend_from_slice(nal);
|
||||
avcc.push(2);
|
||||
avcc.extend_from_slice(&((picture.len() - 4) as u16).to_be_bytes());
|
||||
avcc.extend_from_slice(&picture[4..]);
|
||||
avcc.extend_from_slice(&((slice.len() - 4) as u16).to_be_bytes());
|
||||
avcc.extend_from_slice(&slice[4..]);
|
||||
|
||||
let mut decoder = Decoder::with_threads(1);
|
||||
assert!(decoder.configure_avcc(&avcc).is_err());
|
||||
assert!(decoder.flush().is_empty());
|
||||
assert_eq!(decoder.stats().pictures, 0);
|
||||
}
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
struct Pic {
|
||||
idr: bool,
|
||||
|
||||
+43
-13
@@ -160,22 +160,35 @@ impl Decoder {
|
||||
pub fn configure_hvcc(&mut self, hvcc: &[u8]) -> Result<()> {
|
||||
ensure!(hvcc.len() >= 23, "hvcC too short");
|
||||
ensure!(hvcc[0] == 1, "unsupported hvcC version {}", hvcc[0]);
|
||||
self.nal_length_size = Some((hvcc[21] & 3) as usize + 1);
|
||||
let num_arrays = hvcc[22] as usize;
|
||||
let mut pos = 23;
|
||||
for _ in 0..num_arrays {
|
||||
ensure!(pos + 3 <= hvcc.len(), "hvcC truncated");
|
||||
let n = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize;
|
||||
pos += 3;
|
||||
for _ in 0..n {
|
||||
ensure!(pos + 2 <= hvcc.len(), "hvcC truncated");
|
||||
let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize;
|
||||
pos += 2;
|
||||
ensure!(pos + len <= hvcc.len(), "hvcC truncated");
|
||||
self.handle_nal(&hvcc[pos..pos + len], 0)?;
|
||||
pos += len;
|
||||
for pass in 0..2 {
|
||||
let mut pos = 23;
|
||||
for _ in 0..num_arrays {
|
||||
ensure!(pos <= hvcc.len() && hvcc.len() - pos >= 3, "hvcC truncated");
|
||||
let array_type = hvcc[pos] & 0x3f;
|
||||
let n = u16::from_be_bytes([hvcc[pos + 1], hvcc[pos + 2]]) as usize;
|
||||
pos += 3;
|
||||
for _ in 0..n {
|
||||
ensure!(pos <= hvcc.len() && hvcc.len() - pos >= 2, "hvcC truncated");
|
||||
let len = u16::from_be_bytes([hvcc[pos], hvcc[pos + 1]]) as usize;
|
||||
pos += 2;
|
||||
ensure!(len <= hvcc.len() - pos, "hvcC truncated");
|
||||
let nal = &hvcc[pos..pos + len];
|
||||
if pass == 0 {
|
||||
let header = NalHeader::parse(nal)?;
|
||||
ensure!(
|
||||
[nal_type::VPS, nal_type::SPS, nal_type::PPS, nal_type::PREFIX_SEI, nal_type::SUFFIX_SEI].contains(&array_type),
|
||||
"hvcC array has unsupported NAL type {array_type}"
|
||||
);
|
||||
ensure!(header.nal_type == array_type, "hvcC array type {array_type} disagrees with NAL header type {}", header.nal_type);
|
||||
} else {
|
||||
self.handle_nal(nal, 0)?;
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.nal_length_size = Some((hvcc[21] & 3) as usize + 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -653,3 +666,20 @@ fn make_picture(o: &Output) -> Picture {
|
||||
draft: o.meta.draft,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod config_tests {
|
||||
use super::Decoder;
|
||||
|
||||
#[test]
|
||||
fn hvcc_skips_prefix_and_suffix_sei_arrays() {
|
||||
let mut config = vec![0; 23];
|
||||
config[0] = 1;
|
||||
config[22] = 2;
|
||||
config.extend_from_slice(&[39, 0, 1, 0, 2, 0x4e, 0x01]);
|
||||
config.extend_from_slice(&[40, 0, 1, 0, 2, 0x50, 0x01]);
|
||||
|
||||
let decoder = Decoder::from_hvcc(&config);
|
||||
assert!(decoder.is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,3 +644,62 @@ fn picture_store_is_checked_against_the_memory_budget() {
|
||||
assert!(!decodes_within(&stream, pictures * one - 1));
|
||||
assert!(decodes_within(&stream, deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_accepts_generated_vps_sps_and_pps() {
|
||||
let sets = [vps(), sps(true), pps(0, &None, false)];
|
||||
let mut hvcc = vec![0; 23];
|
||||
hvcc[0] = 1;
|
||||
hvcc[22] = sets.len() as u8;
|
||||
for (kind, set) in [32, 33, 34].into_iter().zip(sets) {
|
||||
let nal = &set[4..];
|
||||
hvcc.extend_from_slice(&[kind, 0, 1]);
|
||||
hvcc.extend_from_slice(&(nal.len() as u16).to_be_bytes());
|
||||
hvcc.extend_from_slice(nal);
|
||||
}
|
||||
assert!(Decoder::from_hvcc(&hvcc).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hvcc_rejects_a_valid_picture_slice_without_starting_a_picture() {
|
||||
let sets = [vps(), sps(true), pps(0, &None, false)];
|
||||
let mut stream = Vec::new();
|
||||
let mut saved = None;
|
||||
encode_picture(
|
||||
&PicDesc {
|
||||
nal_type: 19,
|
||||
poc: 0,
|
||||
intra: true,
|
||||
cu: Cu::Pcm,
|
||||
pps_id: 0,
|
||||
st: vec![],
|
||||
lt: vec![],
|
||||
num_ref: 0,
|
||||
list_entry: None,
|
||||
segments: whole(WC * HC),
|
||||
},
|
||||
&None,
|
||||
&content(0),
|
||||
&mut stream,
|
||||
&mut saved,
|
||||
);
|
||||
|
||||
let mut hvcc = vec![0; 23];
|
||||
hvcc[0] = 1;
|
||||
hvcc[22] = 4;
|
||||
for (kind, set) in [32, 33, 34].into_iter().zip(sets) {
|
||||
let nal = &set[4..];
|
||||
hvcc.extend_from_slice(&[kind, 0, 1]);
|
||||
hvcc.extend_from_slice(&(nal.len() as u16).to_be_bytes());
|
||||
hvcc.extend_from_slice(nal);
|
||||
}
|
||||
let slice = &stream[4..];
|
||||
hvcc.extend_from_slice(&[19, 0, 1]);
|
||||
hvcc.extend_from_slice(&(slice.len() as u16).to_be_bytes());
|
||||
hvcc.extend_from_slice(slice);
|
||||
|
||||
let mut decoder = Decoder::with_threads(1);
|
||||
assert!(decoder.configure_hvcc(&hvcc).is_err());
|
||||
assert!(decoder.flush().is_empty());
|
||||
assert_eq!(decoder.stats().pictures, 0);
|
||||
}
|
||||
|
||||
@@ -62,6 +62,40 @@ fn handshake_and_tool_list() {
|
||||
assert!(s.handle_line("{}").is_some());
|
||||
}
|
||||
|
||||
/// #1151: `insert_chart` takes x, y, w and h like `insert_picture` and `insert_table`.
|
||||
#[test]
|
||||
fn insert_chart_takes_a_box() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
tool(&mut s, "new_presentation", json!({"theme": "Harbor"}));
|
||||
let chart = json!({"type": "pie", "categories": ["A", "B"], "series": [{"name": "S", "values": [3, 5]}]});
|
||||
let box_of = |s: &mut Server, id: u64| {
|
||||
let sl = tool(s, "inspect_slide", json!({}));
|
||||
let shape = sl["shapes"].as_array().unwrap().iter().find(|x| x["id"].as_u64() == Some(id)).unwrap().clone();
|
||||
assert_eq!(shape["kind"], "chart");
|
||||
shape["box"].clone()
|
||||
};
|
||||
let mut args = chart.clone();
|
||||
for (k, v) in [("x", 50), ("y", 60), ("w", 300), ("h", 200)] {
|
||||
args[k] = json!(v);
|
||||
}
|
||||
let placed = tool(&mut s, "insert_chart", args)["id"].as_u64().unwrap();
|
||||
assert_eq!(box_of(&mut s, placed), json!([50.0, 60.0, 300.0, 200.0]));
|
||||
// without a full box the chart keeps its default place, as for pictures and tables
|
||||
let plain = tool(&mut s, "insert_chart", chart.clone())["id"].as_u64().unwrap();
|
||||
let default_box = box_of(&mut s, plain);
|
||||
let mut partial = chart.clone();
|
||||
partial["x"] = json!(50);
|
||||
let partial_id = tool(&mut s, "insert_chart", partial)["id"].as_u64().unwrap();
|
||||
assert_eq!(box_of(&mut s, partial_id), default_box);
|
||||
// a hostile box is answered, not a panic
|
||||
let mut odd = chart;
|
||||
for (k, v) in [("x", json!(-1e300)), ("y", json!(1e300)), ("w", json!(-5)), ("h", json!(0))] {
|
||||
odd[k] = v;
|
||||
}
|
||||
let r = call(&mut s, 11, "tools/call", json!({"name": "insert_chart", "arguments": odd}));
|
||||
assert!(r["result"].is_object(), "{r}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_builds_a_deck() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
|
||||
@@ -234,9 +234,9 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"insert_chart",
|
||||
"Insert chart",
|
||||
"Insert a chart (column, bar, line, pie, doughnut, area, scatter, stackedColumn…) with categories and series.",
|
||||
"Insert a chart (column, bar, line, pie, doughnut, area, scatter, stackedColumn…) with categories and series, optionally at a box.",
|
||||
obj(
|
||||
json!({"type": string("Chart type"), "title": string("Chart title"), "categories": {"type": "array", "items": {"type": "string"}}, "series": {"type": "array", "items": obj(json!({"name": string("Series name"), "values": {"type": "array", "items": {"type": "number"}}}), &["values"])}}),
|
||||
json!({"type": string("Chart type"), "title": string("Chart title"), "categories": {"type": "array", "items": {"type": "string"}}, "series": {"type": "array", "items": obj(json!({"name": string("Series name"), "values": {"type": "array", "items": {"type": "number"}}}), &["values"])}, "x": num("Left"), "y": num("Top"), "w": num("Width"), "h": num("Height")}),
|
||||
&[],
|
||||
),
|
||||
false,
|
||||
@@ -444,7 +444,13 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
}
|
||||
wrap(exec(b, "insert.table", p))
|
||||
}
|
||||
"insert_chart" => wrap(exec(b, "insert.chart", a)),
|
||||
"insert_chart" => {
|
||||
let mut p = a.clone();
|
||||
if let (Some(x), Some(y), Some(w), Some(h)) = (f(&a, "x"), f(&a, "y"), f(&a, "w"), f(&a, "h")) {
|
||||
p["rect"] = json!([x, y, w, h]);
|
||||
}
|
||||
wrap(exec(b, "insert.chart", p))
|
||||
}
|
||||
"select" => wrap(exec(b, "edit.select", a)),
|
||||
"pointer" => wrap(b.call("ui.pointer", a)),
|
||||
"select_tool" => wrap(b.call("ui.tool.select", a)),
|
||||
|
||||
@@ -22,4 +22,4 @@ log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
# Audio decoding for the formats we have no decoder of our own for (MPL-2.0, used unmodified,
|
||||
# as in FilmCraft).
|
||||
symphonia = { version = "0.5.5", default-features = false, features = ["aac", "adpcm", "alac", "flac", "mp1", "mp2", "mp3", "pcm", "vorbis", "wav", "aiff", "caf", "ogg", "mkv", "isomp4"] }
|
||||
symphonia = { version = "0.6.1", default-features = false, features = ["aac", "adpcm", "alac", "flac", "mp1", "mp2", "mp3", "pcm", "vorbis", "wav", "aiff", "caf", "ogg", "mkv", "isomp4"] }
|
||||
|
||||
@@ -72,6 +72,36 @@ pub struct GradientStop {
|
||||
pub color: ColorRef,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum GradientFlip {
|
||||
None,
|
||||
X,
|
||||
Y,
|
||||
Xy,
|
||||
}
|
||||
|
||||
impl GradientFlip {
|
||||
pub fn xml(self) -> &'static str {
|
||||
match self {
|
||||
GradientFlip::None => "none",
|
||||
GradientFlip::X => "x",
|
||||
GradientFlip::Y => "y",
|
||||
GradientFlip::Xy => "xy",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_xml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"none" => Some(GradientFlip::None),
|
||||
"x" => Some(GradientFlip::X),
|
||||
"y" => Some(GradientFlip::Y),
|
||||
"xy" => Some(GradientFlip::Xy),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum GradientShape {
|
||||
@@ -90,6 +120,8 @@ pub enum GradientShape {
|
||||
pub struct Gradient {
|
||||
pub stops: Vec<GradientStop>,
|
||||
pub shape: GradientShape,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flip: Option<GradientFlip>,
|
||||
#[serde(default = "crate::yes")]
|
||||
pub rotate_with_shape: bool,
|
||||
/// `a:tileRect`: the box the gradient is laid out in, as insets from the shape's box
|
||||
@@ -232,6 +264,31 @@ pub enum LineJoin {
|
||||
Miter,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LineAlign {
|
||||
#[default]
|
||||
Center,
|
||||
Inside,
|
||||
}
|
||||
|
||||
impl LineAlign {
|
||||
pub fn xml(self) -> &'static str {
|
||||
match self {
|
||||
LineAlign::Center => "ctr",
|
||||
LineAlign::Inside => "in",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_xml(value: &str) -> Option<Self> {
|
||||
match value {
|
||||
"ctr" => Some(LineAlign::Center),
|
||||
"in" => Some(LineAlign::Inside),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Compound {
|
||||
@@ -324,6 +381,8 @@ pub struct Line {
|
||||
pub dash: Option<Dash>,
|
||||
pub cap: Option<LineCap>,
|
||||
pub join: Option<LineJoin>,
|
||||
pub miter_limit: Option<u32>,
|
||||
pub align: Option<LineAlign>,
|
||||
pub compound: Option<Compound>,
|
||||
pub head: Option<LineEnd>,
|
||||
pub tail: Option<LineEnd>,
|
||||
@@ -347,6 +406,8 @@ impl Line {
|
||||
}
|
||||
self.cap = self.cap.or(parent.cap);
|
||||
self.join = self.join.or(parent.join);
|
||||
self.miter_limit = self.miter_limit.or(parent.miter_limit);
|
||||
self.align = self.align.or(parent.align);
|
||||
self.compound = self.compound.or(parent.compound);
|
||||
if self.head.is_none() {
|
||||
self.head.clone_from(&parent.head);
|
||||
|
||||
@@ -73,8 +73,13 @@ fn a_gradient_without_stops_takes_them_from_its_style() {
|
||||
let p = Presentation::default();
|
||||
let s = &p.slides[0];
|
||||
let ctx = Ctx::for_slide(&p, s).unwrap();
|
||||
let own =
|
||||
style::Gradient { tile: [0.0; 4], stops: vec![], shape: style::GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
let own = style::Gradient {
|
||||
tile: [0.0; 4],
|
||||
stops: vec![],
|
||||
shape: style::GradientShape::Linear { angle: 45.0, scaled: true },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
};
|
||||
let mut st = ShapeStyle::accent(SchemeSlot::Accent2);
|
||||
st.fill_ref.0 = 3;
|
||||
let sh = Shape { style: Some(st.clone()), fill: Some(Fill::Gradient(own.clone())), ..Default::default() };
|
||||
|
||||
@@ -236,10 +236,13 @@ pub struct ParaProps {
|
||||
pub space_after: Option<Spacing>,
|
||||
pub bullet: Option<Bullet>,
|
||||
pub bullet_font: Option<String>,
|
||||
pub bullet_font_follow_text: Option<bool>,
|
||||
/// `None` = follow text colour.
|
||||
pub bullet_color: Option<ColorRef>,
|
||||
pub bullet_color_follow_text: Option<bool>,
|
||||
/// Bullet size as a fraction of the text size.
|
||||
pub bullet_size: Option<f64>,
|
||||
pub bullet_size_follow_text: Option<bool>,
|
||||
pub tabs: Option<Vec<TabStop>>,
|
||||
pub default_tab: Option<f64>,
|
||||
pub rtl: Option<bool>,
|
||||
@@ -263,13 +266,36 @@ impl ParaProps {
|
||||
if self.bullet.is_none() {
|
||||
self.bullet.clone_from(&p.bullet);
|
||||
}
|
||||
if self.bullet_font.is_none() {
|
||||
self.bullet_font.clone_from(&p.bullet_font);
|
||||
if self.bullet_font_follow_text == Some(false) || self.bullet_font.is_some() {
|
||||
self.bullet_font_follow_text = None;
|
||||
}
|
||||
if self.bullet_color.is_none() {
|
||||
self.bullet_color.clone_from(&p.bullet_color);
|
||||
if self.bullet_font.is_none() && self.bullet_font_follow_text.is_none() {
|
||||
if p.bullet_font_follow_text == Some(true) {
|
||||
self.bullet_font_follow_text = Some(true);
|
||||
} else {
|
||||
self.bullet_font.clone_from(&p.bullet_font);
|
||||
}
|
||||
}
|
||||
if self.bullet_color_follow_text == Some(false) || self.bullet_color.is_some() {
|
||||
self.bullet_color_follow_text = None;
|
||||
}
|
||||
if self.bullet_color.is_none() && self.bullet_color_follow_text.is_none() {
|
||||
if p.bullet_color_follow_text == Some(true) {
|
||||
self.bullet_color_follow_text = Some(true);
|
||||
} else {
|
||||
self.bullet_color.clone_from(&p.bullet_color);
|
||||
}
|
||||
}
|
||||
if self.bullet_size_follow_text == Some(false) || self.bullet_size.is_some() {
|
||||
self.bullet_size_follow_text = None;
|
||||
}
|
||||
if self.bullet_size.is_none() && self.bullet_size_follow_text.is_none() {
|
||||
if p.bullet_size_follow_text == Some(true) {
|
||||
self.bullet_size_follow_text = Some(true);
|
||||
} else {
|
||||
self.bullet_size = p.bullet_size;
|
||||
}
|
||||
}
|
||||
self.bullet_size = self.bullet_size.or(p.bullet_size);
|
||||
if self.tabs.is_none() {
|
||||
self.tabs.clone_from(&p.tabs);
|
||||
}
|
||||
@@ -632,3 +658,53 @@ impl TextBody {
|
||||
n + self.paragraphs.len().saturating_sub(1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_color::Rgba;
|
||||
|
||||
#[test]
|
||||
fn explicit_bullet_follow_text_resets_block_inheritance() {
|
||||
let parent = ParaProps {
|
||||
bullet_font: Some("Inter".into()),
|
||||
bullet_color: Some(ColorRef::rgb(Rgba::rgb(0, 0, 0))),
|
||||
bullet_size: Some(2.0),
|
||||
..Default::default()
|
||||
};
|
||||
let mut child = ParaProps {
|
||||
bullet_font_follow_text: Some(true),
|
||||
bullet_color_follow_text: Some(true),
|
||||
bullet_size_follow_text: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
child.inherit(&parent);
|
||||
|
||||
assert_eq!(child.bullet_font, None);
|
||||
assert_eq!(child.bullet_color, None);
|
||||
assert_eq!(child.bullet_size, None);
|
||||
|
||||
let mut inherited_reset = ParaProps::default();
|
||||
inherited_reset.inherit(&ParaProps {
|
||||
bullet_font_follow_text: Some(true),
|
||||
bullet_color_follow_text: Some(true),
|
||||
bullet_size_follow_text: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(inherited_reset.bullet_font_follow_text, Some(true));
|
||||
assert_eq!(inherited_reset.bullet_color_follow_text, Some(true));
|
||||
assert_eq!(inherited_reset.bullet_size_follow_text, Some(true));
|
||||
|
||||
let mut explicit_false = ParaProps {
|
||||
bullet_font_follow_text: Some(false),
|
||||
bullet_color_follow_text: Some(false),
|
||||
bullet_size_follow_text: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
explicit_false.inherit(&parent);
|
||||
assert_eq!(explicit_false.bullet_font, parent.bullet_font);
|
||||
assert_eq!(explicit_false.bullet_color, parent.bullet_color);
|
||||
assert_eq!(explicit_false.bullet_size, parent.bullet_size);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ pub fn default_format() -> FormatScheme {
|
||||
],
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
})
|
||||
};
|
||||
FormatScheme {
|
||||
|
||||
+45
-11
@@ -196,6 +196,21 @@ pub struct OggFile {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Source bytes damage recovery may read per file: the windows the scan for the next capture
|
||||
/// pattern fills and the pages read only to find their CRC wrong.
|
||||
const RECOVERY_BUDGET: u64 = 32 << 20;
|
||||
/// Warnings kept per file (not counting the one about the budget); the rest are counted in one last entry.
|
||||
const MAX_WARNINGS: usize = 256;
|
||||
|
||||
/// Record a warning, keeping at most `MAX_WARNINGS` and one entry saying the rest were dropped.
|
||||
fn warn(warnings: &mut Vec<String>, msg: String) {
|
||||
match warnings.len().cmp(&MAX_WARNINGS) {
|
||||
std::cmp::Ordering::Less => warnings.push(msg),
|
||||
std::cmp::Ordering::Equal => warnings.push("further warnings omitted".into()),
|
||||
std::cmp::Ordering::Greater => {}
|
||||
}
|
||||
}
|
||||
|
||||
struct Partial {
|
||||
parts: Vec<(u64, u32)>,
|
||||
size: u32,
|
||||
@@ -219,7 +234,7 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
match scanner.find(src, pos + 1, len)? {
|
||||
Some(p) => {
|
||||
if found_any {
|
||||
warnings.push(format!("lost sync at byte {pos}; resumed at {p}"));
|
||||
warn(&mut warnings, format!("lost sync at byte {pos}; resumed at {p}"));
|
||||
}
|
||||
pos = p;
|
||||
continue;
|
||||
@@ -229,14 +244,14 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
}
|
||||
let nseg = hdr[26] as usize;
|
||||
if pos + 27 + nseg as u64 > len {
|
||||
warnings.push(format!("truncated page header at byte {pos}"));
|
||||
warn(&mut warnings, format!("truncated page header at byte {pos}"));
|
||||
break;
|
||||
}
|
||||
src.read_at(pos + 27, &mut hdr[27..27 + nseg])?;
|
||||
let body_len: u64 = hdr[27..27 + nseg].iter().map(|&b| b as u64).sum();
|
||||
let page_len = 27 + nseg as u64 + body_len;
|
||||
if pos + page_len > len {
|
||||
warnings.push(format!("truncated page at byte {pos}"));
|
||||
warn(&mut warnings, format!("truncated page at byte {pos}"));
|
||||
break;
|
||||
}
|
||||
let mut page = vec![0u8; page_len as usize];
|
||||
@@ -244,7 +259,11 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
let stored_crc = u32::from_le_bytes([page[22], page[23], page[24], page[25]]);
|
||||
page[22..26].fill(0);
|
||||
if crc32(&page) != stored_crc {
|
||||
warnings.push(format!("bad CRC on page at byte {pos}"));
|
||||
warn(&mut warnings, format!("bad CRC on page at byte {pos}"));
|
||||
// a page read only to find it damaged is recovery work, charged like the scan's windows
|
||||
if scanner.charge(page_len) {
|
||||
break;
|
||||
}
|
||||
pos += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -275,7 +294,7 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
// a continued page without a pending packet (lost a page): drop the fragment
|
||||
let continued = flags & 0x01 != 0;
|
||||
if !continued && partial.remove(&serial).is_some() {
|
||||
warnings.push(format!("incomplete packet before page at byte {pos}"));
|
||||
warn(&mut warnings, format!("incomplete packet before page at byte {pos}"));
|
||||
}
|
||||
let mut completed: Vec<Packet> = Vec::new();
|
||||
let mut off = 0u64;
|
||||
@@ -332,7 +351,11 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
return Err(Error::NotOgg);
|
||||
}
|
||||
if !partial.is_empty() {
|
||||
warnings.push("file ends inside a packet".into());
|
||||
warn(&mut warnings, "file ends inside a packet".into());
|
||||
}
|
||||
if scanner.spent() {
|
||||
// once per file and last, so it is not subject to the cap on the others
|
||||
warnings.push("recovery budget spent; the rest of the damaged data was not searched".into());
|
||||
}
|
||||
Ok(OggFile { streams, warnings })
|
||||
}
|
||||
@@ -340,12 +363,23 @@ pub fn open(src: &(impl ByteSource + ?Sized)) -> Result<OggFile> {
|
||||
struct CaptureScanner {
|
||||
buf: Vec<u8>,
|
||||
buf_start: u64,
|
||||
budget: usize,
|
||||
budget: u64,
|
||||
}
|
||||
|
||||
impl CaptureScanner {
|
||||
fn new() -> Self {
|
||||
Self { buf: Vec::new(), buf_start: 0, budget: 32 * 1024 * 1024 }
|
||||
Self { buf: Vec::new(), buf_start: 0, budget: RECOVERY_BUDGET }
|
||||
}
|
||||
|
||||
/// Charge `bytes` of recovery work; true once the budget is spent.
|
||||
fn charge(&mut self, bytes: u64) -> bool {
|
||||
self.budget = self.budget.saturating_sub(bytes);
|
||||
self.spent()
|
||||
}
|
||||
|
||||
/// Nothing left to search with: fewer bytes than a capture pattern can still be charged for.
|
||||
fn spent(&self) -> bool {
|
||||
self.budget < 4
|
||||
}
|
||||
|
||||
fn find(&mut self, src: &(impl ByteSource + ?Sized), from: u64, len: u64) -> Result<Option<u64>> {
|
||||
@@ -360,17 +394,17 @@ impl CaptureScanner {
|
||||
at = buf_end.saturating_sub(3);
|
||||
continue;
|
||||
}
|
||||
if self.budget == 0 {
|
||||
if self.spent() {
|
||||
return Ok(None);
|
||||
}
|
||||
let n = ((len - at) as usize).min(64 * 1024).min(self.budget);
|
||||
let n = ((len - at) as usize).min(64 * 1024).min(usize::try_from(self.budget).unwrap_or(usize::MAX));
|
||||
if n < 4 {
|
||||
break;
|
||||
}
|
||||
self.buf.resize(n, 0);
|
||||
src.read_at(at, &mut self.buf)?;
|
||||
self.buf_start = at;
|
||||
self.budget = self.budget.saturating_sub(n);
|
||||
self.budget = self.budget.saturating_sub(n as u64);
|
||||
if let Some(i) = self.buf.windows(4).position(|w| w == b"OggS") {
|
||||
return Ok(Some(at + i as u64));
|
||||
}
|
||||
|
||||
+79
-1
@@ -1,6 +1,9 @@
|
||||
//! Synthetic Ogg streams: a small page writer (RFC 3533) and Opus-like packets.
|
||||
|
||||
use super::{Codec, Error, OpusTiming, Packet, Stream, crc32, open, opus_packet_samples, sniff};
|
||||
use std::cell::Cell;
|
||||
use std::io;
|
||||
|
||||
use super::{ByteSource, CaptureScanner, Codec, Error, OpusTiming, Packet, Stream, crc32, open, opus_packet_samples, sniff};
|
||||
|
||||
/// Write one page holding `segments` (lacing already computed by `lace`).
|
||||
fn page(out: &mut Vec<u8>, flags: u8, granule: i64, serial: u32, seq: u32, lacing: &[u8], body: &[u8]) {
|
||||
@@ -196,3 +199,78 @@ fn opus_timing_rejects_a_granule_that_overflows() {
|
||||
assert_eq!(ok.total, 480);
|
||||
assert!(OpusTiming::of(&packet(i64::MIN), 0).is_err());
|
||||
}
|
||||
|
||||
/// A source that counts the bytes read from it.
|
||||
struct Counting<'a> {
|
||||
data: &'a [u8],
|
||||
read: Cell<u64>,
|
||||
}
|
||||
|
||||
impl ByteSource for Counting<'_> {
|
||||
fn len(&self) -> u64 {
|
||||
self.data.len() as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
self.read.set(self.read.get() + buf.len() as u64);
|
||||
self.data.read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid file followed by `len` bytes of 0xFF with a capture pattern every 64 bytes. Each one
|
||||
/// claims 255 full lacing values, so it reads as a page of about 64 KiB that fails its CRC.
|
||||
fn dense_fake_pages(len: usize) -> Vec<u8> {
|
||||
let mut f = opus_file(6, 312, 0);
|
||||
let start = f.len();
|
||||
f.resize(start + len, 0xFF);
|
||||
for at in (start..start + len - 64).step_by(64) {
|
||||
f[at..at + 5].copy_from_slice(b"OggS\0");
|
||||
f[at + 26] = 255;
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_reads_a_bounded_amount_of_a_damaged_file() {
|
||||
let f = dense_fake_pages(1 << 20);
|
||||
let src = Counting { data: &f, read: Cell::new(0) };
|
||||
let o = open(&src).unwrap();
|
||||
assert_eq!(o.streams[0].packets.len(), 6, "the good pages are kept");
|
||||
let read = src.read.get();
|
||||
assert!(read <= 40 << 20, "read {read} bytes of a {} byte file", f.len());
|
||||
assert!(o.warnings.iter().any(|w| w.contains("recovery budget")), "{:?}", o.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warnings_are_capped() {
|
||||
// 100,000 pages with no segments and a wrong CRC, 27 bytes each
|
||||
let mut f = opus_file(6, 312, 0);
|
||||
for _ in 0..100_000 {
|
||||
let at = f.len();
|
||||
f.extend_from_slice(b"OggS\0\0");
|
||||
f.resize(at + 27, 0);
|
||||
f[at + 22] = 1;
|
||||
}
|
||||
let o = open(&f).unwrap();
|
||||
assert!(o.warnings.len() <= 257, "{} warnings", o.warnings.len());
|
||||
assert!(o.warnings.iter().any(|w| w.contains("omitted")), "{:?}", o.warnings.last());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_of_good_pages_is_not_charged_for_recovery() {
|
||||
let f = opus_file(2000, 312, 0);
|
||||
let src = Counting { data: &f, read: Cell::new(0) };
|
||||
let o = open(&src).unwrap();
|
||||
assert_eq!(o.streams[0].packets.len(), 2000);
|
||||
assert!(o.warnings.is_empty(), "{:?}", o.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_budget_too_small_to_search_is_spent() {
|
||||
// 1 to 3 bytes left cannot hold a capture pattern: the scan would stop without saying why
|
||||
let mut scanner = CaptureScanner::new();
|
||||
assert!(!scanner.spent());
|
||||
assert!(scanner.charge(super::RECOVERY_BUDGET - 3));
|
||||
assert!(scanner.spent());
|
||||
let f = opus_file(6, 312, 0);
|
||||
assert_eq!(scanner.find(&f, 0, f.len() as u64).unwrap(), None);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] }
|
||||
hayro-syntax = { version = "=0.8.0", default-features = false, features = ["std"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
@@ -329,15 +329,26 @@ pub fn parse_path(d: &str) -> Vec<(char, Vec<f64>)> {
|
||||
token.clear();
|
||||
}
|
||||
};
|
||||
for ch in d.chars() {
|
||||
let cs: Vec<char> = d.chars().collect();
|
||||
for (i, &ch) in cs.iter().enumerate() {
|
||||
match ch {
|
||||
// An 'E' inside a number followed by a digit (or a sign and a digit) is an exponent,
|
||||
// not the end command.
|
||||
'E' if !token.is_empty() && {
|
||||
let n1 = cs.get(i + 1).copied().unwrap_or(' ');
|
||||
let n2 = cs.get(i + 2).copied().unwrap_or(' ');
|
||||
n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit())
|
||||
} =>
|
||||
{
|
||||
token.push(ch)
|
||||
}
|
||||
'M' | 'L' | 'C' | 'Q' | 'Z' | 'm' | 'l' | 'c' | 'q' | 'z' | 'E' => {
|
||||
push_tok(&mut token, &mut nums);
|
||||
flush(cmd, &mut nums, &mut out);
|
||||
cmd = Some(ch.to_ascii_uppercase());
|
||||
}
|
||||
' ' | ',' | '\t' | '\n' => push_tok(&mut token, &mut nums),
|
||||
'-' if !token.is_empty() && !token.ends_with('e') => {
|
||||
'-' if !token.is_empty() && !token.ends_with(['e', 'E']) => {
|
||||
push_tok(&mut token, &mut nums);
|
||||
token.push(ch);
|
||||
}
|
||||
@@ -472,6 +483,15 @@ mod tests {
|
||||
assert_eq!(back[0].d, "M 0 0 L 100000 0 C 100000 10000 90000 50000 50000 50000 Q 0 50000 0 0 Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_path_uppercase_exponent() {
|
||||
assert_eq!(parse_path("M 1E-5 2 L 3 4"), vec![('M', vec![1e-5, 2.0]), ('L', vec![3.0, 4.0])]);
|
||||
assert_eq!(parse_path("M 1.5E3 2"), vec![('M', vec![1500.0, 2.0])]);
|
||||
assert_eq!(parse_path("M 1e-5 2"), vec![('M', vec![1e-5, 2.0])]);
|
||||
// An 'E' right after a number with no exponent digits is the end command.
|
||||
assert_eq!(parse_path("M 0 0E"), vec![('M', vec![0.0, 0.0]), ('E', vec![])]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_path_tokens() {
|
||||
let v = parse_path("M0,0 L10-5 Z");
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_geom::{Xfrm, emu_to_pt};
|
||||
use deckcraft_model::style::{
|
||||
ColorBase, ColorRef, Compound, Dash, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, LineCap, LineEnd, LineJoin, PatternFill,
|
||||
PictureFill, PictureMode, Reflection, Shadow,
|
||||
ColorBase, ColorRef, Compound, Dash, Effects, Fill, Glow, Gradient, GradientFlip, GradientShape, GradientStop, Line, LineAlign, LineCap, LineEnd,
|
||||
LineJoin, PatternFill, PictureFill, PictureMode, Reflection, Shadow,
|
||||
};
|
||||
use deckcraft_model::text::{
|
||||
Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, LevelStyle, ListStyle, ParaProps, Paragraph, Run, RunKind, RunProps, Spacing,
|
||||
@@ -122,7 +122,13 @@ pub fn gradient(f: &El) -> Gradient {
|
||||
GradientShape::Linear { angle: 90.0, scaled: false }
|
||||
};
|
||||
let tile = f.child("tileRect").map(|r| [pct(r, "l"), pct(r, "t"), pct(r, "r"), pct(r, "b")].map(|v| v.unwrap_or(0.0).clamp(-10.0, 10.0)));
|
||||
Gradient { tile: tile.unwrap_or_default(), stops, shape, rotate_with_shape: f.bool("rotWithShape").unwrap_or(true) }
|
||||
Gradient {
|
||||
tile: tile.unwrap_or_default(),
|
||||
stops,
|
||||
shape,
|
||||
flip: f.attr("flip").and_then(GradientFlip::from_xml),
|
||||
rotate_with_shape: f.bool("rotWithShape").unwrap_or(true),
|
||||
}
|
||||
}
|
||||
|
||||
/// `blipFill` (either `a:` or `p:`) → picture fill, registering the image as media.
|
||||
@@ -222,6 +228,7 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line {
|
||||
_ => LineCap::Flat,
|
||||
});
|
||||
l.compound = ln.attr("cmpd").map(Compound::from_xml);
|
||||
l.align = ln.attr("algn").and_then(LineAlign::from_xml);
|
||||
l.fill = fill_of(imp, part, ln);
|
||||
for e in ln.elements() {
|
||||
match e.local() {
|
||||
@@ -232,7 +239,10 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line {
|
||||
}
|
||||
"round" => l.join = Some(LineJoin::Round),
|
||||
"bevel" => l.join = Some(LineJoin::Bevel),
|
||||
"miter" => l.join = Some(LineJoin::Miter),
|
||||
"miter" => {
|
||||
l.join = Some(LineJoin::Miter);
|
||||
l.miter_limit = e.attr("lim").and_then(parse_miter_limit);
|
||||
}
|
||||
"headEnd" => l.head = line_end(e),
|
||||
"tailEnd" => l.tail = line_end(e),
|
||||
_ => {}
|
||||
@@ -241,6 +251,17 @@ pub fn line(imp: &mut Imp, part: &Part, ln: &El) -> Line {
|
||||
l
|
||||
}
|
||||
|
||||
fn parse_miter_limit(value: &str) -> Option<u32> {
|
||||
const MAX: u32 = i32::MAX as u32;
|
||||
let value = value.trim();
|
||||
if let Some(percent) = value.strip_suffix('%') {
|
||||
let units = percent.trim().parse::<f64>().ok()? * 1000.0;
|
||||
(units.is_finite() && (0.0..=MAX as f64).contains(&units)).then(|| units.round() as u32)
|
||||
} else {
|
||||
value.parse::<u32>().ok().filter(|limit| *limit <= MAX)
|
||||
}
|
||||
}
|
||||
|
||||
fn line_end(e: &El) -> Option<LineEnd> {
|
||||
let kind = e.attr("type").unwrap_or("none");
|
||||
Some(LineEnd { kind: kind.to_string(), w: e.attr("w").unwrap_or("med").to_string(), len: e.attr("len").unwrap_or("med").to_string() })
|
||||
@@ -431,8 +452,11 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
|
||||
}
|
||||
}
|
||||
"buFont" => pp.bullet_font = e.attr("typeface").map(String::from),
|
||||
"buFontTx" => pp.bullet_font_follow_text = Some(true),
|
||||
"buClr" => pp.bullet_color = color(e),
|
||||
"buClrTx" => pp.bullet_color_follow_text = Some(true),
|
||||
"buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)),
|
||||
"buSzTx" => pp.bullet_size_follow_text = Some(true),
|
||||
"tabLst" => {
|
||||
pp.tabs = Some(
|
||||
e.children_named("tab")
|
||||
@@ -668,6 +692,16 @@ mod tests {
|
||||
assert_eq!(color(&d.root).unwrap().base, ColorBase::Rgb { rgb: Rgba::rgb(255, 0, 0) });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miter_limit_accepts_both_percentage_forms_with_schema_bounds() {
|
||||
assert_eq!(parse_miter_limit("200000"), Some(200_000));
|
||||
assert_eq!(parse_miter_limit("200%"), Some(200_000));
|
||||
assert_eq!(parse_miter_limit("1000001"), Some(1_000_001));
|
||||
assert_eq!(parse_miter_limit("malformed"), None);
|
||||
assert_eq!(parse_miter_limit("2147483648"), None);
|
||||
assert_eq!(parse_miter_limit("2147483.648%"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_props() {
|
||||
let d = parse(br#"<a:bodyPr lIns="91440" anchor="ctr" wrap="none" vert="vert270" numCol="2"><a:normAutofit fontScale="62500" lnSpcReduction="20000"/></a:bodyPr>"#).unwrap();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! DrawingML writing: colours, fills, lines, effects, transforms and text.
|
||||
|
||||
use deckcraft_geom::{Xfrm, pt_to_emu};
|
||||
use deckcraft_model::style::{ColorBase, ColorRef, Compound, Dash, Effects, Fill, GradientShape, Line, LineCap, LineJoin, PictureFill, PictureMode};
|
||||
use deckcraft_model::style::{
|
||||
ColorBase, ColorRef, Compound, Dash, Effects, Fill, GradientFlip, GradientShape, Line, LineAlign, LineCap, LineJoin, PictureFill, PictureMode,
|
||||
};
|
||||
use deckcraft_model::text::{
|
||||
Action, AutoFit, BodyProps, Bullet, Caps, Hyperlink, ListStyle, ParaProps, Paragraph, RunKind, RunProps, Spacing, Strike, TextBody,
|
||||
};
|
||||
@@ -208,6 +210,7 @@ pub fn line(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, l: &Line) {
|
||||
LineCap::Flat => "flat",
|
||||
}),
|
||||
)
|
||||
.o("algn", l.align.map(LineAlign::xml))
|
||||
.o("cmpd", l.compound.map(Compound::xml));
|
||||
w.open(tag, a);
|
||||
if let Some(f) = &l.fill {
|
||||
@@ -233,7 +236,10 @@ pub fn line(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, l: &Line) {
|
||||
match l.join {
|
||||
Some(LineJoin::Round) => w.empty0("a:round"),
|
||||
Some(LineJoin::Bevel) => w.empty0("a:bevel"),
|
||||
Some(LineJoin::Miter) => w.empty0("a:miter"),
|
||||
Some(LineJoin::Miter) => {
|
||||
let limit = l.miter_limit.filter(|limit| *limit <= i32::MAX as u32);
|
||||
w.empty("a:miter", A::new().o("lim", limit));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
for (t, e) in [("a:headEnd", &l.head), ("a:tailEnd", &l.tail)] {
|
||||
@@ -279,7 +285,7 @@ fn shadow(w: &mut W, tag: &str, s: &deckcraft_model::style::Shadow) {
|
||||
}
|
||||
|
||||
fn gradient(w: &mut W, g: &deckcraft_model::style::Gradient) {
|
||||
w.open("a:gradFill", A::new().a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
w.open("a:gradFill", A::new().o("flip", g.flip.map(GradientFlip::xml)).a("rotWithShape", if g.rotate_with_shape { "1" } else { "0" }));
|
||||
// No stops: no `a:gsLst`, so the gradient keeps taking them from its style's fill.
|
||||
let mut stops = g.stops.clone();
|
||||
if stops.len() == 1 {
|
||||
@@ -533,8 +539,11 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|
||||
|| p.space_after.is_some()
|
||||
|| p.bullet.is_some()
|
||||
|| p.bullet_font.is_some()
|
||||
|| p.bullet_font_follow_text.is_some()
|
||||
|| p.bullet_color.is_some()
|
||||
|| p.bullet_color_follow_text.is_some()
|
||||
|| p.bullet_size.is_some()
|
||||
|| p.bullet_size_follow_text.is_some()
|
||||
|| p.tabs.is_some()
|
||||
|| def.is_some();
|
||||
if !has_kids {
|
||||
@@ -551,15 +560,21 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|
||||
if let Some(s) = &p.space_after {
|
||||
spacing(w, "a:spcAft", s);
|
||||
}
|
||||
if let Some(c) = &p.bullet_color {
|
||||
if p.bullet_color.is_none() && p.bullet_color_follow_text == Some(true) {
|
||||
w.empty0("a:buClrTx");
|
||||
} else if let Some(c) = &p.bullet_color {
|
||||
w.open0("a:buClr");
|
||||
color(w, c);
|
||||
w.close("a:buClr");
|
||||
}
|
||||
if let Some(s) = p.bullet_size {
|
||||
if p.bullet_size.is_none() && p.bullet_size_follow_text == Some(true) {
|
||||
w.empty0("a:buSzTx");
|
||||
} else if let Some(s) = p.bullet_size {
|
||||
w.val("a:buSzPct", pct(s.clamp(0.25, 4.0)));
|
||||
}
|
||||
if let Some(f) = &p.bullet_font {
|
||||
if p.bullet_font.is_none() && p.bullet_font_follow_text == Some(true) {
|
||||
w.empty0("a:buFontTx");
|
||||
} else if let Some(f) = &p.bullet_font {
|
||||
w.empty("a:buFont", A::new().a("typeface", f));
|
||||
}
|
||||
match &p.bullet {
|
||||
|
||||
@@ -46,6 +46,7 @@ fn gradient(angle: f64) -> Fill {
|
||||
shape: GradientShape::Linear { angle, scaled: true },
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0; 4],
|
||||
flip: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -116,6 +117,7 @@ fn style_deck() -> Presentation {
|
||||
shape: GradientShape::Path { path: "circle".into(), focus: [0.2, 0.2, 0.2, 0.2] },
|
||||
rotate_with_shape: false,
|
||||
tile: [0.0; 4],
|
||||
flip: None,
|
||||
}));
|
||||
|
||||
let mut patt = shape(&mut p, "Pattern", 200.0, 260.0, 120.0, 80.0);
|
||||
|
||||
@@ -88,7 +88,8 @@ fn a_gradient_without_stops_is_saved_without_stops() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id().expect("ids"));
|
||||
let first = p.slides[0].id;
|
||||
let own = Gradient { tile: [0.0; 4], stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true };
|
||||
let own =
|
||||
Gradient { tile: [0.0; 4], stops: vec![], shape: GradientShape::Linear { angle: 45.0, scaled: true }, rotate_with_shape: true, flip: None };
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
id,
|
||||
name: "Styled".into(),
|
||||
@@ -119,6 +120,7 @@ fn a_fill_overlay_round_trips() {
|
||||
],
|
||||
shape: GradientShape::Linear { angle: 4.5, scaled: true },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
}),
|
||||
};
|
||||
p.slide_mut(first).expect("slide").shapes.push(Shape {
|
||||
@@ -148,6 +150,7 @@ fn a_gradient_tile_rect_is_read_and_round_trips() {
|
||||
shape: GradientShape::Path { path: "circle".into(), focus: [0.0, 0.0, 1.0, 1.0] },
|
||||
rotate_with_shape: true,
|
||||
tile: [-1.0, -1.0, 0.0, 0.0],
|
||||
flip: None,
|
||||
};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id().expect("ids"));
|
||||
|
||||
@@ -7,8 +7,8 @@ use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, TextBuild, Transition};
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
use deckcraft_model::style::{
|
||||
ColorRef, Dash, Effects, Fill, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PatternFill, PictureAdjust, PictureFill, PictureMode,
|
||||
Shadow,
|
||||
ColorRef, Dash, Effects, Fill, Glow, Gradient, GradientFlip, GradientShape, GradientStop, Line, LineEnd, PatternFill, PictureAdjust, PictureFill,
|
||||
PictureMode, Shadow,
|
||||
};
|
||||
use deckcraft_model::table::Table;
|
||||
use deckcraft_model::text::{
|
||||
@@ -115,6 +115,8 @@ pub fn rich_deck() -> Presentation {
|
||||
],
|
||||
shape: GradientShape::Linear { angle: 45.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
|
||||
flip: Some(GradientFlip::Xy),
|
||||
}));
|
||||
a.line = Some(Line { width: Some(3.0), dash: Some(Dash::Preset { name: "dash".into() }), ..Line::solid(ColorRef::rgb(Rgba::rgb(0, 0, 0)), 3.0) });
|
||||
a.effects = Some(Effects {
|
||||
@@ -488,6 +490,102 @@ fn shapes_fills_lines_effects_round_trip() {
|
||||
assert_eq!(by_name(&q.slides[1], "Group").id, by_name(&p.slides[1], "Group").id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_miter_limit_survives_two_pptx_round_trips() {
|
||||
let mut p = Presentation::default();
|
||||
let mut limited = shape(&mut p, "Limited miter", 20.0, 20.0, 100.0, 100.0);
|
||||
limited.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), miter_limit: Some(200_000), ..Default::default() });
|
||||
let mut defaulted = shape(&mut p, "Default miter", 140.0, 20.0, 100.0, 100.0);
|
||||
defaulted.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), ..Default::default() });
|
||||
let mut large = shape(&mut p, "Large miter", 260.0, 20.0, 100.0, 100.0);
|
||||
large.line = Some(Line { join: Some(deckcraft_model::style::LineJoin::Miter), miter_limit: Some(1_000_001), ..Default::default() });
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.extend([limited, defaulted, large]);
|
||||
|
||||
let q = round(&round(&p));
|
||||
assert_eq!(by_name(&q.slides[0], "Limited miter").line.as_ref().and_then(|line| line.miter_limit), Some(200_000));
|
||||
assert_eq!(by_name(&q.slides[0], "Default miter").line.as_ref().and_then(|line| line.miter_limit), None);
|
||||
assert_eq!(by_name(&q.slides[0], "Large miter").line.as_ref().and_then(|line| line.miter_limit), Some(1_000_001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn bullet_follow_text_resets_survive_two_pptx_round_trips() {
|
||||
let mut p = Presentation::default();
|
||||
let mut textbox = shape(&mut p, "Follow text bullets", 20.0, 20.0, 400.0, 100.0);
|
||||
textbox.text = Some(TextBody {
|
||||
paragraphs: vec![Paragraph {
|
||||
props: deckcraft_model::text::ParaProps {
|
||||
bullet: Some(Bullet::Char { char: "•".into() }),
|
||||
bullet_color_follow_text: Some(true),
|
||||
bullet_size_follow_text: Some(true),
|
||||
bullet_font_follow_text: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
runs: vec![Run::with("Bullet text", RunProps::default())],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(textbox);
|
||||
let mut conflicting = shape(&mut p, "Explicit bullet values", 20.0, 140.0, 400.0, 100.0);
|
||||
conflicting.text = Some(TextBody {
|
||||
paragraphs: vec![Paragraph {
|
||||
props: deckcraft_model::text::ParaProps {
|
||||
bullet: Some(Bullet::Char { char: "•".into() }),
|
||||
bullet_color: Some(deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))),
|
||||
bullet_color_follow_text: Some(true),
|
||||
bullet_size: Some(1.5),
|
||||
bullet_size_follow_text: Some(true),
|
||||
bullet_font: Some("Inter".into()),
|
||||
bullet_font_follow_text: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
runs: vec![Run::with("Explicit bullet text", RunProps::default())],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(conflicting);
|
||||
|
||||
let q = round(&round(&p));
|
||||
let props = &by_name(&q.slides[0], "Follow text bullets").text.as_ref().expect("text").paragraphs[0].props;
|
||||
assert_eq!(props.bullet_color_follow_text, Some(true));
|
||||
assert_eq!(props.bullet_size_follow_text, Some(true));
|
||||
assert_eq!(props.bullet_font_follow_text, Some(true));
|
||||
let explicit = &by_name(&q.slides[0], "Explicit bullet values").text.as_ref().expect("text").paragraphs[0].props;
|
||||
assert_eq!(explicit.bullet_color_follow_text, None);
|
||||
assert_eq!(explicit.bullet_size_follow_text, None);
|
||||
assert_eq!(explicit.bullet_font_follow_text, None);
|
||||
assert_eq!(explicit.bullet_size, Some(1.5));
|
||||
assert_eq!(explicit.bullet_font.as_deref(), Some("Inter"));
|
||||
assert_eq!(explicit.bullet_color, Some(deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn line_alignment_survives_two_pptx_round_trips() {
|
||||
let mut p = Presentation::default();
|
||||
let mut inside = shape(&mut p, "Inset line", 20.0, 20.0, 100.0, 100.0);
|
||||
inside.line = Some(Line { align: Some(deckcraft_model::style::LineAlign::Inside), ..Default::default() });
|
||||
let mut centered = shape(&mut p, "Centered line", 140.0, 20.0, 100.0, 100.0);
|
||||
centered.line = Some(Line { align: Some(deckcraft_model::style::LineAlign::Center), ..Default::default() });
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.extend([inside, centered]);
|
||||
|
||||
let q = round(&round(&p));
|
||||
assert_eq!(by_name(&q.slides[0], "Inset line").line.as_ref().and_then(|line| line.align), Some(deckcraft_model::style::LineAlign::Inside));
|
||||
assert_eq!(by_name(&q.slides[0], "Centered line").line.as_ref().and_then(|line| line.align), Some(deckcraft_model::style::LineAlign::Center));
|
||||
assert_eq!(deckcraft_model::style::LineAlign::from_xml("out"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn gradient_flip_mode_survives_two_pptx_round_trips() {
|
||||
let p = rich_deck();
|
||||
let q = round(&round(&p));
|
||||
let gradient = by_name(&q.slides[1], "Gradient Star").fill.as_ref();
|
||||
assert!(matches!(gradient, Some(Fill::Gradient(g)) if g.flip == Some(GradientFlip::Xy)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pictures_media_and_ink_round_trip() {
|
||||
let p = rich_deck();
|
||||
|
||||
@@ -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))],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
})),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
@@ -1003,6 +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))],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
})),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
@@ -1044,6 +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)],
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
}),
|
||||
});
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
@@ -1145,6 +1148,7 @@ fn a_text_gradient_starts_over_on_every_line() {
|
||||
stops: vec![stop(0.0, 0), stop(1.0, 255)],
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
}));
|
||||
}
|
||||
sh.text = Some(t);
|
||||
@@ -1306,6 +1310,7 @@ fn rect_path_gradients_draw_x_shaped_contours() {
|
||||
stops: vec![stop(0.0, 0), stop(0.5, 128), stop(1.0, 255)],
|
||||
shape: GradientShape::Path { path: "rect".into(), focus: [0.5, 0.5, 0.5, 0.5] },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
})),
|
||||
line: Some(deckcraft_model::style::Line { fill: Some(Fill::None), ..Default::default() }),
|
||||
..Default::default()
|
||||
|
||||
@@ -186,6 +186,7 @@ fn radial(focus: [f64; 4]) -> Shape {
|
||||
stops,
|
||||
shape: GradientShape::Path { path: "circle".into(), focus },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
})),
|
||||
line: no_line(),
|
||||
..Default::default()
|
||||
@@ -310,8 +311,13 @@ fn a_pictures_border_lies_outside_the_picture() {
|
||||
fn children_filled_with_the_group_fill_show_one_fill_across_the_group() {
|
||||
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
|
||||
let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(RED) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::rgb(0, 0, 255)) }];
|
||||
let grad =
|
||||
Fill::Gradient(Gradient { tile: [0.0; 4], stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true });
|
||||
let grad = Fill::Gradient(Gradient {
|
||||
tile: [0.0; 4],
|
||||
stops,
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
});
|
||||
let child = |id: u32, x: f64| Shape {
|
||||
id: ShapeId(id),
|
||||
xfrm: Some(Xfrm::new(x, 0.0, 90.0, 100.0)),
|
||||
@@ -372,7 +378,13 @@ fn a_linear_gradient_spans_its_tile_rect() {
|
||||
use deckcraft_model::style::{Gradient, GradientShape, GradientStop};
|
||||
// Tile rect the left half of the box: the ramp ends half way and stays black after.
|
||||
let stops = vec![GradientStop { pos: 0.0, color: ColorRef::rgb(WHITE) }, GradientStop { pos: 1.0, color: ColorRef::rgb(Rgba::BLACK) }];
|
||||
let g = Gradient { stops, shape: GradientShape::Linear { angle: 0.0, scaled: false }, rotate_with_shape: true, tile: [0.0, 0.0, 0.5, 0.0] };
|
||||
let g = Gradient {
|
||||
stops,
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0, 0.0, 0.5, 0.0],
|
||||
flip: None,
|
||||
};
|
||||
let sh = Shape {
|
||||
id: ShapeId(20),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 100.0)),
|
||||
@@ -502,6 +514,7 @@ fn gradient_lines_and_their_heads_take_the_gradient() {
|
||||
shape: GradientShape::Linear { angle, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0; 4],
|
||||
flip: None,
|
||||
})
|
||||
};
|
||||
// Left to right along a 20 pt line: red at its start, blue at its end, and the arrowhead at
|
||||
@@ -656,6 +669,7 @@ fn a_gradient_not_rotating_with_its_shape_keeps_to_the_slide() {
|
||||
shape: GradientShape::Linear { angle: 0.0, scaled: false },
|
||||
rotate_with_shape,
|
||||
tile: [0.0; 4],
|
||||
flip: None,
|
||||
};
|
||||
// 200 × 100 turned a quarter: it covers 150..250 across and 50..250 down.
|
||||
let sh = Shape {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
//! Bake the contributor credits into the binary as static Rust tables (craftrules
|
||||
//! standards/contributors.md). Nothing is read at run time.
|
||||
//! Bake the contributor credits into the binary as static Rust tables. Nothing is read at run time.
|
||||
//!
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules
|
||||
//! scripts/contributors.py, which also marks whose people.toml entry it verified.
|
||||
//! - contributors/contributors.json: commit stats per GitHub username, with a mark for whose
|
||||
//! people.toml entry is verified.
|
||||
//! - contributors/people.toml: the names people chose for themselves. A name is used only when the
|
||||
//! JSON says the entry was verified (`people_entry`).
|
||||
//!
|
||||
|
||||
@@ -13,7 +13,7 @@ use super::{
|
||||
mod clipboard;
|
||||
mod commands;
|
||||
mod control;
|
||||
mod files;
|
||||
pub(crate) mod files;
|
||||
mod frame;
|
||||
mod ime;
|
||||
mod input;
|
||||
@@ -30,6 +30,11 @@ pub use keys::key_name;
|
||||
const STATUS_LINGER_MS: f64 = 6000.0;
|
||||
|
||||
impl SlideApp {
|
||||
/// Remove temporary PDFs created for printing.
|
||||
pub fn cleanup_print_previews(&mut self) {
|
||||
self.print_previews.cleanup();
|
||||
}
|
||||
|
||||
/// Build application UI state, taking ownership of audio output and initialising media, dialogs,
|
||||
/// synthetic input, and timing fields.
|
||||
pub fn new(session: Session, mut services: Services) -> Self {
|
||||
@@ -77,6 +82,7 @@ impl SlideApp {
|
||||
integrated_titlebar: false,
|
||||
backstage: None,
|
||||
pending_urls: vec![],
|
||||
print_previews: files::PrintPreviews::default(),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: updates_ui::Runtime::default(),
|
||||
pending_copy: None,
|
||||
|
||||
@@ -3,6 +3,66 @@ use super::{
|
||||
json, media,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct PrintPreviews(Vec<std::path::PathBuf>);
|
||||
|
||||
impl PrintPreviews {
|
||||
fn create(&mut self, title: &str) -> std::io::Result<std::path::PathBuf> {
|
||||
self.create_in(&std::env::temp_dir(), title)
|
||||
}
|
||||
|
||||
fn create_in(&mut self, base: &std::path::Path, title: &str) -> std::io::Result<std::path::PathBuf> {
|
||||
let dir = create_private_print_dir(base)?;
|
||||
self.0.push(dir.clone());
|
||||
let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect();
|
||||
Ok(dir.join(format!("{safe} (print).pdf")))
|
||||
}
|
||||
|
||||
fn discard(&mut self, path: &std::path::Path) {
|
||||
if let Some(dir) = path.parent() {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
self.0.retain(|owned| owned != dir);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cleanup(&mut self) {
|
||||
for dir in self.0.drain(..) {
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PrintPreviews {
|
||||
fn drop(&mut self) {
|
||||
self.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
fn create_private_print_dir(base: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static NEXT_DIR: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
for _ in 0..128 {
|
||||
let id = NEXT_DIR.fetch_add(1, Ordering::Relaxed);
|
||||
let path = base.join(format!("deckcraft-print-{}-{id}", std::process::id()));
|
||||
#[cfg(unix)]
|
||||
let mut builder = std::fs::DirBuilder::new();
|
||||
#[cfg(not(unix))]
|
||||
let builder = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
builder.mode(0o700);
|
||||
}
|
||||
match builder.create(&path) {
|
||||
Ok(()) => return Ok(path),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "could not allocate a unique print preview directory"))
|
||||
}
|
||||
|
||||
fn purpose_of(purpose: &str) -> FilePurpose {
|
||||
match purpose {
|
||||
"open" => FilePurpose::Open,
|
||||
@@ -207,8 +267,14 @@ impl SlideApp {
|
||||
let bytes = deckcraft_engine::cmd::file::pdf_bytes(&doc, ¶ms);
|
||||
return self.download(&format!("{title}.pdf"), bytes);
|
||||
}
|
||||
let safe: String = title.chars().map(|c| if c.is_alphanumeric() || c == ' ' || c == '-' { c } else { '_' }).collect();
|
||||
let path = std::env::temp_dir().join(format!("{safe} (print).pdf")).to_string_lossy().to_string();
|
||||
let preview_path = match self.print_previews.create(&title) {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
self.set_status(format!("Couldn't create a private print preview: {e}"));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let path = preview_path.to_string_lossy().to_string();
|
||||
if let Some(o) = params.as_object_mut() {
|
||||
o.insert("path".into(), json!(path));
|
||||
o.insert("format".into(), json!("pdf"));
|
||||
@@ -219,7 +285,10 @@ impl SlideApp {
|
||||
self.set_status("Opened the presentation as a PDF to print");
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
Err(_) => {
|
||||
self.print_previews.discard(&preview_path);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -665,3 +734,49 @@ pub(crate) fn web_image_files(
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod print_preview_tests {
|
||||
use super::PrintPreviews;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
#[test]
|
||||
fn same_title_gets_distinct_private_paths_and_drop_cleans_them() {
|
||||
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
|
||||
let base = std::env::temp_dir().join(format!("deckcraft-print-test-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&base).unwrap();
|
||||
let (first, second) = {
|
||||
let mut previews = PrintPreviews::default();
|
||||
let first = previews.create_in(&base, "Same title").unwrap();
|
||||
let second = previews.create_in(&base, "Same title").unwrap();
|
||||
assert_ne!(first, second);
|
||||
assert_eq!(first.file_name(), second.file_name());
|
||||
assert!(first.parent().is_some_and(|dir| dir.is_dir()));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(first.parent().unwrap()).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o700);
|
||||
}
|
||||
(first, second)
|
||||
};
|
||||
assert!(!first.parent().is_some_and(|dir| dir.exists()));
|
||||
assert!(!second.parent().is_some_and(|dir| dir.exists()));
|
||||
std::fs::remove_dir(base).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_cleanup_removes_previews() {
|
||||
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or_default();
|
||||
let base = std::env::temp_dir().join(format!("deckcraft-print-cleanup-test-{}-{stamp}", std::process::id()));
|
||||
std::fs::create_dir(&base).unwrap();
|
||||
let mut previews = PrintPreviews::default();
|
||||
let path = previews.create_in(&base, "Explicit cleanup").unwrap();
|
||||
let dir = path.parent().unwrap().to_path_buf();
|
||||
|
||||
previews.cleanup();
|
||||
|
||||
assert!(!dir.exists());
|
||||
std::fs::remove_dir(base).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.run_steps(2);
|
||||
click_on_page(&h, "Print");
|
||||
h.run_steps(2);
|
||||
let url = h.state().pending_urls.first().cloned().unwrap_or_default();
|
||||
// The URL was handed to egui at the end of the frame; check what it pointed at.
|
||||
let printed = std::env::temp_dir().join(format!("{} (print).pdf", h.state().session.active().unwrap().title()));
|
||||
assert!(printed.is_file(), "the PDF to print was written: {url}");
|
||||
assert!(std::fs::read(&printed).unwrap().starts_with(b"%PDF"));
|
||||
let _ = std::fs::remove_file(printed);
|
||||
h.step();
|
||||
let url = h
|
||||
.output()
|
||||
.platform_output
|
||||
.commands
|
||||
.iter()
|
||||
.find_map(|command| match command {
|
||||
egui::OutputCommand::OpenUrl(open) => Some(open.url.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.expect("Print emits an egui OpenUrl command");
|
||||
assert!(url.contains("deckcraft-print-"), "print preview uses its private directory: {url}");
|
||||
let path = url.strip_prefix("file://").expect("print preview URL is a file URL");
|
||||
#[cfg(windows)]
|
||||
let path = path.strip_prefix('/').unwrap_or(path);
|
||||
let printed = std::path::Path::new(path);
|
||||
assert!(printed.is_file(), "the PDF at the emitted URL was written: {url}");
|
||||
assert!(std::fs::read(printed).unwrap().starts_with(b"%PDF"));
|
||||
let _ = std::fs::remove_dir_all(printed.parent().unwrap());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The About window's credits: every contributor (GitHub login always; display and real names only
|
||||
//! with recorded consent) and the AI models that helped. The tables are baked into the binary by
|
||||
//! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is
|
||||
//! build.rs from contributors/contributors.json; nothing is
|
||||
//! read at run time.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//! prompt that offers to update and restart. The flow itself is `craft_update::updater::Updater`.
|
||||
|
||||
use craft_update::updater::{Action, Status, Updater};
|
||||
use craft_update::{CheckResultDialog, CheckResultResponse};
|
||||
|
||||
use crate::SlideApp;
|
||||
|
||||
@@ -12,11 +13,13 @@ pub(crate) struct Runtime {
|
||||
/// The desktop host turns the check at start on; tests and the web build leave it off.
|
||||
enabled: bool,
|
||||
check_requested: bool,
|
||||
/// A manual check's result dialog is showing (checking, up to date or failed).
|
||||
result_open: bool,
|
||||
}
|
||||
|
||||
impl Default for Runtime {
|
||||
fn default() -> Self {
|
||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false }
|
||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false, result_open: false }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +31,7 @@ pub(crate) fn enable(app: &mut SlideApp) {
|
||||
pub(crate) fn request_check(app: &mut SlideApp) {
|
||||
if !matches!(app.updates_rt.updater.status(), Status::Checking) {
|
||||
app.updates_rt.check_requested = true;
|
||||
app.notify("Checking for updates…");
|
||||
app.updates_rt.result_open = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,15 +43,6 @@ pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
rt.updater.check_at_start(ctx);
|
||||
}
|
||||
rt.updater.tick(ctx);
|
||||
let outcome = match rt.updater.status() {
|
||||
Status::UpToDate => Some(format!("DeckCraft {VERSION} is up to date.")),
|
||||
Status::Failed(e) => Some(format!("Could not check for updates: {e}")),
|
||||
Status::Idle | Status::Checking => None,
|
||||
};
|
||||
if let Some(message) = outcome {
|
||||
rt.updater.dismiss_status();
|
||||
app.notify(message);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
@@ -60,6 +54,14 @@ pub(crate) fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
Action::None => {}
|
||||
}
|
||||
let rt = &mut app.updates_rt;
|
||||
if rt.result_open {
|
||||
match CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) {
|
||||
CheckResultResponse::Close => rt.result_open = false,
|
||||
CheckResultResponse::CheckAgain => rt.check_requested = true,
|
||||
CheckResultResponse::None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -293,6 +293,7 @@ pub struct SlideApp {
|
||||
/// creating or closing a presentation leaves it.
|
||||
pub backstage: Option<backstage::Backstage>,
|
||||
pending_urls: Vec<String>,
|
||||
print_previews: app::files::PrintPreviews,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
updates_rt: updates_ui::Runtime,
|
||||
/// Text for the system clipboard from a Copy or Cut command, handed over on the next frame.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use deckcraft_color::{ColorScheme, ColorTransform, SchemeSlot};
|
||||
use deckcraft_model::ColorRef;
|
||||
use deckcraft_model::style::{Gradient, GradientShape, GradientStop, PatternFill};
|
||||
use deckcraft_model::style::{Gradient, GradientFlip, GradientShape, GradientStop, PatternFill};
|
||||
use egui::{Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -21,21 +21,25 @@ pub fn default_gradient(base: ColorRef, angle: f64) -> Gradient {
|
||||
stops: vec![GradientStop { pos: 0.0, color: base }, GradientStop { pos: 1.0, color: dark }],
|
||||
shape: GradientShape::Linear { angle, scaled: false },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The command parameter for `g` (`shape.fill {gradient}` / `design.background {gradient}`).
|
||||
pub fn gradient_json(g: &Gradient) -> Value {
|
||||
let stops: Vec<Value> = g.stops.iter().map(|s| json!([s.pos, cref_param(&s.color)])).collect();
|
||||
let flip = g.flip.map(GradientFlip::xml);
|
||||
match &g.shape {
|
||||
GradientShape::Linear { angle, .. } => json!({"stops": stops, "kind": "linear", "angle": angle, "rotateWithShape": g.rotate_with_shape}),
|
||||
GradientShape::Linear { angle, .. } => {
|
||||
json!({"stops": stops, "kind": "linear", "angle": angle, "rotateWithShape": g.rotate_with_shape, "flip": flip})
|
||||
}
|
||||
GradientShape::Path { path, focus } => {
|
||||
let kind = match path.as_str() {
|
||||
"rect" => "rectangular",
|
||||
"shape" => "path",
|
||||
_ => "radial",
|
||||
};
|
||||
json!({"stops": stops, "kind": kind, "focus": focus, "rotateWithShape": g.rotate_with_shape})
|
||||
json!({"stops": stops, "kind": kind, "focus": focus, "rotateWithShape": g.rotate_with_shape, "flip": flip})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -142,6 +146,7 @@ pub fn gradient_editor(ui: &mut Ui, id: &str, g: &Gradient, sc: &ColorScheme) ->
|
||||
stops: vec![GradientStop { pos: 0.0, color: light }, GradientStop { pos: 1.0, color: dark }],
|
||||
shape: GradientShape::Path { path: "circle".into(), focus: [0.5, 0.5, 0.5, 0.5] },
|
||||
rotate_with_shape: true,
|
||||
flip: None,
|
||||
},
|
||||
Gradient {
|
||||
stops: vec![GradientStop { pos: 0.0, color: base.clone() }, GradientStop { pos: 1.0, color: with_alpha(base, 0.0) }],
|
||||
@@ -508,3 +513,20 @@ pub fn pattern_editor(ui: &mut Ui, pt: &PatternFill, sc: &ColorScheme) -> Option
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gradient_json_retains_flip_mode() {
|
||||
let gradient = Gradient {
|
||||
stops: vec![],
|
||||
shape: GradientShape::Linear { angle: 90.0, scaled: false },
|
||||
flip: Some(GradientFlip::Xy),
|
||||
rotate_with_shape: true,
|
||||
tile: [0.0; 4],
|
||||
};
|
||||
assert_eq!(gradient_json(&gradient).get("flip"), Some(&json!("xy")));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::render::fit;
|
||||
use super::{Click, HitShape, Hyperlink, Presentation, Rect, ShapeId, Show, SlideApp, Ui, pos2, vec2};
|
||||
use super::{Click, HitShape, Hyperlink, PlacedShape, Presentation, Rect, ShapeId, Show, SlideApp, Ui, pos2, vec2};
|
||||
use deckcraft_anim::AnimState;
|
||||
|
||||
/// The media-host voice for slide transition sounds (no shape has this id).
|
||||
pub(crate) const TRANSITION_SOUND: ShapeId = ShapeId(u32::MAX);
|
||||
@@ -152,6 +153,22 @@ pub(super) struct Under {
|
||||
pub(super) pick: Option<Pick>,
|
||||
}
|
||||
|
||||
/// Convert a slide-space point back into a shape's own coordinates, undoing its animation.
|
||||
fn hit_point(point: (f64, f64), shapes: &[PlacedShape], states: &[AnimState], index: usize) -> Option<(f64, f64)> {
|
||||
let shape = shapes.get(index)?;
|
||||
inverse_transform_point(point, (shape.rect.center().x, shape.rect.center().y), *states.get(index)?)
|
||||
}
|
||||
|
||||
fn inverse_transform_point(p: (f64, f64), center: (f64, f64), state: AnimState) -> Option<(f64, f64)> {
|
||||
if state.scale_x.abs() <= f64::EPSILON || state.scale_y.abs() <= f64::EPSILON {
|
||||
return None;
|
||||
}
|
||||
let (x, y) = (p.0 - center.0 - state.offset_x, p.1 - center.1 - state.offset_y);
|
||||
let angle = -state.rotate.to_radians();
|
||||
let (x, y) = (x * angle.cos() - y * angle.sin(), x * angle.sin() + y * angle.cos());
|
||||
Some((center.0 + x / state.scale_x, center.1 + y / state.scale_y))
|
||||
}
|
||||
|
||||
impl Under {
|
||||
fn visible(&self, id: ShapeId) -> bool {
|
||||
self.shapes.iter().zip(&self.visible).find(|(h, _)| h.id == id).is_none_or(|(_, v)| *v)
|
||||
@@ -175,31 +192,29 @@ pub(super) fn shapes_under_pointer(ctx: &egui::Context, ui: &Ui, show: &mut Show
|
||||
}
|
||||
show.timeline(doc);
|
||||
let (Some((_, tl)), Some((_, links, shapes))) = (&show.timeline, &show.links) else { return None };
|
||||
let inside = |x0: f64, y0: f64, x1: f64, y1: f64| x0 <= x && x <= x1 && y0 <= y && y <= y1;
|
||||
let hits: Vec<HitShape> = shapes
|
||||
.iter()
|
||||
.map(|s| HitShape {
|
||||
id: s.id,
|
||||
parent: s.parent,
|
||||
is_group: s.is_group,
|
||||
link: s.link.as_ref().map(|l| l.action.clone()),
|
||||
under_pointer: inside(s.rect.x0, s.rect.y0, s.rect.x1, s.rect.y1),
|
||||
})
|
||||
.collect();
|
||||
let (step, plays, stamps) = (show.state.step, show.state.trigger_plays(), show.state.trigger_stamps());
|
||||
let t = if show.state.playing { now - show.step_start } else { 0.0 };
|
||||
let visible: Vec<bool> = shapes
|
||||
let states: Vec<AnimState> = shapes.iter().map(|s| tl.state_ordered(s.id, None, step, t, &plays, &stamps)).collect();
|
||||
let point_for = |i: usize| hit_point((x, y), shapes, &states, i);
|
||||
let hits: Vec<HitShape> = shapes
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let a = tl.state_ordered(s.id, None, step, t, &plays, &stamps);
|
||||
a.visible && a.opacity > 0.0
|
||||
.enumerate()
|
||||
.map(|(i, s)| {
|
||||
let under_pointer = point_for(i).is_some_and(|(x, y)| s.rect.x0 <= x && x <= s.rect.x1 && s.rect.y0 <= y && y <= s.rect.y1);
|
||||
HitShape { id: s.id, parent: s.parent, is_group: s.is_group, link: s.link.as_ref().map(|l| l.action.clone()), under_pointer }
|
||||
})
|
||||
.collect();
|
||||
let visible: Vec<bool> = states.iter().map(|a| a.visible && a.opacity > 0.0).collect();
|
||||
let mut under = Under { shapes: hits, visible, pick: None };
|
||||
let shown = |id: ShapeId| under.visible(id);
|
||||
// A hyperlink in text applies when its shape is the one on top.
|
||||
let top = show.state.top(doc, tl, &under.shapes, &shown);
|
||||
let text = links.iter().rev().filter(|l| l.text && inside(l.rect.x0, l.rect.y0, l.rect.x1, l.rect.y1)).find(|l| {
|
||||
let text = links.iter().rev().filter(|l| l.text).find(|l| {
|
||||
let Some(i) = shapes.iter().position(|s| s.id == l.shape) else { return false };
|
||||
let Some((x, y)) = point_for(i) else { return false };
|
||||
if !(l.rect.x0 <= x && x <= l.rect.x1 && l.rect.y0 <= y && y <= l.rect.y1) {
|
||||
return false;
|
||||
}
|
||||
let mut at = top;
|
||||
(0..=under.shapes.len()).any(|_| match at.and_then(|k| under.shapes.get(k)) {
|
||||
Some(h) if h.id == l.shape => true,
|
||||
@@ -252,3 +267,45 @@ pub(super) fn gif_hit(ctx: &egui::Context, ui: &Ui, show: &Show, doc_uid: u64, d
|
||||
let gifs = app.media.gifs(doc_uid, doc, show.state.slide, ctx.input(|i| i.time), &app.session.gif_paused);
|
||||
gifs.iter().rev().find(|g| crate::media::gif_button_rect(on_screen(srect, k, &g.rect)).contains(p)).map(|g| g.id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hit_tests {
|
||||
use super::*;
|
||||
|
||||
fn shape(id: u32, parent: Option<usize>, rect: deckcraft_geom::Rect) -> PlacedShape {
|
||||
PlacedShape { id: ShapeId(id), parent, is_group: parent.is_none(), rect, link: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_path_moves_the_hit_box() {
|
||||
let shapes = [shape(1, None, deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))];
|
||||
let states = [AnimState { offset_x: 50.0, scale_x: 2.0, ..Default::default() }];
|
||||
|
||||
let moved = hit_point((65.0, 20.0), &shapes, &states, 0);
|
||||
let abandoned = hit_point((15.0, 20.0), &shapes, &states, 0);
|
||||
assert!(moved.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
|
||||
assert!(!abandoned.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scale_and_rotation_move_the_hit_box() {
|
||||
let shapes = [shape(1, None, deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))];
|
||||
let states = [AnimState { scale_x: 2.0, scale_y: 1.0, rotate: 90.0, ..Default::default() }];
|
||||
|
||||
// The shape center is (20, 20); local (25, 25) scales and rotates to (15, 30).
|
||||
let transformed = hit_point((15.0, 30.0), &shapes, &states, 0);
|
||||
assert!(transformed.is_some_and(|(x, y)| (24.9..=25.1).contains(&x) && (24.9..=25.1).contains(&y)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_animation_does_not_move_child_hit_box() {
|
||||
let shapes =
|
||||
[shape(1, None, deckcraft_geom::Rect::new(0.0, 0.0, 100.0, 100.0)), shape(2, Some(0), deckcraft_geom::Rect::new(10.0, 10.0, 30.0, 30.0))];
|
||||
let states = [AnimState { offset_x: 40.0, ..Default::default() }, AnimState::default()];
|
||||
|
||||
let unchanged = hit_point((15.0, 20.0), &shapes, &states, 1);
|
||||
let displaced = hit_point((55.0, 20.0), &shapes, &states, 1);
|
||||
assert!(unchanged.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
|
||||
assert!(!displaced.is_some_and(|(x, y)| (10.0..=30.0).contains(&x) && (10.0..=30.0).contains(&y)));
|
||||
}
|
||||
}
|
||||
|
||||
+4
-21
@@ -1,16 +1,14 @@
|
||||
# Contributors in the About window
|
||||
|
||||
**About ▸ Contributors** credits everyone who contributed to DeckCraft, and **About ▸ Models** credits
|
||||
the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard
|
||||
[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md);
|
||||
this page is the local copy of the decision.
|
||||
the AI models named in `Co-Authored-By` trailers.
|
||||
|
||||
## Decision
|
||||
|
||||
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
|
||||
disk or the network at run time; the web build has them too):
|
||||
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
|
||||
username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit.
|
||||
username. Never hand-edit.
|
||||
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for
|
||||
themselves. **Each contributor adds only their own entry**, in their own PR.
|
||||
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
|
||||
@@ -36,20 +34,5 @@ display_name = "Nickname"
|
||||
sync_github_name = true # use the name on your public GitHub profile as display name
|
||||
```
|
||||
|
||||
or, with craftrules checked out next to `craft-apps/`:
|
||||
|
||||
```sh
|
||||
python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name
|
||||
```
|
||||
|
||||
Names show after the next refresh: the script checks that each entry was committed by its own
|
||||
user (or by a maintainer with a `consent_source`). Delete your entry to remove your names.
|
||||
|
||||
## Refreshing (maintainers)
|
||||
|
||||
```sh
|
||||
python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh
|
||||
python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only
|
||||
```
|
||||
|
||||
Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded.
|
||||
Names show for entries committed by their own user (or by a maintainer with a `consent_source`).
|
||||
Delete your entry to remove your names. Git author names and emails are never recorded.
|
||||
|
||||
@@ -32,6 +32,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe COMMAND` documents one) |
|
||||
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason |
|
||||
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
|
||||
| `slide.actions` | `{index?}` | Actions on one slide; returns `{index, slideId, actions}`. Each action has an `owner` (shape id/name and group path; text actions also include paragraph/run indexes and run text, plus table row/cell indexes for table text), `event` (`click`, `hover`, `textLink`, or `textHover`), action `type`, `target`, and the full `hyperlink`. Listing is read-only and never executes an action. An empty slide returns `"actions": []`. |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`, `custom`, `goto`; `cells` while See All Slides is open), documents, perf (fps, render ms) |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state, `animPreview` (canvas playback: slide, time, duration), documents, perf (fps, render ms) |
|
||||
| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` |
|
||||
|
||||
+9
-10
@@ -1,8 +1,6 @@
|
||||
# Where DeckCraft falls short of PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-28 · **Change:** trivial (AutoFormat lists run independently of Replace text as you type; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-27 · **Last updated:** 2026-10-27 · **Change:** minor (docs refresh against merged PRs: #67 and #77 landed, #75 closed unmerged, #53 closed, Windows image paste is #1414; one header line; previous: minor, Animation Pane ▸ Play From; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-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)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -95,8 +93,7 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
outside this repo.
|
||||
- **Impact:** the main reason to switch is "open my PowerPoint decks". Fidelity on real decks
|
||||
(corporate templates, SmartArt, `chartex` charts, embedded fonts, EMF) is still mostly unknown.
|
||||
- **Estimate:** 20–35 h: grow the owned corpus in `storytold/photocraft-corpus` style (craftrules
|
||||
`standards/test-corpora.md`), add render checks, have the owner open outputs in PowerPoint, fix
|
||||
- **Estimate:** 20–35 h: grow the owned corpus in `storytold/photocraft-corpus` style, add render checks, have the owner open outputs in PowerPoint, fix
|
||||
what breaks. **Beta blocker.**
|
||||
|
||||
### Round trip loses media playback options (#74)
|
||||
@@ -219,11 +216,12 @@ and starts at that effect. The pane has an advanced timeline with draggable bars
|
||||
found by running three classroom game decks (Jeopardy-style boards, hide-and-reveal, score
|
||||
counters): a click on a shape whose action is "play sound" (`ppaction://media`, 126 and 486
|
||||
shapes in two decks) does nothing; triggers that start on `onStopAudio` or `begin` conditions are
|
||||
not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count) and do
|
||||
not follow a shape that a motion path has moved. Trigger sequences now keep their separate
|
||||
not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count).
|
||||
Slide-show click targets now follow animated translations and scales, including text links.
|
||||
Trigger sequences now keep their separate
|
||||
click steps on PPTX save, including paragraph builds (`crates/pptx/tests/it/trigger_steps.rs`).
|
||||
- **Impact:** games built around sound effects on buttons, audio-triggered effects or moving
|
||||
clickable shapes do not play as authored.
|
||||
- **Impact:** games built around sound effects on buttons or audio-triggered effects do not play
|
||||
as authored.
|
||||
- **Estimate:** 4–8 h.
|
||||
|
||||
### Notes and Handout masters
|
||||
@@ -305,7 +303,7 @@ Detail in [ui-parity.md](ui-parity.md).
|
||||
choice, video/GIF/CD exports or document protection. 4–8 h for the parts that need no cloud.
|
||||
- **Window chrome** (#71 macOS controls misaligned, #26 GNOME title bar, #41 system light/dark).
|
||||
2–4 h.
|
||||
- **Icon consistency across Craft apps** (#73, #56). 2–4 h; coordinate in craftrules.
|
||||
- **Icon consistency across Craft apps** (#73, #56). 2–4 h.
|
||||
- **Dialogs**: 42 dialogs vs PowerPoint's ~60. In since the d556ff9 count of 27: Font, Paragraph
|
||||
and Tabs, Columns, Bullets and Numbering, Find and Replace, Replace Fonts, Action Settings, Size and
|
||||
Position, Date and Time, Photo Album (Edit Photo Album reopens it), Create New Theme Colors and
|
||||
@@ -370,6 +368,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| 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-27 | minor | Refreshed against merged PRs: removed the Morph alignment entry (#67 merged 2026-10-14); #77 (animation rewind, PR #83) is fixed, leaving #74 in the round-trip gap; #75 was closed unmerged, so #60 stays open with no fix PR; #53 is closed and the image-paste gap is now Windows #1414; Reuse Slides (#1103) and Compare reject (#850) noted in the smaller rows; the duplicate header line is gone |
|
||||
| 2026-10-25 | trivial | New entries: copying text and cell ranges (no media, no table), a control-server timing test that may fail under load; Proofing notes that automatic lists follow Replace text as you type and that Backspace puts the typed lead back |
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@ files it was given rather than guarding against other software on the computer.
|
||||
| `screenshot` | The whole window, UI included (connected mode) |
|
||||
| `call_app` | Any [control protocol](control-protocol.md) method (connected mode) |
|
||||
|
||||
A tool refuses an argument that is not in its schema (`insert_chart: unknown argument "data" (expected: categories, series, title, type)`). Keys inside `run_command`'s `params` are that command's parameters.
|
||||
A tool refuses an argument that is not in its schema (`insert_chart: unknown argument "data" (expected: categories, h, series, title, type, w, x, y)`). Keys inside `run_command`'s `params` are that command's parameters.
|
||||
|
||||
Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset,
|
||||
rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`,
|
||||
|
||||
+4
-6
@@ -8,8 +8,7 @@ Forward-looking: where DeckCraft is going and in what order. The one-page summar
|
||||
|
||||
## Alpha gate: PowerPoint's core workflows
|
||||
|
||||
What a typical PowerPoint user does every day, checked end to end on macOS, the main platform
|
||||
(craftrules `standards/progress-docs.md`, "The core-workflow gate"). Every workflow passes, so
|
||||
What a typical PowerPoint user does every day, checked end to end on macOS, the main platform. Every workflow passes, so
|
||||
DeckCraft stays **alpha**. The "partial" rows don't block the workflow, but they are its weakest
|
||||
points and lead the beta plan.
|
||||
|
||||
@@ -38,8 +37,7 @@ points and lead the beta plan.
|
||||
|
||||
## Plan to beta (~120–210 h; ~40–70 h wall clock with 3–4 agents)
|
||||
|
||||
Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx` reliably
|
||||
(craftrules `standards/progress-docs.md`). Ranked:
|
||||
Beta means ~75% ready for real work and opening and saving PowerPoint's `.pptx` reliably. Ranked:
|
||||
|
||||
| # | Work | Hours | Moves |
|
||||
|---|---|---|---|
|
||||
@@ -94,7 +92,7 @@ From the local execution plan (`plan/execution-plan.md`, gitignored). Status as
|
||||
|
||||
## History: the 2026-10-07 alpha checklist
|
||||
|
||||
Before the craftrules stages existed, DeckCraft defined "alpha" as: install a signed build on
|
||||
Before the stages existed, DeckCraft defined "alpha" as: install a signed build on
|
||||
macOS, Windows or Linux, make a real deck from scratch or from a PowerPoint file, present it with
|
||||
transitions, animations and media, save back to `.pptx`/PDF without losing work, and hit no crashes
|
||||
on common paths. On 2026-10-07 it was ~80% there with these blockers:
|
||||
@@ -108,7 +106,7 @@ on common paths. On 2026-10-07 it was ~80% there with these blockers:
|
||||
| UI fidelity pass on the most-used ribbon groups and dialogs; first run | Partly (start-screen fixes #20, #49); beta item 11 |
|
||||
| Mascot app icon, README screenshots, user docs | App icon (with a scalable SVG, #29) and README screenshots in place; user docs open |
|
||||
|
||||
By the standard's definitions DeckCraft is **alpha** (~55% ready); these blockers now feed the beta
|
||||
DeckCraft is **alpha** (~55% ready); these blockers now feed the beta
|
||||
plan above.
|
||||
|
||||
## Revision history
|
||||
|
||||
@@ -16,7 +16,7 @@ number.
|
||||
| **Ready for real work** | **~55%** | **estimated**: weighted dimension table below (was ~56% until the features row was recomputed from written area weights) |
|
||||
| **Mainstream practitioner** | **~53%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) |
|
||||
| **Essentials user** | **~62%** | **estimated**: [method below](#mainstream-practitioner-and-essentials-user) |
|
||||
| Stage | **alpha** (beta is ~20 points and ~120–210 h away) | judgement against craftrules `standards/progress-docs.md`: ~55% is in the alpha band and all six core workflows pass the [alpha gate](roadmap.md#alpha-gate-powerpoints-core-workflows) |
|
||||
| Stage | **alpha** (beta is ~20 points and ~120–210 h away) | judgement: ~55% is in the alpha band and all six core workflows pass the [alpha gate](roadmap.md#alpha-gate-powerpoints-core-workflows) |
|
||||
| Remaining effort to beta | **~120–210 Opus 5.5 agent hours** | estimated, itemized in [roadmap.md](roadmap.md) |
|
||||
| Remaining effort to full parity | **~365–650 Opus 5.5 agent hours** | estimated, by dimension below |
|
||||
|
||||
@@ -85,8 +85,7 @@ multi-display rigs).
|
||||
|
||||
## Mainstream practitioner and essentials user
|
||||
|
||||
Two narrower readings of "ready", computed the same way in every Craft app (craftrules
|
||||
`standards/progress-docs.md`). The stage still follows the full number above and the alpha gate.
|
||||
Two narrower readings of "ready", computed the same way in every Craft app. The stage still follows the full number above and the alpha gate.
|
||||
|
||||
### Mainstream practitioner: ~53%
|
||||
|
||||
|
||||
@@ -45,14 +45,17 @@ fi
|
||||
# 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.
|
||||
case "$ARCH" in
|
||||
x86_64) MACHINE=" 3e 00" ;;
|
||||
aarch64) MACHINE=" b7 00" ;;
|
||||
x86_64) MACHINE=3e00 ;;
|
||||
aarch64) MACHINE=b700 ;;
|
||||
*) echo "error: no ELF machine check for arch '$ARCH'" >&2; exit 1 ;;
|
||||
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
|
||||
[[ "$(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; }
|
||||
[[ "$(od -An -tx1 -j7 -N1 "$f")" =~ ^\ (00|09)$ ]] || { echo "error: $f is not a FreeBSD ELF (OSABI)" >&2; exit 1; }
|
||||
[[ "$(od -An -tx1 -j18 -N2 "$f")" == "$MACHINE" ]] || { echo "error: $f is not an ELF for $ARCH" >&2; exit 1; }
|
||||
[[ -f "$f" ]] || { echo "error: $f was not built" >&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; }
|
||||
[[ "$(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
|
||||
|
||||
NAME="deckcraft-$VERSION-freebsd-$ARCH"
|
||||
|
||||
@@ -17,6 +17,7 @@ runtime-version: '25.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
command: deckcraft
|
||||
finish-args:
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
|
||||
@@ -18,6 +18,7 @@ sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.rust-stable
|
||||
command: deckcraft
|
||||
finish-args:
|
||||
- --filesystem=xdg-data/ArtCraft/versions:ro
|
||||
# Display: Wayland first; X11 only when no Wayland compositor is running.
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::process::Command;
|
||||
|
||||
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";
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
|
||||
Reference in New Issue
Block a user