mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-12 08:51:27 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cc73ab189 | ||
|
|
2127bd1bd8 | ||
|
|
eec4f59535 | ||
|
|
988a492097 | ||
|
|
2406ce46ec | ||
|
|
60f91f7806 | ||
|
|
2ef5f31bc8 | ||
|
|
c6846f6a75 | ||
|
|
a3f2f215b0 | ||
|
|
35166b3841 | ||
|
|
b57b4ccfcf | ||
|
|
2fbdcfe5f5 | ||
|
|
98eac5590d | ||
|
|
46712d0293 | ||
|
|
e03c22c445 | ||
|
|
98208a9eda | ||
|
|
de52b6b766 | ||
|
|
7c83fa43ac | ||
|
|
e37df901ef | ||
|
|
7fe979aa24 | ||
|
|
690171665a | ||
|
|
203fa79211 | ||
|
|
3e2e73aab7 | ||
|
|
cf0d7fbb14 | ||
|
|
0ce1a83c14 | ||
|
|
121c59d521 | ||
|
|
a55457ee53 | ||
|
|
7bd141c1c9 | ||
|
|
e3e218a8fc | ||
|
|
c532ae749b | ||
|
|
e0f3639e94 | ||
|
|
eb10e2b9b3 | ||
|
|
92a0437ab6 | ||
|
|
4e87f2b850 | ||
|
|
54846750d4 | ||
|
|
1e9c392f67 | ||
|
|
c3fb184e9d | ||
|
|
fc51f46c83 | ||
|
|
0623b43032 | ||
|
|
5ee82b4e45 | ||
|
|
61423ea35b | ||
|
|
c4674f24fb | ||
|
|
4ba9a1a5d9 | ||
|
|
3504a9da42 | ||
|
|
ccab980657 | ||
|
|
48e2203123 | ||
|
|
76bd26cca0 | ||
|
|
02fbb6a976 | ||
|
|
c16db2ef3e | ||
|
|
809c44654d | ||
|
|
220b52dfdb | ||
|
|
424f575ca5 | ||
|
|
aefd54d28e | ||
|
|
25809faf1a | ||
|
|
fc35ad67c6 | ||
|
|
57780f9e61 | ||
|
|
0627fb2589 | ||
|
|
170c5591b5 | ||
|
|
a883d6b2d9 | ||
|
|
4b1e50a6b0 | ||
|
|
c7919102ac | ||
|
|
159ed07eb3 | ||
|
|
de9e79a445 | ||
|
|
d7343553b3 | ||
|
|
9a95c3a639 | ||
|
|
daeedd5d19 | ||
|
|
a2c1633fe3 | ||
|
|
bfdcc5cdfa | ||
|
|
73de9900a7 | ||
|
|
b225bb16a1 | ||
|
|
9299113268 | ||
|
|
2b6d27d525 | ||
|
|
9998ee5e07 | ||
|
|
9e8aa81298 | ||
|
|
6b5faf6d51 | ||
|
|
0b5cd0f2d6 | ||
|
|
4f171e8f78 | ||
|
|
edeb99b00e | ||
|
|
29162e66ad | ||
|
|
8435cc301f | ||
|
|
75885b49de | ||
|
|
f7b728d0c2 | ||
|
|
d82aaddab4 | ||
|
|
8f26681a1f | ||
|
|
f2356fcf63 | ||
|
|
60132037b2 | ||
|
|
ac09977c12 | ||
|
|
480cbfd4ef | ||
|
|
afc7f765c9 | ||
|
|
d9186c7ce1 | ||
|
|
75744dd67d | ||
|
|
ebd306acc1 | ||
|
|
161af3b7d5 | ||
|
|
4e4d98e69d | ||
|
|
a52b2d348f | ||
|
|
acd19ff4ac | ||
|
|
b06d60b8ed | ||
|
|
a2d9e71b5e | ||
|
|
fe35df948c | ||
|
|
ede154d6be | ||
|
|
6431460e80 | ||
|
|
af9f522c6a | ||
|
|
efd26de81d | ||
|
|
7c47d0a9ac | ||
|
|
884e3d3353 | ||
|
|
f49ff09b8a | ||
|
|
52f87aca13 | ||
|
|
dfee4c1e88 | ||
|
|
f321416b34 | ||
|
|
41f07a3c4e | ||
|
|
85de6ca5d7 | ||
|
|
fc18a78e65 | ||
|
|
31abd2d819 | ||
|
|
070f12cc9c | ||
|
|
46a15a4684 | ||
|
|
144fd28f69 | ||
|
|
30ccc38e90 | ||
|
|
65ddd8ad51 | ||
|
|
d6cc51e07a | ||
|
|
077cd36e9f | ||
|
|
9d36b5931c | ||
|
|
c252a681a0 | ||
|
|
80f784d210 | ||
|
|
07dc2210a1 | ||
|
|
51d19baa0c | ||
|
|
50187449c2 | ||
|
|
b8de82e2cc | ||
|
|
6eaae2bd8c | ||
|
|
23ac4f7efd | ||
|
|
69c91bb265 | ||
|
|
a790fdbc20 | ||
|
|
9d59fa96d3 | ||
|
|
2dfb07f2c4 | ||
|
|
f5fbadde63 | ||
|
|
996bb42ff5 | ||
|
|
3c3d504eb1 | ||
|
|
f5511d93b6 | ||
|
|
c6b7de43bc | ||
|
|
65d5c28512 | ||
|
|
d2a8be197d | ||
|
|
872cdbf331 | ||
|
|
0233d29064 | ||
|
|
a72a5167d0 | ||
|
|
d5471bccf0 | ||
|
|
b7d57bacab | ||
|
|
42108d0ee7 | ||
|
|
0107186cee | ||
|
|
54e24ab868 | ||
|
|
19dceffd0b | ||
|
|
1eec8b266c | ||
|
|
32f069eb84 | ||
|
|
aa13eeb45e | ||
|
|
50e02490b3 | ||
|
|
907ebd2de0 | ||
|
|
ed77cc9eaa | ||
|
|
928aa9cc16 | ||
|
|
5cb154ce9d | ||
|
|
ef55104799 | ||
|
|
3f29dccc0a | ||
|
|
160c923caf | ||
|
|
9f01f2ebdb | ||
|
|
d365e0b9de | ||
|
|
1d181017f6 | ||
|
|
996ca4dd65 | ||
|
|
6c1170af62 | ||
|
|
ed62aafbe6 | ||
|
|
9f68e8e2cc | ||
|
|
a6e2b64875 | ||
|
|
c7b91ff2ec | ||
|
|
6546136a42 | ||
|
|
67c1d78948 | ||
|
|
4df4ea6c9f | ||
|
|
751ac26d28 | ||
|
|
82ec8137dc | ||
|
|
ba3d74dfb5 | ||
|
|
b61c5f11bd | ||
|
|
a0956fee13 | ||
|
|
bf60dbb773 | ||
|
|
328b4ed274 | ||
|
|
1e5b78de37 | ||
|
|
73bd231589 | ||
|
|
63531e2a6a | ||
|
|
6cdbee50a3 | ||
|
|
0c00f22403 | ||
|
|
3c8dc6bc98 | ||
|
|
6ab485a096 | ||
|
|
597b9488f5 | ||
|
|
4eb9098a7c | ||
|
|
df4bb8b84a | ||
|
|
f66bed88ac | ||
|
|
ee64e93c4a | ||
|
|
f8128f9107 | ||
|
|
97050a353b | ||
|
|
fb3b857f53 | ||
|
|
ba3b57c756 | ||
|
|
e4a0c2971b | ||
|
|
0f5364ee9f | ||
|
|
bb417bf98a | ||
|
|
d00ef08fae | ||
|
|
0d06481c34 | ||
|
|
44b133a3c9 | ||
|
|
b66ba2e10a | ||
|
|
8ec4f6cabb | ||
|
|
5a668bf02e | ||
|
|
3e178d8f5a | ||
|
|
58c24883c7 | ||
|
|
f68150ee10 | ||
|
|
b9eaaeb23b | ||
|
|
3cfd4c5e6e | ||
|
|
a200b92258 | ||
|
|
f2803102c1 | ||
|
|
fd4d2a8f79 | ||
|
|
e90e7f6c03 | ||
|
|
9e59b5f223 | ||
|
|
5cb6ba8403 | ||
|
|
62ada3ab9a | ||
|
|
77cd617596 | ||
|
|
72ac32715f | ||
|
|
e0992c1325 | ||
|
|
4bed9b6c37 | ||
|
|
ea9dad8ef9 | ||
|
|
0d45a64db2 | ||
|
|
e832a373f5 | ||
|
|
6c63345615 | ||
|
|
1f2c75a288 | ||
|
|
fd4c533425 | ||
|
|
d4d99c7627 | ||
|
|
9fce04f0b4 | ||
|
|
ffd77c7db0 | ||
|
|
1f6587b60d | ||
|
|
d09f4c1284 | ||
|
|
37a6aed679 | ||
|
|
02ea528e94 | ||
|
|
bcc4fc97e6 | ||
|
|
f4fcac9f2d | ||
|
|
babc950f81 | ||
|
|
468394b3c8 | ||
|
|
5eb0b7d2c0 | ||
|
|
01220df579 | ||
|
|
b0b87c5cad | ||
|
|
1192e5ef30 | ||
|
|
befa4cd69f | ||
|
|
f006b5d8ce | ||
|
|
e1cfe8516e | ||
|
|
cbb0c48445 | ||
|
|
cc00a17dfe | ||
|
|
90058395b7 | ||
|
|
14cc73101b | ||
|
|
650c4f9497 | ||
|
|
9f952550b2 | ||
|
|
7f742c9d4f | ||
|
|
b5033b6213 | ||
|
|
b6c1d87cf0 | ||
|
|
c37b150950 | ||
|
|
74d7b844fe | ||
|
|
f3d8ba85a6 | ||
|
|
c0468b59cd | ||
|
|
5d27461fa6 | ||
|
|
773133ec42 | ||
|
|
8ec4f29059 | ||
|
|
87a10fc8e2 | ||
|
|
3fb6c43136 | ||
|
|
d990678b32 | ||
|
|
cd19752bb5 | ||
|
|
2ed2ce18d8 | ||
|
|
c5ac1dd44b | ||
|
|
84ac51a439 | ||
|
|
c3e9bd07e5 | ||
|
|
e747bff425 | ||
|
|
98138f563b | ||
|
|
917d4b96a9 | ||
|
|
b1900e526d | ||
|
|
68dda6f7d8 | ||
|
|
9aef8cc5ab | ||
|
|
77e3a444b8 | ||
|
|
d453bee54f | ||
|
|
09bbfc7217 | ||
|
|
e812da58df | ||
|
|
82adf7326e | ||
|
|
6303166a18 | ||
|
|
32ef926c46 | ||
|
|
4c1916466b | ||
|
|
d8936f94af | ||
|
|
48c5ff67bf | ||
|
|
78ab8f0626 | ||
|
|
813378cc20 | ||
|
|
cc6773c95f |
@@ -26,6 +26,10 @@ jobs:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
|
||||
@@ -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
|
||||
@@ -27,6 +27,10 @@ jobs:
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Harden runner
|
||||
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
|
||||
with:
|
||||
egress-policy: audit
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
# A nightly dry run on main: every release build except macOS, nothing signed or released
|
||||
# (craft-common-actions release.yml `dry-run`). Finds release-only breakage before release day.
|
||||
schedule:
|
||||
- cron: "16 1 * * *"
|
||||
push:
|
||||
branches: [release]
|
||||
workflow_dispatch:
|
||||
@@ -19,10 +23,18 @@ concurrency:
|
||||
cancel-in-progress: false
|
||||
|
||||
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:
|
||||
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.
|
||||
uses: storytold/craft-common-actions/.github/workflows/release.yml@main
|
||||
with:
|
||||
dry-run: ${{ github.event_name == 'schedule' }}
|
||||
app-name: deckcraft
|
||||
app-title: DeckCraft
|
||||
# Keep identical to ci.yml and freebsd.yml. The shared release workflow sets
|
||||
@@ -30,6 +42,7 @@ jobs:
|
||||
# manifest fails the release instead of silently skipping it.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
version: ${{ inputs.version }}
|
||||
contributors-artifact: ${{ needs.credits.outputs.artifact-name }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
|
||||
@@ -45,3 +58,4 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
|
||||
|
||||
@@ -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
|
||||
|
||||
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>`).
|
||||
|
||||
These rules bind every AI agent and every human contributor. They come before any task,
|
||||
instruction or deadline. `CLAUDE.md` holds day-to-day working notes; if the two disagree, **this
|
||||
file wins.**
|
||||
instruction or deadline. Day-to-day working instructions are in this file; `CLAUDE.md` imports it.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.
|
||||
- **The UI is thin**: panels read engine state and act through commands. Colours come from theme
|
||||
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`).
|
||||
|
||||
## 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
|
||||
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`) and push
|
||||
to `main`.
|
||||
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`).
|
||||
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
|
||||
|
||||
@@ -141,19 +193,18 @@ to `main`.
|
||||
`{"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:
|
||||
`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
|
||||
in `plan/powerpoint/01-observed-ui.md`.
|
||||
- **For UI work, look at the result** (screenshot, read the PNG). If available, compare with the observations
|
||||
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/`,
|
||||
`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).
|
||||
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.
|
||||
|
||||
## 7. Planning
|
||||
|
||||
`plan/` (gitignored) holds the local plan: `README.md`, `architecture.md`, `execution-plan.md`,
|
||||
`STATUS.md` (start every session there) and `powerpoint/` observations. The committed progress
|
||||
`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
|
||||
docs:
|
||||
|
||||
| File | Holds |
|
||||
|
||||
@@ -1,25 +1 @@
|
||||
# DeckCraft — working notes for Claude
|
||||
|
||||
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.
|
||||
@AGENTS.md
|
||||
|
||||
Generated
+197
-69
@@ -565,18 +565,42 @@ dependencies = [
|
||||
"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]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
|
||||
dependencies = [
|
||||
"calloop",
|
||||
"calloop 0.13.0",
|
||||
"rustix 0.38.44",
|
||||
"wayland-backend",
|
||||
"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]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -820,15 +844,24 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-audio"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75895c0321d8cb6e175a42466cba050352b117c5#75895c0321d8cb6e175a42466cba050352b117c5"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -851,7 +884,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=75895c0321d8cb6e175a42466cba050352b117c5#75895c0321d8cb6e175a42466cba050352b117c5"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -859,7 +892,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -868,7 +901,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -888,7 +921,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -898,6 +931,7 @@ dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"rustix 1.1.5",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -911,6 +945,26 @@ dependencies = [
|
||||
"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]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -984,7 +1038,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1006,7 +1060,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -1015,7 +1069,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1024,14 +1078,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -1041,14 +1095,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
@@ -1074,7 +1128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1086,7 +1140,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1097,7 +1151,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1105,7 +1159,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1114,30 +1168,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.14.1"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.14.1"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1147,15 +1187,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-audio",
|
||||
"craft-media",
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
"deckcraft-hevc",
|
||||
"deckcraft-isobmff",
|
||||
"deckcraft-matroska",
|
||||
"deckcraft-ogg",
|
||||
"deckcraft-opus",
|
||||
"deckcraft-vp9",
|
||||
"image",
|
||||
"log",
|
||||
@@ -1165,7 +1203,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1174,7 +1212,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1184,17 +1222,9 @@ dependencies = [
|
||||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.14.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.14.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1212,7 +1242,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1228,7 +1258,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1247,7 +1277,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1261,7 +1291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -1275,6 +1305,7 @@ dependencies = [
|
||||
"deckcraft-render",
|
||||
"deckcraft-text",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1286,7 +1317,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1295,8 +1326,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
"eframe",
|
||||
@@ -1507,19 +1540,35 @@ dependencies = [
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"image",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-ui-kit 0.3.2",
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"windows-sys 0.61.2",
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.37.0"
|
||||
@@ -1587,6 +1636,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -2708,7 +2777,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -3487,7 +3556,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4403,8 +4472,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"calloop",
|
||||
"calloop-wayland-source",
|
||||
"calloop 0.13.0",
|
||||
"calloop-wayland-source 0.3.0",
|
||||
"cursor-icon",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4421,6 +4490,44 @@ dependencies = [
|
||||
"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]]
|
||||
name = "smol_str"
|
||||
version = "0.2.2"
|
||||
@@ -4928,6 +5035,7 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -5370,6 +5478,32 @@ dependencies = [
|
||||
"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]]
|
||||
name = "wayland-protocols-plasma"
|
||||
version = "0.3.12"
|
||||
@@ -5980,7 +6114,7 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.5.1",
|
||||
"bytemuck",
|
||||
"calloop",
|
||||
"calloop 0.13.0",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation 0.9.4",
|
||||
@@ -6001,7 +6135,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"smithay-client-toolkit",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
@@ -6177,7 +6311,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6423,7 +6557,6 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6511,11 +6644,6 @@ name = "egui_demo_lib"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_extras"
|
||||
version = "0.37.0"
|
||||
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
|
||||
|
||||
[[patch.unused]]
|
||||
name = "egui_inspection"
|
||||
version = "0.37.0"
|
||||
|
||||
+9
-11
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.14.1"
|
||||
version = "0.18.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -27,16 +27,12 @@ deckcraft-engine = { path = "crates/engine" }
|
||||
deckcraft-mcp = { path = "crates/mcp" }
|
||||
deckcraft-ui-egui = { path = "crates/ui-egui" }
|
||||
deckcraft-media = { path = "crates/media" }
|
||||
# codecs (ported from FilmCraft, same owner and licence)
|
||||
# video codecs (ported from FilmCraft, same owner and licence)
|
||||
deckcraft-bitstream = { path = "crates/bitstream" }
|
||||
deckcraft-h264 = { path = "crates/h264" }
|
||||
deckcraft-hevc = { path = "crates/hevc" }
|
||||
deckcraft-vp9 = { path = "crates/vp9" }
|
||||
deckcraft-av1 = { path = "crates/av1" }
|
||||
deckcraft-isobmff = { path = "crates/isobmff" }
|
||||
deckcraft-matroska = { path = "crates/matroska" }
|
||||
deckcraft-ogg = { path = "crates/ogg" }
|
||||
deckcraft-opus = { path = "crates/opus" }
|
||||
|
||||
# foundation
|
||||
kurbo = { version = "0.13", features = ["serde"] }
|
||||
@@ -59,11 +55,13 @@ harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
# craft-media and craft-audio share types and stay on one rev.
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "75895c0321d8cb6e175a42466cba050352b117c5", version = "=0.1.0", default-features = false }
|
||||
craft-audio = { git = "https://github.com/storytold/craft-libs", rev = "75895c0321d8cb6e175a42466cba050352b117c5", version = "=0.1.0", default-features = false, features = ["opus-decode"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0" }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -109,7 +107,7 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[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 = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.8.2" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
@@ -76,8 +76,9 @@ Code dependencies
|
||||
- symphonia (audio decoding for MP3, AAC, ALAC, FLAC, Vorbis, WAV, AIFF, CAF) is used unmodified
|
||||
under the Mozilla Public License 2.0; its source is available at
|
||||
https://github.com/pdeljanov/Symphonia.
|
||||
- The H.264, HEVC, VP9, AV1, Opus, MP4, Matroska and Ogg crates under crates/ are ported from
|
||||
FilmCraft (same owner, MIT OR Apache-2.0).
|
||||
- The H.264, HEVC, VP9 and AV1 crates under crates/ are ported from FilmCraft (same owner,
|
||||
MIT OR Apache-2.0). The Opus decoder (craft-audio) and the MP4, Matroska and Ogg demuxers
|
||||
(craft-media) come from craft-libs (same owner, MIT OR Apache-2.0).
|
||||
|
||||
- Krilla 0.8.2 (https://github.com/LaurenzV/krilla) is used through craft-pdf under
|
||||
MIT OR Apache-2.0. craft-libs vendors that version with page/surface unwind recovery
|
||||
|
||||
+33
-1
@@ -2,7 +2,13 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (print-preview tests use egui's emitted URL, and macOS removes its previews before exit; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-11 · **Change:** trivial (the Opus decoder and the Ogg, MP4 and Matroska demuxers come from craft-libs; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: web `?host=parent` bridge opens and saves through a same-origin framing page; 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-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-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -148,6 +154,23 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-11-11 | Media: the Opus decoder and the Ogg, MP4/MOV and Matroska demuxers are craft-audio and craft-media from craft-libs; DeckCraft's own copies and re-export crates are removed |
|
||||
| 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-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 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves |
|
||||
| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged |
|
||||
| 2026-11-02 | Deleting slides removes their IDs from every custom show; the cleanup is undoable and survives native save/reopen |
|
||||
| 2026-11-02 | Reused slides scale their geometry and content dimensions to the destination slide, including kept source masters and layouts; the reuse picker still lacks thumbnails |
|
||||
| 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences |
|
||||
| 2026-11-02 | PPTX reflection effects keep their authored start position, affine scale/skew, direction and fade, alignment, and rotation flag through native and PPTX saves |
|
||||
| 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables |
|
||||
| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) |
|
||||
| 2026-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-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
|
||||
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
|
||||
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
|
||||
@@ -318,6 +341,15 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-11 | trivial | Opus and container code moved to craft-libs crates; parity scores unchanged |
|
||||
| 2026-11-04 | trivial | Ogg Opus recovery duration includes its recovered tail; parity scores unchanged |
|
||||
| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged |
|
||||
| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged |
|
||||
| 2026-11-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 | 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-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
||||
|
||||
@@ -16,6 +16,10 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
deckcraft-engine = { 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 }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -27,4 +31,4 @@ rfd = { workspace = true }
|
||||
wasm-bindgen = "0.2"
|
||||
wasm-bindgen-futures = "0.4"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget", "UrlSearchParams"] }
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! The host bridge, for pages that embed DeckCraft and keep its files (a Nextcloud app, a personal
|
||||
//! file server). The messages and the two transports are `craft-web-host`'s
|
||||
//! (storytold/craft-libs), the same contract as the other Craft apps' bridges, with the
|
||||
//! `deckcraft:` prefix. This module is DeckCraft's part of it.
|
||||
//!
|
||||
//! - `?host=parent` (an `<iframe>`) talks to the same-origin parent over `postMessage`;
|
||||
//! `data-host="page"` on the canvas (the page's own document, no iframe) uses DOM
|
||||
//! `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
|
||||
//! 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
|
||||
//! a damaged file open untitled.
|
||||
//! - `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 host stores it and reports failures
|
||||
//! 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
|
||||
//! .pptx), as the desktop app does. A name the parent never sent (an untitled presentation, an
|
||||
//! outline, a file sent without a name, a repaired copy of a damaged file, a file the user
|
||||
//! picked or dropped) is a new file: `true`, as for Save As, named `<title>.deckcraft` as
|
||||
//! Save suggests on the desktop.
|
||||
//! - Save As (File › Save As as .pptx or .deckcraft, Ctrl+Shift+S): `true`. The presentation is
|
||||
//! 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)
|
||||
//! and Print: `false`. They are copies; the presentation's file stays the same.
|
||||
//! - `deckcraft:dirty` whenever unsaved changes appear or go away, `deckcraft:failed` when the
|
||||
//! app couldn't start or refused a file of 2 GiB or larger.
|
||||
//!
|
||||
//! In host mode the app starts with an empty presentation instead of the sample, which gives way to
|
||||
//! the first file the host sends; the host guards leaving the page, and `?author=` names the user
|
||||
//! for comments.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
use craft_web_host::web::{FileData, Host, Message};
|
||||
use deckcraft_ui_egui::{Services, SlideApp};
|
||||
|
||||
/// The app id in the host messages (`deckcraft:ready`).
|
||||
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).
|
||||
const MAX_AUTHOR_CHARS: usize = 256;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Bridge {
|
||||
host: Rc<Host>,
|
||||
/// Files the host sent (the name, if it sent one), waiting for the next frame.
|
||||
opened: Rc<RefCell<Vec<(Option<String>, Vec<u8>)>>>,
|
||||
}
|
||||
|
||||
impl Bridge {
|
||||
/// The bridge for this page, or `None` when DeckCraft isn't hosted: no `?host=parent` and no
|
||||
/// `data-host="page"` on the canvas, an unframed `?host=parent` page (it stays the normal app
|
||||
/// with downloads), or a parent on another origin.
|
||||
pub fn detect(canvas: &web_sys::HtmlCanvasElement) -> Option<Self> {
|
||||
let host = Host::connect(APP, canvas)?;
|
||||
// An opaque origin (a file:// page in some browsers) can't be a postMessage target.
|
||||
if host.mode() == craft_web_host::Mode::Parent && web_sys::window()?.location().origin().ok()? == "null" {
|
||||
return None;
|
||||
}
|
||||
Some(Self { host: Rc::new(host), opened: Rc::default() })
|
||||
}
|
||||
|
||||
/// Hand saved and exported files to the host instead of downloading them, and hear the files
|
||||
/// it sends.
|
||||
pub fn connect(&self, services: &mut Services, ctx: egui::Context) {
|
||||
self.listen(ctx);
|
||||
let bridge = self.clone();
|
||||
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
|
||||
// Exports and Print are copies.
|
||||
let bridge = self.clone();
|
||||
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
|
||||
if let Err(e) = bridge.post_save(name, bytes, false) {
|
||||
log::error!("couldn't hand {name} to the page: {e}");
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// `?author=`: the host's name for the user signs new comments.
|
||||
pub fn set_author(app: &mut SlideApp, author: &str) {
|
||||
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
|
||||
if !author.is_empty() {
|
||||
app.session.prefs.author = author;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn post_ready(&self) {
|
||||
let _ = self.host.post_ready(env!("CARGO_PKG_VERSION"), false);
|
||||
}
|
||||
|
||||
/// The app couldn't start (no WebGPU or WebGL2, for example): let the host say so.
|
||||
pub fn post_failed(&self, error: &str) {
|
||||
let _ = self.host.post_failed(error, None);
|
||||
}
|
||||
|
||||
/// Before each frame: open the files the host sent.
|
||||
pub fn open_files(&self, app: &mut SlideApp) {
|
||||
let opened = std::mem::take(&mut *self.opened.borrow_mut());
|
||||
for (name, bytes) in opened {
|
||||
app.open_from_host(name.as_deref(), &bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
self.host.post_dirty(app.session.documents().iter().any(|d| d.is_dirty()));
|
||||
}
|
||||
|
||||
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());
|
||||
self.host.post_save(&name, bytes, None, save_as)
|
||||
}
|
||||
|
||||
/// Hear `deckcraft:open` and queue the file for the next frame.
|
||||
fn listen(&self, ctx: egui::Context) {
|
||||
let opened = self.opened.clone();
|
||||
let host = self.host.clone();
|
||||
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;
|
||||
}
|
||||
let FileData::Bytes(array) = data else {
|
||||
log::warn!("host file {name} came as a File; DeckCraft reads bytes only; not opened");
|
||||
return;
|
||||
};
|
||||
// No name is no file: the presentation opens untitled, never under an invented name.
|
||||
let name = (name != NAMELESS).then_some(name);
|
||||
opened.borrow_mut().push((name, array.to_vec()));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,24 @@
|
||||
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
|
||||
//! asynchronously through `Services::inbox`, applied to the document, slide and operation
|
||||
//! that started the pick (a dismissed picker or a closed document drops the file);
|
||||
//! - Save and Export trigger a browser download;
|
||||
//! - Save and Export trigger a browser download (or go to the framing page with `?host=parent`);
|
||||
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
|
||||
//! - preferences are not persisted.
|
||||
//!
|
||||
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?blank` starts with an
|
||||
//! empty presentation instead of the sample deck; `?show` starts the slide show.
|
||||
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
|
||||
//! presentation to open and stores what DeckCraft saves (`host`), and `&author=` names the user
|
||||
//! for comments.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod presenter;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod web;
|
||||
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
//! Same-origin popup transport. Each window starts its own eframe/WASM runner.
|
||||
use deckcraft_ui_egui::presenter::{Command, DeltaCache, Host, Snapshot, View};
|
||||
use std::{
|
||||
cell::RefCell,
|
||||
collections::VecDeque,
|
||||
rc::{Rc, Weak},
|
||||
};
|
||||
use wasm_bindgen::{JsCast, closure::Closure};
|
||||
|
||||
const MAX_MESSAGE: usize = 16 * 1024 * 1024;
|
||||
const MAX_COMMANDS: usize = 64;
|
||||
const PREFIX: &str = "#deckcraft-presenter=";
|
||||
fn error(e: wasm_bindgen::JsValue) -> String {
|
||||
format!("{e:?}")
|
||||
}
|
||||
fn origin() -> Result<String, String> {
|
||||
web_sys::window().ok_or("No browser window")?.location().origin().map_err(error)
|
||||
}
|
||||
pub fn token() -> Option<String> {
|
||||
let hash = web_sys::window()?.location().hash().ok()?;
|
||||
let token = hash.strip_prefix(PREFIX)?;
|
||||
(!token.is_empty() && token.len() < 128).then(|| token.to_owned())
|
||||
}
|
||||
fn same_source(event: &web_sys::MessageEvent, window: &web_sys::Window, expected_origin: &str) -> bool {
|
||||
event.origin() == expected_origin && event.source().is_some_and(|source| js_sys::Object::is(source.as_ref(), window.as_ref()))
|
||||
}
|
||||
fn send(window: &web_sys::Window, origin: &str, value: serde_json::Value) -> Result<(), String> {
|
||||
let text = serde_json::to_string(&value).map_err(|e| e.to_string())?;
|
||||
if text.len() > MAX_MESSAGE {
|
||||
return Err("Presenter message is too large".into());
|
||||
}
|
||||
window.post_message(&wasm_bindgen::JsValue::from_str(&text), origin).map_err(error)
|
||||
}
|
||||
struct State {
|
||||
popup: Option<web_sys::Window>,
|
||||
token: String,
|
||||
commands: VecDeque<Command>,
|
||||
ready: bool,
|
||||
refresh_images: bool,
|
||||
}
|
||||
pub struct BrowserHost {
|
||||
state: Rc<RefCell<State>>,
|
||||
origin: String,
|
||||
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
button: Option<(web_sys::HtmlElement, Closure<dyn FnMut(web_sys::Event)>)>,
|
||||
images: DeltaCache,
|
||||
}
|
||||
impl BrowserHost {
|
||||
pub fn new(ctx: egui::Context) -> Result<Self, String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let origin = origin()?;
|
||||
let state = Rc::new(RefCell::new(State { popup: None, token: String::new(), commands: VecDeque::new(), ready: false, refresh_images: true }));
|
||||
let weak = Rc::downgrade(&state);
|
||||
let expected = origin.clone();
|
||||
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||
let Some(state) = weak.upgrade() else { return };
|
||||
let Ok(mut state) = state.try_borrow_mut() else { return };
|
||||
let Some(popup) = state.popup.as_ref() else { return };
|
||||
if !same_source(&event, popup, &expected) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = event.data().as_string().filter(|s| s.len() <= 8192) else { return };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||
if value.get("token").and_then(|v| v.as_str()) != Some(state.token.as_str()) {
|
||||
return;
|
||||
}
|
||||
match value.get("kind").and_then(|v| v.as_str()) {
|
||||
Some("ready") => {
|
||||
state.ready = true;
|
||||
state.refresh_images = true;
|
||||
}
|
||||
Some("command") if state.commands.len() < MAX_COMMANDS => {
|
||||
if let Some(command) = value.get("command").and_then(|v| serde_json::from_value(v.clone()).ok()) {
|
||||
state.commands.push_back(command);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
});
|
||||
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||
Ok(Self { state, origin, listener, button: None, images: DeltaCache::default() })
|
||||
}
|
||||
fn remove_button(&mut self) {
|
||||
if let Some((button, callback)) = self.button.take() {
|
||||
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
fn recovery_button(&mut self) {
|
||||
if self.button.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
|
||||
let Some(body) = document.body() else { return };
|
||||
let Ok(element) = document.create_element("button") else { return };
|
||||
let Ok(button) = element.dyn_into::<web_sys::HtmlElement>() else { return };
|
||||
button.set_text_content(Some("Open presenter window"));
|
||||
let _ = button.set_attribute("style", "position:fixed;top:12px;right:12px;z-index:9999;padding:12px;color:white;background:#333;border:1px solid #aaa;border-radius:4px;cursor:pointer");
|
||||
let weak: Weak<RefCell<State>> = Rc::downgrade(&self.state);
|
||||
let button_copy = button.clone();
|
||||
let callback = Closure::new(move |_event: web_sys::Event| {
|
||||
if let Some(state) = weak.upgrade() {
|
||||
match open_popup(&state) {
|
||||
Ok(()) => {
|
||||
button_copy.set_text_content(Some("Opening presenter window…"));
|
||||
}
|
||||
Err(_) => {
|
||||
button_copy.set_text_content(Some("Popup blocked. Allow popups, then click to retry."));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
if button.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()).is_ok() && body.append_child(&button).is_ok() {
|
||||
self.button = Some((button, callback));
|
||||
} else {
|
||||
let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
|
||||
button.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
fn open_popup(state: &Rc<RefCell<State>>) -> Result<(), String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
let mut state = state.try_borrow_mut().map_err(|_| "Presenter is busy")?;
|
||||
if let Some(popup) = &state.popup
|
||||
&& !popup.closed().unwrap_or(true)
|
||||
{
|
||||
let _ = popup.focus();
|
||||
return Ok(());
|
||||
}
|
||||
let token = format!("{}-{}", js_sys::Date::now(), js_sys::Math::random());
|
||||
let url = web_sys::Url::new(&window.location().href().map_err(error)?).map_err(error)?;
|
||||
url.set_hash(&format!("{PREFIX}{token}"));
|
||||
let popup = window
|
||||
.open_with_url_and_target_and_features(&url.href(), "_blank", "popup,width=1100,height=700")
|
||||
.map_err(error)?
|
||||
.ok_or("Presenter popup was blocked. Use Open presenter window to retry.")?;
|
||||
if popup.closed().unwrap_or(true) {
|
||||
return Err("Browser isolation prevented connecting the presenter window".into());
|
||||
}
|
||||
state.popup = Some(popup);
|
||||
state.token = token;
|
||||
state.commands.clear();
|
||||
state.ready = false;
|
||||
Ok(())
|
||||
}
|
||||
impl Host for BrowserHost {
|
||||
fn open(&mut self) -> Result<(), String> {
|
||||
self.close();
|
||||
let result = open_popup(&self.state);
|
||||
if result.is_err() {
|
||||
self.recovery_button();
|
||||
}
|
||||
result
|
||||
}
|
||||
fn is_open(&self) -> bool {
|
||||
self.state.borrow().popup.as_ref().is_some_and(|w| !w.closed().unwrap_or(true))
|
||||
}
|
||||
fn commands(&mut self) -> Vec<Command> {
|
||||
self.state.borrow_mut().commands.drain(..).collect()
|
||||
}
|
||||
fn update(&mut self, snapshot: Snapshot) {
|
||||
if !self.is_open() {
|
||||
self.recovery_button();
|
||||
return;
|
||||
}
|
||||
self.remove_button();
|
||||
let mut state = self.state.borrow_mut();
|
||||
if !state.ready {
|
||||
return;
|
||||
}
|
||||
if let Some(popup) = state.popup.clone() {
|
||||
let token = state.token.clone();
|
||||
let sent = self.images.send(snapshot, &token, state.refresh_images, |delta, current_changed, next_changed| {
|
||||
send(
|
||||
&popup,
|
||||
&self.origin,
|
||||
serde_json::json!({"kind":"snapshot", "token":token, "snapshot":delta,
|
||||
"currentChanged":current_changed, "nextChanged":next_changed}),
|
||||
)
|
||||
});
|
||||
if sent.is_ok() {
|
||||
state.refresh_images = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.remove_button();
|
||||
let mut state = self.state.borrow_mut();
|
||||
if let Some(window) = state.popup.take() {
|
||||
let _ = window.close();
|
||||
}
|
||||
state.commands.clear();
|
||||
state.ready = false;
|
||||
state.token.clear();
|
||||
}
|
||||
}
|
||||
impl Drop for BrowserHost {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Popup {
|
||||
token: String,
|
||||
origin: String,
|
||||
opener: web_sys::Window,
|
||||
snapshot: Rc<RefCell<Option<Snapshot>>>,
|
||||
last_received: Rc<RefCell<f64>>,
|
||||
listener: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
view: View,
|
||||
}
|
||||
impl Popup {
|
||||
pub fn new(token: String, ctx: egui::Context) -> Result<Self, String> {
|
||||
let window = web_sys::window().ok_or("No browser window")?;
|
||||
// `opener` is a WindowProxy from another realm; instanceof Window can fail across
|
||||
// realms. The browser API supplies a WindowProxy or null, and messages still verify
|
||||
// exact source identity and origin before any data is accepted.
|
||||
let opener = window.opener().map_err(error)?;
|
||||
if opener.is_null() || opener.is_undefined() {
|
||||
return Err("The audience window is unavailable".into());
|
||||
}
|
||||
let opener: web_sys::Window = opener.unchecked_into();
|
||||
let origin = origin()?;
|
||||
let snapshot: Rc<RefCell<Option<Snapshot>>> = Rc::new(RefCell::new(None));
|
||||
let last_received = Rc::new(RefCell::new(js_sys::Date::now()));
|
||||
let received = last_received.clone();
|
||||
let target = snapshot.clone();
|
||||
let expected_origin = origin.clone();
|
||||
let expected_opener = opener.clone();
|
||||
let expected_token = token.clone();
|
||||
let listener = Closure::new(move |event: web_sys::MessageEvent| {
|
||||
if !same_source(&event, &expected_opener, &expected_origin) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = event.data().as_string().filter(|s| s.len() <= MAX_MESSAGE) else { return };
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else { return };
|
||||
if value.get("token").and_then(|v| v.as_str()) != Some(expected_token.as_str())
|
||||
|| value.get("kind").and_then(|v| v.as_str()) != Some("snapshot")
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let Some(mut data) = value.get("snapshot").and_then(|v| serde_json::from_value::<Snapshot>(v.clone()).ok()) {
|
||||
if let Some(previous) = target.borrow().as_ref() {
|
||||
if value.get("currentChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.current = previous.current.clone();
|
||||
}
|
||||
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
|
||||
data.next = previous.next.clone();
|
||||
}
|
||||
if data.slides.is_none() {
|
||||
data.slides = previous.slides.clone();
|
||||
}
|
||||
}
|
||||
*target.borrow_mut() = Some(data);
|
||||
*received.borrow_mut() = js_sys::Date::now();
|
||||
ctx.request_repaint();
|
||||
}
|
||||
});
|
||||
window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
|
||||
if let Some(document) = window.document() {
|
||||
document.set_title("Presenter View — DeckCraft");
|
||||
}
|
||||
let popup = Self { token, origin, opener, snapshot, last_received, listener, view: View::default() };
|
||||
send(&popup.opener, &popup.origin, serde_json::json!({"kind":"ready","token":popup.token}))?;
|
||||
Ok(popup)
|
||||
}
|
||||
}
|
||||
impl eframe::App for Popup {
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
let snapshot = self.snapshot.borrow();
|
||||
let notes = snapshot.as_ref().map_or("", |snapshot| snapshot.notes.as_str());
|
||||
if !self.view.prepare(ui.ctx(), notes) {
|
||||
return;
|
||||
}
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
if self.opener.closed().unwrap_or(true) {
|
||||
ui.heading("The audience window has closed.");
|
||||
return;
|
||||
}
|
||||
if js_sys::Date::now() - *self.last_received.borrow() > 10_000.0 {
|
||||
ui.heading("The audience window is not responding.");
|
||||
ui.label("Return to the audience window to resume the connection.");
|
||||
return;
|
||||
}
|
||||
egui::CentralPanel::default().show(ui, |ui| {
|
||||
if let Some(snapshot) = snapshot.as_ref() {
|
||||
for command in self.view.ui(ui, snapshot) {
|
||||
let _ = send(&self.opener, &self.origin, serde_json::json!({"kind":"command","token":self.token,"command":command}));
|
||||
}
|
||||
} else {
|
||||
ui.label("Waiting for the audience window…");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
impl Drop for Popup {
|
||||
fn drop(&mut self) {
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,8 +24,18 @@ pub fn start() {
|
||||
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
|
||||
return;
|
||||
};
|
||||
// Host modes (`craft-web-host`): `?host=parent`, a same-origin page framing DeckCraft,
|
||||
// opens presentations in it and stores what it saves (`host`); `data-host="page"` on the
|
||||
// canvas opts a page in that runs DeckCraft in its own document. The presenter popup is
|
||||
// never the framed page.
|
||||
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");
|
||||
}
|
||||
let app_bridge = bridge.clone();
|
||||
let mut options = eframe::WebOptions::default();
|
||||
if query().contains("webgl")
|
||||
if query_has("webgl")
|
||||
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
|
||||
{
|
||||
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
|
||||
@@ -39,23 +49,48 @@ pub fn start() {
|
||||
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
|
||||
}
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
if let Some(token) = crate::presenter::token() {
|
||||
let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?;
|
||||
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
|
||||
}
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let q = query();
|
||||
if !q.contains("blank") {
|
||||
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
|
||||
if let Some(bridge) = &app_bridge {
|
||||
bridge.connect(&mut services, cc.egui_ctx.clone());
|
||||
}
|
||||
let mut app = SlideApp::new(Session::new(), services);
|
||||
// In host mode the parent sends the presentation to edit; no sample meanwhile.
|
||||
if app_bridge.is_some() {
|
||||
if let Err(e) = app.start_for_host() {
|
||||
log::error!("new presentation: {e}");
|
||||
}
|
||||
} else if !query_has("blank") {
|
||||
if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
|
||||
log::error!("sample deck: {e}");
|
||||
}
|
||||
} else if let Err(e) = app.run("file.new", serde_json::json!({})) {
|
||||
log::error!("new presentation: {e}");
|
||||
}
|
||||
if q.contains("show") {
|
||||
if query_has("show") {
|
||||
app.start_show(0, false);
|
||||
}
|
||||
Ok(Box::new(WebShell { app, inbox }))
|
||||
if app_bridge.is_some()
|
||||
&& let Some(author) = query_param("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 }))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
// Ready only once the runner is up: starting can still fail after the app is made.
|
||||
if let Some(bridge) = &bridge {
|
||||
match &result {
|
||||
Ok(()) => bridge.post_ready(),
|
||||
Err(e) => bridge.post_failed(&format!("{e:?}")),
|
||||
}
|
||||
}
|
||||
if let Some(el) = document.get_element_by_id(LOADING_ID) {
|
||||
match result {
|
||||
Ok(()) => el.remove(),
|
||||
@@ -69,17 +104,41 @@ fn query() -> String {
|
||||
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn query_param(key: &str) -> Option<String> {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
|
||||
}
|
||||
|
||||
/// Whether the URL has the flag `key` (`?show`, `&blank=1`). A whole parameter, never a substring:
|
||||
/// `?author=` is free text.
|
||||
fn query_has(key: &str) -> bool {
|
||||
web_sys::UrlSearchParams::new_with_str(&query()).is_ok_and(|q| q.has(key))
|
||||
}
|
||||
|
||||
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
|
||||
/// and feed them through the inbox.
|
||||
struct WebShell {
|
||||
app: SlideApp,
|
||||
inbox: Inbox,
|
||||
/// The parent page that keeps the files (`?host=parent`).
|
||||
bridge: Option<crate::host::Bridge>,
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
/// Delegate one eframe logic pass to the wrapped application.
|
||||
/// Delegate one eframe logic pass to the wrapped application, opening the files the parent
|
||||
/// page sent first and telling it about unsaved changes after.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
if let Some(bridge) = &self.bridge {
|
||||
bridge.open_files(&mut self.app);
|
||||
}
|
||||
self.app.logic(ctx);
|
||||
if let Some(bridge) = &self.bridge {
|
||||
bridge.report_dirty(&self.app);
|
||||
}
|
||||
if self.app.show.is_none()
|
||||
&& let Some(host) = self.app.services.presenter.as_mut()
|
||||
{
|
||||
host.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
|
||||
@@ -118,7 +177,15 @@ impl eframe::App for WebShell {
|
||||
/// back to `open_async` (browser picker → inbox) and `download`.
|
||||
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
|
||||
let open_inbox = inbox.clone();
|
||||
let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) {
|
||||
Ok(host) => Some(Box::new(host) as Box<dyn deckcraft_ui_egui::presenter::Host>),
|
||||
Err(error) => {
|
||||
log::error!("Presenter window: {error}");
|
||||
None
|
||||
}
|
||||
};
|
||||
Services {
|
||||
presenter,
|
||||
open_async: Some(Box::new(move |purpose: &str, request: u64| {
|
||||
let inbox = open_inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
|
||||
@@ -14,7 +14,9 @@ workspace = true
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-ui-egui = { 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 }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -148,7 +148,19 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
// Messages can embed document-controlled text (a part name, a relationship id): control
|
||||
// characters other than tab are escaped and each continuation line is marked, so such text
|
||||
// cannot start a line that looks like a record of its own.
|
||||
let mut text = String::with_capacity(message.len());
|
||||
for c in message.chars() {
|
||||
match c {
|
||||
'\n' => text.push_str("\n | "),
|
||||
'\t' => text.push(c),
|
||||
c if c.is_control() => text.extend(c.escape_default()),
|
||||
c => text.push(c),
|
||||
}
|
||||
}
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {text}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
@@ -446,8 +458,13 @@ mod tests {
|
||||
),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] deckcraft_pptx::opc: pptx: skipping oversized part ppt/media/image1.png\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
// A multi-line message keeps its lines, each continuation marked; the record still ends in
|
||||
// exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n | b\n"));
|
||||
// Injected line breaks and escape sequences cannot forge a record.
|
||||
let line = format_line("t", log::Level::Warn, "w", "x", "part a\r\n2026 ERROR [m] y: forged\x1b[2J");
|
||||
assert_eq!(line.matches('\n').count(), 2);
|
||||
assert!(!line.contains('\r') && !line.contains('\x1b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -51,6 +51,9 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
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 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
|
||||
@@ -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 {
|
||||
pick_open: Some(Box::new(|purpose: &str| {
|
||||
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()))),
|
||||
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()))),
|
||||
clipboard_image: Some(Box::new(pasteboard::image_png)),
|
||||
clipboard_html: Some(Box::new(pasteboard::html)),
|
||||
clipboard_set_html: Some(Box::new(pasteboard::set_html)),
|
||||
clipboard_image: pasteboard.then(|| Box::new(pasteboard::image_png) as Box<dyn FnMut() -> Option<Vec<u8>>>),
|
||||
clipboard_html: pasteboard.then(|| Box::new(pasteboard::html) as Box<dyn FnMut() -> Option<String>>),
|
||||
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.
|
||||
audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO")
|
||||
.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 USAGE: &str = "\
|
||||
@@ -247,6 +264,11 @@ fn main() -> eframe::Result {
|
||||
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")))]
|
||||
if x11 {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
@@ -268,7 +290,12 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
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);
|
||||
app.enable_update_checks();
|
||||
// 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}");
|
||||
}
|
||||
|
||||
/// 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]
|
||||
fn help_exits_instead_of_opening_a_file() {
|
||||
assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help));
|
||||
|
||||
+59
-29
@@ -1,5 +1,7 @@
|
||||
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
|
||||
|
||||
@@ -15,48 +17,66 @@ fn text_of(s: &Shape) -> String {
|
||||
/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
|
||||
/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used
|
||||
/// once; top-level shapes only (groups move whole).
|
||||
///
|
||||
/// Each pass compares a key of the two shapes, so the shapes of `b` are indexed by key once per
|
||||
/// pass: matching costs the sum of the two shape lists, not their product.
|
||||
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
|
||||
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
|
||||
let mut used_a = vec![false; a.shapes.len()];
|
||||
let mut used_b = vec![false; b.shapes.len()];
|
||||
let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [
|
||||
&|x, y| x.name.starts_with("!!") && x.name == y.name,
|
||||
&|x, y| !x.name.is_empty() && x.name == y.name,
|
||||
&|x, y| {
|
||||
if x.kind_name() != y.kind_name() {
|
||||
return false;
|
||||
// Each shape's text, worked out once rather than inside every comparison.
|
||||
let text_a: Vec<String> = a.shapes.iter().map(text_of).collect();
|
||||
let text_b: Vec<String> = b.shapes.iter().map(text_of).collect();
|
||||
let forced = |s: &Shape, _: &str| (s.name.starts_with("!!")).then(|| MorphKey::Name(s.name.clone()));
|
||||
let named = |s: &Shape, _: &str| (!s.name.is_empty()).then(|| MorphKey::Name(s.name.clone()));
|
||||
let same_text = |s: &Shape, t: &str| (!t.trim().is_empty()).then(|| MorphKey::Text(s.kind_name(), t.to_string()));
|
||||
let same_geom = |s: &Shape, t: &str| match s.geom.preset_name() {
|
||||
Some(g) if t.trim().is_empty() => Some(MorphKey::Geom(s.kind_name(), g.to_string())),
|
||||
_ => None,
|
||||
};
|
||||
let passes: [&dyn Fn(&Shape, &str) -> Option<MorphKey>; 4] = [&forced, &named, &same_text, &same_geom];
|
||||
for key in passes {
|
||||
// Unused shapes of `b` by key, in slide order: the front of each queue is the first unused
|
||||
// match, which is the one a search through `b` would find.
|
||||
let mut index: HashMap<MorphKey, VecDeque<usize>> = HashMap::new();
|
||||
for (j, sb) in b.shapes.iter().enumerate() {
|
||||
if used_b.get(j).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let tx = text_of(x);
|
||||
!tx.trim().is_empty() && tx == text_of(y)
|
||||
},
|
||||
&|x, y| {
|
||||
x.kind_name() == y.kind_name()
|
||||
&& x.geom.preset_name().is_some()
|
||||
&& x.geom.preset_name() == y.geom.preset_name()
|
||||
&& text_of(x).trim().is_empty()
|
||||
&& text_of(y).trim().is_empty()
|
||||
},
|
||||
];
|
||||
for pass in passes {
|
||||
if let Some(k) = key(sb, text_b.get(j).map_or("", String::as_str)) {
|
||||
index.entry(k).or_default().push_back(j);
|
||||
}
|
||||
}
|
||||
if index.is_empty() {
|
||||
continue;
|
||||
}
|
||||
for (i, sa) in a.shapes.iter().enumerate() {
|
||||
if used_a.get(i).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb));
|
||||
if let Some((j, sb)) = found {
|
||||
if let Some(u) = used_a.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
if let Some(u) = used_b.get_mut(j) {
|
||||
*u = true;
|
||||
}
|
||||
pairs.push((sa.id, sb.id));
|
||||
let Some(k) = key(sa, text_a.get(i).map_or("", String::as_str)) else { continue };
|
||||
let Some(j) = index.get_mut(&k).and_then(VecDeque::pop_front) else { continue };
|
||||
let Some(sb) = b.shapes.get(j) else { continue };
|
||||
if let Some(u) = used_a.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
if let Some(u) = used_b.get_mut(j) {
|
||||
*u = true;
|
||||
}
|
||||
pairs.push((sa.id, sb.id));
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// What one [`morph_pairs`] pass matches shapes on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
|
||||
enum MorphKey {
|
||||
Name(String),
|
||||
Text(&'static str, String),
|
||||
Geom(&'static str, String),
|
||||
}
|
||||
|
||||
fn lerp(a: f64, b: f64, t: f64) -> f64 {
|
||||
let v = a + (b - a) * t;
|
||||
if v.is_finite() {
|
||||
@@ -256,13 +276,23 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
|
||||
f.shapes.push((sh, old));
|
||||
id
|
||||
};
|
||||
// Lookups by id, built once (the first shape or pair with an id wins, as a search would).
|
||||
let paired_a: HashSet<ShapeId> = pairs.iter().map(|(ia, _)| *ia).collect();
|
||||
let mut partner: HashMap<ShapeId, ShapeId> = HashMap::with_capacity(pairs.len());
|
||||
for (ia, ib) in &pairs {
|
||||
partner.entry(*ib).or_insert(*ia);
|
||||
}
|
||||
let mut a_by_id: HashMap<ShapeId, &Shape> = HashMap::with_capacity(a.shapes.len());
|
||||
for x in &a.shapes {
|
||||
a_by_id.entry(x.id).or_insert(x);
|
||||
}
|
||||
for sa in &a.shapes {
|
||||
if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
|
||||
if !paired_a.contains(&sa.id) {
|
||||
push(sa.clone(), true, 1.0 - s);
|
||||
}
|
||||
}
|
||||
for sb in &b.shapes {
|
||||
let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
|
||||
let pair = partner.get(&sb.id).and_then(|ia| a_by_id.get(ia).copied());
|
||||
let Some(sa) = pair else {
|
||||
if !hidden_b(sb.id) {
|
||||
push(sb.clone(), false, s);
|
||||
|
||||
@@ -1155,3 +1155,58 @@ fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
|
||||
assert_eq!(tl.effects().len(), 6_000);
|
||||
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_pairs_take_the_first_unused_match_in_slide_order() {
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
a.shapes = vec![shape(1, "Same"), shape(2, "Same"), shape(3, "Other")];
|
||||
b.shapes = vec![shape(11, "Same"), shape(12, "Same"), shape(13, "Same")];
|
||||
// By name first, in slide order; the shape of `a` left over pairs by kind and geometry with
|
||||
// the shape of `b` left over.
|
||||
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(11)), (ShapeId(2), ShapeId(12)), (ShapeId(3), ShapeId(13))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_of_large_slides_stays_linear() {
|
||||
let n = 20_000u32;
|
||||
let text = |i: u32, side: &str| Some(TextBody::from_text(&format!("{side} {i}")));
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
// Text no shape on the other slide shares: every pass looks for a partner and finds none.
|
||||
a.shapes = (0..n).map(|i| Shape { text: text(i, "a"), ..shape(i + 1, "") }).collect();
|
||||
b.shapes = (0..n).map(|i| Shape { text: text(i, "b"), ..shape(n + i + 1, "") }).collect();
|
||||
// One named pair at opposite ends.
|
||||
a.shapes[0].name = "Logo".into();
|
||||
b.shapes[n as usize - 1].name = "Logo".into();
|
||||
// No wall-clock bound (it would flake on a loaded runner): at this size a quadratic pairing
|
||||
// takes minutes, so a regression shows as a test that hangs rather than a failed assertion.
|
||||
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2 * n))]);
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
// Every shape fades in place; the named pair differs in text, so it cross-fades as two.
|
||||
assert_eq!(f.shapes.len(), 2 * n as usize);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_states_of_a_large_build_stay_linear() {
|
||||
let n = 20_000usize;
|
||||
let mut s = slide_with(vec![]);
|
||||
let mut body = TextBody::from_text(&vec!["line"; n].join("\n"));
|
||||
// One first-level paragraph owning every other paragraph.
|
||||
for p in body.paragraphs.iter_mut().skip(1) {
|
||||
p.level = 1;
|
||||
}
|
||||
s.shapes[0].text = Some(body);
|
||||
let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
|
||||
a.text_build = TextBuild::ByParagraph;
|
||||
s.animations = vec![a];
|
||||
let tl = Timeline::new(&s, W, H);
|
||||
// No wall-clock bound, as above: a per-paragraph scan of the slide would take minutes here.
|
||||
assert_eq!(tl.paragraph_targets(ShapeId(1)).len(), n);
|
||||
for p in 0..n {
|
||||
assert!(!tl.state(ShapeId(1), Some(p), 0, 0.0).visible);
|
||||
assert!(tl.state(ShapeId(1), Some(p), 1, 0.0).visible);
|
||||
}
|
||||
assert_eq!(tl.animated_shapes(), [ShapeId(1)]);
|
||||
}
|
||||
|
||||
+47
-12
@@ -103,6 +103,41 @@ pub struct Timeline {
|
||||
/// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent:
|
||||
/// (shape, member paragraph, owner paragraph).
|
||||
groups: Vec<(ShapeId, usize, usize)>,
|
||||
/// Lookups built with the timeline, so evaluating a shape or paragraph costs its own effects
|
||||
/// rather than a scan of every effect, group and colour on the slide.
|
||||
index: TimelineIndex,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct TimelineIndex {
|
||||
/// Effects (indices into `effects`, in order) by target shape and paragraph.
|
||||
by_target: HashMap<(ShapeId, Option<usize>), Vec<usize>>,
|
||||
/// Effects (indices into `effects`, in order) by target shape.
|
||||
by_shape: HashMap<ShapeId, Vec<usize>>,
|
||||
/// Owner paragraph of each member paragraph (the first group recorded wins).
|
||||
owner: HashMap<(ShapeId, usize), usize>,
|
||||
/// Groups (member, owner) by shape, in order.
|
||||
groups_by_shape: HashMap<ShapeId, Vec<(usize, usize)>>,
|
||||
/// Base colour by shape (the first recorded wins).
|
||||
color: HashMap<ShapeId, Rgba>,
|
||||
}
|
||||
|
||||
impl TimelineIndex {
|
||||
fn build(effects: &[Effect], groups: &[(ShapeId, usize, usize)], colors: &[(ShapeId, Rgba)]) -> TimelineIndex {
|
||||
let mut ix = TimelineIndex::default();
|
||||
for (i, e) in effects.iter().enumerate() {
|
||||
ix.by_target.entry((e.timing.shape, e.timing.paragraph)).or_default().push(i);
|
||||
ix.by_shape.entry(e.timing.shape).or_default().push(i);
|
||||
}
|
||||
for &(shape, member, owner) in groups {
|
||||
ix.owner.entry((shape, member)).or_insert(owner);
|
||||
ix.groups_by_shape.entry(shape).or_default().push((member, owner));
|
||||
}
|
||||
for &(shape, c) in colors {
|
||||
ix.color.entry(shape).or_insert(c);
|
||||
}
|
||||
ix
|
||||
}
|
||||
}
|
||||
|
||||
enum Status {
|
||||
@@ -324,6 +359,7 @@ impl Timeline {
|
||||
};
|
||||
tl.colors.push((id, c));
|
||||
}
|
||||
tl.index = TimelineIndex::build(&tl.effects, &tl.groups, &tl.colors);
|
||||
tl
|
||||
}
|
||||
|
||||
@@ -413,8 +449,12 @@ impl Timeline {
|
||||
|
||||
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
|
||||
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec<usize> {
|
||||
let mut v: Vec<usize> = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect();
|
||||
v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::<Vec<_>>());
|
||||
let mine = self.index.by_shape.get(&shape).map_or(&[][..], Vec::as_slice);
|
||||
let mut v: Vec<usize> = mine.iter().filter_map(|&i| self.effects.get(i)).filter_map(|e| e.timing.paragraph).collect();
|
||||
let owners: HashSet<usize> = v.iter().copied().collect();
|
||||
if let Some(groups) = self.index.groups_by_shape.get(&shape) {
|
||||
v.extend(groups.iter().filter(|(_, owner)| owners.contains(owner)).map(|(member, _)| *member));
|
||||
}
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
v
|
||||
@@ -422,13 +462,8 @@ impl Timeline {
|
||||
|
||||
/// Shapes with any effect.
|
||||
pub fn animated_shapes(&self) -> Vec<ShapeId> {
|
||||
let mut v: Vec<ShapeId> = Vec::new();
|
||||
for e in &self.effects {
|
||||
if !v.contains(&e.timing.shape) {
|
||||
v.push(e.timing.shape);
|
||||
}
|
||||
}
|
||||
v
|
||||
let mut seen: HashSet<ShapeId> = HashSet::new();
|
||||
self.effects.iter().map(|e| e.timing.shape).filter(|s| seen.insert(*s)).collect()
|
||||
}
|
||||
|
||||
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
|
||||
@@ -456,9 +491,9 @@ impl Timeline {
|
||||
triggers: &[TriggerPlay],
|
||||
stamps: &[StepStamp],
|
||||
) -> AnimState {
|
||||
let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
|
||||
let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
|
||||
let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
let para = para.map(|p| self.index.owner.get(&(shape, p)).copied().unwrap_or(p));
|
||||
let mine = self.index.by_target.get(&(shape, para)).map_or(&[][..], Vec::as_slice).iter().filter_map(|&i| self.effects.get(i));
|
||||
let base_color = self.index.color.get(&shape).copied().unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
let mut st = AnimState::default();
|
||||
// Initial visibility comes from the first effect in the Animation Pane order.
|
||||
let mut ordered: Vec<&Effect> = mine.collect();
|
||||
|
||||
@@ -118,6 +118,8 @@ enum Output {
|
||||
grain: FilmGrainParams,
|
||||
apply_film_grain: bool,
|
||||
pts: i64,
|
||||
/// What copying the picture out costs, in the reference's own format.
|
||||
bytes: u64,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -132,7 +134,7 @@ impl Output {
|
||||
fn take(self) -> Result<Picture> {
|
||||
match self {
|
||||
Output::Decoded(s) => s.wait().map(|p| Arc::try_unwrap(p).unwrap_or_else(|p| (*p).clone())),
|
||||
Output::Existing { slot, seq, width, height, grain, apply_film_grain, pts } => {
|
||||
Output::Existing { slot, seq, width, height, grain, apply_film_grain, pts, .. } => {
|
||||
let rf = slot.wait()?;
|
||||
Ok(output_picture(&seq, &rf, width, height, &grain, apply_film_grain, pts))
|
||||
}
|
||||
@@ -379,8 +381,8 @@ impl Decoder {
|
||||
// Output policy (7.18.1 note): with scalability, show one frame per temporal unit, the
|
||||
// highest spatial layer present.
|
||||
let scalable = self.seq.as_ref().is_some_and(|s| s.op_idc != 0);
|
||||
if !scalable {
|
||||
self.out.extend(out.into_iter().map(|(o, _, _)| o));
|
||||
let kept: Vec<Output> = if !scalable {
|
||||
out.into_iter().map(|(o, _, _)| o).collect()
|
||||
} else {
|
||||
let mut last: Option<(usize, u32)> = None;
|
||||
let mut sel: Vec<Output> = Vec::new();
|
||||
@@ -400,8 +402,27 @@ impl Decoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
self.out.extend(sel);
|
||||
sel
|
||||
};
|
||||
// Every repeat is copied out whole, so the repeats this call keeps (after the layer
|
||||
// selection) must not queue more than half the decoder budget; the first one over it and
|
||||
// everything after is dropped.
|
||||
let mut r = r;
|
||||
let mut repeat_bytes = 0u64;
|
||||
let mut ok = Vec::with_capacity(kept.len());
|
||||
for o in kept {
|
||||
if let Output::Existing { bytes, .. } = &o {
|
||||
repeat_bytes = repeat_bytes.saturating_add(*bytes);
|
||||
if repeat_bytes > self.memory_budget / 2 {
|
||||
if r.is_ok() {
|
||||
r = Err(Error::Invalid("repeated frame outputs exceed the decoder memory budget"));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
ok.push(o);
|
||||
}
|
||||
self.out.extend(ok);
|
||||
r
|
||||
}
|
||||
|
||||
@@ -425,6 +446,8 @@ impl Decoder {
|
||||
|
||||
fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> {
|
||||
let mut tu = 0usize;
|
||||
// Repeated-frame outputs in `out`, counted as they are added rather than recounted per OBU.
|
||||
let mut existing = out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count();
|
||||
let mut pos = 0;
|
||||
while pos < data.len() {
|
||||
let obu = parse_obu_unit(data, pos)?;
|
||||
@@ -444,9 +467,10 @@ impl Decoder {
|
||||
}
|
||||
OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => {
|
||||
if let Some(o) = self.frame_obu(&obu)? {
|
||||
if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 {
|
||||
if existing >= 32 {
|
||||
return Err(Error::Invalid("too many frame outputs in chunk"));
|
||||
}
|
||||
existing += usize::from(matches!(o, Output::Existing { .. }));
|
||||
out.push((o, tu, spatial_id));
|
||||
}
|
||||
}
|
||||
@@ -500,6 +524,15 @@ impl Decoder {
|
||||
let idx = fh.frame_to_show_map_idx;
|
||||
let slot = self.ref_frames[idx].clone().ok_or(Error::Invalid("show_existing_frame of an empty slot"))?;
|
||||
let info = self.refs[idx].clone();
|
||||
// Sized in the reference's own format: a new sequence header may differ from it.
|
||||
let shape = deckcraft_bitstream::budget::PictureShape {
|
||||
width: u64::from(info.upscaled_width),
|
||||
height: u64::from(info.frame_height),
|
||||
shift_x: u32::from(info.subsampling_x),
|
||||
shift_y: u32::from(info.subsampling_y),
|
||||
chroma_planes: if info.mono_chrome { 0 } else { 2 },
|
||||
bytes_per_sample: 2,
|
||||
};
|
||||
let o = Output::Existing {
|
||||
slot: slot.clone(),
|
||||
seq: seq.clone(),
|
||||
@@ -508,6 +541,7 @@ impl Decoder {
|
||||
grain: fh.film_grain.clone(),
|
||||
apply_film_grain: self.apply_film_grain,
|
||||
pts: self.pts,
|
||||
bytes: shape.bytes().unwrap_or(u64::MAX),
|
||||
};
|
||||
if fh.frame_type == KEY_FRAME as u8 {
|
||||
// reference frame loading process (7.21) then refresh every slot (7.20)
|
||||
@@ -626,6 +660,9 @@ impl Decoder {
|
||||
ri.lf_deltas = fh.lf.deltas;
|
||||
ri.seg_features = fh.seg.features;
|
||||
ri.grain = fh.film_grain.clone();
|
||||
ri.subsampling_x = job.seq.color.subsampling_x;
|
||||
ri.subsampling_y = job.seq.color.subsampling_y;
|
||||
ri.mono_chrome = job.seq.color.mono_chrome;
|
||||
self.ref_frames[i] = Some(job.slot.clone());
|
||||
}
|
||||
}
|
||||
@@ -774,6 +811,14 @@ fn decode_frame(job: &FrameJob, pool: &Pool, stats: &mut DecodeStats) -> Result<
|
||||
Ok((rf, pic))
|
||||
}
|
||||
|
||||
/// Bytes all parallel tile states may hold of whole-frame CDEF and restoration metadata; past it
|
||||
/// the tiles decode one after another on the frame's own metadata.
|
||||
const TILE_METADATA_BUDGET: usize = 256 << 20;
|
||||
|
||||
fn tile_metadata_fits(tiles: usize, per_tile: usize) -> bool {
|
||||
tiles.checked_mul(per_tile).is_some_and(|n| n <= TILE_METADATA_BUDGET)
|
||||
}
|
||||
|
||||
/// Decode the tiles of a frame (tile number, data), in parallel when several; returns the final
|
||||
/// CDFs of tile context_update_tile_id.
|
||||
fn decode_tiles(pool: &Pool, fs: &mut FrameState, cdfs: &Cdfs, tiles: &[(usize, Vec<u8>)]) -> Result<Option<Box<Cdfs>>> {
|
||||
@@ -787,7 +832,7 @@ fn decode_tiles(pool: &Pool, fs: &mut FrameState, cdfs: &Cdfs, tiles: &[(usize,
|
||||
};
|
||||
// Intra block copy reads the current frame through the tile's own buffer: keep whole-frame
|
||||
// buffers (tiles one after another) for it.
|
||||
if tiles.len() > 1 && pool.is_parallel() && !fs.fh.allow_intrabc {
|
||||
if tiles.len() > 1 && pool.is_parallel() && !fs.fh.allow_intrabc && tile_metadata_fits(tiles.len(), fs.tile_metadata_bytes()) {
|
||||
let mut states: Vec<TileState> = tiles
|
||||
.iter()
|
||||
.map(|(num, _)| {
|
||||
@@ -1072,6 +1117,14 @@ mod guard_tests {
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tile_metadata_copies_are_budgeted() {
|
||||
assert!(tile_metadata_fits(64, 1 << 20));
|
||||
assert!(!tile_metadata_fits(4096, 1 << 20));
|
||||
assert!(!tile_metadata_fits(usize::MAX, 2));
|
||||
}
|
||||
|
||||
use deckcraft_bitstream::budget::DECODE_MEMORY_BUDGET;
|
||||
|
||||
fn color(mono: bool) -> crate::header::ColorConfig {
|
||||
|
||||
@@ -360,6 +360,10 @@ pub struct RefInfo {
|
||||
pub lf_deltas: LoopFilterDeltas,
|
||||
pub seg_features: SegmentationFeatures,
|
||||
pub grain: FilmGrainParams,
|
||||
/// The colour format the frame was decoded in (a later sequence header may change it).
|
||||
pub subsampling_x: u8,
|
||||
pub subsampling_y: u8,
|
||||
pub mono_chrome: bool,
|
||||
}
|
||||
|
||||
/// A parsed uncompressed frame header with its derived variables.
|
||||
|
||||
@@ -253,6 +253,13 @@ impl FrameState {
|
||||
self.lr = t.lr;
|
||||
}
|
||||
|
||||
/// Bytes of CDEF and loop-restoration metadata each parallel tile state copies whole.
|
||||
pub fn tile_metadata_bytes(&self) -> usize {
|
||||
let lr = &self.lr;
|
||||
// 1 (type) + 6 (Wiener) + 1 (SGR set) + 2 (SGR weights) bytes per unit.
|
||||
self.cdef.len() + lr.lr_type.iter().map(|v| v.len() * 10).sum::<usize>()
|
||||
}
|
||||
|
||||
/// Private buffers for the tile covering mode info rows [r0, r1) x columns [c0, c1); tiles
|
||||
/// on the right / bottom frame edge also cover the allocation margins.
|
||||
pub fn tile_state(&self, r0: usize, r1: usize, c0: usize, c1: usize) -> TileState {
|
||||
|
||||
@@ -466,22 +466,26 @@ fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape:
|
||||
|
||||
/// Run `f` on each target shape (copy-on-write), materialising inherited boxes first.
|
||||
pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let mut ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "no shapes selected"));
|
||||
}
|
||||
// A shape named twice is edited once.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
ids.retain(|id| seen.insert(*id));
|
||||
s.edit(|doc, sel| {
|
||||
let boxes: Vec<(ShapeId, Xfrm)> = {
|
||||
let st_shapes: Vec<deckcraft_model::Shape> =
|
||||
ids.iter().filter_map(|id| current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, *id)).cloned()).collect();
|
||||
st_shapes.iter().map(|sh| (sh.id, xfrm_of(doc, sel, sh))).collect()
|
||||
};
|
||||
// The boxes are read from the shapes in place: no copy of a shape (and its text) per id.
|
||||
let boxes: std::collections::HashMap<ShapeId, Xfrm> = ids
|
||||
.iter()
|
||||
.filter_map(|id| current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, *id)))
|
||||
.map(|sh| (sh.id, xfrm_of(doc, sel, sh)))
|
||||
.collect();
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
|
||||
if sh.xfrm.is_none() {
|
||||
sh.xfrm = boxes.iter().find(|(i, _)| i == id).map(|(_, x)| *x);
|
||||
sh.xfrm = boxes.get(id).copied();
|
||||
}
|
||||
f(sh)?;
|
||||
n += 1;
|
||||
|
||||
@@ -116,16 +116,31 @@ fn pack(data: &[u8], crop: [f64; 4], w_pt: f64, h_pt: f64, ppi: f64, delete_crop
|
||||
Some(Packed { bytes, content_type, crop: new_crop })
|
||||
}
|
||||
|
||||
/// Picture shapes in `shapes` (groups included) that `want` accepts.
|
||||
fn pictures_mut<'a>(shapes: &'a mut [Shape], want: &dyn Fn(ShapeId) -> bool, out: &mut Vec<&'a mut Shape>, depth: usize) {
|
||||
/// Pictures accepted by `want`, with width and height measured in slide space through group transforms.
|
||||
fn pictures_mut<'a>(
|
||||
shapes: &'a mut [Shape],
|
||||
want: &dyn Fn(ShapeId) -> bool,
|
||||
out: &mut Vec<(&'a mut Shape, f64, f64)>,
|
||||
to_slide: deckcraft_geom::Affine,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
if matches!(sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
|
||||
out.push(sh);
|
||||
} else if let Some(ch) = sh.children_mut() {
|
||||
pictures_mut(ch, want, out, depth + 1);
|
||||
let Some(xfrm) = sh.xfrm else { continue };
|
||||
let local_to_slide = to_slide * xfrm.affine();
|
||||
if matches!(&sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
|
||||
let origin = local_to_slide * deckcraft_geom::Point::ZERO;
|
||||
let across = local_to_slide * deckcraft_geom::Point::new(xfrm.w, 0.0);
|
||||
let down = local_to_slide * deckcraft_geom::Point::new(0.0, xfrm.h);
|
||||
out.push((sh, (across - origin).hypot(), (down - origin).hypot()));
|
||||
continue;
|
||||
}
|
||||
if let ShapeKind::Group { children, child } = &mut sh.kind {
|
||||
let child_rect = deckcraft_geom::Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
||||
let child_to_slide = to_slide * deckcraft_geom::group_child_affine(&xfrm, child_rect);
|
||||
pictures_mut(children, want, out, child_to_slide, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -155,10 +170,9 @@ fn compress(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(slide) = doc.slides.get(i).cloned() else { continue };
|
||||
let mut slide = slide.as_ref().clone();
|
||||
let mut pics = vec![];
|
||||
pictures_mut(&mut slide.shapes, &want, &mut pics, 0);
|
||||
pictures_mut(&mut slide.shapes, &want, &mut pics, deckcraft_geom::Affine::IDENTITY, 0);
|
||||
let mut touched = false;
|
||||
for sh in pics {
|
||||
let (w, h) = sh.xfrm.map(|x| (x.w, x.h)).unwrap_or((0.0, 0.0));
|
||||
for (sh, w, h) in pics {
|
||||
let ShapeKind::Picture { fill } = &mut sh.kind else { continue };
|
||||
let PictureFill { media, crop, .. } = fill.clone();
|
||||
let key = (media.0, crop.map(f64::to_bits), w.to_bits(), h.to_bits());
|
||||
@@ -232,6 +246,114 @@ mod tests {
|
||||
assert!(pack(b"<svg xmlns='http://www.w3.org/2000/svg'/>", [0.0; 4], 72.0, 72.0, 150.0, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_pictures_use_their_displayed_size() {
|
||||
let mut s = Session::with_new();
|
||||
let original = png(600, 600);
|
||||
s.edit(|doc, _| {
|
||||
let media = doc.add_media("grouped.png", "image/png", original.clone()).ok_or_else(|| bad("test", "media"))?;
|
||||
let picture = Shape {
|
||||
id: ShapeId(70_101),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
let nested = Shape {
|
||||
id: ShapeId(70_102),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
|
||||
..Default::default()
|
||||
};
|
||||
let group = Shape {
|
||||
id: ShapeId(70_103),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 720.0)),
|
||||
kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
|
||||
..Default::default()
|
||||
};
|
||||
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
|
||||
Arc::make_mut(slide).shapes.push(group);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
|
||||
assert_eq!(result["pictures"], 0, "a 600 px image displayed at 720×720 pt is already below the requested sampling density");
|
||||
assert!(
|
||||
s.doc().unwrap().doc.media.iter().any(|m| m.data.as_slice() == original.as_slice()),
|
||||
"the under-target original media must remain untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anisotropic_nested_group_scaling_controls_sampling_dimensions() {
|
||||
let mut s = Session::with_new();
|
||||
let original = png(2000, 1000);
|
||||
s.edit(|doc, _| {
|
||||
let media = doc.add_media("scaled-group.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
|
||||
let picture = Shape {
|
||||
id: ShapeId(70_111),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
let nested = Shape {
|
||||
id: ShapeId(70_112),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
|
||||
..Default::default()
|
||||
};
|
||||
let group = Shape {
|
||||
id: ShapeId(70_113),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 360.0)),
|
||||
kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
|
||||
..Default::default()
|
||||
};
|
||||
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
|
||||
Arc::make_mut(slide).shapes.push(group);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
|
||||
assert_eq!(result["pictures"], 1);
|
||||
let slide = s.doc().unwrap().doc.slides.first().unwrap();
|
||||
let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_113)).unwrap();
|
||||
let ShapeKind::Group { children, .. } = &picture.kind else { panic!("outer group expected") };
|
||||
let ShapeKind::Group { children, .. } = &children[0].kind else { panic!("nested group expected") };
|
||||
let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture expected") };
|
||||
let media = s.doc().unwrap().doc.media(fill.media).unwrap();
|
||||
let image = image::load_from_memory(&media.data).unwrap();
|
||||
assert_eq!((image.width(), image.height()), (960, 480));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ungrouped_picture_still_compresses_and_deletes_its_crop() {
|
||||
let mut s = Session::with_new();
|
||||
let original = png(800, 800);
|
||||
s.edit(|doc, _| {
|
||||
let media = doc.add_media("cropped.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
|
||||
let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
|
||||
Arc::make_mut(slide).shapes.push(Shape {
|
||||
id: ShapeId(70_104),
|
||||
xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, crop: [0.5, 0.0, 0.0, 0.0], ..Default::default() } },
|
||||
..Default::default()
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": true})).unwrap();
|
||||
assert_eq!(result["pictures"], 1);
|
||||
let slide = s.doc().unwrap().doc.slides.first().unwrap();
|
||||
let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_104)).unwrap();
|
||||
let ShapeKind::Picture { fill } = &picture.kind else { panic!("picture expected") };
|
||||
assert_eq!(fill.crop, [0.0; 4]);
|
||||
let media = s.doc().unwrap().doc.media(fill.media).unwrap();
|
||||
let image = image::load_from_memory(&media.data).unwrap();
|
||||
assert_eq!((image.width(), image.height()), (96, 192));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_deletes_cropped_areas_or_keeps_them() {
|
||||
let src = png(400, 400);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use super::{
|
||||
Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json,
|
||||
master_index, ok, opt_usize_param, rect_param, str_param, usize_param,
|
||||
master_index, ok, opt_usize_param, rect_param, str_param,
|
||||
};
|
||||
|
||||
pub(super) fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection));
|
||||
let mi = opt_usize_param(p, "master", "view.slideMaster")?.unwrap_or_else(|| master_index(&st.doc, &st.selection));
|
||||
// A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
|
||||
// that layout. With neither key, follow the current slide's layout.
|
||||
let layout = if p.get("master").is_some() && p.get("layout").is_none() {
|
||||
None
|
||||
} else {
|
||||
usize_param(p, "layout")
|
||||
opt_usize_param(p, "layout", "view.slideMaster")?
|
||||
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))))
|
||||
};
|
||||
let target = match layout {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use super::{
|
||||
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value,
|
||||
f64_param, ok, usize_param, with_param,
|
||||
f64_param, ok, opt_usize_param, usize_param, with_param,
|
||||
};
|
||||
|
||||
pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "copy", false) {
|
||||
return copy_slide_background(s);
|
||||
}
|
||||
// Check the index before anything is edited: a picture is added to the media first.
|
||||
let index = opt_usize_param(p, "index", "design.background")?;
|
||||
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
|
||||
let transparency = f64_param(p, "transparency");
|
||||
let reset = bool_or(p, "reset", false);
|
||||
@@ -62,7 +64,6 @@ pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
));
|
||||
};
|
||||
let all = bool_or(p, "all", false);
|
||||
let index = usize_param(p, "index");
|
||||
if matches!(op, BgOp::Adjust) {
|
||||
let next = {
|
||||
let st = s.doc()?;
|
||||
|
||||
@@ -485,3 +485,16 @@ fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_rejects_invalid_index() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let before = s.doc().unwrap().doc.slides[0].background.clone();
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("design.background", &json!({"color": "#0000C8", "index": bad})).is_err());
|
||||
assert!(s.execute("design.background", &json!({"transparency": 0.5, "index": bad})).is_err());
|
||||
assert_eq!(s.doc().unwrap().doc.slides[0].background, before);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
return Ok(plain);
|
||||
}
|
||||
let shapes: Vec<Shape> = st
|
||||
let mut shapes: Vec<Shape> = st
|
||||
.selection
|
||||
.shapes
|
||||
.iter()
|
||||
@@ -120,14 +120,33 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
})
|
||||
})
|
||||
.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 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 source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
|
||||
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() };
|
||||
// The theme of what is being edited: a slide's or layout's colour map, else the master's.
|
||||
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)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
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| {
|
||||
let media = bring_media("edit.paste", doc, &clip.media)?;
|
||||
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)?;
|
||||
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
|
||||
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.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 {
|
||||
sh.ph = None;
|
||||
}
|
||||
@@ -404,8 +431,9 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"text" | "keepTextOnly" => paste_text(s, &json!({})),
|
||||
"keepSource" | "keepSourceFormatting" => {
|
||||
let saved = s.clipboard.clone();
|
||||
if let Some((scheme, fonts)) = &saved.source_theme {
|
||||
bake_theme(&mut s.clipboard.shapes, scheme, fonts);
|
||||
if let Some(theme) = &saved.source_theme {
|
||||
bake_theme(&mut s.clipboard.shapes, theme);
|
||||
bake_theme(&mut s.clipboard.placeholders, theme);
|
||||
}
|
||||
let r = paste(s, &json!({}));
|
||||
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
|
||||
/// shapes keep their look under another theme.
|
||||
pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) {
|
||||
fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) {
|
||||
pub(crate) fn bake_theme(shapes: &mut [Shape], theme: &crate::SourceTheme) {
|
||||
fn rec(v: &mut Value, theme: &crate::SourceTheme, depth: usize) {
|
||||
if depth > 256 {
|
||||
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")
|
||||
&& let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::<deckcraft_color::SchemeSlot>(x.clone()).ok())
|
||||
&& 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.insert("kind".into(), json!("rgb"));
|
||||
@@ -438,14 +466,14 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorSc
|
||||
return;
|
||||
}
|
||||
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('+') => {
|
||||
let set = match t.get(1..3) {
|
||||
Some("mj") => &fonts.major,
|
||||
Some("mn") => &fonts.minor,
|
||||
Some("mj") => &theme.fonts.major,
|
||||
Some("mn") => &theme.fonts.minor,
|
||||
_ => return,
|
||||
};
|
||||
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() {
|
||||
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) {
|
||||
*sh = baked;
|
||||
}
|
||||
@@ -476,8 +504,8 @@ fn paste_picture(s: &mut Session) -> Result<Value> {
|
||||
if clip.shapes.is_empty() {
|
||||
return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture"));
|
||||
}
|
||||
if let Some((scheme, fonts)) = &clip.source_theme {
|
||||
bake_theme(&mut clip.shapes, scheme, fonts);
|
||||
if let Some(theme) = &clip.source_theme {
|
||||
bake_theme(&mut clip.shapes, theme);
|
||||
}
|
||||
let st = s.doc()?;
|
||||
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 sources: Vec<ShapeId> = copies.iter().map(|c| c.id).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
|
||||
// since (#966); anything else gets the usual offset.
|
||||
let (dx, dy) = last
|
||||
|
||||
@@ -253,8 +253,15 @@ pub struct LoadedFile {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_presentation_file(path: &str) -> Result<LoadedFile> {
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let (doc, damaged) = open_checked(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
loaded_file(path, &bytes)
|
||||
}
|
||||
|
||||
/// Parse `bytes` as the presentation file `path`, as [`read_presentation_file`] does after
|
||||
/// reading it. The web host bridge uses it for a file its page sent, which then saves back to
|
||||
/// that name like a file opened on the desktop.
|
||||
pub fn loaded_file(path: &str, bytes: &[u8]) -> Result<LoadedFile> {
|
||||
let (doc, damaged) = open_checked(path, bytes)?;
|
||||
let native = deckcraft_format::sniff(bytes) || deckcraft_pptx::sniff(bytes);
|
||||
Ok(LoadedFile { path: path.to_string(), doc, native, damaged })
|
||||
}
|
||||
|
||||
@@ -345,6 +352,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()),
|
||||
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"gif" => gif_bytes(doc, &Value::Null),
|
||||
"png" | "jpeg" | "jpg" => Err(EngineError::Other("saving a presentation as a single image isn't supported; use file.export".into())),
|
||||
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -359,7 +367,13 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
|
||||
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
|
||||
o.layout = match str_param(p, "layout").unwrap_or("slides") {
|
||||
"notes" => deckcraft_pdf::PageLayout::Notes,
|
||||
"handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
|
||||
"handouts" => {
|
||||
let per_page = opt_usize_param(p, "perPage", "file.export")?.unwrap_or(6);
|
||||
if ![1, 2, 3, 4, 6, 9].contains(&per_page) {
|
||||
return Err(bad("file.export", format!("perPage {per_page} isn't one of 1, 2, 3, 4, 6, 9")));
|
||||
}
|
||||
deckcraft_pdf::PageLayout::Handouts { per_page: per_page as u8 }
|
||||
}
|
||||
_ => deckcraft_pdf::PageLayout::Slides,
|
||||
};
|
||||
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
|
||||
@@ -390,6 +404,7 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
|
||||
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
|
||||
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
|
||||
let mut out: Vec<usize> = vec![];
|
||||
let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
let (a, b) = match part.split_once('-') {
|
||||
Some((a, b)) => (a.trim(), b.trim()),
|
||||
@@ -405,7 +420,7 @@ pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
|
||||
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
|
||||
}
|
||||
for i in lo - 1..hi {
|
||||
if !out.contains(&i) {
|
||||
if seen.insert(i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
@@ -462,7 +477,10 @@ pub fn format_for_path(path: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
|
||||
/// The active presentation is saved as it is now: no unsaved changes, and `path` (when given)
|
||||
/// becomes its file. `file.save` calls it after writing; the web host bridge calls it once the
|
||||
/// page has the bytes.
|
||||
pub fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
|
||||
let st = s.doc_mut()?;
|
||||
st.saved_doc = st.doc.clone();
|
||||
if path.is_some() {
|
||||
@@ -769,6 +787,16 @@ mod tests {
|
||||
|
||||
use super::{parse_slide_range, write_file};
|
||||
|
||||
#[test]
|
||||
fn broad_and_repeated_slide_ranges_expand_once() {
|
||||
// 200 copies of a 100,000-slide range: each index once, in order, without a scan per index.
|
||||
let text = vec!["1-100000"; 200].join(",");
|
||||
let v = parse_slide_range(&text, 100_000).unwrap();
|
||||
assert_eq!(v.len(), 100_000);
|
||||
assert!(v.windows(2).all(|w| w[0] < w[1]));
|
||||
assert_eq!(parse_slide_range("3, 1-3, 2", 3).unwrap(), vec![2, 0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_range_rejects_plus_sign() {
|
||||
assert!(parse_slide_range("+2", 3).is_err());
|
||||
|
||||
@@ -14,6 +14,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justifyLow" | "justLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
"thaiDist" => Align::ThaiDistributed,
|
||||
_ => Align::Left,
|
||||
};
|
||||
align_to(s, a)
|
||||
@@ -55,6 +56,7 @@ pub(super) fn bullets(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if size.is_some() {
|
||||
para.props.bullet_size = size;
|
||||
para.props.bullet_size_points = None;
|
||||
para.props.bullet_size_follow_text = None;
|
||||
}
|
||||
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
|
||||
@@ -85,6 +87,7 @@ pub(super) fn numbering(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if size.is_some() {
|
||||
para.props.bullet_size = size;
|
||||
para.props.bullet_size_points = None;
|
||||
para.props.bullet_size_follow_text = None;
|
||||
}
|
||||
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
|
||||
@@ -117,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> {
|
||||
let a = str_param(p, "align").map(|a| match a {
|
||||
"center" => Align::Center,
|
||||
"right" => Align::Right,
|
||||
"justify" => Align::Justify,
|
||||
"distributed" => Align::Distributed,
|
||||
_ => Align::Left,
|
||||
});
|
||||
let a = str_param(p, "align").map(|a| parse_align("format.paragraph", a)).transpose()?;
|
||||
let ml = f64_param(p, "indentLeft");
|
||||
let ind = f64_param(p, "indentFirst");
|
||||
let sb = f64_param(p, "spaceBefore");
|
||||
@@ -179,12 +176,13 @@ pub(super) fn clamp_pt(v: f64) -> f64 {
|
||||
fn parse_align(cmd: &str, s: &str) -> Result<Align> {
|
||||
Ok(match s {
|
||||
"left" | "l" => Align::Left,
|
||||
"center" | "ctr" => Align::Center,
|
||||
"center" | "centre" | "ctr" => Align::Center,
|
||||
"right" | "r" => Align::Right,
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justLow" => Align::JustLow,
|
||||
"justLow" | "justifyLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")),
|
||||
"thaiDist" => Align::ThaiDistributed,
|
||||
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,6 +194,7 @@ fn align_name(a: Align) -> &'static str {
|
||||
Align::Justify => "justify",
|
||||
Align::JustLow => "justLow",
|
||||
Align::Distributed => "distributed",
|
||||
Align::ThaiDistributed => "thaiDist",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,12 @@ pub(super) fn photo_album_edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let opts = album_opts(p)?;
|
||||
let items = album_items(p)?;
|
||||
let (id, ids) = album_target(s, p)?;
|
||||
// The editor is seeded within the album limits; replacing the album from a trimmed seed would
|
||||
// delete what it left out, so refuse rather than lose it.
|
||||
let st = s.doc()?;
|
||||
if items_from_slides(&st.doc, &album_slides(st, &ids)).1 {
|
||||
return Err(bad("insert.photoAlbumEdit", "this album holds more than the editor can carry; editing it would delete the rest"));
|
||||
}
|
||||
commit_album(s, items, opts, Some((id, ids)))
|
||||
}
|
||||
|
||||
@@ -545,6 +551,19 @@ fn album_target(s: &Session, p: &Value) -> Result<(u32, Vec<u64>)> {
|
||||
Err(bad("insert.photoAlbumEdit", "this slide is not part of a photo album"))
|
||||
}
|
||||
|
||||
/// Whether a slide holds anything besides album items: shapes, notes, comments, a background, a
|
||||
/// transition, animations, a colour map or tags.
|
||||
fn has_slide_content(s: &deckcraft_model::Slide) -> bool {
|
||||
!s.shapes.is_empty()
|
||||
|| !s.notes.text().trim().is_empty()
|
||||
|| !s.comments.is_empty()
|
||||
|| s.background.is_some()
|
||||
|| s.transition.is_some()
|
||||
|| !s.animations.is_empty()
|
||||
|| s.color_map.is_some()
|
||||
|| !s.tags.is_empty()
|
||||
}
|
||||
|
||||
fn pic_count(slide: &deckcraft_model::Slide) -> usize {
|
||||
slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).count()
|
||||
}
|
||||
@@ -592,9 +611,16 @@ fn infer_album(slides: &[&deckcraft_model::Slide], theme_now: &str) -> (String,
|
||||
(layout, frame.into(), captions, any && gray, theme_now.to_string())
|
||||
}
|
||||
|
||||
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> Vec<Value> {
|
||||
/// The album's items for the dialog, within the limits `insert.photoAlbum` itself takes (items,
|
||||
/// bytes per picture and in all), checked before a picture is encoded: an album slide set can
|
||||
/// name one large picture many times.
|
||||
///
|
||||
/// The flag is true when something was left out for a limit.
|
||||
fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_model::Slide]) -> (Vec<Value>, bool) {
|
||||
let mut items = Vec::new();
|
||||
for slide in slides {
|
||||
let mut total = 0usize;
|
||||
let mut omitted = false;
|
||||
'slides: for slide in slides {
|
||||
let pics: Vec<&deckcraft_model::Shape> = slide.shapes.iter().filter(|sh| matches!(sh.kind, ShapeKind::Picture { .. })).collect();
|
||||
if pics.is_empty() {
|
||||
let text = slide
|
||||
@@ -605,13 +631,27 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
|
||||
.find(|t| !t.trim().is_empty())
|
||||
.unwrap_or_default();
|
||||
if !text.trim().is_empty() {
|
||||
if items.len() >= ALBUM_MAX_ITEMS {
|
||||
omitted = true;
|
||||
break 'slides;
|
||||
}
|
||||
items.push(json!({"text": text.trim()}));
|
||||
} else if items.len() >= ALBUM_MAX_ITEMS && has_slide_content(slide) {
|
||||
// Past the item limit, a slide with other content would be deleted by a replace.
|
||||
omitted = true;
|
||||
break 'slides;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for sh in pics {
|
||||
let ShapeKind::Picture { fill } = &sh.kind else { continue };
|
||||
let media = doc.media(fill.media);
|
||||
let size = media.map_or(0, |m| m.data.len());
|
||||
if items.len() >= ALBUM_MAX_ITEMS || size > ALBUM_MAX_BYTES || total.saturating_add(size) > ALBUM_MAX_TOTAL {
|
||||
omitted = true;
|
||||
break 'slides;
|
||||
}
|
||||
total += size;
|
||||
let name = if !sh.descr.is_empty() {
|
||||
sh.descr.clone()
|
||||
} else {
|
||||
@@ -626,14 +666,19 @@ fn items_from_slides(doc: &deckcraft_model::Presentation, slides: &[&deckcraft_m
|
||||
}));
|
||||
}
|
||||
}
|
||||
items
|
||||
(items, omitted)
|
||||
}
|
||||
|
||||
/// The slides of album `ids` as the editor would seed them.
|
||||
fn album_slides<'a>(st: &'a crate::DocState, ids: &[u64]) -> Vec<&'a deckcraft_model::Slide> {
|
||||
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect()
|
||||
}
|
||||
|
||||
fn album_seed(s: &Session) -> Result<Value> {
|
||||
let (id, ids) = album_target(s, &Value::Null)?;
|
||||
let st = s.doc()?;
|
||||
let slides: Vec<&deckcraft_model::Slide> =
|
||||
ids.iter().filter_map(|sid| u32::try_from(*sid).ok()).filter_map(|n| st.doc.slide(deckcraft_model::SlideId(n))).collect();
|
||||
let slides = album_slides(st, &ids);
|
||||
let (items, _) = items_from_slides(&st.doc, &slides);
|
||||
let theme_now = st.doc.masters.first().map(|m| m.theme.name.clone()).filter(|n| !n.is_empty()).unwrap_or_else(|| "DeckCraft".into());
|
||||
let inferred = infer_album(&slides, &theme_now);
|
||||
let mark = slides.first().and_then(|sl| sl.raw_ext.as_deref().and_then(parse_album_mark));
|
||||
@@ -655,7 +700,7 @@ fn album_seed(s: &Session) -> Result<Value> {
|
||||
"captions": captions,
|
||||
"grayscale": grayscale,
|
||||
"theme": theme,
|
||||
"items": items_from_slides(&st.doc, &slides),
|
||||
"items": items,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -722,3 +767,96 @@ fn insert_album(s: &mut Session, items: Vec<AlbumItem>, opts: &AlbumOpts, at: Op
|
||||
s.execute("slide.go", &json!({"index": first}))?;
|
||||
Ok(json!({"slides": slides, "first": first, "pictures": pictures, "album": album_id}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod seed_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reopening_an_album_seeds_at_most_the_items_an_album_takes() {
|
||||
// One picture named by 500 shapes: the dialog gets 48 items, not 500 encoded copies.
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let img = p.add_media("a.png", "image/png", vec![1; 64 * 1024]).unwrap();
|
||||
let pic = |k: u32| Shape {
|
||||
id: ShapeId(10_000 + k),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = (0..500).map(pic).collect();
|
||||
let slides: Vec<&deckcraft_model::Slide> = p.slides.iter().map(|s| s.as_ref()).collect();
|
||||
let (items, omitted) = items_from_slides(&p, &slides);
|
||||
assert_eq!(items.len(), ALBUM_MAX_ITEMS);
|
||||
assert!(omitted);
|
||||
let few: Vec<&deckcraft_model::Slide> = Vec::new();
|
||||
assert!(!items_from_slides(&p, &few).1);
|
||||
}
|
||||
|
||||
/// An album too big for the editor's seed cannot be replaced from that seed: the replace would
|
||||
/// delete what the seed left out.
|
||||
#[test]
|
||||
fn editing_an_album_the_editor_cannot_hold_is_refused() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let mut extra = doc.slides.last().unwrap().as_ref().clone();
|
||||
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
|
||||
doc.slides.push(Arc::new(extra));
|
||||
let n = doc.slides.len();
|
||||
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("delete the rest"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), n);
|
||||
}
|
||||
|
||||
/// Past the item limit, a slide holding only a drawing counts as content the replace would delete.
|
||||
#[test]
|
||||
fn editing_an_album_with_a_drawing_slide_past_the_limit_is_refused() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let mut extra = doc.slides.last().unwrap().as_ref().clone();
|
||||
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
|
||||
extra.shapes = vec![Shape { id: ShapeId(90_000), ..Default::default() }];
|
||||
doc.slides.push(Arc::new(extra));
|
||||
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("delete the rest"), "{e}");
|
||||
}
|
||||
|
||||
/// Notes or comments on a shapeless slide past the limit are content a replace would delete.
|
||||
#[test]
|
||||
fn editing_an_album_with_a_notes_only_slide_past_the_limit_is_refused() {
|
||||
let mut s = crate::Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let texts: Vec<Value> = (0..ALBUM_MAX_ITEMS).map(|i| json!({"text": format!("t{i}")})).collect();
|
||||
s.execute("insert.photoAlbum", &json!({"pictures": texts, "layout": "1"})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = Arc::make_mut(&mut st.doc);
|
||||
let mut extra = doc.slides.last().unwrap().as_ref().clone();
|
||||
extra.id = deckcraft_model::SlideId(doc.alloc_id().unwrap());
|
||||
extra.shapes.clear();
|
||||
extra.notes = deckcraft_model::text::TextBody::from_text("speaker notes");
|
||||
doc.slides.push(Arc::new(extra));
|
||||
let e = s.execute("insert.photoAlbumEdit", &json!({"pictures": [{"text": "x"}], "layout": "1"})).unwrap_err().to_string();
|
||||
assert!(e.contains("delete the rest"), "{e}");
|
||||
}
|
||||
|
||||
/// A shapeless slide with a background, transition, colour map or tags counts as content; a plain empty one does not.
|
||||
#[test]
|
||||
fn slide_level_settings_count_as_slide_content() {
|
||||
use deckcraft_model::Slide;
|
||||
assert!(!has_slide_content(&Slide::default()));
|
||||
assert!(has_slide_content(&Slide { tags: vec![("k".into(), "v".into())], ..Default::default() }));
|
||||
assert!(has_slide_content(&Slide { color_map: Some(vec![("bg1".into(), "lt1".into())]), ..Default::default() }));
|
||||
assert!(has_slide_content(&Slide { transition: Some(Default::default()), ..Default::default() }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,8 +246,17 @@ fn merge(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
list.insert(at + k, sh);
|
||||
}
|
||||
list.retain(|x| !ids.contains(&x.id));
|
||||
sel.shapes = new_ids.clone();
|
||||
let _ = list;
|
||||
if sel.target == crate::Target::Slides
|
||||
&& let Some(sl) = doc.slides.get_mut(sel.slide)
|
||||
{
|
||||
let sl = std::sync::Arc::make_mut(sl);
|
||||
sl.animations.retain(|a| !ids.contains(&a.shape));
|
||||
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| ids.contains(&t))) {
|
||||
a.trigger = None;
|
||||
}
|
||||
}
|
||||
sel.shapes = new_ids.clone();
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"ids": new_ids.iter().map(|i| i.0).collect::<Vec<_>>()}))
|
||||
@@ -338,4 +347,36 @@ mod tests {
|
||||
assert!(matches!(out.kind, ShapeKind::Shape) && out.text.is_none());
|
||||
assert!(matches!(out.fill, Some(deckcraft_model::style::Fill::Solid { .. })), "{:?}", out.fill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_cleans_animation_targets_and_triggers_for_every_operation_and_undo_restores_them() {
|
||||
for op in ["union", "combine", "fragment", "intersect", "subtract"] {
|
||||
let mut s = Session::with_new();
|
||||
let first = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 100, 100]})).expect("first shape")["id"]
|
||||
.as_u64()
|
||||
.expect("id") as u32;
|
||||
let second = s.execute("shape.insert", &json!({"preset": "rect", "rect": [50, 0, 100, 100]})).expect("second shape")["id"]
|
||||
.as_u64()
|
||||
.expect("id") as u32;
|
||||
let unrelated = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 0, 100, 100]})).expect("unrelated shape")["id"]
|
||||
.as_u64()
|
||||
.expect("id") as u32;
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [second]})).expect("operand animation");
|
||||
s.execute("animation.add", &json!({"effect": "spin", "ids": [unrelated]})).expect("unrelated animation");
|
||||
s.execute("animation.timing", &json!({"index": 1, "trigger": first})).expect("trigger animation");
|
||||
|
||||
s.execute("shape.merge", &json!({"op": op, "ids": [first, second]})).expect("merge");
|
||||
let animations = &s.doc().expect("doc").doc.slides[0].animations;
|
||||
assert_eq!(animations.len(), 1, "{op} removed animations for merged operands");
|
||||
assert_eq!(animations[0].shape.0, unrelated, "{op} preserved the unrelated effect");
|
||||
assert_eq!(animations[0].trigger, None, "{op} cleared the removed shape trigger");
|
||||
|
||||
s.execute("edit.undo", &json!({})).expect("undo merge");
|
||||
let animations = &s.doc().expect("doc").doc.slides[0].animations;
|
||||
assert_eq!(animations.len(), 2, "{op} undo restored removed effects");
|
||||
assert_eq!(animations[0].shape.0, second);
|
||||
assert_eq!(animations[1].shape.0, unrelated);
|
||||
assert_eq!(animations[1].trigger.map(|id| id.0), Some(first));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +135,14 @@ pub fn apply(sh: &mut Shape, id: &str) -> bool {
|
||||
let e = Effects {
|
||||
outer_shadow: lk.shadow.map(shadow),
|
||||
soft_edge: lk.soft_edge,
|
||||
reflection: lk.reflection.then_some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
|
||||
reflection: lk.reflection.then_some(Reflection {
|
||||
blur: 0.5,
|
||||
start_alpha: 0.5,
|
||||
end_alpha: 0.0,
|
||||
end_pos: 0.35,
|
||||
dist: 0.0,
|
||||
..Default::default()
|
||||
}),
|
||||
bevel: lk.bevel.map(str::to_string),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -6,16 +6,155 @@
|
||||
//! renumbered with animations, triggers and connector ends following them. Links to another
|
||||
//! reused slide follow its copy.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::table::TableStyleDef;
|
||||
use deckcraft_model::text::{Action, Hyperlink, ListStyle, RunProps, Spacing, TextBody};
|
||||
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
|
||||
use crate::{Result, Session, media_refs};
|
||||
|
||||
/// Scale physical measurements in imported content to the destination slide's coordinate space.
|
||||
/// Text has one size, so it uses the geometric mean of the horizontal and vertical scale.
|
||||
fn scale_text(t: &mut TextBody, sx: f64, sy: f64) {
|
||||
let unit = (sx * sy).sqrt();
|
||||
for v in [&mut t.body.inset_l, &mut t.body.inset_r, &mut t.body.col_spacing].into_iter().flatten() {
|
||||
*v *= sx;
|
||||
}
|
||||
for v in [&mut t.body.inset_t, &mut t.body.inset_b].into_iter().flatten() {
|
||||
*v *= sy;
|
||||
}
|
||||
scale_list(&mut t.list_style, sx, unit);
|
||||
for p in &mut t.paragraphs {
|
||||
for v in [&mut p.props.margin_left, &mut p.props.margin_right, &mut p.props.indent, &mut p.props.default_tab].into_iter().flatten() {
|
||||
*v *= sx;
|
||||
}
|
||||
if let Some(stops) = &mut p.props.tabs {
|
||||
for stop in stops {
|
||||
stop.pos *= sx;
|
||||
}
|
||||
}
|
||||
for spacing in [&mut p.props.line_spacing, &mut p.props.space_before, &mut p.props.space_after] {
|
||||
if let Some(Spacing::Pts(v)) = spacing {
|
||||
*v *= unit;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &mut p.props.bullet_size_points {
|
||||
*v *= unit;
|
||||
}
|
||||
scale_run(&mut p.def_props, unit);
|
||||
scale_run(&mut p.end_props, unit);
|
||||
for r in &mut p.runs {
|
||||
scale_run(&mut r.props, unit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_list(list: &mut ListStyle, sx: f64, unit: f64) {
|
||||
for level in list.levels.iter_mut().flatten() {
|
||||
for v in
|
||||
[&mut level.para.margin_left, &mut level.para.margin_right, &mut level.para.indent, &mut level.para.default_tab].into_iter().flatten()
|
||||
{
|
||||
*v *= sx;
|
||||
}
|
||||
for spacing in [&mut level.para.line_spacing, &mut level.para.space_before, &mut level.para.space_after] {
|
||||
if let Some(Spacing::Pts(v)) = spacing {
|
||||
*v *= unit;
|
||||
}
|
||||
}
|
||||
if let Some(v) = &mut level.para.bullet_size_points {
|
||||
*v *= unit;
|
||||
}
|
||||
scale_run(&mut level.run, unit);
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_run(run: &mut RunProps, factor: f64) {
|
||||
for v in [&mut run.size, &mut run.spacing, &mut run.kern, &mut run.soft_edge].into_iter().flatten() {
|
||||
*v *= factor;
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_shapes(shapes: &mut [deckcraft_model::Shape], sx: f64, sy: f64, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
let unit = (sx * sy).sqrt();
|
||||
for shape in shapes {
|
||||
if let Some(x) = &mut shape.xfrm {
|
||||
x.x *= sx;
|
||||
x.w *= sx;
|
||||
x.y *= sy;
|
||||
x.h *= sy;
|
||||
}
|
||||
if let Some(text) = &mut shape.text {
|
||||
scale_text(text, sx, sy);
|
||||
}
|
||||
match &mut shape.kind {
|
||||
ShapeKind::Table(table) => {
|
||||
for col in &mut table.cols {
|
||||
*col *= sx;
|
||||
}
|
||||
for row in &mut table.rows {
|
||||
row.height *= sy;
|
||||
for cell in &mut row.cells {
|
||||
if let Some(margins) = &mut cell.margins {
|
||||
if let Some(v) = margins.get_mut(0) {
|
||||
*v *= sx;
|
||||
}
|
||||
if let Some(v) = margins.get_mut(1) {
|
||||
*v *= sx;
|
||||
}
|
||||
if let Some(v) = margins.get_mut(2) {
|
||||
*v *= sy;
|
||||
}
|
||||
if let Some(v) = margins.get_mut(3) {
|
||||
*v *= sy;
|
||||
}
|
||||
}
|
||||
scale_text(&mut cell.text, sx, sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
ShapeKind::Group { children, child } => {
|
||||
child.x *= sx;
|
||||
child.w *= sx;
|
||||
child.y *= sy;
|
||||
child.h *= sy;
|
||||
scale_shapes(children, sx, sy, depth + 1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(effects) = &mut shape.effects {
|
||||
for shadow in [&mut effects.outer_shadow, &mut effects.inner_shadow].into_iter().flatten() {
|
||||
shadow.blur *= unit;
|
||||
shadow.dist *= unit;
|
||||
}
|
||||
if let Some(glow) = &mut effects.glow {
|
||||
glow.radius *= unit;
|
||||
}
|
||||
if let Some(reflection) = &mut effects.reflection {
|
||||
reflection.blur *= unit;
|
||||
reflection.dist *= unit;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scale_master(master: &mut Master, sx: f64, sy: f64) {
|
||||
scale_shapes(&mut master.shapes, sx, sy, 0);
|
||||
let unit = (sx * sy).sqrt();
|
||||
scale_list(&mut master.title_style, sx, unit);
|
||||
scale_list(&mut master.body_style, sx, unit);
|
||||
scale_list(&mut master.other_style, sx, unit);
|
||||
for layout in &mut master.layouts {
|
||||
scale_shapes(&mut layout.shapes, sx, sy, 0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
@@ -152,6 +291,90 @@ fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::Layout
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn collect_table_styles(shapes: &[deckcraft_model::Shape], styles: &mut Vec<String>, seen: &mut HashSet<String>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for shape in shapes {
|
||||
if let ShapeKind::Table(table) = &shape.kind
|
||||
&& seen.insert(table.style.to_ascii_lowercase())
|
||||
{
|
||||
styles.push(table.style.clone());
|
||||
}
|
||||
collect_table_styles(shape.children(), styles, seen, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn remap_table_styles(shapes: &mut [deckcraft_model::Shape], styles: &HashMap<String, String>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for shape in shapes {
|
||||
if let ShapeKind::Table(table) = &mut shape.kind
|
||||
&& let Some(style) = styles.get(&table.style.to_ascii_lowercase())
|
||||
{
|
||||
table.style = style.clone();
|
||||
}
|
||||
if let Some(children) = shape.children_mut() {
|
||||
remap_table_styles(children, styles, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_table_style(a: &TableStyleDef, b: &TableStyleDef) -> bool {
|
||||
a.name == b.name && a.parts == b.parts && a.background == b.background && a.raw == b.raw
|
||||
}
|
||||
|
||||
/// Copy only definitions used on selected slides. Conflicting GUIDs receive a fresh GUID so
|
||||
/// existing destination tables continue to resolve to their original definitions.
|
||||
fn import_table_styles(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[String]) -> Result<HashMap<String, String>> {
|
||||
let mut map = HashMap::new();
|
||||
let mut source_defs = HashMap::new();
|
||||
let mut source_ids = HashSet::new();
|
||||
for (index, style) in src.table_styles.iter().enumerate() {
|
||||
let key = style.id.to_ascii_lowercase();
|
||||
source_defs.entry(key.clone()).or_insert(index);
|
||||
source_ids.insert(key);
|
||||
}
|
||||
let mut destination_defs = HashMap::new();
|
||||
for (index, style) in doc.table_styles.iter().enumerate() {
|
||||
destination_defs.entry(style.id.to_ascii_lowercase()).or_insert(index);
|
||||
}
|
||||
for id in used {
|
||||
let key = id.to_ascii_lowercase();
|
||||
if map.contains_key(&key) {
|
||||
continue;
|
||||
}
|
||||
let Some(source_index) = source_defs.get(&key).copied() else { continue };
|
||||
let Some(source) = src.table_styles.get(source_index) else { continue };
|
||||
let destination = destination_defs.get(&key).and_then(|index| doc.table_styles.get(*index));
|
||||
let mut imported = source.clone();
|
||||
if let Some(existing) = destination {
|
||||
if same_table_style(existing, source) {
|
||||
map.insert(key, existing.id.clone());
|
||||
continue;
|
||||
}
|
||||
let candidate = loop {
|
||||
let n = doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?;
|
||||
let candidate = format!("{{00000000-0000-4000-8000-{n:012X}}}");
|
||||
let key = candidate.to_ascii_lowercase();
|
||||
if !destination_defs.contains_key(&key) && !source_ids.contains(&key) {
|
||||
break candidate;
|
||||
}
|
||||
};
|
||||
// The definition's own XML names it too, and PPTX export writes that XML as is.
|
||||
imported.raw = imported.raw.replacen(&format!("styleId=\"{}\"", source.id), &format!("styleId=\"{candidate}\""), 1);
|
||||
imported.id.clone_from(&candidate);
|
||||
map.insert(key, candidate);
|
||||
} else {
|
||||
map.insert(key, imported.id.clone());
|
||||
}
|
||||
destination_defs.insert(imported.id.to_ascii_lowercase(), doc.table_styles.len());
|
||||
doc.table_styles.push(imported);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = super::insert::media_bytes(p, "slide.reuse")?;
|
||||
let src = super::file::open_presentation(&name, &bytes)?;
|
||||
@@ -176,8 +399,23 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let keep = bool_or(p, "keepFormatting", false);
|
||||
let at = usize_param(p, "at");
|
||||
let source_size = src.slide_size;
|
||||
s.edit(|doc, sel| {
|
||||
let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len());
|
||||
let mut used_table_styles = Vec::new();
|
||||
let mut seen_table_styles = HashSet::new();
|
||||
for &i in &picked {
|
||||
if let Some(slide) = src.slides.get(i) {
|
||||
collect_table_styles(&slide.shapes, &mut used_table_styles, &mut seen_table_styles, 0);
|
||||
}
|
||||
}
|
||||
let table_style_map = import_table_styles("slide.reuse", doc, &src, &used_table_styles)?;
|
||||
let scale = |source: f64, destination: f64| {
|
||||
let ratio = destination / source;
|
||||
if source > 0.0 && destination > 0.0 && ratio.is_finite() && ratio > 0.0 { ratio } else { 1.0 }
|
||||
};
|
||||
let sx = scale(source_size.width, doc.slide_size.width);
|
||||
let sy = scale(source_size.height, doc.slide_size.height);
|
||||
// Media: what the slides use is copied under fresh ids.
|
||||
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
|
||||
// Keep Source Formatting: bring each source master used, once, with fresh ids.
|
||||
@@ -190,7 +428,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
masters_done.push(m.id);
|
||||
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
|
||||
let mut m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
|
||||
scale_master(&mut m, sx, sy);
|
||||
doc.masters.push(Arc::new(m));
|
||||
}
|
||||
}
|
||||
@@ -201,6 +440,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some(from) = src.slides.get(i) else { continue };
|
||||
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
let mut sl: Slide = from.as_ref().clone();
|
||||
remap_table_styles(&mut sl.shapes, &table_style_map, 0);
|
||||
scale_shapes(&mut sl.shapes, sx, sy, 0);
|
||||
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
|
||||
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
|
||||
@@ -284,13 +525,205 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_geom::{Size, Xfrm};
|
||||
use deckcraft_model::style::{Artistic, Fill, PictureFill};
|
||||
use deckcraft_model::table::TableStyleDef;
|
||||
use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody};
|
||||
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults};
|
||||
use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, Table, defaults};
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
const CUSTOM_STYLE: &str = "{11111111-2222-4333-8444-555555555555}";
|
||||
|
||||
fn styled_table_presentation(id: &str, color: &str) -> Presentation {
|
||||
let mut p = deckcraft_model::defaults::new_presentation(None);
|
||||
let raw = format!(
|
||||
r#"<a:tblStyle xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" styleId="{id}" styleName="Owned"><a:wholeTbl><a:tcStyle><a:fill><a:solidFill><a:srgbClr val="{color}"/></a:solidFill></a:fill></a:tcStyle></a:wholeTbl></a:tblStyle>"#
|
||||
);
|
||||
p.table_styles.push(TableStyleDef { id: id.into(), name: "Owned".into(), raw, ..Default::default() });
|
||||
let table = deckcraft_model::Table { style: id.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
|
||||
let child = deckcraft_model::Shape { id: ShapeId(p.alloc_id().expect("shape id")), kind: ShapeKind::Table(table), ..Default::default() };
|
||||
let group = deckcraft_model::Shape {
|
||||
id: ShapeId(p.alloc_id().expect("group id")),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
let slide = p.slides.first_mut().expect("default slide");
|
||||
Arc::make_mut(slide).shapes.push(group);
|
||||
p
|
||||
}
|
||||
|
||||
fn first_table_style(shapes: &[deckcraft_model::Shape]) -> Option<String> {
|
||||
for shape in shapes {
|
||||
if let ShapeKind::Table(table) = &shape.kind {
|
||||
return Some(table.style.clone());
|
||||
}
|
||||
if let Some(style) = first_table_style(shape.children()) {
|
||||
return Some(style);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_grouped_table_styles_survive_native_and_pptx_round_trips_and_undo() {
|
||||
for (name, bytes) in [
|
||||
("source.deckcraft", deckcraft_format::save(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("native source")),
|
||||
("source.pptx", deckcraft_pptx::export(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("pptx source")),
|
||||
] {
|
||||
let data = super::super::base64_encode(&bytes);
|
||||
let mut session = Session::with_new();
|
||||
session.execute("slide.reuse", &json!({"data": data, "name": name, "slides": [0], "keepFormatting": true})).expect("reuse");
|
||||
let doc = &session.doc().expect("doc").doc;
|
||||
assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
|
||||
assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("FF0000")));
|
||||
assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
|
||||
|
||||
session.execute("edit.undo", &json!({})).expect("undo reuse");
|
||||
let doc = &session.doc().expect("doc").doc;
|
||||
assert!(doc.table_styles.iter().all(|style| style.id != CUSTOM_STYLE));
|
||||
session.execute("edit.redo", &json!({})).expect("redo reuse");
|
||||
let bytes = crate::cmd::file::save_bytes(&session.doc().expect("doc").doc, "deckcraft").expect("save destination");
|
||||
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &bytes).expect("reopen destination");
|
||||
assert!(reopened.table_styles.iter().any(|style| style.id == CUSTOM_STYLE));
|
||||
assert_eq!(first_table_style(&reopened.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_table_style_definitions_are_deduplicated_or_remapped_on_collision() {
|
||||
let source = styled_table_presentation(CUSTOM_STYLE, "FF0000");
|
||||
let source_bytes = deckcraft_format::save(&source).expect("save source");
|
||||
|
||||
let mut identical = Session::with_new();
|
||||
identical
|
||||
.edit(|doc, _| {
|
||||
doc.table_styles.push(source.table_styles.first().cloned().unwrap_or_default());
|
||||
Ok(())
|
||||
})
|
||||
.expect("seed identical definition");
|
||||
identical
|
||||
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
|
||||
.expect("reuse identical definition");
|
||||
let doc = &identical.doc().expect("doc").doc;
|
||||
assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
|
||||
assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
|
||||
|
||||
let mut conflicting = Session::with_new();
|
||||
conflicting
|
||||
.edit(|doc, _| {
|
||||
let mut own = source.table_styles.first().cloned().unwrap_or_default();
|
||||
own.raw = own.raw.replace("FF0000", "0000FF");
|
||||
doc.table_styles.push(own);
|
||||
let table = deckcraft_model::Table { style: CUSTOM_STYLE.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
|
||||
let id = ShapeId(doc.alloc_id().expect("destination table id"));
|
||||
if let Some(slide) = doc.slides.first_mut() {
|
||||
Arc::make_mut(slide).shapes.push(Shape { id, kind: ShapeKind::Table(table), ..Default::default() });
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.expect("seed conflicting definition");
|
||||
conflicting
|
||||
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
|
||||
.expect("reuse conflicting definition");
|
||||
let doc = &conflicting.doc().expect("doc").doc;
|
||||
assert_eq!(first_table_style(&doc.slides.first().expect("destination slide").shapes).as_deref(), Some(CUSTOM_STYLE));
|
||||
let imported_style = first_table_style(&doc.slides.get(1).expect("reused slide").shapes).expect("grouped table style");
|
||||
assert_ne!(imported_style, CUSTOM_STYLE);
|
||||
assert!(
|
||||
doc.table_styles.iter().any(|style| style.id == imported_style
|
||||
&& style.raw.contains("FF0000")
|
||||
&& style.raw.contains(&format!("styleId=\"{imported_style}\"")))
|
||||
);
|
||||
assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("0000FF")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_slide_content_fits_destination_size() {
|
||||
let mut src = defaults::new_presentation(None);
|
||||
src.slide_size = Size::new(960.0, 540.0);
|
||||
let slide_id = src.slides.first().map(|s| s.id).unwrap_or_default();
|
||||
if let Some(slide) = src.slides.first_mut().map(Arc::make_mut) {
|
||||
let mut text = TextBody::from_text("scaled");
|
||||
if let Some(run) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
|
||||
run.props.size = Some(24.0);
|
||||
}
|
||||
slide.shapes = vec![
|
||||
Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 200.0, 100.0)), ..Default::default() },
|
||||
Shape {
|
||||
xfrm: Some(Xfrm::new(40.0, 50.0, 400.0, 200.0)),
|
||||
text: Some(text),
|
||||
kind: ShapeKind::Table({
|
||||
let mut table = Table::new(2, 2, 400.0, 40.0);
|
||||
table.rows[0].cells[0].text = TextBody::from_text("cell");
|
||||
if let Some(run) = table.rows[0].cells[0].text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
|
||||
run.props.size = Some(18.0);
|
||||
}
|
||||
table
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
Shape {
|
||||
xfrm: Some(Xfrm::new(100.0, 80.0, 300.0, 150.0)),
|
||||
kind: ShapeKind::Group {
|
||||
child: Xfrm::new(0.0, 0.0, 300.0, 150.0),
|
||||
children: vec![Shape { xfrm: Some(Xfrm::new(200.0, 100.0, 80.0, 40.0)), ..Default::default() }],
|
||||
},
|
||||
..Default::default()
|
||||
},
|
||||
];
|
||||
}
|
||||
if let Some(master) = src.masters.first_mut().map(Arc::make_mut) {
|
||||
master.shapes = vec![Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 100.0, 50.0)), ..Default::default() }];
|
||||
if let Some(layout) = master.layouts.first_mut() {
|
||||
layout.shapes = vec![Shape { xfrm: Some(Xfrm::new(700.0, 300.0, 100.0, 50.0)), ..Default::default() }];
|
||||
}
|
||||
}
|
||||
let bytes = deckcraft_format::save(&src).expect("source save");
|
||||
let data = super::super::base64_encode(&bytes);
|
||||
let mut session = Session::with_new();
|
||||
session
|
||||
.edit(|doc, _| {
|
||||
doc.slide_size = Size::new(480.0, 270.0);
|
||||
Ok(())
|
||||
})
|
||||
.expect("destination size");
|
||||
session.execute("slide.reuse", &json!({"data": data, "name": "source.deckcraft", "slides": [0], "keepFormatting": true})).expect("reuse");
|
||||
|
||||
let doc = &session.doc().expect("doc").doc;
|
||||
let reused = doc.slides.get(1).expect("reused slide");
|
||||
let first = reused.shapes.first().and_then(|s| s.xfrm).expect("shape transform");
|
||||
assert_eq!(first, Xfrm::new(400.0, 200.0, 100.0, 50.0));
|
||||
let table_shape = reused.shapes.get(1).expect("table");
|
||||
let table_box = table_shape.xfrm.expect("table transform");
|
||||
assert_eq!(table_box, Xfrm::new(20.0, 25.0, 200.0, 100.0));
|
||||
assert!(matches!(&table_shape.kind, ShapeKind::Table(_)));
|
||||
let ShapeKind::Table(table) = &table_shape.kind else { return };
|
||||
assert_eq!(table.cols, vec![100.0, 100.0]);
|
||||
assert_eq!(table.rows.first().map(|r| r.height), Some(20.0));
|
||||
let font_size = table_shape.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).and_then(|r| r.props.size);
|
||||
assert_eq!(font_size, Some(12.0));
|
||||
let cell_size = table.rows[0].cells[0].text.paragraphs.first().and_then(|p| p.runs.first()).and_then(|r| r.props.size);
|
||||
assert_eq!(cell_size, Some(9.0));
|
||||
let group = reused.shapes.get(2).expect("group");
|
||||
assert!(matches!(&group.kind, ShapeKind::Group { .. }));
|
||||
let ShapeKind::Group { children, child } = &group.kind else { return };
|
||||
assert_eq!(group.xfrm, Some(Xfrm::new(50.0, 40.0, 150.0, 75.0)));
|
||||
assert_eq!(*child, Xfrm::new(0.0, 0.0, 150.0, 75.0));
|
||||
assert_eq!(children.first().and_then(|s| s.xfrm), Some(Xfrm::new(100.0, 50.0, 40.0, 20.0)));
|
||||
|
||||
let imported_master = doc.masters.last().expect("imported master");
|
||||
assert_eq!(imported_master.shapes.first().and_then(|s| s.xfrm), Some(Xfrm::new(400.0, 200.0, 50.0, 25.0)));
|
||||
assert_eq!(imported_master.layouts.first().and_then(|l| l.shapes.first()).and_then(|s| s.xfrm), Some(Xfrm::new(350.0, 150.0, 50.0, 25.0)));
|
||||
assert_ne!(reused.id, slide_id);
|
||||
|
||||
let saved = deckcraft_format::save(&doc.clone()).expect("destination save");
|
||||
let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &saved).expect("destination reopen");
|
||||
let reopened_shape = reopened.slides.get(1).and_then(|s| s.shapes.first()).and_then(|s| s.xfrm);
|
||||
assert_eq!(reopened_shape, Some(Xfrm::new(400.0, 200.0, 100.0, 50.0)));
|
||||
}
|
||||
|
||||
/// Every kind of media a slide can hold comes along, each pointing at its own bytes: the
|
||||
/// artistic-effect original, a background picture, poster, sounds and picture bullets too.
|
||||
#[test]
|
||||
|
||||
@@ -219,7 +219,7 @@ fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result<Value> {
|
||||
let cmd = if forward { "comment.next" } else { "comment.prev" };
|
||||
let (threads, slide, index) = {
|
||||
let st = s.doc()?;
|
||||
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index"))
|
||||
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), super::opt_usize_param(p, "index", cmd)?)
|
||||
};
|
||||
if threads.is_empty() {
|
||||
return Err(bad(cmd, "no comments"));
|
||||
@@ -367,7 +367,7 @@ fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
out.push(v);
|
||||
}
|
||||
let rep: Vec<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
|
||||
let rep: std::collections::HashSet<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
|
||||
for w in crate::spell::paragraph_words(para, &default) {
|
||||
if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) {
|
||||
continue;
|
||||
@@ -421,23 +421,30 @@ fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// Replace whole-word `from` with `to` in `text`; how many were replaced.
|
||||
fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize {
|
||||
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
|
||||
let mut n = 0;
|
||||
let mut at = 0;
|
||||
// The result is built in one pass; the text is not edited in place for every match.
|
||||
let mut out = String::new();
|
||||
let (mut n, mut at, mut copied) = (0, 0, 0);
|
||||
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
|
||||
let end = i + from.len();
|
||||
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
|
||||
// The character before the match as the edited text would have it.
|
||||
let before = text.get(copied..i).and_then(|t| t.chars().next_back()).or_else(|| out.chars().next_back()).is_some_and(is_word);
|
||||
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
|
||||
if before || after {
|
||||
at = end;
|
||||
continue;
|
||||
}
|
||||
text.replace_range(i..end, to);
|
||||
out.push_str(text.get(copied..i).unwrap_or_default());
|
||||
out.push_str(to);
|
||||
n += 1;
|
||||
at = i + to.len();
|
||||
(at, copied) = (end, end);
|
||||
if !all {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
out.push_str(text.get(copied..).unwrap_or_default());
|
||||
*text = out;
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
@@ -764,6 +771,49 @@ fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
/// The replacement as it ran before: edited in place for every match.
|
||||
fn replace_word_in_place(text: &mut String, from: &str, to: &str, all: bool) -> usize {
|
||||
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
|
||||
let mut n = 0;
|
||||
let mut at = 0;
|
||||
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
|
||||
let end = i + from.len();
|
||||
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
|
||||
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
|
||||
if before || after {
|
||||
at = end;
|
||||
continue;
|
||||
}
|
||||
text.replace_range(i..end, to);
|
||||
n += 1;
|
||||
at = i + to.len();
|
||||
if !all {
|
||||
break;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_word_matches_the_in_place_version() {
|
||||
let cases = [
|
||||
("a a b a", "a", "x", true),
|
||||
("aa a. a", "a", "bb", true),
|
||||
("a-a -a-", "-", "ab", true),
|
||||
("foo. foo.", "foo.", "", true),
|
||||
("it's it", "it", "so", true),
|
||||
("x.x. x.", "x.", "y", true),
|
||||
("a a a", "a", "b", false),
|
||||
("none", "z", "q", true),
|
||||
("é é", "é", "e", true),
|
||||
];
|
||||
for (text, from, to, all) in cases {
|
||||
let (mut a, mut b) = (text.to_string(), text.to_string());
|
||||
let (na, nb) = (replace_word(&mut a, from, to, all), replace_word_in_place(&mut b, from, to, all));
|
||||
assert_eq!((na, a), (nb, b), "{text:?} {from:?} -> {to:?}");
|
||||
}
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -812,6 +862,40 @@ mod tests {
|
||||
assert_eq!(here["text"], "on first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_comment_navigation_index_does_not_change_selection() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("comment.add", &json!({"text": "first"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("comment.add", &json!({"text": "second"})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let shape = s.doc().unwrap().doc.slides.first().and_then(|slide| slide.shapes.first()).map(|shape| shape.id).unwrap();
|
||||
s.doc_mut().unwrap().selection.shapes = vec![shape];
|
||||
|
||||
for (command, value) in [
|
||||
("comment.next", json!(-1)),
|
||||
("comment.next", json!(1.5)),
|
||||
("comment.next", json!("bad")),
|
||||
("comment.prev", json!(-1)),
|
||||
("comment.prev", json!(1.5)),
|
||||
("comment.prev", json!("bad")),
|
||||
] {
|
||||
let before = s.doc().unwrap().selection.clone();
|
||||
let error = s.execute(command, &json!({"index": value})).unwrap_err();
|
||||
assert!(error.to_string().contains("valid index"), "{command} {value}: {error}");
|
||||
assert_eq!(s.doc().unwrap().selection, before, "{command} {value}");
|
||||
}
|
||||
|
||||
let zero = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap();
|
||||
assert_eq!(zero["slide"], 1);
|
||||
assert_eq!(zero["index"], 0);
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let omitted = s.execute("comment.next", &json!({})).unwrap();
|
||||
assert_eq!(omitted["slide"], 0);
|
||||
assert_eq!(omitted["index"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_iso_utc() {
|
||||
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
|
||||
|
||||
@@ -135,6 +135,14 @@ fn eye_show_all_and_hide_all() {
|
||||
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_group_with_its_child_targeted_leaves_child_locked() {
|
||||
let (mut s, ids) = deck();
|
||||
let (d, g) = (ids[3], ids[7]);
|
||||
s.execute("shape.lock", &json!({"ids": [g, d, d]})).unwrap();
|
||||
assert!(flags(&s, g).1 && flags(&s, d).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_cascades_through_groups_and_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
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};
|
||||
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Fill",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||||
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool (false keeps linear and radial gradients upright on the slide; rectangular and path gradients turn with the shape when drawn)} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||||
has_selection,
|
||||
fill
|
||||
),
|
||||
@@ -374,6 +374,25 @@ fn visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = bool_param(p, "locked");
|
||||
// A targeted shape inside another targeted group is reached through the group: editing it again
|
||||
// would toggle it back.
|
||||
let mut ids = super::targets(s, p)?;
|
||||
if ids.len() > 1 {
|
||||
let doc = s.doc()?;
|
||||
// Walk each distinct group once, however often `ids` repeats it.
|
||||
let mut once = std::collections::HashSet::new();
|
||||
ids.retain(|id| once.insert(*id));
|
||||
let mut inside = std::collections::HashSet::new();
|
||||
for id in &ids {
|
||||
if let Some(g) = doc.shape(*id) {
|
||||
deckcraft_model::walk(g.children(), &mut |c, _| {
|
||||
inside.insert(c.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
ids.retain(|id| !inside.contains(id));
|
||||
}
|
||||
let p = &json!({"ids": ids, "locked": p.get("locked")});
|
||||
edit_shapes(s, p, "shape.lock", |sh| {
|
||||
let to = v.unwrap_or(!sh.locked);
|
||||
set_locked(sh, to, 0);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::{
|
||||
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
|
||||
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
|
||||
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, 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> {
|
||||
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`"))?;
|
||||
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||||
@@ -74,7 +74,8 @@ pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
|
||||
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
|
||||
/// rotateWithShape?}`.
|
||||
/// rotateWithShape?}`. The renderer keeps only linear and radial gradients upright when
|
||||
/// `rotateWithShape` is false; rectangular and path gradients turn with the shape.
|
||||
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
|
||||
let stops: Vec<GradientStop> = v
|
||||
.get("stops")?
|
||||
@@ -371,9 +372,9 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
|
||||
let refl = str_param(p, "reflection").map(|r| match r {
|
||||
"none" => None,
|
||||
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
|
||||
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }),
|
||||
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
|
||||
"half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0, ..Default::default() }),
|
||||
"full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0, ..Default::default() }),
|
||||
_ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0, ..Default::default() }),
|
||||
});
|
||||
let three_d = p.get("threeD").filter(|v| v.is_object());
|
||||
let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) });
|
||||
@@ -467,7 +468,14 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
e.glow = (g.radius > 0.0).then_some(g);
|
||||
}
|
||||
if let Some(v) = &refl_props {
|
||||
let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 });
|
||||
let mut r = e.reflection.take().unwrap_or(Reflection {
|
||||
blur: 0.5,
|
||||
start_alpha: 0.5,
|
||||
end_alpha: 0.0,
|
||||
end_pos: 0.35,
|
||||
dist: 0.0,
|
||||
..Default::default()
|
||||
});
|
||||
if let Some(t) = num(v, "transparency") {
|
||||
r.start_alpha = 1.0 - t.clamp(0.0, 1.0);
|
||||
}
|
||||
@@ -548,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> {
|
||||
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);
|
||||
edit_shapes(s, p, "shape.quickStyle", |sh| {
|
||||
sh.style = Some(st.clone());
|
||||
|
||||
@@ -3,8 +3,9 @@ use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad
|
||||
pub(super) fn sites(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
|
||||
let st = s.doc()?;
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
|
||||
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
|
||||
// In slide space, as shape.connect uses them, for a shape inside a group too.
|
||||
let v = crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
|
||||
Ok(json!(v.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -12,8 +13,13 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad("shape.connect", "missing `from` / `to`"));
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
|
||||
let (a, b) = (site_list(from), site_list(to));
|
||||
// Anywhere in the slide's tree: a shape inside a group takes a connector too (PowerPoint
|
||||
// glues to grouped shapes), its sites where the group draws them.
|
||||
let site_list = |id| {
|
||||
crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id)
|
||||
.ok_or_else(|| bad("shape.connect", format!("no shape {} on the current slide", id.0)))
|
||||
};
|
||||
let (a, b) = (site_list(from)?, site_list(to)?);
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));
|
||||
}
|
||||
@@ -35,6 +41,11 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let (p0, p1) = (a[ia], b[ib]);
|
||||
let id = match id_param(p, "id", "shape.connect")? {
|
||||
// The ends are in slide space and rerouting follows the slide's own connectors, so a line
|
||||
// inside a group would be drawn in the group's space and never follow its shapes.
|
||||
Some(id) if !st.shapes().iter().any(|sh| sh.id == id) && st.shape(id).is_some_and(|sh| sh.is_line()) => {
|
||||
return Err(bad("shape.connect", "`id` names a line inside a group; ungroup it first, or leave `id` out for a new connector"));
|
||||
}
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
|
||||
@@ -93,7 +104,7 @@ pub(super) fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
a.iter()
|
||||
.filter_map(|v| Some(deckcraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)))
|
||||
.filter(|q| q.x.is_finite() && q.y.is_finite())
|
||||
.take(20_000)
|
||||
.take(crate::tools::MAX_GESTURE_POINTS)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -191,6 +191,13 @@ pub(super) fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
|
||||
edit_geometry(s, p, "shape.flip", |sh| {
|
||||
// PowerPoint greys Flip out for a SmartArt graphic (SmartArt Design > Right to Left mirrors it),
|
||||
// also inside a group: ungrouping would hand the flip to the graphic.
|
||||
let mut graphic = false;
|
||||
deckcraft_model::walk(std::slice::from_ref(&*sh), &mut |x, _| graphic |= x.diagram.is_some());
|
||||
if graphic {
|
||||
return Err(bad("shape.flip", "Flip is not available for a SmartArt graphic; use `smartArt.layout` to change how it is laid out"));
|
||||
}
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
if v {
|
||||
x.flip_v = !x.flip_v;
|
||||
|
||||
@@ -267,6 +267,9 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for sec in &mut doc.sections {
|
||||
sec.slides.retain(|x| !ids.contains(x));
|
||||
}
|
||||
for show in &mut doc.custom_shows {
|
||||
show.slides.retain(|x| !ids.contains(x));
|
||||
}
|
||||
sel.slide = first.min(doc.slides.len().saturating_sub(1));
|
||||
sel.slides = doc.slides.get(sel.slide).map(|x| vec![x.id]).unwrap_or_default();
|
||||
sel.shapes.clear();
|
||||
@@ -405,8 +408,9 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
s.select(|doc, sel| {
|
||||
if add {
|
||||
let mut have: std::collections::HashSet<_> = sel.slides.iter().copied().collect();
|
||||
for i in &ids {
|
||||
if !sel.slides.contains(i) {
|
||||
if have.insert(*i) {
|
||||
sel.slides.push(*i);
|
||||
}
|
||||
}
|
||||
@@ -777,6 +781,45 @@ mod tests {
|
||||
(sel.slide, sel.slides.iter().map(|x| x.0).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_slide_prunes_every_custom_show_in_one_undo_step() {
|
||||
let (mut s, ids) = deck(3);
|
||||
let shows = vec![
|
||||
deckcraft_model::CustomShow {
|
||||
name: "First".into(),
|
||||
slides: vec![deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])],
|
||||
},
|
||||
deckcraft_model::CustomShow { name: "Second".into(), slides: vec![deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])] },
|
||||
deckcraft_model::CustomShow { name: "Empty after delete".into(), slides: vec![deckcraft_model::SlideId(ids[1])] },
|
||||
];
|
||||
s.edit(|doc, _| {
|
||||
doc.custom_shows = shows;
|
||||
Ok(())
|
||||
})
|
||||
.expect("set custom shows");
|
||||
|
||||
s.execute("slide.delete", &json!({"index": 1})).expect("delete slide");
|
||||
let shows = &s.doc().expect("doc").doc.custom_shows;
|
||||
assert_eq!(shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
|
||||
assert_eq!(shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
|
||||
assert!(shows[2].slides.is_empty());
|
||||
|
||||
s.execute("edit.undo", &json!({})).expect("undo deletion");
|
||||
assert_eq!(order(&s), ids);
|
||||
assert_eq!(
|
||||
s.doc().expect("doc").doc.custom_shows[0].slides,
|
||||
[deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])]
|
||||
);
|
||||
assert_eq!(s.doc().expect("doc").doc.custom_shows[1].slides, [deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])]);
|
||||
|
||||
s.execute("edit.redo", &json!({})).expect("redo deletion");
|
||||
let bytes = deckcraft_format::save(&s.doc().expect("doc").doc).expect("save deck");
|
||||
let reopened = deckcraft_format::load(&bytes).expect("reopen deck");
|
||||
assert_eq!(reopened.custom_shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
|
||||
assert_eq!(reopened.custom_shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
|
||||
assert!(reopened.custom_shows[2].slides.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_from_to_moves_one_slide() {
|
||||
let (mut s, ids) = deck(4);
|
||||
|
||||
@@ -253,9 +253,10 @@ fn cell_param(p: &Value, key: &str, cmd: &str) -> Result<Option<(usize, usize)>>
|
||||
None | Some(Value::Null) => return Ok(None),
|
||||
Some(v) => v,
|
||||
};
|
||||
let idx = |i: usize| v.as_array().and_then(|a| a.get(i)).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
|
||||
match (idx(0), idx(1)) {
|
||||
(Some(r), Some(c)) => Ok(Some((r, c))),
|
||||
// Exactly [row, col]: an entry with more is as malformed as one with less.
|
||||
let idx = |x: &Value| x.as_u64().and_then(|x| usize::try_from(x).ok());
|
||||
match v.as_array().map(Vec::as_slice) {
|
||||
Some([r, c]) if let (Some(r), Some(c)) = (idx(r), idx(c)) => Ok(Some((r, c))),
|
||||
_ => Err(bad(cmd, format!("`{key}` is not a valid cell: {v}"))),
|
||||
}
|
||||
}
|
||||
@@ -389,33 +390,54 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// At most this many `cells` in one table cell command.
|
||||
const MAX_CELLS: usize = 100_000;
|
||||
|
||||
/// The cells a command acts on: `cells`, else the selected cell range, else the cell holding the
|
||||
/// caret, else every cell.
|
||||
fn target_cells(s: &Session, p: &Value) -> Result<impl Fn(&Table) -> Vec<(usize, usize)> + use<>> {
|
||||
let cells: Option<Vec<(usize, usize)>> = p
|
||||
.get("cells")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
|
||||
fn target_cells(s: &Session, p: &Value, cmd: &str) -> Result<impl Fn(&Table) -> Result<Vec<(usize, usize)>> + use<>> {
|
||||
// `cells` is a list of [row, col]: anything else, or an entry that is not one, is refused, never
|
||||
// dropped or read as absent (which would act on the selection or the whole table).
|
||||
let cells: Option<Vec<(usize, usize)>> = match p.get("cells") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::Array(a)) if a.len() > MAX_CELLS => {
|
||||
return Err(bad(cmd, format!("`cells` lists {} cells; at most {MAX_CELLS}", a.len())));
|
||||
}
|
||||
Some(Value::Array(a)) => Some(
|
||||
a.iter()
|
||||
.map(|c| {
|
||||
cell_param(&json!({ "cell": c }), "cell", cmd)?.ok_or_else(|| bad(cmd, format!("`cells` entry {c} is not a [row, col] cell")))
|
||||
})
|
||||
.collect::<Result<_>>()?,
|
||||
),
|
||||
Some(v) => return Err(bad(cmd, format!("`cells` is a list of [row, col] cells, not {v}"))),
|
||||
};
|
||||
let st = s.doc()?;
|
||||
// A selected range grows over the merged cells it touches; with only a caret, the caret's cell is used.
|
||||
let sel = st.selection.cells.map(|(_, a, b)| (a, b, true)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c, false)));
|
||||
let cmd = cmd.to_string();
|
||||
Ok(move |t: &Table| match (&cells, sel) {
|
||||
(Some(c), _) => c.clone(),
|
||||
// Each listed cell must be in the table, so a command never reports success for a cell
|
||||
// it could not edit.
|
||||
(Some(c), _) => match c.iter().find(|(r, c)| *r >= t.n_rows() || *c >= t.n_cols()) {
|
||||
Some((r, c)) => Err(bad(&cmd, format!("no cell [{r}, {c}]: the table has {} rows and {} columns", t.n_rows(), t.n_cols()))),
|
||||
None => Ok(c.clone()),
|
||||
},
|
||||
(None, Some((a, b, range))) => {
|
||||
let (a, b) = if range { cell_block(t, a, b) } else { (a, b) };
|
||||
// The selection can name cells outside the table; only walk the ones that exist.
|
||||
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
|
||||
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
|
||||
Ok((a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect())
|
||||
}
|
||||
_ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(),
|
||||
_ => Ok((0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect()),
|
||||
})
|
||||
}
|
||||
|
||||
fn cell_fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) };
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.cellFill")?;
|
||||
with_table(s, p, "table.cellFill", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
for (r, c) in targets(t)? {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.fill.clone_from(&fill);
|
||||
}
|
||||
@@ -447,9 +469,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// "none" clears to no line, so the table style's own lines don't show through either.
|
||||
let line = if which == "none" { Line::none() } else { line };
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.borders")?;
|
||||
with_table(s, p, "table.borders", move |t| {
|
||||
let list = targets(t);
|
||||
let list = targets(t)?;
|
||||
let (Some(r0), Some(r1), Some(c0), Some(c1)) =
|
||||
(list.iter().map(|x| x.0).min(), list.iter().map(|x| x.0).max(), list.iter().map(|x| x.1).min(), list.iter().map(|x| x.1).max())
|
||||
else {
|
||||
@@ -463,9 +485,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let n = match side {
|
||||
LEFT => c.checked_sub(1).map(|c| (r, c, RIGHT)),
|
||||
RIGHT => (c + 1 < cols).then_some((r, c + 1, LEFT)),
|
||||
RIGHT => c.checked_add(1).filter(|c| *c < cols).map(|c| (r, c, LEFT)),
|
||||
TOP => r.checked_sub(1).map(|r| (r, c, BOTTOM)),
|
||||
_ => (r + 1 < rows).then_some((r + 1, c, TOP)),
|
||||
_ => r.checked_add(1).filter(|r| *r < rows).map(|r| (r, c, TOP)),
|
||||
};
|
||||
if let Some((r, c, side)) = n
|
||||
&& let Some(cell) = t.cell_mut(r, c)
|
||||
@@ -523,9 +545,9 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let side = |k: &str, i: usize| f64_param(p, k).filter(|v| v.is_finite()).map_or(base[i], |v| v.clamp(0.0, 400.0));
|
||||
let m = [side("left", 0), side("right", 1), side("top", 2), side("bottom", 3)];
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.cellMargins")?;
|
||||
with_table(s, p, "table.cellMargins", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
for (r, c) in targets(t)? {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
cell.margins = Some(m);
|
||||
}
|
||||
@@ -560,9 +582,9 @@ fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
|
||||
}),
|
||||
};
|
||||
let targets = target_cells(s, p)?;
|
||||
let targets = target_cells(s, p, "table.cellAlign")?;
|
||||
with_table(s, p, "table.cellAlign", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
for (r, c) in targets(t)? {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
if anchor.is_some() {
|
||||
cell.anchor = anchor;
|
||||
|
||||
+112
-18
@@ -121,9 +121,10 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
|
||||
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
|
||||
let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect();
|
||||
let mut out = vec![];
|
||||
let mut media = Media::new(cur, rev);
|
||||
for (i, j) in &pairs {
|
||||
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
|
||||
let details = slide_details(cur, a, rev, b);
|
||||
let details = slide_details(cur, a, rev, b, &mut media);
|
||||
if !details.is_empty() {
|
||||
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
|
||||
}
|
||||
@@ -140,10 +141,18 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
|
||||
});
|
||||
}
|
||||
}
|
||||
// Each revised slide's open match (the first pair naming it).
|
||||
let mut rev_to_cur: HashMap<usize, usize> = HashMap::new();
|
||||
for p in &pairs {
|
||||
rev_to_cur.entry(p.1).or_insert(p.0);
|
||||
}
|
||||
// After the open slide that matches the nearest matched revised slide before it.
|
||||
let mut at = 0;
|
||||
for (j, s) in rev.slides.iter().enumerate() {
|
||||
if let Some(i) = rev_to_cur.get(&j) {
|
||||
at = i + 1;
|
||||
}
|
||||
if !rev_matched.contains(&j) {
|
||||
// After the open slide that matches the revised slide before it.
|
||||
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
|
||||
out.push(Change {
|
||||
kind: ChangeKind::Inserted,
|
||||
slide: at,
|
||||
@@ -174,8 +183,34 @@ fn shape_label(s: &Shape) -> String {
|
||||
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
|
||||
}
|
||||
|
||||
fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> {
|
||||
p.media(id).map(|m| m.data.as_slice())
|
||||
/// Both decks' media bytes by id, and which picture pairs were already compared: a deck can
|
||||
/// show one picture many times, and its bytes are compared once per pair.
|
||||
struct Media<'a> {
|
||||
cur: HashMap<MediaId, &'a [u8]>,
|
||||
rev: HashMap<MediaId, &'a [u8]>,
|
||||
same: HashMap<(MediaId, MediaId), bool>,
|
||||
}
|
||||
|
||||
impl<'a> Media<'a> {
|
||||
fn new(cur: &'a Presentation, rev: &'a Presentation) -> Self {
|
||||
// The first item with an id wins, as in `Presentation::media`.
|
||||
let index = |p: &'a Presentation| {
|
||||
let mut m = HashMap::new();
|
||||
for x in &p.media {
|
||||
m.entry(x.id).or_insert(x.data.as_slice());
|
||||
}
|
||||
m
|
||||
};
|
||||
Media { cur: index(cur), rev: index(rev), same: HashMap::new() }
|
||||
}
|
||||
/// Whether the pictures (by media id, `None` for a shape that is not a picture) differ.
|
||||
fn differ(&mut self, a: Option<MediaId>, b: Option<MediaId>) -> bool {
|
||||
let (ba, bb) = (a.and_then(|x| self.cur.get(&x)), b.and_then(|y| self.rev.get(&y)));
|
||||
match (a, b) {
|
||||
(Some(x), Some(y)) if ba.is_some() && bb.is_some() => !*self.same.entry((x, y)).or_insert_with(|| ba == bb),
|
||||
_ => ba.is_some() || bb.is_some(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A shape without its ids (shape and media), for comparing across decks.
|
||||
@@ -204,7 +239,7 @@ fn turn(ctx: Option<&Ctx>, s: &Shape) -> (f64, bool, bool) {
|
||||
drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v))
|
||||
}
|
||||
|
||||
fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentation, cb: Option<&Ctx>, b: &Shape, out: &mut Vec<String>) {
|
||||
fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media: &mut Media, out: &mut Vec<String>) {
|
||||
let label = shape_label(b);
|
||||
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
|
||||
let mut what = vec![];
|
||||
@@ -234,11 +269,11 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
|
||||
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
|
||||
what.push("formatting");
|
||||
}
|
||||
let pic = |p: &Presentation, s: &Shape| match &s.kind {
|
||||
ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()),
|
||||
let pic = |s: &Shape| match &s.kind {
|
||||
ShapeKind::Picture { fill } => Some(fill.media),
|
||||
_ => None,
|
||||
};
|
||||
if pic(cp, a) != pic(rp, b) {
|
||||
if media.differ(pic(a), pic(b)) {
|
||||
what.push("picture");
|
||||
}
|
||||
// Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also
|
||||
@@ -265,21 +300,32 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
|
||||
}
|
||||
}
|
||||
|
||||
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
|
||||
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide, media: &mut Media) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut used: HashSet<usize> = HashSet::new();
|
||||
let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b));
|
||||
// The revised shapes by id (first wins) and by name (in order, with a cursor past used ones).
|
||||
let mut by_id: HashMap<ShapeId, usize> = HashMap::new();
|
||||
let mut by_name: HashMap<&str, (Vec<usize>, usize)> = HashMap::new();
|
||||
for (k, x) in b.shapes.iter().enumerate() {
|
||||
by_id.entry(x.id).or_insert(k);
|
||||
if !x.name.is_empty() {
|
||||
by_name.entry(x.name.as_str()).or_default().0.push(k);
|
||||
}
|
||||
}
|
||||
for sa in &a.shapes {
|
||||
let k = b
|
||||
.shapes
|
||||
.iter()
|
||||
.position(|x| x.id == sa.id)
|
||||
.or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty()));
|
||||
let k = by_id.get(&sa.id).copied().or_else(|| {
|
||||
let (ks, next) = by_name.get_mut(sa.name.as_str())?;
|
||||
while ks.get(*next).is_some_and(|k| used.contains(k)) {
|
||||
*next += 1;
|
||||
}
|
||||
ks.get(*next).copied()
|
||||
});
|
||||
match k {
|
||||
Some(k) if !used.contains(&k) => {
|
||||
used.insert(k);
|
||||
if let Some(sb) = b.shapes.get(k) {
|
||||
shape_details(cp, ca.as_ref(), sa, rp, cb.as_ref(), sb, &mut out);
|
||||
shape_details(ca.as_ref(), sa, cb.as_ref(), sb, media, &mut out);
|
||||
}
|
||||
}
|
||||
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
|
||||
@@ -334,19 +380,34 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
|
||||
let mut sl = slide.clone();
|
||||
// Media.
|
||||
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
|
||||
// Both decks' media by id (the first item with an id wins, as in `Presentation::media`),
|
||||
// looked up once per reference instead of searched.
|
||||
let mut rev_at: HashMap<MediaId, usize> = HashMap::new();
|
||||
for (i, m) in rev.media.iter().enumerate() {
|
||||
rev_at.entry(m.id).or_insert(i);
|
||||
}
|
||||
let mut doc_at: HashMap<MediaId, usize> = HashMap::new();
|
||||
for (i, m) in doc.media.iter().enumerate() {
|
||||
doc_at.entry(m.id).or_insert(i);
|
||||
}
|
||||
for id in crate::media_refs::slide_media(&sl) {
|
||||
let Some(m) = rev.media(id) else { continue };
|
||||
match doc.media(id) {
|
||||
let Some(m) = rev_at.get(&id).and_then(|&i| rev.media.get(i)) else { continue };
|
||||
match doc_at.get(&id).and_then(|&i| doc.media.get(i)) {
|
||||
Some(x) if x.data == m.data => {}
|
||||
None => {
|
||||
// Keeps the revision's id, so later ids must start past it.
|
||||
doc_at.insert(id, doc.media.len());
|
||||
doc.media.push(m.clone());
|
||||
doc.next_id = doc.next_id.max(id.0);
|
||||
}
|
||||
Some(_) => {
|
||||
let before = doc.media.len();
|
||||
let new = doc
|
||||
.add_media(&m.name, &m.content_type, m.data.as_ref().clone())
|
||||
.ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?;
|
||||
if doc.media.len() > before {
|
||||
doc_at.entry(new).or_insert(before);
|
||||
}
|
||||
media_map.insert(id, new);
|
||||
}
|
||||
}
|
||||
@@ -519,6 +580,39 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_inserted_slides_and_same_named_pictures_compare_by_index() {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind, SlideId, defaults};
|
||||
|
||||
// One slide of 3,000 pictures of one image, all named "Box"; the revision has the same
|
||||
// pictures under other ids (matched by name, in order) and 3,000 slides inserted after it.
|
||||
let mut cur = defaults::new_presentation(None);
|
||||
let img = cur.add_media("a.png", "image/png", vec![7; 4096]).unwrap();
|
||||
let mut rev = cur.clone();
|
||||
let pic = |id: u32| Shape {
|
||||
id: ShapeId(id),
|
||||
name: "Box".into(),
|
||||
xfrm: Some(deckcraft_model::Xfrm::new(10.0, 10.0, 50.0, 50.0)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut cur.slides[0]).shapes = (0..3000).map(|k| pic(100_000 + k)).collect();
|
||||
Arc::make_mut(&mut rev.slides[0]).shapes = (0..3000).map(|k| pic(200_000 + k)).collect();
|
||||
let mut base = (*rev.slides[0]).clone();
|
||||
base.shapes.clear();
|
||||
for k in 0..3000 {
|
||||
let mut s = base.clone();
|
||||
s.id = SlideId(300_000 + k);
|
||||
rev.slides.push(Arc::new(s));
|
||||
}
|
||||
let changes = super::diff(&cur, &rev, false);
|
||||
assert_eq!(changes.len(), 3000, "only the inserted slides differ");
|
||||
assert!(changes.iter().all(|c| c.kind == super::ChangeKind::Inserted && c.slide == 1));
|
||||
}
|
||||
|
||||
/// A deck with one slide: a table whose cell has a picture fill, a picture background and a
|
||||
/// transition sound. `tag` starts the bytes of each.
|
||||
fn media_deck(tag: &str) -> deckcraft_model::Presentation {
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
//! Connectors glued to shapes: connection sites, snapping line ends to them, and rerouting attached
|
||||
//! connectors whenever the shapes they connect move, resize or rotate.
|
||||
|
||||
use deckcraft_geom::{Point, Xfrm};
|
||||
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
|
||||
use crate::Selection;
|
||||
use crate::cmd::xfrm_of;
|
||||
|
||||
/// Connection sites of `s` in slide coordinates (index = site index stored on connectors).
|
||||
/// Connection sites of a top-level shape `s` in slide coordinates (index = site index stored on
|
||||
/// connectors). For a shape inside a group use [`sites_of`] or [`sites_in`].
|
||||
pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
|
||||
sites_in(doc, sel, s, Affine::IDENTITY)
|
||||
}
|
||||
|
||||
/// Connection sites of `s` in slide coordinates, `to_abs` mapping the space its box lives in (the
|
||||
/// slide, or the child space of the groups around it) onto the slide.
|
||||
pub fn sites_in(doc: &Presentation, sel: &Selection, s: &Shape, to_abs: Affine) -> Vec<Point> {
|
||||
if s.is_line() || matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Ink { .. }) {
|
||||
return vec![];
|
||||
}
|
||||
@@ -17,10 +24,42 @@ pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
|
||||
if local.is_empty() {
|
||||
local = vec![Point::new(x.w / 2.0, 0.0), Point::new(0.0, x.h / 2.0), Point::new(x.w / 2.0, x.h), Point::new(x.w, x.h / 2.0)];
|
||||
}
|
||||
let a = x.affine();
|
||||
let a = to_abs * x.affine();
|
||||
local.into_iter().map(|p| a * p).collect()
|
||||
}
|
||||
|
||||
/// The shape `id` anywhere in `shapes` (a group's member included) with the affine that maps the
|
||||
/// space its box lives in onto the slide: a connector glues to a grouped shape, as in PowerPoint,
|
||||
/// and its sites are where the group draws them.
|
||||
pub fn locate<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId) -> Option<(&'a Shape, Affine)> {
|
||||
fn rec<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId, to_abs: Affine, depth: usize) -> Option<(&'a Shape, Affine)> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
for s in shapes {
|
||||
if s.id == id {
|
||||
return Some((s, to_abs));
|
||||
}
|
||||
if let ShapeKind::Group { children, child } = &s.kind {
|
||||
let x = xfrm_of(doc, sel, s);
|
||||
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
||||
let nested = to_abs * deckcraft_geom::group_child_affine(&x, ch);
|
||||
if let Some(f) = rec(doc, sel, children, id, nested, depth + 1) {
|
||||
return Some(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
rec(doc, sel, shapes, id, Affine::IDENTITY, 0)
|
||||
}
|
||||
|
||||
/// Connection sites of shape `id` anywhere in `shapes`, in slide coordinates; `None` when there is
|
||||
/// no such shape.
|
||||
pub fn sites_of(doc: &Presentation, sel: &Selection, shapes: &[Shape], id: ShapeId) -> Option<Vec<Point>> {
|
||||
locate(doc, sel, shapes, id).map(|(s, a)| sites_in(doc, sel, s, a))
|
||||
}
|
||||
|
||||
/// The nearest connection site within `radius` of `p` on any visible shape except `exclude`.
|
||||
pub fn nearest_site(
|
||||
doc: &Presentation,
|
||||
@@ -68,8 +107,8 @@ pub fn reroute(doc: &mut Presentation, sel: &Selection) {
|
||||
let snapshot: Vec<Shape> = list.clone();
|
||||
let view = doc.clone();
|
||||
let site = |id: ShapeId, idx: u32| -> Option<Point> {
|
||||
let s = snapshot.iter().find(|s| s.id == id)?;
|
||||
let v = sites(&view, sel, s);
|
||||
// Anywhere in the tree: a connector glued to a shape that is then grouped keeps following it.
|
||||
let v = sites_of(&view, sel, &snapshot, id)?;
|
||||
v.get(idx as usize).or(v.first()).copied()
|
||||
};
|
||||
let mut updates: Vec<(usize, Xfrm, bool, bool)> = vec![];
|
||||
|
||||
@@ -85,14 +85,20 @@ impl AutomationRoots {
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||
self.check_path(Path::new(path), access)
|
||||
}
|
||||
|
||||
/// [`Self::check`] for a path that may not be UTF-8: the checked path is the one opened.
|
||||
pub fn check_path(&self, path: &Path, access: Access) -> Result<(), String> {
|
||||
let path_text = path.display();
|
||||
let roots = match access {
|
||||
Access::Read => &self.read,
|
||||
Access::Write => &self.write,
|
||||
};
|
||||
if roots.is_empty() {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path_text}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path_text}")),
|
||||
};
|
||||
}
|
||||
let resolved = resolve(path)?;
|
||||
@@ -101,8 +107,8 @@ impl AutomationRoots {
|
||||
}
|
||||
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
|
||||
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path_text}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path_text}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,17 +197,23 @@ pub fn check_write(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
||||
}
|
||||
|
||||
/// May `path` be written here? Always, without roots in force. For a path that may not be UTF-8.
|
||||
pub fn check_write_path(path: &Path) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check_path(path, Access::Write))
|
||||
}
|
||||
|
||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||
/// existing folder's links followed and the rest appended.
|
||||
fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||
if path.is_empty() {
|
||||
fn resolve(path: &Path) -> Result<PathBuf, String> {
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("automation path rejected: path is empty".into());
|
||||
}
|
||||
let shown = path.display();
|
||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
|
||||
if cfg!(windows) && path.components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {shown}"));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {shown}: {e}"))?;
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute.as_path();
|
||||
@@ -212,11 +224,11 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||
return Ok(found);
|
||||
}
|
||||
Err(e) => match std::fs::symlink_metadata(at) {
|
||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
|
||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {shown}")),
|
||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {shown}: {e}")),
|
||||
},
|
||||
}
|
||||
let parent = at.parent();
|
||||
@@ -226,12 +238,12 @@ fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||
at = parent;
|
||||
}
|
||||
(Some(Component::ParentDir), _) => {
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {shown}"));
|
||||
}
|
||||
// Down to the root (or drive) and it doesn't exist either.
|
||||
_ => {
|
||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
|
||||
return Err(format!("automation path rejected: cannot resolve {shown}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text
|
||||
//! draws in a stand-in; see `FontDb::document_face`).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use deckcraft_fonts::FontDb;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{Bullet, TextBody};
|
||||
@@ -12,8 +14,13 @@ use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
/// font to load and are left for the round trip.
|
||||
pub fn install_embedded_fonts(p: &Presentation) {
|
||||
let db = FontDb::global();
|
||||
// Faces can name one media item many times; each is copied and enumerated once.
|
||||
let mut seen = HashSet::new();
|
||||
for font in &p.embedded_fonts {
|
||||
for id in font.faces().into_iter().flatten() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(item) = p.media(id) else { continue };
|
||||
let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue };
|
||||
db.add_font(sfnt.to_vec());
|
||||
@@ -27,12 +34,8 @@ pub fn install_embedded_fonts(p: &Presentation) {
|
||||
/// and can't be installed, so they aren't reported.
|
||||
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
let db = FontDb::global();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for family in used_fonts(p) {
|
||||
if !family.starts_with('.') && !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
|
||||
out.push(family);
|
||||
}
|
||||
}
|
||||
// used_fonts is already free of case-insensitive duplicates.
|
||||
let mut out: Vec<String> = used_fonts(p).into_iter().filter(|f| !f.starts_with('.') && !db.has_family(f)).collect();
|
||||
out.sort_by_key(|f| f.to_lowercase());
|
||||
out
|
||||
}
|
||||
@@ -40,9 +43,10 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
/// The families the slides' text runs and bullets are drawn in.
|
||||
pub fn used_fonts(p: &Presentation) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut add = |f: String| {
|
||||
let f = f.trim().to_string();
|
||||
if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
|
||||
if !f.is_empty() && seen.insert(f.to_ascii_lowercase()) {
|
||||
out.push(f);
|
||||
}
|
||||
};
|
||||
|
||||
+31
-2
@@ -41,12 +41,15 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
Ok(canonical) => canonical,
|
||||
Err(_) => std::path::PathBuf::from(path),
|
||||
};
|
||||
let target_str = target_path.to_str().unwrap_or(path);
|
||||
let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions());
|
||||
|
||||
let mut attempts = 0;
|
||||
let (tmp, mut file) = loop {
|
||||
let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
// A short fixed-size name: appending to the target's name could exceed the file name limit.
|
||||
let tmp_name = format!(".deckcraft.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
let tmp = target_path.with_file_name(tmp_name);
|
||||
// The sibling must pass the same check: when `path` is the write root itself it lands outside.
|
||||
crate::file_access::check_write(&tmp.to_string_lossy())?;
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => break (tmp, f),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
|
||||
@@ -120,4 +123,30 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&test_file);
|
||||
let _ = std::fs::remove_dir(&tmp_dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_overwrites_file_with_name_near_the_length_limit() {
|
||||
let dir = std::env::temp_dir().join("deckcraft_io_long_name_test");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let file = dir.join(format!("{}.pptx", "a".repeat(250)));
|
||||
std::fs::write(&file, b"old").unwrap();
|
||||
let path_str = file.to_str().unwrap();
|
||||
|
||||
write_file(path_str, b"new").unwrap();
|
||||
assert_eq!(read_file(path_str).unwrap(), b"new");
|
||||
|
||||
let _ = std::fs::remove_file(&file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_to_the_write_root_itself_creates_no_sibling_outside_it() {
|
||||
let base = std::env::temp_dir().join(format!("deckcraft_io_root_test_{}", std::process::id()));
|
||||
let root = base.join("root");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let roots = crate::file_access::AutomationRoots::new(&[], std::slice::from_ref(&root)).unwrap().unwrap();
|
||||
let res = crate::file_access::confine(Some(&roots), || write_file(root.to_str().unwrap(), b"x"));
|
||||
assert!(res.unwrap_err().contains("outside the write root"));
|
||||
assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1, "only the root is there");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,17 +454,30 @@ pub struct MediaStatus {
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
pub struct Clipboard {
|
||||
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>,
|
||||
/// Media the copied shapes/slides reference.
|
||||
pub media: Vec<deckcraft_model::MediaItem>,
|
||||
pub text: Option<deckcraft_model::TextBody>,
|
||||
pub plain: String,
|
||||
/// The copied shapes' theme colours (colour map applied) and fonts, for Paste Special ›
|
||||
/// Keep Source Formatting.
|
||||
pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>,
|
||||
/// The theme the shapes were copied under, for Paste Special › Keep Source Formatting.
|
||||
pub source_theme: Option<SourceTheme>,
|
||||
/// How many times the shapes were pasted (each paste offsets further).
|
||||
pub pastes: u32,
|
||||
}
|
||||
@@ -811,10 +824,32 @@ fn clamp_selection(st: &mut DocState) {
|
||||
/// Default for [`History::byte_limit`].
|
||||
pub const UNDO_BYTE_BUDGET: usize = 256 << 20;
|
||||
|
||||
/// A rough size of a text body in memory: its paragraphs and runs, at their inline struct sizes.
|
||||
fn text_cost(t: &deckcraft_model::text::TextBody) -> usize {
|
||||
use deckcraft_model::text::{Paragraph, Run};
|
||||
let (para, run) = (std::mem::size_of::<Paragraph>(), std::mem::size_of::<Run>());
|
||||
t.paragraphs.iter().map(|p| para + p.runs.iter().map(|r| run + r.text.len()).sum::<usize>()).sum()
|
||||
}
|
||||
|
||||
/// A rough size of comments in memory, replies included.
|
||||
fn comments_cost(cs: &[deckcraft_model::Comment], depth: usize) -> usize {
|
||||
let kids = |c: &deckcraft_model::Comment| if depth < 64 { comments_cost(&c.replies, depth + 1) } else { 0 };
|
||||
cs.iter().map(|c| 256 + c.author.len() + c.initials.len() + c.text.len() + c.date.len() + kids(c)).sum()
|
||||
}
|
||||
|
||||
/// A rough size of a slide's own data besides its shapes: notes, comments, tags and kept XML,
|
||||
/// all copied with the slide when an edit changes it.
|
||||
fn slide_extra_cost(s: &deckcraft_model::Slide) -> usize {
|
||||
text_cost(&s.notes)
|
||||
+ comments_cost(&s.comments, 0)
|
||||
+ s.name.len()
|
||||
+ s.tags.iter().map(|(k, v)| 48 + k.len() + v.len()).sum::<usize>()
|
||||
+ s.raw_ext.as_ref().map_or(0, String::len)
|
||||
}
|
||||
|
||||
/// A rough size of a shape in memory: the struct, its text and kept XML, its children.
|
||||
fn shape_cost(sh: &deckcraft_model::Shape, depth: usize) -> usize {
|
||||
let text: usize =
|
||||
sh.text.as_ref().map_or(0, |t| t.paragraphs.iter().map(|p| 96 + p.runs.iter().map(|r| 128 + r.text.len()).sum::<usize>()).sum());
|
||||
let text: usize = sh.text.as_ref().map_or(0, text_cost);
|
||||
let kids: usize = if depth < 64 { sh.children().iter().map(|c| shape_cost(c, depth + 1)).sum() } else { 0 };
|
||||
768 + text + sh.raw_ext.as_ref().map_or(0, String::len) + kids
|
||||
}
|
||||
@@ -832,7 +867,7 @@ pub fn undo_cost(doc: &Presentation, newer: &Presentation) -> usize {
|
||||
let media: HashSet<*const Vec<u8>> = newer.media.iter().map(|m| Arc::as_ptr(&m.data)).collect();
|
||||
let mut n = 64 * doc.slides.len();
|
||||
for s in doc.slides.iter().filter(|s| !slides.contains(&Arc::as_ptr(s))) {
|
||||
n += 512 + shapes_cost(&s.shapes);
|
||||
n += 512 + shapes_cost(&s.shapes) + slide_extra_cost(s);
|
||||
}
|
||||
for m in doc.masters.iter().filter(|m| !masters.contains(&Arc::as_ptr(m))) {
|
||||
n += 4096 + shapes_cost(&m.shapes) + m.layouts.iter().map(|l| 512 + shapes_cost(&l.shapes)).sum::<usize>();
|
||||
|
||||
@@ -259,6 +259,16 @@ mod tests {
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_identity_is_stable_nonzero_and_json_safe() {
|
||||
let id = instance_id();
|
||||
assert_eq!(instance_id(), id);
|
||||
assert!(id != 0 && id <= UID_MASK);
|
||||
assert_eq!(id & 0xFF_FFFF, 0);
|
||||
let encoded = serde_json::to_value(id).unwrap();
|
||||
assert_eq!(encoded.as_f64().unwrap() as u64, id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovers_unsaved_presentations() {
|
||||
let dir = temp("recovery");
|
||||
|
||||
+140
-19
@@ -171,7 +171,9 @@ impl Dictionary {
|
||||
["COMPOUNDWORDMAX", n, ..] => d.compound_max = n.parse().unwrap_or(0),
|
||||
["REP", n] => {
|
||||
for _ in 0..n.parse::<usize>().unwrap_or(0) {
|
||||
let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
|
||||
// The count is the file's claim: stop when the records run out.
|
||||
let Some(line) = lines.next() else { break };
|
||||
let r: Vec<&str> = line.split_whitespace().collect();
|
||||
if let [_, a, b, ..] = r.as_slice() {
|
||||
d.rep.push((a.replace('_', " "), b.replace('_', " ")));
|
||||
}
|
||||
@@ -182,7 +184,8 @@ impl Dictionary {
|
||||
let Some(flag) = one_flag(Some(flag), mode) else { continue };
|
||||
let cross = cross.eq_ignore_ascii_case("y");
|
||||
for _ in 0..n.parse::<usize>().unwrap_or(0) {
|
||||
let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
|
||||
let Some(line) = lines.next() else { break };
|
||||
let r: Vec<&str> = line.split_whitespace().collect();
|
||||
let [_, _, strip, add, rest @ ..] = r.as_slice() else { continue };
|
||||
let (add, cont) = match add.split_once('/') {
|
||||
Some((a, c)) => (a, parse_flags(c, mode)),
|
||||
@@ -304,7 +307,12 @@ impl Dictionary {
|
||||
|
||||
/// `w` made of dictionary parts allowed in compounds (`COMPOUNDFLAG`, or the begin, middle and
|
||||
/// end flags), each at least `COMPOUNDMIN` characters long.
|
||||
fn compounded(&self, w: &[char], index: usize) -> bool {
|
||||
fn compounded(&self, w: &[char], index: usize, failed: &mut HashSet<(usize, usize)>) -> bool {
|
||||
// `w` is always a tail of the checked word, so its length names it: a (tail, part index)
|
||||
// that failed once fails again however the earlier parts were split.
|
||||
if failed.contains(&(w.len(), index)) {
|
||||
return false;
|
||||
}
|
||||
if self.compound.is_none() && self.compound_end.is_none() {
|
||||
return false;
|
||||
}
|
||||
@@ -322,10 +330,11 @@ impl Dictionary {
|
||||
if [self.compound, self.compound_end].into_iter().flatten().any(|f| self.compound_part(rest, f, true)) {
|
||||
return true;
|
||||
}
|
||||
if self.compounded(rest, index + 1) {
|
||||
if self.compounded(rest, index + 1, failed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
failed.insert((w.len(), index));
|
||||
false
|
||||
}
|
||||
|
||||
@@ -352,7 +361,7 @@ impl Dictionary {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.compounded(&w, 0)
|
||||
self.compounded(&w, 0, &mut HashSet::new())
|
||||
}
|
||||
|
||||
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
|
||||
@@ -576,6 +585,15 @@ fn cache() -> &'static Cache {
|
||||
CACHE.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Language strings remembered by [`dictionary`]; a string past it is resolved again on each call.
|
||||
const MAX_CACHED_LANGS: usize = 256;
|
||||
|
||||
/// Parsed dictionaries by their affix file, so aliases of one language share it.
|
||||
fn by_files() -> &'static Mutex<HashMap<PathBuf, Arc<Dictionary>>> {
|
||||
static M: OnceLock<Mutex<HashMap<PathBuf, Arc<Dictionary>>>> = OnceLock::new();
|
||||
M.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// The dictionary for `lang` (a BCP-47 tag such as `en-US`), loaded once per process.
|
||||
pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> {
|
||||
let cache = cache();
|
||||
@@ -586,10 +604,23 @@ pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = find_in(&search_dirs(), lang).and_then(|(a, d)| {
|
||||
let (a, d) = (std::fs::read(a).ok()?, std::fs::read(d).ok()?);
|
||||
Some(Arc::new(Dictionary::parse(&a, &d)))
|
||||
// Tags that resolve to the same files share one parsed dictionary.
|
||||
if let Ok(m) = by_files().lock()
|
||||
&& let Some(dict) = m.get(&a)
|
||||
{
|
||||
return Some(dict.clone());
|
||||
}
|
||||
let (bytes_a, bytes_d) = (std::fs::read(&a).ok()?, std::fs::read(d).ok()?);
|
||||
let dict = Arc::new(Dictionary::parse(&bytes_a, &bytes_d));
|
||||
// A concurrent loader of the same files may have inserted first: share its copy.
|
||||
if let Ok(mut m) = by_files().lock() {
|
||||
return Some(m.entry(a).or_insert(dict).clone());
|
||||
}
|
||||
Some(dict)
|
||||
});
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
if let Ok(mut c) = cache.lock()
|
||||
&& c.len() < MAX_CACHED_LANGS
|
||||
{
|
||||
c.insert(key, loaded.clone());
|
||||
}
|
||||
loaded
|
||||
@@ -668,6 +699,8 @@ pub fn add_user_word(word: &str) -> std::io::Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
let path = user_dictionary_path().ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no folder for the user dictionary"))?;
|
||||
// A confined automation client may write only inside its write roots, this file included.
|
||||
crate::file_access::check_write_path(&path).map_err(|e| std::io::Error::new(std::io::ErrorKind::PermissionDenied, e))?;
|
||||
if let Some(d) = path.parent() {
|
||||
std::fs::create_dir_all(d)?;
|
||||
}
|
||||
@@ -700,16 +733,31 @@ pub struct Word {
|
||||
pub repeated: Option<usize>,
|
||||
}
|
||||
|
||||
/// Longest language tag a word is checked in. BCP 47 tags in documents are a few bytes; a longer
|
||||
/// value from a file is no language we have a dictionary for, and copying it into every word of
|
||||
/// a paragraph would multiply it.
|
||||
const MAX_LANG_LEN: usize = 64;
|
||||
|
||||
/// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words
|
||||
/// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked
|
||||
/// part by part.
|
||||
/// part by part. A language over [`MAX_LANG_LEN`] counts as none.
|
||||
pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Word> {
|
||||
let mut chars: Vec<(char, usize)> = vec![];
|
||||
for (ri, r) in p.runs.iter().enumerate() {
|
||||
chars.extend(r.content().chars().map(|c| (c, ri)));
|
||||
}
|
||||
let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string());
|
||||
let inside = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-';
|
||||
let default = if default.len() > MAX_LANG_LEN { "" } else { default };
|
||||
let lang_of = |ri: usize| {
|
||||
p.runs
|
||||
.get(ri)
|
||||
.and_then(|r| r.props.lang.as_deref())
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|l| if l.len() > MAX_LANG_LEN { "" } else { l })
|
||||
.unwrap_or(default)
|
||||
.to_string()
|
||||
};
|
||||
// Combining marks (decomposed accents) belong to the letter before them.
|
||||
let inside = |c: char| c.is_alphanumeric() || deckcraft_model::words::is_combining_mark(c) || c == '\'' || c == '’' || c == '-';
|
||||
let mut out = vec![];
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
@@ -725,8 +773,14 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Wor
|
||||
while a < b && !chars[a].0.is_alphanumeric() {
|
||||
a += 1;
|
||||
}
|
||||
while b > a && !chars[b - 1].0.is_alphanumeric() {
|
||||
b -= 1;
|
||||
// Trim back to the last letter or digit, keeping only the marks that sit on it
|
||||
// (a mark on a trailing apostrophe goes with the apostrophe).
|
||||
while b > a {
|
||||
let k = (a..b).rev().find(|&k| !deckcraft_model::words::is_combining_mark(chars[k].0)).unwrap_or(a);
|
||||
if chars[k].0.is_alphanumeric() {
|
||||
break;
|
||||
}
|
||||
b = k;
|
||||
}
|
||||
let text: String = chars[a..b].iter().map(|c| c.0).collect();
|
||||
if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) {
|
||||
@@ -784,15 +838,18 @@ pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String]
|
||||
.chain(repeated(p, default))
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.fold(vec![], |mut v: Vec<Word>, w| {
|
||||
.fold((vec![], HashMap::new()), |(mut v, mut at): (Vec<Word>, HashMap<usize, usize>), w| {
|
||||
// A repeated misspelt word is reported once, as repeated.
|
||||
if let Some(i) = v.iter().position(|x| x.start == w.start) {
|
||||
v[i] = w;
|
||||
} else {
|
||||
v.push(w);
|
||||
match at.get(&w.start).and_then(|&i| v.get_mut(i)) {
|
||||
Some(x) => *x = w,
|
||||
None => {
|
||||
at.insert(w.start, v.len());
|
||||
v.push(w);
|
||||
}
|
||||
}
|
||||
v
|
||||
(v, at)
|
||||
})
|
||||
.0
|
||||
}
|
||||
|
||||
/// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself).
|
||||
@@ -819,6 +876,20 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
Dictionary::parse(AFF.as_bytes(), DIC.as_bytes())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nfd_word_not_split() {
|
||||
let p = deckcraft_model::Paragraph::new("Das na\u{ef}ve Caf\u{e9} und cafe\u{301} na\u{308}ive");
|
||||
let w: Vec<String> = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect();
|
||||
assert_eq!(w, ["Das", "na\u{ef}ve", "Caf\u{e9}", "und", "cafe\u{301}", "na\u{308}ive"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_mark_on_trailing_punctuation_is_trimmed_with_it() {
|
||||
let p = deckcraft_model::Paragraph::new("word'\u{301} cafe\u{301}' x");
|
||||
let w: Vec<String> = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect();
|
||||
assert_eq!(w, ["word", "cafe\u{301}", "x"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curly_apostrophe_matches_ascii_entry() {
|
||||
let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes());
|
||||
@@ -859,6 +930,14 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string()));
|
||||
}
|
||||
|
||||
/// An affix header can claim billions of records; parsing stops where the file does.
|
||||
#[test]
|
||||
fn a_header_count_past_the_records_ends_with_them() {
|
||||
let aff = b"SFX S Y 4000000000\nSFX S 0 s .\nREP 4000000000\nREP a b\n";
|
||||
let d = Dictionary::parse(aff, b"1\ncat/S\n".as_slice());
|
||||
assert!(d.check("cats"));
|
||||
}
|
||||
|
||||
/// #1175: compound splitting is quadratic in the word's length, so a word longer than
|
||||
/// `MAX_CHECK_CHARS` is not checked; shorter ones still are.
|
||||
#[test]
|
||||
@@ -893,6 +972,17 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_compound_roots_with_a_failing_ending_stay_cheap() {
|
||||
// Roots a..aaaaaaaa overlap, so many splits of "aaa…ab" reach the same (tail, part) state; unmemoized that is millions of searches.
|
||||
let d = Dictionary::parse(
|
||||
b"COMPOUNDFLAG X\nCOMPOUNDMIN 1\n".as_slice(),
|
||||
b"8\na/X\naa/X\naaa/X\naaaa/X\naaaaa/X\naaaaaa/X\naaaaaaa/X\naaaaaaaa/X\n".as_slice(),
|
||||
);
|
||||
assert!(!d.check(&format!("{}b", "a".repeat(99))));
|
||||
assert!(d.check(&"a".repeat(20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_give_words_their_language() {
|
||||
use deckcraft_model::{Paragraph, Run, RunProps};
|
||||
@@ -932,6 +1022,37 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
assert_eq!(w, vec!["check", "done"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_confined_client_cannot_write_the_user_dictionary_outside_its_roots() {
|
||||
let root = std::env::temp_dir().join(format!("deckcraft-dict-root-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let roots = crate::file_access::AutomationRoots::new(std::slice::from_ref(&root), std::slice::from_ref(&root)).unwrap();
|
||||
// The user dictionary is a file of its own outside `root`, not whatever HOME points at.
|
||||
let outside = std::env::temp_dir().join(format!("deckcraft-dict-outside-{}", std::process::id()));
|
||||
set_user_dictionary_path(outside.join("user.dic"));
|
||||
let r = crate::file_access::confine(roots.as_ref(), || add_user_word("zqxconfinedword"));
|
||||
assert_eq!(r.map_err(|e| e.kind()), Err(std::io::ErrorKind::PermissionDenied));
|
||||
assert!(!outside.exists(), "the refused write created nothing");
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() {
|
||||
use deckcraft_model::{Paragraph, Run, RunProps};
|
||||
// 100,000 copies of one word under a 1 MB language value: every word after the first is
|
||||
// a repeat, and no word carries a copy of the value.
|
||||
let lang = "x".repeat(1 << 20);
|
||||
let p =
|
||||
Paragraph { runs: vec![Run::with("the ".repeat(100_000), RunProps { lang: Some(lang), ..Default::default() })], ..Default::default() };
|
||||
let words = paragraph_words(&p, "en-US");
|
||||
assert_eq!(words.len(), 100_000);
|
||||
assert!(words.iter().all(|w| w.lang.is_empty()));
|
||||
let bad = misspelt(&p, "en-US", &[]);
|
||||
assert_eq!(bad.len(), 99_999);
|
||||
assert!(bad.iter().all(|w| w.repeated.is_some()));
|
||||
assert!(paragraph_words(&Paragraph::new("word"), &"y".repeat(100)).iter().all(|w| w.lang.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_and_numeric_flags_and_latin1() {
|
||||
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
|
||||
|
||||
+1090
-1
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ mod geometry;
|
||||
mod hit;
|
||||
mod pointer;
|
||||
|
||||
pub(crate) use drag::MAX_GESTURE_POINTS;
|
||||
pub use hit::{line_xfrm, resize_box, table_rows, text_pos};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -150,8 +151,10 @@ enum Drag {
|
||||
},
|
||||
Resize {
|
||||
id: ShapeId,
|
||||
handle: u8,
|
||||
orig: Xfrm,
|
||||
ids: Vec<ShapeId>,
|
||||
handle: u8,
|
||||
origs: Vec<(ShapeId, Xfrm)>,
|
||||
start: Point,
|
||||
},
|
||||
LineEnd {
|
||||
@@ -273,6 +276,8 @@ pub struct ToolState {
|
||||
pub path_preview: Vec<Point>,
|
||||
pub path_smooth: bool,
|
||||
pub path_closing: bool,
|
||||
/// Whether the last freeform press added a vertex (it adds none at the point cap).
|
||||
pub press_added_vertex: bool,
|
||||
/// The shape in Edit Points: its vertices are handles instead of the box's.
|
||||
pub edit_points: Option<ShapeId>,
|
||||
/// The vertex (path, index) whose control handles show in Edit Points.
|
||||
|
||||
@@ -4,8 +4,12 @@ use super::{Drag, Hit, Point, Result, Session, ShapeId, ShapeKind, TextSel, Tool
|
||||
impl Session {
|
||||
pub(super) fn double_click(&mut self, p: Point, tol: f64) -> Result<Value> {
|
||||
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
||||
// The double-click's own click added a duplicate vertex.
|
||||
while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) {
|
||||
// The double-click's own click added a duplicate vertex (none at the point cap, where
|
||||
// the existing vertices are all kept).
|
||||
while self.tool.press_added_vertex
|
||||
&& points.len() > 2
|
||||
&& points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol)
|
||||
{
|
||||
points.pop();
|
||||
}
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
||||
|
||||
@@ -12,8 +12,16 @@ struct MoveDrag {
|
||||
enter_text: Option<(ShapeId, Point)>,
|
||||
}
|
||||
|
||||
/// Samples one ink or freeform gesture keeps; later pointer moves extend it no further.
|
||||
pub(crate) const MAX_GESTURE_POINTS: usize = 100_000;
|
||||
|
||||
impl Session {
|
||||
/// Update the active drag interaction using the pointer position and modifiers.
|
||||
pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
if matches!(self.tool.drag, Some(Drag::Ink { .. })) {
|
||||
self.extend_ink(p);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
|
||||
match drag {
|
||||
Drag::Guide { index } => {
|
||||
@@ -26,10 +34,48 @@ impl Session {
|
||||
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
|
||||
return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol);
|
||||
}
|
||||
Drag::Resize { id, handle, orig, start } => {
|
||||
let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt);
|
||||
let nx = self.snap_resize(nx, &orig, handle, id, tol);
|
||||
self.set_xfrm(id, nx)?;
|
||||
Drag::Resize { id, orig, ids, handle, origs, start } => {
|
||||
let keep_aspect = mods.shift || self.is_picture(id);
|
||||
let nx = resize_box(&orig, handle, p - start, keep_aspect, mods.alt);
|
||||
let nx = self.snap_resize(nx, handle, &ids, tol);
|
||||
let sx = if orig.w > 1e-9 { (nx.w / orig.w) * if nx.flip_h != orig.flip_h { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
|
||||
let sy = if orig.h > 1e-9 { (nx.h / orig.h) * if nx.flip_v != orig.flip_v { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
|
||||
let div = if mods.alt { 2.0 } else { 1.0 };
|
||||
let hx = match handle {
|
||||
0 | 6 | 7 => -1.0,
|
||||
2..=4 => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
let hy = match handle {
|
||||
0..=2 => -1.0,
|
||||
4..=6 => 1.0,
|
||||
_ => 0.0,
|
||||
};
|
||||
for (tid, o) in &origs {
|
||||
let target_nx = if *tid == id {
|
||||
nx
|
||||
} else {
|
||||
let picture = self.is_picture(*tid);
|
||||
let (sx, sy) = if picture && matches!(handle, 0 | 2 | 4 | 6) {
|
||||
// Use the axis with the larger relative change so an unchanged
|
||||
// dimension cannot cancel shrinking. Preserve each axis's flip.
|
||||
let scale_x = 1.0 + sx;
|
||||
let scale_y = 1.0 + sy;
|
||||
let scale = if sx.abs() >= sy.abs() { scale_x.abs() } else { scale_y.abs() };
|
||||
(scale.copysign(scale_x) - 1.0, scale.copysign(scale_y) - 1.0)
|
||||
} else {
|
||||
(sx, sy)
|
||||
};
|
||||
|
||||
let lp = Affine::rotate(o.rot.to_radians())
|
||||
* Point::new(
|
||||
hx * sx * o.w / div * if o.flip_h { -1.0 } else { 1.0 },
|
||||
hy * sy * o.h / div * if o.flip_v { -1.0 } else { 1.0 },
|
||||
);
|
||||
resize_box(o, handle, Vec2::new(lp.x, lp.y), picture, mods.alt)
|
||||
};
|
||||
self.set_xfrm(*tid, target_nx)?;
|
||||
}
|
||||
}
|
||||
Drag::LineEnd { id, end, orig } => {
|
||||
let a = orig.affine();
|
||||
@@ -142,7 +188,7 @@ impl Session {
|
||||
}
|
||||
Drag::Freeform { mode, mut points, pressed } => {
|
||||
// Dragging scribbles (except for curves, which only take clicked points).
|
||||
if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
|
||||
if pressed && mode != "curve" && points.len() < MAX_GESTURE_POINTS && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
|
||||
points.push(p);
|
||||
}
|
||||
let mut v = points.clone();
|
||||
@@ -152,15 +198,24 @@ impl Session {
|
||||
self.tool.path_preview = v;
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed });
|
||||
}
|
||||
Drag::Ink { mut points } => {
|
||||
points.push(self.ink_sample(p));
|
||||
self.tool.ink_preview = points.clone();
|
||||
self.tool.drag = Some(Drag::Ink { points });
|
||||
}
|
||||
// Handled above.
|
||||
Drag::Ink { .. } => {}
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Ink grows by one sample per move: extend it (and its preview) in place rather than
|
||||
/// copying the whole stroke twice per event, up to [`MAX_GESTURE_POINTS`].
|
||||
fn extend_ink(&mut self, p: Point) {
|
||||
let sample = self.ink_sample(p);
|
||||
if let Some(Drag::Ink { points }) = self.tool.drag.as_mut()
|
||||
&& points.len() < MAX_GESTURE_POINTS
|
||||
{
|
||||
points.push(sample);
|
||||
self.tool.ink_preview.push(sample);
|
||||
}
|
||||
}
|
||||
|
||||
fn drag_move(&mut self, drag: MoveDrag, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
let MoveDrag { ids, start, origs, moved, duplicate, enter_text } = drag;
|
||||
let mut d = p - start;
|
||||
@@ -197,7 +252,12 @@ impl Session {
|
||||
}
|
||||
}
|
||||
// A connector dragged away from shapes that stay put comes unglued at those ends.
|
||||
let moved: Vec<ShapeId> = origs2.iter().map(|o| o.0).collect();
|
||||
let mut moved: Vec<ShapeId> = vec![];
|
||||
for (id, _) in &origs2 {
|
||||
if let Some(sh) = deckcraft_model::find_shape(list, *id) {
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |s, _| moved.push(s.id));
|
||||
}
|
||||
}
|
||||
for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) {
|
||||
if let ShapeKind::Connector { start, end } = &mut sh.kind {
|
||||
for c in [start, end] {
|
||||
|
||||
@@ -101,6 +101,7 @@ impl Session {
|
||||
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 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)?;
|
||||
for (c, b) in copies.iter_mut().zip(boxes) {
|
||||
c.xfrm = Some(b);
|
||||
@@ -228,7 +229,7 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.
|
||||
pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
|
||||
pub(super) fn snap_resize(&mut self, x: Xfrm, handle: u8, exclude: &[ShapeId], tol: f64) -> Xfrm {
|
||||
if x.rot != 0.0 {
|
||||
self.tool.guides.clear();
|
||||
return x;
|
||||
@@ -241,7 +242,7 @@ impl Session {
|
||||
let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6));
|
||||
let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) };
|
||||
let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) };
|
||||
let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
|
||||
let (off, guides) = self.snap_edges(r, exclude, tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
|
||||
let mut nx = x;
|
||||
if moves_left {
|
||||
nx.x += off.x;
|
||||
|
||||
@@ -216,6 +216,7 @@ pub(super) fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a shape's target at slide point `p`, using its effective fill for interior hits.
|
||||
pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit> {
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let inv = x.affine().inverse();
|
||||
@@ -267,8 +268,11 @@ pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) ->
|
||||
return None;
|
||||
}
|
||||
let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box);
|
||||
// Empty interiors of unfilled, text-less shapes don't catch clicks.
|
||||
let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some();
|
||||
// Match rendering: an explicit No Fill overrides the style, and style/theme fills
|
||||
// can themselves be No Fill. Empty, text-less interiors then let clicks through.
|
||||
let fill =
|
||||
cmd::text::ctx_for(&st.doc, &st.selection).map(|ctx| deckcraft_model::resolve::fill(&ctx, sh).0).unwrap_or_else(|| sh.fill.clone());
|
||||
let filled = !matches!(fill, None | Some(deckcraft_model::Fill::None));
|
||||
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
||||
let in_geo = {
|
||||
use deckcraft_geom::KurboShape;
|
||||
|
||||
@@ -220,6 +220,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a pointer press and start the applicable tool interaction.
|
||||
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
||||
// Next click of a freeform: close on the start point, else add a vertex.
|
||||
@@ -227,7 +228,10 @@ impl Session {
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
||||
return self.finish_freeform(true);
|
||||
}
|
||||
points.push(p);
|
||||
self.tool.press_added_vertex = points.len() < super::MAX_GESTURE_POINTS;
|
||||
if self.tool.press_added_vertex {
|
||||
points.push(p);
|
||||
}
|
||||
self.tool.path_preview = points.clone();
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true });
|
||||
return Ok(Value::Null);
|
||||
@@ -303,8 +307,12 @@ impl Session {
|
||||
Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?,
|
||||
Some(Hit::Resize { id, handle }) => {
|
||||
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
||||
let ids: Vec<ShapeId> = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] };
|
||||
let origs: Vec<(ShapeId, Xfrm)> =
|
||||
ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
|
||||
let ids: Vec<ShapeId> = origs.iter().map(|(id, _)| *id).collect();
|
||||
self.begin("Size");
|
||||
self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p });
|
||||
self.tool.drag = Some(Drag::Resize { id, orig, ids, handle, origs, start: p });
|
||||
}
|
||||
Some(Hit::LineEnd { id, end }) => {
|
||||
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
||||
@@ -436,6 +444,7 @@ impl Session {
|
||||
Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()}))
|
||||
}
|
||||
|
||||
/// Handle a pointer release and finalize the active tool interaction.
|
||||
fn up(&mut self, p: Point, mods: Mods) -> Result<Value> {
|
||||
let drag = self.tool.drag.take();
|
||||
self.tool.guides.clear();
|
||||
@@ -558,10 +567,22 @@ impl Session {
|
||||
out = self.add_ink(points)?;
|
||||
}
|
||||
}
|
||||
Some(Drag::Resize { id, .. }) => {
|
||||
Some(Drag::Resize { origs, .. }) => {
|
||||
let changed: Vec<ShapeId> = origs
|
||||
.iter()
|
||||
.filter_map(|(id, orig)| {
|
||||
let current = self.active().and_then(|st| st.shape(*id).map(|s| xfrm_of(&st.doc, &st.selection, s)))?;
|
||||
|
||||
(current != *orig).then_some(*id)
|
||||
})
|
||||
.collect();
|
||||
if !changed.is_empty() {
|
||||
for id in changed {
|
||||
let _ = cmd::text::fit_text_box(self, id);
|
||||
self.refit_after_resize(id)?;
|
||||
}
|
||||
}
|
||||
self.commit();
|
||||
let _ = cmd::text::fit_text_box(self, id);
|
||||
self.refit_after_resize(id)?;
|
||||
}
|
||||
Some(Drag::TextSelect { before, .. }) => {
|
||||
out = self.paint_if_selection_changed(before, out)?;
|
||||
|
||||
@@ -169,8 +169,14 @@ impl FontDb {
|
||||
if bundled.contains(fam) {
|
||||
continue;
|
||||
}
|
||||
if self.is_loaded(fam) || self.has_family(fam) {
|
||||
let f = self.face(fam, style);
|
||||
if self.is_loaded(fam) || (self.system_fallback_enabled() && self.has_family(fam)) {
|
||||
// With system fallback off, only a face that is already loaded: `face` could load more catalog files.
|
||||
let f = if self.system_fallback_enabled() {
|
||||
self.face(fam, style)
|
||||
} else {
|
||||
let Some(f) = self.find(fam, style) else { continue };
|
||||
f
|
||||
};
|
||||
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
|
||||
return f;
|
||||
}
|
||||
@@ -227,6 +233,9 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most caret records read for one ligature glyph.
|
||||
const MAX_LIGATURE_CARETS: usize = 64;
|
||||
|
||||
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
|
||||
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
|
||||
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
@@ -236,9 +245,12 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
let Ok(cov) = list.coverage() else { return Vec::new() };
|
||||
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
|
||||
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
|
||||
// The limit applies to the records read, not to the carets kept: a font can list thousands of
|
||||
// records it then has to be read through for every ligature laid out.
|
||||
let mut out: Vec<i32> = lig
|
||||
.caret_values()
|
||||
.iter()
|
||||
.take(MAX_LIGATURE_CARETS)
|
||||
.filter_map(|cv| cv.ok())
|
||||
.filter_map(|cv| match cv {
|
||||
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
|
||||
@@ -246,7 +258,6 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
// Format 2 is a contour point index; outlines aren't consulted here.
|
||||
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
|
||||
})
|
||||
.take(64)
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
@@ -306,4 +317,21 @@ mod tests {
|
||||
assert!(c.windows(2).all(|w| w[0] <= w[1]));
|
||||
}
|
||||
}
|
||||
|
||||
/// With system fallback off, the automatic cascades (script families and symbol faces) load no
|
||||
/// installed family: the result comes from what is already loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn cascades_load_no_installed_family_when_system_fallback_is_off() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let base = db.face(crate::DEFAULT_FAMILY, "Regular");
|
||||
let loaded = |db: &FontDb| [b"Arab", b"Hebr", b"Thai", b"Deva"].into_iter().flat_map(|s| os_families(*s)).filter(|f| db.is_loaded(f)).count();
|
||||
let before = loaded(&db);
|
||||
for c in ['\u{0628}', '\u{05D0}', '\u{0E01}', '\u{0905}', '\u{2211}', '\u{2603}'] {
|
||||
let f = db.cascade(&[c], std::slice::from_ref(&base), "Regular");
|
||||
assert!(!f.family.is_empty());
|
||||
}
|
||||
assert_eq!(loaded(&db), before, "no installed family was loaded");
|
||||
}
|
||||
}
|
||||
|
||||
+238
-25
@@ -30,12 +30,15 @@ pub fn bundled() -> Vec<&'static [u8]> {
|
||||
/// Open substitutes for families documents often ask for but we can't ship (metric-compatible
|
||||
/// where one exists), tried in order after the family itself.
|
||||
pub fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
match family.to_ascii_lowercase().as_str() {
|
||||
let f = family.to_ascii_lowercase();
|
||||
if is_monospace_family(&f) {
|
||||
return &["Liberation Mono", "Courier New", "Menlo", "Courier", "DejaVu Sans Mono"];
|
||||
}
|
||||
match f.as_str() {
|
||||
"calibri" | "calibri light" => &["Carlito", "Liberation Sans", "Arial", "Inter"],
|
||||
"cambria" | "cambria math" => &["Caladea", "Liberation Serif", "Times New Roman", "Merriweather"],
|
||||
"arial" | "helvetica" | "helvetica neue" | "arial nova" => &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"],
|
||||
"times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times", "Merriweather"],
|
||||
"courier new" | "courier" | "consolas" | "menlo" => &["Liberation Mono", "Courier New", "Menlo", "Courier"],
|
||||
"aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => {
|
||||
&["Inter", "Helvetica Neue", "Arial", "Liberation Sans"]
|
||||
}
|
||||
@@ -52,12 +55,77 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
|
||||
}
|
||||
}
|
||||
|
||||
/// Families that are monospaced: when not installed, documents keep a monospaced substitute
|
||||
/// ([`substitutes`]) so code blocks and columns stay aligned instead of falling back to
|
||||
/// proportional Calibri.
|
||||
pub fn is_monospace_family(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
let s = f.trim();
|
||||
if matches!(
|
||||
s,
|
||||
"courier new"
|
||||
| "courier"
|
||||
| "cousine"
|
||||
| "consolas"
|
||||
| "menlo"
|
||||
| "monaco"
|
||||
| "source code pro"
|
||||
| "jetbrains mono"
|
||||
| "cascadia code"
|
||||
| "cascadia code pl"
|
||||
| "cascadia mono"
|
||||
| "cascadia mono pl"
|
||||
| "fira code"
|
||||
| "fira mono"
|
||||
| "inconsolata"
|
||||
| "dejavu sans mono"
|
||||
| "sf mono"
|
||||
| "roboto mono"
|
||||
| "hack"
|
||||
| "ubuntu mono"
|
||||
| "pt mono"
|
||||
| "liberation mono"
|
||||
| "andale mono"
|
||||
| "lucida console"
|
||||
| "lucida sans typewriter"
|
||||
| "fixedsys"
|
||||
| "terminal"
|
||||
| "monospace"
|
||||
| "iosevka"
|
||||
| "monolisa"
|
||||
| "envy code r"
|
||||
| "hasklig"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
s.starts_with("courier")
|
||||
|| s.starts_with("consolas")
|
||||
|| s.starts_with("inconsolata")
|
||||
|| s.starts_with("iosevka")
|
||||
|| s.ends_with(" mono")
|
||||
|| s.ends_with(" code")
|
||||
|| s.ends_with(" console")
|
||||
|| s.ends_with(" typewriter")
|
||||
|| s.ends_with(" monospace")
|
||||
|| s.split_whitespace().any(|w| w == "mono" || w == "terminal" || w == "monospace")
|
||||
}
|
||||
|
||||
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
|
||||
/// them breaks into the same lines.
|
||||
fn has_metric_twin(family: &str) -> bool {
|
||||
matches!(
|
||||
family.to_ascii_lowercase().as_str(),
|
||||
"calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
|
||||
"calibri"
|
||||
| "calibri light"
|
||||
| "cambria"
|
||||
| "cambria math"
|
||||
| "arial"
|
||||
| "helvetica"
|
||||
| "times new roman"
|
||||
| "times"
|
||||
| "courier new"
|
||||
| "courier"
|
||||
| "cousine"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -300,7 +368,7 @@ struct CatalogEntry {
|
||||
/// Process-wide font database.
|
||||
pub struct FontDb {
|
||||
faces: RwLock<Vec<Arc<FontFace>>>,
|
||||
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
|
||||
outlines: Mutex<OutlineCache>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock<Vec<CatalogEntry>>,
|
||||
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
|
||||
@@ -371,6 +439,38 @@ const SYSTEM_FALLBACKS: &[&str] = &[
|
||||
|
||||
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
|
||||
const OUTLINE_CACHE_MAX: usize = 50_000;
|
||||
/// Most path elements one glyph outline may expand to. Composite and `VARC` glyphs can draw a
|
||||
/// shared outline many times over; a glyph past this draws nothing rather than holding megabytes.
|
||||
const MAX_OUTLINE_ELEMENTS: usize = 1 << 15;
|
||||
/// Most path elements the outline cache holds across all glyphs (about 230 MB at most) before it
|
||||
/// starts over: distinct glyph ids can each expand one dense outline.
|
||||
const OUTLINE_CACHE_MAX_ELEMENTS: usize = 1 << 22;
|
||||
|
||||
/// Glyph outlines by (face, glyph), with the path elements they hold.
|
||||
#[derive(Default)]
|
||||
struct OutlineCache {
|
||||
paths: HashMap<(u32, u32), Arc<BezPath>>,
|
||||
elements: usize,
|
||||
}
|
||||
|
||||
impl OutlineCache {
|
||||
fn get(&self, key: &(u32, u32)) -> Option<Arc<BezPath>> {
|
||||
self.paths.get(key).cloned()
|
||||
}
|
||||
|
||||
fn insert(&mut self, key: (u32, u32), path: Arc<BezPath>) {
|
||||
let n = path.elements().len();
|
||||
if let Some(old) = self.paths.remove(&key) {
|
||||
self.elements = self.elements.saturating_sub(old.elements().len());
|
||||
}
|
||||
if self.paths.len() >= OUTLINE_CACHE_MAX || self.elements.saturating_add(n) > OUTLINE_CACHE_MAX_ELEMENTS {
|
||||
self.paths.clear();
|
||||
self.elements = 0;
|
||||
}
|
||||
self.paths.insert(key, path);
|
||||
self.elements = self.elements.saturating_add(n);
|
||||
}
|
||||
}
|
||||
|
||||
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
|
||||
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
|
||||
@@ -626,6 +726,10 @@ const MAX_COLLECTION_FACES: u32 = 256;
|
||||
const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384;
|
||||
/// Maximum named instances read from a single face.
|
||||
const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024;
|
||||
/// Maximum faces (named instances counted separately) enumerated from one font file, across all
|
||||
/// of a collection's faces: the per-collection and per-face limits multiply, and each face found
|
||||
/// is then built with its own shaper.
|
||||
const MAX_ENUMERATED_FACES: usize = 4096;
|
||||
|
||||
fn distinct_face_indices(entries: impl IntoIterator<Item = (u32, u32)>) -> Vec<u32> {
|
||||
let mut offsets = std::collections::HashSet::new();
|
||||
@@ -662,21 +766,30 @@ fn enumerate_faces(data: &[u8]) -> Vec<Found> {
|
||||
Err(_) => Vec::new(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
// Named instances read and plain faces kept, across the whole file.
|
||||
let mut budget = MAX_ENUMERATED_FACES;
|
||||
for i in indices {
|
||||
if budget == 0 {
|
||||
break;
|
||||
}
|
||||
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
|
||||
let Some((family, style)) = face_names(&f) else { continue };
|
||||
let axes = f.axes();
|
||||
let mut named = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let instances = f.named_instances();
|
||||
for ni in instances.iter().take(capped_named_instance_count(instances.len())) {
|
||||
let Some(left) = budget.checked_sub(1) else { break };
|
||||
budget = left;
|
||||
let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue };
|
||||
if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
|
||||
if !seen.insert(style.to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect();
|
||||
named.push((i, style, coords));
|
||||
}
|
||||
if named.is_empty() {
|
||||
budget = budget.saturating_sub(1);
|
||||
out.push((i, family, style, Vec::new()));
|
||||
} else {
|
||||
out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c)));
|
||||
@@ -837,7 +950,7 @@ impl FontDb {
|
||||
}
|
||||
Self {
|
||||
faces: RwLock::new(faces),
|
||||
outlines: Mutex::new(HashMap::new()),
|
||||
outlines: Mutex::new(OutlineCache::default()),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
catalog: RwLock::new(Vec::new()),
|
||||
font_dirs,
|
||||
@@ -863,6 +976,17 @@ impl FontDb {
|
||||
let _ = on;
|
||||
}
|
||||
|
||||
/// Whether automatic system-font lookups (fallback by character, symbol and script families)
|
||||
/// may touch installed fonts. Lookups of a family the document or host names are not gated.
|
||||
pub(crate) fn system_fallback_enabled(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
false
|
||||
}
|
||||
|
||||
/// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the
|
||||
/// first time a lookup by family name needs them (or ahead of time by
|
||||
/// [`FontDb::scan_in_background`]).
|
||||
@@ -1080,6 +1204,7 @@ impl FontDb {
|
||||
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
|
||||
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
|
||||
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
|
||||
/// Monospaced families keep a monospaced substitute instead of falling back to Calibri.
|
||||
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
|
||||
pub fn document_face(&self, family: &str, style: &str) -> Arc<FontFace> {
|
||||
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
|
||||
@@ -1088,6 +1213,9 @@ impl FontDb {
|
||||
if let Some(twin) = office_twin(family) {
|
||||
return self.face(twin, style);
|
||||
}
|
||||
if is_monospace_family(family) {
|
||||
return self.face(family, style);
|
||||
}
|
||||
self.face(MISSING_FONT_FAMILY, style)
|
||||
}
|
||||
|
||||
@@ -1143,9 +1271,8 @@ impl FontDb {
|
||||
fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
let open = style.find('{')?;
|
||||
let base = self.find(family, style[..open].trim())?;
|
||||
if base.skrifa().is_none_or(|f| f.axes().is_empty()) {
|
||||
return Some(base);
|
||||
}
|
||||
let Some(font) = base.skrifa().filter(|f| !f.axes().is_empty()) else { return Some(base) };
|
||||
let axis_tags: Vec<[u8; 4]> = font.axes().iter().map(|a| a.tag().to_be_bytes()).collect();
|
||||
let mut coords = base.coords.clone();
|
||||
for kv in style[open + 1..].trim_end_matches('}').split(',') {
|
||||
let (k, v) = kv.split_once(':')?;
|
||||
@@ -1155,6 +1282,11 @@ impl FontDb {
|
||||
return None;
|
||||
}
|
||||
let tag = [k[0], k[1], k[2], k[3]];
|
||||
// A tag the font has no axis for changes nothing; keeping it would grow the list with
|
||||
// every distinct tag in the style text.
|
||||
if !axis_tags.contains(&tag) {
|
||||
continue;
|
||||
}
|
||||
match coords.iter_mut().find(|(t, _)| *t == tag) {
|
||||
Some(c) => c.1 = v,
|
||||
None => coords.push((tag, v)),
|
||||
@@ -1166,7 +1298,7 @@ impl FontDb {
|
||||
Some(f)
|
||||
}
|
||||
|
||||
fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
pub(crate) fn find(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
|
||||
if style.contains('{') {
|
||||
{
|
||||
let faces = self.read_faces();
|
||||
@@ -1352,7 +1484,8 @@ impl FontDb {
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
let cataloged: Vec<String> =
|
||||
if self.system_fallback_enabled() { self.read_catalog().iter().map(|e| e.family.clone()).collect() } else { Vec::new() };
|
||||
for fam in SYSTEM_FALLBACKS.iter().filter(|f| f.contains("Symbol")) {
|
||||
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
|
||||
self.load_cataloged(fam);
|
||||
@@ -1441,41 +1574,72 @@ impl FontDb {
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return p.clone();
|
||||
return p;
|
||||
}
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS);
|
||||
if let Some(f) = face.skrifa()
|
||||
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
|
||||
{
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
|
||||
}
|
||||
let p = Arc::new(pen.0);
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, p.clone());
|
||||
let p = Arc::new(pen.finish());
|
||||
self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone());
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
struct FlipPen(BezPath);
|
||||
/// Collects a glyph outline, y flipped, up to `limit` path elements. Past the limit it ignores
|
||||
/// what follows and [`FlipPen::finish`] gives an empty path: a cut-off outline would fill wrongly.
|
||||
struct FlipPen {
|
||||
path: BezPath,
|
||||
limit: usize,
|
||||
over: bool,
|
||||
}
|
||||
|
||||
impl FlipPen {
|
||||
fn new(limit: usize) -> Self {
|
||||
FlipPen { path: BezPath::new(), limit, over: false }
|
||||
}
|
||||
|
||||
/// Room for one more element?
|
||||
fn room(&mut self) -> bool {
|
||||
if !self.over && self.path.elements().len() >= self.limit {
|
||||
self.over = true;
|
||||
self.path = BezPath::new();
|
||||
}
|
||||
!self.over
|
||||
}
|
||||
|
||||
fn finish(self) -> BezPath {
|
||||
if self.over { BezPath::new() } else { self.path }
|
||||
}
|
||||
}
|
||||
|
||||
impl OutlinePen for FlipPen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.0.move_to((x as f64, -y as f64));
|
||||
if self.room() {
|
||||
self.path.move_to((x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.0.line_to((x as f64, -y as f64));
|
||||
if self.room() {
|
||||
self.path.line_to((x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
|
||||
if self.room() {
|
||||
self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
|
||||
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
|
||||
if self.room() {
|
||||
self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
self.0.close_path();
|
||||
if self.room() {
|
||||
self.path.close_path();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1503,6 +1667,55 @@ mod font_dir_tests {
|
||||
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_of_one_file_are_bounded_across_the_collection() {
|
||||
// The per-collection and per-face limits together would allow far more.
|
||||
assert!((MAX_COLLECTION_FACES as usize) * MAX_NAMED_INSTANCES_PER_FACE > 16 * MAX_ENUMERATED_FACES);
|
||||
for data in bundled() {
|
||||
assert!(enumerate_faces(data).len() <= MAX_ENUMERATED_FACES);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_outline_past_the_element_limit_draws_nothing() {
|
||||
let mut pen = FlipPen::new(8);
|
||||
pen.move_to(0.0, 0.0);
|
||||
pen.line_to(1.0, 1.0);
|
||||
pen.close();
|
||||
assert_eq!(pen.finish().elements().len(), 3);
|
||||
// A shared outline drawn over and over (composite and VARC glyphs).
|
||||
let mut pen = FlipPen::new(8);
|
||||
for i in 0..1_000_000 {
|
||||
pen.move_to(0.0, 0.0);
|
||||
pen.line_to(i as f32, 1.0);
|
||||
pen.close();
|
||||
}
|
||||
assert_eq!(pen.path.elements().len(), 0);
|
||||
assert!(pen.finish().elements().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_cache_is_bounded_by_elements() {
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
for i in 1..MAX_OUTLINE_ELEMENTS {
|
||||
path.line_to((i as f64, 0.0));
|
||||
}
|
||||
let path = Arc::new(path);
|
||||
let mut cache = OutlineCache::default();
|
||||
let glyphs = 4 * OUTLINE_CACHE_MAX_ELEMENTS / MAX_OUTLINE_ELEMENTS;
|
||||
for gid in 0..glyphs as u32 {
|
||||
cache.insert((1, gid), path.clone());
|
||||
assert!(cache.elements <= OUTLINE_CACHE_MAX_ELEMENTS);
|
||||
assert_eq!(cache.elements, cache.paths.values().map(|p| p.elements().len()).sum::<usize>());
|
||||
}
|
||||
assert!(cache.get(&(1, glyphs as u32 - 1)).is_some());
|
||||
// Replacing an entry does not count it twice.
|
||||
let before = cache.elements;
|
||||
cache.insert((1, glyphs as u32 - 1), path.clone());
|
||||
assert_eq!(cache.elements, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_data_dirs_contribute_their_fonts_folders() {
|
||||
let home = Path::new("/home/ann");
|
||||
|
||||
@@ -15,7 +15,8 @@ mod shape;
|
||||
|
||||
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
|
||||
pub use fontdb::{
|
||||
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
|
||||
FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, is_monospace_family, substitutes,
|
||||
system_font_dirs,
|
||||
};
|
||||
pub use harfrust::Feature;
|
||||
use harfrust::Tag;
|
||||
|
||||
+161
-38
@@ -37,56 +37,146 @@ pub struct ShapeParams<'a> {
|
||||
pub language: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// Most bytes of text given to the shaper at once. The shaper lets one call grow its buffers to
|
||||
/// 256 glyphs per character (a font's substitutions decide how many), so a long item is shaped in
|
||||
/// pieces of this size, each with the rest of the paragraph as context.
|
||||
const MAX_SHAPE_CHUNK: usize = 1024;
|
||||
/// Most glyphs kept per character of a piece: a piece the font expands further is laid out one
|
||||
/// glyph per character from the cmap instead.
|
||||
const MAX_GLYPHS_PER_CHAR: usize = 32;
|
||||
|
||||
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
|
||||
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
|
||||
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
|
||||
pub fn shape_range(face: &FontFace, para: &str, range: Range<usize>, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
|
||||
let (Some(_), Some(text), Some(_)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
let chunks = chunks(text, MAX_SHAPE_CHUNK);
|
||||
let mut pieces: Vec<Vec<ShapedGlyph>> = Vec::with_capacity(chunks.len());
|
||||
let shaped = face.hb().map(|hb| {
|
||||
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
buf.set_pre_context(pre);
|
||||
buf.set_post_context(post);
|
||||
for (i, c) in text.char_indices() {
|
||||
buf.add(map(c), (range.start + i) as u32);
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
|
||||
buf.set_script(s);
|
||||
}
|
||||
if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
|
||||
{
|
||||
buf.set_language(l);
|
||||
}
|
||||
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
|
||||
for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
|
||||
out.push(ShapedGlyph {
|
||||
gid: info.glyph_id,
|
||||
cluster: info.cluster as usize,
|
||||
x_advance: pos.x_advance,
|
||||
x_offset: pos.x_offset,
|
||||
y_offset: pos.y_offset,
|
||||
});
|
||||
// Pieces of one item share the script the whole item would be guessed to have.
|
||||
let item_script = (chunks.len() > 1 && params.script.is_none()).then(|| {
|
||||
let mut probe = UnicodeBuffer::new();
|
||||
for c in text.chars() {
|
||||
probe.add(map(c), 0);
|
||||
}
|
||||
probe.guess_segment_properties();
|
||||
probe.script()
|
||||
});
|
||||
for c in &chunks {
|
||||
let r = (range.start + c.start)..(range.start + c.end);
|
||||
let (Some(pre), Some(piece), Some(post)) = (para.get(..r.start), para.get(r.clone()), para.get(r.end..)) else { continue };
|
||||
let mut buf = UnicodeBuffer::new();
|
||||
buf.set_pre_context(pre);
|
||||
buf.set_post_context(post);
|
||||
for (i, c) in piece.char_indices() {
|
||||
buf.add(map(c), (r.start + i) as u32);
|
||||
}
|
||||
buf.guess_segment_properties();
|
||||
if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
|
||||
buf.set_script(s);
|
||||
} else if let Some(s) = item_script {
|
||||
buf.set_script(s);
|
||||
}
|
||||
if let Some(l) =
|
||||
params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::<Language>().ok())
|
||||
{
|
||||
buf.set_language(l);
|
||||
}
|
||||
buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
|
||||
let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
|
||||
let budget = piece.chars().count().saturating_mul(MAX_GLYPHS_PER_CHAR);
|
||||
if gb.len() > budget {
|
||||
// Font-controlled expansion past anything real text needs: keep it bounded.
|
||||
pieces.push(cmap_glyphs(face, para, r, params.rtl, &map));
|
||||
continue;
|
||||
}
|
||||
pieces.push(
|
||||
gb.glyph_infos()
|
||||
.iter()
|
||||
.zip(gb.glyph_positions())
|
||||
.map(|(info, pos)| ShapedGlyph {
|
||||
gid: info.glyph_id,
|
||||
cluster: info.cluster as usize,
|
||||
x_advance: pos.x_advance,
|
||||
x_offset: pos.x_offset,
|
||||
y_offset: pos.y_offset,
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
});
|
||||
if shaped.is_none()
|
||||
&& let Some(f) = face.skrifa()
|
||||
{
|
||||
if shaped.is_none() {
|
||||
// No shaper for this face: one glyph per character from the cmap, in visual order.
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
for (i, c) in text.char_indices() {
|
||||
let g = cmap.map(map(c)).unwrap_or_default();
|
||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
||||
}
|
||||
if params.rtl {
|
||||
out.reverse();
|
||||
return cmap_glyphs(face, para, range, params.rtl, &map);
|
||||
}
|
||||
// Each piece is in visual order; right to left, the later pieces come first.
|
||||
if params.rtl {
|
||||
pieces.reverse();
|
||||
}
|
||||
let mut out = Vec::with_capacity(pieces.iter().map(Vec::len).sum());
|
||||
for p in pieces {
|
||||
out.extend(p);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One glyph per character of `para[range]` from the cmap, in visual order.
|
||||
fn cmap_glyphs(face: &FontFace, para: &str, range: Range<usize>, rtl: bool, map: &impl Fn(char) -> char) -> Vec<ShapedGlyph> {
|
||||
let (Some(text), Some(f)) = (para.get(range.clone()), face.skrifa()) else { return Vec::new() };
|
||||
let cmap = f.charmap();
|
||||
let gm = f.glyph_metrics(Size::unscaled(), face.location());
|
||||
let mut out = Vec::with_capacity(text.len());
|
||||
for (i, c) in text.char_indices() {
|
||||
let g = cmap.map(map(c)).unwrap_or_default();
|
||||
let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
|
||||
out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
|
||||
}
|
||||
if rtl {
|
||||
out.reverse();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `text` cut into byte ranges of at most `max` bytes (or one character, if longer), at the last
|
||||
/// space of a piece where there is one, else before a character that starts a cluster (not a
|
||||
/// combining mark or joiner, not after a joiner). Text that fits is one piece.
|
||||
fn chunks(text: &str, max: usize) -> Vec<Range<usize>> {
|
||||
use unicode_bidi::BidiClass::{BN, NSM};
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
while text.len() - start > max {
|
||||
let window = text.get(start..).unwrap_or_default();
|
||||
let mut space = None;
|
||||
let mut boundary = None;
|
||||
let mut any = None;
|
||||
let mut prev: Option<char> = None;
|
||||
for (i, c) in window.char_indices() {
|
||||
if i > max {
|
||||
break;
|
||||
}
|
||||
if i > 0 {
|
||||
any = Some(i);
|
||||
let joined = prev.is_some_and(|p| matches!(unicode_bidi::bidi_class(p), BN));
|
||||
if !joined && !matches!(unicode_bidi::bidi_class(c), NSM | BN) {
|
||||
boundary = Some(i);
|
||||
if prev.is_some_and(char::is_whitespace) {
|
||||
space = Some(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
prev = Some(c);
|
||||
}
|
||||
// A piece always moves forward by at least one character.
|
||||
let first = window.chars().next().map_or(window.len(), char::len_utf8);
|
||||
let cut = space.filter(|&i| i >= max / 2).or(boundary).or(any).unwrap_or(first).max(first);
|
||||
out.push(start..start + cut);
|
||||
start += cut;
|
||||
}
|
||||
if start < text.len() || out.is_empty() {
|
||||
out.push(start..text.len());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -121,4 +211,37 @@ mod tests {
|
||||
// Not a char boundary.
|
||||
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_items_are_shaped_in_bounded_pieces() {
|
||||
for text in ["word ".repeat(2_000), "e\u{301}".repeat(2_000), "x".repeat(5_000), "\u{1f469}\u{200d}\u{1f4bb} ".repeat(500)] {
|
||||
let pieces = chunks(&text, MAX_SHAPE_CHUNK);
|
||||
assert_eq!(pieces.first().map(|r| r.start), Some(0));
|
||||
assert_eq!(pieces.last().map(|r| r.end), Some(text.len()));
|
||||
for w in pieces.windows(2) {
|
||||
assert_eq!(w[0].end, w[1].start);
|
||||
}
|
||||
for r in &pieces {
|
||||
assert!(!r.is_empty() && r.len() <= MAX_SHAPE_CHUNK);
|
||||
// Never before a combining mark, never after a joiner.
|
||||
let rest = &text[r.end..];
|
||||
assert!(!rest.starts_with('\u{301}'));
|
||||
assert!(!text[..r.end].ends_with('\u{200d}') || rest.is_empty());
|
||||
}
|
||||
}
|
||||
assert_eq!(chunks("short", MAX_SHAPE_CHUNK), vec![0..5]);
|
||||
assert_eq!(chunks("", MAX_SHAPE_CHUNK), vec![0..0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_items_keep_every_cluster_in_visual_order() {
|
||||
let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
|
||||
let text = "ab cd ".repeat(1_000);
|
||||
let g = shape_range(&face, &text, 0..text.len(), &ShapeParams::default(), |c| c);
|
||||
assert_eq!(g.len(), text.len());
|
||||
assert!(g.windows(2).all(|w| w[0].cluster < w[1].cluster));
|
||||
let rtl = shape_range(&face, &text, 0..text.len(), &ShapeParams { rtl: true, ..Default::default() }, |c| c);
|
||||
assert_eq!(rtl.len(), text.len());
|
||||
assert!(rtl.windows(2).all(|w| w[0].cluster > w[1].cluster));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -216,9 +216,13 @@ fn missing_document_fonts_are_drawn_in_calibri() {
|
||||
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
|
||||
}
|
||||
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
|
||||
for (missing, twin) in
|
||||
[("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")]
|
||||
{
|
||||
for (missing, twin) in [
|
||||
("Liberation Sans", "Arial"),
|
||||
("Liberation Serif", "Times New Roman"),
|
||||
("Arial CE", "Arial"),
|
||||
("Times New Roman CYR", "Times New Roman"),
|
||||
("Courier New CE", "Courier New"),
|
||||
] {
|
||||
if !db.has_family(missing) {
|
||||
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
|
||||
}
|
||||
@@ -237,6 +241,73 @@ fn office_fonts_have_substitutes() {
|
||||
assert!(substitutes("Times New Roman").contains(&"Liberation Serif"));
|
||||
assert!(substitutes("Aptos").contains(&"Inter"));
|
||||
assert!(substitutes("zzz").is_empty());
|
||||
|
||||
for mono in [
|
||||
"Courier New",
|
||||
"Cousine",
|
||||
"Consolas",
|
||||
"Menlo",
|
||||
"Monaco",
|
||||
"Source Code Pro",
|
||||
"JetBrains Mono",
|
||||
"Cascadia Code",
|
||||
"Cascadia Code PL",
|
||||
"Cascadia Mono",
|
||||
"Fira Code",
|
||||
"Inconsolata",
|
||||
"DejaVu Sans Mono",
|
||||
"Roboto Mono",
|
||||
"Hack",
|
||||
"Ubuntu Mono",
|
||||
"Custom Monospace",
|
||||
"Fantasque Sans Mono",
|
||||
] {
|
||||
assert!(substitutes(mono).contains(&"Liberation Mono"), "{mono} should have Liberation Mono substitute");
|
||||
assert!(is_monospace_family(mono), "{mono} should be recognized as monospace");
|
||||
}
|
||||
|
||||
for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monoton", "Monotype Baskerville", "Code Pro", "zzz"] {
|
||||
assert!(!is_monospace_family(prop), "{prop} should not be recognized as monospace");
|
||||
}
|
||||
|
||||
// Explicit checks requested in review: Consolas, Courier New, Fira Code, Monotype Corsiva, Monoton,
|
||||
// and ensuring proportional families maintain their unchanged substitutes.
|
||||
assert!(is_monospace_family("Consolas"));
|
||||
assert!(is_monospace_family("Courier New"));
|
||||
assert!(is_monospace_family("Fira Code"));
|
||||
assert!(!is_monospace_family("Monotype Corsiva"));
|
||||
assert!(!is_monospace_family("Monoton"));
|
||||
|
||||
assert_eq!(substitutes("Calibri"), &["Carlito", "Liberation Sans", "Arial", "Inter"]);
|
||||
assert_eq!(substitutes("Arial"), &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"]);
|
||||
assert_eq!(substitutes("Times New Roman"), &["Liberation Serif", "Times New Roman", "Times", "Merriweather"]);
|
||||
assert_eq!(substitutes("Georgia"), &["Merriweather", "Liberation Serif", "Georgia"]);
|
||||
assert!(substitutes("Monotype Corsiva").is_empty());
|
||||
assert!(substitutes("Monoton").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_monospace_fonts_keep_monospace_fallback() {
|
||||
let db = FontDb::global();
|
||||
let mono_sub = db.face("Unavailable JetBrains Mono", "Regular").family.clone();
|
||||
let calibri_target = db.face(MISSING_FONT_FAMILY, "Regular").family.clone();
|
||||
|
||||
assert!(is_monospace_family(&mono_sub), "substitute {mono_sub} must be monospace");
|
||||
|
||||
// Cousine is metric-compatible with Courier New / Liberation Mono and must not fall back to Calibri.
|
||||
if !db.has_family("Cousine") {
|
||||
assert_eq!(db.document_face("Cousine", "Regular").family, mono_sub);
|
||||
}
|
||||
|
||||
// Monospace fonts missing from the system must resolve to a monospace substitute.
|
||||
for mono in ["Unavailable JetBrains Mono", "Custom App Code", "Unknown Space Mono", "Mock Terminal Font"] {
|
||||
let f = db.document_face(mono, "Regular");
|
||||
assert_eq!(f.family, mono_sub, "{mono}");
|
||||
assert!(is_monospace_family(&f.family), "{mono} must resolve to a monospace family");
|
||||
if mono_sub != calibri_target {
|
||||
assert_ne!(f.family, calibri_target, "{mono} must not fall back to Calibri");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -354,3 +425,11 @@ fn the_font_scan_skips_fifos_folders_and_link_loops() {
|
||||
assert!(fifo || n >= 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_system_fallback_gates_automatic_family_loads() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
assert!(cfg!(target_arch = "wasm32") || db.system_fallback_enabled());
|
||||
db.set_system_fallback(false);
|
||||
assert!(!db.system_fallback_enabled());
|
||||
}
|
||||
|
||||
@@ -85,14 +85,14 @@ fn chart_parts_share_media_byte_and_item_budgets() {
|
||||
let mut p = fixture(vec![7; 600]);
|
||||
p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
|
||||
let bytes = save(&p).unwrap();
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
|
||||
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget");
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget");
|
||||
assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
|
||||
assert!(load_within(&bytes, Budget::media(1199, 2)).is_err(), "chart payloads share the cumulative media budget");
|
||||
assert!(load_within(&bytes, Budget::media(1200, 1)).is_err(), "chart parts share the media item budget");
|
||||
let duplicated = rewrite_manifest(&bytes, |manifest| {
|
||||
let media = manifest["presentation"]["media"].as_array_mut().unwrap();
|
||||
media.push(media[0].clone());
|
||||
});
|
||||
assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items");
|
||||
assert!(load_within(&duplicated, Budget::media(1200, 2)).is_err(), "duplicate media IDs still count as listed items");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -104,9 +104,9 @@ fn legacy_inline_chart_bytes_are_budgeted_and_preserved() {
|
||||
part.remove("nativeEntry");
|
||||
part.insert("bytes".into(), json!(vec![9_u8; 600]));
|
||||
});
|
||||
assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p);
|
||||
assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err());
|
||||
assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err());
|
||||
assert_eq!(load_within(&legacy, Budget::media(1200, 2)).unwrap().0, p);
|
||||
assert!(load_within(&legacy, Budget::media(1199, 2)).is_err());
|
||||
assert!(load_within(&legacy, Budget::media(1200, 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -207,7 +207,7 @@ fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() {
|
||||
}
|
||||
// A failed CRC may already have inflated bytes. Continuing with the same remaining
|
||||
// budget would let each corrupt member consume that work budget again.
|
||||
let result = load_within(&bytes, Budget { total: 10, items: 2 });
|
||||
let result = load_within(&bytes, Budget::media(10, 2));
|
||||
assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget");
|
||||
assert!(result.unwrap_err().to_string().contains("chart-parts/0"));
|
||||
}
|
||||
@@ -228,7 +228,7 @@ fn duplicate_physical_zip_names_are_indexed_and_inflated_once() {
|
||||
assert_eq!(replaced, 2);
|
||||
let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1);
|
||||
let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap();
|
||||
let (q, damaged) = load_within(&bytes, Budget::media(10, 2)).unwrap();
|
||||
assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again");
|
||||
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
|
||||
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
|
||||
|
||||
+66
-14
@@ -29,12 +29,24 @@ const MAX_ENTRY: u64 = 2 << 30;
|
||||
const MAX_TOTAL_MEDIA: u64 = 4 << 30;
|
||||
/// Media items and chart sidecar parts one file may list.
|
||||
const MAX_MEDIA_ITEMS: usize = 100_000;
|
||||
/// Largest `presentation.json` we will inflate. The manifest holds no media bytes, so it is far
|
||||
/// smaller than a media entry may be; inflating and parsing one is what dominates memory.
|
||||
const MAX_MANIFEST: u64 = 256 << 20;
|
||||
|
||||
/// What one load may inflate.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Budget {
|
||||
total: u64,
|
||||
items: usize,
|
||||
manifest: u64,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Budget {
|
||||
/// A media budget with the default manifest limit.
|
||||
fn media(total: u64, items: usize) -> Self {
|
||||
Budget { total, items, manifest: MAX_MANIFEST }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -107,6 +119,7 @@ pub fn sniff(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"PK") && [MIMETYPE, LEGACY_MIMETYPE].iter().any(|m| bytes.windows(m.len()).take(200).any(|w| w == m.as_bytes()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<u8>> {
|
||||
read_entry_max(z, name, MAX_ENTRY)
|
||||
}
|
||||
@@ -114,13 +127,30 @@ fn read_entry(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str) -> Result<Vec<
|
||||
/// Read one member, refusing it before inflating if it declares more than `max` bytes, and
|
||||
/// stopping at `max` if it inflates further than it declared.
|
||||
fn read_entry_max(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64) -> Result<Vec<u8>> {
|
||||
read_entry_counted(z, name, max, &mut 0)
|
||||
}
|
||||
|
||||
/// [`read_entry_max`] within `left` (and [`MAX_ENTRY`]), charging `left` for every byte inflated,
|
||||
/// including those inflated before a decode or CRC error, so a member that fails late still
|
||||
/// spends the shared budget it used.
|
||||
fn read_entry_charged(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, left: &mut u64) -> Result<Vec<u8>> {
|
||||
let mut spent = 0;
|
||||
let out = read_entry_counted(z, name, (*left).min(MAX_ENTRY), &mut spent);
|
||||
*left = left.saturating_sub(spent);
|
||||
out
|
||||
}
|
||||
|
||||
/// [`read_entry_max`], adding the bytes inflated to `spent` whether or not the read succeeds.
|
||||
fn read_entry_counted(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64, spent: &mut u64) -> Result<Vec<u8>> {
|
||||
let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
|
||||
if f.size() > max {
|
||||
return Err(FormatError::Damaged(format!("{name} is too large")));
|
||||
}
|
||||
let mut out = Vec::with_capacity(f.size().min(64 << 20) as usize);
|
||||
let mut f = f.take(max.saturating_add(1));
|
||||
f.read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
|
||||
let read = f.read_to_end(&mut out);
|
||||
*spent = spent.saturating_add(out.len() as u64);
|
||||
read.map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
|
||||
if out.len() as u64 > max {
|
||||
return Err(FormatError::Damaged(format!("{name} is too large")));
|
||||
}
|
||||
@@ -137,12 +167,15 @@ pub fn load(bytes: &[u8]) -> Result<Presentation> {
|
||||
/// A chart member that is present but fails decoding, CRC or its entry limit refuses the file.
|
||||
/// Linked media saved without its bytes isn't damage.
|
||||
pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS })
|
||||
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS, manifest: MAX_MANIFEST })
|
||||
}
|
||||
|
||||
fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String>)> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?;
|
||||
let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?;
|
||||
if z.index_for_name("presentation.json").is_none() {
|
||||
return Err(FormatError::NotOurs("no presentation.json".into()));
|
||||
}
|
||||
let json = read_entry_max(&mut z, "presentation.json", budget.manifest)?;
|
||||
let m = parse_manifest(&json)?;
|
||||
if m.format != "deckcraft" {
|
||||
return Err(FormatError::NotOurs(m.format));
|
||||
@@ -164,11 +197,9 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String
|
||||
if z.by_name(&name).is_ok_and(|f| f.size() > left) {
|
||||
return Err(FormatError::Damaged("embedded media is larger than DeckCraft can open".into()));
|
||||
}
|
||||
match read_entry_max(&mut z, &name, left.min(MAX_ENTRY)) {
|
||||
Ok(data) => {
|
||||
left -= data.len() as u64;
|
||||
item.data = Arc::new(data);
|
||||
}
|
||||
// A failed read is charged for what it inflated, so later members can't reuse that budget.
|
||||
match read_entry_charged(&mut z, &name, &mut left) {
|
||||
Ok(data) => item.data = Arc::new(data),
|
||||
Err(_) if linked_only => {}
|
||||
Err(e) => damaged.push(e.to_string()),
|
||||
}
|
||||
@@ -188,9 +219,11 @@ pub fn repair(p: &mut Presentation) {
|
||||
}
|
||||
p.fix_next_id();
|
||||
let first_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
|
||||
// Indexed once: a per-slide scan of every layout is slides × layouts work.
|
||||
let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for i in 0..p.slides.len() {
|
||||
let needs_layout = p.slides.get(i).is_some_and(|s| p.layout(s.layout).is_none());
|
||||
let needs_layout = p.slides.get(i).is_some_and(|s| !layouts.contains(&s.layout));
|
||||
let dup = p.slides.get(i).is_some_and(|s| !seen.insert(s.id));
|
||||
if needs_layout || dup {
|
||||
// An exhausted id space leaves a duplicate slide as it is: the engine refuses to
|
||||
@@ -214,6 +247,11 @@ pub fn repair(p: &mut Presentation) {
|
||||
if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) {
|
||||
p.notes_size = Presentation::default().notes_size;
|
||||
}
|
||||
// A notes page is bounded like a slide: canvas and handout drawing scale with it.
|
||||
p.notes_size = deckcraft_model::Size::new(
|
||||
p.notes_size.width.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()),
|
||||
p.notes_size.height.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()),
|
||||
);
|
||||
sane_numbers(p);
|
||||
}
|
||||
|
||||
@@ -331,16 +369,20 @@ fn failure(src: &[u8]) -> std::result::Result<Manifest, (String, (usize, usize))
|
||||
/// nearest first, and a patch is kept only if the parse then gets further. (Files saved since
|
||||
/// [`finite::Finite`] never contain such `null`s; this reads older ones.)
|
||||
fn parse_manifest(json: &[u8]) -> Result<Manifest> {
|
||||
let mut src = json.to_vec();
|
||||
let mut last = match failure(&src) {
|
||||
// Parse the inflated text as it is: only a file that fails on a `null` number is copied for
|
||||
// patching (the copy is bounded by `MAX_MANIFEST`).
|
||||
let mut last = match failure(json) {
|
||||
Ok(m) => return Ok(m),
|
||||
Err(f) => f,
|
||||
};
|
||||
if !is_null_number(&last.0) {
|
||||
return Err(FormatError::Damaged(last.0));
|
||||
}
|
||||
let mut src = json.to_vec();
|
||||
let mut budget = MAX_REPARSE_BYTES;
|
||||
'fixes: for _ in 0..MAX_NULL_FIXES {
|
||||
let (msg, pos) = &last;
|
||||
let number = msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number");
|
||||
if !number {
|
||||
if !is_null_number(msg) {
|
||||
break;
|
||||
}
|
||||
let end = offset(&src, pos.0, pos.1).unwrap_or(src.len());
|
||||
@@ -374,6 +416,11 @@ fn parse_manifest(json: &[u8]) -> Result<Manifest> {
|
||||
Err(FormatError::Damaged(last.0))
|
||||
}
|
||||
|
||||
/// Is this parse error a `null` where a number belongs (a NaN or infinity serde_json wrote)?
|
||||
fn is_null_number(msg: &str) -> bool {
|
||||
msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number")
|
||||
}
|
||||
|
||||
/// Byte offset of a 1-based `line`:`column`.
|
||||
fn offset(src: &[u8], line: usize, column: usize) -> Option<usize> {
|
||||
let mut start = 0;
|
||||
@@ -453,6 +500,9 @@ fn sane_shadow(s: &mut deckcraft_model::style::Shadow) {
|
||||
fn sane_reflection(r: &mut deckcraft_model::style::Reflection) {
|
||||
r.blur = effect_size(r.blur, MAX_REFLECTION);
|
||||
r.dist = effect_size(r.dist, MAX_REFLECTION);
|
||||
if r.align.as_deref().is_some_and(|v| !matches!(v, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) {
|
||||
r.align = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Run properties a list style hands down to the text that inherits it.
|
||||
@@ -644,7 +694,9 @@ pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
out.push_str(&s.title().replace('\n', " "));
|
||||
out.push('\n');
|
||||
for sh in &s.shapes {
|
||||
if sh.ph_type().is_some_and(|k| k.is_title()) {
|
||||
// The title is the slide's line; footer, date and slide-number placeholders are not
|
||||
// body text (PowerPoint's outline view shows title and body placeholders only).
|
||||
if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(t) = &sh.text {
|
||||
|
||||
@@ -71,7 +71,8 @@ pub fn slides_to_rtf(p: &Presentation) -> String {
|
||||
}
|
||||
out.push_str("\\b0\\par\n");
|
||||
for sh in &s.shapes {
|
||||
if sh.ph_type().is_some_and(|k| k.is_title()) {
|
||||
// As in the text outline: footer, date and slide-number placeholders are not body text.
|
||||
if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) {
|
||||
continue;
|
||||
}
|
||||
let Some(t) = &sh.text else { continue };
|
||||
@@ -299,7 +300,8 @@ impl Outline<'_> {
|
||||
|
||||
/// A one-character control symbol (`\\`, `\{`, `\~`, ...).
|
||||
fn symbol(&mut self, n: u8) {
|
||||
self.i += 1;
|
||||
let c = self.rtf.get(self.i..).and_then(|r| r.chars().next());
|
||||
self.i = advance(self.rtf, self.i, 1);
|
||||
if self.st.skip {
|
||||
return;
|
||||
}
|
||||
@@ -308,6 +310,7 @@ impl Outline<'_> {
|
||||
b'~' => self.line.push('\u{a0}'),
|
||||
b'_' => self.line.push('\u{2011}'),
|
||||
b'\n' | b'\r' => self.flush(),
|
||||
_ if !n.is_ascii() => self.line.extend(c),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -419,6 +422,11 @@ mod tests {
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_control_symbol_keeps_character_and_following_text() {
|
||||
assert_eq!(rtf_to_outline("{\\rtf1 A\\éB\\par}"), "AéB\n");
|
||||
}
|
||||
|
||||
/// `\\_` is RTF's non-breaking hyphen: a visible character, not markup.
|
||||
#[test]
|
||||
fn non_breaking_hyphen_is_kept() {
|
||||
|
||||
+167
-4
@@ -11,6 +11,31 @@ fn chart_sidecars_are_optional_for_older_native_files() {
|
||||
assert_eq!(serde_json::from_value::<Chart>(json).expect("empty sidecar"), chart);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_run_language_survives_two_native_saves_and_absence() {
|
||||
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
Arc::make_mut(&mut p.slides[0]).shapes[0].text = Some(TextBody {
|
||||
paragraphs: vec![Paragraph {
|
||||
runs: vec![
|
||||
Run::with("bonjour", RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }),
|
||||
Run::with(" plain", RunProps { lang: Some("en-US".into()), ..Default::default() }),
|
||||
],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
let mut q = load(&save(&p).expect("save")).expect("load");
|
||||
for _ in 0..2 {
|
||||
let runs = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].runs;
|
||||
assert_eq!(runs[0].props.lang.as_deref(), Some("en-US"));
|
||||
assert_eq!(runs[0].props.alt_lang.as_deref(), Some("fr-FR"));
|
||||
assert_eq!(runs[1].props.lang.as_deref(), Some("en-US"));
|
||||
assert_eq!(runs[1].props.alt_lang, None);
|
||||
q = load(&save(&q).expect("save again")).expect("load again");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_save_keeps_chart_relationships_and_payloads() {
|
||||
use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
|
||||
@@ -48,6 +73,38 @@ fn native_save_keeps_chart_relationships_and_payloads() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_table_direction_and_column_text_survive_two_saves() {
|
||||
use deckcraft_model::table::{Cell, Table};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
for rtl in [Some(true), Some(false), None] {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let mut table = Table::new(1, 2, 200.0, 30.0);
|
||||
table.rtl = rtl;
|
||||
table.rows[0].cells[0] = Cell::new("Left column");
|
||||
table.rows[0].cells[1] = Cell::new("Right column");
|
||||
let shape = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Table(table), ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(shape);
|
||||
let mut q = load(&save(&p).expect("save")).expect("load");
|
||||
for _ in 0..2 {
|
||||
let table = q.slides[0]
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|s| match &s.kind {
|
||||
ShapeKind::Table(table) => Some(table),
|
||||
_ => None,
|
||||
})
|
||||
.expect("table shape");
|
||||
assert_eq!(table.rtl, rtl);
|
||||
assert_eq!(table.rows[0].cells[0].text.paragraphs[0].runs[0].text, "Left column");
|
||||
assert_eq!(table.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Right column");
|
||||
q = load(&save(&q).expect("save again")).expect("load again");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same package without the entries `drop` names.
|
||||
fn without(bytes: &[u8], drop: &str) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
@@ -93,9 +150,60 @@ fn media_budget_is_cumulative() {
|
||||
p.add_media("b.bin", "application/octet-stream", vec![2; 600]);
|
||||
let bytes = save(&p).unwrap();
|
||||
// Exactly at the limit opens; one byte under is refused, though each item fits.
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
|
||||
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err());
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err());
|
||||
assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
|
||||
assert!(load_within(&bytes, Budget::media(1199, 2)).is_err());
|
||||
assert!(load_within(&bytes, Budget::media(1200, 1)).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_media_is_charged_for_what_it_inflated() {
|
||||
let mut p = Presentation::default();
|
||||
p.add_media("a.png", "image/png", b"PICTURE-AAAAAAAAAAAA".to_vec());
|
||||
p.add_media("b.png", "image/png", b"PICTURE-BBBBBBBBBBBB".to_vec());
|
||||
let mut bytes = save(&p).unwrap();
|
||||
// Corrupt both stored members, so each fails its CRC only after inflating all its bytes.
|
||||
for tag in [b"PICTURE-AAAA", b"PICTURE-BBBB"] {
|
||||
let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap();
|
||||
bytes[at] ^= 0xff;
|
||||
}
|
||||
// Each member fits the budget on its own; the second must not reuse what the first spent.
|
||||
let result = load_within(&bytes, Budget::media(20, 2));
|
||||
assert!(result.is_err(), "two corrupt 20-byte members must not share a 20-byte budget");
|
||||
// With room for both, the file opens and reports both.
|
||||
let (_, damaged) = load_within(&bytes, Budget::media(40, 2)).unwrap();
|
||||
assert_eq!(damaged.len(), 2, "{damaged:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_manifest_is_refused_before_inflating() {
|
||||
let bytes = save(&Presentation::default()).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
let size = z.by_name("presentation.json").unwrap().size();
|
||||
assert!(load_within(&bytes, Budget { total: 0, items: 0, manifest: size }).is_ok());
|
||||
let err = load_within(&bytes, Budget { total: 0, items: 0, manifest: size - 1 }).unwrap_err();
|
||||
assert!(err.to_string().contains("too large"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_slides_on_missing_layouts_are_repaired() {
|
||||
let mut p = Presentation::default();
|
||||
let template = p.slides[0].clone();
|
||||
let good = template.layout;
|
||||
p.slides = (0..2000u32)
|
||||
.map(|i| {
|
||||
let mut s = (*template).clone();
|
||||
s.id = deckcraft_model::SlideId(10_000 + i);
|
||||
if i % 2 == 1 {
|
||||
s.layout = deckcraft_model::LayoutId(900_000 + i);
|
||||
}
|
||||
Arc::new(s)
|
||||
})
|
||||
.collect();
|
||||
p.next_id = 20_000;
|
||||
repair(&mut p);
|
||||
assert!(p.validate().is_empty(), "{:?}", p.validate().first());
|
||||
assert!(p.slides.iter().all(|s| p.layout(s.layout).is_some()));
|
||||
assert_eq!(p.slides[0].layout, good);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -124,7 +232,7 @@ fn repeated_media_ids_are_read_once() {
|
||||
}
|
||||
let bytes = out.finish().unwrap().into_inner();
|
||||
// 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once.
|
||||
let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0;
|
||||
let q = load_within(&bytes, Budget::media(1000, 51)).unwrap().0;
|
||||
assert_eq!(q.media.len(), 1);
|
||||
assert_eq!(q.media[0].data.len(), 1000);
|
||||
// And the deck saves again.
|
||||
@@ -208,6 +316,54 @@ fn a_file_saved_with_non_finite_numbers_reopens() {
|
||||
assert_eq!(load(&save(&back).unwrap()).unwrap(), back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflection_transform_survives_two_native_saves_and_absent_fields_stay_absent() {
|
||||
use deckcraft_model::style::Reflection;
|
||||
use deckcraft_model::{Effects, Shape};
|
||||
let mut p = Presentation::default();
|
||||
let reflection = Reflection {
|
||||
blur: 1.0,
|
||||
start_alpha: 0.5,
|
||||
end_alpha: 0.0,
|
||||
end_pos: 0.75,
|
||||
dist: 2.0,
|
||||
st_pos: Some(20_000),
|
||||
fade_dir: Some(0),
|
||||
sx: Some(75_000),
|
||||
sy: Some(-50_000),
|
||||
kx: Some(600_000),
|
||||
align: Some("bl".into()),
|
||||
rotate_with_shape: Some(false),
|
||||
..Default::default()
|
||||
};
|
||||
let shape = Shape { effects: Some(Effects { reflection: Some(reflection.clone()), ..Default::default() }), ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(shape);
|
||||
let once = load(&save(&p).expect("save")).expect("load");
|
||||
let twice = load(&save(&once).expect("save again")).expect("load again");
|
||||
assert_eq!(twice.slides[0].shapes.last().and_then(|s| s.effects.as_ref()).and_then(|e| e.reflection.as_ref()), Some(&reflection));
|
||||
|
||||
let absent = Reflection {
|
||||
blur: 1.0,
|
||||
start_alpha: 1.0,
|
||||
end_alpha: 0.0,
|
||||
end_pos: 1.0,
|
||||
dist: 0.0,
|
||||
st_pos: None,
|
||||
dir: None,
|
||||
fade_dir: None,
|
||||
sx: None,
|
||||
sy: None,
|
||||
kx: None,
|
||||
ky: None,
|
||||
align: None,
|
||||
rotate_with_shape: None,
|
||||
};
|
||||
let shape = Shape { effects: Some(Effects { reflection: Some(absent.clone()), ..Default::default() }), ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = vec![shape];
|
||||
let reopened = load(&save(&p).expect("save absent values")).expect("load absent values");
|
||||
assert_eq!(reopened.slides[0].shapes.last().and_then(|s| s.effects.as_ref()).and_then(|e| e.reflection.as_ref()), Some(&absent));
|
||||
}
|
||||
|
||||
/// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too,
|
||||
/// including a `null` inside a tagged enum, where serde reports only the enclosing object.
|
||||
#[test]
|
||||
@@ -295,6 +451,13 @@ fn degenerate_slide_sizes_are_repaired() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_bounds_the_notes_page_size() {
|
||||
let mut p = Presentation { notes_size: deckcraft_model::Size::new(1e12, 0.01), ..Default::default() };
|
||||
repair(&mut p);
|
||||
assert_eq!((p.notes_size.width, p.notes_size.height), (4032.0, 72.0));
|
||||
}
|
||||
|
||||
/// A file whose slide size is null, a string or missing a side (written by hand or by
|
||||
/// another tool; NaN is also saved as null) opens with the default size rather than failing
|
||||
/// as damaged.
|
||||
|
||||
+22
-3
@@ -71,7 +71,10 @@ impl Unit {
|
||||
/// `2.08"` style text for a length in points.
|
||||
pub fn format(self, pt: f64) -> String {
|
||||
let v = pt / self.points_per_unit();
|
||||
let s = format!("{v:.2}");
|
||||
let mut s = format!("{v:.2}");
|
||||
if s.starts_with('-') && s[1..].bytes().all(|b| b == b'0' || b == b'.') {
|
||||
s.remove(0);
|
||||
}
|
||||
format!("{s}{}", self.suffix())
|
||||
}
|
||||
/// Parses `2.5`, `2.5"`, `2.5 in`, `6 cm`, `30 pt`, `40px`, `5mm` into points. A bare number uses `self`.
|
||||
@@ -153,8 +156,8 @@ impl Xfrm {
|
||||
}
|
||||
/// Rotation normalised to 0..360.
|
||||
pub fn rot_norm(&self) -> f64 {
|
||||
let r = self.rot % 360.0;
|
||||
if r < 0.0 { r + 360.0 } else { r }
|
||||
let r = self.rot.rem_euclid(360.0);
|
||||
if r >= 360.0 { 0.0 } else { r }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,6 +225,14 @@ pub fn preset_flatten(path: &BezPath, f: &mut dyn FnMut(PathEl)) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn format_has_no_negative_zero() {
|
||||
assert_eq!(Unit::Points.format(-0.001), "0.00 pt");
|
||||
assert_eq!(Unit::Inches.format(-0.001), "0.00\"");
|
||||
assert!(!Unit::Centimeters.format(-0.01).starts_with('-'));
|
||||
assert_eq!(Unit::Points.format(-1.5), "-1.50 pt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emu_roundtrip_is_exact() {
|
||||
for emu in [0i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] {
|
||||
@@ -266,6 +277,14 @@ mod tests {
|
||||
assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rot_norm_stays_below_360() {
|
||||
for rot in [-1e-15, -1e-13, -f64::EPSILON, -0.0, 360.0, 720.0, -360.0] {
|
||||
let n = Xfrm { rot, ..Default::default() }.rot_norm();
|
||||
assert!((0.0..360.0).contains(&n), "rot {rot} -> {n}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn segment_distance() {
|
||||
assert!((dist_to_segment(Point::new(5.0, 5.0), Point::new(0.0, 0.0), Point::new(10.0, 0.0)) - 5.0).abs() < 1e-9);
|
||||
|
||||
@@ -126,6 +126,21 @@ fn split_curve(c: &[Point], n: usize, out: &mut Vec<Point>) {
|
||||
out.extend([p1, p2, p3]);
|
||||
}
|
||||
|
||||
/// A piece length ordered totally, so it can sit in a heap.
|
||||
#[derive(PartialEq)]
|
||||
struct Share(f64);
|
||||
impl Eq for Share {}
|
||||
impl PartialOrd for Share {
|
||||
fn partial_cmp(&self, o: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(o))
|
||||
}
|
||||
}
|
||||
impl Ord for Share {
|
||||
fn cmp(&self, o: &Self) -> std::cmp::Ordering {
|
||||
self.0.total_cmp(&o.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra
|
||||
/// splits go to the longest pieces, so a long side gets more points than a short one.
|
||||
fn refine(c: &Contour, n: usize) -> Contour {
|
||||
@@ -136,11 +151,15 @@ fn refine(c: &Contour, n: usize) -> Contour {
|
||||
let curves: Vec<&[Point]> = (0..have).filter_map(|k| c.pts.get(3 * k..3 * k + 4)).collect();
|
||||
let len: Vec<f64> = curves.iter().map(|q| curve_length(q) + 1e-9).collect();
|
||||
let mut parts = vec![1usize; curves.len()];
|
||||
// The curve with the longest piece gets the next split (the lowest index on a tie); a heap keeps
|
||||
// this O(n log n) instead of rescanning every curve for each split.
|
||||
let mut heap: std::collections::BinaryHeap<(Share, std::cmp::Reverse<usize>)> =
|
||||
len.iter().enumerate().map(|(k, l)| (Share(*l), std::cmp::Reverse(k))).collect();
|
||||
for _ in 0..n - have {
|
||||
let share = |k: usize| len.get(k).copied().unwrap_or(0.0) / parts.get(k).copied().unwrap_or(1) as f64;
|
||||
let best = (1..curves.len()).fold(0, |b, k| if share(k) > share(b) { k } else { b });
|
||||
let Some((_, std::cmp::Reverse(best))) = heap.pop() else { break };
|
||||
if let Some(p) = parts.get_mut(best) {
|
||||
*p += 1;
|
||||
heap.push((Share(len.get(best).copied().unwrap_or(0.0) / *p as f64), std::cmp::Reverse(best)));
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(1 + 3 * n);
|
||||
@@ -420,6 +439,16 @@ mod tests {
|
||||
assert_eq!(contours(&to_path(&cs)), cs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_many_curves_finishes_at_the_asked_count() {
|
||||
let mut c = Contour { closed: false, pts: vec![Point::ZERO] };
|
||||
for i in 1..=20_000 {
|
||||
line_to(&mut c.pts, Point::new(f64::from(i), f64::from(i % 7)));
|
||||
}
|
||||
let r = refine(&c, 40_000);
|
||||
assert_eq!(r.curves(), 40_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refine_splits_the_longest_curves_and_keeps_the_shape() {
|
||||
let c = Contour { closed: false, pts: vec![Point::ZERO, Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)] };
|
||||
|
||||
+19
-9
@@ -928,11 +928,25 @@ impl Warp {
|
||||
k_fill = 1.0;
|
||||
}
|
||||
zoom = zoom.max(k_fill);
|
||||
// Per band, measured once: its widest line and its first and last lines' baselines.
|
||||
let mut stats: Vec<Option<(f64, f64, f64)>> = vec![None; nb];
|
||||
for (l, &b) in lines.iter().zip(&band_of) {
|
||||
let w = l.extent.1 - l.extent.0;
|
||||
if let Some(slot) = stats.get_mut(b) {
|
||||
match slot {
|
||||
Some((widest, _, last)) => {
|
||||
*widest = widest.max(w);
|
||||
*last = l.ref_y;
|
||||
}
|
||||
None => *slot = Some((0.0f64.max(w), l.ref_y, l.ref_y)),
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut maps = Vec::with_capacity(n);
|
||||
let mut offsets = Vec::new();
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let b = band_of.get(i).copied().unwrap_or(0);
|
||||
let in_band = |j: &usize| band_of.get(*j) == Some(&b);
|
||||
let band = stats.get(b).copied().flatten();
|
||||
match self.bands.get(b) {
|
||||
Some(Band::Fill { .. }) | None => {
|
||||
let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0));
|
||||
@@ -941,16 +955,12 @@ impl Warp {
|
||||
}
|
||||
Some(Band::Follow { path, anchor }) => {
|
||||
let len = path.length();
|
||||
let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max);
|
||||
let widest = band.map_or(0.0, |s| s.0);
|
||||
let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 };
|
||||
let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect();
|
||||
let ref_y = match anchor {
|
||||
LineAnchor::Last => members.last().map(|l| l.ref_y),
|
||||
LineAnchor::First => members.first().map(|l| l.ref_y),
|
||||
LineAnchor::Middle => match (members.first(), members.last()) {
|
||||
(Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0),
|
||||
_ => None,
|
||||
},
|
||||
LineAnchor::Last => band.map(|s| s.2),
|
||||
LineAnchor::First => band.map(|s| s.1),
|
||||
LineAnchor::Middle => band.map(|s| (s.1 + s.2) / 2.0),
|
||||
}
|
||||
.unwrap_or(l.ref_y);
|
||||
// Lines off the guide run on a parallel curve at their natural length.
|
||||
|
||||
@@ -319,3 +319,20 @@ fn arch_and_inflate_handles_follow_the_pointer() {
|
||||
assert!(more > adjust_defaults("textInflate")[0], "{more}");
|
||||
assert!((more - 20_000.0).abs() < 1.0, "{more}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_lines_on_a_follow_band_are_mapped_from_one_measurement() {
|
||||
// 50,000 lines on an arch: each line's scale and anchor come from the band measured once.
|
||||
let w = warp("textArchUp", &[], rect()).unwrap();
|
||||
let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6);
|
||||
let lines: Vec<WarpLine> = (0..50_000)
|
||||
.map(|i| {
|
||||
let y = i as f64 * 0.01;
|
||||
WarpLine { ink: Some(Rect::new(190.0, y, 230.0, y + 1.0)), extent: (190.0, 230.0), ref_y: y + 1.0 }
|
||||
})
|
||||
.collect();
|
||||
let m = w.mapper(&lines, inner);
|
||||
// The last line sits on the arc, as with two lines.
|
||||
let last = lines.len() - 1;
|
||||
assert!(m.map(last, Point::new(210.0, lines[last].ref_y)).y.abs() < 0.05);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ impl SliceDecoder<'_> {
|
||||
let intra_slice = self.sh.slice_type.is_intra();
|
||||
loop {
|
||||
ensure!(addr < total, "macroblock address {addr} out of range");
|
||||
self.start_mb(addr);
|
||||
self.start_mb(addr)?;
|
||||
let skip = !intra_slice && self.decode_skip_flag(&mut c);
|
||||
if skip {
|
||||
self.decode_skip()?;
|
||||
|
||||
+43
-10
@@ -15,7 +15,7 @@ use crate::slicedec::{PicState, SliceDecoder};
|
||||
use crate::transform::LevelScale;
|
||||
use crate::{ColorInfo, Picture};
|
||||
use deckcraft_bitstream::{BitReader, annexb_nals, length_prefixed_nals, unescape_rbsp};
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// One slice of a picture job.
|
||||
@@ -90,6 +90,10 @@ pub struct Decoder {
|
||||
ls_cache: Vec<(Arc<Pps>, Arc<LevelScale>)>,
|
||||
/// Pictures leaving the DPB, converted once their decoding job has finished.
|
||||
out_queue: VecDeque<Output>,
|
||||
/// Queued entries per frame id and their summed bytes, kept in step with `out_queue` so the
|
||||
/// budget check does not rescan it.
|
||||
queued_ids: HashMap<u32, u64>,
|
||||
queued_bytes: u64,
|
||||
shared: Arc<Shared>,
|
||||
#[cfg(feature = "threads")]
|
||||
pool: Option<rayon::ThreadPool>,
|
||||
@@ -175,6 +179,8 @@ impl Decoder {
|
||||
active_sps: None,
|
||||
ls_cache: Vec::new(),
|
||||
out_queue: VecDeque::new(),
|
||||
queued_ids: HashMap::new(),
|
||||
queued_bytes: 0,
|
||||
shared: Arc::new(Shared::default()),
|
||||
#[cfg(feature = "threads")]
|
||||
pool,
|
||||
@@ -283,7 +289,7 @@ impl Decoder {
|
||||
if !o.frame.is_published(o.frame.mb_h() - 1) && self.out_queue.len() <= max_queue {
|
||||
break;
|
||||
}
|
||||
let Some(o) = self.out_queue.pop_front() else { break };
|
||||
let Some(o) = self.pop_out() else { break };
|
||||
pics.push(make_picture(&o));
|
||||
}
|
||||
Ok(pics)
|
||||
@@ -295,7 +301,7 @@ impl Decoder {
|
||||
let mut outs = Vec::new();
|
||||
self.dpb.flush(&mut outs);
|
||||
self.emit(outs);
|
||||
let pics = self.out_queue.drain(..).map(|o| make_picture(&o)).collect();
|
||||
let pics = std::iter::from_fn(|| self.pop_out()).map(|o| make_picture(&o)).collect();
|
||||
while let Some(f) = self.in_flight.pop_front() {
|
||||
f.wait_complete();
|
||||
}
|
||||
@@ -451,13 +457,24 @@ impl Decoder {
|
||||
chroma_planes: if chroma == 0 { 0 } else { 2 },
|
||||
bytes_per_sample: 1,
|
||||
};
|
||||
let pictures = (sps.max_dpb_frames() + 1 + self.max_in_flight) as u64;
|
||||
ensure!(
|
||||
shape.fits(pictures, self.memory_budget),
|
||||
"{}x{} pictures with {pictures} in the decoder exceed the memory budget",
|
||||
sps.width(),
|
||||
sps.height()
|
||||
);
|
||||
// Pictures already held count at their own size, not the incoming SPS's: after a
|
||||
// resolution change, larger pictures of the old size can still be in the DPB or waiting
|
||||
// for output (one decode call may carry many access units). A buffer shared with a DPB
|
||||
// entry is counted once.
|
||||
let pic = shape.bytes().unwrap_or(u64::MAX);
|
||||
let held = |frames: &mut dyn Iterator<Item = &Arc<Frame>>| frames.fold(0u64, |n, f| n.saturating_add(f.bytes()));
|
||||
// Queued bytes minus the queue entries that share a DPB frame (each distinct DPB frame once).
|
||||
let mut ids: Vec<(u32, u64)> = self.dpb.entries.iter().map(|e| (e.frame.id, e.frame.bytes())).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let shared = ids.iter().fold(0u64, |n, (id, b)| n.saturating_add(self.queued_ids.get(id).copied().unwrap_or(0).saturating_mul(*b)));
|
||||
let queued = self.queued_bytes.saturating_sub(shared);
|
||||
let in_dpb = held(&mut self.dpb.entries.iter().map(|e| &e.frame));
|
||||
let dpb = sps.max_dpb_frames().max(self.dpb.entries.len()) as u64;
|
||||
// The DPB (the larger of its new slots and what it holds now), the picture being decoded,
|
||||
// and what is in flight or queued.
|
||||
let bytes = pic.saturating_mul(dpb).max(in_dpb).saturating_add(pic).saturating_add(pic.saturating_mul(self.max_in_flight as u64).max(queued));
|
||||
ensure!(bytes <= self.memory_budget, "{}x{} pictures with {bytes} bytes in the decoder exceed the memory budget", sps.width(), sps.height());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -562,8 +579,24 @@ impl Decoder {
|
||||
}
|
||||
|
||||
fn emit(&mut self, outs: Vec<Output>) {
|
||||
for o in &outs {
|
||||
*self.queued_ids.entry(o.frame.id).or_insert(0) += 1;
|
||||
self.queued_bytes = self.queued_bytes.saturating_add(o.frame.bytes());
|
||||
}
|
||||
self.out_queue.extend(outs);
|
||||
}
|
||||
|
||||
fn pop_out(&mut self) -> Option<Output> {
|
||||
let o = self.out_queue.pop_front()?;
|
||||
if let Some(n) = self.queued_ids.get_mut(&o.frame.id) {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
self.queued_ids.remove(&o.frame.id);
|
||||
}
|
||||
}
|
||||
self.queued_bytes = self.queued_bytes.saturating_sub(o.frame.bytes());
|
||||
Some(o)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode all slices of one picture and publish its rows.
|
||||
|
||||
+53
-10
@@ -139,8 +139,9 @@ impl Dpb {
|
||||
let mut unused = (prev_ref_frame_num + 1) % max_frame_num;
|
||||
// Only the last `max_num_ref_frames` "non-existing" frames can survive the sliding window, and
|
||||
// inserting more than that evicts every earlier short-term reference. Skip straight to that tail
|
||||
// (keeping the POC state walk for every missing frame_num) so a hostile gap of up to 65535
|
||||
// frames costs at most `max_num_ref_frames` frame allocations instead of one per missing frame.
|
||||
// (advancing the POC state across the skipped run in constant time) so a hostile gap of up to
|
||||
// 65535 frames costs at most `max_num_ref_frames` frame allocations and POC updates instead of
|
||||
// one per missing frame.
|
||||
let gap = frame_num.wrapping_add(max_frame_num).wrapping_sub(unused) % max_frame_num;
|
||||
let keep = u32::try_from(max_num_ref_frames.max(1)).unwrap_or(u32::MAX);
|
||||
let skip = gap.saturating_sub(keep);
|
||||
@@ -149,10 +150,17 @@ impl Dpb {
|
||||
e.mark = RefMark::Unused;
|
||||
}
|
||||
self.prune();
|
||||
for _ in 0..skip {
|
||||
poc_state(unused);
|
||||
unused = (unused + 1) % max_frame_num;
|
||||
// The POC state only advances FrameNumOffset where frame_num decreases, so walking the
|
||||
// skipped run is the same as visiting its first value, the wrap (if the run crosses
|
||||
// it) and its last value: constant work however long the gap.
|
||||
let last = (unused + (skip - 1) % max_frame_num) % max_frame_num;
|
||||
poc_state(unused);
|
||||
if last < unused {
|
||||
poc_state(max_frame_num - 1);
|
||||
poc_state(0);
|
||||
}
|
||||
poc_state(last);
|
||||
unused = (last + 1) % max_frame_num;
|
||||
}
|
||||
let mut guard = 0;
|
||||
while unused != frame_num && guard < max_frame_num {
|
||||
@@ -438,14 +446,15 @@ impl Dpb {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
while self.entries.len() >= self.capacity {
|
||||
// Evict until there is room (frame_num gap concealment can leave the DPB over-full), so
|
||||
// the store never holds more pictures than the memory budget was checked for.
|
||||
while self.entries.len() >= self.capacity.max(1) {
|
||||
if !self.bump(out) {
|
||||
if let Some(pos) = self.entries.iter().position(|e| !e.needed_for_output) {
|
||||
self.entries.remove(pos);
|
||||
} else {
|
||||
self.entries.remove(0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -546,6 +555,37 @@ mod tests {
|
||||
(made, walk, left)
|
||||
}
|
||||
|
||||
/// (FrameNumOffset, prev frame_num) after `walk`, by the rule of `PocState::update_gap_frame`.
|
||||
fn poc_after(walk: &[u32], prev: u32, max: u32) -> (u64, u32) {
|
||||
walk.iter().fold((0, prev), |(off, prev), &f| (if prev > f { off + u64::from(max) } else { off }, f))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_walk_matches_every_frame_num_for_any_skip() {
|
||||
for (prev, cur) in [(1, 9), (10, 3), (14, 0), (15, 14), (0, 15), (7, 6)] {
|
||||
for refs in 1..4 {
|
||||
let mut dpb = Dpb::new();
|
||||
let (_, walk, _) = fill(&mut dpb, prev, cur, 16, refs);
|
||||
let every: Vec<u32> = (1..16).map(|i| (prev + i) % 16).take_while(|&f| f != cur).collect();
|
||||
assert_eq!(poc_after(&walk, prev, 16), poc_after(&every, prev, 16), "{prev} -> {cur}, {refs} refs");
|
||||
assert_eq!(walk.last(), every.last(), "{prev} -> {cur}, {refs} refs");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_store_evicts_down_to_capacity() {
|
||||
let mut dpb = Dpb::new();
|
||||
dpb.capacity = 2;
|
||||
// Gap concealment can leave more non-output references than the capacity.
|
||||
for i in 0..5 {
|
||||
short(&mut dpb, i, i);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
assert!(!dpb.bump_for_space(RefMark::Short, 0, &mut out));
|
||||
assert!(dpb.entries.len() < 2, "{} pictures left before storing into a 2-picture DPB", dpb.entries.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_gap_makes_only_the_frames_that_stay_referenced() {
|
||||
let mut dpb = Dpb::new();
|
||||
@@ -553,8 +593,9 @@ mod tests {
|
||||
short(&mut dpb, 2, 1);
|
||||
let (made, walk, left) = fill(&mut dpb, 1, 65535, 65536, 4);
|
||||
assert_eq!(made, 4);
|
||||
// The POC state still walks every missing frame_num, in order.
|
||||
assert_eq!(walk.len(), 65533);
|
||||
// The POC state ends where walking every missing frame_num would leave it, in a few steps.
|
||||
assert_eq!(poc_after(&walk, 1, 65536), poc_after(&(2..65535).collect::<Vec<_>>(), 1, 65536));
|
||||
assert!(walk.len() <= 8, "{} POC updates", walk.len());
|
||||
assert_eq!(walk.first(), Some(&2));
|
||||
assert_eq!(walk.last(), Some(&65534));
|
||||
assert_eq!(left, vec![(65531, true), (65532, true), (65533, true), (65534, true)]);
|
||||
@@ -566,7 +607,9 @@ mod tests {
|
||||
short(&mut dpb, 1, 65000);
|
||||
let (made, walk, left) = fill(&mut dpb, 65000, 3, 65536, 2);
|
||||
assert_eq!(made, 2);
|
||||
assert_eq!(walk.len(), 538);
|
||||
let every: Vec<u32> = (65001..65536).chain(0..3).collect();
|
||||
assert_eq!(poc_after(&walk, 65000, 65536), poc_after(&every, 65000, 65536));
|
||||
assert!(walk.len() <= 6, "{} POC updates", walk.len());
|
||||
assert_eq!(walk.first(), Some(&65001));
|
||||
assert_eq!(walk.last(), Some(&2));
|
||||
assert_eq!(left, vec![(1, true), (2, true)]);
|
||||
|
||||
@@ -160,6 +160,14 @@ impl Frame {
|
||||
self.rows.len()
|
||||
}
|
||||
|
||||
/// Sample bytes this picture holds (luma and both chroma planes), as the decoder's memory
|
||||
/// budget counts a picture.
|
||||
pub fn bytes(&self) -> u64 {
|
||||
(self.width as u64)
|
||||
.saturating_mul(self.height as u64)
|
||||
.saturating_add((self.cwidth as u64).saturating_mul(self.cheight as u64).saturating_mul(2))
|
||||
}
|
||||
|
||||
/// Publish MB row `r` (ignored if already published).
|
||||
pub fn publish(&self, r: usize, row: FrameRow) {
|
||||
let _ = self.rows[r].set(row);
|
||||
|
||||
+52
-11
@@ -346,8 +346,11 @@ impl<'a> SliceDecoder<'a> {
|
||||
// Neighbour helpers
|
||||
// ------------------------------------------------------------------------------------------
|
||||
|
||||
/// Set up the current macroblock address and neighbour availability.
|
||||
pub fn start_mb(&mut self, addr: usize) {
|
||||
/// Set up the current macroblock address and neighbour availability. A macroblock an earlier
|
||||
/// slice of this picture already decoded is refused: overlapping slices would otherwise redo
|
||||
/// the picture's reconstruction once per slice.
|
||||
pub fn start_mb(&mut self, addr: usize) -> Result<()> {
|
||||
ensure!(self.pic.mbs[addr].slice_num == u32::MAX, "macroblock {addr} overlaps an earlier slice");
|
||||
self.mb_addr = addr;
|
||||
self.mb_x = addr % self.mb_w;
|
||||
self.mb_y = addr / self.mb_w;
|
||||
@@ -364,6 +367,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
];
|
||||
self.mv_done = 0;
|
||||
self.pic.mbs[addr] = MbState { slice_num: sn, ..MbState::default() };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -566,6 +570,19 @@ impl<'a> SliceDecoder<'a> {
|
||||
(refs, mvs, false)
|
||||
}
|
||||
|
||||
/// Temporal direct mvs (8.4.1.2.3) from the co-located mv and `DistScaleFactor` (clamped
|
||||
/// to ±1024 in `SliceDecoder`): mvL0 = clip_mv((mvCol · dsf + 128) >> 8), mvL1 =
|
||||
/// clip_mv(mvL0 − mvCol), both clipped to the spec's motion-vector range −2^15..2^15−1.
|
||||
/// The i32 maths cannot overflow; a truncating `as i16` used to wrap mvL0 past the range,
|
||||
/// after which the mvL1 subtraction overflowed a debug build. mvL1 is scaled from the
|
||||
/// unclamped mvL0 with only the results clipped, as the sibling SoundCraft decoder's
|
||||
/// review established (soundcraft#994): dsf 512, mvCol 32767 keeps mvL1 at 32767.
|
||||
fn temporal_direct_mvs(dsf: i32, mv_col: [i16; 2]) -> ([i16; 2], [i16; 2]) {
|
||||
let clamp = |v: i32| v.clamp(-(1 << 15), (1 << 15) - 1) as i16;
|
||||
let m0 = mv_col.map(|c| (dsf * i32::from(c) + 128) >> 8);
|
||||
(m0.map(clamp), [clamp(m0[0] - i32::from(mv_col[0])), clamp(m0[1] - i32::from(mv_col[1]))])
|
||||
}
|
||||
|
||||
/// Derive direct-mode motion for the 8x8 blocks in `b8_mask` of the current MB.
|
||||
fn derive_direct(&mut self, b8_mask: u8) -> Result<()> {
|
||||
let spatial = self.sh.direct_spatial_mv_pred;
|
||||
@@ -600,13 +617,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
};
|
||||
let (dsf, ltr) = self.tdirect.get(ref0 as usize).copied().unwrap_or((256, true));
|
||||
let (mv0, mv1) = if ltr {
|
||||
(mv_col, [0, 0])
|
||||
} else {
|
||||
let s = |c: i16| ((dsf * c as i32 + 128) >> 8) as i16;
|
||||
let m0 = [s(mv_col[0]), s(mv_col[1])];
|
||||
(m0, [m0[0] - mv_col[0], m0[1] - mv_col[1]])
|
||||
};
|
||||
let (mv0, mv1) = if ltr { (mv_col, [0, 0]) } else { Self::temporal_direct_mvs(dsf, mv_col) };
|
||||
([ref0, 0], [mv0, mv1])
|
||||
};
|
||||
self.set_motion(x, y, unit, unit, refs, mvs);
|
||||
@@ -1460,7 +1471,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
let run = r.read_ue()? as usize;
|
||||
ensure!(addr + run <= total, "mb_skip_run out of range");
|
||||
for _ in 0..run {
|
||||
self.start_mb(addr);
|
||||
self.start_mb(addr)?;
|
||||
self.decode_skip()?;
|
||||
self.pic.mb_done(addr);
|
||||
addr += 1;
|
||||
@@ -1470,7 +1481,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
ensure!(addr < total, "macroblock address {addr} out of range");
|
||||
}
|
||||
self.start_mb(addr);
|
||||
self.start_mb(addr)?;
|
||||
self.decode_mb_cavlc(r)?;
|
||||
self.pic.mb_done(addr);
|
||||
addr += 1;
|
||||
@@ -1481,3 +1492,33 @@ impl<'a> SliceDecoder<'a> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SliceDecoder;
|
||||
|
||||
/// Temporal direct mvs stay inside the spec's motion-vector range −2^15..2^15−1 with a
|
||||
/// large `DistScaleFactor` and a co-located mv at an i16 extreme: the scale used to
|
||||
/// truncate past the range with `as i16`, and mvL0 − mvCol then overflowed a debug build
|
||||
/// ("attempt to subtract with overflow"). `dsf` 256 is the identity scale.
|
||||
#[test]
|
||||
fn temporal_direct_mvs_at_i16_extremes_stay_in_the_spec_range() {
|
||||
for dsf in [-1024i32, -1, 256, 1023] {
|
||||
for col in [[i16::MIN, i16::MIN], [i16::MAX, i16::MAX], [i16::MIN, i16::MAX]] {
|
||||
let (mv0, mv1) = SliceDecoder::temporal_direct_mvs(dsf, col);
|
||||
for mv in [mv0, mv1] {
|
||||
for c in mv {
|
||||
assert!((-(1 << 15)..(1 << 15)).contains(&i32::from(c)), "dsf {dsf}, col {col:?}: {mv:?}");
|
||||
}
|
||||
}
|
||||
if dsf == 256 {
|
||||
assert_eq!((mv0, mv1), (col, [0, 0]), "dsf 256 is the identity scale");
|
||||
}
|
||||
}
|
||||
}
|
||||
// soundcraft#994: mvL1 is scaled from the unclamped mvL0 with only the results clipped;
|
||||
// deriving it from the clipped mvL0 gave [0, 0] here.
|
||||
let (mv0, mv1) = SliceDecoder::temporal_direct_mvs(512, [i16::MAX, 0]);
|
||||
assert_eq!((mv0, mv1), ([i16::MAX, 0], [i16::MAX, 0]), "mvL1 keeps mvL0's off-range part");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,7 @@ fn avcc_rejects_a_valid_picture_slice_without_starting_a_picture() {
|
||||
poc_lsb: Some(0),
|
||||
poc_delta: None,
|
||||
wt: None,
|
||||
first_mb: 0,
|
||||
},
|
||||
false,
|
||||
);
|
||||
@@ -153,6 +154,8 @@ struct Pic {
|
||||
/// Explicit weights (luma, cb, cr) as (weight, offset) for refIdx 0 of list 0 and list 1;
|
||||
/// log2 denominators are 5 (luma) and 3 (chroma).
|
||||
wt: Option<[[(i32, i32); 3]; 2]>,
|
||||
/// first_mb_in_slice (`mbs` then lists the slice's macroblocks from there).
|
||||
first_mb: u32,
|
||||
}
|
||||
|
||||
impl Pic {
|
||||
@@ -165,7 +168,7 @@ impl Pic {
|
||||
}
|
||||
|
||||
fn slice_header(w: &mut BitWriter, p: &Pic, cabac: bool) {
|
||||
w.write_ue(0); // first_mb
|
||||
w.write_ue(p.first_mb);
|
||||
w.write_ue(if p.b {
|
||||
6
|
||||
} else if p.is_p() {
|
||||
@@ -660,3 +663,63 @@ fn frame_num_gap_is_filled_when_the_sps_allows_gaps() {
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(dec.stats().frame_num_gaps, 1);
|
||||
}
|
||||
|
||||
/// An IDR picture followed by `pictures` reference P pictures of P_Skip macroblocks.
|
||||
fn skip_stream(pictures: u32) -> (Vec<u8>, Vec<Vec<u8>>) {
|
||||
let mut seed = 11u32;
|
||||
let a: Vec<Option<Vec<u8>>> = (0..NMB).map(|_| Some(random_mb(&mut seed))).collect();
|
||||
let mut head = sps(66, 2, false, PocType::T2);
|
||||
head.extend(pps(false, false));
|
||||
head.extend(slice_nal(&Pic { idr: true, frame_num: 0, ref_idc: 3, mbs: a, ..Default::default() }, false));
|
||||
let ps = (1..=pictures).map(|k| slice_nal(&Pic { frame_num: k % 16, ref_idc: 2, mbs: vec![None; NMB], ..Default::default() }, false)).collect();
|
||||
(head, ps)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_slices_are_not_reconstructed_twice() {
|
||||
let (head, ps) = skip_stream(1);
|
||||
let mut once = Decoder::with_threads(1);
|
||||
let mut out = once.decode(&[head.clone(), ps[0].clone()].concat(), 0).unwrap();
|
||||
out.extend(once.flush());
|
||||
assert!(once.take_error().is_none());
|
||||
assert_eq!(out.len(), 2);
|
||||
// A second slice of the same picture starting at macroblock 1 covers those macroblocks again.
|
||||
let again = slice_nal(&Pic { frame_num: 1, ref_idc: 2, mbs: vec![None; NMB - 1], first_mb: 1, ..Default::default() }, false);
|
||||
let mut twice = Decoder::with_threads(1);
|
||||
let result = twice.decode(&[head, ps[0].clone(), again].concat(), 0);
|
||||
twice.flush();
|
||||
let err = result.err().or_else(|| twice.take_error()).expect("overlapping slice refused");
|
||||
assert!(err.to_string().contains("overlaps"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_queue_counts_against_the_memory_budget() {
|
||||
let (head, ps) = skip_stream(40);
|
||||
let sps_nal = sps(66, 2, false, PocType::T2);
|
||||
let parsed = crate::params::Sps::parse(&deckcraft_bitstream::unescape_rbsp(&sps_nal[5..])).unwrap();
|
||||
let one = (NMB * 256 + 2 * NMB * 64) as u64;
|
||||
let budget = (parsed.max_dpb_frames() + 1 + 3) as u64 * one;
|
||||
// One access unit per call: output drains as it goes, and the stream fits.
|
||||
let mut dec = Decoder::with_threads(1);
|
||||
dec.set_memory_budget(budget);
|
||||
let mut out = dec.decode(&head, 0).unwrap();
|
||||
for (i, p) in ps.iter().enumerate() {
|
||||
out.extend(dec.decode(p, i as i64 + 1).unwrap());
|
||||
}
|
||||
out.extend(dec.flush());
|
||||
assert!(dec.take_error().is_none());
|
||||
assert_eq!(out.len(), 41);
|
||||
// Every access unit in one call: the pictures held for output stop fitting the budget.
|
||||
let mut dec = Decoder::with_threads(1);
|
||||
dec.set_memory_budget(budget);
|
||||
let err = dec.decode(&[head, ps.concat()].concat(), 0).expect_err("output queue is over budget");
|
||||
assert!(err.to_string().contains("memory budget"), "{err}");
|
||||
}
|
||||
|
||||
/// A held picture is charged at its own size (luma and both 4:2:0 chroma planes), the same
|
||||
/// count the budget uses for the incoming SPS's pictures.
|
||||
#[test]
|
||||
fn a_held_frame_is_charged_at_its_own_size() {
|
||||
let f = crate::picture::Frame::new(0, 0, 2, 3);
|
||||
assert_eq!(f.bytes(), (2 * 3 * 256 + 2 * 2 * 3 * 64) as u64);
|
||||
}
|
||||
|
||||
@@ -404,10 +404,36 @@ impl Decoder {
|
||||
chroma_planes: if chroma == 0 { 0 } else { 2 },
|
||||
bytes_per_sample: 2,
|
||||
};
|
||||
let frame_bytes = |f: &crate::picture::Frame| {
|
||||
(f.width as u64)
|
||||
.saturating_mul(f.height as u64)
|
||||
.saturating_add((f.cwidth as u64).saturating_mul(f.cheight as u64).saturating_mul(2))
|
||||
.saturating_mul(2)
|
||||
};
|
||||
// Output frames waiting to be returned stay allocated after they leave the DPB, at their
|
||||
// own size: a resolution change in this call can leave pictures of the previous SPS.
|
||||
let queued: u64 = self
|
||||
.out_queue
|
||||
.iter()
|
||||
.filter(|o| !self.dpb.entries.iter().any(|e| Arc::ptr_eq(&e.frame, &o.frame)) && !self.in_flight.iter().any(|f| Arc::ptr_eq(f, &o.frame)))
|
||||
.map(|o| frame_bytes(&o.frame))
|
||||
.fold(0, u64::saturating_add);
|
||||
// Frames the DPB holds now count at their own size too (an SPS change flushes them into the
|
||||
// queue before the new pictures are made): the DPB part is the larger of its new slots and that.
|
||||
let held: u64 = self.dpb.entries.iter().map(|e| frame_bytes(&e.frame)).fold(0, u64::saturating_add);
|
||||
// A queued frame still being decoded is one of the in-flight pictures already reserved, so the
|
||||
// queue and the in-flight reservation overlap: charge the larger, not both.
|
||||
let pictures = u64::from(sps.max_dec_pic_buffering) + 1 + self.max_in_flight as u64;
|
||||
let slots = shape.store_bytes(u64::from(sps.max_dec_pic_buffering));
|
||||
let flight = shape.store_bytes(self.max_in_flight as u64);
|
||||
let total = shape
|
||||
.store_bytes(1)
|
||||
.zip(slots)
|
||||
.zip(flight)
|
||||
.map(|((one, slots), flight)| slots.max(held).saturating_add(one).saturating_add(flight).saturating_add(queued));
|
||||
ensure!(
|
||||
shape.fits(pictures, self.memory_budget),
|
||||
"{}x{} pictures with {pictures} in the decoder exceed the memory budget",
|
||||
total.is_some_and(|b| b <= self.memory_budget),
|
||||
"{}x{} pictures with {pictures} in the decoder and {queued} bytes queued exceed the memory budget",
|
||||
sps.width,
|
||||
sps.height
|
||||
);
|
||||
|
||||
@@ -326,7 +326,9 @@ impl SliceHeader {
|
||||
|
||||
/// entry_point_offset list (tiles / wavefront substreams).
|
||||
fn parse_entry_points(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> {
|
||||
self.entry_points.clear();
|
||||
// A fresh list, not `clear()`: a dependent segment starts as a copy of its independent
|
||||
// segment's header, and clearing would keep that copy's allocation for every segment.
|
||||
self.entry_points = Vec::new();
|
||||
if pps.tiles_enabled || pps.entropy_coding_sync {
|
||||
let n = r.read_ue()?;
|
||||
ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range");
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
[package]
|
||||
name = "deckcraft-isobmff"
|
||||
description = "Clean-room ISO BMFF (MP4) and QuickTime (MOV) demuxer and muxer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-media = { workspace = true, features = ["isobmff"] }
|
||||
@@ -1,6 +0,0 @@
|
||||
//! ISO BMFF (MP4) and QuickTime (MOV) demuxer and muxer: a re-export of [`craft_media::isobmff`]
|
||||
//! from craft-libs. Supported boxes and limitations are in the craft-media README.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub use craft_media::isobmff::*;
|
||||
@@ -1,13 +0,0 @@
|
||||
[package]
|
||||
name = "deckcraft-matroska"
|
||||
description = "Clean-room Matroska (MKV) and WebM demuxer and muxer"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-media = { workspace = true, features = ["matroska"] }
|
||||
@@ -1,6 +0,0 @@
|
||||
//! Matroska (MKV) and WebM demuxer and muxer: a re-export of [`craft_media::matroska`] from
|
||||
//! craft-libs. Supported elements and limitations are in the craft-media README.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub use craft_media::matroska::*;
|
||||
@@ -16,6 +16,12 @@ const INVALID_REQUEST: i64 = -32600;
|
||||
const METHOD_NOT_FOUND: i64 = -32601;
|
||||
const INVALID_PARAMS: i64 = -32602;
|
||||
const INTERNAL_ERROR: i64 = -32603;
|
||||
|
||||
/// Entries one batch (a JSON-RPC batch, or the `batch` tool's commands) may hold.
|
||||
pub(crate) const MAX_BATCH_ITEMS: usize = 1000;
|
||||
/// Bytes of results one batch may gather before the rest of it is refused: each result can copy
|
||||
/// much of the document (an inspection holds every note), so a batch holds at most this much.
|
||||
pub(crate) const MAX_BATCH_BYTES: usize = 64 << 20;
|
||||
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
/// Keep request frames in step with the connected backend's reply limit.
|
||||
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
||||
@@ -93,8 +99,28 @@ impl Server {
|
||||
// Batches were removed in 2025-06-18; still answer older clients sensibly.
|
||||
if batch.is_empty() {
|
||||
Some(error(Value::Null, INVALID_REQUEST, "empty batch"))
|
||||
} else if batch.len() > MAX_BATCH_ITEMS {
|
||||
Some(error(Value::Null, INVALID_REQUEST, format!("a batch holds at most {MAX_BATCH_ITEMS} messages")))
|
||||
} else {
|
||||
let replies: Vec<Value> = batch.into_iter().filter_map(|m| self.handle(m)).collect();
|
||||
let mut used = 0usize;
|
||||
let mut replies: Vec<Value> = Vec::new();
|
||||
for m in batch {
|
||||
if used > MAX_BATCH_BYTES {
|
||||
// Not run: answer each remaining request with a short error.
|
||||
if let Some(id) = m.get("id").filter(|id| id.is_string() || id.is_number()) {
|
||||
replies.push(error(id.clone(), INTERNAL_ERROR, "the batch's replies passed 64 MiB; not run"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(r) = self.handle(m) {
|
||||
used = used.saturating_add(r.to_string().len());
|
||||
replies.push(if used > MAX_BATCH_BYTES {
|
||||
error(r.get("id").cloned().unwrap_or(Value::Null), INTERNAL_ERROR, "the batch's replies passed 64 MiB; reply dropped")
|
||||
} else {
|
||||
r
|
||||
});
|
||||
}
|
||||
}
|
||||
(!replies.is_empty()).then_some(Value::Array(replies))
|
||||
}
|
||||
}
|
||||
|
||||
+29
-10
@@ -357,21 +357,40 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, a: &Value) -> ToolResult {
|
||||
wrap(exec(b, c, a.get("params").cloned().unwrap_or(json!({}))))
|
||||
}
|
||||
"batch" => {
|
||||
use crate::server::{MAX_BATCH_BYTES, MAX_BATCH_ITEMS};
|
||||
let keep = a.get("keepGoing").and_then(Value::as_bool).unwrap_or(false);
|
||||
let commands = a.get("commands").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
|
||||
if commands.len() > MAX_BATCH_ITEMS {
|
||||
return ToolResult::error(format!("a batch holds at most {MAX_BATCH_ITEMS} commands"));
|
||||
}
|
||||
let mut out = vec![];
|
||||
for c in a.get("commands").and_then(Value::as_array).cloned().unwrap_or_default() {
|
||||
let mut used = 0usize;
|
||||
for c in commands {
|
||||
let id = c.get("command").and_then(Value::as_str).unwrap_or("");
|
||||
match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
|
||||
Ok(v) => out.push(json!({"command": id, "ok": true, "result": v})),
|
||||
Err(e) => {
|
||||
out.push(json!({"command": id, "ok": false, "error": e}));
|
||||
if !keep {
|
||||
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
||||
}
|
||||
}
|
||||
let (entry, failed) = match exec(b, id, c.get("params").cloned().unwrap_or(json!({}))) {
|
||||
Ok(v) => (json!({"command": id, "ok": true, "result": v}), false),
|
||||
Err(e) => (json!({"command": id, "ok": false, "error": e}), true),
|
||||
};
|
||||
// What the reply carries: the entry as text, escaped again inside the JSON-RPC reply.
|
||||
// Failures count too: with keepGoing, a remote backend's large errors add up.
|
||||
used = used.saturating_add(json!(entry.to_string()).to_string().len());
|
||||
if used > MAX_BATCH_BYTES {
|
||||
let shown: String = id.chars().take(128).collect();
|
||||
out.push(json!({"command": shown, "ok": false, "error": "the batch's results passed 64 MiB; stopped here"}));
|
||||
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
||||
}
|
||||
out.push(entry);
|
||||
if failed && !keep {
|
||||
return ToolResult { content: vec![json!({"type": "text", "text": Value::Array(out).to_string()})], is_error: true };
|
||||
}
|
||||
}
|
||||
ToolResult::json(&Value::Array(out))
|
||||
// The reply is pretty-printed, which is longer than the compact entries counted above.
|
||||
let reply = ToolResult::json(&Value::Array(out));
|
||||
let text = reply.content[0]["text"].as_str().unwrap_or_default();
|
||||
if json!(text).to_string().len() > MAX_BATCH_BYTES {
|
||||
return ToolResult::error("the batch's results passed 64 MiB; nothing is returned");
|
||||
}
|
||||
reply
|
||||
}
|
||||
"new_presentation" => wrap(exec(b, "file.new", a)),
|
||||
"open_presentation" => wrap(exec(b, "file.open", a)),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
//! A batch (JSON-RPC or the `batch` tool) is bounded in entries and in the bytes of results it
|
||||
//! gathers, so a short request cannot copy a large document into memory hundreds of times.
|
||||
|
||||
use deckcraft_mcp::{Headless, Server};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn server_with_notes(bytes: usize) -> Server {
|
||||
let mut server = Server::new(Box::new(Headless::with_document()));
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "n".repeat(bytes)}}}});
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
|
||||
assert_ne!(reply["result"]["isError"], true, "{reply}");
|
||||
server
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_rpc_batches_are_bounded_in_entries_and_reply_bytes() {
|
||||
let mut server = server_with_notes(1 << 20);
|
||||
let ping = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "ping"});
|
||||
let too_many: Vec<Value> = (0..1001).map(ping).collect();
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&Value::Array(too_many).to_string()).unwrap()).unwrap();
|
||||
assert_eq!(reply["error"]["code"], -32600, "{reply}");
|
||||
// 100 inspections of a slide with 1 MB of notes: about 64 are answered, the rest refused.
|
||||
let inspect = |i: usize| json!({"jsonrpc": "2.0", "id": i, "method": "tools/call", "params": {"name": "inspect_slide", "arguments": {}}});
|
||||
let line = Value::Array((0..100).map(inspect).collect()).to_string();
|
||||
let reply = server.handle_line(&line).unwrap();
|
||||
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
|
||||
let replies: Vec<Value> = serde_json::from_str(&reply).unwrap();
|
||||
assert_eq!(replies.len(), 100, "every request is answered");
|
||||
assert!(replies.last().unwrap()["error"]["message"].as_str().unwrap().contains("64 MiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_batch_tool_is_bounded_in_commands_and_result_bytes() {
|
||||
let mut server = server_with_notes(1 << 20);
|
||||
let call = |server: &mut Server, commands: Vec<Value>| {
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&line.to_string()).unwrap()).unwrap();
|
||||
(reply["result"]["isError"] == true, reply["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string())
|
||||
};
|
||||
let (err, text) = call(&mut server, vec![json!({"command": "document.inspect"}); 1001]);
|
||||
assert!(err && text.contains("at most 1000"), "{text}");
|
||||
let (err, text) = call(&mut server, vec![json!({"command": "slide.inspect"}); 100]);
|
||||
assert!(err && text.contains("64 MiB"), "{}", &text[text.len().saturating_sub(200)..]);
|
||||
assert!(text.len() < (64 << 20) + (4 << 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_batch_tool_charges_the_escaped_reply() {
|
||||
// Quotes escape twice on the way out (`\\\"`), so 30 results of ~2 MiB serialize to ~120 MiB.
|
||||
let mut server = Server::new(Box::new(Headless::with_document()));
|
||||
let notes = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "run_command", "arguments": {"command": "slide.notes", "params": {"text": "\"".repeat(1 << 20)}}}});
|
||||
server.handle_line(¬es.to_string()).unwrap();
|
||||
let commands = vec![json!({"command": "slide.inspect"}); 30];
|
||||
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
|
||||
let reply = server.handle_line(&line.to_string()).unwrap();
|
||||
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
|
||||
assert!(reply.contains("64 MiB"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_batch_tool_charges_failures_too() {
|
||||
// Each unknown command's error echoes its ~1 MiB id; with keepGoing none of them is a result.
|
||||
let mut server = Server::new(Box::new(Headless::with_document()));
|
||||
let id = "x".repeat(1 << 20);
|
||||
let commands = vec![json!({"command": id}); 70];
|
||||
let line = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "batch", "arguments": {"commands": commands, "keepGoing": true}}});
|
||||
let reply = server.handle_line(&line.to_string()).unwrap();
|
||||
assert!(reply.len() < (64 << 20) + (4 << 20), "{} bytes", reply.len());
|
||||
assert!(reply.contains("64 MiB"));
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod automation_roots;
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod batch_budget;
|
||||
|
||||
@@ -9,10 +9,9 @@ description = "DeckCraft media: probing, audio decoding (MP3, AAC/M4A, WAV, AIFF
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
deckcraft-isobmff = { workspace = true }
|
||||
deckcraft-matroska = { workspace = true }
|
||||
deckcraft-ogg = { workspace = true }
|
||||
deckcraft-opus = { workspace = true }
|
||||
# Containers (MP4/MOV, Matroska/WebM, Ogg) and the Opus decoder, shared with the other Craft apps.
|
||||
craft-media = { workspace = true, features = ["isobmff", "matroska", "ogg"] }
|
||||
craft-audio = { workspace = true }
|
||||
deckcraft-h264 = { workspace = true }
|
||||
deckcraft-hevc = { workspace = true }
|
||||
deckcraft-vp9 = { workspace = true }
|
||||
|
||||
@@ -81,7 +81,9 @@ pub fn decode(bytes: &Bytes) -> Result<Pcm> {
|
||||
Some(r) => r,
|
||||
None => contained(|| decode_symphonia(bytes, "mkv")),
|
||||
},
|
||||
Container::Mp4 if !crate::mp4guard::tables_fit(bytes) => Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into())),
|
||||
Container::Mp4 | Container::Unknown | Container::Mp3 if !crate::mp4guard::probe_fits(bytes) => {
|
||||
Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into()))
|
||||
}
|
||||
c => contained(|| decode_symphonia(bytes, c.name())),
|
||||
}
|
||||
}
|
||||
@@ -89,15 +91,15 @@ pub fn decode(bytes: &Bytes) -> Result<Pcm> {
|
||||
/// Ogg Opus with our decoder; `None` when the file holds no Opus stream.
|
||||
fn decode_ogg_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
|
||||
let src: &[u8] = bytes;
|
||||
let file = deckcraft_ogg::open(src).ok()?;
|
||||
let s = file.stream_of(deckcraft_ogg::Codec::Opus)?;
|
||||
let file = craft_media::ogg::open(src).ok()?;
|
||||
let s = file.stream_of(craft_media::ogg::Codec::Opus)?;
|
||||
let stream = file.streams.get(s)?;
|
||||
Some((|| {
|
||||
let head = stream.headers.first().ok_or(MediaError::Corrupt("Opus header missing".into()))?;
|
||||
let mut dec = deckcraft_opus::Decoder::new(head).map_err(|e| MediaError::Corrupt(e.to_string()))?;
|
||||
let mut dec = craft_audio::opus::Decoder::new(head).map_err(|e| MediaError::Corrupt(e.to_string()))?;
|
||||
dec.set_trim_pre_skip(true);
|
||||
let channels = dec.channels() as u16;
|
||||
let timing = deckcraft_ogg::OpusTiming::of(stream, dec.pre_skip() as u32).map_err(|e| MediaError::Corrupt(e.to_string()))?;
|
||||
let timing = craft_media::ogg::OpusTiming::of(stream, dec.pre_skip() as u32);
|
||||
let mut out = Vec::new();
|
||||
for i in 0..stream.packets.len() {
|
||||
if MAX_PCM_SAMPLES.saturating_sub(out.len()) < channels.max(1) as usize {
|
||||
@@ -113,7 +115,8 @@ fn decode_ogg_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
|
||||
}
|
||||
}
|
||||
// End trimming (RFC 7845 §4.5).
|
||||
let total = timing.total.max(0) as usize * channels.max(1) as usize;
|
||||
// the timing saturates on a hostile granule, so this product must too
|
||||
let total = usize::try_from(timing.total).unwrap_or(0).saturating_mul(channels.max(1) as usize);
|
||||
if total > 0 && total < out.len() {
|
||||
out.truncate(total);
|
||||
}
|
||||
@@ -124,13 +127,13 @@ fn decode_ogg_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
|
||||
/// WebM / Matroska Opus with our decoder; `None` when there is no Opus track.
|
||||
fn decode_mkv_opus(bytes: &Bytes) -> Option<Result<Pcm>> {
|
||||
let src: &[u8] = bytes;
|
||||
let file = deckcraft_matroska::open(src).ok()?;
|
||||
let file = craft_media::matroska::open(src).ok()?;
|
||||
let (ti, head) = file.tracks.iter().enumerate().find_map(|(i, t)| match &t.codec {
|
||||
deckcraft_matroska::Codec::Opus { head } => Some((i, head.clone())),
|
||||
craft_media::matroska::Codec::Opus { head } => Some((i, head.clone())),
|
||||
_ => None,
|
||||
})?;
|
||||
Some((|| {
|
||||
let mut dec = deckcraft_opus::Decoder::new(&head).map_err(|e| MediaError::Corrupt(e.to_string()))?;
|
||||
let mut dec = craft_audio::opus::Decoder::new(&head).map_err(|e| MediaError::Corrupt(e.to_string()))?;
|
||||
dec.set_trim_pre_skip(true);
|
||||
let channels = dec.channels() as u16;
|
||||
let mut out = Vec::new();
|
||||
|
||||
@@ -12,6 +12,29 @@ pub(crate) fn tables_fit(b: &[u8]) -> bool {
|
||||
boxes_fit(b, b.len() as u64, 0)
|
||||
}
|
||||
|
||||
/// Bytes symphonia's probe scans for a format marker before giving up.
|
||||
const PROBE_SEARCH_LIMIT: usize = 1024 * 1024;
|
||||
|
||||
/// Whether symphonia can be given `b`, whatever the sniffer made of it: its probe takes the first
|
||||
/// `ftyp` within its search window as the start of an MP4 (the size field 4 bytes before it), so
|
||||
/// the table check must start where that parser will, not at offset 0.
|
||||
pub(crate) fn probe_fits(b: &[u8]) -> bool {
|
||||
if crate::Container::sniff(b) == crate::Container::Mp4 && !tables_fit(b) {
|
||||
return false;
|
||||
}
|
||||
// The probe reads leading ID3v2 tags as metadata and searches again after the last one.
|
||||
let mut rest = b;
|
||||
while rest.starts_with(b"ID3") {
|
||||
let size = rest.get(6..10).map(|s| s.iter().fold(0usize, |a, &x| (a << 7) | (x & 0x7f) as usize)).unwrap_or(0);
|
||||
rest = rest.get(10 + size..).unwrap_or(&[]);
|
||||
}
|
||||
let head = &rest[..rest.len().min(PROBE_SEARCH_LIMIT + 16)];
|
||||
match head.windows(4).position(|w| w == b"ftyp") {
|
||||
Some(p) => tables_fit(&rest[p.saturating_sub(4)..]),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn be32(b: &[u8], at: usize) -> Option<u64> {
|
||||
b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as u64)
|
||||
}
|
||||
@@ -74,3 +97,29 @@ fn table_fits(kind: &[u8], body: &[u8], file_len: u64) -> bool {
|
||||
};
|
||||
be32(body, 4).is_some_and(|count| count <= body.len().saturating_sub(8) as u64 / entry)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An MP4 whose `stsc` count asks for far more entries than the file holds.
|
||||
fn mp4_with_huge_stsc() -> Vec<u8> {
|
||||
let stsc = [&24u32.to_be_bytes()[..], b"stsc", &[0, 0, 0, 0], &u32::MAX.to_be_bytes(), &[0; 8]].concat();
|
||||
let stbl = [&(stsc.len() as u32 + 8).to_be_bytes()[..], b"stbl", &stsc].concat();
|
||||
let moov = [&(stbl.len() as u32 + 8).to_be_bytes()[..], b"moov", &stbl].concat();
|
||||
[&16u32.to_be_bytes()[..], b"ftyp", b"isom", &[0; 4], &moov].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prefixed_mp4_is_checked_where_symphonia_starts_it() {
|
||||
let mp4 = mp4_with_huge_stsc();
|
||||
assert!(!tables_fit(&mp4));
|
||||
assert!(!probe_fits(&mp4));
|
||||
let prefixed = [&b"junk before the file "[..], &mp4].concat();
|
||||
assert_eq!(crate::Container::sniff(&prefixed), crate::Container::Unknown);
|
||||
assert!(tables_fit(&prefixed), "offset 0 sees no MP4 boxes");
|
||||
assert!(!probe_fits(&prefixed));
|
||||
let far = [&vec![0u8; PROBE_SEARCH_LIMIT + 16][..], &mp4].concat();
|
||||
assert!(probe_fits(&far), "past the probe window symphonia never reads it as an MP4");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,8 @@ pub fn probe_headers(bytes: &Bytes) -> Result<MediaInfo> {
|
||||
};
|
||||
if info.audio.is_none() && info.video.is_none() || info.audio.as_ref().is_some_and(|a| a.sample_rate == 0) {
|
||||
// Plain audio files (and anything our demuxers didn't describe): symphonia's probe.
|
||||
if c == Container::Mp4 && !crate::mp4guard::tables_fit(bytes) {
|
||||
// Symphonia opens an MP4 wherever its probe finds an `ftyp` (after leading ID3v2 tags too); other known containers (a WAV's audio bytes can spell an `ftyp`) skip the check.
|
||||
if matches!(c, Container::Mp4 | Container::Unknown | Container::Mp3) && !crate::mp4guard::probe_fits(bytes) {
|
||||
return Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into()));
|
||||
}
|
||||
let (codec, rate, channels, frames) = std::panic::catch_unwind(|| crate::audio::probe_symphonia(bytes, c.name()))
|
||||
@@ -105,9 +106,9 @@ fn codec_label(short: &str) -> String {
|
||||
}
|
||||
|
||||
fn probe_mp4(bytes: &Bytes) -> Result<MediaInfo> {
|
||||
use deckcraft_isobmff::{CodecConfig, TrackKind};
|
||||
use craft_media::isobmff::{CodecConfig, TrackKind};
|
||||
let src: &[u8] = bytes;
|
||||
let f = deckcraft_isobmff::open(src).map_err(|e| MediaError::Corrupt(e.to_string()))?;
|
||||
let f = craft_media::isobmff::open(src).map_err(|e| MediaError::Corrupt(e.to_string()))?;
|
||||
let mut info = MediaInfo { container: if f.is_quicktime { "mov" } else { "mp4" }, ..Default::default() };
|
||||
let mut dur = if f.timescale > 0 { f.duration as f64 / f.timescale as f64 } else { 0.0 };
|
||||
if let Some(ti) = f.track_of_kind(TrackKind::Video) {
|
||||
@@ -145,9 +146,9 @@ fn probe_mp4(bytes: &Bytes) -> Result<MediaInfo> {
|
||||
}
|
||||
|
||||
fn probe_mkv(bytes: &Bytes) -> Result<MediaInfo> {
|
||||
use deckcraft_matroska::{Codec, TrackKind};
|
||||
use craft_media::matroska::{Codec, TrackKind};
|
||||
let src: &[u8] = bytes;
|
||||
let f = deckcraft_matroska::open(src).map_err(|e| MediaError::Corrupt(e.to_string()))?;
|
||||
let f = craft_media::matroska::open(src).map_err(|e| MediaError::Corrupt(e.to_string()))?;
|
||||
let mut info = MediaInfo { container: if f.is_webm() { "webm" } else { "matroska" }, ..Default::default() };
|
||||
let dur = f.duration_ns().map(|n| n as f64 / 1e9).unwrap_or(0.0);
|
||||
info.duration_ms = ms(dur);
|
||||
@@ -178,11 +179,11 @@ fn probe_mkv(bytes: &Bytes) -> Result<MediaInfo> {
|
||||
|
||||
fn probe_ogg(bytes: &Bytes) -> Option<MediaInfo> {
|
||||
let src: &[u8] = bytes;
|
||||
let f = deckcraft_ogg::open(src).ok()?;
|
||||
let s = f.stream_of(deckcraft_ogg::Codec::Opus)?;
|
||||
let f = craft_media::ogg::open(src).ok()?;
|
||||
let s = f.stream_of(craft_media::ogg::Codec::Opus)?;
|
||||
let st = f.streams.get(s)?;
|
||||
let head = deckcraft_opus::OpusHead::parse(st.headers.first()?).ok()?;
|
||||
let timing = deckcraft_ogg::OpusTiming::of(st, head.pre_skip as u32).ok()?;
|
||||
let head = craft_audio::opus::OpusHead::parse(st.headers.first()?).ok()?;
|
||||
let timing = craft_media::ogg::OpusTiming::of(st, head.pre_skip as u32);
|
||||
Some(MediaInfo {
|
||||
container: "ogg",
|
||||
duration_ms: ms(timing.total as f64 / 48_000.0),
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use deckcraft_isobmff::CodecConfig;
|
||||
use deckcraft_matroska::Codec as MkvCodec;
|
||||
use craft_media::isobmff::CodecConfig;
|
||||
use craft_media::matroska::Codec as MkvCodec;
|
||||
|
||||
use crate::yuv::{self, Matrix, Planes};
|
||||
use crate::{Bytes, Container, MediaError, Result};
|
||||
@@ -257,8 +257,8 @@ struct Track {
|
||||
}
|
||||
|
||||
enum Demux {
|
||||
Mp4(Box<deckcraft_isobmff::Mp4File>, usize),
|
||||
Mkv(Box<deckcraft_matroska::MkvFile>, usize),
|
||||
Mp4(Box<craft_media::isobmff::Mp4File>, usize),
|
||||
Mkv(Box<craft_media::matroska::MkvFile>, usize),
|
||||
}
|
||||
|
||||
impl Demux {
|
||||
@@ -273,8 +273,8 @@ impl Demux {
|
||||
fn open_track(bytes: &[u8]) -> Result<(Demux, Track, Box<dyn Codec>)> {
|
||||
match Container::sniff(bytes) {
|
||||
Container::Mp4 => {
|
||||
let f = deckcraft_isobmff::open(bytes).map_err(dec_err)?;
|
||||
let ti = f.track_of_kind(deckcraft_isobmff::TrackKind::Video).ok_or(MediaError::Missing("video track"))?;
|
||||
let f = craft_media::isobmff::open(bytes).map_err(dec_err)?;
|
||||
let ti = f.track_of_kind(craft_media::isobmff::TrackKind::Video).ok_or(MediaError::Missing("video track"))?;
|
||||
let t = &f.tracks[ti];
|
||||
let entry = t.entries.first().ok_or(MediaError::Missing("video track"))?;
|
||||
let codec: Box<dyn Codec> = match &entry.codec {
|
||||
@@ -290,11 +290,11 @@ fn open_track(bytes: &[u8]) -> Result<(Demux, Track, Box<dyn Codec>)> {
|
||||
Ok((Demux::Mp4(Box::new(f), ti), track, codec))
|
||||
}
|
||||
Container::Matroska => {
|
||||
let f = deckcraft_matroska::open(bytes).map_err(dec_err)?;
|
||||
let f = craft_media::matroska::open(bytes).map_err(dec_err)?;
|
||||
let ti = f
|
||||
.tracks
|
||||
.iter()
|
||||
.position(|t| t.kind == deckcraft_matroska::TrackKind::Video && !t.samples.is_empty())
|
||||
.position(|t| t.kind == craft_media::matroska::TrackKind::Video && !t.samples.is_empty())
|
||||
.ok_or(MediaError::Missing("video track"))?;
|
||||
let t = &f.tracks[ti];
|
||||
let codec: Box<dyn Codec> = match &t.codec {
|
||||
@@ -303,7 +303,7 @@ fn open_track(bytes: &[u8]) -> Result<(Demux, Track, Box<dyn Codec>)> {
|
||||
MkvCodec::Vp9 { .. } => Box::new(Vp9 { dec: deckcraft_vp9::Decoder::new() }),
|
||||
MkvCodec::Av1 { av1c } => Box::new(Av1 {
|
||||
dec: deckcraft_av1::Decoder::new(),
|
||||
config_obus: deckcraft_isobmff::Av1Config::parse(av1c).map(|c| c.config_obus).unwrap_or_default(),
|
||||
config_obus: craft_media::isobmff::Av1Config::parse(av1c).map(|c| c.config_obus).unwrap_or_default(),
|
||||
primed: false,
|
||||
}),
|
||||
other => return Err(MediaError::Unsupported(format!("{} video", mkv_codec_name(other)))),
|
||||
|
||||
@@ -166,6 +166,52 @@ fn ogg_opus() {
|
||||
check_tone("opus", &fixture("tone.opus"), 48_000, 1);
|
||||
}
|
||||
|
||||
/// Ogg Opus timing saturates on a hostile granule position instead of failing, so the end-trim
|
||||
/// arithmetic on its total must not overflow either: a three-channel file that ends with a page
|
||||
/// carrying the largest granule still probes and decodes.
|
||||
#[test]
|
||||
fn ogg_opus_with_a_huge_granule_does_not_overflow() {
|
||||
fn seal(mut page: Vec<u8>) -> Vec<u8> {
|
||||
page[22..26].fill(0);
|
||||
let crc = craft_media::ogg::crc32(&page);
|
||||
page[22..26].copy_from_slice(&crc.to_le_bytes());
|
||||
page
|
||||
}
|
||||
let f = fixture("tone.opus");
|
||||
let mut pages: Vec<Vec<u8>> = Vec::new();
|
||||
let mut at = 0;
|
||||
while at + 27 <= f.len() && &f[at..at + 4] == b"OggS" {
|
||||
let nseg = f[at + 26] as usize;
|
||||
let end = at + 27 + nseg + f[at + 27..at + 27 + nseg].iter().map(|&b| b as usize).sum::<usize>();
|
||||
pages.push(f[at..end].to_vec());
|
||||
at = end;
|
||||
}
|
||||
assert_eq!(at, f.len(), "the fixture is whole pages");
|
||||
// an OpusHead for three channels (mapping family 1), each playing the one mono stream
|
||||
let head = pages[0][28..].to_vec();
|
||||
let mut first = pages[0][..26].to_vec();
|
||||
first.extend_from_slice(&[1, 24]);
|
||||
first.extend_from_slice(&head[..9]);
|
||||
first.push(3);
|
||||
first.extend_from_slice(&head[10..18]);
|
||||
first.extend_from_slice(&[1, 1, 0, 0, 0, 0]);
|
||||
pages[0] = seal(first);
|
||||
// the last page once more, as the next page of the stream, with the largest granule
|
||||
let last = pages.len() - 1;
|
||||
let mut extra = pages[last].clone();
|
||||
pages[last][5] &= !0x04;
|
||||
pages[last] = seal(pages[last].clone());
|
||||
extra[6..14].copy_from_slice(&i64::MAX.to_le_bytes());
|
||||
let seq = u32::from_le_bytes([extra[18], extra[19], extra[20], extra[21]]) + 1;
|
||||
extra[18..22].copy_from_slice(&seq.to_le_bytes());
|
||||
pages.push(seal(extra));
|
||||
let b: Bytes = Arc::new(pages.concat());
|
||||
assert!(probe(&b).is_ok_and(|i| i.duration_ms > 1 << 40), "the granule is taken at its word");
|
||||
let p = audio::decode(&b).expect("decode");
|
||||
assert_eq!(p.channels, 3);
|
||||
assert!(!p.samples.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wma_is_probed_but_not_decoded() {
|
||||
let b = fixture("tone.wma");
|
||||
|
||||
@@ -313,6 +313,8 @@ pub(crate) struct Canvas {
|
||||
saved: Vec<Saved>,
|
||||
/// Bytes held by the items so far (path ops and pixels), capped at [`MAX_BYTES`].
|
||||
used: usize,
|
||||
/// Bytes retained outside the canvas (EMF+ objects) that count against the same cap.
|
||||
held: usize,
|
||||
objects: Vec<Option<Obj>>,
|
||||
pen: Pen,
|
||||
brush: Brush,
|
||||
@@ -345,6 +347,7 @@ impl Canvas {
|
||||
viewport_set: false,
|
||||
saved: Vec::new(),
|
||||
used: 0,
|
||||
held: 0,
|
||||
objects: Vec::new(),
|
||||
pen: Pen { style: 0, width: 0.0, color: [0, 0, 0, 255] },
|
||||
brush: Brush::new(BS_SOLID, [255, 255, 255, 255]),
|
||||
@@ -368,6 +371,17 @@ impl Canvas {
|
||||
self.surface = clip::normalize(rect);
|
||||
}
|
||||
|
||||
/// Bytes retained outside the canvas that share its budget (see [`Canvas::hold`]).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn held(&self) -> usize {
|
||||
self.held
|
||||
}
|
||||
|
||||
/// Bytes the canvas has retained so far.
|
||||
pub fn used(&self) -> usize {
|
||||
self.used
|
||||
}
|
||||
|
||||
/// True once the item cap is hit; the readers stop there and keep what they have.
|
||||
pub fn full(&self) -> bool {
|
||||
self.full
|
||||
@@ -385,6 +399,12 @@ impl Canvas {
|
||||
}
|
||||
}
|
||||
|
||||
/// Records the bytes another part of the player retains, so the items and they stay within
|
||||
/// [`MAX_BYTES`] together.
|
||||
pub fn hold(&mut self, bytes: usize) {
|
||||
self.held = bytes;
|
||||
}
|
||||
|
||||
fn push_raw(&mut self, mut raw: Raw) {
|
||||
if self.clip.as_ref().is_some_and(Vec::is_empty) {
|
||||
return;
|
||||
@@ -402,7 +422,7 @@ impl Canvas {
|
||||
Raw::Bitmap { img, .. } => img.px.len(),
|
||||
Raw::Text { text: t, .. } => t.text.len() + t.dx.len() * 4 + t.font.family.len() + 128,
|
||||
} + region_cost;
|
||||
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(cost) > MAX_BYTES {
|
||||
if self.items.len() >= MAX_ITEMS || self.used.saturating_add(self.held).saturating_add(cost) > MAX_BYTES {
|
||||
self.full = true;
|
||||
} else {
|
||||
self.used += cost;
|
||||
@@ -551,6 +571,10 @@ impl Canvas {
|
||||
/// of at most [`SHADE_RES`] cells on the longer side, laid out so that its one repeat covers
|
||||
/// the shape.
|
||||
pub fn shade_shape(&mut self, ops: &[Op], shade: &dyn Fn(P) -> [u8; 4]) {
|
||||
// A full canvas or an empty clip keeps nothing more, so do not sample a tile for it.
|
||||
if self.full || self.clip.as_ref().is_some_and(Vec::is_empty) {
|
||||
return;
|
||||
}
|
||||
let dev: Vec<Op> = ops.iter().map(|o| self.map_op(*o)).collect();
|
||||
let Some([l, t, r, b]) = crate::cliprect::ops_bbox(&dev) else { return };
|
||||
let side = (r - l).max(b - t);
|
||||
@@ -666,7 +690,7 @@ impl Canvas {
|
||||
// A pattern brush's pixels count against the byte budget like drawn items.
|
||||
if let Obj::Brush(Brush { pattern: Some(p), .. }) = &obj {
|
||||
let cost = p.px.len();
|
||||
if self.used.saturating_add(cost) > MAX_BYTES {
|
||||
if self.used.saturating_add(self.held).saturating_add(cost) > MAX_BYTES {
|
||||
self.full = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ use crate::geom::{Op, P};
|
||||
/// Line segments per Bézier when a crossing shape is flattened.
|
||||
const CURVE_STEPS: usize = 16;
|
||||
|
||||
/// Point-against-edge tests one clipped item may cost (all its subpaths together); an item over
|
||||
/// it is dropped, as a single polygon over it already was.
|
||||
const MAX_CLIP_WORK: usize = 1_000_000;
|
||||
|
||||
/// The same allowance for intersecting two clip regions, a budget of its own: two convex
|
||||
/// 1,000-gons cost about 1.5 million tests once the cut polygon has grown.
|
||||
pub(crate) const MAX_AREA_CLIP_WORK: usize = 4 * MAX_CLIP_WORK;
|
||||
|
||||
/// The bounding box `[l, t, r, b]` of the points in `ops` (control points included).
|
||||
pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> {
|
||||
let mut b: Option<[f64; 4]> = None;
|
||||
@@ -98,16 +106,18 @@ fn is_rect(clip: &[P]) -> bool {
|
||||
pts_bbox(clip).is_some_and(|b| clip.len() == 4 && clip.iter().all(|p| (p.0 == b[0] || p.0 == b[2]) && (p.1 == b[1] || p.1 == b[3])))
|
||||
}
|
||||
|
||||
/// Sutherland–Hodgman: `poly` cut by convex `clip`.
|
||||
pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
|
||||
if poly.len().saturating_mul(clip.len()) > 1_000_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
/// Sutherland–Hodgman: `poly` cut by convex `clip`, charging each point visited against each clip
|
||||
/// edge to `budget`: the polygon
|
||||
/// can gain a vertex at every edge, so its cost isn't known from its input size alone. `None`
|
||||
/// once the budget is spent.
|
||||
pub(crate) fn clip_polygon_within(poly: &[P], clip: &[P], budget: &mut usize) -> Option<Vec<P>> {
|
||||
let mut poly = poly.to_vec();
|
||||
for i in 0..clip.len() {
|
||||
if poly.len().saturating_mul(clip.len() - i) > 1_000_000 {
|
||||
return Vec::new();
|
||||
// The work actually done: this edge visits the polygon as it now stands.
|
||||
if poly.len() > *budget {
|
||||
return None;
|
||||
}
|
||||
*budget -= poly.len();
|
||||
let (a, b) = (clip[i], clip[(i + 1) % clip.len()]);
|
||||
let keep = |p: P| cross(a, b, p) >= 0.0;
|
||||
let cut = |p: P, q: P| {
|
||||
@@ -133,7 +143,7 @@ pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
poly
|
||||
Some(poly)
|
||||
}
|
||||
|
||||
fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool {
|
||||
@@ -158,15 +168,17 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
|
||||
return Vec::new();
|
||||
}
|
||||
let subs = flatten(&ops);
|
||||
// One allowance for the fill and the stroke together; the item goes once it is spent.
|
||||
let mut budget = MAX_CLIP_WORK;
|
||||
let mut out = Vec::new();
|
||||
if fill.is_some() {
|
||||
let f = clip_fill(&subs, clip);
|
||||
let Some(f) = clip_fill(&subs, clip, &mut budget) else { return Vec::new() };
|
||||
if !f.is_empty() {
|
||||
out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() });
|
||||
}
|
||||
}
|
||||
if stroke.is_some() {
|
||||
let s = clip_stroke(&subs, clip);
|
||||
let Some(s) = clip_stroke(&subs, clip, &mut budget) else { return Vec::new() };
|
||||
if !s.is_empty() {
|
||||
out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region });
|
||||
}
|
||||
@@ -181,7 +193,8 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
|
||||
if disjoint(b, cb) {
|
||||
return Vec::new();
|
||||
}
|
||||
let f = clip_fill(&flatten(&ops), clip);
|
||||
let subs = flatten(&ops);
|
||||
let f = clip_fill(&subs, clip, &mut { MAX_CLIP_WORK }).unwrap_or_default();
|
||||
if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] }
|
||||
}
|
||||
Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(),
|
||||
@@ -230,20 +243,23 @@ pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec<P>, bool)> {
|
||||
subs
|
||||
}
|
||||
|
||||
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
/// The clipped fill of every subpath, its clipping work charged to `budget` with that of the
|
||||
/// stroke: `None` (the whole item goes) once it is spent.
|
||||
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P], budget: &mut usize) -> Option<Vec<Op>> {
|
||||
let mut ops = Vec::new();
|
||||
for (pts, _) in subs {
|
||||
let poly = clip_polygon(pts, clip);
|
||||
let poly = clip_polygon_within(pts, clip, budget)?;
|
||||
if poly.len() >= 3 {
|
||||
ops.push(Op::M(poly[0]));
|
||||
ops.extend(poly[1..].iter().map(|p| Op::L(*p)));
|
||||
ops.push(Op::Z);
|
||||
}
|
||||
}
|
||||
ops
|
||||
Some(ops)
|
||||
}
|
||||
|
||||
fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
/// The clipped outline of every subpath; `None` once `budget` is spent.
|
||||
fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P], budget: &mut usize) -> Option<Vec<Op>> {
|
||||
let mut ops = Vec::new();
|
||||
for (pts, closed) in subs {
|
||||
let mut pts = pts.clone();
|
||||
@@ -252,7 +268,7 @@ fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
}
|
||||
let mut last: Option<P> = None;
|
||||
for w in pts.windows(2) {
|
||||
let Some((a, b)) = segment(w[0], w[1], clip) else {
|
||||
let Some((a, b)) = segment(w[0], w[1], clip, budget)? else {
|
||||
last = None;
|
||||
continue;
|
||||
};
|
||||
@@ -263,21 +279,23 @@ fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
last = Some(b);
|
||||
}
|
||||
}
|
||||
ops
|
||||
Some(ops)
|
||||
}
|
||||
|
||||
/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`.
|
||||
fn segment(a: P, b: P, clip: &[P]) -> Option<(P, P)> {
|
||||
/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`, one unit of `budget` per clip edge
|
||||
/// visited; the outer `None` is a spent budget.
|
||||
fn segment(a: P, b: P, clip: &[P], budget: &mut usize) -> Option<Option<(P, P)>> {
|
||||
let d = (b.0 - a.0, b.1 - a.1);
|
||||
let (mut t0, mut t1) = (0.0f64, 1.0f64);
|
||||
for i in 0..clip.len() {
|
||||
*budget = budget.checked_sub(1)?;
|
||||
let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]);
|
||||
// Inside is cross(e0, e1, p) >= 0, linear in t along the segment.
|
||||
let f0 = cross(e0, e1, a);
|
||||
let df = (e1.0 - e0.0) * d.1 - (e1.1 - e0.1) * d.0;
|
||||
if df == 0.0 {
|
||||
if f0 < 0.0 {
|
||||
return None;
|
||||
return Some(None);
|
||||
}
|
||||
} else {
|
||||
let t = -f0 / df;
|
||||
@@ -288,7 +306,7 @@ fn segment(a: P, b: P, clip: &[P]) -> Option<(P, P)> {
|
||||
}
|
||||
}
|
||||
}
|
||||
(t0 <= t1).then_some(((a.0 + d.0 * t0, a.1 + d.1 * t0), (a.0 + d.0 * t1, a.1 + d.1 * t1)))
|
||||
Some((t0 <= t1).then_some(((a.0 + d.0 * t0, a.1 + d.1 * t0), (a.0 + d.0 * t1, a.1 + d.1 * t1))))
|
||||
}
|
||||
|
||||
/// Crops a bitmap drawn into `rect` = `[x, y, w, h]` (device, positive extents) to the clip
|
||||
|
||||
@@ -28,7 +28,7 @@ use std::sync::Arc;
|
||||
use crate::bytes::Bytes;
|
||||
use crate::canvas::Raw;
|
||||
use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, LogFont, PS_NULL, Pen, RawTile, Xform, hatch_tile, mul};
|
||||
use crate::cliprect::{clip_polygon, convex, flatten, ops_bbox, pts_bbox, rect_poly};
|
||||
use crate::cliprect::{MAX_AREA_CLIP_WORK, clip_polygon_within, convex, flatten, ops_bbox, pts_bbox, rect_poly};
|
||||
use crate::dib::Rgba;
|
||||
use crate::geom::{Op, P};
|
||||
use crate::text::{ETO_CLIPPED, Font};
|
||||
@@ -288,13 +288,23 @@ impl Plus {
|
||||
if !self.active {
|
||||
return None;
|
||||
}
|
||||
match typ {
|
||||
0x4008 => self.object(flags, r),
|
||||
let done = match typ {
|
||||
0x4008 => self.object(flags, r, cv.used().saturating_add(self.clip_bytes())),
|
||||
0x4009 | 0x4013 | 0x401A..=0x401C | 0x4036 => self.record_image_text_region(cv, typ, flags, r),
|
||||
0x4025..=0x4035 => self.record_state(cv, typ, flags, r),
|
||||
0x4010..=0x4012 | 0x4016..=0x4019 => self.record_curve(cv, typ, flags, r),
|
||||
_ => self.record_shape(cv, typ, flags, r),
|
||||
}
|
||||
};
|
||||
// What this player retains (objects, a continued object's pieces, the current and saved
|
||||
// clips) shares the canvas's byte budget; clip changes are charged as they happen.
|
||||
cv.hold(self.object_bytes.saturating_add(self.partial.as_ref().map_or(0, |p| p.data.len())).saturating_add(self.clip_bytes()));
|
||||
done
|
||||
}
|
||||
|
||||
/// Bytes of the current clip and of every saved one.
|
||||
fn clip_bytes(&self) -> usize {
|
||||
let area = |a: &Area| a.as_ref().map_or(0, |v| v.len().saturating_mul(std::mem::size_of::<P>()));
|
||||
self.stack.iter().map(|s| area(&s.clip)).fold(area(&self.clip), usize::saturating_add)
|
||||
}
|
||||
|
||||
/// Rectangle, polygon, ellipse and path records.
|
||||
@@ -559,7 +569,7 @@ impl Plus {
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn object(&mut self, flags: u16, r: Bytes) -> Option<()> {
|
||||
fn object(&mut self, flags: u16, r: Bytes, canvas_used: usize) -> Option<()> {
|
||||
let id = usize::from(flags & 0xFF);
|
||||
let kind = (flags >> 8) & 0x7F;
|
||||
if flags & 0x8000 != 0 {
|
||||
@@ -575,7 +585,7 @@ impl Plus {
|
||||
p.data.extend_from_slice(&chunk[..chunk.len().min(room)]);
|
||||
if p.data.len() >= p.total {
|
||||
let obj = parse_object(p.kind, Bytes(&p.data));
|
||||
self.store(p.id, obj);
|
||||
self.store(p.id, obj, canvas_used);
|
||||
} else {
|
||||
self.partial = Some(p);
|
||||
}
|
||||
@@ -589,22 +599,23 @@ impl Plus {
|
||||
let room = p.total.saturating_sub(p.data.len());
|
||||
p.data.extend_from_slice(&r.0[..r.0.len().min(room)]);
|
||||
let obj = parse_object(kind, Bytes(&p.data));
|
||||
self.store(id, obj);
|
||||
self.store(id, obj, canvas_used);
|
||||
return Some(());
|
||||
}
|
||||
let obj = parse_object(kind, r);
|
||||
self.store(id, obj);
|
||||
self.store(id, obj, canvas_used);
|
||||
Some(())
|
||||
}
|
||||
|
||||
fn store(&mut self, id: usize, obj: Option<Obj>) {
|
||||
/// `canvas_used` is what the canvas already retains: objects and items share one budget.
|
||||
fn store(&mut self, id: usize, obj: Option<Obj>, canvas_used: usize) {
|
||||
let Some(slot) = self.objects.get_mut(id) else { return };
|
||||
if let Some(old) = slot.take() {
|
||||
self.object_bytes = self.object_bytes.saturating_sub(Self::obj_cost(&old));
|
||||
}
|
||||
if let Some(new_obj) = obj {
|
||||
let cost = Self::obj_cost(&new_obj);
|
||||
if self.object_bytes.saturating_add(cost) <= crate::MAX_BYTES {
|
||||
if self.object_bytes.saturating_add(cost).saturating_add(canvas_used) <= crate::MAX_BYTES {
|
||||
self.object_bytes += cost;
|
||||
*slot = Some(new_obj);
|
||||
}
|
||||
@@ -615,7 +626,7 @@ impl Plus {
|
||||
match obj {
|
||||
Obj::Image(img) => match img.as_ref() {
|
||||
ImageData::Bitmap(rgba) => rgba.px.len(),
|
||||
ImageData::Meta(pic) => pic.items.len().saturating_mul(128),
|
||||
ImageData::Meta(pic) => picture_cost(&pic.items),
|
||||
},
|
||||
Obj::Brush(Fill::Texture { img, .. }) => img.px.len(),
|
||||
Obj::Path(ops) => ops.len().saturating_mul(std::mem::size_of::<Op>()),
|
||||
@@ -1170,7 +1181,13 @@ fn area_of_ops(ops: &[Op]) -> Vec<P> {
|
||||
fn area_intersect(a: Area, b: Area) -> Area {
|
||||
match (a, b) {
|
||||
(None, x) | (x, None) => x,
|
||||
(Some(a), Some(b)) if a.len() >= 3 && b.len() >= 3 => Some(convex(&clip_polygon(&a, &b)).unwrap_or_default()),
|
||||
(Some(a), Some(b)) if a.len() >= 3 && b.len() >= 3 => {
|
||||
// Out of work: keep the area so far rather than call the region empty.
|
||||
match clip_polygon_within(&a, &b, &mut { MAX_AREA_CLIP_WORK }) {
|
||||
Some(p) => Some(convex(&p).unwrap_or_default()),
|
||||
None => Some(a),
|
||||
}
|
||||
}
|
||||
_ => Some(Vec::new()),
|
||||
}
|
||||
}
|
||||
@@ -1276,6 +1293,33 @@ fn metafile(r: Bytes) -> Option<Picture> {
|
||||
pic.filter(|p| p.width > 0.0 && p.height > 0.0)
|
||||
}
|
||||
|
||||
/// Bytes the items of a nested picture own: path segments, clip rectangles, pixels and text, with
|
||||
/// each shared pattern tile counted once.
|
||||
fn picture_cost(items: &[crate::Item]) -> usize {
|
||||
use crate::Item;
|
||||
let region = |r: &Option<Vec<[f32; 4]>>| r.as_ref().map_or(0, |v| v.len().saturating_mul(16));
|
||||
let mut tiles = std::collections::HashSet::new();
|
||||
let mut total = 0usize;
|
||||
for item in items {
|
||||
let own = match item {
|
||||
Item::Path { segs, region: r, .. } => segs.len().saturating_mul(std::mem::size_of::<crate::Seg>()).saturating_add(region(r)),
|
||||
Item::Bitmap { rgba, region: r, .. } => rgba.len().saturating_add(region(r)),
|
||||
Item::Pattern { segs, tile, region: r, .. } => {
|
||||
let tile_bytes = if tiles.insert(Arc::as_ptr(tile)) { tile.rgba.len() } else { 0 };
|
||||
segs.len().saturating_mul(std::mem::size_of::<crate::Seg>()).saturating_add(tile_bytes).saturating_add(region(r))
|
||||
}
|
||||
Item::Text(t) => t
|
||||
.text
|
||||
.len()
|
||||
.saturating_add(t.dx.len().saturating_mul(std::mem::size_of::<f32>()))
|
||||
.saturating_add(t.font.family.len())
|
||||
.saturating_add(region(&t.region)),
|
||||
};
|
||||
total = total.saturating_add(128).saturating_add(own);
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
fn bitmap(r: Bytes) -> Option<Rgba> {
|
||||
let (w, h, stride) = (r.u32(8)? as i32, r.u32(12)? as i32, r.u32(16)? as i32);
|
||||
let format = r.u32(20)?;
|
||||
@@ -1553,3 +1597,45 @@ fn wrap_line(line: &[u16], width: f64, em: f64) -> Vec<Vec<u16>> {
|
||||
out.push(cur);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cost_tests {
|
||||
use super::*;
|
||||
use crate::{Item, Seg, Tile};
|
||||
|
||||
#[test]
|
||||
fn a_shared_pattern_tile_is_charged_once() {
|
||||
let tile = Arc::new(Tile { width: 512, height: 512, rgba: vec![0; 1 << 20], cell: [1.0, 1.0], origin: [0.0, 0.0] });
|
||||
let item = || Item::Pattern { segs: vec![Seg::Move(0.0, 0.0), Seg::Line(1.0, 1.0)], even_odd: false, tile: tile.clone(), region: None };
|
||||
let items: Vec<Item> = (0..257).map(|_| item()).collect();
|
||||
assert!(picture_cost(&items) < 2 << 20, "{}", picture_cost(&items));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pieces_of_a_continued_object_count_against_the_shared_budget() {
|
||||
let (mut plus, mut cv) = (Plus::new(), Canvas::new());
|
||||
plus.active = true;
|
||||
// Image object (kind 5), id 0, continued: 1000 bytes announced, 100 delivered.
|
||||
let mut rec = 1000u32.to_le_bytes().to_vec();
|
||||
rec.extend([0u8; 100]);
|
||||
plus.record(&mut cv, 0x4008, 0x8000 | (5 << 8), Bytes(&rec));
|
||||
assert_eq!(cv.held(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_and_saved_clips_count_against_the_shared_budget() {
|
||||
let (mut plus, mut cv) = (Plus::new(), Canvas::new());
|
||||
plus.active = true;
|
||||
let pts = vec![(0.0, 0.0); 1000];
|
||||
plus.clip = Some(pts.clone());
|
||||
plus.stack.push(Saved { id: 1, world: IDENTITY, page: 1.0, clip: Some(pts) });
|
||||
// Any record refreshes the held count (an unknown record draws nothing).
|
||||
plus.record(&mut cv, 0x40FF, 0, Bytes(&[]));
|
||||
assert_eq!(cv.held(), 2 * 1000 * std::mem::size_of::<P>());
|
||||
// Restoring the saved state releases the charge of what it dropped.
|
||||
plus.stack.clear();
|
||||
plus.clip = None;
|
||||
plus.record(&mut cv, 0x40FF, 0, Bytes(&[]));
|
||||
assert_eq!(cv.held(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ impl Picture {
|
||||
let k = (c[0].abs() + c[3].abs()) / 2.0;
|
||||
let mut out = Vec::with_capacity(self.items.len());
|
||||
let mut total_elements = 0usize;
|
||||
let region_elements = std::cell::Cell::new(0usize);
|
||||
let mut cached_region: Option<(*const [f32; 4], usize, Option<Option<Arc<BezPath>>>)> = None;
|
||||
let mut get_region = |reg: &Option<Vec<[f32; 4]>>| -> Option<Option<Arc<BezPath>>> {
|
||||
let (ptr, len) = match reg {
|
||||
@@ -71,12 +72,15 @@ impl Picture {
|
||||
return res.clone();
|
||||
}
|
||||
let res = placed_region(&tf, reg);
|
||||
// Each distinct clip region is expanded once; its elements count against the placement budget.
|
||||
let n = res.as_ref().and_then(Option::as_ref).map_or(0, |p| p.elements().len());
|
||||
region_elements.set(region_elements.get().saturating_add(n));
|
||||
cached_region = Some((ptr, len, res.clone()));
|
||||
res
|
||||
};
|
||||
|
||||
for (index, item) in self.items.iter().enumerate() {
|
||||
if total_elements >= MAX_PLACED_ELEMENTS {
|
||||
if total_elements.saturating_add(region_elements.get()) >= MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
match item {
|
||||
@@ -84,6 +88,10 @@ impl Picture {
|
||||
let path = tf * bez(segs);
|
||||
let Some(region) = get_region(region) else { continue };
|
||||
if in_range(path.bounding_box()) {
|
||||
// This item's own region is expanded by now: it counts before it is kept.
|
||||
if total_elements.saturating_add(path.elements().len()).saturating_add(region_elements.get()) > MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
total_elements = total_elements.saturating_add(path.elements().len());
|
||||
let stroke = stroke.map(|(c, w)| (c, f64::from(w) * k));
|
||||
out.push(PlacedItem::Path { path, fill: *fill, stroke, even_odd: *even_odd, region });
|
||||
@@ -97,6 +105,9 @@ impl Picture {
|
||||
* Affine::scale_non_uniform(f64::from(t.cell[0]), f64::from(t.cell[1]));
|
||||
let det = tile.determinant();
|
||||
if in_range(path.bounding_box()) && det.is_finite() && det.abs() > 1e-12 {
|
||||
if total_elements.saturating_add(path.elements().len()).saturating_add(region_elements.get()) > MAX_PLACED_ELEMENTS {
|
||||
break;
|
||||
}
|
||||
total_elements = total_elements.saturating_add(path.elements().len());
|
||||
out.push(PlacedItem::Pattern { index, path, even_odd: *even_odd, tile, region });
|
||||
}
|
||||
|
||||
@@ -897,6 +897,24 @@ fn place_fits_the_frame_scales_strokes_and_drops_far_paths() {
|
||||
assert_eq!(stroke.map(|s| s.1), Some(8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_counts_the_items_own_clip_region_before_keeping_it() {
|
||||
// A path of 499,998 elements nearly fills the 500,000 budget; a second one whose new clip
|
||||
// region adds elements goes past it and is left out.
|
||||
let mut segs = vec![Seg::Move(0.0, 0.0)];
|
||||
segs.extend((0..499_997).map(|i| Seg::Line(1.0 + (i % 50) as f32, 1.0)));
|
||||
let big = Item::Path { segs, fill: None, stroke: None, even_odd: false, region: None };
|
||||
let small = Item::Path {
|
||||
segs: vec![Seg::Move(0.0, 0.0), Seg::Line(1.0, 1.0)],
|
||||
fill: None,
|
||||
stroke: None,
|
||||
even_odd: false,
|
||||
region: Some(vec![[0.0, 0.0, 50.0, 25.0], [10.0, 10.0, 20.0, 20.0]]),
|
||||
};
|
||||
let pic = crate::Picture { width: 100.0, height: 50.0, items: vec![big, small] };
|
||||
assert_eq!(pic.place(kurbo::Rect::new(0.0, 0.0, 100.0, 50.0), [0.0; 4]).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn place_keeps_only_finite_bounded_bitmaps_with_area() {
|
||||
let bitmap = |rect: [f32; 4]| Item::Bitmap { rect, width: 1, height: 1, rgba: vec![0; 4], region: None };
|
||||
@@ -2071,6 +2089,108 @@ fn emfplus_wrap_line_linear_performance() {
|
||||
fn clip_polygon_work_bound() {
|
||||
let poly: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect();
|
||||
let clip: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect();
|
||||
let clipped = crate::cliprect::clip_polygon(&poly, &clip);
|
||||
assert!(clipped.is_empty(), "excessive polygon clip product rejected");
|
||||
assert!(crate::cliprect::clip_polygon_within(&poly, &clip, &mut 1_000_000).is_none(), "excessive polygon clip product rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_polygon_charges_actual_work_not_projected_growth() {
|
||||
// Two convex 1,000-gons: the product is exactly the budget, and a cut that adds a vertex must
|
||||
// not make a later step look over budget.
|
||||
let gon = |cx: f64| -> Vec<(f64, f64)> {
|
||||
(0..1000).map(|i| (i as f64 / 1000.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (cx + 100.0 * c, 100.0 * s)).collect()
|
||||
};
|
||||
let clipped = crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut 4_000_000).unwrap();
|
||||
assert!(clipped.len() > 100, "the intersection is kept: {}", clipped.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_work_is_shared_by_fill_and_stroke() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
// One zigzag subpath crossing a 512-gon's edge: about 510k point-edge tests per pass, so a
|
||||
// fill alone fits the allowance but a fill plus a stroke would spend it twice.
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
let mut ops = vec![Op::M((0.0, 95.0))];
|
||||
ops.extend((1..1000).map(|i| Op::L((i as f64 * 0.19, if i % 2 == 0 { 95.0 } else { 300.0 }))));
|
||||
ops.push(Op::Z);
|
||||
let raw = |fill, stroke| Raw::Path { ops: ops.clone(), fill, stroke, even_odd: false, region: None };
|
||||
let black = [0, 0, 0, 255];
|
||||
assert!(!crate::cliprect::clip_raw(raw(Some(black), None), &clip).is_empty(), "a fill alone is within the allowance");
|
||||
assert!(crate::cliprect::clip_raw(raw(Some(black), Some((black, 1.0))), &clip).is_empty(), "fill and stroke share one allowance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_work_is_bounded_across_subpaths() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
// 512-gon clip; 4,000 small triangles crossing its edge: each under the per-polygon cap, together
|
||||
// far over it, for both the fill and the stroke.
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
let tri = |x: f64| [Op::M((x, 95.0)), Op::L((x + 1.0, 95.0)), Op::L((x, 300.0)), Op::Z];
|
||||
let many: Vec<Op> = (0..4000).flat_map(|i| tri(i as f64 % 200.0)).collect();
|
||||
let raw = |ops| Raw::Path { ops, fill: Some([0, 0, 0, 255]), stroke: Some(([0, 0, 0, 255], 1.0)), even_odd: false, region: None };
|
||||
assert!(crate::cliprect::clip_raw(raw(many), &clip).is_empty(), "the whole item's clipping work is bounded");
|
||||
assert_eq!(crate::cliprect::clip_raw(raw(tri(100.0).to_vec()), &clip).len(), 2, "a small crossing shape is still clipped");
|
||||
}
|
||||
|
||||
/// Clipping a fill can add a vertex at every clip edge, so a polygon's work isn't its input size:
|
||||
/// shapes that pass the up-front estimate but grow while clipped are charged as they grow, against
|
||||
/// one budget for the whole item.
|
||||
#[test]
|
||||
fn growing_fill_clips_share_one_budget() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
// A triangle around the whole circle: clipped, it becomes the 512-gon, about 130,000 steps.
|
||||
let tri = [Op::M((-1000.0, -1000.0)), Op::L((1000.0, -1000.0)), Op::L((100.0, 1000.0)), Op::Z];
|
||||
let raw = |n: usize| Raw::Path { ops: tri.repeat(n), fill: Some([0, 0, 0, 255]), stroke: None, even_odd: false, region: None };
|
||||
// 400 of them pass the estimate (400 x 4 x 512 is under a million) but cost about 52 million.
|
||||
assert!(crate::cliprect::clip_raw(raw(400), &clip).is_empty(), "the growing work is charged");
|
||||
assert_eq!(crate::cliprect::clip_raw(raw(2), &clip).len(), 1, "a couple of them are still clipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_work_is_charged_and_outside_items_are_cheap() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
// A stroke-only zigzag of 4,000 segments across the edge: 4,000 x 512 tests, over the allowance.
|
||||
let mut ops = vec![Op::M((0.0, 95.0))];
|
||||
ops.extend((1..4000).map(|i| Op::L((i as f64 * 0.05, if i % 2 == 0 { 95.0 } else { 300.0 }))));
|
||||
let raw = Raw::Path { ops, fill: None, stroke: Some(([0, 0, 0, 255], 1.0)), even_odd: false, region: None };
|
||||
assert!(crate::cliprect::clip_raw(raw, &clip).is_empty(), "stroke work is bounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_intersection_over_budget_keeps_the_previous_area() {
|
||||
let gon = |cx: f64| -> Vec<(f64, f64)> {
|
||||
(0..1000).map(|i| (i as f64 / 1000.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (cx + 100.0 * c, 100.0 * s)).collect()
|
||||
};
|
||||
assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut 10).is_none());
|
||||
assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut crate::cliprect::MAX_AREA_CLIP_WORK.clone()).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_canvas_does_not_sample_gradient_tiles() {
|
||||
let mut cv = Canvas::new();
|
||||
let big = || Rgba { w: 1, h: 1, bottom_up: false, px: vec![0u8; MAX_BYTES / 2] };
|
||||
for _ in 0..3 {
|
||||
cv.bitmap([0.0, 0.0, 1.0, 1.0], big());
|
||||
}
|
||||
assert!(cv.full());
|
||||
cv.shade_shape(&crate::geom::rect(0.0, 0.0, 10.0, 10.0), &|_| panic!("sampled a full canvas"));
|
||||
assert_eq!(cv.items.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canvas_budget_counts_bytes_held_elsewhere() {
|
||||
let mut cv = Canvas::new();
|
||||
cv.hold(MAX_BYTES - 100);
|
||||
cv.bitmap([0.0, 0.0, 1.0, 1.0], Rgba { w: 1, h: 1, bottom_up: false, px: vec![0u8; 200] });
|
||||
assert!(cv.full());
|
||||
assert!(cv.items.is_empty());
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user