mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-06 10:29:42 -06:00
Compare commits
168
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
58c24883c7 | ||
|
|
b9eaaeb23b | ||
|
|
3cfd4c5e6e | ||
|
|
a200b92258 | ||
|
|
f2803102c1 | ||
|
|
fd4d2a8f79 | ||
|
|
e90e7f6c03 | ||
|
|
9e59b5f223 | ||
|
|
5cb6ba8403 | ||
|
|
62ada3ab9a | ||
|
|
77cd617596 | ||
|
|
72ac32715f | ||
|
|
e0992c1325 | ||
|
|
4bed9b6c37 | ||
|
|
ea9dad8ef9 | ||
|
|
1192e5ef30 | ||
|
|
f006b5d8ce | ||
|
|
e1cfe8516e | ||
|
|
cbb0c48445 | ||
|
|
74d7b844fe | ||
|
|
98138f563b | ||
|
|
917d4b96a9 | ||
|
|
b1900e526d | ||
|
|
d453bee54f | ||
|
|
6303166a18 | ||
|
|
d8936f94af | ||
|
|
813378cc20 | ||
|
|
cc6773c95f |
@@ -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
|
||||
@@ -19,7 +19,14 @@ 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:
|
||||
@@ -30,6 +37,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 +53,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
+142
-11
@@ -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"
|
||||
@@ -911,6 +935,15 @@ dependencies = [
|
||||
"zip 9.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-web-host"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
||||
dependencies = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -1275,6 +1308,7 @@ dependencies = [
|
||||
"deckcraft-render",
|
||||
"deckcraft-text",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1297,6 +1331,7 @@ dependencies = [
|
||||
name = "deckcraft-web"
|
||||
version = "0.15.0"
|
||||
dependencies = [
|
||||
"craft-web-host",
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
"eframe",
|
||||
@@ -1507,19 +1542,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 +1638,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"
|
||||
@@ -3487,7 +3558,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 +4474,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 +4492,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 +5037,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 +5480,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 +6116,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 +6137,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",
|
||||
@@ -6511,11 +6647,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"
|
||||
|
||||
+25
@@ -2,7 +2,12 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-04 · **Change:** trivial (Ogg Opus recovery includes trailing packets without granules in its reported duration; 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)
|
||||
@@ -149,7 +154,21 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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 |
|
||||
@@ -321,7 +340,13 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
@@ -16,6 +16,8 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-ui-egui = { workspace = true }
|
||||
# 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 = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
||||
egui = { workspace = true }
|
||||
log = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -27,4 +29,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", "MessageEvent", "Event", "EventTarget"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget", "UrlSearchParams"] }
|
||||
|
||||
@@ -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,20 @@
|
||||
//! - 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(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod presenter;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -251,6 +251,9 @@ impl Popup {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
@@ -44,22 +54,42 @@ pub fn start() {
|
||||
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);
|
||||
}
|
||||
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(),
|
||||
@@ -73,17 +103,36 @@ 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()
|
||||
{
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -103,7 +103,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 +132,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 +143,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 +261,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 +287,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 +359,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));
|
||||
|
||||
@@ -425,6 +425,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 +446,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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
@@ -184,7 +187,8 @@ fn parse_align(cmd: &str, s: &str) -> Result<Align> {
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justLow" => 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 +200,7 @@ fn align_name(a: Align) -> &'static str {
|
||||
Align::Justify => "justify",
|
||||
Align::JustLow => "justLow",
|
||||
Align::Distributed => "distributed",
|
||||
Align::ThaiDistributed => "thaiDist",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||||
"{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
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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![];
|
||||
|
||||
+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,
|
||||
}
|
||||
|
||||
@@ -700,15 +700,29 @@ 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 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![];
|
||||
@@ -791,15 +805,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).
|
||||
@@ -953,6 +970,23 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
assert_eq!(w, vec!["check", "done"]);
|
||||
}
|
||||
|
||||
#[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");
|
||||
|
||||
+572
-1
@@ -214,6 +214,368 @@ fn clipboard_copy_paste_shapes_and_slides() {
|
||||
assert!(s.doc().unwrap().doc.validate().is_empty());
|
||||
}
|
||||
|
||||
/// The resolved look of every paragraph and run of shape `id` on the current slide: size, font,
|
||||
/// colour, bold, bullet, indent, alignment, and the box's insets and anchor.
|
||||
fn resolved_look(s: &Session, id: ShapeId) -> String {
|
||||
use deckcraft_model::resolve;
|
||||
let st = s.doc().unwrap();
|
||||
let slide = st.current_slide().unwrap();
|
||||
let ctx = resolve::Ctx::for_slide(&st.doc, slide).unwrap();
|
||||
let sh = st.shape(id).unwrap();
|
||||
let t = sh.text.as_ref().unwrap();
|
||||
let b = resolve::body(&ctx, sh);
|
||||
let mut out = format!("body {:?} {:?} {:?}\n", b.anchor, b.inset_l, b.autofit);
|
||||
for p in &t.paragraphs {
|
||||
let pp = resolve::para(&ctx, sh, p);
|
||||
out += &format!("para {:?} {:?} {:?} {:?}\n", pp.align, pp.margin_left, pp.indent, pp.bullet);
|
||||
for r in &p.runs {
|
||||
let rp = resolve::run(&ctx, sh, p, &r.props);
|
||||
out += &format!(" run {:?} {:?} {:?} {:?}\n", rp.size, rp.font, rp.bold, rp.fill);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A placeholder pasted or duplicated on a slide becomes a regular shape, and keeps the look its
|
||||
/// layout and master gave it: the title's size and font, the body's bullets and indents. Before,
|
||||
/// the copy fell back to the master's other-text style (18 pt, no bullets).
|
||||
#[test]
|
||||
fn a_pasted_or_duplicated_placeholder_keeps_its_inherited_look() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let phs: Vec<ShapeId> = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect();
|
||||
assert!(phs.len() >= 2, "a title and a body placeholder");
|
||||
for (k, id) in phs.iter().enumerate() {
|
||||
s.execute("text.set", &json!({"id": id.0, "text": format!("Placeholder {k}\nSecond line")})).unwrap();
|
||||
}
|
||||
for id in phs {
|
||||
let want = resolved_look(&s, id);
|
||||
for cmd in ["paste", "duplicate"] {
|
||||
s.execute("selection.select", &json!({"id": id.0})).unwrap();
|
||||
if cmd == "paste" {
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
} else {
|
||||
s.execute("edit.duplicate", &json!({})).unwrap();
|
||||
}
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_ne!(copy, id);
|
||||
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "{cmd}: the copy is a regular shape");
|
||||
assert_eq!(resolved_look(&s, copy), want, "{cmd} of placeholder {}", id.0);
|
||||
}
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.validate().is_empty());
|
||||
}
|
||||
|
||||
/// Dragging a copy of a placeholder off it keeps the look too: the title's size and font, the
|
||||
/// body's bullets. `edit.duplicate` is not the only way to duplicate.
|
||||
#[test]
|
||||
fn a_placeholder_duplicated_by_dragging_keeps_its_look() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let phs: Vec<ShapeId> = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect();
|
||||
assert!(phs.len() >= 2, "a title and a body placeholder");
|
||||
for id in phs {
|
||||
s.execute("text.set", &json!({"id": id.0, "text": "First\nSecond"})).unwrap();
|
||||
let want = resolved_look(&s, id);
|
||||
s.execute("selection.select", &json!({"id": id.0})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let x = crate::cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap());
|
||||
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
|
||||
let alt = |kind, x, y| PointerEvent { mods: Mods { alt: true, ..Default::default() }, ..tev(kind, x, y) };
|
||||
s.pointer(alt(PointerKind::Down, cx, cy)).unwrap();
|
||||
s.pointer(alt(PointerKind::Drag, cx + 30.0, cy + 30.0)).unwrap();
|
||||
s.pointer(alt(PointerKind::Up, cx + 30.0, cy + 30.0)).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_ne!(copy, id, "the drag made a copy");
|
||||
assert!(s.doc().unwrap().shape(id).unwrap().ph.is_some(), "the original stays a placeholder");
|
||||
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "the copy is a regular shape");
|
||||
assert_eq!(resolved_look(&s, copy), want, "placeholder {}", id.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep Source Formatting fixes a copy's theme colours as the source drew them: through the colour
|
||||
/// map of its slide or layout, not the master's. A title's inherited `tx1` stays the light colour
|
||||
/// an override gave it.
|
||||
#[test]
|
||||
fn keep_source_formatting_uses_the_slides_colour_map() {
|
||||
use deckcraft_model::resolve;
|
||||
let first_run_colour = |s: &Session, id: ShapeId| {
|
||||
let st = s.doc().unwrap();
|
||||
let ctx = resolve::Ctx::for_slide(&st.doc, st.current_slide().unwrap()).unwrap();
|
||||
let sh = st.shape(id).unwrap();
|
||||
let p = &sh.text.as_ref().unwrap().paragraphs[0];
|
||||
resolve::text_color(&ctx, &resolve::run(&ctx, sh, p, &p.runs[0].props))
|
||||
};
|
||||
for on_layout in [false, true] {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id;
|
||||
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
|
||||
let plain = first_run_colour(&s, title);
|
||||
let st = s.doc_mut().unwrap();
|
||||
let at = st.selection.slide;
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
if on_layout {
|
||||
let layout = doc.slides[at].layout;
|
||||
let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = swapped_colour_map();
|
||||
} else {
|
||||
std::sync::Arc::make_mut(&mut doc.slides[at]).color_map = swapped_colour_map();
|
||||
}
|
||||
let want = first_run_colour(&s, title);
|
||||
assert_ne!(want, plain, "the override changes how the title draws");
|
||||
|
||||
s.execute("selection.select", &json!({"id": title.0})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_ne!(copy, title);
|
||||
assert_eq!(first_run_colour(&s, copy), want, "override on the layout: {on_layout}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour map that swaps text and background, as a dark slide or layout has.
|
||||
fn swapped_colour_map() -> Option<Vec<(String, String)>> {
|
||||
Some([("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].map(|(a, b)| (a.to_string(), b.to_string())).to_vec())
|
||||
}
|
||||
|
||||
/// A new rectangle in the edited tree, filled with theme colour `slot`.
|
||||
fn rect_filled_with(s: &mut Session, slot: deckcraft_color::SchemeSlot) -> ShapeId {
|
||||
use deckcraft_model::{ColorRef, Fill};
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let id = ShapeId(u32::try_from(id).unwrap());
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).unwrap();
|
||||
list.iter_mut().find(|x| x.id == id).unwrap().fill = Some(Fill::solid(ColorRef::scheme(slot)));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// How shape `id`'s fill draws in the edited tree.
|
||||
fn drawn_fill(s: &Session, id: ShapeId) -> deckcraft_color::Rgba {
|
||||
use deckcraft_model::{Fill, resolve};
|
||||
let st = s.doc().unwrap();
|
||||
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection).unwrap();
|
||||
match resolve::fill(&ctx, st.shape(id).unwrap()).0 {
|
||||
Some(Fill::Solid { color }) => ctx.color(&color, None),
|
||||
other => panic!("not a solid fill: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the selected shape and paste it with Keep Source Formatting; the copy's id.
|
||||
fn keep_source_copy(s: &mut Session, id: ShapeId) -> ShapeId {
|
||||
s.execute("selection.select", &json!({"id": id.0})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_ne!(copy, id);
|
||||
copy
|
||||
}
|
||||
|
||||
/// A colour map renames only `tx1`, `bg1`, `tx2` and `bg2`. Keep Source Formatting fixes every slot
|
||||
/// as it draws: `tx1` follows the map, while a direct `dk1` stays the theme's dark colour.
|
||||
#[test]
|
||||
fn keep_source_formatting_maps_only_the_alias_colours() {
|
||||
use deckcraft_color::SchemeSlot::{self, *};
|
||||
let mut s = session();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let at = st.selection.slide;
|
||||
std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).slides[at]).color_map = swapped_colour_map();
|
||||
let slots: [SchemeSlot; 9] = [Dk1, Lt1, Dk2, Lt2, Tx1, Bg1, Tx2, Bg2, Accent1];
|
||||
let mut drawn = vec![];
|
||||
for slot in slots {
|
||||
let id = rect_filled_with(&mut s, slot);
|
||||
let want = drawn_fill(&s, id);
|
||||
let copy = keep_source_copy(&mut s, id);
|
||||
assert_eq!(drawn_fill(&s, copy), want, "{slot:?}");
|
||||
drawn.push(want);
|
||||
}
|
||||
assert_ne!(drawn[0], drawn[4], "the map sets `tx1` apart from `dk1`");
|
||||
}
|
||||
|
||||
/// In Slide Master view a copy draws through the edited master's map, not an override on the slide
|
||||
/// that happens to be current.
|
||||
#[test]
|
||||
fn keep_source_formatting_in_slide_master_view_ignores_the_slides_colour_map() {
|
||||
use deckcraft_color::SchemeSlot::Tx1;
|
||||
for master in [false, true] {
|
||||
let mut s = session();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let at = st.selection.slide;
|
||||
std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).slides[at]).color_map = swapped_colour_map();
|
||||
s.execute("view.slideMaster", &if master { json!({"master": 0}) } else { json!({}) }).unwrap();
|
||||
let id = rect_filled_with(&mut s, Tx1);
|
||||
let want = drawn_fill(&s, id);
|
||||
let copy = keep_source_copy(&mut s, id);
|
||||
assert_eq!(drawn_fill(&s, copy), want, "editing the master: {master}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A slide's placeholder pasted in Slide Master view stays a placeholder that inherits from where
|
||||
/// it lands; only a paste on a slide turns it into a regular shape with the look it had.
|
||||
#[test]
|
||||
fn a_slide_placeholder_pasted_in_slide_master_view_stays_a_placeholder() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id;
|
||||
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
|
||||
let want = resolved_look(&s, title);
|
||||
let source = s.doc().unwrap().shape(title).unwrap().clone();
|
||||
s.execute("selection.select", &json!({"id": title.0})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
|
||||
s.execute("view.slideMaster", &json!({})).unwrap();
|
||||
for paste in ["edit.paste", "edit.pasteSpecial"] {
|
||||
s.execute(paste, &json!({"as": "keepSource"})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let copy = st.shape(st.selection.shapes[0]).unwrap();
|
||||
assert_eq!(copy.ph.as_ref().map(|p| p.kind), source.ph.as_ref().map(|p| p.kind), "{paste} keeps the placeholder");
|
||||
let list = copy.text.as_ref().map(|t| t.list_style.clone());
|
||||
assert_eq!(list, source.text.as_ref().map(|t| t.list_style.clone()), "{paste} adds no baked styles");
|
||||
}
|
||||
s.execute("view.closeMaster", &json!({})).unwrap();
|
||||
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "on a slide the copy is a regular shape");
|
||||
assert_eq!(resolved_look(&s, copy), want);
|
||||
}
|
||||
|
||||
/// The resolved fill, line and effects of shape `id` on the current slide, with their style colours.
|
||||
fn resolved_box(s: &Session, id: ShapeId) -> String {
|
||||
use deckcraft_model::resolve;
|
||||
let st = s.doc().unwrap();
|
||||
let ctx = resolve::Ctx::for_slide(&st.doc, st.current_slide().unwrap()).unwrap();
|
||||
let sh = st.shape(id).unwrap();
|
||||
format!("{:?}\n{:?}\n{:?}", resolve::fill(&ctx, sh), resolve::line(&ctx, sh), resolve::effects(&ctx, sh))
|
||||
}
|
||||
|
||||
/// Edit the title placeholders the current slide's title inherits from: its layout's and its
|
||||
/// master's. Returns the slide title's id.
|
||||
fn edit_title_parents(
|
||||
s: &mut Session,
|
||||
layout: impl FnOnce(&mut deckcraft_model::Shape),
|
||||
master: impl FnOnce(&mut deckcraft_model::Shape),
|
||||
) -> ShapeId {
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide = st.current_slide().unwrap().clone();
|
||||
let title = slide.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap().id;
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
|
||||
let lay = m.layouts.iter_mut().find(|l| l.id == slide.layout).unwrap();
|
||||
layout(lay.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap());
|
||||
master(m.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap());
|
||||
title
|
||||
}
|
||||
|
||||
/// A copied placeholder takes each of fill, line and effects from where the original does: a
|
||||
/// style on the layout's placeholder (or on the slide's own) wins over an explicit value on the
|
||||
/// master's, as `resolve::fill`, `resolve::line` and `resolve::effects` decide.
|
||||
#[test]
|
||||
fn a_pasted_placeholder_keeps_the_style_precedence_of_its_box() {
|
||||
use deckcraft_color::SchemeSlot;
|
||||
use deckcraft_model::{ColorRef, Effects, Fill, Line, ShapeStyle};
|
||||
for style_on_slide in [false, true] {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let style = ShapeStyle {
|
||||
line_ref: (1, ColorRef::scheme(SchemeSlot::Accent2)),
|
||||
fill_ref: (1, ColorRef::scheme(SchemeSlot::Accent1)),
|
||||
effect_ref: (0, ColorRef::scheme(SchemeSlot::Accent3)),
|
||||
font_ref: ("minor".into(), None),
|
||||
};
|
||||
let layout_style = (!style_on_slide).then(|| style.clone());
|
||||
let title = edit_title_parents(
|
||||
&mut s,
|
||||
|l| l.style = layout_style,
|
||||
|m| {
|
||||
m.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Accent4)));
|
||||
m.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent5), 20.0));
|
||||
m.effects = Some(Effects { soft_edge: Some(5.0), ..Default::default() });
|
||||
},
|
||||
);
|
||||
if style_on_slide {
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).unwrap();
|
||||
list.iter_mut().find(|x| x.id == title).unwrap().style = Some(style.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("text.set", &json!({"id": title.0, "text": "Styled title"})).unwrap();
|
||||
let want = (resolved_box(&s, title), resolved_look(&s, title));
|
||||
s.execute("selection.select", &json!({"id": title.0})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_eq!((resolved_box(&s, copy), resolved_look(&s, copy)), want, "style on the slide: {style_on_slide}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The master's placeholder style never reaches the copy's text: the original's text ignores it
|
||||
/// (only the shape's or the layout's font reference counts), so the copy must too.
|
||||
#[test]
|
||||
fn a_pasted_placeholder_ignores_the_masters_font_reference() {
|
||||
use deckcraft_color::SchemeSlot;
|
||||
use deckcraft_model::{ColorRef, ShapeStyle};
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let title = edit_title_parents(
|
||||
&mut s,
|
||||
|l| l.style = None,
|
||||
|m| {
|
||||
m.style = Some(ShapeStyle {
|
||||
line_ref: (0, ColorRef::scheme(SchemeSlot::Accent2)),
|
||||
fill_ref: (0, ColorRef::scheme(SchemeSlot::Accent1)),
|
||||
effect_ref: (0, ColorRef::scheme(SchemeSlot::Accent3)),
|
||||
font_ref: ("minor".into(), Some(ColorRef::scheme(SchemeSlot::Accent6))),
|
||||
})
|
||||
},
|
||||
);
|
||||
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
|
||||
let want = (resolved_box(&s, title), resolved_look(&s, title));
|
||||
assert!(want.1.contains("+mj-lt"), "the title draws in the heading font: {}", want.1);
|
||||
s.execute("selection.select", &json!({"id": title.0})).unwrap();
|
||||
s.execute("edit.duplicate", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_eq!((resolved_box(&s, copy), resolved_look(&s, copy)), want);
|
||||
}
|
||||
|
||||
/// The same on every text placeholder of the sample deck (titles, bulleted bodies, subtitles).
|
||||
#[test]
|
||||
fn sample_placeholders_paste_with_their_look() {
|
||||
let mut s = Session::new();
|
||||
crate::sample::open_sample(&mut s).unwrap();
|
||||
let slides = s.doc().unwrap().doc.slides.len();
|
||||
let mut checked = 0;
|
||||
for i in 0..slides {
|
||||
s.execute("slide.go", &json!({"index": i})).unwrap();
|
||||
let phs: Vec<ShapeId> = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter(|x| x.ph.is_some() && x.text.as_ref().is_some_and(|t| !t.is_empty()))
|
||||
.map(|x| x.id)
|
||||
.collect();
|
||||
for id in phs {
|
||||
let want = resolved_look(&s, id);
|
||||
s.execute("selection.select", &json!({"id": id.0})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_eq!(resolved_look(&s, copy), want, "slide {i}, placeholder {}", id.0);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked >= 3, "the sample has text placeholders ({checked})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_shapes_get_the_next_automatic_name() {
|
||||
let mut s = session();
|
||||
@@ -2410,6 +2772,23 @@ fn pdf_options_take_paper_and_orientation() {
|
||||
assert!(cmd::file::pdf_options(&json!({"orientation": "sideways"}), 1).is_err());
|
||||
}
|
||||
|
||||
/// Handout `perPage` accepts only 1, 2, 3, 4, 6 and 9; anything else is an error, not a 9-up page.
|
||||
#[test]
|
||||
fn pdf_options_reject_unsupported_per_page() {
|
||||
for n in [1, 2, 3, 4, 6, 9] {
|
||||
let o = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap();
|
||||
assert!(matches!(o.layout, deckcraft_pdf::PageLayout::Handouts { per_page } if per_page as usize == n));
|
||||
}
|
||||
for n in [0, 5, 7, 8, 10] {
|
||||
let e = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap_err();
|
||||
assert!(e.to_string().contains("1, 2, 3, 4, 6, 9"), "perPage {n}: {e}");
|
||||
}
|
||||
// Not a count at all: refused too, instead of falling back to 6.
|
||||
for v in [json!(-1), json!(5.5), json!("6")] {
|
||||
assert!(cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": v}), 1).is_err(), "perPage {v}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_line_sets_compound_and_arrow_sizes() {
|
||||
// The Format Shape pane's Line page: compound type, arrowheads and their sizes.
|
||||
@@ -3206,6 +3585,7 @@ fn numbering_takes_color_and_size() {
|
||||
props.bullet_font_follow_text = Some(true);
|
||||
props.bullet_color_follow_text = Some(true);
|
||||
props.bullet_size_follow_text = Some(true);
|
||||
props.bullet_size_points = Some(12.0);
|
||||
}
|
||||
s.execute("format.numbering", &json!({"on": true, "scheme": "romanUcPeriod", "start": 3, "color": "accent2", "size": 150})).unwrap();
|
||||
let para = |s: &Session| {
|
||||
@@ -3219,9 +3599,11 @@ fn numbering_takes_color_and_size() {
|
||||
assert_eq!(pp.bullet_font_follow_text, Some(true));
|
||||
assert_eq!(pp.bullet_color_follow_text, None);
|
||||
assert_eq!(pp.bullet_size_follow_text, None);
|
||||
assert_eq!(pp.bullet_size_points, None);
|
||||
assert_eq!(pp.bullet_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2)));
|
||||
s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap();
|
||||
assert_eq!(para(&s).bullet_size, Some(4.0));
|
||||
assert_eq!(para(&s).bullet_size_points, None);
|
||||
assert_eq!(para(&s).bullet_font_follow_text, None);
|
||||
}
|
||||
|
||||
@@ -4301,6 +4683,9 @@ fn shape_fill_documents_each_gradient_key_and_kind() {
|
||||
let params = cmd::parse_params(find_command("shape.fill").unwrap().params).unwrap();
|
||||
let about = params.iter().find(|p| p.name == "gradient").unwrap().about;
|
||||
assert_eq!(cmd::param_keys(about).unwrap(), ["stops", "kind", "angle", "focus", "rotateWithShape"]);
|
||||
// The renderer keeps only linear and radial gradients upright (`paint::fill_path`).
|
||||
let rotate = cmd::parse_params(about).unwrap().into_iter().find(|p| p.name == "rotateWithShape").unwrap().about;
|
||||
assert!(rotate.contains("rectangular and path gradients turn with the shape"), "{rotate}");
|
||||
for kind in ["linear", "radial", "rectangular", "path"] {
|
||||
assert!(about.contains(kind), "{kind}: {about}");
|
||||
let g = cmd::shape::gradient_param(&json!({
|
||||
@@ -5342,7 +5727,8 @@ fn opening_a_file_caps_absurd_effect_sizes() {
|
||||
e.soft_edge = Some(f64::NAN);
|
||||
let sw = e.outer_shadow.as_mut().unwrap();
|
||||
(sw.blur, sw.dist, sw.sx) = (huge, huge, huge);
|
||||
*e.reflection.as_mut().unwrap() = Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge };
|
||||
*e.reflection.as_mut().unwrap() =
|
||||
Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge, ..Default::default() };
|
||||
}
|
||||
let glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge });
|
||||
let runs: Vec<&mut deckcraft_model::text::Run> = match &mut sh.kind {
|
||||
@@ -6458,3 +6844,188 @@ fn master_body_placeholder_format_keeps_per_level_formatting() {
|
||||
assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0)));
|
||||
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_master_view_rejects_invalid_indices() {
|
||||
let mut s = Session::with_new();
|
||||
for p in [json!({"master": -1}), json!({"master": 0.5}), json!({"master": 0, "layout": -1}), json!({"master": 0, "layout": 1.5})] {
|
||||
let r = s.execute("view.slideMaster", &p);
|
||||
assert!(r.is_err(), "invalid index accepted for {p}: {r:?}");
|
||||
}
|
||||
assert!(s.execute("view.slideMaster", &json!({})).is_ok());
|
||||
assert!(s.execute("view.slideMaster", &json!({"master": 0})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_to_image_path_is_an_error_not_zip_bytes() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-save-img-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for (cmd, name) in [("file.save", "a.png"), ("file.saveAs", "a.jpg"), ("file.save", "a.jpeg")] {
|
||||
let mut s = Session::new();
|
||||
sample::open_sample(&mut s).unwrap();
|
||||
let path = dir.join(name).to_string_lossy().to_string();
|
||||
let before = s.doc().unwrap().path.clone();
|
||||
assert!(s.execute(cmd, &json!({"path": path})).is_err(), "{cmd} {name} should fail");
|
||||
assert!(!std::path::Path::new(&path).exists(), "{name} must not be written");
|
||||
assert_eq!(s.doc().unwrap().path, before);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `shape.connect` glues to a shape inside a group, as PowerPoint does: the connector's
|
||||
/// end is set to the grouped shape and follows it when the group moves. The glue was dropped at
|
||||
/// once (the end stayed `None`) because rerouting only looked at the slide's top-level shapes,
|
||||
/// and the sites of a grouped shape were computed in the group's child space.
|
||||
#[test]
|
||||
fn shape_connect_glues_to_a_shape_inside_a_group() {
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 200, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||||
let c = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let glue = |s: &Session, id: u64| {
|
||||
let st = s.doc().unwrap();
|
||||
match st.shape(ShapeId(id as u32)).unwrap().kind {
|
||||
ShapeKind::Connector { start, end } => (start.map(|(i, _)| i.0 as u64), end.map(|(i, _)| i.0 as u64)),
|
||||
_ => (None, None),
|
||||
}
|
||||
};
|
||||
let near = |p: deckcraft_geom::Point, x: f64, y: f64| (p.x - x).abs() < 1e-6 && (p.y - y).abs() < 1e-6;
|
||||
// A new connector into the group.
|
||||
let r = s.execute("shape.connect", &json!({"from": c, "to": a})).unwrap();
|
||||
let k = r["id"].as_u64().unwrap();
|
||||
assert_eq!(glue(&s, k), (Some(c), Some(a)), "{r}");
|
||||
let (p0, p1) = line_ends(&s, k);
|
||||
assert!(near(p0, 400.0, 35.0), "from C's left site: {p0:?}");
|
||||
assert!(near(p1, 110.0, 35.0), "to A's right site in slide space: {p1:?}");
|
||||
s.execute("shape.move", &json!({"ids": [g], "dy": 100})).unwrap();
|
||||
assert_eq!(glue(&s, k), (Some(c), Some(a)), "the glue survives the move");
|
||||
let (_, p1) = line_ends(&s, k);
|
||||
assert!(near(p1, 110.0, 135.0), "the connector follows the grouped shape: {p1:?}");
|
||||
// Re-gluing an existing line with `id`.
|
||||
let l = s.execute("shape.insert", &json!({"preset": "straightConnector1", "rect": [600, 400, 50, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let r = s.execute("shape.connect", &json!({"id": l, "from": c, "to": b})).unwrap();
|
||||
assert_eq!(r["id"], l, "{r}");
|
||||
assert_eq!(glue(&s, l), (Some(c), Some(b)), "{r}");
|
||||
let (_, p1) = line_ends(&s, l);
|
||||
assert!(near(p1, 110.0, 325.0), "to B's right site (C is mostly to its right) in slide space, B moved with the group: {p1:?}");
|
||||
s.execute("shape.move", &json!({"ids": [g], "dx": 50})).unwrap();
|
||||
assert_eq!(glue(&s, l), (Some(c), Some(b)));
|
||||
let (_, p1) = line_ends(&s, l);
|
||||
assert!(near(p1, 160.0, 325.0), "{p1:?}");
|
||||
// shape.sites answers in slide space, as shape.connect uses them: A's right site, A moved
|
||||
// with the group.
|
||||
let v = s.execute("shape.sites", &json!({"id": a})).unwrap();
|
||||
assert!(
|
||||
v.as_array().unwrap().iter().any(|q| near(deckcraft_geom::Point::new(q[0].as_f64().unwrap(), q[1].as_f64().unwrap()), 160.0, 135.0)),
|
||||
"{v}"
|
||||
);
|
||||
// A line inside a group is refused as `id`: its ends would be written in slide space into
|
||||
// the group's space, and rerouting does not follow grouped connectors.
|
||||
let m = s.execute("shape.insert", &json!({"preset": "line", "rect": [600, 300, 50, 0]})).unwrap()["id"].as_u64().unwrap();
|
||||
let n = s.execute("shape.insert", &json!({"preset": "rect", "rect": [600, 450, 50, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("arrange.group", &json!({"ids": [m, n]})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let e = s.execute("shape.connect", &json!({"id": m, "from": c, "to": b})).unwrap_err().to_string();
|
||||
assert!(e.contains("inside a group"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
|
||||
// A grouped shape that is not a line is refused as not a line, not as a grouped line.
|
||||
let e = s.execute("shape.connect", &json!({"id": n, "from": c, "to": b})).unwrap_err().to_string();
|
||||
assert!(e.contains("not a line"), "{e}");
|
||||
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flip_is_refused_on_a_smartart_graphic_and_leaves_it_a_graphic() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("insert.textBox", &json!({"text": "Goals\nRevenue"})).unwrap();
|
||||
let gid = s.execute("smartArt.fromText", &json!({"kind": "blockList"})).unwrap()["id"].as_u64().unwrap();
|
||||
for cmd in ["arrange.flipHorizontal", "arrange.flipVertical", "shape.flip"] {
|
||||
let e = s.execute(cmd, &json!({"ids": [gid]})).unwrap_err().to_string();
|
||||
assert!(e.contains("SmartArt") && e.contains("smartArt.layout"), "{cmd}: {e}");
|
||||
}
|
||||
// Grouped with another shape it cannot be flipped either: ungrouping would hand it the flip.
|
||||
let other = s.execute("insert.textBox", &json!({"text": "x"})).unwrap()["id"].as_u64().unwrap();
|
||||
let group = s.execute("arrange.group", &json!({"ids": [gid, other]})).unwrap()["id"].as_u64().unwrap();
|
||||
let e = s.execute("shape.flip", &json!({"ids": [group]})).unwrap_err().to_string();
|
||||
assert!(e.contains("SmartArt"), "{e}");
|
||||
s.execute("arrange.ungroup", &json!({"ids": [group]})).unwrap();
|
||||
// Still a SmartArt graphic: smartArt commands keep working.
|
||||
s.execute("smartArt.layout", &json!({"id": gid, "kind": "cycle"})).unwrap();
|
||||
// Ordinary shapes still flip.
|
||||
let b = s.execute("insert.textBox", &json!({"text": "x"})).unwrap();
|
||||
let id = b["id"].as_u64().unwrap();
|
||||
assert_eq!(s.execute("arrange.flipHorizontal", &json!({"ids": [id]})).unwrap(), json!(1));
|
||||
}
|
||||
|
||||
/// Table cell commands refuse a `cells` that is not a list of [row, col] cells in the table,
|
||||
/// instead of dropping the entry or reading the list as absent (which acts on the selection).
|
||||
#[test]
|
||||
fn table_cell_commands_refuse_a_malformed_cells_list() {
|
||||
let mut s = session();
|
||||
let t = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
|
||||
let rev = s.doc().unwrap().revision;
|
||||
for cells in [
|
||||
json!([[-1, 0]]),
|
||||
json!([[0]]),
|
||||
json!([[0, 0], "x"]),
|
||||
json!([[0, 0, "x"]]),
|
||||
json!([[0, 2]]),
|
||||
json!([[0, u64::MAX]]),
|
||||
json!("0,0"),
|
||||
json!({"0": 0}),
|
||||
] {
|
||||
for (cmd, extra) in [
|
||||
("table.cellFill", json!({"color": "#FF0000"})),
|
||||
("table.cellMargins", json!({"left": 4})),
|
||||
("table.cellAlign", json!({"align": "center"})),
|
||||
("table.borders", json!({"which": "right"})),
|
||||
] {
|
||||
let mut p = extra.clone();
|
||||
p["id"] = json!(t);
|
||||
p["cells"] = cells.clone();
|
||||
let e = s.execute(cmd, &p).unwrap_err().to_string();
|
||||
assert!(e.contains(cmd) && e.contains("cell"), "{cmd} {cells}: {e}");
|
||||
}
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().revision, rev, "nothing changed");
|
||||
let many = json!(vec![[0, 0]; 100_001]);
|
||||
assert!(s.execute("table.cellFill", &json!({"id": t, "color": "#FF0000", "cells": many})).unwrap_err().to_string().contains("at most"));
|
||||
s.execute("table.cellFill", &json!({"id": t, "color": "#FF0000", "cells": [[1, 1]]})).unwrap();
|
||||
assert!(table_of(&s, t).cell(1, 1).unwrap().fill.is_some() && table_of(&s, t).cell(0, 0).unwrap().fill.is_none());
|
||||
}
|
||||
|
||||
/// The outline exports (PowerPoint's outline view) holds titles and body text only: footer,
|
||||
/// date and slide-number placeholders are not body lines.
|
||||
#[test]
|
||||
fn outline_export_skips_footer_date_and_slide_number() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap();
|
||||
s.execute(
|
||||
"design.headerFooter",
|
||||
&json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}),
|
||||
)
|
||||
.unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-outline-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("deck.md");
|
||||
s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "outline"})).unwrap();
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(text.contains("Agenda") && text.contains("First point") && text.contains("Second point"), "{text:?}");
|
||||
assert!(!text.contains("Company Confidential"), "footer text is not a body line: {text:?}");
|
||||
assert!(!text.contains("‹#›"), "the slide-number field is not a body line: {text:?}");
|
||||
assert!(!text.contains("1 May 2026"), "the date is not a body line: {text:?}");
|
||||
// The RTF outline (Save As ▸ Outline/RTF) too.
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("deck.rtf");
|
||||
s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "rtf"})).unwrap();
|
||||
let rtf = std::fs::read_to_string(&path).unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{rtf:?}");
|
||||
assert!(!rtf.contains("Company Confidential") && !rtf.contains("1 May 2026"), "{rtf:?}");
|
||||
}
|
||||
|
||||
@@ -236,7 +236,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);
|
||||
|
||||
@@ -227,6 +227,9 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// Most caret records read for one ligature glyph.
|
||||
const MAX_LIGATURE_CARETS: usize = 64;
|
||||
|
||||
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
|
||||
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
|
||||
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
@@ -236,9 +239,12 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
let Ok(cov) = list.coverage() else { return Vec::new() };
|
||||
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
|
||||
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
|
||||
// The limit applies to the records read, not to the carets kept: a font can list thousands of
|
||||
// records it then has to be read through for every ligature laid out.
|
||||
let mut out: Vec<i32> = lig
|
||||
.caret_values()
|
||||
.iter()
|
||||
.take(MAX_LIGATURE_CARETS)
|
||||
.filter_map(|cv| cv.ok())
|
||||
.filter_map(|cv| match cv {
|
||||
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
|
||||
@@ -246,7 +252,6 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
|
||||
// Format 2 is a contour point index; outlines aren't consulted here.
|
||||
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
|
||||
})
|
||||
.take(64)
|
||||
.collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
|
||||
+149
-20
@@ -368,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`]).
|
||||
@@ -439,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()))
|
||||
@@ -694,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();
|
||||
@@ -730,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)));
|
||||
@@ -905,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,
|
||||
@@ -1215,9 +1260,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(':')?;
|
||||
@@ -1227,6 +1271,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)),
|
||||
@@ -1513,41 +1562,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1575,6 +1655,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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
+89
-34
@@ -758,17 +758,20 @@ impl Chart {
|
||||
}
|
||||
|
||||
fn stacked_value_range(&self, n: usize) -> (f64, f64) {
|
||||
let mut lo: f64 = 0.0;
|
||||
let mut hi: f64 = 0.0;
|
||||
for i in 0..n {
|
||||
let (mut p, mut m) = (0.0, 0.0);
|
||||
for s in &self.series {
|
||||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0);
|
||||
if v >= 0.0 { p += v } else { m += v }
|
||||
// Sums per category, from the values the series hold (an absent one adds nothing).
|
||||
let (mut pos, mut neg) = (vec![0.0f64; n], vec![0.0f64; n]);
|
||||
for s in &self.series {
|
||||
for (i, v) in s.values.iter().enumerate().take(n) {
|
||||
let v = v.unwrap_or(0.0);
|
||||
if v >= 0.0 {
|
||||
pos[i] += v;
|
||||
} else {
|
||||
neg[i] += v;
|
||||
}
|
||||
}
|
||||
hi = hi.max(p);
|
||||
lo = lo.min(m);
|
||||
}
|
||||
let hi = pos.iter().fold(0.0f64, |a, &p| a.max(p));
|
||||
let lo = neg.iter().fold(0.0f64, |a, &m| a.min(m));
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
@@ -1348,40 +1351,50 @@ impl Chart {
|
||||
/// The box chart's groups (distinct category labels in order of appearance; one unnamed
|
||||
/// group without categories) and, per series, each group's summary (None when it has no
|
||||
/// points).
|
||||
///
|
||||
/// Each value is put in its group's bucket once (groups are found by name in a map), and the
|
||||
/// series x groups result is held to [`MAX_BOX_CELLS`]: groups past it are left out.
|
||||
pub fn box_groups(&self) -> (Vec<String>, Vec<Vec<Option<BoxStats>>>) {
|
||||
let max_groups = (MAX_BOX_CELLS / self.series.len().max(1)).max(1);
|
||||
let mut groups: Vec<String> = Vec::new();
|
||||
let mut index: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
|
||||
for c in &self.categories {
|
||||
if !groups.contains(c) {
|
||||
if !index.contains_key(c.as_str()) && groups.len() < max_groups {
|
||||
index.insert(c, groups.len());
|
||||
groups.push(c.clone());
|
||||
}
|
||||
}
|
||||
if groups.is_empty() {
|
||||
groups.push(String::new());
|
||||
index.insert("", 0);
|
||||
}
|
||||
// A value past the categories belongs to the unnamed group, when there is one.
|
||||
let past_end = index.get("").copied();
|
||||
let stats = self
|
||||
.series
|
||||
.iter()
|
||||
.map(|s| {
|
||||
groups
|
||||
.iter()
|
||||
.map(|g| {
|
||||
let pts: Vec<f64> = s
|
||||
.values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| self.categories.get(*i).map_or(g.is_empty() || self.categories.is_empty(), |c| c == g))
|
||||
.filter_map(|(_, v)| *v)
|
||||
.filter(|v| v.is_finite())
|
||||
.collect();
|
||||
box_stats_by(pts, self.inclusive_quartiles)
|
||||
})
|
||||
.collect()
|
||||
let mut buckets: Vec<Vec<f64>> = vec![Vec::new(); groups.len()];
|
||||
for (i, v) in s.values.iter().enumerate() {
|
||||
let Some(v) = v.filter(|v| v.is_finite()) else { continue };
|
||||
let g = match self.categories.get(i) {
|
||||
Some(c) => index.get(c.as_str()).copied(),
|
||||
None => past_end,
|
||||
};
|
||||
if let Some(b) = g.and_then(|g| buckets.get_mut(g)) {
|
||||
b.push(v);
|
||||
}
|
||||
}
|
||||
buckets.into_iter().map(|pts| box_stats_by(pts, self.inclusive_quartiles)).collect()
|
||||
})
|
||||
.collect();
|
||||
(groups, stats)
|
||||
}
|
||||
}
|
||||
|
||||
/// Most series x group summaries [`Chart::box_groups`] makes.
|
||||
pub const MAX_BOX_CELLS: usize = 1_000_000;
|
||||
|
||||
/// Widens the (min, max) extent `ext` to include `v`.
|
||||
fn widen(ext: &mut (f64, f64), v: f64) {
|
||||
ext.0 = ext.0.min(v);
|
||||
@@ -1454,33 +1467,41 @@ impl Chart {
|
||||
let n = self.categories.len().max(s.values.len());
|
||||
let levels: Vec<&Vec<String>> = self.category_levels.iter().take(MAX_LEVELS).collect();
|
||||
// Each leaf's path from the top level down, blanks filled from the row above.
|
||||
let mut last: Vec<String> = vec![String::new(); levels.len()];
|
||||
let paths: Vec<Vec<String>> = (0..n)
|
||||
// Labels are shared, not copied: an outer label given once stands for every leaf under it.
|
||||
use std::rc::Rc;
|
||||
let empty: Rc<str> = Rc::from("");
|
||||
let mut last: Vec<Rc<str>> = vec![empty.clone(); levels.len()];
|
||||
let paths: Vec<Vec<Rc<str>>> = (0..n)
|
||||
.map(|i| {
|
||||
let mut path = Vec::with_capacity(levels.len() + 1);
|
||||
for (d, lv) in levels.iter().enumerate().rev() {
|
||||
let l = lv.get(i).map(|x| x.trim()).unwrap_or("");
|
||||
if !l.is_empty() {
|
||||
last[d] = l.to_string();
|
||||
last[d] = Rc::from(l);
|
||||
// A new outer label starts fresh inner groups.
|
||||
for inner in last.iter_mut().take(d) {
|
||||
inner.clear();
|
||||
*inner = empty.clone();
|
||||
}
|
||||
}
|
||||
path.push(last[d].clone());
|
||||
}
|
||||
path.push(self.categories.get(i).cloned().unwrap_or_default());
|
||||
path.push(self.categories.get(i).map_or_else(|| empty.clone(), |c| Rc::from(c.as_str())));
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
let value = |i: usize| s.values.get(i).copied().flatten().filter(|v| v.is_finite()).unwrap_or(0.0).max(0.0);
|
||||
fn build(rows: &[(usize, &[String])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
|
||||
fn build(rows: &[(usize, &[std::rc::Rc<str>])], value: &dyn Fn(usize) -> f64) -> Vec<TreeNode> {
|
||||
let mut out: Vec<TreeNode> = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < rows.len() {
|
||||
let (idx, path) = rows[i];
|
||||
if path.len() <= 1 {
|
||||
out.push(TreeNode { name: path.first().cloned().unwrap_or_default(), value: value(idx), leaf: Some(idx), children: vec![] });
|
||||
out.push(TreeNode {
|
||||
name: path.first().map(|p| p.to_string()).unwrap_or_default(),
|
||||
value: value(idx),
|
||||
leaf: Some(idx),
|
||||
children: vec![],
|
||||
});
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -1489,14 +1510,14 @@ impl Chart {
|
||||
while j < rows.len() && rows[j].1.len() > 1 && &rows[j].1[0] == head {
|
||||
j += 1;
|
||||
}
|
||||
let sub: Vec<(usize, &[String])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
|
||||
let sub: Vec<(usize, &[std::rc::Rc<str>])> = rows[i..j].iter().map(|(k, p)| (*k, &p[1..])).collect();
|
||||
let children = build(&sub, value);
|
||||
out.push(TreeNode { name: head.clone(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
|
||||
out.push(TreeNode { name: head.to_string(), value: children.iter().map(|c| c.value).sum(), leaf: None, children });
|
||||
i = j;
|
||||
}
|
||||
out
|
||||
}
|
||||
let rows: Vec<(usize, &[String])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
|
||||
let rows: Vec<(usize, &[Rc<str>])> = paths.iter().enumerate().map(|(i, p)| (i, p.as_slice())).collect();
|
||||
build(&rows, &value)
|
||||
}
|
||||
}
|
||||
@@ -1607,6 +1628,40 @@ mod chartex_tests {
|
||||
assert!(c.category_tree().iter().all(|n| n.leaf.is_some()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_box_hierarchy_and_stacked_charts_take_linear_work() {
|
||||
// 50,000 distinct categories in one series: grouped through a map, one pass.
|
||||
let n = 50_000;
|
||||
let mut c = Chart::sample(ChartType::BoxWhisker);
|
||||
c.series.truncate(1);
|
||||
c.categories = (0..n).map(|i| format!("g{i}")).collect();
|
||||
c.series[0].values = (0..n).map(|i| Some(i as f64)).collect();
|
||||
let (groups, stats) = c.box_groups();
|
||||
assert_eq!((groups.len(), stats[0].len()), (n, n));
|
||||
assert!(stats[0].iter().all(Option::is_some));
|
||||
// 2,000 series cap the groups so the result stays within MAX_BOX_CELLS.
|
||||
c.series = vec![c.series[0].clone(); 2000];
|
||||
let (groups, stats) = c.box_groups();
|
||||
assert_eq!(groups.len(), MAX_BOX_CELLS / 2000);
|
||||
assert!(stats.iter().all(|s| s.len() == groups.len()));
|
||||
// One long outer label over 50,000 leaves is shared, not copied per leaf.
|
||||
let mut t = Chart::sample(ChartType::Treemap);
|
||||
t.series.truncate(1);
|
||||
t.categories = (0..n).map(|i| format!("c{i}")).collect();
|
||||
t.series[0].values = (0..n).map(|_| Some(1.0)).collect();
|
||||
let mut outer = vec![String::new(); n];
|
||||
outer[0] = "o".repeat(1 << 16);
|
||||
t.category_levels = vec![outer];
|
||||
let tree = t.category_tree();
|
||||
assert_eq!(tree.len(), 1);
|
||||
assert_eq!(tree[0].children.len(), n);
|
||||
// Stacked sums over many sparse series.
|
||||
let mut st = Chart::sample(ChartType::StackedBar);
|
||||
st.categories = (0..n).map(|i| i.to_string()).collect();
|
||||
st.series = (0..2000).map(|k| Series { values: vec![Some(1.0); if k == 0 { n } else { 1 }], ..Default::default() }).collect();
|
||||
assert!(st.value_range().1 >= 2000.0, "{:?}", st.value_range());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_groups_follow_category_labels() {
|
||||
let c = Chart::sample(ChartType::BoxWhisker);
|
||||
|
||||
+128
-21
@@ -78,45 +78,57 @@ pub fn insert(body: &mut TextBody, pos: Pos, text: &str, props: Option<RunProps>
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Paragraph::default());
|
||||
}
|
||||
let (mut pi, mut ch) = clamp(body, pos);
|
||||
let props = props.unwrap_or_else(|| props_at(body, (pi, ch)));
|
||||
let (pi0, mut ch) = clamp(body, pos);
|
||||
let props = props.unwrap_or_else(|| props_at(body, (pi0, ch)));
|
||||
let text = text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
// Work on the paragraph being edited apart from the body, so each new paragraph is pushed to a
|
||||
// list and the body's vector is spliced once instead of shifted for every `\n`.
|
||||
let Some(slot) = body.paragraphs.get_mut(pi0) else { return (pi0, ch) };
|
||||
let mut cur = std::mem::take(slot);
|
||||
let mut done: Vec<Paragraph> = Vec::new();
|
||||
let mut first = true;
|
||||
for part in text.split('\n') {
|
||||
if !first {
|
||||
// Split paragraph pi at ch.
|
||||
let Some(p) = body.paragraphs.get_mut(pi) else { break };
|
||||
let idx = split_at(p, ch);
|
||||
let tail: Vec<Run> = p.runs.drain(idx..).collect();
|
||||
let newp =
|
||||
Paragraph { level: p.level, props: p.props.clone(), runs: tail, def_props: p.def_props.clone(), end_props: p.end_props.clone() };
|
||||
p.end_props = props.clone();
|
||||
body.paragraphs.insert(pi + 1, newp);
|
||||
pi += 1;
|
||||
// Split the current paragraph at ch.
|
||||
let idx = split_at(&mut cur, ch);
|
||||
let tail: Vec<Run> = cur.runs.drain(idx..).collect();
|
||||
let newp = Paragraph {
|
||||
level: cur.level,
|
||||
props: cur.props.clone(),
|
||||
runs: tail,
|
||||
def_props: cur.def_props.clone(),
|
||||
end_props: cur.end_props.clone(),
|
||||
};
|
||||
cur.end_props = props.clone();
|
||||
done.push(std::mem::replace(&mut cur, newp));
|
||||
ch = 0;
|
||||
}
|
||||
first = false;
|
||||
// The run index just after the last insertion: the next one lands there with no rescan.
|
||||
let mut next: Option<usize> = None;
|
||||
for (k, seg) in part.split('\u{b}').enumerate() {
|
||||
let Some(p) = body.paragraphs.get_mut(pi) else { break };
|
||||
if k > 0 {
|
||||
let idx = split_at(p, ch);
|
||||
p.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
|
||||
let idx = next.unwrap_or_else(|| split_at(&mut cur, ch));
|
||||
cur.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
|
||||
ch += 1;
|
||||
next = Some(idx + 1);
|
||||
}
|
||||
if seg.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let idx = split_at(p, ch);
|
||||
p.runs.insert(idx, Run::with(seg, props.clone()));
|
||||
let idx = next.unwrap_or_else(|| split_at(&mut cur, ch));
|
||||
cur.runs.insert(idx, Run::with(seg, props.clone()));
|
||||
ch += seg.chars().count();
|
||||
next = Some(idx + 1);
|
||||
}
|
||||
if let Some(p) = body.paragraphs.get_mut(pi) {
|
||||
p.normalize();
|
||||
if p.runs.is_empty() {
|
||||
p.end_props = props.clone();
|
||||
}
|
||||
cur.normalize();
|
||||
if cur.runs.is_empty() {
|
||||
cur.end_props = props.clone();
|
||||
}
|
||||
}
|
||||
let pi = pi0 + done.len();
|
||||
done.push(cur);
|
||||
body.paragraphs.splice(pi0..=pi0, done);
|
||||
(pi, ch)
|
||||
}
|
||||
|
||||
@@ -252,8 +264,16 @@ pub fn slice(body: &TextBody, a: Pos, b: Pos) -> TextBody {
|
||||
/// Insert a formatted body at `pos` (paste): first paragraph merges into the current one.
|
||||
pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
let mut caret = clamp(body, pos);
|
||||
// Plain text runs go straight in at the run index after the previous one and the paragraph is
|
||||
// normalized once, not rescanned and merged for every source run.
|
||||
let mut next: Option<usize> = None;
|
||||
let mut dirty = false;
|
||||
for (k, sp) in src.paragraphs.iter().enumerate() {
|
||||
if k > 0 {
|
||||
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
|
||||
p.normalize();
|
||||
}
|
||||
(next, dirty) = (None, false);
|
||||
caret = insert(body, caret, "\n", None);
|
||||
if let Some(p) = body.paragraphs.get_mut(caret.0) {
|
||||
p.level = sp.level;
|
||||
@@ -261,6 +281,22 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
}
|
||||
}
|
||||
for r in &sp.runs {
|
||||
if r.kind == RunKind::Text
|
||||
&& !r.text.is_empty()
|
||||
&& !r.text.contains(['\n', '\r', '\u{b}'])
|
||||
&& let Some(p) = body.paragraphs.get_mut(caret.0)
|
||||
{
|
||||
let idx = next.unwrap_or_else(|| split_at(p, caret.1));
|
||||
p.runs.insert(idx, Run::with(&r.text, r.props.clone()));
|
||||
caret.1 += r.text.chars().count();
|
||||
(next, dirty) = (Some(idx + 1), true);
|
||||
continue;
|
||||
}
|
||||
// Anything else takes the general path, after the pending runs are merged.
|
||||
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
|
||||
p.normalize();
|
||||
}
|
||||
(next, dirty) = (None, false);
|
||||
match r.kind {
|
||||
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
|
||||
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
|
||||
@@ -274,6 +310,9 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
|
||||
p.normalize();
|
||||
}
|
||||
caret
|
||||
}
|
||||
|
||||
@@ -568,6 +607,74 @@ mod tests {
|
||||
assert_eq!(caret, (0, 5));
|
||||
}
|
||||
|
||||
/// The paste as it ran before the fast path: one `insert` (and normalize) per source run.
|
||||
fn insert_body_by_runs(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
|
||||
let mut caret = clamp(body, pos);
|
||||
for (k, sp) in src.paragraphs.iter().enumerate() {
|
||||
if k > 0 {
|
||||
caret = insert(body, caret, "\n", None);
|
||||
if let Some(p) = body.paragraphs.get_mut(caret.0) {
|
||||
p.level = sp.level;
|
||||
p.props = sp.props.clone();
|
||||
}
|
||||
}
|
||||
for r in &sp.runs {
|
||||
match r.kind {
|
||||
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
|
||||
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
|
||||
_ => {
|
||||
if let Some(p) = body.paragraphs.get_mut(caret.0) {
|
||||
let idx = split_at(p, caret.1);
|
||||
p.runs.insert(idx, r.clone());
|
||||
caret.1 += r.char_len();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
caret
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paste_matches_inserting_run_by_run() {
|
||||
let bold = |t: &str| Run::with(t, RunProps { bold: Some(true), ..Default::default() });
|
||||
let mut src = body("");
|
||||
src.paragraphs = vec![
|
||||
Paragraph {
|
||||
runs: vec![Run::with("ab", RunProps::default()), Run::with("cd", RunProps::default()), bold("ef"), bold("g")],
|
||||
..Default::default()
|
||||
},
|
||||
Paragraph {
|
||||
runs: vec![
|
||||
bold("h"),
|
||||
Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break },
|
||||
Run::with("i", RunProps::default()),
|
||||
],
|
||||
level: 1,
|
||||
..Default::default()
|
||||
},
|
||||
Paragraph { runs: vec![Run::with("jk", RunProps::default())], ..Default::default() },
|
||||
];
|
||||
for at in [0, 1, 3, 6] {
|
||||
let mut dest = body("XYZabc");
|
||||
format(&mut dest, (0, 2), (0, 4), &|p| p.italic = Some(true));
|
||||
let mut want = dest.clone();
|
||||
let want_caret = insert_body_by_runs(&mut want, (0, at), &src);
|
||||
let caret = insert_body(&mut dest, (0, at), &src);
|
||||
assert_eq!(caret, want_caret, "caret at {at}");
|
||||
assert_eq!(format!("{:?}", dest.paragraphs), format!("{:?}", want.paragraphs), "paste at {at}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiline_insert_splits_into_paragraphs() {
|
||||
let mut b = body("abcd");
|
||||
let c = insert(&mut b, (0, 2), "x\ny\u{b}z\n\nw", None);
|
||||
assert_eq!(c, (3, 1));
|
||||
let texts: Vec<String> = b.paragraphs.iter().map(Paragraph::text).collect();
|
||||
assert_eq!(texts, ["abx", "y\u{b}z", "", "wcd"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_and_moves() {
|
||||
let b = body("Hello brave world");
|
||||
|
||||
@@ -1112,11 +1112,13 @@ impl Presentation {
|
||||
pub fn validate(&self) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
// Indexed once: a per-slide scan of every layout is slides × layouts work.
|
||||
let layouts: std::collections::HashSet<_> = self.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
|
||||
for s in &self.slides {
|
||||
if !seen.insert(s.id) {
|
||||
out.push(format!("duplicate slide id {}", s.id));
|
||||
}
|
||||
if self.layout(s.layout).is_none() {
|
||||
if !layouts.contains(&s.layout) {
|
||||
out.push(format!("slide {} uses missing layout {}", s.id, s.layout));
|
||||
}
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
|
||||
@@ -143,9 +143,14 @@ fn group(digits: &str, sep: char) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Longest number format applied, as in Excel (255 characters); a longer one formats as
|
||||
/// General, so a chart cannot make every label expand a huge format.
|
||||
pub const MAX_CODE_LEN: usize = 255;
|
||||
|
||||
/// `v` formatted with the Excel number format `code` in language `lang` (axis labels: the text
|
||||
/// section of a format doesn't apply).
|
||||
pub fn format(code: &str, v: f64, lang: Option<&str>) -> String {
|
||||
let code = if code.len() > MAX_CODE_LEN * 4 || code.chars().count() > MAX_CODE_LEN { "General" } else { code };
|
||||
let (sep, dec) = marks(lang);
|
||||
if !v.is_finite() {
|
||||
return String::new();
|
||||
@@ -299,6 +304,14 @@ mod tests {
|
||||
|
||||
const RUB: &str = r#"_-* #\ ##0\ "₽"_-;\-* #\ ##0\ "₽"_-;_-* "-"??\ "₽"_-;_-@_-"#;
|
||||
|
||||
#[test]
|
||||
fn an_overlong_format_is_general() {
|
||||
let code = "0".repeat(100_000);
|
||||
assert_eq!(format(&code, 12.5, None), format("General", 12.5, None));
|
||||
let ok = "0".repeat(MAX_CODE_LEN);
|
||||
assert_eq!(format(&ok, 7.0, None).len(), MAX_CODE_LEN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scientific_subnormal() {
|
||||
assert_eq!(format("0.00E+00", 5e-324, None), "4.94E-324");
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use deckcraft_color::{ColorScheme, Rgba, SchemeSlot};
|
||||
use deckcraft_geom::Xfrm;
|
||||
|
||||
use crate::style::{ColorRef, Effects, Fill, Line};
|
||||
use crate::style::{ColorRef, Effects, Fill, Line, ShapeStyle};
|
||||
use crate::text::{Anchor, AutoFit, BodyProps, LevelStyle, ListStyle, ParaProps, RunProps};
|
||||
use crate::{Background, Layout, Master, PhType, Presentation, Shape, Slide};
|
||||
|
||||
@@ -218,6 +218,68 @@ pub fn effects(ctx: &Ctx, shape: &Shape) -> (Option<Effects>, Option<Rgba>) {
|
||||
(None, None)
|
||||
}
|
||||
|
||||
/// Turn a placeholder into a regular shape that looks the same: what it inherited from its layout
|
||||
/// and master placeholders (position, fill, line, effects, style, body properties and the text
|
||||
/// style of every outline level, the master's title or body style included) becomes its own, and
|
||||
/// `ph` is cleared. A shape that is not a placeholder is left as it is.
|
||||
///
|
||||
/// Theme references (`+mj-lt`, scheme colours) stay references, so the copy still follows the
|
||||
/// destination theme, as PowerPoint's default paste does.
|
||||
pub fn detach_placeholder(ctx: &Ctx, shape: &mut Shape) {
|
||||
if shape.ph.is_none() {
|
||||
return;
|
||||
}
|
||||
// The text first: `level_style` reads the shape's own style, which changes below.
|
||||
let text = shape.text.as_ref().map(|_| (body(ctx, shape), (0..9).map(|l| Some(level_style(ctx, shape, l))).collect::<Vec<_>>()));
|
||||
let own = shape.clone();
|
||||
let (lp, mp) = parents(ctx, &own);
|
||||
let chain = [Some(&own), lp, mp].into_iter().flatten();
|
||||
|
||||
// Each property comes from where `fill`, `line` and `effects` take it: the first shape in the
|
||||
// chain with an explicit value or a style. Lines merge explicit values up to the first styled
|
||||
// shape, whose style line fills the rest.
|
||||
let fill_src = chain.clone().find(|s| s.fill.is_some() || s.style.is_some());
|
||||
let effects_src = chain.clone().find(|s| s.effects.is_some() || s.style.is_some());
|
||||
let mut line = None;
|
||||
let mut line_src = None;
|
||||
for s in chain.clone() {
|
||||
if let Some(l) = &s.line {
|
||||
line.get_or_insert_with(Line::default).inherit(l);
|
||||
}
|
||||
if s.style.is_some() {
|
||||
line_src = Some(s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Text takes its font reference from the shape's style or its layout placeholder's (a table's
|
||||
// from its own only), never the master's.
|
||||
let table = matches!(own.kind, crate::ShapeKind::Table(_));
|
||||
let font_src = own.style.as_ref().or(lp.filter(|_| !table).and_then(|s| s.style.as_ref()));
|
||||
|
||||
// One style for the copy, each reference taken from the shape that supplied that property. A
|
||||
// property with an explicit value ignores its reference, except a gradient without stops,
|
||||
// which takes them from its own shape's style; a neutral reference (index 0) adds nothing.
|
||||
let style_of = |s: Option<&Shape>| s.and_then(|s| s.style.clone());
|
||||
let neutral = (0, ColorRef::scheme(SchemeSlot::Tx1));
|
||||
let style = chain.clone().any(|s| s.style.is_some()).then(|| ShapeStyle {
|
||||
fill_ref: style_of(fill_src).map_or_else(|| neutral.clone(), |st| st.fill_ref),
|
||||
line_ref: style_of(line_src).map_or_else(|| neutral.clone(), |st| st.line_ref),
|
||||
effect_ref: style_of(effects_src).map_or_else(|| neutral.clone(), |st| st.effect_ref),
|
||||
font_ref: font_src.map_or_else(|| ("none".into(), None), |st| st.font_ref.clone()),
|
||||
});
|
||||
|
||||
shape.xfrm = Some(xfrm(ctx, &own));
|
||||
shape.fill = fill_src.and_then(|s| s.fill.clone());
|
||||
shape.effects = effects_src.and_then(|s| s.effects.clone());
|
||||
shape.line = line;
|
||||
shape.style = style;
|
||||
if let (Some(t), Some((body, levels))) = (shape.text.as_mut(), text) {
|
||||
t.body = body;
|
||||
t.list_style = ListStyle { levels };
|
||||
}
|
||||
shape.ph = None;
|
||||
}
|
||||
|
||||
/// Body properties with defaults filled in.
|
||||
pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
|
||||
let (lp, mp) = parents(ctx, shape);
|
||||
@@ -249,6 +311,9 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
|
||||
rtl_col: Some(false),
|
||||
upright: Some(false),
|
||||
space_first_last: Some(false),
|
||||
compat_ln_spc: None,
|
||||
vert_overflow: None,
|
||||
horz_overflow: None,
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
@@ -459,13 +459,44 @@ pub struct Glow {
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct Reflection {
|
||||
pub blur: f64,
|
||||
pub start_alpha: f64,
|
||||
pub end_alpha: f64,
|
||||
pub end_pos: f64,
|
||||
pub dist: f64,
|
||||
/// DrawingML reflection transform values retain their native integer units.
|
||||
pub st_pos: Option<i32>,
|
||||
pub dir: Option<i32>,
|
||||
pub fade_dir: Option<i32>,
|
||||
pub sx: Option<i32>,
|
||||
pub sy: Option<i32>,
|
||||
pub kx: Option<i32>,
|
||||
pub ky: Option<i32>,
|
||||
pub align: Option<String>,
|
||||
pub rotate_with_shape: Option<bool>,
|
||||
}
|
||||
|
||||
impl Default for Reflection {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
blur: 0.0,
|
||||
start_alpha: 0.0,
|
||||
end_alpha: 0.0,
|
||||
end_pos: 0.0,
|
||||
dist: 0.0,
|
||||
st_pos: None,
|
||||
dir: Some(5_400_000),
|
||||
fade_dir: None,
|
||||
sx: None,
|
||||
sy: Some(-100_000),
|
||||
kx: None,
|
||||
ky: None,
|
||||
align: Some("bl".into()),
|
||||
rotate_with_shape: Some(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
|
||||
@@ -23,6 +23,8 @@ pub struct Cell {
|
||||
pub h_merge: bool,
|
||||
pub v_merge: bool,
|
||||
pub vertical: Option<crate::text::TextDir>,
|
||||
pub anchor_ctr: Option<bool>,
|
||||
pub horz_overflow: Option<crate::text::TextOverflow>,
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
@@ -55,6 +57,9 @@ pub struct Table {
|
||||
pub last_col: bool,
|
||||
pub band_row: bool,
|
||||
pub band_col: bool,
|
||||
/// Whether columns are ordered right-to-left (`a:tblPr/@rtl`).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rtl: Option<bool>,
|
||||
/// `a:tblPr` fill: a fill behind the whole table (over the style's own background).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub background: Option<Fill>,
|
||||
@@ -72,6 +77,7 @@ impl Default for Table {
|
||||
last_col: false,
|
||||
band_row: true,
|
||||
band_col: false,
|
||||
rtl: None,
|
||||
background: None,
|
||||
}
|
||||
}
|
||||
|
||||
+108
-5
@@ -121,6 +121,46 @@ pub struct BodyProps {
|
||||
pub upright: Option<bool>,
|
||||
/// Space before the first paragraph and after the last count too (`spcFirstLastPara`).
|
||||
pub space_first_last: Option<bool>,
|
||||
pub compat_ln_spc: Option<bool>,
|
||||
pub vert_overflow: Option<TextOverflow>,
|
||||
pub horz_overflow: Option<TextOverflow>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum TextOverflow {
|
||||
Overflow,
|
||||
Clip,
|
||||
Ellipsis,
|
||||
}
|
||||
|
||||
impl TextOverflow {
|
||||
pub fn xml(self) -> &'static str {
|
||||
match self {
|
||||
Self::Overflow => "overflow",
|
||||
Self::Clip => "clip",
|
||||
Self::Ellipsis => "ellipsis",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_xml(value: &str) -> Option<Self> {
|
||||
Some(match value {
|
||||
"overflow" => Self::Overflow,
|
||||
"clip" => Self::Clip,
|
||||
"ellipsis" => Self::Ellipsis,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A `horzOverflow` value: `ST_TextHorzOverflowType` has only `overflow` and `clip`.
|
||||
pub fn from_horz_xml(value: &str) -> Option<Self> {
|
||||
Self::from_xml(value).filter(|v| *v != Self::Ellipsis)
|
||||
}
|
||||
|
||||
/// The `horzOverflow` attribute to write, leaving out `ellipsis`, which is vertical only.
|
||||
pub fn horz_xml(v: Option<Self>) -> Option<&'static str> {
|
||||
v.filter(|v| *v != Self::Ellipsis).map(Self::xml)
|
||||
}
|
||||
}
|
||||
|
||||
impl BodyProps {
|
||||
@@ -144,6 +184,9 @@ impl BodyProps {
|
||||
self.rtl_col = self.rtl_col.or(p.rtl_col);
|
||||
self.upright = self.upright.or(p.upright);
|
||||
self.space_first_last = self.space_first_last.or(p.space_first_last);
|
||||
self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc);
|
||||
self.vert_overflow = self.vert_overflow.or(p.vert_overflow);
|
||||
self.horz_overflow = self.horz_overflow.or(p.horz_overflow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +200,7 @@ pub enum Align {
|
||||
/// Justified with kashida (tatweel) elongation for Arabic (`justLow`).
|
||||
JustLow,
|
||||
Distributed,
|
||||
ThaiDistributed,
|
||||
}
|
||||
|
||||
impl Align {
|
||||
@@ -168,6 +212,7 @@ impl Align {
|
||||
Align::Justify => "just",
|
||||
Align::JustLow => "justLow",
|
||||
Align::Distributed => "dist",
|
||||
Align::ThaiDistributed => "thaiDist",
|
||||
}
|
||||
}
|
||||
pub fn from_xml(s: &str) -> Option<Self> {
|
||||
@@ -175,7 +220,8 @@ impl Align {
|
||||
"l" => Align::Left,
|
||||
"ctr" => Align::Center,
|
||||
"r" => Align::Right,
|
||||
"just" | "thaiDist" => Align::Justify,
|
||||
"just" => Align::Justify,
|
||||
"thaiDist" => Align::ThaiDistributed,
|
||||
"justLow" => Align::JustLow,
|
||||
"dist" => Align::Distributed,
|
||||
_ => return None,
|
||||
@@ -242,6 +288,8 @@ pub struct ParaProps {
|
||||
pub bullet_color_follow_text: Option<bool>,
|
||||
/// Bullet size as a fraction of the text size.
|
||||
pub bullet_size: Option<f64>,
|
||||
/// Absolute bullet font size in points, independent of the paragraph's run size.
|
||||
pub bullet_size_points: Option<f64>,
|
||||
pub bullet_size_follow_text: Option<bool>,
|
||||
pub tabs: Option<Vec<TabStop>>,
|
||||
pub default_tab: Option<f64>,
|
||||
@@ -286,11 +334,13 @@ impl ParaProps {
|
||||
self.bullet_color.clone_from(&p.bullet_color);
|
||||
}
|
||||
}
|
||||
if self.bullet_size_follow_text == Some(false) || self.bullet_size.is_some() {
|
||||
if self.bullet_size_follow_text == Some(false) || self.bullet_size.is_some() || self.bullet_size_points.is_some() {
|
||||
self.bullet_size_follow_text = None;
|
||||
}
|
||||
if self.bullet_size.is_none() && self.bullet_size_follow_text.is_none() {
|
||||
if p.bullet_size_follow_text == Some(true) {
|
||||
if self.bullet_size.is_none() && self.bullet_size_points.is_none() && self.bullet_size_follow_text.is_none() {
|
||||
if p.bullet_size_points.is_some() {
|
||||
self.bullet_size_points = p.bullet_size_points;
|
||||
} else if p.bullet_size_follow_text == Some(true) {
|
||||
self.bullet_size_follow_text = Some(true);
|
||||
} else {
|
||||
self.bullet_size = p.bullet_size;
|
||||
@@ -310,6 +360,33 @@ impl ParaProps {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod bullet_size_tests {
|
||||
use super::ParaProps;
|
||||
|
||||
#[test]
|
||||
fn bullet_size_inheritance_keeps_percentage_and_points_as_one_mode() {
|
||||
let parent = ParaProps { bullet_size_points: Some(12.0), ..Default::default() };
|
||||
let mut child = ParaProps { bullet_size: Some(2.0), ..Default::default() };
|
||||
child.inherit(&parent);
|
||||
assert_eq!(child.bullet_size, Some(2.0));
|
||||
assert_eq!(child.bullet_size_points, None);
|
||||
|
||||
let parent = ParaProps { bullet_size: Some(1.5), ..Default::default() };
|
||||
let mut child = ParaProps { bullet_size_points: Some(14.0), ..Default::default() };
|
||||
child.inherit(&parent);
|
||||
assert_eq!(child.bullet_size, None);
|
||||
assert_eq!(child.bullet_size_points, Some(14.0));
|
||||
|
||||
let parent = ParaProps { bullet_size_points: Some(12.0), ..Default::default() };
|
||||
let mut child = ParaProps { bullet_size_follow_text: Some(true), ..Default::default() };
|
||||
child.inherit(&parent);
|
||||
assert_eq!(child.bullet_size, None);
|
||||
assert_eq!(child.bullet_size_points, None);
|
||||
assert_eq!(child.bullet_size_follow_text, Some(true));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Caps {
|
||||
@@ -425,6 +502,8 @@ pub struct RunProps {
|
||||
/// Action on mouse over (Insert ▸ Action ▸ Mouse Over on selected text, `a:hlinkMouseOver`).
|
||||
pub hover: Option<Hyperlink>,
|
||||
pub lang: Option<String>,
|
||||
#[serde(deserialize_with = "bounded_language")]
|
||||
pub alt_lang: Option<String>,
|
||||
pub shadow: Option<crate::style::Shadow>,
|
||||
/// `a:innerShdw` on the run. Outer shadow stays in `shadow`.
|
||||
pub inner_shadow: Option<crate::style::Shadow>,
|
||||
@@ -434,6 +513,14 @@ pub struct RunProps {
|
||||
pub soft_edge: Option<f64>,
|
||||
/// Spelling/grammar errors are not flagged.
|
||||
pub no_proof: Option<bool>,
|
||||
pub normalize_height: Option<bool>,
|
||||
}
|
||||
|
||||
fn bounded_language<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<String>::deserialize(deserializer).map(|lang| lang.map(|value| value.chars().take(256).collect()))
|
||||
}
|
||||
|
||||
impl RunProps {
|
||||
@@ -462,12 +549,14 @@ impl RunProps {
|
||||
link,
|
||||
hover,
|
||||
lang,
|
||||
alt_lang,
|
||||
shadow,
|
||||
inner_shadow,
|
||||
glow,
|
||||
reflection,
|
||||
soft_edge,
|
||||
no_proof
|
||||
no_proof,
|
||||
normalize_height
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -664,6 +753,20 @@ mod tests {
|
||||
use super::*;
|
||||
use deckcraft_color::Rgba;
|
||||
|
||||
#[test]
|
||||
fn alternate_language_inherits_and_native_input_is_bounded() {
|
||||
let mut child = RunProps::default();
|
||||
child.inherit(&RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() });
|
||||
assert_eq!(child.lang.as_deref(), Some("en-US"));
|
||||
assert_eq!(child.alt_lang.as_deref(), Some("fr-FR"));
|
||||
|
||||
let oversized = "x".repeat(300);
|
||||
let json = serde_json::json!({"altLang": oversized});
|
||||
let props: RunProps = serde_json::from_value(json).expect("run props");
|
||||
assert_eq!(props.alt_lang.as_deref().map(str::len), Some(256));
|
||||
assert_eq!(serde_json::from_value::<RunProps>(serde_json::json!({})).expect("old run props"), RunProps::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_bullet_follow_text_resets_block_inheritance() {
|
||||
let parent = ParaProps {
|
||||
|
||||
@@ -415,6 +415,11 @@ impl CaptureScanner {
|
||||
}
|
||||
|
||||
fn read_parts(src: &(impl ByteSource + ?Sized), parts: &[(u64, u32)]) -> Result<Vec<u8>> {
|
||||
// Ranges come from a scan of some source; check them against this one before sizing a buffer.
|
||||
let len = src.len();
|
||||
if parts.iter().any(|&(o, n)| o.checked_add(u64::from(n)).is_none_or(|end| end > len)) {
|
||||
return Err(Error::Invalid("packet range lies outside the source".into()));
|
||||
}
|
||||
let mut v = Vec::with_capacity(parts.iter().map(|p| p.1 as usize).sum());
|
||||
for &(o, n) in parts {
|
||||
let a = v.len();
|
||||
@@ -508,6 +513,7 @@ impl OpusTiming {
|
||||
c = timing_add(c, i64::from(durations[k]))?;
|
||||
ends[k] = Some(c);
|
||||
}
|
||||
cursor = Some(c);
|
||||
break;
|
||||
}
|
||||
let Some(g) = stream.packets[j].granule else { break };
|
||||
|
||||
@@ -183,6 +183,27 @@ fn opus_toc_durations() {
|
||||
assert_eq!(opus_packet_samples([0xF8, 0], 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_timing_includes_packets_after_the_last_known_granule() {
|
||||
let f = opus_file(6, 312, 0);
|
||||
let o = open(&f).unwrap();
|
||||
let mut stream = o.streams[0].clone();
|
||||
// Missing granules on complete-packet pages are non-conforming. The existing recovery
|
||||
// fallback still assigns their packet positions; total must include the same samples.
|
||||
stream.packets.last_mut().unwrap().granule = None;
|
||||
let timing = OpusTiming::of(&stream, 312).unwrap();
|
||||
assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]);
|
||||
assert_eq!(timing.total, 6 * 960 - 312);
|
||||
|
||||
// With no usable granule, every complete packet still contributes its TOC duration.
|
||||
for packet in &mut stream.packets {
|
||||
packet.granule = None;
|
||||
}
|
||||
let timing = OpusTiming::of(&stream, 312).unwrap();
|
||||
assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]);
|
||||
assert_eq!(timing.total, 6 * 960 - 312);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opus_timing_rejects_a_granule_that_overflows() {
|
||||
let packet = |granule: i64| Stream {
|
||||
@@ -274,3 +295,13 @@ fn a_budget_too_small_to_search_is_spent() {
|
||||
let f = opus_file(6, 312, 0);
|
||||
assert_eq!(scanner.find(&f, 0, f.len() as u64).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_packet_checks_its_ranges_against_the_source_before_allocating() {
|
||||
let f = opus_file(10, 312, 500);
|
||||
let o = open(&f).unwrap();
|
||||
let s = o.stream_of(Codec::Opus).unwrap();
|
||||
// The last packet lies past the end of a source cut short: an error, not a read of missing bytes.
|
||||
let short = &f[..f.len() / 2];
|
||||
assert!(matches!(o.read_packet(&short, s, 9), Err(Error::Invalid(_))));
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape
|
||||
.body
|
||||
.paragraphs
|
||||
.get(run.para)
|
||||
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
|
||||
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.skip(run.chars.0)
|
||||
@@ -522,7 +522,8 @@ impl Exporter<'_> {
|
||||
let k = b.width() / sw;
|
||||
// Raster at the requested dpi relative to the box size on paper.
|
||||
let scale = raster_scale(b.width(), (sw, sh), self.opts.dpi);
|
||||
let ropts = RenderOpts { scale, threads: 4, ..Default::default() };
|
||||
// One clock reading for the raster and the text layer, so a date field cannot differ between them.
|
||||
let ropts = RenderOpts { scale, threads: 4, now: deckcraft_text::datetime::DateTime::now_local(), ..Default::default() };
|
||||
// Two rasters per slide: the backdrop (background, master and layout graphics) is the same
|
||||
// on every slide that shares its layout, so the writer stores it once, and the slide's own
|
||||
// shapes draw over it (#845).
|
||||
@@ -563,7 +564,7 @@ impl Exporter<'_> {
|
||||
}
|
||||
if self.opts.text_layer {
|
||||
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
|
||||
for pt in deckcraft_render::place_slide(pres, i).texts {
|
||||
for pt in deckcraft_render::place_slide_with(pres, i, &ropts).texts {
|
||||
self.text_layer(s, &pt, view, false)?;
|
||||
}
|
||||
let _ = sh;
|
||||
@@ -581,7 +582,7 @@ impl Exporter<'_> {
|
||||
.body
|
||||
.paragraphs
|
||||
.get(run.para)
|
||||
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
|
||||
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.skip(run.chars.0)
|
||||
|
||||
+56
-5
@@ -269,8 +269,6 @@ fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::{Dict, Object, Stream};
|
||||
|
||||
let text = "Café Ελληνικά Привет";
|
||||
let face = FontDb::global().face("Ubuntu", "Regular");
|
||||
@@ -292,8 +290,17 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
|
||||
assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0)));
|
||||
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let page = &pdf.pages()[0];
|
||||
assert_eq!(page_text(&bytes, 0), text);
|
||||
}
|
||||
|
||||
/// The text of page `index`, read the way a PDF reader does: the content stream's text-showing
|
||||
/// operators decoded through the page font's ToUnicode map.
|
||||
fn page_text(bytes: &[u8], index: usize) -> String {
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::{Dict, Object, Stream};
|
||||
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
|
||||
let page = &pdf.pages()[index];
|
||||
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
|
||||
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
|
||||
assert_eq!(fonts.len(), 1);
|
||||
@@ -344,7 +351,51 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(extracted, text);
|
||||
extracted
|
||||
}
|
||||
|
||||
fn ubuntu_runs(runs: Vec<deckcraft_model::text::Run>) -> TextBody {
|
||||
let paragraph = deckcraft_model::text::Paragraph { runs, ..Default::default() };
|
||||
let mut body = TextBody { paragraphs: vec![paragraph], ..Default::default() };
|
||||
for run in &mut body.paragraphs[0].runs {
|
||||
run.props.font = Some("Ubuntu".into());
|
||||
run.props.size = Some(20.0);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn deck_with_body(n: usize, body: TextBody) -> Presentation {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
let mut p = deck(n);
|
||||
for slide in &mut p.slides {
|
||||
Arc::make_mut(slide).shapes =
|
||||
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body.clone()), ..Default::default() }];
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn slide_number_field_is_recovered_from_the_page_text_layer() {
|
||||
use deckcraft_model::text::{Run, RunKind};
|
||||
let mut field = Run::new("<#>");
|
||||
field.kind = RunKind::Field { field: "slidenum".into() };
|
||||
let p = deck_with_body(3, ubuntu_runs(vec![Run::new("Page "), field]));
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
for (i, want) in ["Page 1", "Page 2", "Page 3"].into_iter().enumerate() {
|
||||
assert_eq!(page_text(&bytes, i), want);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn line_break_keeps_the_text_layer_aligned_with_the_layout() {
|
||||
use deckcraft_model::text::{Run, RunKind};
|
||||
let mut brk = Run::new("");
|
||||
brk.kind = RunKind::Break;
|
||||
let p = deck_with_body(1, ubuntu_runs(vec![Run::new("one"), brk, Run::new("two")]));
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
assert_eq!(page_text(&bytes, 0), "onetwo");
|
||||
}
|
||||
|
||||
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
|
||||
|
||||
@@ -175,3 +175,43 @@ mod import_theme_tests {
|
||||
assert!(crate::import_theme(&out.finish().unwrap().into_inner()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod table_direction_tests {
|
||||
use deckcraft_model::table::{Cell, Table};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, defaults};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn table(p: &Presentation) -> &Table {
|
||||
p.slides[0]
|
||||
.shapes
|
||||
.iter()
|
||||
.find_map(|shape| match &shape.kind {
|
||||
ShapeKind::Table(table) => Some(table),
|
||||
_ => None,
|
||||
})
|
||||
.expect("table shape")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_direction_and_column_order_survive_two_pptx_saves() {
|
||||
for rtl in [Some(true), Some(false), None] {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let mut t = Table::new(1, 2, 200.0, 30.0);
|
||||
t.rtl = rtl;
|
||||
t.rows[0].cells[0] = Cell::new("First column");
|
||||
t.rows[0].cells[1] = Cell::new("Second column");
|
||||
let shape = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Table(t), ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(shape);
|
||||
|
||||
let mut roundtrip = crate::import(&crate::export(&p).expect("export")).expect("import");
|
||||
for _ in 0..2 {
|
||||
let t = table(&roundtrip);
|
||||
assert_eq!(t.rtl, rtl);
|
||||
assert_eq!(t.rows[0].cells[0].text.paragraphs[0].runs[0].text, "First column");
|
||||
assert_eq!(t.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Second column");
|
||||
roundtrip = crate::import(&crate::export(&roundtrip).expect("export again")).expect("import again");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-3
@@ -171,6 +171,7 @@ fn rels_of(doc: &xml::Doc, part: &str) -> Rels {
|
||||
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external"));
|
||||
let target_raw = target.to_string();
|
||||
let target = if external { target_raw.clone() } else { resolve(&base, target) };
|
||||
out.by_id.entry(id.to_string()).or_insert(out.items.len());
|
||||
out.items.push(Rel {
|
||||
id: id.to_string(),
|
||||
kind: ty.rsplit('/').next().unwrap_or(ty).to_string(),
|
||||
@@ -211,11 +212,13 @@ pub struct Rel {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Rels {
|
||||
pub items: Vec<Rel>,
|
||||
/// Id → index of its first relationship in `items`.
|
||||
by_id: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Rels {
|
||||
pub fn get(&self, id: &str) -> Option<&Rel> {
|
||||
self.items.iter().find(|r| r.id == id)
|
||||
self.by_id.get(id).and_then(|&i| self.items.get(i))
|
||||
}
|
||||
pub fn target(&self, id: &str) -> Option<&str> {
|
||||
self.get(id).map(|r| r.target.as_str())
|
||||
@@ -339,23 +342,29 @@ pub fn relative(from: &str, to: &str) -> String {
|
||||
#[derive(Default)]
|
||||
pub struct RelsOut {
|
||||
pub items: Vec<(String, String, String, bool)>,
|
||||
/// (type, target, external) → id of the relationship [`RelsOut::add`] made for it.
|
||||
added: HashMap<(String, String, bool), String>,
|
||||
}
|
||||
|
||||
impl RelsOut {
|
||||
/// Add (or reuse) a relationship; `kind` is a full type URI or a suffix of the standard base.
|
||||
pub fn add(&mut self, kind: &str, target: &str, external: bool) -> String {
|
||||
let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") };
|
||||
if let Some((id, ..)) = self.items.iter().find(|(_, t, tg, e)| *t == ty && tg == target && *e == external) {
|
||||
let key = (ty, target.to_string(), external);
|
||||
if let Some(id) = self.added.get(&key) {
|
||||
return id.clone();
|
||||
}
|
||||
let id = format!("rId{}", self.items.len() + 1);
|
||||
self.items.push((id.clone(), ty, target.to_string(), external));
|
||||
self.items.push((id.clone(), key.0.clone(), key.1.clone(), external));
|
||||
self.added.insert(key, id.clone());
|
||||
id
|
||||
}
|
||||
/// Always add a new relationship even if an identical one exists.
|
||||
pub fn add_new(&mut self, kind: &str, target: &str, external: bool) -> String {
|
||||
let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") };
|
||||
let id = format!("rId{}", self.items.len() + 1);
|
||||
// `add` reuses the first relationship for a target, whichever call made it.
|
||||
self.added.entry((ty.clone(), target.to_string(), external)).or_insert_with(|| id.clone());
|
||||
self.items.push((id.clone(), ty, target.to_string(), external));
|
||||
id
|
||||
}
|
||||
@@ -500,6 +509,27 @@ mod tests {
|
||||
assert!(p.get("e").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_relationships_are_found_by_index_and_first_id_wins() {
|
||||
let n = 50_000;
|
||||
let mut x = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
|
||||
for i in 0..n {
|
||||
x.push_str(&format!(r#"<Relationship Id="rId{i}" Type="{RT_BASE}image" Target="../media/image{i}.png"/>"#));
|
||||
}
|
||||
x.push_str(&format!(r#"<Relationship Id="rId0" Type="{RT_BASE}image" Target="../media/dup.png"/></Relationships>"#));
|
||||
let rels = rels_from_bytes(x.as_bytes(), "ppt/slides/slide1.xml");
|
||||
assert_eq!(rels.target("rId0"), Some("ppt/media/image0.png"), "the first of a repeated id wins");
|
||||
assert!((0..n).all(|i| rels.target(&format!("rId{i}")) == Some(&*format!("ppt/media/image{i}.png"))));
|
||||
|
||||
let mut out = RelsOut::default();
|
||||
let ids: Vec<String> = (0..n).map(|i| out.add("hyperlink", &format!("https://example.com/{i}"), true)).collect();
|
||||
assert_eq!(out.add("hyperlink", "https://example.com/7", true), ids[7], "an identical target reuses its id");
|
||||
assert_eq!(out.add("hyperlink", "https://example.com/7", false), format!("rId{}", n + 1), "internal is distinct");
|
||||
let fresh = out.add_new("image", "../media/a.png", false);
|
||||
assert_ne!(out.add_new("image", "../media/a.png", false), fresh);
|
||||
assert_eq!(out.add("image", "../media/a.png", false), fresh, "add reuses the first add_new made");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backslash_zip_entry_names_are_found() {
|
||||
let z = zip_of(&[("PPT\\Slides\\Slide1.xml", 3)]);
|
||||
|
||||
+26
-17
@@ -46,6 +46,9 @@ pub struct Imp<'a> {
|
||||
pub tag_bytes: usize,
|
||||
/// The tag budget ran out and the warning was given.
|
||||
pub tags_capped: bool,
|
||||
/// Whether a part has relationships of its own, by normalized part name: looked up once
|
||||
/// however many embedded objects point at the part.
|
||||
pub has_rels: HashMap<String, bool>,
|
||||
}
|
||||
|
||||
impl Imp<'_> {
|
||||
@@ -446,6 +449,7 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
|
||||
tags_by_part: HashMap::new(),
|
||||
tag_bytes: 0,
|
||||
tags_capped: false,
|
||||
has_rels: HashMap::new(),
|
||||
};
|
||||
let part = Part::new(&pkg, &theme_path, &doc);
|
||||
let theme = theme::read_theme(&mut imp, &part, &doc.root);
|
||||
@@ -487,6 +491,7 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
|
||||
tags_by_part: HashMap::new(),
|
||||
tag_bytes: 0,
|
||||
tags_capped: false,
|
||||
has_rels: HashMap::new(),
|
||||
};
|
||||
// Slide ids first so hyperlinks anywhere can point at slides.
|
||||
let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides);
|
||||
@@ -663,52 +668,55 @@ fn read_slides(
|
||||
let pkg = imp.pkg;
|
||||
let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
|
||||
let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new();
|
||||
let mut notes_by_target: HashMap<String, TextBody> = HashMap::new();
|
||||
// A notes or comments part belongs to one slide; one shared by many must not be read (and
|
||||
// copied) once per slide that names it.
|
||||
let mut owned_parts: HashSet<String> = HashSet::new();
|
||||
for (file_id, path, id) in slide_ids {
|
||||
let Some(doc) = docs.get(&opc::norm(path)) else { continue };
|
||||
let part = Part::new(pkg, path, doc);
|
||||
let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout);
|
||||
let mut slide = read_slide(imp, &part, doc, *id, layout);
|
||||
if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) {
|
||||
if let Some(notes) = slide_notes(imp, &part, &mut owned_parts) {
|
||||
slide.notes = notes;
|
||||
}
|
||||
read_slide_comments(pkg, pres_part, &part, &mut slide);
|
||||
read_slide_comments(pkg, pres_part, &part, &mut slide, &mut owned_parts);
|
||||
file_slide_ids.insert(*file_id, *id);
|
||||
p.slides.push(Arc::new(slide));
|
||||
}
|
||||
file_slide_ids
|
||||
}
|
||||
|
||||
/// The slide's notes text, read once per notes part.
|
||||
fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap<String, TextBody>) -> Option<TextBody> {
|
||||
/// The slide's notes text. A notes part is the notes of the first slide that names it.
|
||||
fn slide_notes(imp: &mut Imp, part: &Part, owned: &mut HashSet<String>) -> Option<TextBody> {
|
||||
let n = part.rels.first_of("notesSlide")?;
|
||||
let key = opc::norm(&n.target);
|
||||
if let Some(cached) = cache.get(&key) {
|
||||
return Some(cached.clone());
|
||||
if !owned.insert(opc::norm(&n.target)) {
|
||||
log::warn!("pptx: notes part {} is named by more than one slide; kept on the first", n.target);
|
||||
return None;
|
||||
}
|
||||
let pkg = imp.pkg;
|
||||
let nd = load(pkg, &n.target)?;
|
||||
let np = Part::new(pkg, &n.target, &nd);
|
||||
let notes = read_notes(imp, &np, &nd.root);
|
||||
cache.insert(key, notes.clone());
|
||||
Some(notes)
|
||||
Some(read_notes(imp, &np, &nd.root))
|
||||
}
|
||||
|
||||
/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
|
||||
/// writes both): the modern one wins, its flattened legacy copy is skipped.
|
||||
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) {
|
||||
let mut parts: Vec<(Doc, bool)> = vec![];
|
||||
/// Each comments part is read once, for the first slide that names it.
|
||||
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide, owned: &mut HashSet<String>) {
|
||||
let mut parts: Vec<(Doc, bool, bool)> = vec![];
|
||||
for c in part.rels.all_of("comments") {
|
||||
// An already-owned part is not re-read, but its kind still decides modern precedence.
|
||||
let fresh = owned.insert(opc::norm(&c.target));
|
||||
if let Some(cd) = pkg.xml(&c.target) {
|
||||
// The content type names the modern part; a package that types it only through a
|
||||
// Default entry instead of an Override is still modern when its root is p188.
|
||||
let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188";
|
||||
parts.push((cd, modern));
|
||||
parts.push((cd, modern, fresh));
|
||||
}
|
||||
}
|
||||
let has_modern = parts.iter().any(|(_, m)| *m);
|
||||
for (cd, modern) in parts {
|
||||
if has_modern && !modern {
|
||||
let has_modern = parts.iter().any(|(_, m, _)| *m);
|
||||
for (cd, modern, fresh) in parts {
|
||||
if !fresh || (has_modern && !modern) {
|
||||
continue;
|
||||
}
|
||||
slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern));
|
||||
@@ -1343,6 +1351,7 @@ fn read_show(sp: &El, shows: &HashMap<String, String>) -> ShowSettings {
|
||||
without_narration: sp.bool("showNarration") == Some(false),
|
||||
without_animation: sp.bool("showAnimation") == Some(false),
|
||||
use_timings: sp.bool("useTimings").unwrap_or(true),
|
||||
show_media_controls: sp.find("showMediaCtrls").and_then(|e| e.bool("val")).unwrap_or(true),
|
||||
..Default::default()
|
||||
};
|
||||
if sp.child("browse").is_some() {
|
||||
|
||||
@@ -23,6 +23,16 @@ pub fn pct(e: &El, name: &str) -> Option<f64> {
|
||||
e.f64(name).map(|v| v / 100_000.0)
|
||||
}
|
||||
|
||||
fn bullet_size_points(value: Option<&str>) -> Option<f64> {
|
||||
value.and_then(|v| v.trim().parse::<u32>().ok()).filter(|v| (100..=400_000).contains(v)).map(|v| v as f64 / 100.0)
|
||||
}
|
||||
|
||||
fn bullet_size_percent(value: Option<&str>) -> Option<f64> {
|
||||
let value = value?.trim();
|
||||
let (value, scale) = if let Some(value) = value.strip_suffix('%') { (value.trim(), 100.0) } else { (value, 100_000.0) };
|
||||
value.parse::<f64>().ok().filter(|v| v.is_finite()).map(|v| v / scale)
|
||||
}
|
||||
|
||||
/// 1/60 000 degree → degrees.
|
||||
pub fn ang(e: &El, name: &str) -> Option<f64> {
|
||||
e.f64(name).map(|v| v / 60_000.0)
|
||||
@@ -312,6 +322,15 @@ pub fn effects(el: &El) -> Effects {
|
||||
end_alpha: pct(e, "endA").unwrap_or(0.0),
|
||||
end_pos: pct(e, "endPos").unwrap_or(1.0),
|
||||
dist: pt(e, "dist").unwrap_or(0.0),
|
||||
st_pos: e.i64("stPos").and_then(|v| i32::try_from(v).ok()),
|
||||
dir: e.i64("dir").and_then(|v| i32::try_from(v).ok()),
|
||||
fade_dir: e.i64("fadeDir").and_then(|v| i32::try_from(v).ok()),
|
||||
sx: e.i64("sx").and_then(|v| i32::try_from(v).ok()),
|
||||
sy: e.i64("sy").and_then(|v| i32::try_from(v).ok()),
|
||||
kx: e.i64("kx").and_then(|v| i32::try_from(v).ok()),
|
||||
ky: e.i64("ky").and_then(|v| i32::try_from(v).ok()),
|
||||
align: e.attr("algn").filter(|v| matches!(*v, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")).map(str::to_owned),
|
||||
rotate_with_shape: e.bool("rotWithShape"),
|
||||
})
|
||||
}
|
||||
_ => {}
|
||||
@@ -358,6 +377,9 @@ pub fn body_pr(b: &El) -> BodyProps {
|
||||
rtl_col: b.bool("rtlCol"),
|
||||
upright: b.bool("upright"),
|
||||
space_first_last: b.bool("spcFirstLastPara"),
|
||||
compat_ln_spc: b.bool("compatLnSpc"),
|
||||
vert_overflow: b.attr("vertOverflow").and_then(deckcraft_model::text::TextOverflow::from_xml),
|
||||
horz_overflow: b.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_horz_xml),
|
||||
..Default::default()
|
||||
};
|
||||
for e in b.elements() {
|
||||
@@ -454,9 +476,22 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
|
||||
"buFont" => pp.bullet_font = e.attr("typeface").map(String::from),
|
||||
"buFontTx" => pp.bullet_font_follow_text = Some(true),
|
||||
"buClr" => pp.bullet_color = color(e),
|
||||
"buSzPct" => {
|
||||
pp.bullet_size_points = None;
|
||||
pp.bullet_size_follow_text = None;
|
||||
pp.bullet_size = bullet_size_percent(e.attr("val")).map(|v| v.clamp(0.25, 4.0));
|
||||
}
|
||||
"buSzPts" => {
|
||||
pp.bullet_size = None;
|
||||
pp.bullet_size_follow_text = None;
|
||||
pp.bullet_size_points = bullet_size_points(e.attr("val"));
|
||||
}
|
||||
"buClrTx" => pp.bullet_color_follow_text = Some(true),
|
||||
"buSzPct" => pp.bullet_size = pct(e, "val").map(|v| v.clamp(0.25, 4.0)),
|
||||
"buSzTx" => pp.bullet_size_follow_text = Some(true),
|
||||
"buSzTx" => {
|
||||
pp.bullet_size = None;
|
||||
pp.bullet_size_points = None;
|
||||
pp.bullet_size_follow_text = Some(true);
|
||||
}
|
||||
"tabLst" => {
|
||||
pp.tabs = Some(
|
||||
e.children_named("tab")
|
||||
@@ -530,6 +565,7 @@ fn typeface(e: &El) -> Option<String> {
|
||||
pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps {
|
||||
let mut p = RunProps {
|
||||
lang: r.attr("lang").map(String::from),
|
||||
alt_lang: r.attr("altLang").map(|lang| lang.chars().take(256).collect()),
|
||||
size: r.f64("sz").map(|s| (s / 100.0).clamp(1.0, 4000.0)),
|
||||
bold: r.bool("b"),
|
||||
italic: r.bool("i"),
|
||||
@@ -548,6 +584,7 @@ pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps {
|
||||
spacing: r.f64("spc").map(|s| (s / 100.0).clamp(-4000.0, 4000.0)),
|
||||
kern: r.f64("kern").map(|k| (k / 100.0).clamp(0.0, 4000.0)),
|
||||
no_proof: r.bool("noProof"),
|
||||
normalize_height: r.bool("normalizeH"),
|
||||
..Default::default()
|
||||
};
|
||||
for e in r.elements() {
|
||||
@@ -627,12 +664,22 @@ fn push_text_runs(para: &mut Paragraph, props: RunProps, text: &str) {
|
||||
/// Appends an Office Math (a14:m) run: its plain text and its OMML.
|
||||
fn push_math_run(para: &mut Paragraph, part: &Part, e: &El) {
|
||||
if para.runs.len() < MAX_PARAGRAPH_RUNS {
|
||||
let mut text = String::new();
|
||||
let mut ts = vec![];
|
||||
e.find_all("t", &mut ts);
|
||||
for t in ts {
|
||||
text.push_str(&t.text());
|
||||
// The text of the outermost `t` elements: a `t` nested in another is already part of
|
||||
// the outer one's text, and taking it again would copy it once per nesting level.
|
||||
fn outer_t(e: &El, depth: usize, text: &mut String) {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for c in e.elements() {
|
||||
if c.is("t") {
|
||||
text.push_str(&c.text());
|
||||
} else {
|
||||
outer_t(c, depth + 1, text);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut text = String::new();
|
||||
outer_t(e, 0, &mut text);
|
||||
let omml = e.elements().map(|c| part.keep_inner(c)).collect::<String>();
|
||||
para.runs.push(Run { text, props: RunProps::default(), kind: RunKind::Math { omml } });
|
||||
}
|
||||
@@ -677,6 +724,17 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::xml::parse;
|
||||
|
||||
#[test]
|
||||
fn nested_math_text_is_taken_once() {
|
||||
// 200 `m:t` nested in each other around one word: the word, not 200 copies of it.
|
||||
let x = format!(r#"<a14:m xmlns:a14="a" xmlns:m="m"><m:oMath>{}word{}</m:oMath></a14:m>"#, "<m:t>".repeat(200), "</m:t>".repeat(200));
|
||||
let d = parse(x.as_bytes()).unwrap();
|
||||
let part = Part { name: "ppt/slides/slide1.xml".into(), rels: Default::default(), ns: vec![], inner_ns: vec![] };
|
||||
let mut para = Paragraph::default();
|
||||
push_math_run(&mut para, &part, &d.root);
|
||||
assert_eq!(para.runs[0].text, "word");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colors_and_transforms() {
|
||||
let d = parse(
|
||||
@@ -692,6 +750,21 @@ mod tests {
|
||||
assert_eq!(color(&d.root).unwrap().base, ColorBase::Rgb { rgb: Rgba::rgb(255, 0, 0) });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullet_point_size_trims_schema_whitespace() {
|
||||
let d = parse(br#"<a:buSzPts xmlns:a="x" val=" 1400 "/>"#).unwrap();
|
||||
assert_eq!(bullet_size_points(d.root.attr("val")), Some(14.0));
|
||||
assert_eq!(bullet_size_points(Some("1400")), Some(14.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullet_percentage_size_accepts_numeric_and_percent_forms() {
|
||||
assert_eq!(bullet_size_percent(Some("100000")), Some(1.0));
|
||||
assert_eq!(bullet_size_percent(Some("100%")), Some(1.0));
|
||||
assert_eq!(bullet_size_percent(Some(" 125% ")), Some(1.25));
|
||||
assert_eq!(bullet_size_percent(Some("invalid")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn miter_limit_accepts_both_percentage_forms_with_schema_bounds() {
|
||||
assert_eq!(parse_miter_limit("200000"), Some(200_000));
|
||||
|
||||
@@ -475,7 +475,8 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let rel = part.rels.get(&id).filter(|r| !r.external)?;
|
||||
if !imp.pkg.rels(&rel.target).items.is_empty() {
|
||||
let pkg = imp.pkg;
|
||||
if *imp.has_rels.entry(crate::opc::norm(&rel.target)).or_insert_with(|| !pkg.rels(&rel.target).items.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
let (kind, target) = (rel.ty.clone(), rel.target.clone());
|
||||
@@ -757,6 +758,7 @@ fn table(imp: &mut Imp, part: &Part, tbl: &El) -> Table {
|
||||
t.last_col = pr.bool("lastCol").unwrap_or(false);
|
||||
t.band_row = pr.bool("bandRow").unwrap_or(false);
|
||||
t.band_col = pr.bool("bandCol").unwrap_or(false);
|
||||
t.rtl = pr.bool("rtl");
|
||||
if let Some(id) = pr.child("tableStyleId").map(|e| e.text()) {
|
||||
// A style the file defines is drawn from its definition (by its GUID).
|
||||
// DeckCraft's own styles (written with GUIDs derived from their ids) map back to them.
|
||||
@@ -805,6 +807,8 @@ fn cell(imp: &mut Imp, part: &Part, tc: &El) -> Cell {
|
||||
]);
|
||||
}
|
||||
c.anchor = pr.attr("anchor").and_then(Anchor::from_xml);
|
||||
c.anchor_ctr = pr.bool("anchorCtr");
|
||||
c.horz_overflow = pr.attr("horzOverflow").and_then(deckcraft_model::text::TextOverflow::from_horz_xml);
|
||||
c.vertical = pr.attr("vert").and_then(TextDir::from_xml);
|
||||
for (i, n) in ["lnL", "lnR", "lnT", "lnB"].iter().enumerate() {
|
||||
if let Some(ln) = pr.child(n)
|
||||
|
||||
@@ -53,25 +53,128 @@ pub fn transition(imp: &mut Imp, part: &Part, t: &El) -> Option<Transition> {
|
||||
struct Found<'a> {
|
||||
ctn: &'a El,
|
||||
trigger: Option<u32>,
|
||||
/// Click group the effect sits in: a new one starts at every time node whose start is `indefinite`.
|
||||
group: usize,
|
||||
/// Sum of the start delays of the time nodes between the click group and the effect, in ms.
|
||||
offset: u32,
|
||||
}
|
||||
|
||||
fn collect<'a>(e: &'a El, trigger: Option<u32>, out: &mut Vec<Found<'a>>, depth: usize) {
|
||||
/// Where `collect` is in the time tree: the click group and the delay accumulated inside it.
|
||||
#[derive(Clone, Copy)]
|
||||
struct At {
|
||||
trigger: Option<u32>,
|
||||
group: usize,
|
||||
offset: u32,
|
||||
}
|
||||
|
||||
fn collect<'a>(e: &'a El, at: At, groups: &mut usize, out: &mut Vec<Found<'a>>, depth: usize) {
|
||||
if depth > 64 || out.len() > 10_000 {
|
||||
return;
|
||||
}
|
||||
for c in e.elements() {
|
||||
if c.is("cTn") && c.has_attr("presetClass") {
|
||||
out.push(Found { ctn: c, trigger });
|
||||
out.push(Found { ctn: c, trigger: at.trigger, group: at.group, offset: at.offset });
|
||||
continue;
|
||||
}
|
||||
let mut trig = trigger;
|
||||
let mut at = at;
|
||||
if c.is("seq")
|
||||
&& let Some(ctn) = c.child("cTn")
|
||||
&& ctn.attr("nodeType") == Some("interactiveSeq")
|
||||
{
|
||||
trig = ctn.path(&["stCondLst", "cond", "tgtEl", "spTgt"]).and_then(|s| s.u32("spid"));
|
||||
at.trigger = ctn.path(&["stCondLst", "cond", "tgtEl", "spTgt"]).and_then(|s| s.u32("spid"));
|
||||
}
|
||||
if c.is("cTn")
|
||||
&& let Some(cond) = c.path(&["stCondLst", "cond"])
|
||||
{
|
||||
if cond.attr("delay").map(str::trim) == Some("indefinite") {
|
||||
*groups += 1;
|
||||
at.group = *groups;
|
||||
at.offset = 0;
|
||||
} else {
|
||||
at.offset = at.offset.saturating_add(ms_attr(cond, "delay").unwrap_or(0));
|
||||
}
|
||||
}
|
||||
collect(c, at, groups, out, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/// How long an effect holds back an After Previous one, as the show timeline counts it.
|
||||
fn span_ms(a: &Animation) -> u32 {
|
||||
let iterations = if a.repeat == u32::MAX || a.repeat <= 1 { 1 } else { a.repeat.min(1_000) };
|
||||
a.duration_ms.saturating_mul(if a.auto_reverse { 2 } else { 1 }).saturating_mul(iterations)
|
||||
}
|
||||
|
||||
/// Start times (ms after the click) that the flat effect list plays at, effect after effect.
|
||||
fn flat_starts(anims: &[&Animation]) -> Vec<u32> {
|
||||
let (mut begin, mut end) = (0u32, 0u32);
|
||||
anims
|
||||
.iter()
|
||||
.map(|a| {
|
||||
if a.start == AnimStart::AfterPrevious {
|
||||
begin = end;
|
||||
}
|
||||
let start = begin.saturating_add(a.delay_ms);
|
||||
end = end.max(start.saturating_add(span_ms(a)));
|
||||
start
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// PowerPoint places each effect at the delays of all its enclosing time nodes, so a file may list
|
||||
/// effects out of time order (a countdown written first, then effects at 5 s placed only by their
|
||||
/// parent's delay). The flat list only knows "after/with previous" plus the effect's own delay, so
|
||||
/// a click group listed out of time order, whose flat reading disagrees with the file, is sorted by
|
||||
/// start time and its starts and delays rewritten to land every effect where PowerPoint plays it.
|
||||
/// Every other group is left untouched.
|
||||
fn place_by_parent_delays(out: &mut [(Animation, u32, String, bool)], at: &[(Option<u32>, usize, u32)]) {
|
||||
let mut groups: Vec<Vec<usize>> = vec![];
|
||||
let mut slot: std::collections::HashMap<(Option<u32>, usize), usize> = std::collections::HashMap::new();
|
||||
for (i, &(t, g, _)) in at.iter().enumerate() {
|
||||
let n = *slot.entry((t, g)).or_insert_with(|| {
|
||||
groups.push(vec![]);
|
||||
groups.len() - 1
|
||||
});
|
||||
groups[n].push(i);
|
||||
}
|
||||
for idx in groups {
|
||||
// More than one click in a group means the tree isn't one click per group: leave it.
|
||||
if idx.iter().skip(1).any(|&i| out[i].0.start == AnimStart::OnClick) {
|
||||
continue;
|
||||
}
|
||||
let abs: Vec<u32> = idx.iter().map(|&i| at[i].2.saturating_add(out[i].0.delay_ms)).collect();
|
||||
// Only a file that lists effects out of time order needs this: files PowerPoint or DeckCraft
|
||||
// save are in order, and their reading stays exactly as before.
|
||||
if abs.is_sorted() {
|
||||
continue;
|
||||
}
|
||||
let flat = flat_starts(&idx.iter().map(|&i| &out[i].0).collect::<Vec<_>>());
|
||||
if flat == abs {
|
||||
continue;
|
||||
}
|
||||
// The group's click stays on whichever effect now plays first.
|
||||
let had_click = out[idx[0]].0.start == AnimStart::OnClick;
|
||||
let mut order: Vec<usize> = (0..idx.len()).collect();
|
||||
order.sort_by_key(|&k| abs[k]);
|
||||
let mut sorted: Vec<(Animation, u32, String, bool)> = order.iter().map(|&k| out[idx[k]].clone()).collect();
|
||||
let (mut begin, mut end) = (0u32, 0u32);
|
||||
for (n, (&k, item)) in order.iter().zip(sorted.iter_mut()).enumerate() {
|
||||
let a = &mut item.0;
|
||||
let t = abs[k];
|
||||
if n == 0 && had_click {
|
||||
a.start = AnimStart::OnClick;
|
||||
a.delay_ms = t;
|
||||
} else if a.start == AnimStart::AfterPrevious && t >= end {
|
||||
begin = end;
|
||||
a.delay_ms = t - end;
|
||||
} else {
|
||||
a.start = AnimStart::WithPrevious;
|
||||
a.delay_ms = t.saturating_sub(begin);
|
||||
}
|
||||
end = end.max(t.saturating_add(span_ms(a)));
|
||||
}
|
||||
for (&i, item) in idx.iter().zip(sorted) {
|
||||
out[i] = item;
|
||||
}
|
||||
collect(c, trig, out, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,7 +238,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
|
||||
let _ = (imp, part);
|
||||
let Some(tn) = timing.child("tnLst") else { return vec![] };
|
||||
let mut found = vec![];
|
||||
collect(tn, None, &mut found, 0);
|
||||
collect(tn, At { trigger: None, group: 0, offset: 0 }, &mut 0, &mut found, 0);
|
||||
// Paragraph builds (`bldP build="p"`) by (spid, grpId).
|
||||
let by_para: Vec<(u32, String)> = timing
|
||||
.child("bldLst")
|
||||
@@ -147,6 +250,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut out: Vec<(Animation, u32, String, bool)> = vec![];
|
||||
let mut at: Vec<(Option<u32>, usize, u32)> = vec![];
|
||||
for f in found {
|
||||
let c = f.ctn;
|
||||
let class = AnimClass::from_xml(c.attr("presetClass").unwrap_or("entr"));
|
||||
@@ -212,7 +316,9 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, shapes:
|
||||
let grp = c.attr("grpId").unwrap_or("0").to_string();
|
||||
let build = paragraph.is_some() && by_para.iter().any(|(s, g)| *s == file_id && *g == grp);
|
||||
out.push((a, file_id, grp, build));
|
||||
at.push((f.trigger, f.group, f.offset));
|
||||
}
|
||||
place_by_parent_delays(&mut out, &at);
|
||||
// A paragraph build's effects, one per paragraph, become one by-paragraph animation only when
|
||||
// the timeline plays that back exactly: PowerPoint lets each paragraph keep its own start and
|
||||
// effect, and other shapes' effects can sit between them (#844).
|
||||
|
||||
@@ -210,13 +210,20 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt
|
||||
let mut w = W::new();
|
||||
w.open("a:tblStyleLst", A::new().a("xmlns:a", NS_A).a("def", "{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}"));
|
||||
let names = deckcraft_model::table::table_styles();
|
||||
let mut done: Vec<&str> = vec![];
|
||||
// Indexes instead of scans: written ids (as given and ASCII lower-case), and the file's
|
||||
// definitions by lower-case id (the first wins).
|
||||
let mut done: std::collections::HashSet<&str> = std::collections::HashSet::new();
|
||||
let mut done_lower: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
let mut def_of: std::collections::HashMap<String, &deckcraft_model::table::TableStyleDef> = std::collections::HashMap::new();
|
||||
for d in defs {
|
||||
def_of.entry(d.id.to_ascii_lowercase()).or_insert(d);
|
||||
}
|
||||
for s in used {
|
||||
if done.contains(&s.as_str()) {
|
||||
if !done.insert(s.as_str()) {
|
||||
continue;
|
||||
}
|
||||
done.push(s);
|
||||
if let Some(d) = defs.iter().find(|d| d.id.eq_ignore_ascii_case(s))
|
||||
done_lower.insert(s.to_ascii_lowercase());
|
||||
if let Some(d) = def_of.get(&s.to_ascii_lowercase())
|
||||
&& crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle"))
|
||||
{
|
||||
w.raw(&d.raw);
|
||||
@@ -231,11 +238,11 @@ pub fn table_styles_xml(used: &[String], defs: &[deckcraft_model::table::TableSt
|
||||
}
|
||||
// Styles the file defined but no table uses still belong to the deck.
|
||||
for d in defs {
|
||||
if done.iter().any(|s| s.eq_ignore_ascii_case(&d.id)) {
|
||||
if done_lower.contains(&d.id.to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
if crate::xml::parse(d.raw.as_bytes()).is_ok_and(|x| x.root.is("tblStyle")) {
|
||||
done.push(&d.id);
|
||||
done_lower.insert(d.id.to_ascii_lowercase());
|
||||
w.raw(&d.raw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@ pub struct Exp<'a> {
|
||||
pub p: &'a Presentation,
|
||||
pub pkg: PackageOut,
|
||||
media_parts: HashMap<MediaId, String>,
|
||||
/// Each media id's item in `p.media` (the first with the id, as `Presentation::media`).
|
||||
media_at: HashMap<MediaId, usize>,
|
||||
n_image: u32,
|
||||
n_media: u32,
|
||||
n_chart: u32,
|
||||
@@ -63,6 +65,8 @@ pub struct Exp<'a> {
|
||||
n_kept: u32,
|
||||
pub slide_parts: HashMap<SlideId, String>,
|
||||
pub table_styles: Vec<String>,
|
||||
/// The ids in `table_styles`, for checking membership without a scan.
|
||||
pub table_style_set: std::collections::HashSet<String>,
|
||||
placeholder: Option<String>,
|
||||
}
|
||||
|
||||
@@ -119,12 +123,17 @@ fn ext_for(content_type: &str, name: &str, data: &[u8]) -> String {
|
||||
if !e.is_empty() && e.len() <= 5 && e.chars().all(|c| c.is_ascii_alphanumeric()) { e } else { "bin".into() }
|
||||
}
|
||||
|
||||
impl Exp<'_> {
|
||||
impl<'a> Exp<'a> {
|
||||
/// The media item `id`, found through the index rather than a scan of every item.
|
||||
pub fn media(&self, id: MediaId) -> Option<&'a deckcraft_model::MediaItem> {
|
||||
let p: &'a Presentation = self.p;
|
||||
self.media_at.get(&id).and_then(|&i| p.media.get(i))
|
||||
}
|
||||
fn media_part(&mut self, id: MediaId) -> Option<String> {
|
||||
if let Some(p) = self.media_parts.get(&id) {
|
||||
return Some(p.clone());
|
||||
}
|
||||
let m = self.p.media(id)?;
|
||||
let m = self.media(id)?;
|
||||
if m.data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -147,7 +156,7 @@ impl Exp<'_> {
|
||||
}
|
||||
/// A relationship from `o` to a media item (embedded part, or external link).
|
||||
pub fn media_rel(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> {
|
||||
let m = self.p.media(id)?;
|
||||
let m = self.media(id)?;
|
||||
if m.data.is_empty() {
|
||||
let link = m.link.clone()?;
|
||||
return Some(o.rels.add(kind, &link, true));
|
||||
@@ -156,7 +165,7 @@ impl Exp<'_> {
|
||||
Some(o.rels.add(kind, &opc::relative(&o.name, &part), false))
|
||||
}
|
||||
pub fn media_rel_new(&mut self, o: &mut Out, id: MediaId, kind: &str) -> Option<String> {
|
||||
let m = self.p.media(id)?;
|
||||
let m = self.media(id)?;
|
||||
if m.data.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -489,7 +498,8 @@ fn valid_date(s: &str) -> bool {
|
||||
/// A copy of the presentation with every slide pointing at a layout that exists.
|
||||
fn sanitized(p: &Presentation) -> std::borrow::Cow<'_, Presentation> {
|
||||
let mut need = p.masters.is_empty() || p.masters.iter().any(|m| m.layouts.is_empty());
|
||||
need |= p.slides.iter().any(|s| p.layout(s.layout).is_none());
|
||||
let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
|
||||
need |= p.slides.iter().any(|s| !layouts.contains(&s.layout));
|
||||
if !need {
|
||||
return std::borrow::Cow::Borrowed(p);
|
||||
}
|
||||
@@ -532,6 +542,13 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
|
||||
p,
|
||||
pkg: PackageOut::default(),
|
||||
media_parts: HashMap::new(),
|
||||
media_at: {
|
||||
let mut m = HashMap::new();
|
||||
for (i, item) in p.media.iter().enumerate() {
|
||||
m.entry(item.id).or_insert(i);
|
||||
}
|
||||
m
|
||||
},
|
||||
n_image: 0,
|
||||
n_media: 0,
|
||||
n_chart: 0,
|
||||
@@ -541,6 +558,7 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result<Vec<u8>, PptxError> {
|
||||
n_kept: 0,
|
||||
slide_parts: HashMap::new(),
|
||||
table_styles: vec![],
|
||||
table_style_set: Default::default(),
|
||||
placeholder: None,
|
||||
};
|
||||
for (i, s) in p.slides.iter().enumerate() {
|
||||
@@ -959,9 +977,16 @@ fn section_slides(p: &Presentation) -> Vec<(String, Vec<usize>)> {
|
||||
if out.is_empty() {
|
||||
return out;
|
||||
}
|
||||
// Each slide's first section, found once: a section may name a slide many times.
|
||||
let mut first: HashMap<SlideId, usize> = HashMap::new();
|
||||
for (k, sec) in p.sections.iter().enumerate() {
|
||||
for id in &sec.slides {
|
||||
first.entry(*id).or_insert(k);
|
||||
}
|
||||
}
|
||||
let mut cur = 0usize;
|
||||
for (i, s) in p.slides.iter().enumerate() {
|
||||
if let Some(k) = p.sections.iter().position(|sec| sec.slides.contains(&s.id))
|
||||
if let Some(&k) = first.get(&s.id)
|
||||
&& k >= cur
|
||||
{
|
||||
cur = k;
|
||||
@@ -1027,7 +1052,7 @@ fn write_embedded_fonts(x: &mut Exp, o: &mut Out) -> Vec<WrittenFont> {
|
||||
if let Some(rid) = seen_part.get(&id) {
|
||||
return Some(rid.clone());
|
||||
}
|
||||
let m = x.p.media(id)?;
|
||||
let m = x.media(id)?;
|
||||
if m.data.is_empty() || m.data.len() > 32 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
@@ -1181,6 +1206,14 @@ fn pres_props_xml(p: &Presentation) -> Vec<u8> {
|
||||
w.open0("p:penClr");
|
||||
w.val("a:srgbClr", s.pen_color.hex());
|
||||
w.close("p:penClr");
|
||||
if !s.show_media_controls {
|
||||
// "Show media controls" is PowerPoint's p14 extension; absent means shown.
|
||||
w.open0("p:extLst");
|
||||
w.open("p:ext", A::new().a("uri", "{2FDB2607-1784-4EEB-B798-7EB5836EED8A}"));
|
||||
w.empty("p14:showMediaCtrls", A::new().a("xmlns:p14", NS_P14).a("val", 0));
|
||||
w.close("p:ext");
|
||||
w.close("p:extLst");
|
||||
}
|
||||
w.close("p:showPr");
|
||||
w.close("p:presentationPr");
|
||||
w.finish()
|
||||
|
||||
@@ -100,7 +100,7 @@ pub fn fill(w: &mut W, x: &mut Exp, o: &mut Out, f: &Fill) {
|
||||
pub fn blip_fill(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, pf: &PictureFill) {
|
||||
// An image that lives outside the package is a linked picture: `a:blip` names it through
|
||||
// `r:link`; `r:embed` may only reference a part inside the package.
|
||||
let linked = x.p.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some());
|
||||
let linked = x.media(pf.media).is_some_and(|m| m.data.is_empty() && m.link.is_some());
|
||||
let rid = x.media_rel(o, pf.media, "image").unwrap_or_else(|| x.placeholder_rel(o));
|
||||
w.open(tag, A::new().a("rotWithShape", if pf.upright { "0" } else { "1" }));
|
||||
let adj = &pf.adjust;
|
||||
@@ -357,10 +357,18 @@ pub fn effects(w: &mut W, fx: &Effects) {
|
||||
.a("endA", pct(r.end_alpha.clamp(0.0, 1.0)))
|
||||
.a("endPos", pct(r.end_pos.clamp(0.0, 1.0)))
|
||||
.a("dist", emu(r.dist).max(0))
|
||||
.a("dir", 5_400_000)
|
||||
.a("sy", -100_000)
|
||||
.a("algn", "bl")
|
||||
.a("rotWithShape", "0"),
|
||||
// Kept from a file as read: held to the schema's ranges so a stray value can't
|
||||
// make PowerPoint repair the file (ST_PositiveFixedAngle, ST_FixedAngle,
|
||||
// ST_PositiveFixedPercentage).
|
||||
.o("stPos", r.st_pos.map(|v| v.clamp(0, 100_000)))
|
||||
.o("dir", r.dir.map(|v| v.rem_euclid(21_600_000)))
|
||||
.o("fadeDir", r.fade_dir.map(|v| v.rem_euclid(21_600_000)))
|
||||
.o("sx", r.sx)
|
||||
.o("sy", r.sy)
|
||||
.o("kx", r.kx.map(|v| v.clamp(-5_399_999, 5_399_999)))
|
||||
.o("ky", r.ky.map(|v| v.clamp(-5_399_999, 5_399_999)))
|
||||
.o("algn", r.align.as_deref())
|
||||
.b("rotWithShape", r.rotate_with_shape),
|
||||
);
|
||||
}
|
||||
if let Some(r) = fx.soft_edge {
|
||||
@@ -441,7 +449,7 @@ pub fn hyperlink(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, h: &Hyperlink)
|
||||
return;
|
||||
};
|
||||
w.open(tag, attrs);
|
||||
let name = x.p.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into());
|
||||
let name = x.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into());
|
||||
w.empty("a:snd", A::new().a("r:embed", sound_rid).a("name", name));
|
||||
w.close(tag);
|
||||
}
|
||||
@@ -468,7 +476,10 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
.o("anchor", b.anchor.map(|a| a.xml()))
|
||||
.b("anchorCtr", b.anchor_ctr)
|
||||
.b("upright", b.upright)
|
||||
.b("spcFirstLastPara", b.space_first_last);
|
||||
.b("spcFirstLastPara", b.space_first_last)
|
||||
.b("compatLnSpc", b.compat_ln_spc)
|
||||
.o("vertOverflow", b.vert_overflow.map(|v| v.xml()))
|
||||
.o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(b.horz_overflow));
|
||||
let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
if warp.is_none() && b.autofit.is_none() {
|
||||
w.empty("a:bodyPr", a);
|
||||
@@ -543,6 +554,7 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|
||||
|| p.bullet_color.is_some()
|
||||
|| p.bullet_color_follow_text.is_some()
|
||||
|| p.bullet_size.is_some()
|
||||
|| p.bullet_size_points.is_some()
|
||||
|| p.bullet_size_follow_text.is_some()
|
||||
|| p.tabs.is_some()
|
||||
|| def.is_some();
|
||||
@@ -567,8 +579,10 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|
||||
color(w, c);
|
||||
w.close("a:buClr");
|
||||
}
|
||||
if p.bullet_size.is_none() && p.bullet_size_follow_text == Some(true) {
|
||||
if p.bullet_size.is_none() && p.bullet_size_points.is_none() && p.bullet_size_follow_text == Some(true) {
|
||||
w.empty0("a:buSzTx");
|
||||
} else if let Some(s) = p.bullet_size_points.filter(|s| s.is_finite() && (1.0..=4000.0).contains(s)) {
|
||||
w.val("a:buSzPts", ((s * 100.0).round() as u32).to_string());
|
||||
} else if let Some(s) = p.bullet_size {
|
||||
w.val("a:buSzPct", pct(s.clamp(0.25, 4.0)));
|
||||
}
|
||||
@@ -587,7 +601,7 @@ pub fn ppr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, level: Option<u8>, p:
|
||||
Some(Bullet::Picture { media }) => match x.media_rel(o, *media, "image") {
|
||||
Some(rid) => {
|
||||
w.open0("a:buBlip");
|
||||
let a = if x.p.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) {
|
||||
let a = if x.media(*media).is_some_and(|m| m.data.is_empty() && m.link.is_some()) {
|
||||
A::new().a("r:link", &rid)
|
||||
} else {
|
||||
A::new().a("r:embed", &rid)
|
||||
@@ -626,6 +640,7 @@ fn typeface(w: &mut W, tag: &str, f: &Option<String>) {
|
||||
pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) {
|
||||
let a = A::new()
|
||||
.o("lang", r.lang.as_deref().filter(|l| !l.is_empty()))
|
||||
.o("altLang", r.alt_lang.as_deref().filter(|l| !l.is_empty()))
|
||||
.o("sz", r.size.map(|s| (s.clamp(1.0, 4000.0) * 100.0).round() as i64))
|
||||
.b("b", r.bold)
|
||||
.b("i", r.italic)
|
||||
@@ -649,7 +664,8 @@ pub fn rpr(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, r: &RunProps) {
|
||||
)
|
||||
.o("spc", r.spacing.map(|s| (s.clamp(-4000.0, 4000.0) * 100.0).round() as i64))
|
||||
.o("baseline", r.baseline.map(|b| pct(b.clamp(-10.0, 10.0))))
|
||||
.b("noProof", r.no_proof);
|
||||
.b("noProof", r.no_proof)
|
||||
.b("normalizeH", r.normalize_height);
|
||||
let has_fx = r.shadow.is_some() || r.inner_shadow.is_some() || r.glow.is_some() || r.reflection.is_some() || r.soft_edge.is_some();
|
||||
let has_kids = r.outline.is_some()
|
||||
|| r.fill.is_some()
|
||||
@@ -746,17 +762,27 @@ pub fn text_body(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, tb: &TextBody,
|
||||
}
|
||||
|
||||
fn run_text(w: &mut W, x: &mut Exp, o: &mut Out, text: &str, props: &RunProps) {
|
||||
// The run's properties are the same for every piece and line break: serialize them once.
|
||||
let mut rpr_xml: Option<String> = None;
|
||||
let mut put_rpr = |w: &mut W| match &rpr_xml {
|
||||
Some(xml) => w.s.push_str(xml),
|
||||
None => {
|
||||
let start = w.s.len();
|
||||
rpr(w, x, o, "a:rPr", props);
|
||||
rpr_xml = Some(w.s[start..].to_string());
|
||||
}
|
||||
};
|
||||
for (i, piece) in text.split(['\n', '\u{b}']).enumerate() {
|
||||
if i > 0 {
|
||||
w.open0("a:br");
|
||||
rpr(w, x, o, "a:rPr", props);
|
||||
put_rpr(w);
|
||||
w.close("a:br");
|
||||
}
|
||||
if piece.is_empty() && i > 0 {
|
||||
continue;
|
||||
}
|
||||
w.open0("a:r");
|
||||
rpr(w, x, o, "a:rPr", props);
|
||||
put_rpr(w);
|
||||
w.elt("a:t", piece);
|
||||
w.close("a:r");
|
||||
}
|
||||
|
||||
@@ -446,7 +446,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
|
||||
}
|
||||
|
||||
fn model_rel(x: &mut Exp, o: &mut Out, media: deckcraft_model::MediaId) -> Option<String> {
|
||||
if x.p.media(media).is_none_or(|m| m.data.is_empty()) {
|
||||
if x.media(media).is_none_or(|m| m.data.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
x.media_rel(o, media, crate::opc::RT_MODEL3D)
|
||||
@@ -479,7 +479,7 @@ fn write_model(
|
||||
return;
|
||||
};
|
||||
let id = cx.id_for(s);
|
||||
let poster = poster.filter(|p| x.p.media(*p).is_some_and(|m| !m.data.is_empty()));
|
||||
let poster = poster.filter(|p| x.media(*p).is_some_and(|m| !m.data.is_empty()));
|
||||
let Some(poster) = poster else {
|
||||
write_model_part(w, x, o, id, s, &rid);
|
||||
return;
|
||||
@@ -700,7 +700,7 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
|
||||
t.normalize();
|
||||
w.open0("a:tbl");
|
||||
let (guid, _) = crate::tables::guid_for_style(&t.style);
|
||||
if !x.table_styles.contains(&t.style) {
|
||||
if x.table_style_set.insert(t.style.clone()) {
|
||||
x.table_styles.push(t.style.clone());
|
||||
}
|
||||
w.open(
|
||||
@@ -711,7 +711,8 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
|
||||
.t("lastRow", t.last_row)
|
||||
.t("lastCol", t.last_col)
|
||||
.t("bandRow", t.band_row)
|
||||
.t("bandCol", t.band_col),
|
||||
.t("bandCol", t.band_col)
|
||||
.b("rtl", t.rtl),
|
||||
);
|
||||
if let Some(f) = &t.background {
|
||||
dml::fill(w, x, o, f);
|
||||
@@ -746,6 +747,8 @@ fn table(w: &mut W, x: &mut Exp, o: &mut Out, t: &Table) {
|
||||
other => other.xml(),
|
||||
}),
|
||||
);
|
||||
a = a.b("anchorCtr", c.anchor_ctr);
|
||||
a = a.o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(c.horz_overflow));
|
||||
w.open("a:tcPr", a);
|
||||
for (tag, l) in [
|
||||
("a:lnL", &c.borders[0]),
|
||||
@@ -881,7 +884,7 @@ fn opaque(
|
||||
p.name = label.to_string();
|
||||
}
|
||||
match preview {
|
||||
Some(m) if x.p.media(m).is_some() => {
|
||||
Some(m) if x.media(m).is_some() => {
|
||||
p.kind = ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media: m, ..Default::default() } };
|
||||
p.ph = None;
|
||||
shape_with_id(w, x, o, cx, &p, id);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Transitions and the animation timing tree.
|
||||
|
||||
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind, find_shape};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
|
||||
use super::shapes::IdMap;
|
||||
use super::{Exp, Out};
|
||||
@@ -16,7 +16,7 @@ pub fn transition(w: &mut W, x: &mut Exp, o: &mut Out, t: &Transition) {
|
||||
601..=900 => "med",
|
||||
_ => "slow",
|
||||
};
|
||||
let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.p.media(m).map(|i| i.name.clone()).unwrap_or_default())));
|
||||
let sound = t.sound.and_then(|m| x.media_rel(o, m, "audio").map(|r| (r, x.media(m).map(|i| i.name.clone()).unwrap_or_default())));
|
||||
let snd = |w: &mut W| {
|
||||
if let Some((r, name)) = &sound {
|
||||
w.open0("p:sndAc");
|
||||
@@ -577,6 +577,21 @@ fn click_pars(w: &mut W, x: &mut Exp, ids: &mut Ids, clicks: &Groups, interactiv
|
||||
}
|
||||
}
|
||||
|
||||
/// Most effects one slide's timing writes; animations past it are left out.
|
||||
const MAX_EFFECTS: usize = 100_000;
|
||||
|
||||
/// The shapes of the tree by id, the first in document order for a repeated id (as `find_shape`
|
||||
/// finds it), so each animation resolves its shape without a tree walk.
|
||||
fn shape_index<'a>(shapes: &'a [Shape], depth: usize, out: &mut std::collections::HashMap<ShapeId, &'a Shape>) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for s in shapes {
|
||||
out.entry(s.id).or_insert(s);
|
||||
shape_index(s.children(), depth + 1, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `p:timing` for a slide's animations.
|
||||
pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idmap: &IdMap) {
|
||||
let mut grp_count: std::collections::HashMap<u32, u32> = Default::default();
|
||||
@@ -585,16 +600,31 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
|
||||
let mut bld: Vec<(u32, u32, bool)> = vec![];
|
||||
// The last paragraph effect written: (shape, class, trigger, grpId).
|
||||
let mut prev_para: Option<(ShapeId, AnimClass, Option<ShapeId>, u32)> = None;
|
||||
let mut by_id = std::collections::HashMap::new();
|
||||
shape_index(shapes, 0, &mut by_id);
|
||||
let mut roles_of: std::collections::HashMap<ShapeId, Vec<BuildRole>> = Default::default();
|
||||
let mut effect_count = 0usize;
|
||||
let mut warned = false;
|
||||
for a in anims.iter().take(10_000) {
|
||||
if effect_count >= MAX_EFFECTS {
|
||||
if !warned {
|
||||
log::warn!("pptx: more than {MAX_EFFECTS} animation effects; the rest are left out");
|
||||
}
|
||||
break;
|
||||
}
|
||||
let Some(spid) = idmap.get(a.shape) else {
|
||||
log::warn!("pptx: animation on missing shape {} skipped", a.shape);
|
||||
continue;
|
||||
};
|
||||
let Some(shape) = find_shape(shapes, a.shape) else { continue };
|
||||
let Some(&shape) = by_id.get(&a.shape) else { continue };
|
||||
let text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some();
|
||||
let is_text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.as_ref().is_some_and(|t| !t.is_empty());
|
||||
let text = shape.text.as_ref().filter(|_| a.text_build == TextBuild::ByParagraph && is_text_sp);
|
||||
let roles = text.map(paragraph_build_roles).unwrap_or_default();
|
||||
let no_roles = Vec::new();
|
||||
let roles = match text {
|
||||
Some(t) => roles_of.entry(a.shape).or_insert_with(|| paragraph_build_roles(t)),
|
||||
None => &no_roles,
|
||||
};
|
||||
let by_para = roles.contains(&BuildRole::Top);
|
||||
// PowerPoint keeps a shape's consecutive paragraph effects in one build (`grpId`) marked
|
||||
// `build="p"`; the reader turns them back into a by-paragraph build only if that plays them
|
||||
@@ -632,11 +662,19 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
|
||||
_ => continue,
|
||||
};
|
||||
let Ok(pi) = u32::try_from(i) else { break };
|
||||
if effect_count + effs.len() >= MAX_EFFECTS {
|
||||
if !warned {
|
||||
log::warn!("pptx: more than {MAX_EFFECTS} animation effects; the rest are left out");
|
||||
warned = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
effs.push(Eff { a, spid, start, paragraph: Some(pi), grp });
|
||||
}
|
||||
} else {
|
||||
effs.push(Eff { a, spid, start: a.start, paragraph: a.paragraph, grp });
|
||||
}
|
||||
effect_count += effs.len();
|
||||
if text_sp && !joins {
|
||||
bld.push((spid, grp, by_para || para_effect));
|
||||
}
|
||||
|
||||
@@ -146,6 +146,33 @@ fn comments_on_several_slides_round_trip() {
|
||||
assert_eq!(q.slides[2].comments[0].text, "Slide three");
|
||||
}
|
||||
|
||||
/// A slide that names a modern part another slide already owns, plus its own legacy part, must
|
||||
/// not import the legacy copy: modern precedence holds even when the modern part is not re-read.
|
||||
#[test]
|
||||
fn owned_modern_part_still_suppresses_later_legacy() {
|
||||
let mut p = deck();
|
||||
let layout = p.slides[0].layout;
|
||||
let s2 = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
p.slides.push(Arc::new(s2));
|
||||
Arc::make_mut(&mut p.slides[1]).comments = vec![Comment { author: "Cara".into(), text: "Slide two".into(), ..Default::default() }];
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let rels2 = part(&bytes, "ppt/slides/_rels/slide2.xml.rels").replace("modernComment2.xml", "modernComment1.xml");
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");
|
||||
if f.name() == "ppt/slides/_rels/slide2.xml.rels" {
|
||||
std::io::Write::write_all(&mut out, rels2.as_bytes()).expect("write");
|
||||
} else {
|
||||
std::io::copy(&mut f, &mut out).expect("copy");
|
||||
}
|
||||
}
|
||||
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
|
||||
assert_eq!(q.slides[0].comments.len(), 2);
|
||||
assert!(q.slides[1].comments.is_empty(), "{:?}", q.slides[1].comments);
|
||||
}
|
||||
|
||||
/// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions,
|
||||
/// flattened replies.
|
||||
#[test]
|
||||
|
||||
@@ -34,7 +34,7 @@ fn glow() -> Glow {
|
||||
}
|
||||
|
||||
fn reflection() -> Reflection {
|
||||
Reflection { blur: 2.0, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.75, dist: 6.0 }
|
||||
Reflection { blur: 2.0, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.75, dist: 6.0, ..Default::default() }
|
||||
}
|
||||
|
||||
fn gradient(angle: f64) -> Fill {
|
||||
|
||||
@@ -103,7 +103,7 @@ fn effects_and_links() -> Vec<u8> {
|
||||
"Reflected",
|
||||
"",
|
||||
8636000,
|
||||
r#"<a:reflection blurRad="6350" stA="50000" endA="300" endPos="55500" dist="50800" dir="5400000" sy="-100000" algn="bl" rotWithShape="0"/>"#,
|
||||
r#"<a:reflection blurRad="6350" stA="50000" endA="300" endPos="55500" dist="50800" stPos="20000" dir="5400000" fadeDir="0" sx="75000" sy="-50000" kx="600000" ky="-300000" algn="bl" rotWithShape="0"/>"#,
|
||||
r#"<a:p><a:r><a:rPr lang="en-US"><a:hlinkMouseOver r:id="rId90" tooltip="Preview"/></a:rPr><a:t>Hover me</a:t></a:r></a:p>"#,
|
||||
);
|
||||
let slide = format!(
|
||||
@@ -134,6 +134,11 @@ fn effects_and_links_survive_a_round_trip() {
|
||||
assert_eq!(fx(get("Soft edged")).soft_edge, Some(5.0));
|
||||
let r = fx(get("Reflected")).reflection.expect("reflection");
|
||||
assert!((r.end_pos - 0.555).abs() < 1e-6 && (r.dist - 4.0).abs() < 1e-6, "{r:?}");
|
||||
assert_eq!(
|
||||
(r.st_pos, r.dir, r.fade_dir, r.sx, r.sy, r.kx, r.ky),
|
||||
(Some(20_000), Some(5_400_000), Some(0), Some(75_000), Some(-50_000), Some(600_000), Some(-300_000))
|
||||
);
|
||||
assert_eq!((r.align.as_deref(), r.rotate_with_shape), (Some("bl"), Some(false)));
|
||||
let shape = get("Shadowed");
|
||||
assert!(matches!(shape.click.as_ref().map(|h| (&h.action, h.highlight_click)), Some((Action::EndShow, true))));
|
||||
assert!(
|
||||
@@ -173,7 +178,17 @@ fn effects_and_links_survive_resave(a: &Presentation) {
|
||||
assert_eq!(ha, hb, "text hover of {n}");
|
||||
}
|
||||
let out = deckcraft_pptx::export(&b).expect("export");
|
||||
assert!(part(&out, "ppt/slides/slide1.xml").contains("hlinkMouseOver"));
|
||||
let xml = part(&out, "ppt/slides/slide1.xml");
|
||||
assert!(xml.contains("hlinkMouseOver"));
|
||||
let c = deckcraft_pptx::import(&out).expect("second reopen");
|
||||
assert_eq!(fx(shapes_named(&b, 0)("Reflected")).reflection, fx(shapes_named(&c, 0)("Reflected")).reflection);
|
||||
assert!(
|
||||
xml.contains("stPos=\"20000\"")
|
||||
&& xml.contains("fadeDir=\"0\"")
|
||||
&& xml.contains("sx=\"75000\"")
|
||||
&& xml.contains("sy=\"-50000\"")
|
||||
&& xml.contains("kx=\"600000\"")
|
||||
);
|
||||
}
|
||||
|
||||
/// Two masters with their own themes (colours, fonts) and layouts, slides on each.
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
//! Export looks media, layouts, sections and table styles up by id: a deck with many of each and
|
||||
//! many references to the last ones exports in time linear in its size.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{Section, Shape, ShapeId, ShapeKind, defaults};
|
||||
|
||||
#[test]
|
||||
fn many_media_sections_and_table_styles_export() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
// 5,000 media items; 5,000 pictures of the last one.
|
||||
let mut last = None;
|
||||
for i in 0..5_000u32 {
|
||||
last = p.add_media(&format!("m{i}.png"), "image/png", i.to_le_bytes().to_vec());
|
||||
}
|
||||
let last = last.unwrap();
|
||||
let pic = |k: u32| Shape {
|
||||
id: ShapeId(100_000 + k),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media: last, ..Default::default() } },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = (0..5_000).map(pic).collect();
|
||||
// A section naming the slide 50,000 times, after 2,000 empty sections.
|
||||
let id = p.slides[0].id;
|
||||
p.sections = (0..2_000).map(|i| Section { name: format!("s{i}"), slides: vec![] }).collect();
|
||||
p.sections.push(Section { name: "last".into(), slides: vec![id; 50_000] });
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let q = deckcraft_pptx::import(&bytes).expect("import");
|
||||
assert_eq!(q.slides[0].shapes.iter().filter(|s| matches!(s.kind, ShapeKind::Picture { .. })).count(), 5_000);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ mod corpus_kinds;
|
||||
mod corpus_media;
|
||||
mod corpus_style;
|
||||
mod corpus_themes;
|
||||
mod export_scale;
|
||||
mod fills;
|
||||
mod kept_parts;
|
||||
mod locks;
|
||||
@@ -18,6 +19,7 @@ mod media_timing;
|
||||
mod notes;
|
||||
mod ole;
|
||||
mod ole_vml;
|
||||
mod parent_delays;
|
||||
mod placeholders;
|
||||
mod roundtrip;
|
||||
mod sample;
|
||||
|
||||
@@ -287,3 +287,34 @@ fn unreadable_slide_is_reported() {
|
||||
assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]);
|
||||
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
|
||||
}
|
||||
|
||||
/// Many slides naming one notes part: it is read for the first slide only, not copied onto every
|
||||
/// slide that names it.
|
||||
#[test]
|
||||
fn a_shared_notes_part_is_read_once() {
|
||||
use std::sync::Arc;
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = p.slides[0].layout;
|
||||
for _ in 0..3 {
|
||||
let s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
p.slides.push(Arc::new(s));
|
||||
}
|
||||
for (i, s) in p.slides.iter_mut().enumerate() {
|
||||
Arc::make_mut(s).notes = deckcraft_model::text::TextBody::from_text(&format!("notes {i}"));
|
||||
}
|
||||
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
|
||||
for (n, d) in es.iter_mut() {
|
||||
if n.starts_with("ppt/slides/_rels/") {
|
||||
let x = String::from_utf8(d.clone()).expect("utf8");
|
||||
let x = x
|
||||
.replace("notesSlide2.xml", "notesSlide1.xml")
|
||||
.replace("notesSlide3.xml", "notesSlide1.xml")
|
||||
.replace("notesSlide4.xml", "notesSlide1.xml");
|
||||
*d = x.into_bytes();
|
||||
}
|
||||
}
|
||||
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
|
||||
assert_eq!(q.slides.len(), 4);
|
||||
assert_eq!(q.slides[0].notes.text(), "notes 0");
|
||||
assert!(q.slides[1..].iter().all(|s| s.notes.text().is_empty()), "a shared notes part is not copied");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! PowerPoint places an effect at the sum of its enclosing time nodes' delays, so a file can list
|
||||
//! effects out of time order. The BNI meeting-deck generator writes a 1 s countdown chain first,
|
||||
//! then "swap to the visual at 5 s" effects placed only by their parent `p:par` delay: read in file
|
||||
//! order, those all fired together when the countdown ended.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::anim::AnimStart;
|
||||
use deckcraft_model::{Shape, ShapeId, defaults};
|
||||
|
||||
/// An "appear"/"disappear" effect (preset 1) in its own `p:par`, as PowerPoint writes it.
|
||||
fn eff(id: u32, class: &str, node: &str, delay: u32, spid: u32) -> String {
|
||||
let vis = if class == "entr" { "visible" } else { "hidden" };
|
||||
format!(
|
||||
r#"<p:par><p:cTn id="{id}" presetID="1" presetClass="{class}" presetSubtype="0" fill="hold" nodeType="{node}"><p:stCondLst><p:cond delay="{delay}"/></p:stCondLst><p:childTnLst><p:set><p:cBhvr><p:cTn id="{}" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="{spid}"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="{vis}"/></p:to></p:set></p:childTnLst></p:cTn></p:par>"#,
|
||||
id + 1000
|
||||
)
|
||||
}
|
||||
|
||||
/// A group `p:par` starting `delay` ms into the click, holding `inner`.
|
||||
fn group(id: u32, delay: u32, inner: &str) -> String {
|
||||
format!(
|
||||
r#"<p:par><p:cTn id="{id}" fill="hold"><p:stCondLst><p:cond delay="{delay}"/></p:stCondLst><p:childTnLst>{inner}</p:childTnLst></p:cTn></p:par>"#
|
||||
)
|
||||
}
|
||||
|
||||
fn import_with_timing(build: impl Fn(&[u32]) -> String) -> Vec<(String, AnimStart, u32)> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let shapes: Vec<Shape> = ["Three", "Two", "One", "Visual"]
|
||||
.into_iter()
|
||||
.map(|name| Shape {
|
||||
id: ShapeId(p.alloc_id().expect("id")),
|
||||
name: name.into(),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 50.0)),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = shapes;
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(deckcraft_pptx::export(&p).expect("export"))).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).expect("read");
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
let xml = String::from_utf8(d).expect("utf8");
|
||||
let spids: Vec<u32> = ["Three", "Two", "One", "Visual"]
|
||||
.iter()
|
||||
.map(|n| {
|
||||
let at = xml.find(&format!(r#"name="{n}""#)).expect("shape written");
|
||||
let open = xml[..at].rfind("<p:cNvPr ").expect("cNvPr");
|
||||
let tag = &xml[open..at];
|
||||
let id = tag.split(r#"id=""#).nth(1).expect("id").split('"').next().expect("id");
|
||||
id.parse().expect("number")
|
||||
})
|
||||
.collect();
|
||||
let timing = format!(
|
||||
r#"<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst><p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst><p:par><p:cTn id="3" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst>{}</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn><p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst><p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst></p:seq></p:childTnLst></p:cTn></p:par></p:tnLst></p:timing>"#,
|
||||
build(&spids)
|
||||
);
|
||||
let xml = match (xml.find("<p:timing>"), xml.find("</p:timing>")) {
|
||||
(Some(a), Some(b)) => format!("{}{}{}", &xml[..a], timing, &xml[b + "</p:timing>".len()..]),
|
||||
_ => xml.replacen("</p:sld>", &format!("{timing}</p:sld>"), 1),
|
||||
};
|
||||
d = xml.into_bytes();
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||
out.write_all(&d).expect("write");
|
||||
}
|
||||
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
|
||||
let s = &q.slides[0];
|
||||
s.animations
|
||||
.iter()
|
||||
.map(|a| {
|
||||
let name = s.shapes.iter().find(|x| x.id == a.shape).map(|x| x.name.clone()).unwrap_or_default();
|
||||
(name, a.start, a.delay_ms)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Start (ms after the click) of each effect when the flat list is played effect after effect.
|
||||
fn flat(effects: &[(String, AnimStart, u32)]) -> Vec<(String, u32)> {
|
||||
let (mut begin, mut end) = (0u32, 0u32);
|
||||
effects
|
||||
.iter()
|
||||
.map(|(n, start, delay)| {
|
||||
if *start == AnimStart::AfterPrevious {
|
||||
begin = end;
|
||||
}
|
||||
let t = begin + delay;
|
||||
end = end.max(t);
|
||||
(n.clone(), t)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effects_placed_by_parent_delay_play_when_powerpoint_plays_them() {
|
||||
// Click: "Three" goes, then "Two" after 1 s, "One" after 1 s more (a countdown chain), then —
|
||||
// written last — the visual appears at 1 s, placed only by its parent's delay.
|
||||
let fx = import_with_timing(|s| {
|
||||
[
|
||||
group(10, 0, &eff(11, "exit", "clickEffect", 0, s[0])),
|
||||
group(20, 0, &eff(21, "exit", "afterEffect", 1000, s[1])),
|
||||
group(30, 1000, &eff(31, "exit", "afterEffect", 1000, s[2])),
|
||||
group(40, 1000, &eff(41, "entr", "afterEffect", 0, s[3])),
|
||||
]
|
||||
.concat()
|
||||
});
|
||||
let got = flat(&fx);
|
||||
let at = |n: &str| got.iter().find(|(x, _)| x == n).map(|(_, t)| *t);
|
||||
assert_eq!((at("Three"), at("Two"), at("Visual"), at("One")), (Some(0), Some(1000), Some(1000), Some(2000)), "{fx:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timeline_already_in_order_is_read_unchanged() {
|
||||
let fx = import_with_timing(|s| {
|
||||
[
|
||||
group(10, 0, &[eff(11, "exit", "clickEffect", 0, s[0]), eff(12, "entr", "withEffect", 500, s[3])].concat()),
|
||||
group(20, 500, &eff(21, "exit", "afterEffect", 1000, s[1])),
|
||||
]
|
||||
.concat()
|
||||
});
|
||||
let starts: Vec<_> = fx.iter().map(|(n, st, d)| (n.as_str(), *st, *d)).collect();
|
||||
assert_eq!(starts, [("Three", AnimStart::OnClick, 0), ("Visual", AnimStart::WithPrevious, 500), ("Two", AnimStart::AfterPrevious, 1000)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_delayed_click_effect_sorted_after_a_with_effect_keeps_the_click_on_the_group() {
|
||||
// The click effect waits 500 ms; a with-effect in the same click plays at once, and a later
|
||||
// effect is placed only by its parent's delay. Sorted, the with-effect plays first and takes
|
||||
// the click, so the group still waits for one.
|
||||
let fx = import_with_timing(|s| {
|
||||
[
|
||||
group(10, 0, &[eff(11, "exit", "clickEffect", 500, s[0]), eff(12, "entr", "withEffect", 0, s[3])].concat()),
|
||||
group(20, 1000, &eff(21, "exit", "afterEffect", 0, s[1])),
|
||||
]
|
||||
.concat()
|
||||
});
|
||||
let starts: Vec<_> = fx.iter().map(|(n, st, _)| (n.as_str(), *st)).collect();
|
||||
assert_eq!(starts[0], ("Visual", AnimStart::OnClick), "{fx:?}");
|
||||
assert_eq!(starts.iter().filter(|(_, st)| *st == AnimStart::OnClick).count(), 1, "{fx:?}");
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use deckcraft_model::style::{
|
||||
};
|
||||
use deckcraft_model::table::Table;
|
||||
use deckcraft_model::text::{
|
||||
Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, Paragraph, Run, RunKind, RunProps, Spacing, Strike, TextBody,
|
||||
Action, Align, Anchor, AutoFit, BodyProps, Bullet, Caps, Hyperlink, Paragraph, Run, RunKind, RunProps, Spacing, Strike, TextBody, TextOverflow,
|
||||
};
|
||||
use deckcraft_model::{
|
||||
Background, Comment, CustomPath, CustomShow, Geom, InkStroke, MediaClip, Presentation, Section, Shape, ShapeId, ShapeKind, Slide, SlideId,
|
||||
@@ -468,6 +468,143 @@ fn text_and_formatting_round_trip() {
|
||||
assert_eq!(run.text, "x=1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centered_cell_anchor_survives_two_pptx_round_trips() {
|
||||
let mut p = Presentation::default();
|
||||
let mut slide = new_slide(&mut p, "Cell anchor");
|
||||
let mut table_shape = shape(&mut p, "Table", 10.0, 10.0, 200.0, 80.0);
|
||||
let mut table = Table::new(1, 1, 200.0, 80.0);
|
||||
table.rows[0].cells[0].anchor_ctr = Some(true);
|
||||
table_shape.kind = ShapeKind::Table(table);
|
||||
slide.shapes.push(table_shape);
|
||||
p.slides.push(Arc::new(slide));
|
||||
|
||||
let once = round(&p);
|
||||
let twice = round(&once);
|
||||
let slide = twice.slides.last().expect("added slide");
|
||||
let ShapeKind::Table(table) = &by_name(slide, "Table").kind else { panic!("table") };
|
||||
assert_eq!(table.rows[0].cells[0].anchor_ctr, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_character_height_survives_two_pptx_round_trips() {
|
||||
let mut p = Presentation::default();
|
||||
let mut slide = new_slide(&mut p, "Normalized height");
|
||||
let mut enabled = shape(&mut p, "Enabled", 10.0, 10.0, 200.0, 80.0);
|
||||
let mut enabled_body = TextBody::from_text("enabled");
|
||||
enabled_body.paragraphs[0].runs[0].props.normalize_height = Some(true);
|
||||
enabled_body.paragraphs[0].runs[0].props.size = Some(18.0);
|
||||
enabled_body.paragraphs[0].runs[0].props.caps = Some(Caps::Small);
|
||||
enabled_body.paragraphs[0].runs[0].props.spacing = Some(1.5);
|
||||
enabled.text = Some(enabled_body);
|
||||
let mut disabled = shape(&mut p, "Disabled", 10.0, 100.0, 200.0, 80.0);
|
||||
let mut disabled_body = TextBody::from_text("disabled");
|
||||
disabled_body.paragraphs[0].runs[0].props.normalize_height = Some(false);
|
||||
disabled.text = Some(disabled_body);
|
||||
let mut absent = shape(&mut p, "Absent", 10.0, 190.0, 200.0, 80.0);
|
||||
absent.text = Some(TextBody::from_text("absent"));
|
||||
slide.shapes = vec![enabled, disabled, absent];
|
||||
p.slides.push(Arc::new(slide));
|
||||
|
||||
let twice = round(&round(&p));
|
||||
let slide = twice.slides.last().expect("added slide");
|
||||
let run = &by_name(slide, "Enabled").text.as_ref().expect("text").paragraphs[0].runs[0];
|
||||
assert_eq!(run.props.normalize_height, Some(true));
|
||||
assert_eq!(run.props.size, Some(18.0));
|
||||
assert_eq!(run.props.caps, Some(Caps::Small));
|
||||
assert_eq!(run.props.spacing, Some(1.5));
|
||||
let run = &by_name(slide, "Disabled").text.as_ref().expect("text").paragraphs[0].runs[0];
|
||||
assert_eq!(run.props.normalize_height, Some(false));
|
||||
let run = &by_name(slide, "Absent").text.as_ref().expect("text").paragraphs[0].runs[0];
|
||||
assert_eq!(run.props.normalize_height, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_distributed_alignment_survives_two_pptx_round_trips() {
|
||||
assert_eq!(Align::from_xml("thaiDist"), Some(Align::ThaiDistributed));
|
||||
assert_eq!(Align::ThaiDistributed.xml(), "thaiDist");
|
||||
assert_eq!(Align::from_xml("just"), Some(Align::Justify));
|
||||
|
||||
let mut p = Presentation::default();
|
||||
let mut slide = new_slide(&mut p, "Thai distributed alignment");
|
||||
let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0);
|
||||
let mut body = TextBody::from_text("one two three");
|
||||
body.paragraphs[0].props.align = Some(Align::ThaiDistributed);
|
||||
text.text = Some(body);
|
||||
slide.shapes.push(text);
|
||||
p.slides.push(Arc::new(slide));
|
||||
|
||||
let twice = round(&round(&p));
|
||||
let slide = twice.slides.last().expect("added slide");
|
||||
let text = by_name(slide, "Text").text.as_ref().expect("text body");
|
||||
assert_eq!(text.paragraphs[0].props.align, Some(Align::ThaiDistributed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_line_spacing_mode_survives_two_pptx_round_trips() {
|
||||
let mut p = Presentation::default();
|
||||
let mut slide = new_slide(&mut p, "Compatible line spacing");
|
||||
let mut enabled = shape(&mut p, "Enabled", 10.0, 10.0, 200.0, 80.0);
|
||||
let mut enabled_body = TextBody::from_text("enabled");
|
||||
enabled_body.body.compat_ln_spc = Some(true);
|
||||
enabled.text = Some(enabled_body);
|
||||
let mut disabled = shape(&mut p, "Disabled", 10.0, 100.0, 200.0, 80.0);
|
||||
let mut disabled_body = TextBody::from_text("disabled");
|
||||
disabled_body.body.compat_ln_spc = Some(false);
|
||||
disabled.text = Some(disabled_body);
|
||||
slide.shapes = vec![enabled, disabled];
|
||||
p.slides.push(Arc::new(slide));
|
||||
|
||||
let twice = round(&round(&p));
|
||||
let slide = twice.slides.last().expect("added slide");
|
||||
assert_eq!(by_name(slide, "Enabled").text.as_ref().expect("text").body.compat_ln_spc, Some(true));
|
||||
assert_eq!(by_name(slide, "Disabled").text.as_ref().expect("text").body.compat_ln_spc, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_overflow_policies_survive_two_pptx_round_trips() {
|
||||
let mut p = Presentation::default();
|
||||
let mut slide = new_slide(&mut p, "Text overflow");
|
||||
let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0);
|
||||
let mut body = TextBody::from_text("policy");
|
||||
body.body.vert_overflow = Some(TextOverflow::Clip);
|
||||
body.body.horz_overflow = Some(TextOverflow::Overflow);
|
||||
text.text = Some(body);
|
||||
let mut table_shape = shape(&mut p, "Table", 10.0, 100.0, 200.0, 80.0);
|
||||
let mut table = Table::new(1, 1, 200.0, 80.0);
|
||||
table.rows[0].cells[0].horz_overflow = Some(TextOverflow::Clip);
|
||||
table_shape.kind = ShapeKind::Table(table);
|
||||
slide.shapes = vec![text, table_shape];
|
||||
p.slides.push(Arc::new(slide));
|
||||
|
||||
let twice = round(&round(&p));
|
||||
let slide = twice.slides.last().expect("added slide");
|
||||
let text = by_name(slide, "Text").text.as_ref().expect("text body");
|
||||
assert_eq!(text.body.vert_overflow, Some(TextOverflow::Clip));
|
||||
assert_eq!(text.body.horz_overflow, Some(TextOverflow::Overflow));
|
||||
let ShapeKind::Table(table) = &by_name(slide, "Table").kind else { panic!("table") };
|
||||
assert_eq!(table.rows[0].cells[0].horz_overflow, Some(TextOverflow::Clip));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn horizontal_ellipsis_is_not_written_because_only_vertical_overflow_has_it() {
|
||||
let mut p = Presentation::default();
|
||||
let mut slide = new_slide(&mut p, "Horizontal ellipsis");
|
||||
let mut text = shape(&mut p, "Text", 10.0, 10.0, 200.0, 80.0);
|
||||
let mut body = TextBody::from_text("policy");
|
||||
body.body.vert_overflow = Some(TextOverflow::Ellipsis);
|
||||
body.body.horz_overflow = Some(TextOverflow::Ellipsis);
|
||||
text.text = Some(body);
|
||||
slide.shapes = vec![text];
|
||||
p.slides.push(Arc::new(slide));
|
||||
|
||||
let once = round(&p);
|
||||
let slide = once.slides.last().expect("added slide");
|
||||
let text = by_name(slide, "Text").text.as_ref().expect("text body");
|
||||
assert_eq!(text.body.vert_overflow, Some(TextOverflow::Ellipsis));
|
||||
assert_eq!(text.body.horz_overflow, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_fills_lines_effects_round_trip() {
|
||||
let p = rich_deck();
|
||||
@@ -490,6 +627,33 @@ fn shapes_fills_lines_effects_round_trip() {
|
||||
assert_eq!(by_name(&q.slides[1], "Group").id, by_name(&p.slides[1], "Group").id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullet_size_in_points_survives_two_pptx_round_trips() {
|
||||
let mut p = Presentation::default();
|
||||
let mut textbox = shape(&mut p, "Point-sized bullet", 20.0, 20.0, 400.0, 100.0);
|
||||
textbox.text = Some(TextBody {
|
||||
paragraphs: [12.0, 500.0, 4000.0]
|
||||
.into_iter()
|
||||
.map(|size| Paragraph {
|
||||
props: deckcraft_model::text::ParaProps {
|
||||
bullet: Some(Bullet::Char { char: "•".into() }),
|
||||
bullet_size_points: Some(size),
|
||||
..Default::default()
|
||||
},
|
||||
runs: vec![Run::with("Bullet text", RunProps::default())],
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
});
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(textbox);
|
||||
|
||||
let q = round(&round(&p));
|
||||
let props = &by_name(&q.slides[0], "Point-sized bullet").text.as_ref().expect("text").paragraphs;
|
||||
assert_eq!(props.iter().map(|p| p.props.bullet_size_points).collect::<Vec<_>>(), vec![Some(12.0), Some(500.0), Some(4000.0)]);
|
||||
assert!(props.iter().all(|p| p.props.bullet_size.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_miter_limit_survives_two_pptx_round_trips() {
|
||||
let mut p = Presentation::default();
|
||||
@@ -1500,6 +1664,32 @@ fn literal_line_ends_in_run_text_read_as_breaks_and_survive_save() {
|
||||
assert_eq!(runs_of(&round(&a), "one"), want, "save and reopen keep the runs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_run_language_survives_pptx_saves_and_absence() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let first = p.slides[0].id;
|
||||
let slide = p.slide_mut(first).expect("slide");
|
||||
slide.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 = round(&p);
|
||||
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 = round(&q);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_pulse_keeps_its_effect_colour_and_duration() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
@@ -2067,3 +2257,54 @@ fn default_durations_match_powerpoint() {
|
||||
assert_eq!(a.4, ms, "{effect}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Show media controls is PowerPoint's `p14:showMediaCtrls` extension inside `p:showPr`: off is
|
||||
/// written there, under its extension uri and with the p14 namespace declared on the element;
|
||||
/// on (the default) writes nothing.
|
||||
#[test]
|
||||
fn hidden_media_controls_survive_save_and_reopen() {
|
||||
let props = |p: &Presentation| {
|
||||
let bytes = deckcraft_pptx::export(p).expect("export");
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
|
||||
let mut x = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("ppt/presProps.xml").expect("presProps"), &mut x).expect("read");
|
||||
x
|
||||
};
|
||||
let mut p = Presentation::default();
|
||||
p.show.show_media_controls = false;
|
||||
let xml = props(&p);
|
||||
let show = xml.split("<p:showPr").nth(1).and_then(|r| r.split("</p:showPr>").next()).expect("p:showPr");
|
||||
assert!(
|
||||
show.contains(
|
||||
"<p:ext uri=\"{2FDB2607-1784-4EEB-B798-7EB5836EED8A}\"><p14:showMediaCtrls xmlns:p14=\"http://schemas.microsoft.com/office/powerpoint/2010/main\" val=\"0\"/></p:ext>"
|
||||
),
|
||||
"{xml}"
|
||||
);
|
||||
assert!(!round(&p).show.show_media_controls);
|
||||
let default = Presentation::default();
|
||||
assert!(!props(&default).contains("showMediaCtrls"));
|
||||
assert!(round(&default).show.show_media_controls);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflection_angles_and_skews_out_of_range_are_written_inside_the_schema() {
|
||||
let mut p = Presentation::default();
|
||||
let mut slide = new_slide(&mut p, "Reflection ranges");
|
||||
let mut s = shape(&mut p, "Reflected", 10.0, 10.0, 200.0, 80.0);
|
||||
s.effects = Some(deckcraft_model::style::Effects {
|
||||
reflection: Some(deckcraft_model::style::Reflection {
|
||||
st_pos: Some(150_000),
|
||||
dir: Some(-5),
|
||||
fade_dir: Some(21_600_000),
|
||||
kx: Some(9_000_000),
|
||||
ky: Some(-9_000_000),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
slide.shapes.push(s);
|
||||
p.slides.push(Arc::new(slide));
|
||||
|
||||
let r = round(&p).slides.last().and_then(|sl| by_name(sl, "Reflected").effects.clone()).and_then(|fx| fx.reflection).expect("reflection");
|
||||
assert_eq!((r.st_pos, r.dir, r.fade_dir, r.kx, r.ky), (Some(100_000), Some(21_599_995), Some(0), Some(5_399_999), Some(-5_399_999)));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Charts: title, plot area with axes and gridlines, series marks, legend.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_model::Shape;
|
||||
use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale};
|
||||
@@ -85,9 +87,9 @@ fn bar_outline(ctx: &Ctx, s: &deckcraft_model::chart::Series) -> Option<(Rgba, f
|
||||
/// Paint of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
|
||||
/// (not waterfalls, which are never read from a `<c:barChart>`). Pattern fills draw as patterns and
|
||||
/// `a:noFill` leaves the bar empty, as in PowerPoint.
|
||||
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Paint {
|
||||
if let Some((_, f)) = s.point_fills.iter().find(|(k, _)| *k == pi) {
|
||||
return paint_of(ctx, Some(f), series_fill(ctx, c, si, Some(s)));
|
||||
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series, fills: Option<&HashMap<usize, &Fill>>) -> Paint {
|
||||
if let Some(f) = fills.and_then(|m| m.get(&pi)) {
|
||||
return paint_of(ctx, Some(*f), series_fill(ctx, c, si, Some(s)));
|
||||
}
|
||||
if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall {
|
||||
return Paint::Color(series_color(ctx, c, pi));
|
||||
@@ -883,6 +885,8 @@ fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) {
|
||||
let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) };
|
||||
let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values));
|
||||
let mut pts = vec![];
|
||||
// Whether the series has any x value, found once rather than per point.
|
||||
let any_x = s.x_values.iter().flatten().next().is_some();
|
||||
for i in 0..ncat {
|
||||
let Some(v) = s.values.get(i).copied().flatten() else { continue };
|
||||
let v = if stacked {
|
||||
@@ -896,11 +900,11 @@ fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) {
|
||||
};
|
||||
let x = match kind {
|
||||
ChartType::Scatter | ChartType::Bubble => {
|
||||
match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) {
|
||||
match (s.x_values.get(i).copied().flatten().and_then(to_x), any_x) {
|
||||
(Some(x), _) => x,
|
||||
// No x values at all: the points are numbered 1, 2, 3...
|
||||
(None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)),
|
||||
(None, Some(_)) => continue,
|
||||
(None, false) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)),
|
||||
(None, true) => continue,
|
||||
}
|
||||
}
|
||||
_ => plot.x0 + band * (slot(i) + 0.5),
|
||||
@@ -970,6 +974,19 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
|
||||
let group = bar + step * (n - 1.0);
|
||||
// Error bars need each series' mean and spread: worked out once, not per point.
|
||||
let stats: Vec<deckcraft_model::chart::SeriesStats> = bars.iter().map(|(_, s)| deckcraft_model::chart::SeriesStats::of(&s.values)).collect();
|
||||
// Each series' per-point fills by point (the first for a point wins), not searched per bar.
|
||||
let point_fills: Vec<HashMap<usize, &Fill>> = bars
|
||||
.iter()
|
||||
.map(|(_, s)| {
|
||||
let mut m = HashMap::new();
|
||||
for (k, f) in &s.point_fills {
|
||||
m.entry(*k).or_insert(f);
|
||||
}
|
||||
m
|
||||
})
|
||||
.collect();
|
||||
// A waterfall's running bars, worked out once for all its categories.
|
||||
let waterfall_bars = if waterfall { c.waterfall_bars() } else { Vec::new() };
|
||||
for i in 0..ncat {
|
||||
let mut pos_acc = 0.0;
|
||||
let mut neg_acc = 0.0;
|
||||
@@ -983,7 +1000,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
|
||||
let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total;
|
||||
let start = band * slot(i) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 };
|
||||
let (a, b) = if waterfall {
|
||||
c.waterfall_bars().get(i).copied().unwrap_or_default()
|
||||
waterfall_bars.get(i).copied().unwrap_or_default()
|
||||
} else if stacked {
|
||||
if v >= 0.0 {
|
||||
let r = (pos_acc, pos_acc + v);
|
||||
@@ -1015,7 +1032,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
|
||||
};
|
||||
Paint::Color(series_color(rctx, c, slot))
|
||||
} else {
|
||||
bar_paint(rctx, c, *si, i, s)
|
||||
bar_paint(rctx, c, *si, i, s, point_fills.get(bi))
|
||||
};
|
||||
fill_with(ctx, &paint, r.abs(), m);
|
||||
if !waterfall && let Some((oc, ow)) = bar_outline(rctx, s).filter(|o| o.1 > 0.0) {
|
||||
|
||||
@@ -37,7 +37,8 @@ struct Cache {
|
||||
bad_order: VecDeque<usize>,
|
||||
bad_bytes: usize,
|
||||
/// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them.
|
||||
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>)>,
|
||||
/// Decoded animations by (bytes, adjustments), each with the clock of its last use.
|
||||
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>, u64)>,
|
||||
}
|
||||
|
||||
/// Longest side, total decoded size and frame count kept for one animated GIF; the largest canvas
|
||||
@@ -432,16 +433,33 @@ fn anim(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust) -> Option<Arc<Anim>> {
|
||||
return None;
|
||||
}
|
||||
let key = (Arc::as_ptr(bytes) as usize, adjust_key(adj));
|
||||
if let Some(a) = with(|c| c.anims.get(&key).map(|e| e.1.clone())) {
|
||||
let hit = with(|c| {
|
||||
c.clock += 1;
|
||||
let clock = c.clock;
|
||||
c.anims.get_mut(&key).map(|e| {
|
||||
e.2 = clock;
|
||||
e.1.clone()
|
||||
})
|
||||
});
|
||||
if let Some(a) = hit {
|
||||
return a;
|
||||
}
|
||||
let a = decode_anim(bytes, adj).map(Arc::new);
|
||||
with(|c| {
|
||||
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
|
||||
if held + a.as_ref().map_or(0, |a| a.bytes()) > GIF_CACHE_BUDGET || c.anims.len() >= 64 {
|
||||
c.anims.clear();
|
||||
// Make room by dropping the least recently used animations, not every one of them, so a
|
||||
// slide whose animations overflow the cache does not decode all of them again each frame.
|
||||
let new = a.as_ref().map_or(0, |a| a.bytes());
|
||||
loop {
|
||||
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
|
||||
if held + new <= GIF_CACHE_BUDGET && c.anims.len() < 64 {
|
||||
break;
|
||||
}
|
||||
let Some(oldest) = c.anims.iter().min_by_key(|(_, e)| e.2).map(|(k, _)| *k) else { break };
|
||||
c.anims.remove(&oldest);
|
||||
}
|
||||
c.anims.insert(key, (bytes.clone(), a.clone()));
|
||||
c.clock += 1;
|
||||
let clock = c.clock;
|
||||
c.anims.insert(key, (bytes.clone(), a.clone(), clock));
|
||||
});
|
||||
a
|
||||
}
|
||||
@@ -535,6 +553,26 @@ mod tests {
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
/// A two-frame GIF whose first pixel is `n`, so each one has its own bytes.
|
||||
fn gif(n: u8) -> Arc<Vec<u8>> {
|
||||
use image::codecs::gif::GifEncoder;
|
||||
let frame = |c: u8| image::Frame::new(image::RgbaImage::from_pixel(2, 2, image::Rgba([n, c, 0, 255])));
|
||||
let mut out = Vec::new();
|
||||
GifEncoder::new(&mut out).encode_frames([frame(0), frame(255)]).expect("encode");
|
||||
Arc::new(out)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_animation_cache_drops_the_oldest_entry_not_all_of_them() {
|
||||
let adj = PictureAdjust::default();
|
||||
let gifs: Vec<_> = (0..70).map(gif).collect();
|
||||
for g in &gifs {
|
||||
assert!(anim(g, &adj).is_some());
|
||||
}
|
||||
let (len, newest) = with(|c| (c.anims.len(), c.anims.contains_key(&(Arc::as_ptr(&gifs[69]) as usize, adjust_key(&adj)))));
|
||||
assert!(newest && len >= 32, "{len} cached");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rgba_conversion_is_refused_past_the_cap() {
|
||||
let png = grey_png(100, 50);
|
||||
|
||||
@@ -54,7 +54,7 @@ pub use images::{decode as decode_image, gif_frame};
|
||||
pub use morph_path::PathMorph;
|
||||
pub use morph_text::TextMorph;
|
||||
pub use pattern::{is_fg as pattern_is_fg, presets as pattern_presets, tile as pattern_tile};
|
||||
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline};
|
||||
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, place_slide_with, text_outline};
|
||||
pub use table::cell_body as table_cell_body;
|
||||
pub use table::cell_rects as table_cell_rects;
|
||||
pub use table::row_heights as table_row_heights;
|
||||
|
||||
@@ -76,16 +76,12 @@ fn tokens(l: &TextLayout, chars: bool) -> (Vec<Token>, Vec<(usize, usize)>) {
|
||||
|
||||
/// For each token of `b`, the token of `a` with the same text, in reading order, each used once.
|
||||
fn matches(a: &[Token], b: &[Token]) -> Vec<Option<usize>> {
|
||||
let mut used = vec![false; a.len()];
|
||||
b.iter()
|
||||
.map(|tb| {
|
||||
let i = a.iter().enumerate().position(|(i, ta)| !used.get(i).copied().unwrap_or(true) && ta.key == tb.key)?;
|
||||
if let Some(u) = used.get_mut(i) {
|
||||
*u = true;
|
||||
}
|
||||
Some(i)
|
||||
})
|
||||
.collect()
|
||||
// Unused tokens of `a` by text, in reading order, so each lookup is a pop rather than a scan.
|
||||
let mut free: std::collections::HashMap<&str, std::collections::VecDeque<usize>> = std::collections::HashMap::new();
|
||||
for (i, ta) in a.iter().enumerate() {
|
||||
free.entry(ta.key.as_str()).or_default().push_back(i);
|
||||
}
|
||||
b.iter().map(|tb| free.get_mut(tb.key.as_str())?.pop_front()).collect()
|
||||
}
|
||||
|
||||
fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option<TextLayout> {
|
||||
@@ -228,3 +224,19 @@ pub(crate) fn draw(
|
||||
pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec<String> {
|
||||
tokens(l, chars).0.into_iter().map(|t| t.key).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod match_tests {
|
||||
use super::*;
|
||||
|
||||
fn tok(k: &str) -> Token {
|
||||
Token { key: k.into(), glyphs: Vec::new(), origin: Point::ZERO, run: 0 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_source_token_is_used_once_in_reading_order() {
|
||||
let a: Vec<Token> = ["a", "b", "a"].map(tok).into();
|
||||
let b: Vec<Token> = ["a", "a", "a", "b", "c"].map(tok).into();
|
||||
assert_eq!(matches(&a, &b), vec![Some(0), Some(2), None, Some(1), None]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,10 @@ pub fn fill_path(
|
||||
let on_path = m * path.clone();
|
||||
ctx.set_transform(Affine::IDENTITY);
|
||||
ctx.push_clip_layer(&on_path);
|
||||
picture(ctx, pres, &resolved(rctx, pf, ph), on_slide, Affine::IDENTITY, None);
|
||||
// Drawn in device pixels, so a tile (given in shape units) is sized to pixels first.
|
||||
let tiled = tiled_per_point(fill, (c[0].hypot(c[1]), c[2].hypot(c[3])));
|
||||
let upright = if let Fill::Picture(p) = tiled.as_ref() { p } else { pf };
|
||||
picture(ctx, pres, &resolved(rctx, upright, ph), on_slide, Affine::IDENTITY, None);
|
||||
ctx.pop_layer();
|
||||
return;
|
||||
}
|
||||
@@ -226,9 +229,12 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
|
||||
}
|
||||
let (nw, nh) = images::natural_size(&item.data).unwrap_or((iw, ih));
|
||||
// PowerPoint draws the tile at a whole number of output pixels.
|
||||
let k = px_scale.max(1e-9);
|
||||
let snap = |v: f64| (v * k).round().max(1.0) / k;
|
||||
let (tile_w, tile_h) = (snap(nw * 0.75 * sx), snap(nh * 0.75 * sy));
|
||||
// Per axis: a group can stretch its child space unevenly, and a pixel is then a different
|
||||
// number of shape units across and down.
|
||||
let c = m.as_coeffs();
|
||||
let (kx, ky) = (c[0].hypot(c[1]).max(1e-9), c[2].hypot(c[3]).max(1e-9));
|
||||
let snap = |v: f64, k: f64| (v * k).round().max(1.0) / k;
|
||||
let (tile_w, tile_h) = (snap(nw * 0.75 * sx, kx), snap(nh * 0.75 * sy, ky));
|
||||
if !(tile_w > 1e-6 && tile_h > 1e-6 && tile_w.is_finite() && tile_h.is_finite()) {
|
||||
return;
|
||||
}
|
||||
@@ -260,6 +266,31 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
|
||||
draw_image(ctx, &image, iw, ih, dest, m, alpha);
|
||||
}
|
||||
|
||||
/// `fill`, with a tiled picture's tile size and offset moved from points to shape units, given
|
||||
/// the shape units to the point along x and y. A group's child coordinates scale its shapes'
|
||||
/// boxes (a group can stretch them unevenly), but a tile keeps its size on the slide as PowerPoint
|
||||
/// draws it, so in a group with a legacy child space the tile would otherwise cover hundreds of
|
||||
/// boxes and the fill show one flat texel. Anything but a tiled picture, or a unit scale, is
|
||||
/// returned as it is.
|
||||
pub fn tiled_per_point(fill: &Fill, per_point: (f64, f64)) -> std::borrow::Cow<'_, Fill> {
|
||||
let Fill::Picture(pf) = fill else { return std::borrow::Cow::Borrowed(fill) };
|
||||
match tiled_picture_per_point(pf, per_point) {
|
||||
std::borrow::Cow::Borrowed(_) => std::borrow::Cow::Borrowed(fill),
|
||||
std::borrow::Cow::Owned(p) => std::borrow::Cow::Owned(Fill::Picture(p)),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`tiled_per_point`] for a picture fill itself (a picture shape's own fill is not a [`Fill`]).
|
||||
pub fn tiled_picture_per_point(pf: &PictureFill, per_point: (f64, f64)) -> std::borrow::Cow<'_, PictureFill> {
|
||||
let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return std::borrow::Cow::Borrowed(pf) };
|
||||
let (kx, ky) = per_point;
|
||||
if !(kx.is_finite() && ky.is_finite() && kx > 1e-12 && ky > 1e-12) || ((kx - 1.0).abs() < 1e-9 && (ky - 1.0).abs() < 1e-9) {
|
||||
return std::borrow::Cow::Borrowed(pf);
|
||||
}
|
||||
let mode = PictureMode::Tile { tx: tx * kx, ty: ty * ky, sx: sx * kx, sy: sy * ky, flip: flip.clone(), align: align.clone() };
|
||||
std::borrow::Cow::Owned(PictureFill { mode, ..pf.clone() })
|
||||
}
|
||||
|
||||
/// Where the first tile of a tiled picture fill goes in `bounds`: its corner, edge middle or
|
||||
/// centre named by `align` (`tl`, `t`, `tr`, `l`, `ctr`, `r`, `bl`, `b`, `br`) on the same point
|
||||
/// of the box, then moved by the offset (points).
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! that put real text over a raster (PDF text layer, links) and for text extraction.
|
||||
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{Hyperlink, TextBody};
|
||||
use deckcraft_model::text::{Hyperlink, RunKind, TextBody};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
use deckcraft_text::{Opts, TextLayout};
|
||||
use deckcraft_text::{Fields, Opts, TextLayout};
|
||||
use kurbo::{Affine, Rect};
|
||||
|
||||
use crate::{SlideFields, shape_geometry, table};
|
||||
@@ -52,10 +52,16 @@ pub struct Placed {
|
||||
|
||||
/// Text and links of slide `index` (master and layout graphics first, as rendered).
|
||||
pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
|
||||
place_slide_with(pres, index, &crate::RenderOpts::default())
|
||||
}
|
||||
|
||||
/// [`place_slide`] with the render options the slide was drawn with, so date fields take the same
|
||||
/// date and time as the raster (`opts.now`).
|
||||
pub fn place_slide_with(pres: &Presentation, index: usize, opts: &crate::RenderOpts) -> Placed {
|
||||
let mut out = Placed::default();
|
||||
let Some(slide) = pres.slides.get(index) else { return out };
|
||||
let Some(rctx) = Ctx::for_slide(pres, slide) else { return out };
|
||||
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default());
|
||||
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts);
|
||||
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
|
||||
if show_master {
|
||||
let mctx = Ctx::for_master(pres, rctx.master);
|
||||
@@ -131,9 +137,11 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
|
||||
}
|
||||
ShapeKind::Table(t) => {
|
||||
// Cell text, laid out exactly as the table draws it.
|
||||
// The table's style is looked up once, not once per cell.
|
||||
let def = table::resolve_style(rctx, t);
|
||||
for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) {
|
||||
let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue };
|
||||
let body = table::cell_body(rctx, t, r, c, cell);
|
||||
let body = table::cell_body_with_def(rctx, def, t, r, c, cell);
|
||||
let tmp = Shape::table_cell(body.clone());
|
||||
place_text(out, rctx, fields, s.id, &tmp, &body, rect, m);
|
||||
}
|
||||
@@ -152,9 +160,24 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
|
||||
fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId, s: &Shape, body: &TextBody, tr: Rect, m: Affine) {
|
||||
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
|
||||
let m = crate::text_transform(m, tr, layout.rotation);
|
||||
// Field runs carry their saved text (`‹#›`); the layout drew their values, so the body that
|
||||
// goes with it (character offsets, text layer) must hold the same values.
|
||||
let mut placed = body.clone();
|
||||
for (pi, para) in body.paragraphs.iter().enumerate() {
|
||||
for (ri, run) in para.runs.iter().enumerate() {
|
||||
let RunKind::Field { field } = &run.kind else { continue };
|
||||
let lang = resolve::run(rctx, s, para, &run.props).lang;
|
||||
if let Some(v) = fields.field_in(field, lang.as_deref())
|
||||
&& let Some(out_run) = placed.paragraphs.get_mut(pi).and_then(|p| p.runs.get_mut(ri))
|
||||
{
|
||||
out_run.text = v;
|
||||
out_run.kind = RunKind::Text;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Text hyperlinks: one box per line segment of a linked run.
|
||||
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
|
||||
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
|
||||
let Some(link) = run_link(&placed, run.para, run.chars.0) else { continue };
|
||||
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
|
||||
// The run's visual extent (right-to-left runs run from right to left).
|
||||
let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
|
||||
@@ -162,7 +185,7 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId
|
||||
let r = Rect::new(x0, line.top, x1, line.bottom);
|
||||
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true });
|
||||
}
|
||||
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
|
||||
out.texts.push(PlacedText { layout, transform: m, body: placed });
|
||||
}
|
||||
|
||||
/// The hyperlink of the model run containing character `at` of paragraph `para`.
|
||||
|
||||
@@ -33,6 +33,10 @@ pub struct Renderer {
|
||||
/// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget
|
||||
/// of the normal drawing that follows.
|
||||
shadow_work: usize,
|
||||
/// `RenderOpts::skip` as a set, with the slice (address, length) it was built from: the
|
||||
/// slice can be as long as the slide, and is tested for every shape drawn. Forgotten when a
|
||||
/// frame starts, so another frame's slice at the same address is never mistaken for it.
|
||||
skip: Option<(usize, usize, std::collections::HashSet<ShapeId>)>,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
@@ -48,6 +52,7 @@ impl Default for Renderer {
|
||||
in_shadow_pass: false,
|
||||
render_work: 0,
|
||||
shadow_work: 0,
|
||||
skip: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -163,6 +168,7 @@ impl Renderer {
|
||||
pub(crate) fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
|
||||
self.render_work = 0;
|
||||
self.shadow_work = 0;
|
||||
self.skip = None;
|
||||
self.in_shadow_pass = false;
|
||||
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
|
||||
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
|
||||
@@ -178,6 +184,7 @@ impl Renderer {
|
||||
pub(crate) fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image {
|
||||
self.render_work = 0;
|
||||
self.shadow_work = 0;
|
||||
self.skip = None;
|
||||
self.in_shadow_pass = false;
|
||||
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
|
||||
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
|
||||
@@ -212,6 +219,7 @@ impl Renderer {
|
||||
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
|
||||
self.render_work = 0;
|
||||
self.shadow_work = 0;
|
||||
self.skip = None;
|
||||
self.in_shadow_pass = false;
|
||||
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0));
|
||||
let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) };
|
||||
@@ -260,6 +268,7 @@ impl Renderer {
|
||||
pub(crate) fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
|
||||
self.render_work = 0;
|
||||
self.shadow_work = 0;
|
||||
self.skip = None;
|
||||
self.in_shadow_pass = false;
|
||||
let (w, h) = output_size(pres, opts);
|
||||
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
|
||||
@@ -346,6 +355,14 @@ impl Renderer {
|
||||
if k.is_finite() && k > 1e-12 { 1.0 / k } else { 1.0 }
|
||||
}
|
||||
|
||||
/// [`Renderer::per_point`] along the shape's own x and y axes: a group can stretch its child
|
||||
/// space unevenly, and a tiled fill keeps its size in points on the slide on both axes.
|
||||
fn per_point_xy(&self, m: Affine, depth: usize) -> (f64, f64) {
|
||||
let c = self.line_space(m, depth).1.as_coeffs();
|
||||
let inv = |s: f64| if s.is_finite() && s > 1e-12 { 1.0 / s } else { 1.0 };
|
||||
(inv(c[0].hypot(c[1])), inv(c[2].hypot(c[3])))
|
||||
}
|
||||
|
||||
/// The size and transform a shape's text is set in: inside a group whose child coordinates
|
||||
/// scale the shape, the shape's size in slide points and `m` with that scale taken out, so
|
||||
/// insets and font sizes stay in points; otherwise `w`, `h` and `m` themselves.
|
||||
@@ -385,8 +402,25 @@ impl Renderer {
|
||||
geo
|
||||
}
|
||||
|
||||
/// Whether `id` is in `skip`, looked up in a set built once per frame and slice.
|
||||
fn skipped(&mut self, skip: &[ShapeId], id: ShapeId) -> bool {
|
||||
if skip.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let key = (skip.as_ptr() as usize, skip.len());
|
||||
match &self.skip {
|
||||
Some((p, n, set)) if (*p, *n) == key => set.contains(&id),
|
||||
_ => {
|
||||
let set: std::collections::HashSet<ShapeId> = skip.iter().copied().collect();
|
||||
let hit = set.contains(&id);
|
||||
self.skip = Some((key.0, key.1, set));
|
||||
hit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
|
||||
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
|
||||
if depth > 32 || (s.hidden && !f.opts.show_hidden) || self.skipped(f.opts.skip, s.id) {
|
||||
return;
|
||||
}
|
||||
let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work };
|
||||
@@ -428,7 +462,8 @@ impl Renderer {
|
||||
// A group's own fill (a group filled with its parent's passes that on).
|
||||
let own = match &s.fill {
|
||||
Some(Fill::Group) | None => self.group_fill.clone(),
|
||||
Some(fl) => Some((fl.clone(), m, Rect::new(0.0, 0.0, x.w, x.h))),
|
||||
// A tile is given in points; this group's own space may be scaled by its parents.
|
||||
Some(fl) => Some((paint::tiled_per_point(fl, self.per_point_xy(m, depth)).into_owned(), m, Rect::new(0.0, 0.0, x.w, x.h))),
|
||||
};
|
||||
let outer = std::mem::replace(&mut self.group_fill, own);
|
||||
// A group's outer shadow is cast by everything in it, as PowerPoint draws it: the
|
||||
@@ -541,6 +576,7 @@ impl Renderer {
|
||||
geo: &Geometry,
|
||||
(w, h): (f64, f64),
|
||||
m: Affine,
|
||||
per_point: (f64, f64),
|
||||
fade: &dyn Fn(usize) -> deckcraft_geom::morph::Fade,
|
||||
) {
|
||||
for (i, sp) in geo.paths.iter().enumerate() {
|
||||
@@ -566,7 +602,10 @@ impl Renderer {
|
||||
paint::fill_path(ctx, rctx, gf, None, &path, *gbox, *gm, sp.fill, fade(i).fill, f.pres);
|
||||
}
|
||||
}
|
||||
_ => paint::fill_path(ctx, rctx, fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres),
|
||||
_ => {
|
||||
let fl = paint::tiled_per_point(fl, per_point);
|
||||
paint::fill_path(ctx, rctx, &fl, fill_ph, &sp.path, Rect::new(0.0, 0.0, w, h), m, sp.fill, fade(i).fill, f.pres)
|
||||
}
|
||||
}
|
||||
ctx.set_fill_rule(peniko::Fill::NonZero);
|
||||
}
|
||||
@@ -655,9 +694,13 @@ impl Renderer {
|
||||
let line = &d.line;
|
||||
// Effects below the shape.
|
||||
if let Some(e) = &effects {
|
||||
// A picture (or picture fill) casts its shadow from its pixels, not its frame.
|
||||
// A picture (or picture fill) casts its shadow from its pixels, not its frame. A tile
|
||||
// is sized in points on the slide, as the face's is, and so is its shadow and reflection.
|
||||
let content = match (&s.kind, &fill) {
|
||||
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => Some(paint::resolved(rctx, pf, None).into_owned()),
|
||||
(ShapeKind::Picture { fill: pf }, _) | (_, Some(Fill::Picture(pf))) => {
|
||||
let pf = paint::tiled_picture_per_point(pf, self.per_point_xy(m, depth));
|
||||
Some(paint::resolved(rctx, &pf, None).into_owned())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
// What casts the shadow: the fill as see-through as it is, and the outline.
|
||||
@@ -736,6 +779,7 @@ impl Renderer {
|
||||
let fill = &d.fill;
|
||||
let outline = &d.outline;
|
||||
let fade = |i: usize| d.fade(i);
|
||||
let pt_xy = self.per_point_xy(m, d.depth);
|
||||
// Fill.
|
||||
match &s.kind {
|
||||
ShapeKind::Picture { fill: pf } => {
|
||||
@@ -744,13 +788,14 @@ impl Renderer {
|
||||
if s.fill.as_ref().is_some_and(|f| !f.is_none())
|
||||
&& let Some(fl) = &fill
|
||||
{
|
||||
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade);
|
||||
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade);
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
let bounds = Rect::new(0.0, 0.0, w, h);
|
||||
ctx.push_clip_layer(outline);
|
||||
let t = f.opts.gif_times.iter().find(|g| g.0 == s.id).map(|g| g.1);
|
||||
paint::picture(ctx, f.pres, &paint::resolved(rctx, pf, None), bounds, m, t);
|
||||
let pf = paint::tiled_picture_per_point(pf, pt_xy);
|
||||
paint::picture(ctx, f.pres, &paint::resolved(rctx, &pf, None), bounds, m, t);
|
||||
ctx.pop_layer();
|
||||
}
|
||||
// An object DeckCraft doesn't model (an embedded OLE object, an unknown graphic): its
|
||||
@@ -761,7 +806,7 @@ impl Renderer {
|
||||
if s.fill.as_ref().is_some_and(|f| !f.is_none())
|
||||
&& let Some(fl) = &fill
|
||||
{
|
||||
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade);
|
||||
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade);
|
||||
}
|
||||
ctx.set_transform(m);
|
||||
let pf = deckcraft_model::style::PictureFill { media: *media, ..Default::default() };
|
||||
@@ -787,7 +832,7 @@ impl Renderer {
|
||||
if let Some(fl) = &fill
|
||||
&& !(empty_ph && f.opts.edit && s.fill.is_none() && s.line.is_none() && s.style.is_none())
|
||||
{
|
||||
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, &fade);
|
||||
self.fill_geometry(ctx, f, rctx, fl, fill_ph, geo, (w, h), m, pt_xy, &fade);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,7 +191,8 @@ pub(crate) fn limit_dash_count(path: &BezPath, pat: Vec<f64>) -> Vec<f64> {
|
||||
return pat;
|
||||
}
|
||||
let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum();
|
||||
let dashes = len / period;
|
||||
// Every dash of the pattern counts, not each repeat of it: a custom pattern can hold many.
|
||||
let dashes = len / period * pat.len().div_ceil(2) as f64;
|
||||
if !len.is_finite() {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::{
|
||||
Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph,
|
||||
crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide,
|
||||
render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
|
||||
place_slide_with, render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
|
||||
};
|
||||
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
|
||||
use deckcraft_model::style::ColorRef;
|
||||
@@ -2971,6 +2971,27 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
|
||||
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_text_holds_the_field_value_not_the_saved_placeholder() {
|
||||
let mut p = deck_with(vec![field_shape(Some("slidenum"), "<#>")]);
|
||||
let second = p.slides[0].clone();
|
||||
p.slides.push(second);
|
||||
let placed = place_slide(&p, 1);
|
||||
assert_eq!(placed.texts.len(), 1);
|
||||
assert_eq!(placed.texts[0].body.text(), "2");
|
||||
// The layout was drawn from the same value, so the offsets agree with the body.
|
||||
assert_eq!(placed.texts[0].layout.runs.iter().map(|r| r.chars.1).max(), Some(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placed_text_takes_its_date_from_the_given_render_options() {
|
||||
use deckcraft_text::datetime::DateTime;
|
||||
let p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]);
|
||||
let at = |now: DateTime| place_slide_with(&p, 0, &RenderOpts { now: Some(now), ..Default::default() });
|
||||
assert_eq!(at(DateTime::new(2026, 10, 9, 20, 5, 7)).texts[0].body.text(), "10/9/2026");
|
||||
assert_eq!(at(DateTime::new(2027, 3, 4, 0, 0, 0)).texts[0].body.text(), "3/4/2027");
|
||||
}
|
||||
|
||||
/// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour
|
||||
/// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible
|
||||
/// there), 595959 grey on Harbor's light ones.
|
||||
|
||||
@@ -44,7 +44,9 @@ fn scene(shape: &str, effect: Effect) -> Presentation {
|
||||
})
|
||||
}
|
||||
Effect::Glow => effects.glow = Some(Glow { color: ColorRef::rgb(Rgba::rgb(255, 0, 0)), radius: 8.0 }),
|
||||
Effect::Reflection => effects.reflection = Some(Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 1.0, dist: 10.0 }),
|
||||
Effect::Reflection => {
|
||||
effects.reflection = Some(Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 1.0, dist: 10.0, ..Default::default() })
|
||||
}
|
||||
}
|
||||
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
slide.background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(ColorRef::rgb(Rgba::WHITE)) });
|
||||
|
||||
@@ -454,6 +454,198 @@ fn tiled_pictures_flip_align_and_skip_non_positive_scales() {
|
||||
assert!(close(img.pixel(115, 115), WHITE, 5) && close(img.pixel(300, 150), WHITE, 5));
|
||||
}
|
||||
|
||||
#[allow(clippy::unwrap_used)]
|
||||
fn red_over_blue() -> Vec<u8> {
|
||||
let img = image::RgbImage::from_fn(4, 8, |_, y| if y < 4 { image::Rgb([255, 0, 0]) } else { image::Rgb([0, 0, 255]) });
|
||||
let mut out = std::io::Cursor::new(vec![]);
|
||||
image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).unwrap();
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_keeps_its_size_in_a_group_with_scaled_child_coordinates() {
|
||||
use deckcraft_model::style::{PictureFill, PictureMode};
|
||||
// A group's child space scales its shapes' boxes, not the tile: the same tiles show whether
|
||||
// the group's child space is 1600 times smaller (old decks write them so), 4 times larger,
|
||||
// the slide's own or stretched unevenly (PowerPoint draws all of them alike, measured on its
|
||||
// own export of a probe deck). Before, one tile covered hundreds of boxes and the fill was one
|
||||
// flat texel, and an uneven group stretched the tile.
|
||||
// Wide: 8x4 px = 6x3 pt, ten times = 60x30 pt tiles, red then blue across.
|
||||
// Tall: 4x8 px = 3x6 pt, ten times = 30x60 pt tiles, red over blue down.
|
||||
let blue = Rgba::rgb(0, 0, 255);
|
||||
let scales = [(1.0, 1.0), (1600.0, 1600.0), (0.25, 0.25), (2.0, 0.5), (0.5, 2.0), (1800.0, 1.0), (1.0, 1800.0)];
|
||||
for (kx, ky) in scales {
|
||||
for tall in [false, true] {
|
||||
let mut p = Presentation::default();
|
||||
let png = if tall { red_over_blue() } else { half_red_half_blue() };
|
||||
let media = p.add_media("t.png", "image/png", png).expect("media");
|
||||
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
|
||||
let member = Shape {
|
||||
id: ShapeId(22),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, 240.0 / kx, 60.0 / ky)),
|
||||
fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })),
|
||||
line: no_line(),
|
||||
..Default::default()
|
||||
};
|
||||
let group = Shape {
|
||||
id: ShapeId(21),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)),
|
||||
kind: ShapeKind::Group { children: vec![member], child: Xfrm::new(0.0, 0.0, 240.0 / kx, 60.0 / ky) },
|
||||
..Default::default()
|
||||
};
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![group];
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
// Along the tile's long side: first half red, second blue, then red again.
|
||||
let (a, b, c) = if tall { ((115, 115), (115, 145), (115, 175)) } else { ((115, 115), (145, 115), (175, 115)) };
|
||||
let (pa, pb, pc) = (img.pixel(a.0, a.1), img.pixel(b.0, b.1), img.pixel(c.0, c.1));
|
||||
assert!(
|
||||
close(pa, RED, 30) && close(pb, blue, 30) && (tall || close(pc, RED, 30)) && (!tall || close(pc, WHITE, 5) || close(pc, RED, 30)),
|
||||
"child space {kx} x {ky}, tall={tall}: {pa:?} {pb:?} {pc:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A slide with `shape` (built for a box of `w` x `h` child units) in a group at (100, 100) that is
|
||||
/// 240 x 60 pt on the slide and scaled `kx` x `ky` against its child space, drawn at 1 px per point.
|
||||
fn in_a_scaled_group(kx: f64, ky: f64, p: Presentation, shape: impl FnOnce(f64, f64) -> Shape) -> Image {
|
||||
let (w, h) = (240.0 / kx, 60.0 / ky);
|
||||
let group = Shape {
|
||||
id: ShapeId(21),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 60.0)),
|
||||
kind: ShapeKind::Group { children: vec![shape(w, h)], child: Xfrm::new(0.0, 0.0, w, h) },
|
||||
..Default::default()
|
||||
};
|
||||
let mut p = p;
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![group];
|
||||
render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tile_a_group_passes_to_its_members_keeps_its_size_inside_a_scaled_group() {
|
||||
use deckcraft_model::style::{PictureFill, PictureMode};
|
||||
// The group that owns the tiled fill sits inside another group whose child space is scaled: the
|
||||
// members that take it (`grpFill`) get tiles of 60 x 30 pt, as with no outer group.
|
||||
let blue = Rgba::rgb(0, 0, 255);
|
||||
for (kx, ky) in [(1.0, 1.0), (0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
|
||||
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
|
||||
let img = in_a_scaled_group(kx, ky, p, |w, h| {
|
||||
let member =
|
||||
Shape { id: ShapeId(23), xfrm: Some(Xfrm::new(0.0, 0.0, w, h)), fill: Some(Fill::Group), line: no_line(), ..Default::default() };
|
||||
Shape {
|
||||
id: ShapeId(22),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, w, h)),
|
||||
fill: Some(Fill::Picture(PictureFill { media, mode, ..Default::default() })),
|
||||
kind: ShapeKind::Group { children: vec![member], child: Xfrm::new(0.0, 0.0, w, h) },
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
let (a, b, c) = (img.pixel(115, 115), img.pixel(145, 115), img.pixel(175, 115));
|
||||
assert!(close(a, RED, 30) && close(b, blue, 30) && close(c, RED, 30), "outer group {kx} x {ky}: {a:?} {b:?} {c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_shapes_own_tile_keeps_its_size_inside_a_scaled_group() {
|
||||
use deckcraft_model::style::{PictureFill, PictureMode};
|
||||
// A picture (`p:pic`) whose blip fill is a tile rather than a stretch.
|
||||
let blue = Rgba::rgb(0, 0, 255);
|
||||
for (kx, ky) in [(1.0, 1.0), (0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
|
||||
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
|
||||
let img = in_a_scaled_group(kx, ky, p, |w, h| Shape {
|
||||
id: ShapeId(22),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, w, h)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, mode, ..Default::default() } },
|
||||
line: no_line(),
|
||||
..Default::default()
|
||||
});
|
||||
let (a, b, c) = (img.pixel(115, 115), img.pixel(145, 115), img.pixel(175, 115));
|
||||
assert!(close(a, RED, 30) && close(b, blue, 30) && close(c, RED, 30), "group {kx} x {ky}: {a:?} {b:?} {c:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tiled_pictures_reflection_keeps_the_tile_size_inside_a_scaled_group() {
|
||||
use deckcraft_model::style::{PictureFill, PictureMode, Reflection};
|
||||
// The reflection mirrors the picture's pixels, so its tiles must be the face's, 60 x 30 pt,
|
||||
// whatever the group's child scale: just below the box it shows the same colours.
|
||||
let reflected = |kx: f64, ky: f64| {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
|
||||
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
|
||||
let rf = Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0, ..Default::default() };
|
||||
let img = in_a_scaled_group(kx, ky, p, |w, h| Shape {
|
||||
id: ShapeId(22),
|
||||
xfrm: Some(Xfrm::new(0.0, 0.0, w, h)),
|
||||
kind: ShapeKind::Picture { fill: PictureFill { media, mode, ..Default::default() } },
|
||||
line: no_line(),
|
||||
effects: Some(deckcraft_model::Effects { reflection: Some(rf), ..Default::default() }),
|
||||
..Default::default()
|
||||
});
|
||||
[img.pixel(115, 163), img.pixel(145, 163), img.pixel(175, 163)]
|
||||
};
|
||||
let plain = reflected(1.0, 1.0);
|
||||
assert!(plain[0][0] > plain[0][2] && plain[1][2] > plain[1][0] && plain[2][0] > plain[2][2], "red, blue, red in the reflection: {plain:?}");
|
||||
for (kx, ky) in [(0.25, 0.25), (1600.0, 1600.0), (2.0, 0.5)] {
|
||||
let got = reflected(kx, ky);
|
||||
for (g, e) in got.iter().zip(&plain) {
|
||||
assert!(close(*g, Rgba::rgb(e[0], e[1], e[2]), 40), "child scale {kx} x {ky}: {got:?} against {plain:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upright_tile_in_a_turned_shape_keeps_its_size_in_points_at_any_zoom() {
|
||||
use deckcraft_model::style::{PictureFill, PictureMode};
|
||||
// `rotWithShape="0"` draws the tiles on the slide's axes, in device pixels: they were sized in
|
||||
// pixels, so zooming (or a group's child scale) changed their size on the slide. PowerPoint's
|
||||
// export of the same shapes shows 60x30 pt tiles at every zoom.
|
||||
let draw = |scale: f64, group: f64| {
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("t.png", "image/png", half_red_half_blue()).expect("media");
|
||||
let mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 10.0, sy: 10.0, flip: "none".into(), align: "tl".into() };
|
||||
let fill = Fill::Picture(PictureFill { media, mode, upright: true, ..Default::default() });
|
||||
let shape = Shape {
|
||||
id: ShapeId(22),
|
||||
xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(0.0, 0.0, 240.0 / group, 120.0 / group) }),
|
||||
fill: Some(fill),
|
||||
line: no_line(),
|
||||
..Default::default()
|
||||
};
|
||||
let shape = if group == 1.0 {
|
||||
Shape { xfrm: Some(Xfrm { rot: 20.0, ..Xfrm::new(100.0, 100.0, 240.0, 120.0) }), ..shape }
|
||||
} else {
|
||||
Shape {
|
||||
id: ShapeId(21),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 240.0, 120.0)),
|
||||
kind: ShapeKind::Group { children: vec![shape], child: Xfrm::new(0.0, 0.0, 240.0 / group, 120.0 / group) },
|
||||
..Default::default()
|
||||
}
|
||||
};
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![shape];
|
||||
render_slide(&p, 0, &RenderOpts { scale, ..Default::default() })
|
||||
};
|
||||
for group in [1.0, 0.25] {
|
||||
let (one, two) = (draw(1.0, group), draw(2.0, group));
|
||||
let (mut same, mut total) = (0, 0);
|
||||
for y in (120..220).step_by(7) {
|
||||
for x in (120..320).step_by(9) {
|
||||
let (a, b) = (one.pixel(x, y), two.pixel(2 * x, 2 * y));
|
||||
if close(a, WHITE, 5) && close(b, WHITE, 5) {
|
||||
continue; // outside the turned rectangle
|
||||
}
|
||||
total += 1;
|
||||
same += usize::from(close(a, Rgba::rgb(b[0], b[1], b[2]), 40));
|
||||
}
|
||||
}
|
||||
assert!(total > 40 && same * 10 >= total * 9, "group scale {group}: {same} of {total} samples agree between zoom 1 and 2");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tiles_first_copy_sits_on_the_point_its_alignment_names() {
|
||||
let b = Rect::new(0.0, 0.0, 100.0, 50.0);
|
||||
@@ -743,7 +935,7 @@ fn a_picture_reflects_its_pixels() {
|
||||
let mut p = picture_deck(None);
|
||||
let s = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
if let Some(sh) = s.shapes.first_mut() {
|
||||
let rf = deckcraft_model::style::Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0 };
|
||||
let rf = deckcraft_model::style::Reflection { blur: 0.0, start_alpha: 1.0, end_alpha: 0.0, end_pos: 0.5, dist: 0.0, ..Default::default() };
|
||||
sh.effects = Some(deckcraft_model::Effects { reflection: Some(rf), ..Default::default() });
|
||||
}
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
@@ -798,4 +990,37 @@ fn a_tiny_dash_on_a_huge_shape_draws_without_running_out_of_memory() {
|
||||
p.line_to((4.0e6, 0.0));
|
||||
let pat = limit_dash_count(&p, vec![1.0, 1.0]);
|
||||
assert!((4.0e6 / (pat[0] + pat[1]) - MAX_DASHES).abs() < 1.0, "{pat:?}");
|
||||
// A custom pattern of 1,000 dashes counts each of them: the limit is on dashes drawn.
|
||||
let pat = limit_dash_count(&p, vec![1.0; 2000]);
|
||||
let period: f64 = pat.iter().sum();
|
||||
assert!((4.0e6 / period * 1000.0 - MAX_DASHES).abs() < 1.0, "{}", period);
|
||||
}
|
||||
|
||||
/// A bar chart with 20,000 points, each with its own fill, and a sparse scatter of the same size:
|
||||
/// the per-point fills and the scatter's "has any x" are looked up, not searched per point.
|
||||
#[test]
|
||||
fn many_point_fills_and_sparse_scatter_x_render() {
|
||||
use deckcraft_model::ShapeKind;
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
use deckcraft_model::style::Fill;
|
||||
let n = 20_000;
|
||||
let chart = |kind: ChartType| {
|
||||
let mut c = Chart::sample(kind);
|
||||
c.series.truncate(1);
|
||||
c.categories = (0..n).map(|i| i.to_string()).collect();
|
||||
c.series[0].values = (0..n).map(|i| Some(i as f64)).collect();
|
||||
c
|
||||
};
|
||||
let mut bars = chart(ChartType::Column);
|
||||
bars.series[0].point_fills = (0..n).rev().map(|i| (i, Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0))))).collect();
|
||||
let mut scatter = chart(ChartType::Scatter);
|
||||
scatter.series[0].x_values = (0..n).map(|i| (i + 1 == n).then_some(1.0)).collect();
|
||||
let shape = |id: u32, c: Chart| Shape {
|
||||
id: ShapeId(id),
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 400.0, 300.0)),
|
||||
kind: ShapeKind::Chart(Box::new(c)),
|
||||
..Default::default()
|
||||
};
|
||||
let img = render(vec![shape(2, bars), shape(3, scatter)]);
|
||||
assert!(img.width > 0);
|
||||
}
|
||||
|
||||
@@ -35,22 +35,30 @@ pub(crate) fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody {
|
||||
t
|
||||
}
|
||||
|
||||
/// Most path elements this thread's glyph cache retains. Its entries are shared with the font
|
||||
/// cache, but they stay alive here after that cache evicts them, so they are bounded by elements.
|
||||
const GLYPH_CACHE_MAX_ELEMENTS: usize = 1 << 22;
|
||||
|
||||
thread_local! {
|
||||
static GLYPH_CACHE: std::cell::RefCell<HashMap<(u32, u32), Arc<BezPath>>> = std::cell::RefCell::new(HashMap::new());
|
||||
/// The cached outlines, and the path elements they hold.
|
||||
static GLYPH_CACHE: std::cell::RefCell<(HashMap<(u32, u32), Arc<BezPath>>, usize)> = std::cell::RefCell::new((HashMap::new(), 0));
|
||||
}
|
||||
|
||||
pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id(), gid);
|
||||
if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) {
|
||||
if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().0.get(&key).cloned()) {
|
||||
return p;
|
||||
}
|
||||
let p = FontDb::global().outline(face, gid);
|
||||
GLYPH_CACHE.with(|c| {
|
||||
let mut c = c.borrow_mut();
|
||||
if c.len() > 200_000 {
|
||||
c.clear();
|
||||
let n = p.elements().len();
|
||||
if c.0.len() > 200_000 || c.1.saturating_add(n) > GLYPH_CACHE_MAX_ELEMENTS {
|
||||
c.0.clear();
|
||||
c.1 = 0;
|
||||
}
|
||||
c.insert(key, p.clone());
|
||||
c.1 += n;
|
||||
c.0.insert(key, p.clone());
|
||||
});
|
||||
p
|
||||
}
|
||||
@@ -105,6 +113,7 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr:
|
||||
pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) {
|
||||
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
|
||||
let m = text_transform(m, tr, l.rotation);
|
||||
let lines_of = LineIndex::new(l);
|
||||
let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
|
||||
let ps = para(d.para);
|
||||
if !ps.visible || ps.opacity <= 0.001 {
|
||||
@@ -144,7 +153,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
|
||||
draw_glyph(ctx, run, *gid, at, ps.opacity);
|
||||
continue;
|
||||
};
|
||||
let li = line_at(l, run.para, *y);
|
||||
let li = lines_of.line_at(run.para, *y);
|
||||
if !grads.iter().any(|(i, _)| *i == li) {
|
||||
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
|
||||
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
|
||||
@@ -191,8 +200,78 @@ pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
|
||||
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
|
||||
/// Finds the line of a paragraph holding a glyph on baseline `y` without scanning the paragraph's
|
||||
/// lines for every glyph. A paragraph whose lines are ordered top to bottom (every layout the text
|
||||
/// crate produces) is searched by bisection; any other falls back to a scan with the same answer.
|
||||
pub(crate) struct LineIndex<'a> {
|
||||
l: &'a deckcraft_text::TextLayout,
|
||||
/// Per paragraph: its lines' tops, bottoms and baselines all rise with the line index.
|
||||
ordered: Vec<bool>,
|
||||
}
|
||||
|
||||
impl<'a> LineIndex<'a> {
|
||||
pub(crate) fn new(l: &'a deckcraft_text::TextLayout) -> Self {
|
||||
let ordered = l
|
||||
.para_lines
|
||||
.iter()
|
||||
.map(|&(first, count)| {
|
||||
let end = first.saturating_add(count).min(l.lines.len());
|
||||
l.lines.get(first..end).is_some_and(|ls| {
|
||||
ls.iter().all(|a| a.top <= a.bottom)
|
||||
&& ls.windows(2).all(|w| w[0].top <= w[1].top && w[0].bottom <= w[1].bottom && w[0].baseline <= w[1].baseline)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
LineIndex { l, ordered }
|
||||
}
|
||||
|
||||
/// The nearest line baseline to `y` among `lines` (the first of equals), as an index into `lines`.
|
||||
fn nearest(lines: &[deckcraft_text::LineInfo], y: f64) -> Option<usize> {
|
||||
let i = lines.partition_point(|li| li.baseline < y);
|
||||
let best = match (i.checked_sub(1), lines.get(i)) {
|
||||
(Some(p), Some(n)) => {
|
||||
if (lines[p].baseline - y).abs() <= (n.baseline - y).abs() {
|
||||
p
|
||||
} else {
|
||||
i
|
||||
}
|
||||
}
|
||||
(Some(p), None) => p,
|
||||
(None, Some(_)) => i,
|
||||
(None, None) => return None,
|
||||
};
|
||||
let b = lines[best].baseline;
|
||||
Some(lines.partition_point(|li| li.baseline < b))
|
||||
}
|
||||
|
||||
/// The paragraph's lines when they are ordered, with the index of the first.
|
||||
fn ordered_lines(&self, para: usize) -> Option<(usize, &[deckcraft_text::LineInfo])> {
|
||||
let &(first, count) = self.l.para_lines.get(para)?;
|
||||
let end = first.saturating_add(count).min(self.l.lines.len());
|
||||
(self.ordered[para] && first < end).then(|| (first, &self.l.lines[first..end]))
|
||||
}
|
||||
|
||||
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
|
||||
pub(crate) fn line_at(&self, para: usize, y: f64) -> usize {
|
||||
if let Some((first, ls)) = self.ordered_lines(para) {
|
||||
return Self::nearest(ls, y).map_or(0, |i| first + i);
|
||||
}
|
||||
line_at_scan(self.l, para, y)
|
||||
}
|
||||
|
||||
/// The first line of paragraph `para` whose box holds `y`, else the nearest baseline's line.
|
||||
pub(crate) fn line_of(&self, para: usize, y: f64) -> Option<usize> {
|
||||
let Some((first, ls)) = self.ordered_lines(para) else { return crate::warp::line_of_scan(self.l, para, y) };
|
||||
let i = ls.partition_point(|li| li.bottom + 0.01 < y);
|
||||
if ls.get(i).is_some_and(|li| y >= li.top - 0.01) {
|
||||
return Some(first + i);
|
||||
}
|
||||
Self::nearest(ls, y).map(|i| first + i)
|
||||
}
|
||||
}
|
||||
|
||||
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline), by scanning.
|
||||
fn line_at_scan(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
|
||||
if let Some(&(first, count)) = l.para_lines.get(para) {
|
||||
if count == 0 {
|
||||
return 0;
|
||||
@@ -318,3 +397,38 @@ fn draw_glyph_with(
|
||||
ctx.stroke_path(&path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod line_index_tests {
|
||||
use super::*;
|
||||
use deckcraft_text::{LineInfo, TextLayout};
|
||||
|
||||
fn layout(ordered: bool) -> TextLayout {
|
||||
let line = |para, top: f64| LineInfo { para, top, baseline: top + 8.0, bottom: top + 10.0, ..Default::default() };
|
||||
let mut lines: Vec<LineInfo> = (0..40).map(|i| line(usize::from(i >= 25), f64::from(i) * 10.0)).collect();
|
||||
if !ordered {
|
||||
lines.swap(3, 7);
|
||||
}
|
||||
TextLayout { lines, para_lines: vec![(0, 25), (25, 15)], ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bisection_matches_the_scan() {
|
||||
for ordered in [true, false] {
|
||||
let l = layout(ordered);
|
||||
let idx = LineIndex::new(l_ref(&l));
|
||||
assert_eq!(idx.ordered, vec![ordered, true]);
|
||||
for para in 0..2 {
|
||||
for k in -20..450 {
|
||||
let y = f64::from(k) * 0.9;
|
||||
assert_eq!(idx.line_at(para, y), line_at_scan(&l, para, y), "line_at {para} {y}");
|
||||
assert_eq!(idx.line_of(para, y), crate::warp::line_of_scan(&l, para, y), "line_of {para} {y}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn l_ref(l: &TextLayout) -> &TextLayout {
|
||||
l
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,14 @@ use deckcraft_text::{GlyphRun, TextLayout};
|
||||
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _};
|
||||
use vello_cpu::{RenderContext, peniko};
|
||||
|
||||
use crate::text_draw::LineIndex;
|
||||
use crate::{ParaState, color, glyph, scale_of, text_transform};
|
||||
|
||||
/// Most points one warped glyph may have (a hostile size or zoom can't explode the work).
|
||||
const MAX_POINTS: usize = 40_000;
|
||||
|
||||
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to.
|
||||
fn line_of(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
|
||||
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to, by scanning.
|
||||
pub(crate) fn line_of_scan(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
|
||||
if let Some(&(first, count)) = l.para_lines.get(para) {
|
||||
if count == 0 {
|
||||
return None;
|
||||
@@ -110,6 +111,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
|
||||
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
|
||||
let m = text_transform(m, tr, l.rotation);
|
||||
let n = l.lines.len();
|
||||
let lines_of = LineIndex::new(l);
|
||||
let mut ink: Vec<Option<Rect>> = vec![None; n];
|
||||
let mut off: Vec<Option<f64>> = vec![None; n];
|
||||
let mut items: Vec<Item> = Vec::new();
|
||||
@@ -117,7 +119,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
|
||||
let k = run.size / run.face.upem.max(1.0);
|
||||
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
|
||||
for (gid, x, y) in &run.glyphs {
|
||||
let Some(li) = line_of(l, run.para, *y) else { continue };
|
||||
let Some(li) = lines_of.line_of(run.para, *y) else { continue };
|
||||
if let Some(o) = off.get_mut(li) {
|
||||
*o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run))));
|
||||
}
|
||||
@@ -156,7 +158,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
|
||||
return;
|
||||
}
|
||||
let y = (d.rect.y0 + d.rect.y1) / 2.0;
|
||||
let Some(li) = line_of(l, d.para, y) else { return };
|
||||
let Some(li) = lines_of.line_of(d.para, y) else { return };
|
||||
ctx.set_transform(m * Affine::translate(ps.offset));
|
||||
ctx.set_paint(color(d.color, ps.opacity));
|
||||
for r in d.pieces() {
|
||||
|
||||
+29
-12
@@ -260,12 +260,15 @@ pub(crate) struct Tab {
|
||||
align: TabAlign,
|
||||
}
|
||||
|
||||
/// The paragraph's tab stops in position order (a stop at no finite position is left out), so
|
||||
/// [`tab_advance`] finds the next one by binary search instead of a scan per tab.
|
||||
pub(crate) fn tabs_of(props: &ParaProps) -> Vec<Tab> {
|
||||
props
|
||||
let mut tabs: Vec<Tab> = props
|
||||
.tabs
|
||||
.as_ref()
|
||||
.map(|t| {
|
||||
t.iter()
|
||||
.filter(|s| s.pos.is_finite())
|
||||
.map(|s| Tab {
|
||||
pos: s.pos,
|
||||
align: match s.align.as_str() {
|
||||
@@ -277,7 +280,9 @@ pub(crate) fn tabs_of(props: &ParaProps) -> Vec<Tab> {
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_default();
|
||||
tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos));
|
||||
tabs
|
||||
}
|
||||
|
||||
/// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of
|
||||
@@ -301,18 +306,30 @@ pub(crate) fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) {
|
||||
/// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never
|
||||
/// moving back, as PowerPoint does; past the last stop, to the next default stop.
|
||||
pub(crate) fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 {
|
||||
for s in stops {
|
||||
if s.pos > x + 0.5 {
|
||||
let to = s.pos - x;
|
||||
return match s.align {
|
||||
TabAlign::Left => to,
|
||||
TabAlign::Right => (to - seg.0).max(0.0),
|
||||
TabAlign::Center => (to - seg.0 / 2.0).max(0.0),
|
||||
TabAlign::Decimal => (to - seg.1).max(0.0),
|
||||
};
|
||||
}
|
||||
// `stops` is in position order (`tabs_of`): the next stop is the first past x.
|
||||
// A NaN `x` (a NaN spacing upstream) skips the lookup and takes the finite default-stop path.
|
||||
if let Some(s) = stops.get(stops.partition_point(|s| s.pos <= x + 0.5)).filter(|_| !x.is_nan()) {
|
||||
let to = s.pos - x;
|
||||
return match s.align {
|
||||
TabAlign::Left => to,
|
||||
TabAlign::Right => (to - seg.0).max(0.0),
|
||||
TabAlign::Center => (to - seg.0 / 2.0).max(0.0),
|
||||
TabAlign::Decimal => (to - seg.1).max(0.0),
|
||||
};
|
||||
}
|
||||
let d = if default_tab > 1.0 { default_tab } else { 72.0 };
|
||||
let next = ((x / d).floor() + 1.0) * d;
|
||||
(next - x).max(1.0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tab_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_nan_position_takes_the_finite_default_stop() {
|
||||
let stops = [Tab { pos: 100.0, align: TabAlign::Left }];
|
||||
assert!(tab_advance(f64::NAN, &stops, 72.0, (0.0, 0.0)).is_finite());
|
||||
assert_eq!(tab_advance(10.0, &stops, 72.0, (0.0, 0.0)), 90.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -418,7 +418,7 @@ fn justify_line(p: &ParaShaped, rl: &RawLine, adv: &mut [f64], align: Align, sla
|
||||
filled = true;
|
||||
}
|
||||
}
|
||||
if stretch && !filled && matches!(align, Align::Justify | Align::JustLow) {
|
||||
if stretch && !filled && matches!(align, Align::Justify | Align::JustLow | Align::ThaiDistributed) {
|
||||
let spaces = line_cells.iter().filter(|c| c.ch == ' ').count();
|
||||
if spaces > 0 {
|
||||
for (a, c) in adv.iter_mut().zip(line_cells) {
|
||||
@@ -450,8 +450,8 @@ fn line_left(align: Align, filled: bool, rtl: bool, bx: (f64, f64), line_w: f64,
|
||||
Align::Center => box_l + (box_r - box_l - line_w) / 2.0,
|
||||
Align::Distributed if n <= 1 => box_l + slack / 2.0,
|
||||
// Justified lines that aren't stretched (the last one) sit at the start edge.
|
||||
Align::Justify | Align::JustLow | Align::Distributed if rtl => box_r - line_w,
|
||||
Align::Justify | Align::JustLow | Align::Distributed => box_l,
|
||||
Align::Justify | Align::JustLow | Align::Distributed | Align::ThaiDistributed if rtl => box_r - line_w,
|
||||
Align::Justify | Align::JustLow | Align::Distributed | Align::ThaiDistributed => box_l,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ pub struct GlyphRun {
|
||||
pub link: bool,
|
||||
/// Opacity of the fill on top of `color`'s own alpha (0 for no fill).
|
||||
pub alpha: f64,
|
||||
pub gradient: Option<deckcraft_model::style::Gradient>,
|
||||
pub gradient: Option<std::sync::Arc<deckcraft_model::style::Gradient>>,
|
||||
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
|
||||
pub effects: Option<RunEffects>,
|
||||
/// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn
|
||||
|
||||
@@ -76,7 +76,7 @@ pub(crate) fn shape_para(
|
||||
if let Some(c) = &props.bullet_color {
|
||||
rp.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||||
rp.size = Some(props.bullet_size_points.unwrap_or(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)));
|
||||
rp.underline = None;
|
||||
rp.strike = None;
|
||||
rp.highlight = None;
|
||||
@@ -105,7 +105,7 @@ pub(crate) fn shape_para(
|
||||
if let Some(c) = &props.bullet_color {
|
||||
rp.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||||
rp.size = Some(props.bullet_size_points.unwrap_or(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0)));
|
||||
rp.underline = None;
|
||||
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
|
||||
|
||||
@@ -46,7 +46,7 @@ pub(crate) struct Style {
|
||||
pub(crate) highlight: Option<Rgba>,
|
||||
pub(crate) outline: Option<(Rgba, f64)>,
|
||||
pub(crate) link: bool,
|
||||
pub(crate) gradient: Option<deckcraft_model::style::Gradient>,
|
||||
pub(crate) gradient: Option<std::sync::Arc<deckcraft_model::style::Gradient>>,
|
||||
pub(crate) effects: Option<RunEffects>,
|
||||
/// "Regular", "Bold"… for the slot and fallback faces.
|
||||
pub(crate) style_name: &'static str,
|
||||
@@ -136,7 +136,7 @@ pub(crate) fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgb
|
||||
for s in &mut g.stops {
|
||||
s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None));
|
||||
}
|
||||
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g))
|
||||
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(std::sync::Arc::new(g)))
|
||||
}
|
||||
Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None),
|
||||
_ => (resolve::text_color(ctx, r), 1.0, None),
|
||||
|
||||
@@ -1416,6 +1416,16 @@ fn tab_stops_align_the_text_after_them() {
|
||||
assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_tabs_over_many_stops_lay_out_by_position() {
|
||||
// 20,000 stops given out of order, and 2,000 tabs: each tab finds the next stop by position.
|
||||
let mut stops: Vec<(f64, &str)> = (1..=20_000).rev().map(|k| (k as f64 * 0.05 + 30.0, "l")).collect();
|
||||
stops.push((f64::NAN, "l"));
|
||||
let text = "a\t".repeat(2_000);
|
||||
let l = tabbed(&text, &stops, 1.0e6);
|
||||
assert!((l.lines[0].edges[2].0 - l.inner.x0 - 30.05).abs() < 0.01, "{:?}", &l.lines[0].edges[..3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tab_past_the_end_moves_to_the_next_line() {
|
||||
// PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next
|
||||
|
||||
@@ -19,6 +19,7 @@ deckcraft-anim = { workspace = true }
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-media = { workspace = true }
|
||||
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
|
||||
egui_extras = { workspace = true }
|
||||
craft-ui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -106,6 +106,7 @@ impl SlideApp {
|
||||
twisting: false,
|
||||
next_file_id: 1,
|
||||
pending_files: Vec::new(),
|
||||
host_start: None,
|
||||
ime_tracker: egui::ime_context::ImeContextTracker::new(),
|
||||
ime_pending: None,
|
||||
ime_frame_viewport: None,
|
||||
|
||||
@@ -11,6 +11,52 @@ pub(crate) fn system_clipboard_text(t: &str) -> String {
|
||||
if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() }
|
||||
}
|
||||
|
||||
/// Whether pasted `text` (and the clipboard's `html`) only name a picture that is on the clipboard
|
||||
/// too, as a browser's Copy Image leaves them: the picture's address, perhaps followed by its alt
|
||||
/// text, or HTML that is a lone `<img>`. Then Ctrl+V pastes the picture, as PowerPoint does.
|
||||
/// Text with NULs is UTF-16 read as UTF-8 (Firefox's `text/x-moz-url`), never words to keep.
|
||||
pub(crate) fn names_a_picture(text: &str, html: Option<&str>) -> bool {
|
||||
if text.contains('\0') {
|
||||
return true;
|
||||
}
|
||||
if let Some(h) = html
|
||||
&& h.to_ascii_lowercase().contains("<img")
|
||||
&& visible_text(h).trim().is_empty()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let mut lines = text.trim().lines();
|
||||
let first = lines.next().unwrap_or("").trim();
|
||||
let link = ["http://", "https://", "file://", "data:image/"].iter().any(|p| first.to_ascii_lowercase().starts_with(p));
|
||||
link && !first.contains(char::is_whitespace) && lines.count() <= 1
|
||||
}
|
||||
|
||||
/// The text of an HTML fragment outside its tags (and outside comments and `<style>`/`<script>`
|
||||
/// bodies, which browsers sometimes put around a copied picture).
|
||||
fn visible_text(html: &str) -> String {
|
||||
let lower = html.to_ascii_lowercase();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < html.len() {
|
||||
let rest = &lower[i..];
|
||||
let skip_to = |end: &str| rest.find(end).map(|e| i + e + end.len()).unwrap_or(html.len());
|
||||
if rest.starts_with("<!--") {
|
||||
i = skip_to("-->");
|
||||
} else if rest.starts_with("<style") {
|
||||
i = skip_to("</style>");
|
||||
} else if rest.starts_with("<script") {
|
||||
i = skip_to("</script>");
|
||||
} else if rest.starts_with('<') {
|
||||
i = skip_to(">");
|
||||
} else {
|
||||
let end = rest.find('<').map_or(html.len(), |e| i + e);
|
||||
out.push_str(&html[i..end]);
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
out.replace(" ", " ")
|
||||
}
|
||||
|
||||
impl SlideApp {
|
||||
/// Offer the copy or cut on the system clipboard.
|
||||
///
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user