Compare commits

...
48 Commits
Author SHA1 Message Date
Dylan M. Taylor 0623b43032 Merge pull request #1985 from dotCooCoo/chore/craft-update-1b34eba41
Updates: take craft-update from craft-libs main at 1b34eba41
2026-11-08 22:55:36 -05:00
dotCooCoo 5ee82b4e45 Updates: take craft-update from craft-libs main at 1b34eba41 2026-11-08 19:43:26 -08:00
Dylan M. Taylor 61423ea35b Merge pull request #1980 from dotCooCoo/chore/craft-update-updater-fixes
Updates: the updater reports installing, ready, installed and failed, and installs at exit
2026-11-08 22:12:17 -05:00
aiongg c4674f24fb Merge pull request #1982 from Silvertree2010/fix/h13151-polynomial-trendline-fit-fails-f
Make polynomial trendline fits independent of x scale and offset
2026-11-08 15:46:38 -05:00
NetterSilverandClaude Opus 5.5 4ba9a1a5d9 Make polynomial trendline fits independent of x scale and offset
Found while testing crates/model/src/trendline.rs:~419 (solve, used by polyfit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:33:22 +01:00
dotCooCoo 3504a9da42 xtask: pdf-deps expects craft-pdf and krilla from the moved craft-libs pin
The pdf-deps check compared craft-pdf and krilla against storytold/craft-libs
at c5a1cbfb, so the gate stopped at that step once the pin moved to
dotCooCoo/craft-libs c7c84230. The expected repository and revision now
match the pin in Cargo.toml.
2026-11-08 12:11:03 -08:00
dotCooCoo ccab980657 Updates: the updater reports installing, ready, installed and failed, and installs at exit
craft-media, craft-pdf, craft-ui, craft-update and krilla move from craft-libs
c5a1cbfb to c7c84230, which carries storytold/craft-libs#687 and #696.

The check result window now shows whenever the updater's status is not
idle: after Help > Check for Updates, and at the first start after an
update, to say that it installed or why it failed. Restart to Update in
that window asks the app to close once nothing is unsaved. on_exit starts
the helper of a ready update, since on macOS it ends the process before
the app is dropped.
2026-11-08 11:30:36 -08:00
aiongg 48e2203123 Merge pull request #1969 from Silvertree2010/fix/h11403-sentence-case-capitalizes-the-wo
Don't capitalize the word after a decimal point in sentence case
2026-11-08 03:35:42 -05:00
NetterSilverandClaude Haiku 5.5 76bd26cca0 Don't capitalize the word after a decimal point in sentence case
Found while testing crates/model/src/edit.rs:~505 (change_case_parts, sentence branch)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 08:26:05 +00:00
Nasim 02fbb6a976 Notes: pin that Paste reaches the focused speaker-notes field, bare and inside the ⌘V batch (refs #1855) (#1856) 2026-11-08 01:59:22 -05:00
aiongg c16db2ef3e Merge pull request #1943 from neilcawse/fix/pptx-body-and-group-3d
pptx: preserve 3-D scene and extrusion XML on text bodies and groups
2026-11-08 01:18:16 -05:00
aiongg 809c44654d Apply review fixes
- crates/pptx/src/write/shapes.rs
2026-11-08 05:59:15 +00:00
aiongg 220b52dfdb Apply review fixes
- docs/file-format-parity.md
2026-11-08 05:43:41 +00:00
Neil Cawse 424f575ca5 fix(pptx): bound and validate body/group raw3d XML on read/write and expand 3-D tests 2026-11-08 05:26:33 +00:00
Neil Cawse aefd54d28e pptx: preserve 3-D scene and extrusion XML on text bodies and groups 2026-11-08 05:24:59 +00:00
Nasim 25809faf1a Accessibility: name ribbon drop-downs by purpose and expose the shown text as their value (#1956) (#1963) 2026-11-08 00:18:14 -05:00
aiongg fc35ad67c6 Merge pull request #1950 from Silvertree2010/fix/h9583-format-paragraph-silently-left-a
Fix format.paragraph silently left-aligning known align names
2026-11-07 15:10:55 -05:00
NetterSilverandClaude Haiku 5.5 57780f9e61 Fix format.paragraph silently left-aligning known align names
Found while testing crates/engine/src/cmd/format/paragraph.rs:123-129

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:00:33 +00:00
FredApps 0627fb2589 Merge pull request #1932 from helenkwok/web/page-api
deckcraft-web: window.deckcraft, the page command API from craft-web-api
2026-11-07 15:37:54 +01:00
aiongg 170c5591b5 Merge pull request #1934 from Silvertree2010/fix/h7742-shape-adjust-with-an-invalid-neg
Reject invalid index in shape.adjust and shape.quickStyle
2026-11-07 03:34:42 -05:00
NetterSilverandClaude Haiku 5.5 a883d6b2d9 Reject invalid index in shape.adjust and shape.quickStyle
Found while testing crates/engine/src/cmd/shape/appearance.rs:8 (adjust, index via usize_param); same pattern in quick_style at :559

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 08:14:47 +00:00
Helen Kwok 4b1e50a6b0 deckcraft-web: put each Cargo.toml comment above its own dependency, and say a call the app already took still finishes after a timeout 2026-11-07 10:28:04 +08:00
Helen Kwok c7919102ac deckcraft-web: window.deckcraft, the page command API from craft-web-api
The web shell queues the page's calls as control requests (the channel the desktop control server uses) and answers them on the next frame. The API is installed in every mode; opening, saving, the file name and the unsaved flag stay with the host messages.
2026-11-07 08:05:09 +08:00
FredApps 159ed07eb3 Merge pull request #1918 from storytold/house-rules
Copy the shared house rules into AGENTS.md
2026-11-06 15:00:00 +01:00
FredApps de9e79a445 docs: preserve local guidance in house-rules rollout 2026-11-06 12:36:46 +01:00
FredApps d7343553b3 Copy the shared house rules into AGENTS.md.
The blocks come from craft-contrib at dde09ea31e2e457f5b4653a6e76f7cdf55944bdf. CLAUDE.md imports AGENTS.md. The house-rules workflow checks the copy.
2026-11-06 11:50:01 +01:00
Luis 9a95c3a639 Release: refresh contributor credits for each release (#1889) (#1890)
About > Contributors still showed the snapshot taken for v0.4.0, because
nothing regenerated contributors/contributors.json before a release.
Opt in to the shared refresh from craft-common-actions: a read-only
credits job regenerates the snapshot for the release commit, and the
shared release workflow installs it before every source build. The
snapshot is not committed back.
2026-11-05 17:23:44 +01:00
FredApps daeedd5d19 Merge pull request #1895 from helenkwok/web/host-crate
deckcraft-web: host messages through craft-web-host, and page host mode
2026-11-05 14:11:06 +01:00
FredAppsandCursor a2c1633fe3 fix(web): refuse a host file of exactly 2 GiB
wasm32 cannot allocate a Vec of 2 GiB (isize::MAX is one byte less), so
the open check that only rejected a larger file trapped on that boundary.
Refuse 2 GiB as well, and say so in the failure the host receives.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-11-05 13:59:10 +01:00
aiongg bfdcc5cdfa Apply review fixes
- packaging/web/README.md
- apps/deckcraft-web/src/host.rs
2026-11-05 11:55:08 +00:00
Michael Müllerandaiongg 73de9900a7 Show: Presenter View only with a second display (#1156)
* Show: Presenter View only with a second display

With one display the presenter window opened on the same screen as the show. Under Hyprland's
focus-follows-mouse, whichever window the pointer was over took full screen, and the hidden one
stalled every frame: the show lagged, dropped key presses or froze.

One display now shows only the slides, as in PowerPoint. The host counts displays through winit's
monitor list when a show starts; hosts that can't tell keep Presenter View.

* Show: count displays by MonitorHandle

Wayland lists one output twice under the same wl_output id, so comparing handles counts it once, and two separate displays with the same name and geometry still count as two.

* Show: docs for Presenter View with a second display

slideshow-parity and hardware-parity note that on Linux one display shows only the slides, with status lines and revision history; ROADMAP progress log. display_count collects into a BTreeSet (MonitorHandle's Ord matches its Eq).

* Show: docs state the one-display rule exactly

Presenter View stays off when winit lists exactly one display; if it lists none, the setting is kept. The host callback covers Unix other than macOS, so the docs say Linux and the BSDs.

* Apply review fixes

- docs/slideshow-parity.md

---------

Co-authored-by: aiongg <67543387+aiongg@users.noreply.github.com>
2026-11-05 12:51:16 +01:00
aiongg b225bb16a1 Apply review fixes
- apps/deckcraft-web/src/host.rs
2026-11-05 11:49:17 +00:00
Helen Kwok 9299113268 deckcraft-web: speak the host messages through craft-web-host, and add page host mode
The transports, the message types and the origin checks come from the shared crate (storytold/craft-libs#420). `data-host="page"` on the canvas now hosts DeckCraft in a page's own document, as in the other apps; the parent bridge keeps its behaviour.
2026-11-05 17:51:01 +08:00
FredApps 2b6d27d525 Merge pull request #1658 from SSchott/copy-the-fix
Fix Copying Images, Ctrl-C/Ctrl-V and within Deckcraft
2026-11-05 05:59:48 +01:00
FredApps 9998ee5e07 Merge pull request #1809 from jimprince/web-host-bridge
Web: open and save through a framing page (?host=parent)
2026-11-05 05:59:44 +01:00
FredApps 9e8aa81298 Merge remote-tracking branch 'origin/main' into copy-the-fix 2026-11-05 05:49:57 +01:00
FredApps 4f171e8f78 Merge remote-tracking branch 'origin/main' into copy-the-fix 2026-11-05 05:41:44 +01:00
FredApps d82aaddab4 Merge remote-tracking branch 'origin/main' into copy-the-fix 2026-11-05 05:24:59 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 070f12cc9c Keep Source Formatting resolves colours as the renderer does
Review follow-up on #1658 (CodeRabbit), both from storing the slide's
mapped scheme in 30ccc38e:

- Copying in Slide Master view used the current slide's colour map. Use
  `ctx_for`, so the theme is that of what is being edited.
- `ColorScheme` shares an entry between `dk1` and `tx1`, so a mapped scheme
  changed direct `dk1` colours too: a dark fill pasted white. `SourceTheme`
  keeps the theme's colours and the map apart, and `bake_theme` maps only
  `tx1`, `bg1`, `tx2` and `bg2`, as `Ctx::color` does.

The browser image test now checks that non-picture pastes keep their text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:23:35 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 46a15a4684 Merge branch 'main' into copy-the-fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:19:46 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 30ccc38e90 Keep Source Formatting uses the slide's colour map
Review follow-up on #1658 (CodeRabbit): `source_theme` stored the master's
scheme, so a slide or layout that overrides the colour map had its colours
fixed through the wrong map. Detaching made this reach a placeholder's
inherited colours: a title drawn white under an override pasted black.
Store the scheme the slide draws with. Paste as Picture reads the same
value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:02:22 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 d6cc51e07a Alt+drag detaches placeholders; Slide Master view pastes them as placeholders
Review follow-up on #1658:

- The drag duplicate cleared `ph` without detaching, so a title or body
  dragged off with Alt lost its size and bullets. Copy, Duplicate and the
  drag now share `detach_on_slide`.
- Detaching at copy time turned a slide placeholder pasted in Slide Master
  view into a regular shape. The clipboard keeps the originals, and a paste
  there takes them, so it stays a placeholder as before this PR. Keep
  Source Formatting bakes them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:43:33 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 077cd36e9f Merge branch 'main' into copy-the-fix
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:38:04 +01:00
Stephan Schott-Verdugo ba3d74dfb5 Merge branch 'main' into copy-the-fix 2026-11-03 09:10:31 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 1192e5ef30 Detached placeholders follow each property's style precedence
Review follow-up (CodeRabbit on #1658):

- Text levels are resolved before the copy's style changes. A master
  placeholder's font reference reached the copy's text (body font in an
  accent colour for a title) although the original ignores it: text only
  takes the shape's or the layout placeholder's font reference.
- Fill, line and effects come from where `resolve::fill`, `resolve::line`
  and `resolve::effects` take them: the first shape in the chain with an
  explicit value or a style. A layout's (or the slide's own) style no
  longer loses to an explicit fill, line or effect on the master. The
  copy gets one style whose references each come from the shape that
  supplied that property; unused references are neutral.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:06:09 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 f006b5d8ce Ctrl+V pastes a picture copied in a browser, not its link
A browser's Copy Image puts the picture on the clipboard together with
text that only names it (the address and alt text, or Firefox's UTF-16
text/x-moz-url read as UTF-8) and an <img> HTML fragment. Ctrl+V only
looked for a picture when the text was empty, so it pasted a text box
with the link. When the text or HTML only names a picture, paste the
picture; with no picture on the clipboard the link still pastes as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:38:05 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 e1cfe8516e Paste and duplicate keep a placeholder's inherited look
A placeholder copied or duplicated on a slide became a regular shape by
clearing `ph`, which also dropped everything it inherited from its layout
and master: the title's 44 pt heading font, the body's bullets and indent
levels, autofit and the box. Without `ph` the text fell back to the
presentation's default style (18 pt, no bullets), on Ctrl+V and the
context menu alike.

`resolve::detach_placeholder` makes the inherited box, fill, line,
effects, body properties and every level's text style the shape's own
before `ph` is cleared. Theme references stay references, so the default
paste still follows the destination theme. Copy (so Cut and pasting into
another deck work after the source is gone) and Duplicate use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:29:43 +01:00
Stephan Schott-VerdugoandClaude Opus 5.5 cbb0c48445 Fix OS clipboard: enable eframe's clipboard feature dropped in the egui 0.37 migration
Since #1350 (v0.11.1) the desktop build listed eframe features without
`clipboard`, so egui-winit simulated a clipboard inside the app: Ctrl+V
never read text or pictures from other apps, and Ctrl+C of text (sent out
as HTML through arboard) never reached egui's clipboard, so Ctrl+V pasted
nothing or stale text. The context menu kept working because it only uses
the session clipboard.

Turn the feature back on. On native Wayland, egui-winit owns the clipboard
through winit's data device (text and PNG); leave the X11-only arboard
pasteboard (HTML, pictures) off there so copies are claimed on the Wayland
selection. Regression tests guard the feature list and the Wayland switch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:19:06 +01:00
49 changed files with 1581 additions and 254 deletions
+28
View File
@@ -0,0 +1,28 @@
name: House rules
on:
push:
branches: [main]
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
pull_request:
paths:
- AGENTS.md
- CLAUDE.md
- .github/workflows/house-rules.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: house-rules-${{ github.ref }}
cancel-in-progress: true
jobs:
house-rules:
uses: storytold/craft-common-actions/.github/workflows/house-rules.yml@main
with:
rust: true
+8
View File
@@ -19,7 +19,14 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
# Regenerates contributors/contributors.json for the release commit; every build bundles it.
credits:
permissions:
contents: read
pull-requests: read
uses: storytold/craft-common-actions/.github/workflows/refresh-contributors.yml@main
release: release:
needs: credits
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/release.yml@main uses: storytold/craft-common-actions/.github/workflows/release.yml@main
with: with:
@@ -30,6 +37,7 @@ jobs:
# manifest fails the release instead of silently skipping it. # manifest fails the release instead of silently skipping it.
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69 craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
version: ${{ inputs.version }} version: ${{ inputs.version }}
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
secrets: secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
+61 -10
View File
@@ -1,3 +1,56 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
1. **A person is accountable.** Whoever runs an agent answers for what it opens and for its review comments.
2. **Check, then claim.** Before writing code, read the issue comments, search pull requests for `#N` and the keywords, and check recent commits on `main`. Comment on the issue first. Release a claim you will not finish.
3. **Verify, don't guess.** Open a definition before using it. Confirm the cause in the code, even when someone names a location.
4. **Keep fixes narrow and tested.** Fix what the issue describes, where the bug is. Add a test that fails without the fix.
5. **Review the diff before opening a pull request.** Identifiers exist as used, the test fails without the fix, nothing unrelated changed, and the description is true.
6. **Look for a better pull request.** If another fixes the same thing, close yours when it is better or equal, and say why.
7. **Report only what ran.** Say what you tested and what you could not run. Without a local build or test, say so and rely on CI before marking ready.
8. **`main` changes through a pull request.** Do not push to it. Branch protection enforces this. There is no exception.
9. **Stop and ask only about** licensing outside the allowlist, publishing, releases, a new remote, secrets, spending money, destructive git, and anything else hard to undo. Otherwise keep working. `plan/` is a gitignored notebook, not rules. Without it, do not ask for it and do not stop.
10. **Keep secrets and personal details out** of the repository, logs and requests.
11. **CI stays shared.** Workflows call `storytold/craft-common-actions`. Do not change CI in a feature or fix pull request. If CI cannot cover a change, open an issue there.
12. **Guards say why.** A check that looks removable says what it prevents. Read that before changing it.
13. **Instructions must be readable by everyone.** No notes about one machine, and no links to files a reader cannot open. `craftrules` is retired. Do not link to it.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
1. **Never crash.** Non-test code does not panic and has no `unsafe`, except where the repository file names an exception. Return errors, treat input as hostile, and bound recursion and allocations. New production crate roots copy the `deny` attribute from an existing crate: `clippy::unwrap_used`, `clippy::expect_used`, `clippy::panic`, `clippy::unimplemented`, `clippy::todo`, `clippy::unreachable`. Every crash fix comes with a test that panicked before the fix.
2. **Clean room.** Learn from observed behaviour and public specifications. Do not copy another product's code, assets, strings or artwork. Check a dependency's licence before adding it.
3. **Assets and fonts.** Every asset is original or suitably licensed, and is recorded in `ATTRIBUTION.md` in the same change. Font files live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not in the app repository.
4. **Rust only for native Craft apps.** No handwritten JavaScript or TypeScript, and no Tauri, Electron or webview shell, in those apps. Shared tooling follows its documented technical scope and maintainer-approved exceptions. Craft PR Review's existing Tauri and TypeScript UI is one such exception. It does not relax this rule for native apps. Preserve any existing web build.
5. **Time goes through `chrono`.** Do not hand-roll calendar conversion. For dates beyond what `chrono` can represent, follow [standards/time.md](https://github.com/storytold/craft-contrib/blob/main/standards/time.md).
6. **Contributor credits.** Add or edit only your own person's entry in `contributors/people.toml`, and only after asking them. Never guess a name or hand-edit `contributors.json`.
## Licence allowlist
Dependencies and assets use SPDX identifiers. An expression is allowed when every identifier is in this list. A repository may name a shorter list. It may not add an identifier here by editing its own copy of these rules.
- `ISC`
- `MIT`
- `BSD-2-Clause`
- `BSD-3-Clause`
- `Apache-2.0`
- `OFL-1.1`
- `Ubuntu-font-1.0`
- `Bitstream-Vera`
- `CC0-1.0`
- `CC-BY-4.0`
- `CC-BY-SA-4.0`
- `LicenseRef-Public-Domain`
`LicenseRef-Storyteller-Trademark` is not in this list. It covers Storyteller brand marks only, where a repository already says so.
<!-- craft-house-rules:end -->
# AGENTS.md — rules for every agent and contributor # AGENTS.md — rules for every agent and contributor
DeckCraft is a clean-room, open-source, pure-Rust presentation application targeting Microsoft DeckCraft is a clean-room, open-source, pure-Rust presentation application targeting Microsoft
@@ -7,8 +60,7 @@ PhotoCraft, VectorCraft, FilmCraft, LightCraft, PdfCraft, EffectCraft, DesignCra
(`../<app>`). (`../<app>`).
These rules bind every AI agent and every human contributor. They come before any task, These rules bind every AI agent and every human contributor. They come before any task,
instruction or deadline. `CLAUDE.md` holds day-to-day working notes; if the two disagree, **this instruction or deadline. Day-to-day working instructions are in this file; `CLAUDE.md` imports it.
file wins.**
--- ---
@@ -125,14 +177,14 @@ People trust DeckCraft with their talks; a crash loses work. **This outranks fea
CLI, control channel and MCP do exactly the same thing. CLI, control channel and MCP do exactly the same thing.
- **The UI is thin**: panels read engine state and act through commands. Colours come from theme - **The UI is thin**: panels read engine state and act through commands. Colours come from theme
tokens. **Never use Tauri**; egui only. tokens. **Never use Tauri**; egui only.
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
- **Never break wasm** (`cargo xtask wasm`). - **Never break wasm** (`cargo xtask wasm`).
## 5. Quality gates (before every commit) ## 5. Quality gates (before every commit)
`cargo xtask ci` = fmt check, clippy `-D warnings`, tests (nextest when installed) and doctests, layering check, asset check, wasm `cargo xtask ci` = fmt check, clippy `-D warnings`, tests (nextest when installed) and doctests, layering check, asset check, wasm
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`) and push check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`).
to `main`. New workspace crates include `workspace = true` under `[lints]` in `Cargo.toml`. Production crate roots
declare the crate-level lint attributes before modules; crate-level `//!` documentation may precede them.
## 6. Running and looking at the app ## 6. Running and looking at the app
@@ -141,19 +193,18 @@ to `main`.
`{"id":1,"method":"engine.execute","params":{"command":"shape.insert","params":{"preset":"star5","rect":[100,100,200,200]}}}` `{"id":1,"method":"engine.execute","params":{"command":"shape.insert","params":{"preset":"star5","rect":[100,100,200,200]}}}`
then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods:
`crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`. `crates/ui-egui/src/control.rs`, docs: `docs/control-protocol.md`.
- **For UI work, look at the result** (screenshot, read the PNG) and compare with the observations - **For UI work, look at the result** (screenshot, read the PNG). If available, compare with the observations
in `plan/powerpoint/01-observed-ui.md`. in `plan/powerpoint/01-observed-ui.md`. Otherwise verify the intended behaviour against the task and
committed documentation; the optional notebook is not a prerequisite.
- Headless: `deckcraft-cli render --sample --all --scale 1 out/`, - Headless: `deckcraft-cli render --sample --all --scale 1 out/`,
`deckcraft-cli run --sample --cmd 'slide.new={"title":"Hi"}' --export out.png`. `deckcraft-cli run --sample --cmd 'slide.new={"title":"Hi"}' --export out.png`.
- MCP: `deckcraft-cli mcp` (headless) or `deckcraft-cli mcp --connect 7990` (drive the app). - MCP: `deckcraft-cli mcp` (headless) or `deckcraft-cli mcp --connect 7990` (drive the app).
See `docs/mcp.md`. See `docs/mcp.md`.
- Shell gotcha on the dev machine: `mv`/`cp` are aliased interactive; use `/bin/mv -f`, `/bin/cp -f`.
- Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own. - Parallel agents: separate `CARGO_TARGET_DIR` per agent; edit only the crates you own.
## 7. Planning ## 7. Planning
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`, `plan/` is an optional gitignored notebook (status, execution notes, `powerpoint/` observations). Do not ask for it and do not stop when it is absent. The committed progress
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
docs: docs:
| File | Holds | | File | Holds |
+1 -25
View File
@@ -1,25 +1 @@
# DeckCraft — working notes for Claude @AGENTS.md
Read [AGENTS.md](AGENTS.md) first: its rules (asset policy, clean room, never crash) bind you and
win over anything here.
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
before modules or other crate items; crate-level `//!` documentation may precede them.
## Start every session
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`.
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/*`).
- Look at UI changes: `deckcraft --sample --control 7990` + `ui.screenshot`, or
`deckcraft-cli render`.
- Commit after each landed arc (`M<n>.<k>: what`) and push to `origin main`.
- Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current.
- Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`.
## CI workflows
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
Generated
+126 -13
View File
@@ -565,18 +565,42 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
[[package]]
name = "calloop"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
dependencies = [
"bitflags 2.13.2",
"polling",
"rustix 1.1.5",
"tracing",
]
[[package]] [[package]]
name = "calloop-wayland-source" name = "calloop-wayland-source"
version = "0.3.0" version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
dependencies = [ dependencies = [
"calloop", "calloop 0.13.0",
"rustix 0.38.44", "rustix 0.38.44",
"wayland-backend", "wayland-backend",
"wayland-client", "wayland-client",
] ]
[[package]]
name = "calloop-wayland-source"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
dependencies = [
"calloop 0.14.5",
"rustix 1.1.5",
"wayland-backend",
"wayland-client",
]
[[package]] [[package]]
name = "cast" name = "cast"
version = "0.3.0" version = "0.3.0"
@@ -823,12 +847,12 @@ dependencies = [
[[package]] [[package]]
name = "craft-fonts-build" name = "craft-fonts-build"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
[[package]] [[package]]
name = "craft-i18n" name = "craft-i18n"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [ dependencies = [
"fixed_decimal", "fixed_decimal",
"icu_decimal", "icu_decimal",
@@ -851,7 +875,7 @@ dependencies = [
[[package]] [[package]]
name = "craft-media" name = "craft-media"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [ dependencies = [
"thiserror 2.0.21", "thiserror 2.0.21",
] ]
@@ -859,7 +883,7 @@ dependencies = [
[[package]] [[package]]
name = "craft-pdf" name = "craft-pdf"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [ dependencies = [
"krilla", "krilla",
"thiserror 2.0.21", "thiserror 2.0.21",
@@ -868,7 +892,7 @@ dependencies = [
[[package]] [[package]]
name = "craft-ui" name = "craft-ui"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [ dependencies = [
"craft-fonts-build", "craft-fonts-build",
"craft-i18n", "craft-i18n",
@@ -888,7 +912,7 @@ dependencies = [
[[package]] [[package]]
name = "craft-update" name = "craft-update"
version = "0.1.0" version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [ dependencies = [
"chrono", "chrono",
"dunce", "dunce",
@@ -911,6 +935,26 @@ dependencies = [
"zip 9.1.0", "zip 9.1.0",
] ]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.2" version = "1.5.2"
@@ -1298,6 +1342,8 @@ dependencies = [
name = "deckcraft-web" name = "deckcraft-web"
version = "0.15.0" version = "0.15.0"
dependencies = [ dependencies = [
"craft-web-api",
"craft-web-host",
"deckcraft-engine", "deckcraft-engine",
"deckcraft-ui-egui", "deckcraft-ui-egui",
"eframe", "eframe",
@@ -1508,13 +1554,16 @@ dependencies = [
"block2 0.6.2", "block2 0.6.2",
"bytemuck", "bytemuck",
"egui", "egui",
"image",
"log", "log",
"objc2 0.6.5", "objc2 0.6.5",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
"objc2-ui-kit 0.3.2", "objc2-ui-kit 0.3.2",
"parking_lot",
"profiling", "profiling",
"raw-window-handle", "raw-window-handle",
"smithay-clipboard",
"web-time", "web-time",
"webbrowser", "webbrowser",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@@ -2742,7 +2791,7 @@ dependencies = [
[[package]] [[package]]
name = "krilla" name = "krilla"
version = "0.8.2" version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7" source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bumpalo", "bumpalo",
@@ -4437,8 +4486,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"calloop", "calloop 0.13.0",
"calloop-wayland-source", "calloop-wayland-source 0.3.0",
"cursor-icon", "cursor-icon",
"libc", "libc",
"log", "log",
@@ -4455,6 +4504,44 @@ dependencies = [
"xkeysym", "xkeysym",
] ]
[[package]]
name = "smithay-client-toolkit"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
dependencies = [
"bitflags 2.13.2",
"calloop 0.14.5",
"calloop-wayland-source 0.4.1",
"cursor-icon",
"libc",
"log",
"memmap2",
"rustix 1.1.5",
"thiserror 2.0.21",
"wayland-backend",
"wayland-client",
"wayland-csd-frame",
"wayland-cursor",
"wayland-protocols",
"wayland-protocols-experimental",
"wayland-protocols-misc",
"wayland-protocols-wlr",
"wayland-scanner",
"xkeysym",
]
[[package]]
name = "smithay-clipboard"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
dependencies = [
"libc",
"smithay-client-toolkit 0.20.0",
"wayland-backend",
]
[[package]] [[package]]
name = "smol_str" name = "smol_str"
version = "0.2.2" version = "0.2.2"
@@ -4962,6 +5049,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [ dependencies = [
"log",
"pin-project-lite", "pin-project-lite",
"tracing-attributes", "tracing-attributes",
"tracing-core", "tracing-core",
@@ -5404,6 +5492,32 @@ dependencies = [
"wayland-scanner", "wayland-scanner",
] ]
[[package]]
name = "wayland-protocols-experimental"
version = "20250721.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]]
name = "wayland-protocols-misc"
version = "0.3.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
dependencies = [
"bitflags 2.13.2",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"wayland-scanner",
]
[[package]] [[package]]
name = "wayland-protocols-plasma" name = "wayland-protocols-plasma"
version = "0.3.12" version = "0.3.12"
@@ -6014,7 +6128,7 @@ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"block2 0.5.1", "block2 0.5.1",
"bytemuck", "bytemuck",
"calloop", "calloop 0.13.0",
"cfg_aliases", "cfg_aliases",
"concurrent-queue", "concurrent-queue",
"core-foundation 0.9.4", "core-foundation 0.9.4",
@@ -6035,7 +6149,7 @@ dependencies = [
"raw-window-handle", "raw-window-handle",
"redox_syscall 0.4.1", "redox_syscall 0.4.1",
"rustix 0.38.44", "rustix 0.38.44",
"smithay-client-toolkit", "smithay-client-toolkit 0.19.2",
"smol_str", "smol_str",
"tracing", "tracing",
"unicode-segmentation", "unicode-segmentation",
@@ -6457,7 +6571,6 @@ dependencies = [
"nix", "nix",
"typed-path", "typed-path",
"windows-sys 0.61.2", "windows-sys 0.61.2",
"zopfli",
] ]
[[package]] [[package]]
+5 -5
View File
@@ -59,11 +59,11 @@ harfrust = "0.13"
vello_cpu = "0.2" vello_cpu = "0.2"
vello_common = { version = "0.2", default-features = false } vello_common = { version = "0.2", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false } craft-media = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", 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-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
# craft-update: app-specific pretend versions apply to startup and manual checks. # craft-update: app-specific pretend versions apply to startup and manual checks.
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] } craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" } craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0" }
i_overlay = "9" i_overlay = "9"
quick-xml = "0.41" quick-xml = "0.41"
zip = { version = "3", default-features = false, features = ["deflate"] } zip = { version = "3", default-features = false, features = ["deflate"] }
@@ -109,7 +109,7 @@ codegen-units = 16
debug = 0 debug = 0
[patch.crates-io] [patch.crates-io]
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.8.2" } krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.8.2" }
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" } epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
+6
View File
@@ -7,6 +7,7 @@
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (progress log: fix for a workflow-breaking regression: Ctrl+C/X/V use the system clipboard again, broken for all text and objects from 0.11.1 to 0.15.0; pasted and duplicated placeholders keep their look; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -155,6 +156,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|---|---| |---|---|
| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered | | 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered |
| 2026-11-03 | Web: the `?host=parent` bridge (as in GridCraft, WordCraft and PdfCraft) lets a same-origin page that frames DeckCraft send a presentation and store its saves; Save writes an opened .pptx back as .pptx under its own name, as on the desktop | | 2026-11-03 | Web: the `?host=parent` bridge (as in GridCraft, WordCraft and PdfCraft) lets a same-origin page that frames DeckCraft send a presentation and store its saves; Save writes an opened .pptx back as .pptx under its own name, as on the desktop |
| 2026-11-02 | Slide show on Linux and the BSDs: Presenter View stays off when winit lists exactly one display, as in PowerPoint; if it lists none, the setting is kept. On one display the presenter window covered the show, and the hidden window stalled every frame: slide changes took up to ~1.7 s under Hyprland (#1156) |
| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order | | 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order |
| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves | | 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves |
| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged | | 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged |
@@ -164,6 +166,9 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-11-02 | PPTX reflection effects keep their authored start position, affine scale/skew, direction and fade, alignment, and rotation flag through native and PPTX saves | | 2026-11-02 | PPTX reflection effects keep their authored start position, affine scale/skew, direction and fade, alignment, and rotation flag through native and PPTX saves |
| 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables | | 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables |
| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) | | 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) |
| 2026-11-01 | Ctrl+V of a picture copied in a browser (Copy Image) pastes the picture, not a text box with its link and alt text; the link still pastes as text when no picture came with it |
| 2026-11-01 | Copy, Cut, Paste, Duplicate and Alt+drag of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text |
| 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows |
| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) |
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | 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-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 |
@@ -341,6 +346,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-11-03 | trivial | Progress log: web `?host=parent` bridge to a framing page; scores unchanged | | 2026-11-03 | trivial | Progress log: web `?host=parent` bridge to a framing page; scores unchanged |
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained | | 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged | | 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged |
| 2026-11-01 | trivial | Workflow-breaking regression fixed: the desktop build uses the system clipboard again (eframe `clipboard` feature) |
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | | 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged | | 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
+4
View File
@@ -16,6 +16,10 @@ path = "src/main.rs"
[dependencies] [dependencies]
deckcraft-engine = { workspace = true } deckcraft-engine = { workspace = true }
deckcraft-ui-egui = { workspace = true } deckcraft-ui-egui = { workspace = true }
# The page command API (storytold/craft-libs#413): window.deckcraft over the control channel.
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
egui = { workspace = true } egui = { workspace = true }
log = { workspace = true } log = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
+151
View File
@@ -0,0 +1,151 @@
//! `window.deckcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
//!
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
//! `screenshot`, as promises with a timeout). This module is DeckCraft's part: a [`Request`] that
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of
//! applying a command the page was told had failed.
use std::sync::Arc;
use std::sync::atomic::AtomicU8;
use std::sync::mpsc::{Receiver, TryRecvError};
use deckcraft_ui_egui::ControlRequest;
use serde_json::{Value, json};
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
#[cfg(target_arch = "wasm32")]
const POLL_MS: i32 = 16;
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes).
struct Pending {
reply: Receiver<Value>,
state: Arc<AtomicU8>,
answered: bool,
}
impl Pending {
/// `Some` once the app has answered or gone away; `None` while the request is still waiting.
fn poll(&mut self) -> Option<Value> {
match self.reply.try_recv() {
Ok(reply) => {
self.answered = true;
Some(reply)
}
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
self.answered = true;
Some(json!({"ok": false, "error": "the app dropped the request"}))
}
}
}
}
impl Drop for Pending {
fn drop(&mut self) {
if !self.answered {
ControlRequest::abandon(&self.state);
}
}
}
#[cfg(target_arch = "wasm32")]
pub use web::install;
#[cfg(target_arch = "wasm32")]
mod web {
use std::rc::Rc;
use std::sync::mpsc::channel;
use craft_web_api::methods::Methods;
use craft_web_api::page::{PageApi, Request};
use deckcraft_ui_egui::{ControlRequest, SlideApp};
use serde_json::json;
use wasm_bindgen_futures::JsFuture;
use super::{POLL_MS, Pending};
/// The app id in `window.<app>`.
const APP: &str = "deckcraft";
/// Give the app its control queue and put `window.deckcraft` on the page. An existing
/// `window.deckcraft` keeps the functions it already has.
pub fn install(app: &mut SlideApp, ctx: egui::Context) {
let (queue, requests) = channel::<ControlRequest>();
app.control_rx = Some(requests);
let request: Request = Rc::new(move |method, params| {
let (control, reply) = ControlRequest::new(method, params);
let mut pending = Pending { reply, state: control.state.clone(), answered: false };
if queue.send(control).is_err() {
pending.answered = true;
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
}
ctx.request_repaint();
Box::pin(async move {
loop {
if let Some(reply) = pending.poll() {
return reply;
}
sleep(POLL_MS).await;
}
})
});
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
log::warn!("couldn't install window.{APP}: {e:?}");
}
}
async fn sleep(ms: i32) {
let promise = js_sys::Promise::new(&mut |resolve, _| {
if let Some(window) = web_sys::window() {
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
}
});
let _ = JsFuture::from(promise).await;
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use super::*;
/// `ControlRequest`'s `state` values (`deckcraft_ui_egui::control`): the app has started it, or its
/// sender gave up.
const TAKEN: u8 = 1;
const ABANDONED: u8 = 2;
fn pending() -> (Pending, ControlRequest) {
let (control, reply) = ControlRequest::new("document.inspect", json!({}));
(Pending { reply, state: control.state.clone(), answered: false }, control)
}
#[test]
fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() {
let (waiting, control) = pending();
drop(waiting);
assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED);
assert!(!control.take(), "the app must not run a request the page was told had failed");
}
#[test]
fn an_answered_call_is_not_released() {
let (mut waiting, control) = pending();
assert!(control.take());
control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1})));
drop(waiting);
assert_eq!(control.state.load(Ordering::SeqCst), TAKEN);
}
#[test]
fn a_request_the_app_never_runs_waits_until_it_is_dropped() {
let (mut waiting, control) = pending();
assert_eq!(waiting.poll(), None);
drop(control);
assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
}
}
+64 -107
View File
@@ -1,17 +1,21 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed //! The host bridge, for pages that embed DeckCraft and keep its files (a Nextcloud app, a personal
//! DeckCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The //! file server). The messages and the two transports are `craft-web-host`'s
//! same messages as GridCraft's, WordCraft's and PdfCraft's bridges; each is a plain object with a //! (storytold/craft-libs), the same contract as the other Craft apps' bridges, with the
//! `type`: //! `deckcraft:` prefix. This module is DeckCraft's part of it.
//! //!
//! - `deckcraft:ready` (to the parent): `{ version }`, sent once the app is running and //! - `?host=parent` (an `<iframe>`) talks to the same-origin parent over `postMessage`;
//! listening; send a file now. //! `data-host="page"` on the canvas (the page's own document, no iframe) uses DOM
//! - `deckcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or //! `CustomEvent`s on the canvas instead. `?host=parent` wins when both are present. With no
//! parent, or a parent on another origin, there is no bridge: the normal app with downloads.
//! - `deckcraft:ready` (to the host): sent once the app is running and listening; send a file now. DeckCraft
//! reads bytes, so it says `file: false` and a host sends `bytes`.
//! - `deckcraft:open` (from the host): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`, name a bare file name. It opens like a file picked with File › Open on the //! `Uint8Array`, name a bare file name. It opens like a file picked with File › Open on the
//! desktop: a presentation sent with its name (.pptx, .potx, .ppsx, .deckcraft) has that name //! desktop: a presentation sent with its name (.pptx, .potx, .ppsx, .deckcraft) has that name
//! as its file, so Save goes back to it; an outline (.txt, .md, .rtf), a file without a name and //! as its file, so Save goes back to it; an outline (.txt, .md, .rtf), a file without a name and
//! a damaged file open untitled. //! a damaged file open untitled.
//! - `deckcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for //! - `deckcraft:save` (to the host): `{ name, saveAs, bytes }` (with the bytes as a transferred `ArrayBuffer` over `postMessage`) for
//! every file DeckCraft would otherwise download. The parent stores it and reports failures //! every file DeckCraft would otherwise download. The host stores it and reports failures
//! itself. `saveAs` says whether the file becomes the presentation's file: //! itself. `saveAs` says whether the file becomes the presentation's file:
//! - Save: `false`. It writes the opened file under its own name and format (a .pptx stays a //! - Save: `false`. It writes the opened file under its own name and format (a .pptx stays a
//! .pptx), as the desktop app does. A name the parent never sent (an untitled presentation, an //! .pptx), as the desktop app does. A name the parent never sent (an untitled presentation, an
@@ -22,59 +26,56 @@
//! now that file; the next Save sends its name with `false`. //! now that file; the next Save sends its name with `false`.
//! - Export (PDF, pictures, outline, GIF, handouts, a Save As type that isn't a presentation) //! - Export (PDF, pictures, outline, GIF, handouts, a Save As type that isn't a presentation)
//! and Print: `false`. They are copies; the presentation's file stays the same. //! and Print: `false`. They are copies; the presentation's file stays the same.
//! - `deckcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away. //! - `deckcraft:dirty` whenever unsaved changes appear or go away, `deckcraft:failed` when the
//! - `deckcraft:failed` (to the parent): `{ error }` when the app couldn't start. //! app couldn't start or refused a file of 2 GiB or larger.
//! //!
//! Only the parent window on the page's own origin is heard or answered. In host mode the app //! In host mode the app starts with an empty presentation instead of the sample, which gives way to
//! starts with an empty presentation instead of the sample, which gives way to the first file the //! the first file the host sends; the host guards leaving the page, and `?author=` names the user
//! parent sends; the parent guards leaving the page, and `?author=` names the user for comments. //! for comments.
use std::cell::{Cell, RefCell}; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use craft_web_host::web::{FileData, Host, Message};
use deckcraft_ui_egui::{Services, SlideApp}; use deckcraft_ui_egui::{Services, SlideApp};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue; /// The app id in the host messages (`deckcraft:ready`).
use wasm_bindgen::closure::Closure; const APP: &str = "deckcraft";
/// The name `craft-web-host` gives an open request that carries none (it cleans a host's name and
/// falls back to this): no name is no file, so the presentation opens untitled.
const NAMELESS: &str = "document";
/// The largest file a host may send. File › Open on the desktop refuses anything over 2 GiB.
/// On wasm32 a `Vec` cannot hold 2 GiB (`isize::MAX` is one byte less), so a file of exactly
/// 2 GiB is refused too, before its bytes are copied.
const MAX_OPEN_BYTES: f64 = 2_147_483_648.0;
/// The longest user name taken from `?author=` (the preferences' own limit). /// The longest user name taken from `?author=` (the preferences' own limit).
const MAX_AUTHOR_CHARS: usize = 256; const MAX_AUTHOR_CHARS: usize = 256;
#[derive(Clone)] #[derive(Clone)]
pub struct Bridge { pub struct Bridge {
window: web_sys::Window, host: Rc<Host>,
parent: web_sys::Window, /// Files the host sent (the name, if it sent one), waiting for the next frame.
origin: String,
/// Files the parent sent (the name, if it sent one), waiting for the next frame.
opened: Rc<RefCell<Vec<(Option<String>, Vec<u8>)>>>, opened: Rc<RefCell<Vec<(Option<String>, Vec<u8>)>>>,
/// The unsaved-changes state last reported to the parent.
reported: Rc<Cell<Option<bool>>>,
} }
impl Bridge { impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is /// The bridge for this page, or `None` when DeckCraft isn't hosted: no `?host=parent` and no
/// on another origin: messages only go to this origin, so the browser would drop every save /// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
/// without an error while the presentation showed as saved. /// with downloads), or a parent on another origin.
pub fn new() -> Option<Self> { pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
let window = web_sys::window()?; let host = Host::connect(APP, canvas)?;
let parent = window.parent().ok().flatten()?; // An opaque origin (a file:// page in some browsers) can't be a postMessage target.
if js_sys::Object::is(&parent, &window) { if host.mode() == craft_web_host::Mode::Parent && web_sys::window()?.location().origin().ok()? == "null" {
return None; return None;
} }
let origin = window.location().origin().ok()?; Some(Self { host: Rc::new(host), opened: Rc::default() })
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
if origin == "null" {
return None;
}
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin, opened: Rc::default(), reported: Rc::default() })
} }
/// Hand saved and exported files to the parent instead of downloading them, and hear the /// Hand saved and exported files to the host instead of downloading them, and hear the files
/// files it sends. /// it sends.
pub fn connect(&self, services: &mut Services, ctx: egui::Context) { pub fn connect(&self, services: &mut Services, ctx: egui::Context) {
self.listen(ctx); self.listen(ctx);
let bridge = self.clone(); let bridge = self.clone();
@@ -97,15 +98,15 @@ impl Bridge {
} }
pub fn post_ready(&self) { pub fn post_ready(&self) {
self.send(&[("type", "deckcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]); let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
} }
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so. /// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
pub fn post_failed(&self, error: &str) { pub fn post_failed(&self, error: &str) {
self.send(&[("type", "deckcraft:failed".into()), ("error", error.into())]); let _ = self.host.post_failed(error, None);
} }
/// Before each frame: open the files the parent sent. /// Before each frame: open the files the host sent.
pub fn open_files(&self, app: &mut SlideApp) { pub fn open_files(&self, app: &mut SlideApp) {
let opened = std::mem::take(&mut *self.opened.borrow_mut()); let opened = std::mem::take(&mut *self.opened.borrow_mut());
for (name, bytes) in opened { for (name, bytes) in opened {
@@ -113,80 +114,36 @@ impl Bridge {
} }
} }
/// After each frame: tell the parent when unsaved changes appear or go away. /// After each frame: tell the host when unsaved changes appear or go away (the crate does not
/// repeat a state it already reported).
pub fn report_dirty(&self, app: &SlideApp) { pub fn report_dirty(&self, app: &SlideApp) {
let dirty = app.session.documents().iter().any(|d| d.is_dirty()); self.host.post_dirty(app.session.documents().iter().any(|d| d.is_dirty()));
if self.reported.get() != Some(dirty) && self.send(&[("type", "deckcraft:dirty".into()), ("dirty", dirty.into())]) {
self.reported.set(Some(dirty));
}
} }
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> { fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string()); let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer(); self.host.post_save(&name, bytes, None, save_as)
let message = message(&[
("type", "deckcraft:save".into()),
("name", name.as_str().into()),
("saveAs", save_as.into()),
("bytes", buffer.clone().into()),
])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
} }
/// Hear `deckcraft:open` from the parent and queue the file for the next frame. /// Hear `deckcraft:open` and queue the file for the next frame.
fn listen(&self, ctx: egui::Context) { fn listen(&self, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let opened = self.opened.clone(); let opened = self.opened.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| { let host = self.host.clone();
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) { self.host.listen(move |message| {
let Message::Open { name, data } = message else { return };
if data.size() >= MAX_OPEN_BYTES {
log::warn!("host file {name} is 2 GiB or larger; not opened");
let _ = host.post_failed(&format!("{name} is 2 GiB or larger, which DeckCraft's web build cannot open"), None);
return; return;
} }
let data = event.data(); let FileData::Bytes(array) = data else {
if string(&data, "type").as_deref() != Some("deckcraft:open") { log::warn!("host file {name} came as a File; DeckCraft reads bytes only; not opened");
return; return;
} };
// No name is no file: the presentation opens untitled, never under an invented name. // No name is no file: the presentation opens untitled, never under an invented name.
let name = string(&data, "name").filter(|n| !n.trim().is_empty()); let name = (name != NAMELESS).then_some(name);
let Some(bytes) = bytes(&data) else { return }; opened.borrow_mut().push((name, array.to_vec()));
opened.borrow_mut().push((name, bytes));
ctx.request_repaint(); ctx.request_repaint();
}); });
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
} }
} }
+2
View File
@@ -21,6 +21,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
#[cfg(any(target_arch = "wasm32", test))]
mod api;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
mod host; mod host;
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
+8 -5
View File
@@ -24,11 +24,13 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">"); log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return; return;
}; };
// `?host=parent`: a same-origin page framing DeckCraft opens presentations in it and // Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing DeckCraft,
// stores what it saves (`host`). The presenter popup is never the framed page. // opens presentations in it and stores what it saves (`host`); `data-host="page"` on the
let host = query_param("host").as_deref() == Some("parent") && crate::presenter::token().is_none(); // canvas opts a page in that runs DeckCraft in its own document. The presenter popup is
let bridge = if host { crate::host::Bridge::new() } else { None }; // never the framed page.
if host && bridge.is_none() { let hosted = crate::presenter::token().is_none();
let bridge = if hosted { crate::host::Bridge::detect(&canvas) } else { None };
if hosted && bridge.is_none() && query_param("host").as_deref() == Some("parent") {
log::warn!("?host=parent needs a framing page on the same origin; saves download instead"); log::warn!("?host=parent needs a framing page on the same origin; saves download instead");
} }
let app_bridge = bridge.clone(); let app_bridge = bridge.clone();
@@ -77,6 +79,7 @@ pub fn start() {
{ {
crate::host::Bridge::set_author(&mut app, &author); crate::host::Bridge::set_author(&mut app, &author);
} }
crate::api::install(&mut app, cc.egui_ctx.clone());
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge })) Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
}), }),
) )
+3 -1
View File
@@ -14,7 +14,9 @@ workspace = true
deckcraft-engine = { workspace = true } deckcraft-engine = { workspace = true }
deckcraft-ui-egui = { workspace = true } deckcraft-ui-egui = { workspace = true }
deckcraft-media = { workspace = true } deckcraft-media = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] } # `clipboard` is the OS clipboard (Ctrl+C/X/V with other apps). Without it egui simulates one inside
# the app; the egui 0.37 migration (#1350) dropped it from this list.
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] }
egui = { workspace = true } egui = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
log = { workspace = true } log = { workspace = true }
+52 -5
View File
@@ -51,6 +51,9 @@ impl eframe::App for App {
} }
fn on_exit(&mut self) { fn on_exit(&mut self) {
save_prefs(&self.0); save_prefs(&self.0);
// The window is closing for good: a ready update's helper starts now and installs it once
// the process has exited.
self.0.start_pending_update();
// macOS (#279): end the process here, before eframe tears the window down. AccessKit's // macOS (#279): end the process here, before eframe tears the window down. AccessKit's
// macOS adapter subclasses the window's content view and restores its class on teardown; // macOS adapter subclasses the window's content view and restores its class on teardown;
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle // AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
@@ -103,7 +106,13 @@ fn save_prefs(app: &SlideApp) {
} }
} }
fn services() -> Services { /// The desktop services. `native_wayland`: the window is a Wayland client (not Xwayland).
fn services(native_wayland: bool) -> Services {
// On native Wayland, egui-winit owns the clipboard through winit's data device, which carries
// text and PNG but no HTML. `pasteboard` (arboard, built for X11 only) would put a copy on the
// Xwayland selection instead and skip egui's Wayland claim, so Ctrl+V would read something
// else. There, text goes through egui and pictures arrive as `PasteImage` events.
let pasteboard = !native_wayland;
Services { Services {
pick_open: Some(Box::new(|purpose: &str| { pick_open: Some(Box::new(|purpose: &str| {
let d = rfd::FileDialog::new(); let d = rfd::FileDialog::new();
@@ -126,9 +135,9 @@ fn services() -> Services {
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))), pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))), read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))), write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
clipboard_image: Some(Box::new(pasteboard::image_png)), clipboard_image: pasteboard.then(|| Box::new(pasteboard::image_png) as Box<dyn FnMut() -> Option<Vec<u8>>>),
clipboard_html: Some(Box::new(pasteboard::html)), clipboard_html: pasteboard.then(|| Box::new(pasteboard::html) as Box<dyn FnMut() -> Option<String>>),
clipboard_set_html: Some(Box::new(pasteboard::set_html)), clipboard_set_html: pasteboard.then(|| Box::new(pasteboard::set_html) as Box<dyn FnMut(&str, &str) -> bool>),
// Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently. // Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently.
audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO") audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO")
.is_none() .is_none()
@@ -137,6 +146,14 @@ fn services() -> Services {
} }
} }
/// Displays winit lists for `window`, counting an output Wayland lists twice once. `None` when it
/// lists none: Presenter View then stays as it was.
#[cfg(all(unix, not(target_os = "macos")))]
fn display_count(window: &winit::window::Window) -> Option<usize> {
let n = window.available_monitors().collect::<std::collections::BTreeSet<_>>().len();
(n > 0).then_some(n)
}
const APP_ID: &str = "ai.storyteller.deckcraft"; const APP_ID: &str = "ai.storyteller.deckcraft";
const USAGE: &str = "\ const USAGE: &str = "\
@@ -247,6 +264,11 @@ fn main() -> eframe::Result {
false false
} }
}; };
// `Default` lets winit choose, and winit takes Wayland whenever `WAYLAND_DISPLAY` is set.
#[cfg(all(unix, not(target_os = "macos")))]
let native_wayland = !x11 && std::env::var_os("WAYLAND_DISPLAY").is_some_and(|w| !w.is_empty());
#[cfg(not(all(unix, not(target_os = "macos"))))]
let native_wayland = false;
#[cfg(all(unix, not(target_os = "macos")))] #[cfg(all(unix, not(target_os = "macos")))]
if x11 { if x11 {
options.event_loop_builder = Some(Box::new(|b| { options.event_loop_builder = Some(Box::new(|b| {
@@ -268,7 +290,12 @@ fn main() -> eframe::Result {
options, options,
Box::new(move |cc| { Box::new(move |cc| {
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx); deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
let mut app = SlideApp::new(Session::new(), services()); let mut app = SlideApp::new(Session::new(), services(native_wayland));
// Presenter View asks winit how many displays there are when a show starts.
#[cfg(all(unix, not(target_os = "macos")))]
if let Some(window) = cc.winit_window().cloned() {
app.services.displays = Some(Box::new(move || display_count(&window)));
}
load_prefs(&mut app); load_prefs(&mut app);
app.enable_update_checks(); app.enable_update_checks();
// AutoRecover: reopen what a previous run left unsaved, then keep it current. // AutoRecover: reopen what a previous run left unsaved, then keep it current.
@@ -335,6 +362,26 @@ mod tests {
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}"); assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
} }
/// The egui 0.37 migration (#1350) left `clipboard` out of eframe's features. egui then
/// simulated a clipboard inside the app: nothing pasted from other apps, and Ctrl+C/Ctrl+V
/// lost text that went out as HTML. Keep the OS clipboard on.
#[test]
fn eframe_uses_the_os_clipboard() {
let manifest = include_str!("../Cargo.toml");
let eframe = manifest.lines().find(|l| l.starts_with("eframe = ")).unwrap_or_default();
assert!(eframe.contains("\"clipboard\""), "eframe must enable the OS clipboard: {eframe}");
}
/// On native Wayland, the X11-only pasteboard would bypass egui's Wayland clipboard; elsewhere
/// it carries HTML and pictures beside egui's text.
#[test]
fn the_pasteboard_stays_off_native_wayland() {
let s = services(true);
assert!(s.clipboard_html.is_none() && s.clipboard_set_html.is_none() && s.clipboard_image.is_none());
let s = services(false);
assert!(s.clipboard_html.is_some() && s.clipboard_set_html.is_some() && s.clipboard_image.is_some());
}
#[test] #[test]
fn help_exits_instead_of_opening_a_file() { fn help_exits_instead_of_opening_a_file() {
assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help)); assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help));
+45 -16
View File
@@ -107,7 +107,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() }; s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() };
return Ok(plain); return Ok(plain);
} }
let shapes: Vec<Shape> = st let mut shapes: Vec<Shape> = st
.selection .selection
.shapes .shapes
.iter() .iter()
@@ -120,14 +120,33 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
}) })
}) })
.collect(); .collect();
// Copying is the last chance to read what a placeholder inherits: a cut removes it, and the
// paste can be in another deck. Slide Master view pastes the originals instead.
let placeholders: Vec<Shape> = shapes.iter().filter(|x| x.ph.is_some()).cloned().collect();
detach_on_slide(&st.doc, &st.selection, &mut shapes);
let mids = crate::media_refs::shapes_media(&shapes); let mids = crate::media_refs::shapes_media(&shapes);
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect(); let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n"); let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone())); // The theme of what is being edited: a slide's or layout's colour map, else the master's.
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() }; let source_theme = super::text::ctx_for(&st.doc, &st.selection).map(|ctx| crate::SourceTheme {
colors: ctx.master.theme.colors.clone(),
color_map: ctx.color_map.clone(),
fonts: ctx.master.theme.fonts.clone(),
});
s.clipboard = crate::Clipboard { shapes, placeholders, media, plain: plain.clone(), source_theme, ..Default::default() };
Ok(plain) Ok(plain)
} }
/// On a slide, a copy of a placeholder is a regular shape: give `shapes` the look they inherit
/// there. In Slide Master view a copy stays a placeholder, so this changes nothing.
pub(crate) fn detach_on_slide(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shapes: &mut [Shape]) {
if sel.target == crate::Target::Slides
&& let Some(ctx) = super::text::ctx_for(doc, sel)
{
shapes.iter_mut().for_each(|sh| deckcraft_model::resolve::detach_placeholder(&ctx, sh));
}
}
fn copying_caret(s: &Session) -> bool { fn copying_caret(s: &Session) -> bool {
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range())) s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
} }
@@ -319,6 +338,13 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
return s.edit(|doc, sel| { return s.edit(|doc, sel| {
let media = bring_media("edit.paste", doc, &clip.media)?; let media = bring_media("edit.paste", doc, &clip.media)?;
let mut shapes = clip.shapes.clone(); let mut shapes = clip.shapes.clone();
if sel.target != crate::Target::Slides {
for sh in &mut shapes {
if let Some(original) = clip.placeholders.iter().find(|p| p.id == sh.id) {
sh.clone_from(original);
}
}
}
reid("edit.paste", doc, &mut shapes)?; reid("edit.paste", doc, &mut shapes)?;
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media)); crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
let (mut taken, mut count) = super::current_names(doc, sel); let (mut taken, mut count) = super::current_names(doc, sel);
@@ -340,7 +366,8 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
x.x += off; x.x += off;
x.y += off; x.y += off;
} }
// A pasted placeholder becomes a regular shape keeping its look. // A placeholder pasted on a slide becomes a regular shape. One copied from a slide
// already has its look; see `copy_inner`.
if sh.ph.is_some() && sel.target == crate::Target::Slides { if sh.ph.is_some() && sel.target == crate::Target::Slides {
sh.ph = None; sh.ph = None;
} }
@@ -404,8 +431,9 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
"text" | "keepTextOnly" => paste_text(s, &json!({})), "text" | "keepTextOnly" => paste_text(s, &json!({})),
"keepSource" | "keepSourceFormatting" => { "keepSource" | "keepSourceFormatting" => {
let saved = s.clipboard.clone(); let saved = s.clipboard.clone();
if let Some((scheme, fonts)) = &saved.source_theme { if let Some(theme) = &saved.source_theme {
bake_theme(&mut s.clipboard.shapes, scheme, fonts); bake_theme(&mut s.clipboard.shapes, theme);
bake_theme(&mut s.clipboard.placeholders, theme);
} }
let r = paste(s, &json!({})); let r = paste(s, &json!({}));
let pastes = s.clipboard.pastes; let pastes = s.clipboard.pastes;
@@ -420,8 +448,8 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
/// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the /// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the
/// shapes keep their look under another theme. /// shapes keep their look under another theme.
pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) { pub(crate) fn bake_theme(shapes: &mut [Shape], theme: &crate::SourceTheme) {
fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) { fn rec(v: &mut Value, theme: &crate::SourceTheme, depth: usize) {
if depth > 256 { if depth > 256 {
return; return;
} }
@@ -430,7 +458,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
if o.get("kind").and_then(Value::as_str) == Some("scheme") if o.get("kind").and_then(Value::as_str) == Some("scheme")
&& let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::<deckcraft_color::SchemeSlot>(x.clone()).ok()) && let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::<deckcraft_color::SchemeSlot>(x.clone()).ok())
&& slot != deckcraft_color::SchemeSlot::PhClr && slot != deckcraft_color::SchemeSlot::PhClr
&& let Ok(rgb) = serde_json::to_value(scheme.get(slot)) && let Ok(rgb) = serde_json::to_value(theme.colors.get(deckcraft_model::map_slot(&theme.color_map, slot)))
{ {
o.clear(); o.clear();
o.insert("kind".into(), json!("rgb")); o.insert("kind".into(), json!("rgb"));
@@ -438,14 +466,14 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
return; return;
} }
for x in o.values_mut() { for x in o.values_mut() {
rec(x, scheme, fonts, depth + 1); rec(x, theme, depth + 1);
} }
} }
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)), Value::Array(a) => a.iter_mut().for_each(|x| rec(x, theme, depth + 1)),
Value::String(t) if t.starts_with('+') => { Value::String(t) if t.starts_with('+') => {
let set = match t.get(1..3) { let set = match t.get(1..3) {
Some("mj") => &fonts.major, Some("mj") => &theme.fonts.major,
Some("mn") => &fonts.minor, Some("mn") => &theme.fonts.minor,
_ => return, _ => return,
}; };
let face = match t.get(3..) { let face = match t.get(3..) {
@@ -463,7 +491,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
} }
for sh in shapes.iter_mut() { for sh in shapes.iter_mut() {
let Ok(mut v) = serde_json::to_value(&*sh) else { continue }; let Ok(mut v) = serde_json::to_value(&*sh) else { continue };
rec(&mut v, scheme, fonts, 0); rec(&mut v, theme, 0);
if let Ok(baked) = serde_json::from_value::<Shape>(v) { if let Ok(baked) = serde_json::from_value::<Shape>(v) {
*sh = baked; *sh = baked;
} }
@@ -476,8 +504,8 @@ fn paste_picture(s: &mut Session) -> Result<Value> {
if clip.shapes.is_empty() { if clip.shapes.is_empty() {
return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture")); return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture"));
} }
if let Some((scheme, fonts)) = &clip.source_theme { if let Some(theme) = &clip.source_theme {
bake_theme(&mut clip.shapes, scheme, fonts); bake_theme(&mut clip.shapes, theme);
} }
let st = s.doc()?; let st = s.doc()?;
let mut doc = (*st.doc).clone(); let mut doc = (*st.doc).clone();
@@ -533,6 +561,7 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
let sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect(); let sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
detach_on_slide(doc, sel, &mut copies);
// Duplicating the copies of the last Duplicate again repeats how far they were moved // Duplicating the copies of the last Duplicate again repeats how far they were moved
// since (#966); anything else gets the usual offset. // since (#966); anything else gets the usual offset.
let (dx, dy) = last let (dx, dy) = last
+3 -9
View File
@@ -120,13 +120,7 @@ pub(super) fn line_spacing(s: &mut Session, p: &Value) -> Result<Value> {
} }
pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
let a = str_param(p, "align").map(|a| match a { let a = str_param(p, "align").map(|a| parse_align("format.paragraph", a)).transpose()?;
"center" => Align::Center,
"right" => Align::Right,
"justify" => Align::Justify,
"distributed" => Align::Distributed,
_ => Align::Left,
});
let ml = f64_param(p, "indentLeft"); let ml = f64_param(p, "indentLeft");
let ind = f64_param(p, "indentFirst"); let ind = f64_param(p, "indentFirst");
let sb = f64_param(p, "spaceBefore"); let sb = f64_param(p, "spaceBefore");
@@ -182,10 +176,10 @@ pub(super) fn clamp_pt(v: f64) -> f64 {
fn parse_align(cmd: &str, s: &str) -> Result<Align> { fn parse_align(cmd: &str, s: &str) -> Result<Align> {
Ok(match s { Ok(match s {
"left" | "l" => Align::Left, "left" | "l" => Align::Left,
"center" | "ctr" => Align::Center, "center" | "centre" | "ctr" => Align::Center,
"right" | "r" => Align::Right, "right" | "r" => Align::Right,
"justify" | "just" => Align::Justify, "justify" | "just" => Align::Justify,
"justLow" => Align::JustLow, "justLow" | "justifyLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed, "distributed" | "dist" => Align::Distributed,
"thaiDist" => Align::ThaiDistributed, "thaiDist" => Align::ThaiDistributed,
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")), _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
+1 -1
View File
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
use super::{ use super::{
CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, color_param, color_value, edit_geometry, edit_shapes, f64_param, has_selection, CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, color_param, color_value, edit_geometry, edit_shapes, f64_param, has_selection,
has_slide, id_param, ok, str_param, targets, usize_param, with_param, xfrm_of, has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param, with_param, xfrm_of,
}; };
use crate::{Result, Session}; use crate::{Result, Session};
+3 -3
View File
@@ -1,11 +1,11 @@
use super::{ use super::{
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode, ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry, Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param, edit_shapes, f64_param, inherited_fills, json, opt_usize_param, requested_fill, str_param, targets,
}; };
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(0).min(16); let i = opt_usize_param(p, "index", "shape.adjust")?.unwrap_or(0).min(16);
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?; let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
edit_geometry(s, p, "shape.adjust", |sh| { edit_geometry(s, p, "shape.adjust", |sh| {
if let Geom::Preset { name, adj } = &mut sh.geom { if let Geom::Preset { name, adj } = &mut sh.geom {
@@ -556,7 +556,7 @@ pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>)
} }
pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> { pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
let i = usize_param(p, "index").unwrap_or(1).min(41); let i = opt_usize_param(p, "index", "shape.quickStyle")?.unwrap_or(1).min(41);
let (st, f, l) = quick_style_for(i); let (st, f, l) = quick_style_for(i);
edit_shapes(s, p, "shape.quickStyle", |sh| { edit_shapes(s, p, "shape.quickStyle", |sh| {
sh.style = Some(st.clone()); sh.style = Some(st.clone());
+16 -3
View File
@@ -454,17 +454,30 @@ pub struct MediaStatus {
} }
/// Clipboard contents copied within the app. /// Clipboard contents copied within the app.
/// The theme a copy drew with. The colours and the map stay apart, as in
/// `deckcraft_model::resolve::Ctx::color`: the map renames only `tx1`, `bg1`, `tx2` and `bg2`, and
/// a direct `dk1` keeps the theme's colour.
#[derive(Clone, Debug)]
pub struct SourceTheme {
/// The theme's own colours, no colour map applied.
pub colors: deckcraft_color::ColorScheme,
pub color_map: Vec<(String, String)>,
pub fonts: deckcraft_model::theme::FontScheme,
}
#[derive(Clone, Debug, Default)] #[derive(Clone, Debug, Default)]
pub struct Clipboard { pub struct Clipboard {
pub shapes: Vec<Shape>, pub shapes: Vec<Shape>,
/// The copied placeholders as they were, by id. `shapes` holds a slide's placeholders as
/// regular shapes; a paste in Slide Master view takes these, which stay placeholders.
pub placeholders: Vec<Shape>,
pub slides: Vec<Slide>, pub slides: Vec<Slide>,
/// Media the copied shapes/slides reference. /// Media the copied shapes/slides reference.
pub media: Vec<deckcraft_model::MediaItem>, pub media: Vec<deckcraft_model::MediaItem>,
pub text: Option<deckcraft_model::TextBody>, pub text: Option<deckcraft_model::TextBody>,
pub plain: String, pub plain: String,
/// The copied shapes' theme colours (colour map applied) and fonts, for Paste Special › /// The theme the shapes were copied under, for Paste Special › Keep Source Formatting.
/// Keep Source Formatting. pub source_theme: Option<SourceTheme>,
pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>,
/// How many times the shapes were pasted (each paste offsets further). /// How many times the shapes were pasted (each paste offsets further).
pub pastes: u32, pub pastes: u32,
} }
+402
View File
@@ -214,6 +214,368 @@ fn clipboard_copy_paste_shapes_and_slides() {
assert!(s.doc().unwrap().doc.validate().is_empty()); assert!(s.doc().unwrap().doc.validate().is_empty());
} }
/// The resolved look of every paragraph and run of shape `id` on the current slide: size, font,
/// colour, bold, bullet, indent, alignment, and the box's insets and anchor.
fn resolved_look(s: &Session, id: ShapeId) -> String {
use deckcraft_model::resolve;
let st = s.doc().unwrap();
let slide = st.current_slide().unwrap();
let ctx = resolve::Ctx::for_slide(&st.doc, slide).unwrap();
let sh = st.shape(id).unwrap();
let t = sh.text.as_ref().unwrap();
let b = resolve::body(&ctx, sh);
let mut out = format!("body {:?} {:?} {:?}\n", b.anchor, b.inset_l, b.autofit);
for p in &t.paragraphs {
let pp = resolve::para(&ctx, sh, p);
out += &format!("para {:?} {:?} {:?} {:?}\n", pp.align, pp.margin_left, pp.indent, pp.bullet);
for r in &p.runs {
let rp = resolve::run(&ctx, sh, p, &r.props);
out += &format!(" run {:?} {:?} {:?} {:?}\n", rp.size, rp.font, rp.bold, rp.fill);
}
}
out
}
/// A placeholder pasted or duplicated on a slide becomes a regular shape, and keeps the look its
/// layout and master gave it: the title's size and font, the body's bullets and indents. Before,
/// the copy fell back to the master's other-text style (18 pt, no bullets).
#[test]
fn a_pasted_or_duplicated_placeholder_keeps_its_inherited_look() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let phs: Vec<ShapeId> = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect();
assert!(phs.len() >= 2, "a title and a body placeholder");
for (k, id) in phs.iter().enumerate() {
s.execute("text.set", &json!({"id": id.0, "text": format!("Placeholder {k}\nSecond line")})).unwrap();
}
for id in phs {
let want = resolved_look(&s, id);
for cmd in ["paste", "duplicate"] {
s.execute("selection.select", &json!({"id": id.0})).unwrap();
if cmd == "paste" {
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
} else {
s.execute("edit.duplicate", &json!({})).unwrap();
}
let copy = s.doc().unwrap().selection.shapes[0];
assert_ne!(copy, id);
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "{cmd}: the copy is a regular shape");
assert_eq!(resolved_look(&s, copy), want, "{cmd} of placeholder {}", id.0);
}
}
assert!(s.doc().unwrap().doc.validate().is_empty());
}
/// Dragging a copy of a placeholder off it keeps the look too: the title's size and font, the
/// body's bullets. `edit.duplicate` is not the only way to duplicate.
#[test]
fn a_placeholder_duplicated_by_dragging_keeps_its_look() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let phs: Vec<ShapeId> = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect();
assert!(phs.len() >= 2, "a title and a body placeholder");
for id in phs {
s.execute("text.set", &json!({"id": id.0, "text": "First\nSecond"})).unwrap();
let want = resolved_look(&s, id);
s.execute("selection.select", &json!({"id": id.0})).unwrap();
let st = s.doc().unwrap();
let x = crate::cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap());
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
let alt = |kind, x, y| PointerEvent { mods: Mods { alt: true, ..Default::default() }, ..tev(kind, x, y) };
s.pointer(alt(PointerKind::Down, cx, cy)).unwrap();
s.pointer(alt(PointerKind::Drag, cx + 30.0, cy + 30.0)).unwrap();
s.pointer(alt(PointerKind::Up, cx + 30.0, cy + 30.0)).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_ne!(copy, id, "the drag made a copy");
assert!(s.doc().unwrap().shape(id).unwrap().ph.is_some(), "the original stays a placeholder");
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "the copy is a regular shape");
assert_eq!(resolved_look(&s, copy), want, "placeholder {}", id.0);
}
}
/// Keep Source Formatting fixes a copy's theme colours as the source drew them: through the colour
/// map of its slide or layout, not the master's. A title's inherited `tx1` stays the light colour
/// an override gave it.
#[test]
fn keep_source_formatting_uses_the_slides_colour_map() {
use deckcraft_model::resolve;
let first_run_colour = |s: &Session, id: ShapeId| {
let st = s.doc().unwrap();
let ctx = resolve::Ctx::for_slide(&st.doc, st.current_slide().unwrap()).unwrap();
let sh = st.shape(id).unwrap();
let p = &sh.text.as_ref().unwrap().paragraphs[0];
resolve::text_color(&ctx, &resolve::run(&ctx, sh, p, &p.runs[0].props))
};
for on_layout in [false, true] {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id;
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
let plain = first_run_colour(&s, title);
let st = s.doc_mut().unwrap();
let at = st.selection.slide;
let doc = std::sync::Arc::make_mut(&mut st.doc);
if on_layout {
let layout = doc.slides[at].layout;
let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = swapped_colour_map();
} else {
std::sync::Arc::make_mut(&mut doc.slides[at]).color_map = swapped_colour_map();
}
let want = first_run_colour(&s, title);
assert_ne!(want, plain, "the override changes how the title draws");
s.execute("selection.select", &json!({"id": title.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_ne!(copy, title);
assert_eq!(first_run_colour(&s, copy), want, "override on the layout: {on_layout}");
}
}
/// A colour map that swaps text and background, as a dark slide or layout has.
fn swapped_colour_map() -> Option<Vec<(String, String)>> {
Some([("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].map(|(a, b)| (a.to_string(), b.to_string())).to_vec())
}
/// A new rectangle in the edited tree, filled with theme colour `slot`.
fn rect_filled_with(s: &mut Session, slot: deckcraft_color::SchemeSlot) -> ShapeId {
use deckcraft_model::{ColorRef, Fill};
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let id = ShapeId(u32::try_from(id).unwrap());
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).unwrap();
list.iter_mut().find(|x| x.id == id).unwrap().fill = Some(Fill::solid(ColorRef::scheme(slot)));
Ok(())
})
.unwrap();
id
}
/// How shape `id`'s fill draws in the edited tree.
fn drawn_fill(s: &Session, id: ShapeId) -> deckcraft_color::Rgba {
use deckcraft_model::{Fill, resolve};
let st = s.doc().unwrap();
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection).unwrap();
match resolve::fill(&ctx, st.shape(id).unwrap()).0 {
Some(Fill::Solid { color }) => ctx.color(&color, None),
other => panic!("not a solid fill: {other:?}"),
}
}
/// Copy the selected shape and paste it with Keep Source Formatting; the copy's id.
fn keep_source_copy(s: &mut Session, id: ShapeId) -> ShapeId {
s.execute("selection.select", &json!({"id": id.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_ne!(copy, id);
copy
}
/// A colour map renames only `tx1`, `bg1`, `tx2` and `bg2`. Keep Source Formatting fixes every slot
/// as it draws: `tx1` follows the map, while a direct `dk1` stays the theme's dark colour.
#[test]
fn keep_source_formatting_maps_only_the_alias_colours() {
use deckcraft_color::SchemeSlot::{self, *};
let mut s = session();
let st = s.doc_mut().unwrap();
let at = st.selection.slide;
std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).slides[at]).color_map = swapped_colour_map();
let slots: [SchemeSlot; 9] = [Dk1, Lt1, Dk2, Lt2, Tx1, Bg1, Tx2, Bg2, Accent1];
let mut drawn = vec![];
for slot in slots {
let id = rect_filled_with(&mut s, slot);
let want = drawn_fill(&s, id);
let copy = keep_source_copy(&mut s, id);
assert_eq!(drawn_fill(&s, copy), want, "{slot:?}");
drawn.push(want);
}
assert_ne!(drawn[0], drawn[4], "the map sets `tx1` apart from `dk1`");
}
/// In Slide Master view a copy draws through the edited master's map, not an override on the slide
/// that happens to be current.
#[test]
fn keep_source_formatting_in_slide_master_view_ignores_the_slides_colour_map() {
use deckcraft_color::SchemeSlot::Tx1;
for master in [false, true] {
let mut s = session();
let st = s.doc_mut().unwrap();
let at = st.selection.slide;
std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).slides[at]).color_map = swapped_colour_map();
s.execute("view.slideMaster", &if master { json!({"master": 0}) } else { json!({}) }).unwrap();
let id = rect_filled_with(&mut s, Tx1);
let want = drawn_fill(&s, id);
let copy = keep_source_copy(&mut s, id);
assert_eq!(drawn_fill(&s, copy), want, "editing the master: {master}");
}
}
/// A slide's placeholder pasted in Slide Master view stays a placeholder that inherits from where
/// it lands; only a paste on a slide turns it into a regular shape with the look it had.
#[test]
fn a_slide_placeholder_pasted_in_slide_master_view_stays_a_placeholder() {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id;
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
let want = resolved_look(&s, title);
let source = s.doc().unwrap().shape(title).unwrap().clone();
s.execute("selection.select", &json!({"id": title.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("view.slideMaster", &json!({})).unwrap();
for paste in ["edit.paste", "edit.pasteSpecial"] {
s.execute(paste, &json!({"as": "keepSource"})).unwrap();
let st = s.doc().unwrap();
let copy = st.shape(st.selection.shapes[0]).unwrap();
assert_eq!(copy.ph.as_ref().map(|p| p.kind), source.ph.as_ref().map(|p| p.kind), "{paste} keeps the placeholder");
let list = copy.text.as_ref().map(|t| t.list_style.clone());
assert_eq!(list, source.text.as_ref().map(|t| t.list_style.clone()), "{paste} adds no baked styles");
}
s.execute("view.closeMaster", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "on a slide the copy is a regular shape");
assert_eq!(resolved_look(&s, copy), want);
}
/// The resolved fill, line and effects of shape `id` on the current slide, with their style colours.
fn resolved_box(s: &Session, id: ShapeId) -> String {
use deckcraft_model::resolve;
let st = s.doc().unwrap();
let ctx = resolve::Ctx::for_slide(&st.doc, st.current_slide().unwrap()).unwrap();
let sh = st.shape(id).unwrap();
format!("{:?}\n{:?}\n{:?}", resolve::fill(&ctx, sh), resolve::line(&ctx, sh), resolve::effects(&ctx, sh))
}
/// Edit the title placeholders the current slide's title inherits from: its layout's and its
/// master's. Returns the slide title's id.
fn edit_title_parents(
s: &mut Session,
layout: impl FnOnce(&mut deckcraft_model::Shape),
master: impl FnOnce(&mut deckcraft_model::Shape),
) -> ShapeId {
let st = s.doc_mut().unwrap();
let slide = st.current_slide().unwrap().clone();
let title = slide.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap().id;
let doc = std::sync::Arc::make_mut(&mut st.doc);
let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
let lay = m.layouts.iter_mut().find(|l| l.id == slide.layout).unwrap();
layout(lay.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap());
master(m.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap());
title
}
/// A copied placeholder takes each of fill, line and effects from where the original does: a
/// style on the layout's placeholder (or on the slide's own) wins over an explicit value on the
/// master's, as `resolve::fill`, `resolve::line` and `resolve::effects` decide.
#[test]
fn a_pasted_placeholder_keeps_the_style_precedence_of_its_box() {
use deckcraft_color::SchemeSlot;
use deckcraft_model::{ColorRef, Effects, Fill, Line, ShapeStyle};
for style_on_slide in [false, true] {
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let style = ShapeStyle {
line_ref: (1, ColorRef::scheme(SchemeSlot::Accent2)),
fill_ref: (1, ColorRef::scheme(SchemeSlot::Accent1)),
effect_ref: (0, ColorRef::scheme(SchemeSlot::Accent3)),
font_ref: ("minor".into(), None),
};
let layout_style = (!style_on_slide).then(|| style.clone());
let title = edit_title_parents(
&mut s,
|l| l.style = layout_style,
|m| {
m.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Accent4)));
m.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent5), 20.0));
m.effects = Some(Effects { soft_edge: Some(5.0), ..Default::default() });
},
);
if style_on_slide {
s.edit(|doc, sel| {
let list = crate::shapes_mut(doc, sel).unwrap();
list.iter_mut().find(|x| x.id == title).unwrap().style = Some(style.clone());
Ok(())
})
.unwrap();
}
s.execute("text.set", &json!({"id": title.0, "text": "Styled title"})).unwrap();
let want = (resolved_box(&s, title), resolved_look(&s, title));
s.execute("selection.select", &json!({"id": title.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_eq!((resolved_box(&s, copy), resolved_look(&s, copy)), want, "style on the slide: {style_on_slide}");
}
}
/// The master's placeholder style never reaches the copy's text: the original's text ignores it
/// (only the shape's or the layout's font reference counts), so the copy must too.
#[test]
fn a_pasted_placeholder_ignores_the_masters_font_reference() {
use deckcraft_color::SchemeSlot;
use deckcraft_model::{ColorRef, ShapeStyle};
let mut s = session();
s.execute("slide.new", &json!({})).unwrap();
let title = edit_title_parents(
&mut s,
|l| l.style = None,
|m| {
m.style = Some(ShapeStyle {
line_ref: (0, ColorRef::scheme(SchemeSlot::Accent2)),
fill_ref: (0, ColorRef::scheme(SchemeSlot::Accent1)),
effect_ref: (0, ColorRef::scheme(SchemeSlot::Accent3)),
font_ref: ("minor".into(), Some(ColorRef::scheme(SchemeSlot::Accent6))),
})
},
);
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
let want = (resolved_box(&s, title), resolved_look(&s, title));
assert!(want.1.contains("+mj-lt"), "the title draws in the heading font: {}", want.1);
s.execute("selection.select", &json!({"id": title.0})).unwrap();
s.execute("edit.duplicate", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_eq!((resolved_box(&s, copy), resolved_look(&s, copy)), want);
}
/// The same on every text placeholder of the sample deck (titles, bulleted bodies, subtitles).
#[test]
fn sample_placeholders_paste_with_their_look() {
let mut s = Session::new();
crate::sample::open_sample(&mut s).unwrap();
let slides = s.doc().unwrap().doc.slides.len();
let mut checked = 0;
for i in 0..slides {
s.execute("slide.go", &json!({"index": i})).unwrap();
let phs: Vec<ShapeId> = s
.doc()
.unwrap()
.current_slide()
.unwrap()
.shapes
.iter()
.filter(|x| x.ph.is_some() && x.text.as_ref().is_some_and(|t| !t.is_empty()))
.map(|x| x.id)
.collect();
for id in phs {
let want = resolved_look(&s, id);
s.execute("selection.select", &json!({"id": id.0})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.shapes[0];
assert_eq!(resolved_look(&s, copy), want, "slide {i}, placeholder {}", id.0);
checked += 1;
}
}
assert!(checked >= 3, "the sample has text placeholders ({checked})");
}
#[test] #[test]
fn pasted_shapes_get_the_next_automatic_name() { fn pasted_shapes_get_the_next_automatic_name() {
let mut s = session(); let mut s = session();
@@ -1351,6 +1713,31 @@ fn tab_moves_between_cells_skipping_merged_ones() {
assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row"); assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row");
} }
#[test]
fn format_paragraph_align_names_match_format_align() {
use deckcraft_model::text::Align;
let mut s = session();
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
s.execute("text.edit", &json!({"id": title})).unwrap();
let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.align;
for (name, want) in [
("justLow", Align::JustLow),
("justifyLow", Align::JustLow),
("thaiDist", Align::ThaiDistributed),
("ctr", Align::Center),
("centre", Align::Center),
("r", Align::Right),
("just", Align::Justify),
("dist", Align::Distributed),
("distributed", Align::Distributed),
("left", Align::Left),
] {
s.execute("format.paragraph", &json!({"align": name})).unwrap();
assert_eq!(para(&s), Some(want), "{name}");
}
assert!(s.execute("format.paragraph", &json!({"align": "bogus"})).is_err());
}
#[test] #[test]
fn right_to_left_paragraphs() { fn right_to_left_paragraphs() {
let mut s = session(); let mut s = session();
@@ -6667,3 +7054,18 @@ fn outline_export_skips_footer_date_and_slide_number() {
assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{rtf:?}"); assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{rtf:?}");
assert!(!rtf.contains("Company Confidential") && !rtf.contains("1 May 2026"), "{rtf:?}"); assert!(!rtf.contains("Company Confidential") && !rtf.contains("1 May 2026"), "{rtf:?}");
} }
#[test]
fn shape_adjust_and_quick_style_reject_invalid_index() {
let mut s = session();
let r = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [100, 100, 200, 100]})).unwrap();
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
let before = s.doc().unwrap().shape(id).unwrap().clone();
for bad in [json!(-1), json!(1.5)] {
assert!(s.execute("shape.adjust", &json!({"index": bad, "value": 0.4})).is_err(), "adjust accepted {bad}");
assert!(s.execute("shape.quickStyle", &json!({"index": bad})).is_err(), "quickStyle accepted {bad}");
}
assert_eq!(s.doc().unwrap().shape(id).unwrap().geom, before.geom);
assert_eq!(s.doc().unwrap().shape(id).unwrap().style, before.style);
s.execute("shape.adjust", &json!({"value": 0.4})).unwrap();
}
+1
View File
@@ -101,6 +101,7 @@ impl Session {
let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default(); let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default();
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect(); let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
let boxes: Vec<Xfrm> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect(); let boxes: Vec<Xfrm> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
cmd::edit::detach_on_slide(doc, sel, &mut copies);
cmd::edit::reid("edit.duplicate", doc, &mut copies)?; cmd::edit::reid("edit.duplicate", doc, &mut copies)?;
for (c, b) in copies.iter_mut().zip(boxes) { for (c, b) in copies.iter_mut().zip(boxes) {
c.xfrm = Some(b); c.xfrm = Some(b);
+13 -1
View File
@@ -498,6 +498,11 @@ pub fn next_case(s: &str) -> &'static str {
} }
} }
/// Whether the `.` at `i` sits between two ASCII digits (a decimal point or version number).
fn is_decimal_point(all: &[char], i: usize) -> bool {
i > 0 && all[i - 1].is_ascii_digit() && all.get(i + 1).is_some_and(|n| n.is_ascii_digit())
}
/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`. /// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
pub fn change_case(s: &str, mode: &str) -> String { pub fn change_case(s: &str, mode: &str) -> String {
change_case_parts(&[s], mode).concat() change_case_parts(&[s], mode).concat()
@@ -532,7 +537,7 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
} else { } else {
out.extend(c.to_lowercase()); out.extend(c.to_lowercase());
} }
if (title && c.is_whitespace()) || (!title && matches!(c, '.' | '!' | '?')) { if (title && c.is_whitespace()) || (!title && matches!(c, '.' | '!' | '?') && !(c == '.' && is_decimal_point(&all, i))) {
start = true; start = true;
} }
} }
@@ -558,6 +563,13 @@ mod tests {
assert_eq!(change_case("ΟΔΟΣ", "lower"), "οδος"); assert_eq!(change_case("ΟΔΟΣ", "lower"), "οδος");
} }
#[test]
fn sentence_case_decimal_point_is_not_a_boundary() {
assert_eq!(change_case("I HAVE 3.5 APPLES", "sentence"), "I have 3.5 apples");
assert_eq!(change_case_parts(&["V 1.", "2 BETA"], "sentence").concat(), "V 1.2 beta");
assert_eq!(change_case("HELLO. WORLD", "sentence"), "Hello. World");
}
#[test] #[test]
fn title_sigma_across_runs_is_not_final() { fn title_sigma_across_runs_is_not_final() {
assert_eq!(change_case_parts(&["ΟΔΟΣ", "ΑΣ"], "title"), ["Οδοσ", "ας"]); assert_eq!(change_case_parts(&["ΟΔΟΣ", "ΑΣ"], "title"), ["Οδοσ", "ας"]);
+64 -1
View File
@@ -4,7 +4,7 @@
use deckcraft_color::{ColorScheme, Rgba, SchemeSlot}; use deckcraft_color::{ColorScheme, Rgba, SchemeSlot};
use deckcraft_geom::Xfrm; use deckcraft_geom::Xfrm;
use crate::style::{ColorRef, Effects, Fill, Line}; use crate::style::{ColorRef, Effects, Fill, Line, ShapeStyle};
use crate::text::{Anchor, AutoFit, BodyProps, LevelStyle, ListStyle, ParaProps, RunProps}; use crate::text::{Anchor, AutoFit, BodyProps, LevelStyle, ListStyle, ParaProps, RunProps};
use crate::{Background, Layout, Master, PhType, Presentation, Shape, Slide}; use crate::{Background, Layout, Master, PhType, Presentation, Shape, Slide};
@@ -218,6 +218,68 @@ pub fn effects(ctx: &Ctx, shape: &Shape) -> (Option<Effects>, Option<Rgba>) {
(None, None) (None, None)
} }
/// Turn a placeholder into a regular shape that looks the same: what it inherited from its layout
/// and master placeholders (position, fill, line, effects, style, body properties and the text
/// style of every outline level, the master's title or body style included) becomes its own, and
/// `ph` is cleared. A shape that is not a placeholder is left as it is.
///
/// Theme references (`+mj-lt`, scheme colours) stay references, so the copy still follows the
/// destination theme, as PowerPoint's default paste does.
pub fn detach_placeholder(ctx: &Ctx, shape: &mut Shape) {
if shape.ph.is_none() {
return;
}
// The text first: `level_style` reads the shape's own style, which changes below.
let text = shape.text.as_ref().map(|_| (body(ctx, shape), (0..9).map(|l| Some(level_style(ctx, shape, l))).collect::<Vec<_>>()));
let own = shape.clone();
let (lp, mp) = parents(ctx, &own);
let chain = [Some(&own), lp, mp].into_iter().flatten();
// Each property comes from where `fill`, `line` and `effects` take it: the first shape in the
// chain with an explicit value or a style. Lines merge explicit values up to the first styled
// shape, whose style line fills the rest.
let fill_src = chain.clone().find(|s| s.fill.is_some() || s.style.is_some());
let effects_src = chain.clone().find(|s| s.effects.is_some() || s.style.is_some());
let mut line = None;
let mut line_src = None;
for s in chain.clone() {
if let Some(l) = &s.line {
line.get_or_insert_with(Line::default).inherit(l);
}
if s.style.is_some() {
line_src = Some(s);
break;
}
}
// Text takes its font reference from the shape's style or its layout placeholder's (a table's
// from its own only), never the master's.
let table = matches!(own.kind, crate::ShapeKind::Table(_));
let font_src = own.style.as_ref().or(lp.filter(|_| !table).and_then(|s| s.style.as_ref()));
// One style for the copy, each reference taken from the shape that supplied that property. A
// property with an explicit value ignores its reference, except a gradient without stops,
// which takes them from its own shape's style; a neutral reference (index 0) adds nothing.
let style_of = |s: Option<&Shape>| s.and_then(|s| s.style.clone());
let neutral = (0, ColorRef::scheme(SchemeSlot::Tx1));
let style = chain.clone().any(|s| s.style.is_some()).then(|| ShapeStyle {
fill_ref: style_of(fill_src).map_or_else(|| neutral.clone(), |st| st.fill_ref),
line_ref: style_of(line_src).map_or_else(|| neutral.clone(), |st| st.line_ref),
effect_ref: style_of(effects_src).map_or_else(|| neutral.clone(), |st| st.effect_ref),
font_ref: font_src.map_or_else(|| ("none".into(), None), |st| st.font_ref.clone()),
});
shape.xfrm = Some(xfrm(ctx, &own));
shape.fill = fill_src.and_then(|s| s.fill.clone());
shape.effects = effects_src.and_then(|s| s.effects.clone());
shape.line = line;
shape.style = style;
if let (Some(t), Some((body, levels))) = (shape.text.as_mut(), text) {
t.body = body;
t.list_style = ListStyle { levels };
}
shape.ph = None;
}
/// Body properties with defaults filled in. /// Body properties with defaults filled in.
pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps { pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
let (lp, mp) = parents(ctx, shape); let (lp, mp) = parents(ctx, shape);
@@ -252,6 +314,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
compat_ln_spc: None, compat_ln_spc: None,
vert_overflow: None, vert_overflow: None,
horz_overflow: None, horz_overflow: None,
raw3d: None,
}); });
b b
} }
+6
View File
@@ -124,6 +124,9 @@ pub struct BodyProps {
pub compat_ln_spc: Option<bool>, pub compat_ln_spc: Option<bool>,
pub vert_overflow: Option<TextOverflow>, pub vert_overflow: Option<TextOverflow>,
pub horz_overflow: Option<TextOverflow>, pub horz_overflow: Option<TextOverflow>,
/// Unmodelled 3-D XML on the text body (`a:scene3d`, `a:sp3d`, `a:flatTx`).
#[serde(skip_serializing_if = "Option::is_none")]
pub raw3d: Option<String>,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -187,6 +190,9 @@ impl BodyProps {
self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc); self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc);
self.vert_overflow = self.vert_overflow.or(p.vert_overflow); self.vert_overflow = self.vert_overflow.or(p.vert_overflow);
self.horz_overflow = self.horz_overflow.or(p.horz_overflow); self.horz_overflow = self.horz_overflow.or(p.horz_overflow);
if self.raw3d.is_none() {
self.raw3d.clone_from(&p.raw3d);
}
} }
} }
+71 -6
View File
@@ -337,16 +337,31 @@ fn fit_moving_avg(pairs: &[(f64, f64)], period: usize) -> Option<(Model, Vec<f64
#[allow(clippy::needless_range_loop)] #[allow(clippy::needless_range_loop)]
fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Option<Vec<f64>> { fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Option<Vec<f64>> {
// Fit `y - b0 = (x / sx) * q(u)` with `u = (x - xm) / su` and `q` of degree `order - 1`. The
// basis `(x / sx) * u^j` spans the same polynomials as `x^(j + 1)`, but keeps the pivots in
// `solve` independent of the units and the offset of x.
let n = xs.len() as f64;
let sx = xs.iter().fold(0.0_f64, |acc, x| acc.max(x.abs()));
if sx <= 0.0 || !sx.is_finite() {
return None;
}
let xm = xs.iter().sum::<f64>() / n;
let su = match xs.iter().fold(0.0_f64, |acc, x| acc.max((x - xm).abs())) {
s if s > 0.0 && s.is_finite() => s,
_ => 1.0,
};
let m = order; let m = order;
let mut ata = vec![vec![0.0; m]; m]; let mut ata = vec![vec![0.0; m]; m];
let mut atb = vec![0.0; m]; let mut atb = vec![0.0; m];
for (x, y) in xs.iter().zip(ys) { for (x, y) in xs.iter().zip(ys) {
let v = x / sx;
let u = (x - xm) / su;
let dy = y - b0; let dy = y - b0;
for i in 0..m { for i in 0..m {
let xi = x.powi((i + 1) as i32); let xi = v * u.powi(i as i32);
atb[i] += dy * xi; atb[i] += dy * xi;
for j in i..m { for j in i..m {
ata[i][j] += xi * x.powi((j + 1) as i32); ata[i][j] += xi * v * u.powi(j as i32);
} }
} }
} }
@@ -355,10 +370,11 @@ fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Opti
ata[i][j] = ata[j][i]; ata[i][j] = ata[j][i];
} }
} }
let solved = solve(ata, atb)?; let centred: Vec<f64> = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / su.powi(k as i32)).collect();
let q = expand_shift(&centred, xm);
let mut coeff = Vec::with_capacity(order + 1); let mut coeff = Vec::with_capacity(order + 1);
coeff.push(b0); coeff.push(b0);
coeff.extend(solved); coeff.extend(q.iter().map(|c| c / sx));
coeff.iter().all(|v| v.is_finite()).then_some(coeff) coeff.iter().all(|v| v.is_finite()).then_some(coeff)
} }
@@ -374,7 +390,13 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Opti
return polyfit_with_intercept(xs, ys, order, b0); return polyfit_with_intercept(xs, ys, order, b0);
} }
let xm = xs.iter().sum::<f64>() / n as f64; let xm = xs.iter().sum::<f64>() / n as f64;
let us: Vec<f64> = xs.iter().map(|x| x - xm).collect(); // Scale the centred x to unit range so the pivot threshold in `solve` does not depend on the
// units of x.
let s = xs.iter().fold(0.0_f64, |acc, x| acc.max((x - xm).abs()));
if s <= 0.0 || !s.is_finite() {
return None;
}
let us: Vec<f64> = xs.iter().map(|x| (x - xm) / s).collect();
let m = order + 1; let m = order + 1;
let mut ata = vec![vec![0.0; m]; m]; let mut ata = vec![vec![0.0; m]; m];
let mut atb = vec![0.0; m]; let mut atb = vec![0.0; m];
@@ -393,7 +415,7 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Opti
ata[i][j] = ata[j][i]; ata[i][j] = ata[j][i];
} }
} }
let centred = solve(ata, atb)?; let centred: Vec<f64> = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / s.powi(k as i32)).collect();
let coeff = expand_shift(&centred, xm); let coeff = expand_shift(&centred, xm);
coeff.iter().all(|v| v.is_finite()).then_some(coeff) coeff.iter().all(|v| v.is_finite()).then_some(coeff)
} }
@@ -571,6 +593,49 @@ mod tests {
assert!((fit.y(3e-10).unwrap() - 3.0).abs() < 1e-6); assert!((fit.y(3e-10).unwrap() - 3.0).abs() < 1e-6);
} }
#[test]
fn poly_fits_tiny_x_values() {
let xs = [1e-5, 2e-5, 3e-5, 4e-5, 5e-5];
let f = |x: f64| 1.0 + 2.0 * x + 3.0 * x * x;
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
let fit = line(TrendlineType::Poly).fit(&xs, &ys).expect("poly fit of tiny x values");
assert!((fit.y(3e-5).unwrap() - f(3e-5)).abs() < 1e-9);
let big: Vec<f64> = xs.iter().map(|x| x * 1e5).collect();
let scaled = line(TrendlineType::Poly).fit(&big, &ys).expect("poly fit of scaled x values");
for x in xs {
assert!((fit.y(x).unwrap() - scaled.y(x * 1e5).unwrap()).abs() < 1e-9, "{x}");
}
}
#[test]
fn poly_with_intercept_fits_tiny_x_values() {
let xs = [1e-5, 2e-5, 3e-5, 4e-5, 5e-5];
let f = |x: f64| 1.0 + 2.0 * x + 3.0 * x * x;
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
let mut poly = line(TrendlineType::Poly);
poly.intercept = Some(1.0);
let fit = poly.fit(&xs, &ys).expect("poly fit with intercept of tiny x values");
assert!((fit.y(3e-5).unwrap() - f(3e-5)).abs() < 1e-9);
let big: Vec<f64> = xs.iter().map(|x| x * 1e5).collect();
let scaled = poly.fit(&big, &ys).expect("poly fit with intercept of scaled x values");
for x in xs {
assert!((fit.y(x).unwrap() - scaled.y(x * 1e5).unwrap()).abs() < 1e-9, "{x}");
}
}
#[test]
fn poly_with_intercept_fits_offset_but_narrow_x_range() {
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
let f = |x: f64| 1.0 + x * (1.0 + 100.0 * (x - 45000.02));
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
let mut poly = line(TrendlineType::Poly);
poly.intercept = Some(1.0);
let fit = poly.fit(&xs, &ys).expect("poly fit with intercept of offset x values");
for x in xs {
assert!((fit.y(x).unwrap() - f(x)).abs() < 1e-2, "{x}");
}
}
#[test] #[test]
fn linear_fits_offset_but_narrow_x_range() { fn linear_fits_offset_but_narrow_x_range() {
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04]; let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
+13 -1
View File
@@ -629,7 +629,19 @@ pub fn list_style(imp: &mut Imp, part: &Part, ls: &El) -> ListStyle {
} }
pub fn text_body(imp: &mut Imp, part: &Part, tx: &El) -> TextBody { pub fn text_body(imp: &mut Imp, part: &Part, tx: &El) -> TextBody {
let body = tx.child("bodyPr").map(body_pr).unwrap_or_default(); let mut body = tx.child("bodyPr").map(body_pr).unwrap_or_default();
if let Some(b) = tx.child("bodyPr") {
let mut raw3d = String::new();
if let Some(sc) = b.child("scene3d") {
raw3d.push_str(&part.keep_inner(sc));
}
if let Some(sp) = b.child("sp3d").or_else(|| b.child("flatTx")) {
raw3d.push_str(&part.keep_inner(sp));
}
if !raw3d.is_empty() && raw3d.len() <= 64 * 1024 {
body.raw3d = Some(raw3d);
}
}
let list_style = tx.child("lstStyle").map(|l| list_style(imp, part, l)).unwrap_or_default(); let list_style = tx.child("lstStyle").map(|l| list_style(imp, part, l)).unwrap_or_default();
let paragraphs = tx.children_named("p").take(100_000).map(|p| paragraph(imp, part, p)).collect(); let paragraphs = tx.children_named("p").take(100_000).map(|p| paragraph(imp, part, p)).collect();
TextBody { body, list_style, paragraphs } TextBody { body, list_style, paragraphs }
+6
View File
@@ -395,6 +395,12 @@ fn grp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> Sha
if let Some(fx) = pr.child("effectLst") { if let Some(fx) = pr.child("effectLst") {
s.effects = Some(dml::effects(fx)); s.effects = Some(dml::effects(fx));
} }
if let Some(sc) = pr.child("scene3d") {
let raw = part.keep_inner(sc);
if !raw.is_empty() && raw.len() <= 64 * 1024 {
s.effects.get_or_insert_with(Effects::default).raw3d = Some(raw);
}
}
} }
let children = sp_tree(imp, part, ctx, e, depth + 1); let children = sp_tree(imp, part, ctx, e, depth + 1);
let child = child.or_else(|| s.xfrm.map(|x| Xfrm::new(x.x, x.y, x.w, x.h))).unwrap_or_default(); let child = child.or_else(|| s.xfrm.map(|x| Xfrm::new(x.x, x.y, x.w, x.h))).unwrap_or_default();
+20 -1
View File
@@ -461,6 +461,21 @@ fn sound_label(name: &str) -> String {
if label.is_empty() { "sound.wav".into() } else { label } if label.is_empty() { "sound.wav".into() } else { label }
} }
/// Validate an unmodelled 3-D fragment (`a:scene3d`, `a:sp3d`, `a:flatTx`) before writing raw XML.
pub fn valid_raw3d<'a>(raw: &'a str, allowed: &[&str]) -> Option<&'a str> {
if raw.is_empty() || raw.len() > 64 * 1024 {
return None;
}
let wrap = format!("<r xmlns:a=\"{}\">{raw}</r>", crate::opc::NS_A);
let doc = crate::xml::parse(wrap.as_bytes()).ok()?;
let els: Vec<_> = doc.root.elements().collect();
(!els.is_empty()
&& els.len() <= allowed.len()
&& doc.root.children.len() == els.len()
&& els.iter().all(|e| e.prefix() == "a" && allowed.contains(&e.local()) && !crate::read::has_rel_refs(e)))
.then_some(raw)
}
pub fn body_pr(w: &mut W, b: &BodyProps) { pub fn body_pr(w: &mut W, b: &BodyProps) {
let a = A::new() let a = A::new()
.o("rot", b.rot.map(|r| (r * 60_000.0).round().clamp(-21_600_000.0, 21_600_000.0) as i64)) .o("rot", b.rot.map(|r| (r * 60_000.0).round().clamp(-21_600_000.0, 21_600_000.0) as i64))
@@ -481,7 +496,8 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
.o("vertOverflow", b.vert_overflow.map(|v| v.xml())) .o("vertOverflow", b.vert_overflow.map(|v| v.xml()))
.o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(b.horz_overflow)); .o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(b.horz_overflow));
let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric())); let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()));
if warp.is_none() && b.autofit.is_none() { let raw3d = b.raw3d.as_deref().and_then(|s| valid_raw3d(s, &["scene3d", "sp3d", "flatTx"]));
if warp.is_none() && b.autofit.is_none() && raw3d.is_none() {
w.empty("a:bodyPr", a); w.empty("a:bodyPr", a);
return; return;
} }
@@ -515,6 +531,9 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
} }
None => {} None => {}
} }
if let Some(raw) = raw3d {
w.raw(raw);
}
w.close("a:bodyPr"); w.close("a:bodyPr");
} }
+14 -1
View File
@@ -334,7 +334,20 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
dml::fill(w, x, o, f); dml::fill(w, x, o, f);
} }
if let Some(fx) = &s.effects { if let Some(fx) = &s.effects {
dml::effects(w, fx); if fx.fill_overlay.is_some()
|| fx.glow.is_some()
|| fx.inner_shadow.is_some()
|| fx.outer_shadow.is_some()
|| fx.reflection.is_some()
|| fx.soft_edge.is_some()
{
dml::effects(w, fx);
}
if let Some(raw) =
fx.raw3d.as_deref().map(|s| s.find("<a:sp3d").map_or(s, |i| &s[..i])).and_then(|s| dml::valid_raw3d(s, &["scene3d"]))
{
w.raw(raw);
}
} }
w.close("p:grpSpPr"); w.close("p:grpSpPr");
shapes(w, x, o, cx, children, depth + 1); shapes(w, x, o, cx, children, depth + 1);
+61
View File
@@ -241,6 +241,67 @@ fn theme_and_bevel_survive(once: &Presentation, twice: &Presentation) {
assert_eq!(by_name(twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw)); assert_eq!(by_name(twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw));
} }
#[test]
fn text_and_group_3d_survive() {
let text_3d =
"<a:scene3d><a:camera prst=\"orthographicFront\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d><a:sp3d extrusionH=\"12700\"/>";
let flat_tx = "<a:scene3d><a:camera prst=\"orthographicFront\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d><a:flatTx z=\"0\"/>";
let mut p3 = defaults::new_presentation(None);
let mut sh = shape(&mut p3, "Text3D", 10.0, 10.0, 100.0, 40.0);
sh.text = Some(TextBody {
body: BodyProps { raw3d: Some(text_3d.into()), ..Default::default() },
paragraphs: vec![Paragraph::new("3D Text")],
..Default::default()
});
let mut sh_flat = shape(&mut p3, "FlatTx3D", 10.0, 55.0, 100.0, 40.0);
sh_flat.text = Some(TextBody {
body: BodyProps { raw3d: Some(flat_tx.into()), ..Default::default() },
paragraphs: vec![Paragraph::new("Flat Text")],
..Default::default()
});
let grp_3d = "<a:scene3d><a:camera prst=\"isometricTopUp\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d>";
let mut grp = shape(&mut p3, "Group3D", 10.0, 60.0, 100.0, 80.0);
grp.effects = Some(Effects { raw3d: Some(grp_3d.into()), ..Default::default() });
grp.kind = ShapeKind::Group { children: vec![sh, sh_flat], child: Xfrm::new(10.0, 10.0, 100.0, 80.0) };
let layout = defaults::layout_of_kind(&p3, deckcraft_model::LayoutType::Blank).unwrap_or_default();
let mut s3 = defaults::new_slide(&mut p3, layout).expect("slide");
s3.shapes.push(grp);
p3.slides.push(Arc::new(s3));
let bytes = deckcraft_pptx::export(&p3).expect("export");
let slide_xml = {
use std::io::Read;
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
let mut s = String::new();
z.by_name("ppt/slides/slide2.xml").expect("slide2").read_to_string(&mut s).expect("utf8");
s
};
assert!(!slide_xml.contains("<a:effectLst/>"), "Group3D with only raw3d must not emit empty effectLst: {slide_xml}");
assert!(slide_xml.contains("<a:flatTx z=\"0\"/>"), "{slide_xml}");
let r3 = deckcraft_pptx::import(&bytes).expect("import");
let r3_twice = round(&r3);
let g3 = by_name(&r3, "Group3D");
let g3_raw = g3.effects.as_ref().and_then(|e| e.raw3d.as_deref()).unwrap_or("");
let t3_raw = g3.children()[0].text.as_ref().and_then(|t| t.body.raw3d.as_deref()).unwrap_or("");
let flat_raw = g3.children()[1].text.as_ref().and_then(|t| t.body.raw3d.as_deref()).unwrap_or("");
assert!(g3_raw.contains("isometricTopUp"), "{g3_raw}");
assert!(t3_raw.contains("orthographicFront") && t3_raw.contains("extrusionH=\"12700\""), "{t3_raw}");
assert!(flat_raw.contains("flatTx"), "{flat_raw}");
assert_eq!(by_name(&r3_twice, "Group3D").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(g3_raw));
assert_eq!(by_name(&r3_twice, "Group3D").children()[0].text.as_ref().and_then(|t| t.body.raw3d.as_deref()), Some(t3_raw));
assert_eq!(by_name(&r3_twice, "Group3D").children()[1].text.as_ref().and_then(|t| t.body.raw3d.as_deref()), Some(flat_raw));
// BodyProps::inherit propagates parent raw3d only when child raw3d is None.
let parent_body = BodyProps { raw3d: Some(text_3d.into()), ..Default::default() };
let mut child_body = BodyProps::default();
child_body.inherit(&parent_body);
assert_eq!(child_body.raw3d.as_deref(), Some(text_3d));
let mut override_body = BodyProps { raw3d: Some(flat_tx.into()), ..Default::default() };
override_body.inherit(&parent_body);
assert_eq!(override_body.raw3d.as_deref(), Some(flat_tx));
}
fn connector_glue_survives(p: &Presentation, once: &Presentation, twice: &Presentation) { fn connector_glue_survives(p: &Presentation, once: &Presentation, twice: &Presentation) {
let c = by_name(once, "Connector"); let c = by_name(once, "Connector");
assert!(matches!(c.kind, ShapeKind::Connector { start: Some((s, 3)), end: Some((e, 1)) } if s == left_of(p) && e == right_of(p)), "{:?}", c.kind); assert!(matches!(c.kind, ShapeKind::Connector { start: Some((s, 3)), end: Some((e, 1)) } if s == left_of(p) && e == right_of(p)), "{:?}", c.kind);
+8
View File
@@ -137,6 +137,14 @@ impl SlideApp {
updates_ui::enable(self); updates_ui::enable(self);
} }
/// Start the helper of an update that is ready, so that it installs once the process has
/// exited. The desktop shell calls it in `on_exit`, which ends the process on macOS before the
/// app is dropped. Later calls do nothing.
#[cfg(not(target_arch = "wasm32"))]
pub fn start_pending_update(&mut self) {
updates_ui::start_pending_update(self);
}
/// A message that must be seen wherever the user is: a toast, which also shows over the start /// A message that must be seen wherever the user is: a toast, which also shows over the start
/// screen and the File tab, where there is no status bar. /// screen and the File tab, where there is no status bar.
pub fn notify(&mut self, msg: impl Into<String>) { pub fn notify(&mut self, msg: impl Into<String>) {
+46
View File
@@ -11,6 +11,52 @@ pub(crate) fn system_clipboard_text(t: &str) -> String {
if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() } if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() }
} }
/// Whether pasted `text` (and the clipboard's `html`) only name a picture that is on the clipboard
/// too, as a browser's Copy Image leaves them: the picture's address, perhaps followed by its alt
/// text, or HTML that is a lone `<img>`. Then Ctrl+V pastes the picture, as PowerPoint does.
/// Text with NULs is UTF-16 read as UTF-8 (Firefox's `text/x-moz-url`), never words to keep.
pub(crate) fn names_a_picture(text: &str, html: Option<&str>) -> bool {
if text.contains('\0') {
return true;
}
if let Some(h) = html
&& h.to_ascii_lowercase().contains("<img")
&& visible_text(h).trim().is_empty()
{
return true;
}
let mut lines = text.trim().lines();
let first = lines.next().unwrap_or("").trim();
let link = ["http://", "https://", "file://", "data:image/"].iter().any(|p| first.to_ascii_lowercase().starts_with(p));
link && !first.contains(char::is_whitespace) && lines.count() <= 1
}
/// The text of an HTML fragment outside its tags (and outside comments and `<style>`/`<script>`
/// bodies, which browsers sometimes put around a copied picture).
fn visible_text(html: &str) -> String {
let lower = html.to_ascii_lowercase();
let mut out = String::new();
let mut i = 0;
while i < html.len() {
let rest = &lower[i..];
let skip_to = |end: &str| rest.find(end).map(|e| i + e + end.len()).unwrap_or(html.len());
if rest.starts_with("<!--") {
i = skip_to("-->");
} else if rest.starts_with("<style") {
i = skip_to("</style>");
} else if rest.starts_with("<script") {
i = skip_to("</script>");
} else if rest.starts_with('<') {
i = skip_to(">");
} else {
let end = rest.find('<').map_or(html.len(), |e| i + e);
out.push_str(&html[i..end]);
i = end;
}
}
out.replace("&nbsp;", " ")
}
impl SlideApp { impl SlideApp {
/// Offer the copy or cut on the system clipboard. /// Offer the copy or cut on the system clipboard.
/// ///
+4 -2
View File
@@ -152,8 +152,10 @@ impl SlideApp {
// Text keeps its HTML (bold, italic, underline) when the host can read it. // Text keeps its HTML (bold, italic, underline) when the host can read it.
let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty()); let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty());
// egui 0.37 sends `PasteImage` directly; the services fallback stays for // egui 0.37 sends `PasteImage` directly; the services fallback stays for
// integrations that still send an empty `Paste`. Never insert twice. // integrations that still send an empty `Paste`, and for a browser's Copy
let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None }; // Image, whose text is only the picture's link. Never insert twice.
let wants_picture = text.is_empty() || super::clipboard::names_a_picture(&text, html.as_deref());
let img = if wants_picture && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None };
let revision = self.session.active().map(|d| d.revision); let revision = self.session.active().map(|d| d.revision);
let r = match img { let r = match img {
Some(png) => self Some(png) => self
+10 -1
View File
@@ -32,15 +32,24 @@ impl SlideApp {
pub fn start_show(&mut self, from: usize, reading: bool) { pub fn start_show(&mut self, from: usize, reading: bool) {
self.anim_preview = None; self.anim_preview = None;
self.media.stop_owned(media::Owner::Editor); self.media.stop_owned(media::Owner::Editor);
let presenter = self.ui.presenter_view && !reading && self.second_display();
if let Some(d) = self.session.active() { if let Some(d) = self.session.active() {
self.show_instance = self.show_instance.wrapping_add(1).max(1); self.show_instance = self.show_instance.wrapping_add(1).max(1);
let mut show = show::Show::new(&d.doc, from, reading, self.ui.presenter_view && !reading); let mut show = show::Show::new(&d.doc, from, reading, presenter);
show.instance = self.show_instance; show.instance = self.show_instance;
show.keep_cursor = self.ui.show_keep_cursor; show.keep_cursor = self.ui.show_keep_cursor;
self.show = Some(show); self.show = Some(show);
} }
} }
/// Whether a second display can take Presenter View. Like PowerPoint, one display shows only
/// the slides: the presenter window would open on the same screen, and whichever of the two
/// windows ends up behind the other stalls the show (the compositor stops pacing a hidden
/// window). Hosts that can't count displays keep Presenter View.
fn second_display(&mut self) -> bool {
self.services.displays.as_mut().and_then(|f| f()).is_none_or(|n| n >= 2)
}
/// Play custom show `name` from its first slide. Set Up Slide Show is left as it was. /// Play custom show `name` from its first slide. Set Up Slide Show is left as it was.
pub fn start_custom_show(&mut self, name: &str, reading: bool) { pub fn start_custom_show(&mut self, name: &str, reading: bool) {
let state = self.session.active().and_then(|d| deckcraft_anim::ShowState::play_custom(&d.doc, name)); let state = self.session.active().and_then(|d| deckcraft_anim::ShowState::play_custom(&d.doc, name));
+83 -12
View File
@@ -13,13 +13,11 @@ pub(crate) struct Runtime {
/// The desktop host turns the check at start on; tests and the web build leave it off. /// The desktop host turns the check at start on; tests and the web build leave it off.
enabled: bool, enabled: bool,
check_requested: bool, check_requested: bool,
/// A manual check's result dialog is showing (checking, up to date or failed).
result_open: bool,
} }
impl Default for Runtime { impl Default for Runtime {
fn default() -> Self { fn default() -> Self {
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false, result_open: false } Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false }
} }
} }
@@ -31,10 +29,15 @@ pub(crate) fn enable(app: &mut SlideApp) {
pub(crate) fn request_check(app: &mut SlideApp) { pub(crate) fn request_check(app: &mut SlideApp) {
if !matches!(app.updates_rt.updater.status(), Status::Checking) { if !matches!(app.updates_rt.updater.status(), Status::Checking) {
app.updates_rt.check_requested = true; app.updates_rt.check_requested = true;
app.updates_rt.result_open = true;
} }
} }
/// Starts the helper of an update that is ready, so that it installs once the process has exited.
/// Later calls do nothing.
pub(crate) fn start_pending_update(app: &mut SlideApp) {
app.updates_rt.updater.start_pending_update();
}
pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) { pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
let rt = &mut app.updates_rt; let rt = &mut app.updates_rt;
if std::mem::take(&mut rt.check_requested) { if std::mem::take(&mut rt.check_requested) {
@@ -46,21 +49,31 @@ pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
} }
pub(crate) fn show(app: &mut SlideApp, ctx: &egui::Context) { pub(crate) fn show(app: &mut SlideApp, ctx: &egui::Context) {
// The restart helper is scheduled before the app closes, so no update starts over unsaved work. // An installation starts, and a ready update asks the app to close, only while nothing is unsaved.
let clean = !app.session.documents().iter().any(|d| d.is_dirty()); let clean = !app.session.documents().iter().any(|d| d.is_dirty());
match app.updates_rt.updater.show_if(ctx, clean) { match app.updates_rt.updater.show_if(ctx, clean) {
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)), Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
// The app's close path asks about unsaved work; the restart helper relaunches once the process exits. // The app's close path asks about unsaved work; `on_exit` starts the restart helper.
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close), Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
Action::None => {} Action::None => {}
} }
// The result window reports a requested check, and at the first start after an update what
// that update came to (installed, or why it failed).
let rt = &mut app.updates_rt; let rt = &mut app.updates_rt;
if rt.result_open { if !matches!(rt.updater.status(), Status::Idle) {
match CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) { let response = CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status());
CheckResultResponse::Close => rt.result_open = false, answer(rt, response);
CheckResultResponse::CheckAgain => rt.check_requested = true, }
CheckResultResponse::None => {} }
}
/// Acts on the result window's answer.
fn answer(rt: &mut Runtime, response: CheckResultResponse) {
match response {
CheckResultResponse::Close => rt.updater.dismiss_status(),
CheckResultResponse::CheckAgain => rt.check_requested = true,
// The next frame's `show_if` asks the window to close once nothing is unsaved.
CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(),
CheckResultResponse::None => {}
} }
} }
@@ -77,4 +90,62 @@ mod tests {
ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui.ctx())).textures_delta.clear(); ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui.ctx())).textures_delta.clear();
assert!(app.updates_rt.updater.prompt_open()); assert!(app.updates_rt.updater.prompt_open());
} }
/// An app whose updater keeps its outcome records under `root`, reads no launcher choice and
/// asks no server, with the check at start turned on.
fn app_with_records(root: &std::path::Path) -> SlideApp {
let mut app = SlideApp::new(deckcraft_engine::Session::new(), crate::Services::default());
app.updates_rt.updater = Updater::new(&crate::UPDATE_APP, VERSION)
.with_paths(craft_update::Paths::under(root))
.with_pins_path(root.join("pins.json"))
.with_source(std::sync::Arc::new(|| Ok(None)));
app.updates_rt.enabled = true;
app
}
fn records_dir(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("deckcraft-updates-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
/// The first start after an update reports it; OK dismisses the report.
#[test]
fn the_start_after_an_update_reports_it_until_dismissed() {
let root = records_dir("installed");
std::fs::create_dir_all(root.join("data")).unwrap();
std::fs::write(root.join("data").join("self-update-done.txt"), VERSION).unwrap();
let mut app = app_with_records(&root);
let ctx = egui::Context::default();
ctx.run_ui(egui::RawInput::default(), |ui| {
tick(&mut app, ui.ctx());
show(&mut app, ui.ctx());
})
.textures_delta
.clear();
assert!(matches!(app.updates_rt.updater.status(), Status::Updated(_)), "{:?}", app.updates_rt.updater.status());
answer(&mut app.updates_rt, CheckResultResponse::Close);
assert_eq!(app.updates_rt.updater.status(), &Status::Idle);
let _ = std::fs::remove_dir_all(&root);
}
/// The first start after a failed update says why; Check Again asks for a new check.
#[test]
fn the_start_after_a_failed_update_reports_why_and_offers_a_new_check() {
let root = records_dir("failed");
std::fs::create_dir_all(root.join("data").join("logs")).unwrap();
std::fs::write(root.join("data").join("logs").join("self-update-error.txt"), "the disk is full").unwrap();
let mut app = app_with_records(&root);
let ctx = egui::Context::default();
ctx.run_ui(egui::RawInput::default(), |ui| {
tick(&mut app, ui.ctx());
show(&mut app, ui.ctx());
})
.textures_delta
.clear();
assert_eq!(app.updates_rt.updater.status(), &Status::InstallFailed("the disk is full".into()));
answer(&mut app.updates_rt, CheckResultResponse::CheckAgain);
assert!(app.updates_rt.check_requested);
let _ = std::fs::remove_dir_all(&root);
}
} }
+17
View File
@@ -16,3 +16,20 @@ fn show_presenter_answers_on_and_window() {
let doc = crate::UI_COMMANDS.iter().find(|c| c.0 == "show.presenter").unwrap().3; let doc = crate::UI_COMMANDS.iter().find(|c| c.0 == "show.presenter").unwrap().3;
assert!(doc.contains("separate window"), "{doc}"); assert!(doc.contains("separate window"), "{doc}");
} }
#[test]
fn presenter_view_needs_a_second_display() {
// One display: the presenter window covered the show (or the show covered it) and the
// hidden window stalled every frame.
let presenter = |displays: Option<usize>, setting: bool| {
let services = Services { displays: Some(Box::new(move || displays)), ..Default::default() };
let mut app = SlideApp::new(Session::with_new(), services);
app.ui.presenter_view = setting;
app.start_show(0, false);
app.show.as_ref().map(|s| s.presenter)
};
assert_eq!(presenter(Some(1), true), Some(false));
assert_eq!(presenter(Some(2), true), Some(true));
assert_eq!(presenter(None, true), Some(true), "a host that can't count displays keeps Presenter View");
assert_eq!(presenter(Some(2), false), Some(false), "the setting still turns it off");
}
+1 -1
View File
@@ -164,7 +164,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) {
deckcraft_model::AnimStart::WithPrevious => "With Previous", deckcraft_model::AnimStart::WithPrevious => "With Previous",
deckcraft_model::AnimStart::AfterPrevious => "After Previous", deckcraft_model::AnimStart::AfterPrevious => "After Previous",
}); });
dropdown(ui, "astart", s.unwrap_or(""), 112.0, cur.is_some(), |ui| { dropdown(ui, "Start", s.unwrap_or(""), 112.0, cur.is_some(), |ui| {
for (l, v) in [("On Click", "onClick"), ("With Previous", "withPrevious"), ("After Previous", "afterPrevious")] { for (l, v) in [("On Click", "onClick"), ("With Previous", "withPrevious"), ("After Previous", "afterPrevious")] {
if ui.button(l).clicked() { if ui.button(l).clicked() {
run(app, "animation.timing", json!({"start": v})); run(app, "animation.timing", json!({"start": v}));
+2 -2
View File
@@ -171,7 +171,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
let size = st.get("size").and_then(Value::as_f64); let size = st.get("size").and_then(Value::as_f64);
let fams = font_families(app); let fams = font_families(app);
let theme_fonts: Vec<String> = fams.iter().take(2).cloned().collect(); let theme_fonts: Vec<String> = fams.iter().take(2).cloned().collect();
dropdown(ui, "font", &font, 150.0, en, |ui| { dropdown(ui, "Font", &font, 150.0, en, |ui| {
for (k, f) in fams.iter().enumerate() { for (k, f) in fams.iter().enumerate() {
let label = if k == 0 && theme_fonts.len() == 2 { let label = if k == 0 && theme_fonts.len() == 2 {
format!("{f} (Headings)") format!("{f} (Headings)")
@@ -196,7 +196,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
} }
}); });
let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default(); let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default();
dropdown(ui, "size", &size_text, 52.0, en, |ui| { dropdown(ui, "Font size", &size_text, 52.0, en, |ui| {
for s in deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) { for s in deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) {
if ui.button(widgets::fmt_num(*s, "")).clicked() { if ui.button(widgets::fmt_num(*s, "")).clicked() {
run(app, "format.size", json!({"size": s})); run(app, "format.size", json!({"size": s}));
+2
View File
@@ -71,4 +71,6 @@ pub struct Services {
pub clipboard_set_html: Option<Box<dyn FnMut(&str, &str) -> bool>>, pub clipboard_set_html: Option<Box<dyn FnMut(&str, &str) -> bool>>,
/// Audio output for media playback (cpal on desktop); without one media plays silently. /// Audio output for media playback (cpal on desktop); without one media plays silently.
pub audio_out: Option<Box<dyn deckcraft_media::AudioOut>>, pub audio_out: Option<Box<dyn deckcraft_media::AudioOut>>,
/// How many displays are connected, when the host can tell: Presenter View needs a second.
pub displays: Option<Box<dyn FnMut() -> Option<usize>>>,
} }
+98
View File
@@ -378,6 +378,54 @@ fn title_bar_export_opens_the_export_dialog() {
assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export")); assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export"));
} }
/// A browser's Copy Image puts the picture on the clipboard with text that only names it: the
/// address (and the alt text), and an `<img>` fragment as HTML. Ctrl+V pastes the picture, as
/// PowerPoint does, instead of a text box with the link. Without a picture the link pastes as text.
#[test]
fn ctrl_v_pastes_the_picture_a_browser_copied_not_its_link() {
let url = "https://github.com/storytold/deckcraft/raw/main/assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png";
let caption = format!("{url}\nDeckCraft icon: a presentation screen with a bar chart on an orange tile");
for (text, html, with_picture, pictures_after) in [
(caption.clone(), Some(format!("<img src=\"{url}\" alt=\"DeckCraft icon\">")), true, 1),
(url.to_string(), None, true, 1),
// Firefox's text/x-moz-url is UTF-16; read as UTF-8 it carries NULs.
(url.chars().flat_map(|c| [c, '\0']).collect(), None, true, 1),
(url.to_string(), None, false, 0),
("See https://example.com for the photos".to_string(), None, true, 0),
] {
let session = Session::with_new();
let png = {
let d = session.active().unwrap();
deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0
};
let services = Services {
clipboard_image: Some(Box::new(move || with_picture.then(|| png.clone()))),
clipboard_html: Some(Box::new(move || html.clone())),
..Default::default()
};
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|ui, app: &mut SlideApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SlideApp::new(session, services),
);
harness.run_steps(2);
let shapes_before = harness.state().session.active().unwrap().doc.slides[0].shapes.len();
harness.event(egui::Event::Paste(text.clone()));
harness.run_steps(2);
let d = harness.state().session.active().unwrap();
let shapes = &d.doc.slides[0].shapes;
let pictures = shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count();
assert_eq!(pictures, pictures_after, "{text:?}");
assert_eq!(shapes.len(), shapes_before + 1, "one shape, picture or text, for {text:?}");
if pictures_after == 0 {
let pasted = shapes.last().and_then(|s| s.text.as_ref()).map(deckcraft_model::TextBody::text);
assert_eq!(pasted.as_deref(), Some(text.as_str()), "the text survives");
}
}
}
/// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and /// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and
/// swallows the key press, so only the V release reaches the app. /// swallows the key press, so only the V release reaches the app.
#[test] #[test]
@@ -1175,6 +1223,56 @@ fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> {
h h
} }
/// #1855: text pasted into the focused speaker-notes field lands at its caret. While a stock
/// field has the focus, `SlideApp::keyboard` leaves the batch to it and nothing in the app drops
/// `Paste`, whether it arrives alone or inside the ⌘V batch egui-winit sends (V down, Paste, V up).
#[test]
fn paste_reaches_the_focused_notes_field() {
let mut h = step_app(Services::default());
let id = egui::Id::unique("notes_edit");
h.ctx.memory_mut(|m| m.request_focus(id));
h.run_steps(2);
assert!(h.ctx.memory(|m| m.has_focus(id)), "the notes field has the focus");
h.event(egui::Event::Text("ab".into()));
h.run_steps(2);
h.event(egui::Event::Paste("PASTED".into()));
h.run_steps(2);
assert_eq!(h.state().notes_buf.2.text, "abPASTED", "a bare Paste event");
let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND };
h.input_mut().events.extend([v(true), egui::Event::Paste("TWO".into()), v(false)]);
h.run_steps(2);
assert_eq!(h.state().notes_buf.2.text, "abPASTEDTWO", "the ⌘V batch");
// Leaving the field commits the notes to the slide.
h.ctx.memory_mut(|m| m.surrender_focus(id));
h.run_steps(3);
assert_eq!(h.state().session.active().unwrap().doc.slides[0].notes_text(), "abPASTEDTWO");
}
/// #1956: the ribbon's drop-downs are announced by their purpose, not by their value or an
/// internal key. Animations › Start with a shape that has no animation was "astart"; Home's Font
/// and Font size boxes were "font" and "size" while nothing told them a font or size.
#[test]
fn ribbon_dropdowns_are_named_by_purpose_even_when_empty() {
use egui::accesskit::Role;
let mut h = step_app(Services::default());
// A fresh shape, selected but not edited and without an animation.
h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0]})).unwrap();
h.state_mut().run("text.exit", json!({})).unwrap();
h.state_mut().ui.tab = "Animations".into();
h.run_steps(2);
let start = h.get_by_role_and_label(Role::ComboBox, "Start");
assert_eq!(start.value(), None, "no animation, no start value");
assert!(h.query_by_label("astart").is_none(), "the internal key is not a name");
h.state_mut().ui.tab = "Home".into();
h.state_mut().run("edit.select", json!({"ids": []})).unwrap();
h.run_steps(2);
for (name, key) in [("Font", "font"), ("Font size", "size")] {
assert!(h.query_by_role_and_label(Role::ComboBox, name).is_some(), "{name} is named while empty");
assert!(h.query_by_role_and_label(Role::ComboBox, key).is_none(), "{key} is not announced");
}
}
/// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold. /// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold.
#[test] #[test]
fn copy_offers_html_and_paste_keeps_bold() { fn copy_offers_html_and_paste_keeps_bold() {
+18 -7
View File
@@ -330,7 +330,9 @@ pub fn rows<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
} }
/// A text dropdown field (font name, size): shows `current`, opens a popup with `add_items`. /// A text dropdown field (font name, size): shows `current`, opens a popup with `add_items`.
pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui)) -> Response { /// `label` is what assistive technology calls it ("Font", "Start"); `current` is exposed as its
/// value, so an empty box is still announced by its purpose and never by an internal key (#1956).
pub fn dropdown(ui: &mut Ui, label: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui)) -> Response {
let t = Tokens::get(ui.ctx()); let t = Tokens::get(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(width, 22.0), if enabled { Sense::click() } else { Sense::hover() }); let (rect, resp) = ui.allocate_exact_size(vec2(width, 22.0), if enabled { Sense::click() } else { Sense::hover() });
let painter = ui.painter_at(rect); let painter = ui.painter_at(rect);
@@ -350,8 +352,10 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool,
if enabled { t.text } else { t.text_faint }, if enabled { t.text } else { t.text_faint },
); );
chevron(&painter, pos2(rect.max.x - 9.0, rect.center().y), 3.0, t.text_dim); chevron(&painter, pos2(rect.max.x - 9.0, rect.center().y), 3.0, t.text_dim);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, if current.is_empty() { id } else { current })); resp.widget_info(|| egui::WidgetInfo {
let _ = id; current_text_value: (!current.is_empty()).then(|| current.to_string()),
..egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, label)
});
egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| { egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items); egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items);
}); });
@@ -742,19 +746,26 @@ mod tests {
} }
/// The custom ribbon dropdown is a real combo box in the accessibility tree, named by its /// The custom ribbon dropdown is a real combo box in the accessibility tree, named by its
/// current value (0.37's strict unnamed-input check would otherwise reject it). /// purpose with the shown text as its value. An empty one (no selection to read a font or
/// size from) keeps its name rather than falling back to an internal key (#1956).
#[test] #[test]
fn the_dropdown_has_a_combo_box_role_and_a_name() { fn the_dropdown_is_named_by_its_purpose_and_carries_its_value() {
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| { let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| {
theme::install_fonts(ui.ctx()); theme::install_fonts(ui.ctx());
dropdown(ui, "font-size", "18 pt", 120.0, true, |ui| { dropdown(ui, "Font size", "18 pt", 120.0, true, |ui| {
ui.label("12 pt"); ui.label("12 pt");
ui.label("18 pt"); ui.label("18 pt");
}); });
dropdown(ui, "Start", "", 112.0, false, |_| {});
}); });
harness.run(); harness.run();
let combo = harness.get_by_role_and_label(Role::ComboBox, "18 pt"); let combo = harness.get_by_role_and_label(Role::ComboBox, "Font size");
assert_eq!(combo.value().as_deref(), Some("18 pt"));
assert!(combo.rect().width() > 100.0); assert!(combo.rect().width() > 100.0);
assert!(harness.query_by_role_and_label(Role::ComboBox, "18 pt").is_none(), "the value is not its name");
let empty = harness.get_by_role_and_label(Role::ComboBox, "Start");
assert_eq!(empty.value(), None, "nothing shown, no value");
assert!(harness.query_by_label("astart").is_none() && harness.query_by_label("start").is_none(), "no internal key is announced");
} }
/// #32: clicking the "More Colors" hex field closed the colour menu before anything could be /// #32: clicking the "More Colors" hex field closed the colour menu before anything could be
+2 -1
View File
@@ -8,7 +8,8 @@ the AI models named in `Co-Authored-By` trailers.
- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from - Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from
disk or the network at run time; the web build has them too): disk or the network at run time; the web build has them too):
- [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub
username. Never hand-edit. username. Never hand-edit. Release builds regenerate it for the release commit
(`.github/workflows/release.yml`, `credits` job); the copy in the repository is not updated.
- [`contributors/people.toml`](../contributors/people.toml): the names people chose for - [`contributors/people.toml`](../contributors/people.toml): the names people chose for
themselves. **Each contributor adds only their own entry**, in their own PR. themselves. **Each contributor adds only their own entry**, in their own PR.
- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced - Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced
+1 -1
View File
@@ -84,7 +84,7 @@ the round trip even though DeckCraft doesn't model it.
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Run `lang` and `altLang` are independently retained through native and PPTX saves; other round-trip tests cover `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) | | Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Run `lang` and `altLang` are independently retained through native and PPTX saves; other round-trip tests cover `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) |
| Effects: shadow, glow, soft edges, reflection | yes | yes | On a run, inner shadow, reflection and soft edges are kept along with the outer shadow and glow | | Effects: shadow, glow, soft edges, reflection | yes | yes | On a run, inner shadow, reflection and soft edges are kept along with the outer shadow and glow |
| 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. Bevels, cameras, face lighting and extrusion sides draw approximately | | 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. `scene3d`, `sp3d` and `flatTx` on a text body (`a:bodyPr`) and `scene3d` on a group (`p:grpSpPr`) are kept as raw XML too. Bevels, cameras, face lighting and extrusion sides draw approximately |
| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
| Tables with styles | yes | yes | Merges, both diagonal borders and optional RTL column direction round-trip; Reuse Slides transfers definitions referenced by source tables, including grouped tables, with conflicting ids remapped | | Tables with styles | yes | yes | Merges, both diagonal borders and optional RTL column direction round-trip; Reuse Slides transfers definitions referenced by source tables, including grouped tables, with conflicting ids remapped |
+3 -2
View File
@@ -1,6 +1,6 @@
# Where DeckCraft falls short of PowerPoint # Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-03 · **Change:** trivial (run alternate language and reflection transforms survive saves; reused slide contents scale to the destination, picker thumbnails remain a gap; previous: trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-04 · **Change:** trivial (workflow-breaking regression fixed: Ctrl+C/X/V use the system clipboard again; #1414 to re-test on Windows; previous: trivial, run alternate language and reflection transforms survive saves; reused slide contents scale to the destination, picker thumbnails remain a gap; previous: trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every known shortfall, one entry each, ranked by how much it stops someone who presents with Every known shortfall, one entry each, ranked by how much it stops someone who presents with
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
@@ -161,7 +161,7 @@ Detail in [file-format-parity.md](file-format-parity.md).
- **Estimate:** 3–5 h. **Beta blocker.** - **Estimate:** 3–5 h. **Beta blocker.**
### Image paste fails on Windows (#1414) ### Image paste fails on Windows (#1414)
- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. Linux was not re-checked after #53 closed. - **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. From the egui 0.37 migration (#1350, v0.11.1) to 0.15.0 the desktop build left out eframe's `clipboard` feature, so egui simulated a clipboard inside the app and Ctrl+V never read the system clipboard on any platform. Fixed 2026-11-01, as is a browser's Copy Image on Linux, which pasted its link because Ctrl+V preferred any text over the picture. #1414 may have either cause: re-test on Windows.
- **Impact:** copy-paste of screenshots is a core workflow. - **Impact:** copy-paste of screenshots is a core workflow.
- **Estimate:** 2–4 h. **Beta blocker.** - **Estimate:** 2–4 h. **Beta blocker.**
@@ -371,6 +371,7 @@ Detail in [localization-parity.md](localization-parity.md).
| 2026-11-02 | trivial | Run alternate-language metadata now survives native and PPTX saves; UI and proofing gaps remain; scores unchanged | | 2026-11-02 | trivial | Run alternate-language metadata now survives native and PPTX saves; UI and proofing gaps remain; scores unchanged |
| 2026-11-02 | trivial | PPTX reflection geometry is retained through native and PPTX resaves; no rendering parity claim | | 2026-11-02 | trivial | PPTX reflection geometry is retained through native and PPTX resaves; no rendering parity claim |
| 2026-11-02 | trivial | Reused slide geometry and content scale to the destination; picker thumbnails remain the reuse gap | | 2026-11-02 | trivial | Reused slide geometry and content scale to the destination; picker thumbnails remain the reuse gap |
| 2026-11-01 | trivial | Workflow-breaking regression: image paste entry: the OS clipboard was off from #1350 to 0.15.0; Linux re-checked, Windows #1414 to re-test |
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
| 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added | | 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added |
| 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated | | 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated |
+3 -2
View File
@@ -1,6 +1,6 @@
# Hardware parity with PowerPoint # Hardware parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (community PR wave 2026-10-14: picture export DPI, Retina pixel grid (#184), twist to rotate, swipe to turn the page, pen pressure; duplicated rows and headers folded; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-02 · **Change:** minor (Presenter View only with a second display on Linux and the BSDs, #1156; previous: minor, community PR wave 2026-10-14: picture export DPI, Retina pixel grid (#184), twist to rotate, swipe to turn the page, pen pressure; duplicated rows and headers folded; previous: minor, IME input, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365); PowerPoint for Windows where noted
What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40% What hardware PowerPoint uses and what DeckCraft uses, per platform. Dimension score: **~40%
ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too ready** (estimated), **25–45 h** to parity. Performance (unmeasured, ~55%) is tracked here too
@@ -34,7 +34,7 @@ because it mostly follows from hardware use.
| Feature | PowerPoint | DeckCraft | | Feature | PowerPoint | DeckCraft |
|---|---|---| |---|---|---|
| Detect the projector, show on it, presenter view on the laptop | yes | **no**: the show goes full screen on the current display; presenter view opens as a normal window (egui viewport) | | Detect the projector, show on it, presenter view on the laptop | yes | **no**: the show goes full screen on the current display; presenter view opens as a normal window (egui viewport). On Linux and the BSDs, when winit lists exactly one display, the show opens without Presenter View, as in PowerPoint; if it lists none, the setting is kept (#1156) |
| Swap displays, choose the monitor in Set Up Show | yes | no | | Swap displays, choose the monitor in Set Up Show | yes | no |
| Mirroring-aware behaviour | yes | no | | Mirroring-aware behaviour | yes | no |
@@ -73,6 +73,7 @@ alignment runs once per transition (#67), and the decoders run in software.
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-11-02 | minor | On Linux and the BSDs, Presenter View stays off when winit lists exactly one display: the presenter window covered the show and stalled it. If winit lists none, the setting is kept (#1156) |
| 2026-10-14 | minor | PNG and JPEG slide export record 144 dpi at the default 2 pixels per point, so a Retina picture opens at the slide's size | | 2026-10-14 | minor | PNG and JPEG slide export record 144 dpi at the default 2 pixels per point, so a Retina picture opens at the slide's size |
| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) | | 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) |
| 2026-10-14 | trivial | Community PR wave folded: one row each for pinch/two-finger gestures and pen pressure | | 2026-10-14 | trivial | Community PR wave folded: one row each for pinch/two-finger gestures and pen pressure |
+2 -2
View File
@@ -42,7 +42,7 @@ The summary between `<!-- SUMMARY -->
| Automation (✱) | 100% | | Automation (✱) | 100% |
| Platforms (✱) | 67% | | Platforms (✱) | 67% |
Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows, #1414; Linux not re-checked), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds. Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows, #1414, re-test after the 2026-11-01 OS-clipboard fix), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
## Rows ## Rows
@@ -193,7 +193,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Pictures | Picture styles and borders | P1 | D | | Pictures | Picture styles and borders | P1 | D |
| Pictures | Change / reset picture | P1 | D | | Pictures | Change / reset picture | P1 | D |
| Pictures | Compress pictures | P2 | D | | Pictures | Compress pictures | P2 | D |
| Pictures | Paste image from clipboard (fails on Windows, #1414; Linux not re-checked) | P0 | P | | Pictures | Paste image from clipboard (fails on Windows, #1414, re-test after the 2026-11-01 OS-clipboard fix) | P0 | P |
| Pictures | Insert screenshot | P2 | M | | Pictures | Insert screenshot | P2 | M |
| Media | Insert audio (any common format) | P0 | D | | Media | Insert audio (any common format) | P0 | D |
| Media | Insert video | P1 | D | | Media | Insert video | P1 | D |
+3 -2
View File
@@ -1,6 +1,6 @@
# Slide show and presenter view: parity with PowerPoint # Slide show and presenter view: parity with PowerPoint
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-02 · **Change:** minor (deleting slides removes them from custom shows; previous: minor, Set Up Slide Show ▸ Keep mouse cursor visible, #1445; previous: minor, Ctrl+wheel zooms the show around the pointer, #1286; previous: major, community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-02 · **Change:** minor (deleting slides removes them from custom shows; previous: minor, Presenter View only with a second display on Linux and the BSDs, #1156; previous: minor, Set Up Slide Show ▸ Keep mouse cursor visible, #1445; previous: minor, Ctrl+wheel zooms the show around the pointer, #1286; previous: major, community PR wave 2026-10-14: interactive decks, laser pointer, See All Slides and zoom, go-to-slide number, custom show playback, presenter timer, clock, notes size and keyboard, Show Media Controls, swipe; breadth 68%; duplicated rows and headers folded; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view, Presenting is the job PowerPoint is hired for. This doc covers the show window, presenter view,
rehearsal and recording. Transition and animation playback are in rehearsal and recording. Transition and animation playback are in
@@ -41,7 +41,7 @@ ready** (estimated), **10–16 h** to parity.
| Feature | PowerPoint | DeckCraft | Notes | | Feature | PowerPoint | DeckCraft | Notes |
|---|---|---|---| |---|---|---|---|
| Separate presenter window with current slide, next slide, notes | yes | yes (an egui viewport, 1100×700); the notes keep their bullets, numbers, indents and bold/italic, as in the notes pane | | | Separate presenter window with current slide, next slide, notes | yes | yes (an egui viewport, 1100×700); the notes keep their bullets, numbers, indents and bold/italic, as in the notes pane | On Linux and the BSDs it stays off if winit lists exactly one display as the show starts, as in PowerPoint: on one display it covered the show, and the hidden window stalled every frame (#1156). If winit lists none, the setting is kept. There is not yet a way to switch to Presenter View by hand during a one-display show. macOS, Windows and the browser are unchanged |
| Elapsed timer | yes | yes | | | Elapsed timer | yes | yes | |
| Pause / reset timer, clock | yes | yes: Pause/Resume and Reset in presenter view (T pauses, R resets); the clock is local time, `--:--` where there is no clock | Pausing the timer does not pause the show, animations or rehearsal | | Pause / reset timer, clock | yes | yes: Pause/Resume and Reset in presenter view (T pauses, R resets); the clock is local time, `--:--` where there is no clock | Pausing the timer does not pause the show, animations or rehearsal |
| Slide counter, previous/next/end buttons | yes | yes | | | Slide counter, previous/next/end buttons | yes | yes | |
@@ -79,6 +79,7 @@ Reading View in a window with navigation: present (checklist row done).
| Date | Change | Summary | | Date | Change | Summary |
|---|---|---| |---|---|---|
| 2026-11-02 | minor | On Linux and the BSDs, Presenter View stays off when winit lists exactly one display; if it lists none, the setting is kept (#1156) |
| 2026-10-27 | minor | Set Up Slide Show ▸ Keep mouse cursor visible: full-screen shows keep a still pointer, so clickable choices stay targetable; Reading View unchanged (#1445) | | 2026-10-27 | minor | Set Up Slide Show ▸ Keep mouse cursor visible: full-screen shows keep a still pointer, so clickable choices stay targetable; Reading View unchanged (#1445) |
| 2026-10-22 | minor | See All Slides from presenter view opens in presenter view, not on the audience screen (#1130) | | 2026-10-22 | minor | See All Slides from presenter view opens in presenter view, not on the audience screen (#1130) |
| 2026-10-14 | trivial | Community PR wave folded: one media playback row, the presenter pen/laser row matches the laser that exists, estimates keep only the open work (~9–16 h); checklist breadth 68% | | 2026-10-14 | trivial | Community PR wave folded: one media playback row, the presenter pen/laser row matches the laser that exists, estimates keep only the open work (~9–16 h); checklist breadth 68% |
+16 -2
View File
@@ -86,11 +86,11 @@ Messages are plain objects with a `type`:
| Message | Direction | Fields | | Message | Direction | Fields |
|---|---|---| |---|---|---|
| `deckcraft:ready` | to the page | `version`. The app runs and listens: send the file now. | | `deckcraft:ready` | to the page | `version`, `appVersion`. The app runs and listens: send the file now. |
| `deckcraft:open` | from the page | `name` (a bare file name, such as `Talk.pptx`), `bytes` (`ArrayBuffer` or `Uint8Array`). Opens like File › Open: a presentation (.pptx, .potx, .ppsx, .deckcraft) has that name as its file; an outline (.txt, .md, .rtf), a file sent without a name and a damaged file open untitled. | | `deckcraft:open` | from the page | `name` (a bare file name, such as `Talk.pptx`), `bytes` (`ArrayBuffer` or `Uint8Array`). Opens like File › Open: a presentation (.pptx, .potx, .ppsx, .deckcraft) has that name as its file; an outline (.txt, .md, .rtf), a file sent without a name and a damaged file open untitled. |
| `deckcraft:save` | to the page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`), for every file DeckCraft would otherwise download. The page stores it. | | `deckcraft:save` | to the page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`), for every file DeckCraft would otherwise download. The page stores it. |
| `deckcraft:dirty` | to the page | `dirty`, whenever unsaved changes appear or go away. | | `deckcraft:dirty` | to the page | `dirty`, whenever unsaved changes appear or go away. |
| `deckcraft:failed` | to the page | `error`, when the app couldn't start (no WebGPU or WebGL2). | | `deckcraft:failed` | to the page | `error`, when the app couldn't start (no WebGPU or WebGL2) or refused a file of 2 GiB or larger. |
`saveAs` says whether the file becomes the presentation's file, as on the desktop: `saveAs` says whether the file becomes the presentation's file, as on the desktop:
@@ -134,6 +134,20 @@ beside it. The page guards leaving itself (DeckCraft adds no guard of its own),
</script> </script>
``` ```
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `deckcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. DeckCraft reads bytes, so a host sends `bytes`, not a `File`.
### Driving it from the page (`window.deckcraft`)
Every DeckCraft web build puts `window.deckcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `deckcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects.
```js
const deck = await deckcraft.docInspect(); // a summary of the open presentation
await deckcraft.execute("shape.insert", { preset: "roundRect", rect: [100, 100, 240, 120], text: "Hello" });
const { data } = await deckcraft.screenshot(); // the canvas as PNG (base64)
```
The API is there in every mode, with or without a host. Opening a file, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
## Renderer selection and fallback flags ## Renderer selection and fallback flags
DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
+4 -3
View File
@@ -6,7 +6,8 @@ use std::process::Command;
use serde_json::Value; use serde_json::Value;
const SHARED_REV: &str = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7"; const SHARED_REPO: &str = "https://github.com/storytold/craft-libs";
const SHARED_REV: &str = "1b34eba41e62138fc2438d2734523b302840b45d";
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index"; const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
pub fn run(root: &Path) -> Result<(), String> { pub fn run(root: &Path) -> Result<(), String> {
@@ -70,7 +71,7 @@ fn validate(metadata: &Value) -> Result<(), String> {
} }
matches.first().copied().ok_or_else(|| format!("missing {name}")) matches.first().copied().ok_or_else(|| format!("missing {name}"))
}; };
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}"); let source = format!("git+{SHARED_REPO}?rev={SHARED_REV}#{SHARED_REV}");
for (name, version, expected_source, expected_features) in [ for (name, version, expected_source, expected_features) in [
("craft-pdf", "0.1.0", source.as_str(), vec!["write", "write-simple-text"]), ("craft-pdf", "0.1.0", source.as_str(), vec!["write", "write-simple-text"]),
("krilla", "0.8.2", source.as_str(), vec!["raster-images", "rayon", "simple-text"]), ("krilla", "0.8.2", source.as_str(), vec!["raster-images", "rayon", "simple-text"]),
@@ -143,7 +144,7 @@ mod tests {
use serde_json::json; use serde_json::json;
fn metadata() -> Value { fn metadata() -> Value {
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}"); let source = format!("git+{SHARED_REPO}?rev={SHARED_REV}#{SHARED_REV}");
json!({ json!({
"packages": [ "packages": [
{"id":"app", "name":"deckcraft-pdf"}, {"id":"app", "name":"deckcraft-pdf"},