mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
265
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fadda5ecf | ||
|
|
b913c4f9d0 | ||
|
|
9aef21e627 | ||
|
|
7626f74b99 | ||
|
|
a58bdbc756 | ||
|
|
10d911cdd6 | ||
|
|
7871ff3d62 | ||
|
|
8b912c106e | ||
|
|
37ac38447d | ||
|
|
a7876aab54 | ||
|
|
e282b90185 | ||
|
|
4ada0efac2 | ||
|
|
8c55529df9 | ||
|
|
137685b098 | ||
|
|
07cbe5c908 | ||
|
|
a5ca7d04b0 | ||
|
|
b6bd10e0a8 | ||
|
|
c7a1423105 | ||
|
|
e294a09664 | ||
|
|
3545a0a995 | ||
|
|
9861de3c29 | ||
|
|
48066c4674 | ||
|
|
964d470122 | ||
|
|
8f81cdc380 | ||
|
|
a64487ca2c | ||
|
|
5018f53391 | ||
|
|
bfb1362cef | ||
|
|
7ada390639 | ||
|
|
e452a751c1 | ||
|
|
c6c86e7a3f | ||
|
|
e27d549be7 | ||
|
|
a0aca7f36b | ||
|
|
b2017dcb47 | ||
|
|
4030f3c008 | ||
|
|
a7c4894471 | ||
|
|
1d6205d576 | ||
|
|
274a55e812 | ||
|
|
7e5b21b794 | ||
|
|
fe9674297a | ||
|
|
836313560e | ||
|
|
b339ba490d | ||
|
|
1763368843 | ||
|
|
2736193d31 | ||
|
|
e6527e5c2c | ||
|
|
2d7b69d7b6 | ||
|
|
8485b10356 | ||
|
|
e8e36be177 | ||
|
|
0f00a94c23 | ||
|
|
5bebcd9075 | ||
|
|
998429fb9e | ||
|
|
f5e9cf5b0a | ||
|
|
9057bb7ef0 | ||
|
|
6498809e29 | ||
|
|
eefb1ac380 | ||
|
|
ddeef2d9a6 | ||
|
|
1cb436bcbb | ||
|
|
e128a09caa | ||
|
|
3084f5b5f4 | ||
|
|
532d88cda1 | ||
|
|
e676b823f9 | ||
|
|
7d80f052fb | ||
|
|
86f6b365f3 | ||
|
|
8a1a1ee698 | ||
|
|
4b6ff7d324 | ||
|
|
8aa76142c9 | ||
|
|
070cee617e | ||
|
|
c787f4f080 | ||
|
|
ebf67f8a9a | ||
|
|
b7ea777be7 | ||
|
|
8a75773988 | ||
|
|
8a61586585 | ||
|
|
9a3db3840a | ||
|
|
5bdea79f5c | ||
|
|
f001f37906 | ||
|
|
49f296cca1 | ||
|
|
f43d42d535 | ||
|
|
ac0130d2d7 | ||
|
|
0f4fffcf6c | ||
|
|
bdcb532608 | ||
|
|
de18659978 | ||
|
|
58d746bc17 | ||
|
|
9eacd994bb | ||
|
|
40f6f1756b | ||
|
|
3a917bb4e0 | ||
|
|
90d40e2ecc | ||
|
|
da9bbac5a0 | ||
|
|
da3e89695f | ||
|
|
6e201094c6 | ||
|
|
c0b405c42a | ||
|
|
ced5dc04ed | ||
|
|
f330a02970 | ||
|
|
7858d589cd | ||
|
|
19ebd9c2dc | ||
|
|
b47195feec | ||
|
|
bde35fab11 | ||
|
|
c4a459d1ca | ||
|
|
07c8e2d77f | ||
|
|
df0fab857d | ||
|
|
42e89b347d | ||
|
|
a2435383b1 | ||
|
|
d01254cf2d | ||
|
|
3f399dd450 | ||
|
|
8f04a999b9 | ||
|
|
f011313a3a | ||
|
|
792829ec41 | ||
|
|
4a1d27a227 | ||
|
|
0b8a4cc23e | ||
|
|
31a828b027 | ||
|
|
1dd96d3ddc | ||
|
|
dffef33960 | ||
|
|
7f2955be1c | ||
|
|
aa6f45ba2c | ||
|
|
e11c7e4d7e | ||
|
|
128cb0b883 | ||
|
|
8ddb977733 | ||
|
|
7dce06dca4 | ||
|
|
e149f32208 | ||
|
|
354087ecd5 | ||
|
|
4113361660 | ||
|
|
30d00468e2 | ||
|
|
7919429652 | ||
|
|
48dbdffe07 | ||
|
|
5a76518ddd | ||
|
|
9ad2cf3be8 | ||
|
|
f71ce3fa9d | ||
|
|
63831cdfa9 | ||
|
|
e03debfd4b | ||
|
|
906898f662 | ||
|
|
2741d3236b | ||
|
|
96697f7612 | ||
|
|
7b0252f291 | ||
|
|
2303efe7d5 | ||
|
|
2d6582163f | ||
|
|
e89a893fe1 | ||
|
|
72a9e4456e | ||
|
|
309d9f5b2c | ||
|
|
6b91132ce7 | ||
|
|
5e0a60b15e | ||
|
|
02b3940efd | ||
|
|
98b6d04413 | ||
|
|
e5a407d6e2 | ||
|
|
6f83ed464d | ||
|
|
e37267f548 | ||
|
|
a31b990d32 | ||
|
|
7571e01210 | ||
|
|
18f5e17b36 | ||
|
|
65564cfc1b | ||
|
|
2071ff599d | ||
|
|
1df1d2e724 | ||
|
|
e843309280 | ||
|
|
fe510bdac6 | ||
|
|
a9031d63e1 | ||
|
|
0dc39b9efc | ||
|
|
57d65faf4f | ||
|
|
7040bb0619 | ||
|
|
900f62f5a3 | ||
|
|
f8c8223ff0 | ||
|
|
883cc6e391 | ||
|
|
f34f4121c8 | ||
|
|
ba194e35d8 | ||
|
|
1090ca2d70 | ||
|
|
9412d3ccaf | ||
|
|
c90ea7d9ae | ||
|
|
5beca8c1b6 | ||
|
|
5ac14f2015 | ||
|
|
9b56f83929 | ||
|
|
935724850b | ||
|
|
a49570e45b | ||
|
|
b75ea712e9 | ||
|
|
fc8e811c2a | ||
|
|
010941ec60 | ||
|
|
e34169ff75 | ||
|
|
86e7879f5e | ||
|
|
0a0ad04f47 | ||
|
|
74bc27f27e | ||
|
|
445e7c6b55 | ||
|
|
0298201b2c | ||
|
|
03993057a4 | ||
|
|
172952f6e0 | ||
|
|
e54bd2ccb7 | ||
|
|
8e9c198c7c | ||
|
|
cb78440a16 | ||
|
|
601823d98c | ||
|
|
868016ee2f | ||
|
|
0981e6e675 | ||
|
|
006bbe1136 | ||
|
|
dfe40a17bf | ||
|
|
0b6705c12c | ||
|
|
ef6e8cdc2f | ||
|
|
9430673350 | ||
|
|
efc279d6f2 | ||
|
|
c1184d64fd | ||
|
|
8b01ed3e88 | ||
|
|
9e1430e296 | ||
|
|
fab77f45e5 | ||
|
|
11bc157477 | ||
|
|
389c2c2921 | ||
|
|
4fcf593bac | ||
|
|
26371b0629 | ||
|
|
dfa91441e6 | ||
|
|
9eadf35c97 | ||
|
|
f5a895ef75 | ||
|
|
0883d89e77 | ||
|
|
eb417f0470 | ||
|
|
483a351c37 | ||
|
|
19fadde1cc | ||
|
|
46dec4a660 | ||
|
|
45eeb7c819 | ||
|
|
6dca3e860b | ||
|
|
3e729bbf08 | ||
|
|
3f973b0d87 | ||
|
|
e5a3c9ca65 | ||
|
|
8ee5094c60 | ||
|
|
368e07493c | ||
|
|
051d3b4fd3 | ||
|
|
daae74b57e | ||
|
|
5e8c1bcc51 | ||
|
|
f4afe6d2df | ||
|
|
a773fcfce9 | ||
|
|
121882e177 | ||
|
|
22db9261f9 | ||
|
|
19724866a6 | ||
|
|
83f21e7cb9 | ||
|
|
bdcbee8d0b | ||
|
|
23144e36fe | ||
|
|
a178b9955b | ||
|
|
2cf4535c5d | ||
|
|
887e868a46 | ||
|
|
2a4adf3024 | ||
|
|
9632162029 | ||
|
|
12085b1153 | ||
|
|
b0006d6af8 | ||
|
|
70244c899d | ||
|
|
8f4b0d4c3f | ||
|
|
271c6c4037 | ||
|
|
432016fc96 | ||
|
|
d3fb412167 | ||
|
|
7f1979a1cc | ||
|
|
392734a135 | ||
|
|
39e7fb1d4f | ||
|
|
c7423aa986 | ||
|
|
8c41140abe | ||
|
|
b6af19ae35 | ||
|
|
1dc69720db | ||
|
|
1b64de3499 | ||
|
|
ad6be08603 | ||
|
|
e5620b538a | ||
|
|
1bb408462c | ||
|
|
6d98534a27 | ||
|
|
e755d3eeb1 | ||
|
|
f4731822c9 | ||
|
|
f9af68a06f | ||
|
|
be2f10d8cb | ||
|
|
acea4e3f14 | ||
|
|
367c3488c5 | ||
|
|
c8dc372519 | ||
|
|
d7509e7ad6 | ||
|
|
595d8f17c1 | ||
|
|
59d5893793 | ||
|
|
0954289c62 | ||
|
|
062adfe405 | ||
|
|
aa681eafad | ||
|
|
c39d71a51a | ||
|
|
79088b7060 | ||
|
|
9859da3925 |
@@ -0,0 +1,6 @@
|
||||
# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
|
||||
reviews:
|
||||
auto_review:
|
||||
# Dependabot bumps are checked by CI; skip CodeRabbit reviews on them.
|
||||
ignore_usernames:
|
||||
- "dependabot[bot]"
|
||||
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
+18
-45
@@ -1,55 +1,28 @@
|
||||
name: ci
|
||||
name: CI
|
||||
|
||||
# Quality gates for every push to main and every pull request: rustfmt, clippy (-D warnings),
|
||||
# tests, asset attribution, crate layering and the wasm32 check — `cargo xtask ci`.
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Same craft-fonts commit as release.yml (Latin presentation fonts).
|
||||
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
timeout-minutes: 60
|
||||
name: gates (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
|
||||
# release.yml builds macOS (a failing release is fixed and rebuilt).
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: ci-${{ matrix.os }}
|
||||
- name: cargo xtask ci
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
run: cargo xtask ci
|
||||
gate:
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
packages: >-
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev
|
||||
libgl1-mesa-dev
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow itself still run it.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "crates/fonts/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
schedule:
|
||||
- cron: "27 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix:
|
||||
# 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/nix.yml@main
|
||||
@@ -0,0 +1,21 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,31 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall.
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
# Nightly, so dependency and runner-image drift shows up within a day.
|
||||
schedule:
|
||||
- cron: "3 5 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# 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/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-dir: DeckCraft
|
||||
@@ -131,11 +131,12 @@ Standard: craftrules `standards/never-crash.md`.
|
||||
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, layering check, asset check, wasm
|
||||
`cargo xtask ci` = fmt check, clippy `-D warnings`, tests (nextest when installed) and doctests, layering check, asset check, wasm
|
||||
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`) and push
|
||||
to `main`.
|
||||
|
||||
|
||||
Generated
+62
-163
@@ -444,30 +444,15 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
|
||||
dependencies = [
|
||||
"bit-vec 0.8.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec 0.9.1",
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
@@ -820,10 +805,18 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -892,15 +885,17 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"deckcraft-engine",
|
||||
"deckcraft-media",
|
||||
"deckcraft-ui-egui",
|
||||
"eframe",
|
||||
"egui",
|
||||
"fmv-macos-events",
|
||||
"image",
|
||||
"log",
|
||||
"rfd",
|
||||
@@ -911,7 +906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -920,7 +915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -929,31 +924,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
"serde_json",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
@@ -977,7 +974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -989,7 +986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1000,7 +997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1008,7 +1005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1017,7 +1014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1026,19 +1023,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1048,7 +1047,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1066,7 +1065,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1075,7 +1074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1087,15 +1086,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1113,7 +1112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1129,7 +1128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1148,7 +1147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1162,7 +1161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1184,7 +1183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1193,7 +1192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1631,10 +1630,15 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
name = "fmv-macos-events"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
checksum = "90642cf66dde7f205dc8b42dfe57b47d0f3a29f571b832742883ef512e23fe55"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
@@ -2247,7 +2251,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error 2.0.1",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2425,7 +2429,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
@@ -2634,7 +2638,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"cfg-if",
|
||||
"cfg_aliases",
|
||||
@@ -3385,15 +3389,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "presser"
|
||||
version = "0.3.1"
|
||||
@@ -3424,37 +3419,12 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "proptest"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"bit-vec 0.8.0",
|
||||
"bitflags 2.13.2",
|
||||
"num-traits",
|
||||
"rand",
|
||||
"rand_chacha",
|
||||
"rand_xorshift",
|
||||
"regex-syntax",
|
||||
"rusty-fork",
|
||||
"tempfile",
|
||||
"unarray",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
@@ -3491,44 +3461,6 @@ version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_xorshift"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a"
|
||||
dependencies = [
|
||||
"rand_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "range-alloc"
|
||||
version = "0.1.5"
|
||||
@@ -3643,12 +3575,6 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "renderdoc-sys"
|
||||
version = "1.1.0"
|
||||
@@ -3735,18 +3661,6 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "rusty-fork"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error 1.2.3",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustybuzz"
|
||||
version = "0.20.1"
|
||||
@@ -4384,7 +4298,7 @@ dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error 2.0.1",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
@@ -4531,12 +4445,6 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unarray"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -4692,15 +4600,6 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -5029,8 +4928,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"bit-set 0.10.0",
|
||||
"bit-vec 0.9.1",
|
||||
"bit-set",
|
||||
"bit-vec",
|
||||
"bitflags 2.13.2",
|
||||
"bytemuck",
|
||||
"cfg_aliases",
|
||||
@@ -5102,7 +5001,7 @@ dependencies = [
|
||||
"android_system_properties",
|
||||
"arrayvec",
|
||||
"ash",
|
||||
"bit-set 0.10.0",
|
||||
"bit-set",
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
@@ -5720,7 +5619,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+12
-4
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -49,7 +49,9 @@ rayon = "1"
|
||||
unicode-bidi = "0.3"
|
||||
unicode-segmentation = "1"
|
||||
unicode-linebreak = "0.1"
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
# Base features are wasm-safe (`std` only). The non-wasm `clock` feature is requested
|
||||
# per-crate for targets that read the local clock (see crates/text).
|
||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||
|
||||
# text / rendering / formats
|
||||
skrifa = "0.47"
|
||||
@@ -57,7 +59,8 @@ harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -82,11 +85,16 @@ self_named_constructors = "allow"
|
||||
large_enum_variant = "allow"
|
||||
result_large_err = "allow"
|
||||
|
||||
# Line tables keep file and line in panics and backtraces; full debug info
|
||||
# for every test binary and dependency would multiply the target dir size.
|
||||
# The test profile inherits both settings.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
@@ -95,4 +103,4 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" }
|
||||
|
||||
@@ -7,15 +7,19 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png" width="128" height="128" alt="DeckCraft icon: a presentation screen with a bar chart on an orange tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">DeckCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Presentations and slide shows; an open-source, clean-room reimplementation of Microsoft PowerPoint, rebuilt in pure Rust.</b>
|
||||
<b>Presentations and slide shows; an open-source, modern presentation and slide designer, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Build decks with themes, layouts, shapes, pictures, tables, charts, transitions and animations,
|
||||
open and save PowerPoint files, and present them full screen. Native on macOS, Windows, Linux
|
||||
open and save .pptx presentation files, and present them full screen. Native on macOS, Windows, Linux
|
||||
and FreeBSD, and in the browser. Every button is also a command that the CLI and AI agents
|
||||
(through MCP) can drive.
|
||||
</p>
|
||||
@@ -25,6 +29,9 @@
|
||||
<img alt="License: MIT OR Apache-2.0" src="https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue?style=flat-square">
|
||||
<img alt="Written in Rust" src="https://img.shields.io/badge/written%20in-Rust-b7410e?style=flat-square">
|
||||
<img alt="Platforms" src="https://img.shields.io/badge/platforms-macOS%20%C2%B7%20Windows%20%C2%B7%20Linux%20%C2%B7%20BSD%20%C2%B7%20Web-555?style=flat-square">
|
||||
<br>
|
||||
<a href="https://sonarcloud.io/project/overview?id=storytold_deckcraft"><img alt="Scanned by SonarCloud" src="https://img.shields.io/badge/Scanned_by-SonarCloud-4b9fd5?style=flat-square&logo=sonarqubecloud&logoColor=white"></a>
|
||||
<a href="https://coderabbit.ai"><img alt="Reviewed by CodeRabbit" src="https://img.shields.io/coderabbit/prs/github/storytold/deckcraft?style=flat-square&label=Reviewed%20by%20CodeRabbit&logo=coderabbit"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -74,7 +81,7 @@ Today it can:
|
||||
audio and video (MP3, AAC, FLAC, ALAC, Ogg, Opus, WAV, AIFF; H.264, HEVC, VP9, AV1).
|
||||
- **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations,
|
||||
by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback.
|
||||
- **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts
|
||||
- **Exchange files**: open and save .pptx presentations, export PDF (slides, notes pages, handouts
|
||||
with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover.
|
||||
- **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an
|
||||
MCP server for AI agents.
|
||||
@@ -132,7 +139,7 @@ settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraf
|
||||
`$XDG_CONFIG_HOME/deckcraft/logs/`; macOS `~/Library/Application Support/DeckCraft/logs/`; Windows
|
||||
`%APPDATA%\DeckCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so this file
|
||||
is what to attach to a bug report: the crash guard's panic report, a failed crash-recovery save,
|
||||
PowerPoint parts skipped on import and audio output problems land there. Each launch moves the
|
||||
Parts skipped on import and audio output problems land there. Each launch moves the
|
||||
previous log to `deckcraft.1.log` (and that one to `deckcraft.2.log`), so the log of a run that
|
||||
crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file.
|
||||
|
||||
@@ -158,8 +165,8 @@ deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # con
|
||||
|
||||
## Roadmap
|
||||
|
||||
DeckCraft is in **alpha**. It covers **84% of PowerPoint's features by breadth** (92% of the core
|
||||
ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
|
||||
DeckCraft is in **alpha**. It covers **84% of the features tracked in [`docs/target-app-parity.md`](docs/target-app-parity.md) by breadth** (92% of the core
|
||||
ones, measured) and is about **55% ready for real work across all tracked capabilities** (estimated).
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
|
||||
[docs/gaps.md](docs/gaps.md) for every known shortfall and
|
||||
@@ -217,6 +224,41 @@ DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcom
|
||||
|
||||
RISC-V 64 (riscv64): `deckcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (cross-compiled; needs glibc 2.39+, e.g. Ubuntu 24.04+).
|
||||
|
||||
**Runtime libraries.** The window-system, GPU and file-dialog libraries DeckCraft needs are opened
|
||||
when the app starts (`dlopen` through `x11-dl`, `xkbcommon-dl`, `ash`, `glutin` and `rfd`) instead
|
||||
of being linked in, so `ldd` shows none of them: an install that satisfies the linked dependencies
|
||||
can still fail at start-up with a loader error and no hint of what to install. Only
|
||||
`libasound.so.2` (slide-show sound) is a hard link-time dependency, and only of the `deckcraft`
|
||||
GUI — `deckcraft-cli` uses neither sound nor a display. The `.deb` and `.rpm` declare the set in
|
||||
`packaging/linux/nfpm.yaml`; a tarball install or a musl system has to provide it itself:
|
||||
|
||||
```sh
|
||||
apk add libx11 libxcursor libxrandr libxi libxkbcommon xkeyboard-config \
|
||||
wayland-libs-client alsa-lib \
|
||||
vulkan-loader mesa-vulkan-swrast \
|
||||
mesa-gl mesa-egl mesa-dri-gallium \
|
||||
xdg-desktop-portal
|
||||
```
|
||||
|
||||
Alpine Linux `v3.22` names above; Void's musl variant uses the same `/lib/ld-musl-x86_64.so.1`
|
||||
loader and the same libraries as `libX11 libXcursor libXrandr libXi libxkbcommon xkeyboard-config
|
||||
wayland alsa-lib vulkan-loader mesa xdg-desktop-portal`. There is no musl build among the release
|
||||
artifacts yet — this is what a build from source on musl needs at run time, and what a future musl
|
||||
artifact will need (#954).
|
||||
|
||||
- `mesa-vulkan-swrast` is the software Vulkan driver: it runs anywhere, VMs and containers
|
||||
included. On a real GPU install your vendor's driver instead (`mesa-vulkan-intel`,
|
||||
`mesa-vulkan-ati`, `mesa-vulkan-nouveau`, …).
|
||||
- Without Vulkan the app falls back to CPU rendering through Mesa's GL drivers. It works, but
|
||||
slowly — a driver limit, not a DeckCraft bug.
|
||||
- `xdg-desktop-portal` (with `xdg-desktop-portal-gtk` or `-kde`; `-wlr` has no file chooser, so
|
||||
on wlroots compositors install `zenity`) is what the Open and Save dialogs talk to; `xkeyboard-config` holds the XKB keyboard data `libxkbcommon` reads.
|
||||
- fontconfig and freetype are not needed: DeckCraft reads `/usr/share/fonts` itself and embeds its
|
||||
presentation fonts.
|
||||
|
||||
Building from source on musl also needs `build-base pkgconf alsa-lib-dev` (`alsa-lib-dev` is the
|
||||
ALSA headers).
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
@@ -279,15 +321,13 @@ Bundled fonts, icons, images and other assets keep their own open licenses; each
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
DeckCraft's themes, layouts, shape geometry, icons and sample decks are original work, drawn or
|
||||
generated in code; no Microsoft artwork, fonts, themes or templates are used.
|
||||
generated in code; no proprietary artwork, fonts, themes or templates are used.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and DeckCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Microsoft and PowerPoint are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. DeckCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+52
-1
@@ -2,7 +2,10 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** trivial (progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
@@ -131,6 +134,38 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
|
||||
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
|
||||
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
|
||||
| 2026-10-18 | Duplicate (Ctrl+D) repeats a move: duplicate a shape, move the copy, and the next Duplicate places its copy the same distance further on, as in PowerPoint (#966) |
|
||||
| 2026-10-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms |
|
||||
| 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
|
||||
| 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
|
||||
| 2026-10-23 | A shape with no fill and no line (a text box) no longer draws its shape-level glow as a halo round its box; the text's own glow is all PowerPoint shows |
|
||||
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
|
||||
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
|
||||
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
|
||||
| 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
|
||||
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
|
||||
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
|
||||
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
|
||||
| 2026-10-18 | Slide show: a video or audio clip is looked up on the slide it belongs to, so slides whose shape ids repeat (the PPTX import numbers each slide afresh) no longer play each other's clips (#994) |
|
||||
| 2026-10-18 | About: Contributors and Models get their own fixed-size block below the tab row, so the tab row is no longer squeezed into the credits and the tabs keep answering (#843) |
|
||||
| 2026-10-19 | README: the Linux runtime libraries a tarball or musl install has to provide — the `dlopen`ed X11/Vulkan/GL/dialog libraries with their Alpine and Void package names, the software-Vulkan fallback, and why fontconfig and freetype are not among them (#955) |
|
||||
| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
|
||||
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
|
||||
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
|
||||
| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) |
|
||||
| 2026-10-16 | Symbol-font characters: a run's U+F0xx symbol code (from `<a:sym typeface="Symbol"/>`) now draws from the installed Symbol face even when it has no private-use mapping — Apple's Symbol on macOS maps the glyphs to real Unicode, so the code is remapped to that character (the Adobe Symbol encoding) instead of falling to a look-alike. Bullets and metafile text take the same remap |
|
||||
| 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection |
|
||||
| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
|
||||
| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
|
||||
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
|
||||
| 2026-10-16 | Fitted canvas: discrete mouse-wheel steps navigate immediately, including rapid and batched input, while trackpad gestures retain their cooldown and zoomed slides still pan. |
|
||||
| 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
|
||||
| 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) |
|
||||
| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off |
|
||||
| 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) |
|
||||
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
|
||||
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
|
||||
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
|
||||
@@ -194,6 +229,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read |
|
||||
| 2026-10-13 | Saving a presentation keeps the embedded workbook behind an unchanged chart, and the chart part's relationships (#335). Editing the chart drops that workbook instead of writing numbers that no longer match it |
|
||||
| 2026-10-13 | A presentation whose object ids are used up refuses to grow instead of handing out duplicate ids (#407) |
|
||||
| 2026-10-13 | macOS: a Finder double-click, Open With, a drop on the Dock icon and `open -a DeckCraft deck.pptx` open the file (#138, #347). `Info.plist` now declares the documents DeckCraft opens (`.deckcraft`, `.pptx`, `.potx`, `.ppsx`, the same set as the Windows installer and the Linux desktop file, kept equal by a test), and the `kAEOpenDocuments` Apple event is handled through the pinned `fmv-macos-events` crate as in GridCraft; the `-psn_` argument older macOS adds on a Finder launch is ignored. PowerPoint stays the default until the user picks DeckCraft |
|
||||
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
|
||||
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
|
||||
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
|
||||
@@ -226,6 +262,20 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
|
||||
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
|
||||
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
|
||||
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
|
||||
| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
|
||||
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
|
||||
| 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) |
|
||||
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
|
||||
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
|
||||
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
|
||||
| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style |
|
||||
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
|
||||
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
|
||||
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
|
||||
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
|
||||
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
|
||||
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
|
||||
@@ -279,6 +329,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-13 | trivial | Progress log: Grid Options dialog |
|
||||
| 2026-10-14 | trivial | Progress log: pen pressure scales ink width |
|
||||
| 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) |
|
||||
| 2026-10-13 | trivial | Progress log: Finder opens presentations on macOS (#138) |
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
|
||||
| 2026-10-12 | minor | Progress log: table cell editing and border resizing |
|
||||
|
||||
@@ -17,3 +17,6 @@ path = "src/main.rs"
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -19,15 +19,16 @@ The shapes of one slide, with their ids, boxes and text, come from the slide.ins
|
||||
},
|
||||
Subcommand {
|
||||
name: "render",
|
||||
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT",
|
||||
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] [--transparent] OUT",
|
||||
summary: "draw slides as PNG images",
|
||||
details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first;
|
||||
OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per
|
||||
slide. Prints each image's path and size.
|
||||
|
||||
--slide N the slide at index N; indices start at 0, and 0 is the default
|
||||
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
|
||||
--edit draw what the editor shows: placeholder prompts and their dashed outlines
|
||||
--slide N the slide at index N; indices start at 0, and 0 is the default
|
||||
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
|
||||
--edit draw what the editor shows: placeholder prompts and their dashed outlines
|
||||
--transparent leave the slide background out, so only what is on the slide is drawn
|
||||
|
||||
deckcraft-cli render deck.deckcraft --slide 2 slide-3.png",
|
||||
},
|
||||
|
||||
@@ -117,11 +117,27 @@ fn open(s: &mut Session, input: Option<&str>, sample: bool) -> Result<(), String
|
||||
return deckcraft_engine::sample::open_sample(s).map_err(|e| e.to_string());
|
||||
}
|
||||
match input {
|
||||
Some(p) => s.execute("file.open", &json!({"path": p})).map(|_| ()).map_err(|e| e.to_string()),
|
||||
Some(p) => {
|
||||
let opened = s.execute("file.open", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
warn_if_damaged(p, &opened);
|
||||
Ok(())
|
||||
}
|
||||
None => s.execute("file.new", &json!({})).map(|_| ()).map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Say on stderr what a file lost on opening, so a saved copy doesn't silently miss it (#203).
|
||||
fn warn_if_damaged(path: &str, opened: &serde_json::Value) {
|
||||
let count = |key: &str| opened.get(key).and_then(|v| v.as_array()).map_or(0, |a| a.len());
|
||||
let (slides, media) = (count("lostSlides"), count("damagedMedia"));
|
||||
if slides > 0 {
|
||||
eprintln!("deckcraft-cli: warning: {path}: {slides} slide{} couldn't be read and left out", if slides == 1 { "" } else { "s" });
|
||||
}
|
||||
if media > 0 {
|
||||
eprintln!("deckcraft-cli: warning: {path}: {media} picture or media file{} couldn't be read", if media == 1 { "" } else { "s" });
|
||||
}
|
||||
}
|
||||
|
||||
/// Median and slowest of `runs` timings of `f`, in milliseconds.
|
||||
fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f64, f64), String> {
|
||||
let mut ms = Vec::with_capacity(runs);
|
||||
@@ -244,11 +260,13 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
let mut all = false;
|
||||
let mut scale = 2.0;
|
||||
let mut edit = false;
|
||||
let mut transparent = false;
|
||||
let mut out = None;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--sample" => sample = true,
|
||||
"--transparent" => transparent = true,
|
||||
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
|
||||
"--all" => all = true,
|
||||
"--edit" => edit = true,
|
||||
@@ -281,7 +299,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?;
|
||||
}
|
||||
for i in list {
|
||||
let (png, w, h) = deckcraft_engine::cmd::file::render_png(&st.doc, i, scale, edit);
|
||||
let (png, w, h) = deckcraft_engine::cmd::file::render_png_with(&st.doc, i, scale, edit, transparent);
|
||||
let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() };
|
||||
std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?;
|
||||
outln!("{path} ({w}×{h})");
|
||||
@@ -680,6 +698,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `render --transparent` leaves the slide background out (#784): the corner is transparent.
|
||||
#[test]
|
||||
fn render_transparent_leaves_the_background_out() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-transparent-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let alpha = |path: &std::path::Path| deckcraft_engine::render::decode_image(&std::fs::read(path).unwrap()).unwrap().get_pixel(1, 1).0[3];
|
||||
let opaque = dir.join("opaque.png");
|
||||
render(&args(&["--sample", "--scale", "0.25", &opaque.to_string_lossy()])).unwrap();
|
||||
assert_eq!(alpha(&opaque), 255);
|
||||
let clear = dir.join("clear.png");
|
||||
render(&args(&["--sample", "--transparent", "--scale", "0.25", &clear.to_string_lossy()])).unwrap();
|
||||
assert_eq!(alpha(&clear), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs.
|
||||
#[test]
|
||||
fn exports_an_animated_gif() {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Opening a `.pptx` that lost slides says so on stderr instead of saving a shorter deck in silence (#203).
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// `deck` with `ppt/slides/slide2.xml` cut off.
|
||||
fn without_slide2(deck: &[u8]) -> Vec<u8> {
|
||||
let mut zin = zip::ZipArchive::new(Cursor::new(deck)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..zin.len() {
|
||||
let mut f = zin.by_index(i).unwrap();
|
||||
let mut data = vec![];
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if f.name() == "ppt/slides/slide2.xml" {
|
||||
data = b"<p:sld".to_vec();
|
||||
}
|
||||
out.start_file(f.name(), options).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pptx_with_an_unreadable_slide_is_reported_on_stderr() {
|
||||
let dir = temp_dir("lost-slide");
|
||||
let good = dir.join("good.pptx");
|
||||
let made = cli(&["run", "--sample", "--save", good.to_str().unwrap()]);
|
||||
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
|
||||
let broken = dir.join("broken.pptx");
|
||||
std::fs::write(&broken, without_slide2(&std::fs::read(&good).unwrap())).unwrap();
|
||||
|
||||
let ok = cli(&["info", good.to_str().unwrap()]);
|
||||
assert!(ok.status.success() && !String::from_utf8_lossy(&ok.stderr).contains("warning"), "{}", String::from_utf8_lossy(&ok.stderr));
|
||||
|
||||
let bad = cli(&["info", broken.to_str().unwrap()]);
|
||||
assert!(bad.status.success(), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
let err = String::from_utf8_lossy(&bad.stderr);
|
||||
assert!(err.contains("warning") && err.contains("1 slide couldn't be read and left out"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
mod cli;
|
||||
mod convert;
|
||||
mod damaged;
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod mcp_roots;
|
||||
@@ -1,6 +1,4 @@
|
||||
//! `deckcraft-cli mcp` with the automation root flags, run as the real binary.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
@@ -18,6 +18,7 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for deckcraft.exe; build.rs only uses it when the target is Windows.
|
||||
@@ -29,5 +30,12 @@ arboard = { workspace = true, features = ["image-data"] }
|
||||
image = { workspace = true }
|
||||
cpal = "0.16"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Finder double-click, Open With, Dock drops and `open -a DeckCraft deck.pptx` arrive as a
|
||||
# kAEOpenDocuments Apple event, which winit 0.30 doesn't handle. This audited crate (also used by
|
||||
# GridCraft, PdfCraft and PhotoCraft) registers the handler (Objective-C interop needs unsafe, which
|
||||
# our workspace forbids) behind a safe API. Pinned exactly: review the diff before bumping.
|
||||
fmv-macos-events = "=0.1.0"
|
||||
|
||||
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
|
||||
winit = { version = "0.30", default-features = false, features = ["x11"] }
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! macOS open-documents and quit Apple events.
|
||||
//!
|
||||
//! Finder double-clicks, Open With, drops on the Dock icon and `open -a DeckCraft deck.pptx` don't
|
||||
//! pass paths on the command line: LaunchServices sends the running (or just-launched) app a
|
||||
//! `kAEOpenDocuments` ('odoc') Apple event. winit 0.30 doesn't handle it and owns the
|
||||
//! `NSApplicationDelegate`. Handling it ourselves needs Objective-C class declarations, i.e.
|
||||
//! `unsafe`, which this workspace forbids. The audited `fmv-macos-events` crate (also used by
|
||||
//! GridCraft, PdfCraft and PhotoCraft) wraps exactly that, an `NSAppleEventManager` handler
|
||||
//! registered before Finder's launch event that leaves winit's delegate alone, behind a safe
|
||||
//! main-thread API.
|
||||
|
||||
use deckcraft_ui_egui::SlideApp;
|
||||
use fmv_macos_events::{Event, Inbox, Registration};
|
||||
|
||||
/// Keeps the Apple-event handlers registered; hold it until the event loop returns.
|
||||
pub struct AppleEvents {
|
||||
_registration: Registration,
|
||||
inbox: Inbox,
|
||||
}
|
||||
|
||||
impl AppleEvents {
|
||||
/// Register the handlers. Call on the main thread before the event loop starts, so the event
|
||||
/// that launched the app (a Finder double-click) is caught too.
|
||||
pub fn install() -> Self {
|
||||
let (registration, inbox) = Registration::install();
|
||||
Self { _registration: registration, inbox }
|
||||
}
|
||||
|
||||
/// The queue the app drains every frame ([`poll`]); events arriving later wake `ctx`.
|
||||
pub fn connect(&self, ctx: &egui::Context) -> Inbox {
|
||||
let ctx = ctx.clone();
|
||||
self.inbox.set_wake(move || ctx.request_repaint());
|
||||
self.inbox.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame.
|
||||
pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
if let Err(e) = app.open_path(p) {
|
||||
log::error!("{p}: {e}");
|
||||
app.set_status(format!("Couldn't open {p}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`deckcraft-cli mcp --connect`) wraps.
|
||||
//!
|
||||
//! Every resource a client can hold is bounded ([`Limits`]): simultaneous connections, request
|
||||
//! line length, queued requests, and how long a socket may sit idle or blocked on a write.
|
||||
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
|
||||
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
|
||||
//! closed, so nothing sent after it runs. Every resource a client can hold is bounded
|
||||
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
|
||||
//! socket may sit idle or blocked on a write.
|
||||
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::net::{Shutdown, TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use deckcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
@@ -58,12 +61,22 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
|
||||
/// example while the process has no file descriptors left, and retrying at once would keep a
|
||||
/// core busy until it recovers.
|
||||
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The connections `incoming` accepts, calling `wait` after each failed accept.
|
||||
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
|
||||
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
|
||||
}
|
||||
|
||||
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
std::thread::spawn(move || {
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
|
||||
let _ = stream.set_write_timeout(Some(limits.write_timeout));
|
||||
if active.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
@@ -120,6 +133,82 @@ fn read_line(r: &mut impl BufRead, max: usize) -> Line {
|
||||
}
|
||||
}
|
||||
|
||||
/// One request line, parsed.
|
||||
enum Parsed {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> Parsed {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Parsed::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Parsed::Blank;
|
||||
}
|
||||
if is_http_request_line(text) {
|
||||
return Parsed::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Parsed::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Parsed::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Parsed::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Parsed::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
|
||||
fn is_http_request_line(text: &str) -> bool {
|
||||
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
|
||||
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
/// Close after the last reply so the peer can still read it. A peer that had sent another line
|
||||
/// before the reply arrived -- two lines in one write, which any script does -- leaves input
|
||||
/// unread in the socket, and closing a socket with unread input resets the connection: the reply
|
||||
/// already queued for the peer is discarded and it sees only the reset. Sending the FIN first and
|
||||
/// then draining, briefly and boundedly, makes the close orderly and the reason survive.
|
||||
fn close_politely(out: &TcpStream, reader: &mut BufReader<TcpStream>, grace: Duration) {
|
||||
if out.shutdown(Shutdown::Write).is_err() {
|
||||
return;
|
||||
}
|
||||
// Enough for a pipelined line or two; a peer that keeps talking is dropped at the deadline.
|
||||
// The timeout is what is left of the grace before each read, not the whole of it: a peer that
|
||||
// sends a byte just before each one expires would otherwise hold this thread, and its slot,
|
||||
// for close to twice as long as the deadline says.
|
||||
const MAX_DRAIN: usize = 64 << 10;
|
||||
let until = Instant::now() + grace;
|
||||
let mut sink = [0u8; 4 << 10];
|
||||
let mut drained = 0;
|
||||
while drained < MAX_DRAIN {
|
||||
let left = until.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() || out.set_read_timeout(Some(left)).is_err() {
|
||||
break;
|
||||
}
|
||||
match reader.read(&mut sink) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => drained += n,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits) {
|
||||
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
|
||||
return;
|
||||
@@ -132,24 +221,22 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
Line::Text(t) => t,
|
||||
Line::TooLong => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("request line longer than {} bytes", limits.max_line)}));
|
||||
close_politely(&out, &mut reader, limits.write_timeout);
|
||||
break;
|
||||
}
|
||||
Line::Closed => break,
|
||||
};
|
||||
let Ok(line) = String::from_utf8(line) else {
|
||||
if writeln!(out, "{}", json!({"ok": false, "error": "bad JSON: not UTF-8"})).is_err() {
|
||||
let reply = match parse(&line) {
|
||||
Parsed::Blank => continue,
|
||||
Parsed::Reject(e) => {
|
||||
// The connection closes either way, but the peer is told why first: see
|
||||
// `close_politely`, without which a peer that had pipelined another line had this
|
||||
// reply reset away and saw nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
|
||||
close_politely(&out, &mut reader, limits.write_timeout);
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
Parsed::Request { id, method, params } => {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let state = Arc::clone(&req.state);
|
||||
let mut r = match tx.try_send(req) {
|
||||
@@ -177,7 +264,6 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
if let Err(e) = writeln!(out, "{reply}") {
|
||||
if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) {
|
||||
@@ -191,6 +277,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A failed accept is followed by a wait before the next one, so a listener that keeps
|
||||
/// failing does not spin, and the connections around the failures still come through.
|
||||
#[test]
|
||||
fn a_failed_accept_waits_before_the_next() {
|
||||
let failed = || Err(std::io::Error::other("no file descriptors left"));
|
||||
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
|
||||
let mut waits = 0;
|
||||
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
|
||||
assert_eq!(connections, [1, 2]);
|
||||
assert_eq!(waits, 3, "one wait per failed accept");
|
||||
}
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -245,6 +343,66 @@ mod tests {
|
||||
assert_eq!(v["result"], "ui.inspect");
|
||||
}
|
||||
|
||||
/// Every reply line until the server closes the connection.
|
||||
fn replies_until_closed(r: &mut BufReader<TcpStream>) -> Vec<Value> {
|
||||
std::iter::from_fn(|| reply(r)).collect()
|
||||
}
|
||||
|
||||
/// A browser can POST to the port: its request line closes the connection before the JSON
|
||||
/// line in its body is read, so nothing is dispatched.
|
||||
#[test]
|
||||
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
|
||||
let (addr, rx) = server(Limits { max_line: 512, ..small() });
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(
|
||||
b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
|
||||
assert!(rx.try_recv().is_err(), "the body was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_a_request_closes_the_connection() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(junk).unwrap();
|
||||
w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
}
|
||||
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
|
||||
assert!(is_http_request_line("PRI * HTTP/2.0"));
|
||||
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
|
||||
}
|
||||
|
||||
/// A peer that pipelines -- two lines in one write, as a script does -- still reads why its
|
||||
/// line was rejected. Closing with its second line unread resets the connection and discards
|
||||
/// the reply already on its way, so the peer saw nothing at all; repeated here because it
|
||||
/// depended on the timing of the two.
|
||||
#[test]
|
||||
fn a_pipelined_peer_still_reads_why_its_line_was_rejected() {
|
||||
let (addr, rx) = server(small());
|
||||
for _ in 0..12 {
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"hello\n").unwrap();
|
||||
// A second line, written after the first as a script writes it: it races the close.
|
||||
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
assert!(replies[0]["error"].as_str().unwrap_or_default().contains("closing the connection"), "{replies:?}");
|
||||
}
|
||||
// The line after the rejected one is never dispatched, pipelined or not.
|
||||
assert!(rx.try_recv().is_err(), "a line after the rejection was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_line_closes_connection() {
|
||||
let (addr, rx) = server(small());
|
||||
|
||||
@@ -95,29 +95,35 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
|
||||
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
|
||||
/// instead of panicking.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
|
||||
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
|
||||
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
|
||||
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -389,6 +395,17 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
|
||||
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
|
||||
// with a checked add and only asserted where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+116
-7
@@ -1,6 +1,7 @@
|
||||
//! DeckCraft desktop app.
|
||||
//!
|
||||
//! Usage: `deckcraft [--control <port>] [--sample] [--show] [files…]`
|
||||
//! A PowerPoint Show (`.ppsx`) given last starts its slide show, as a double-click does in PowerPoint.
|
||||
//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits.
|
||||
//!
|
||||
//! `--control <port>` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
@@ -13,6 +14,8 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod apple_events;
|
||||
mod audio;
|
||||
mod control_server;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
@@ -26,10 +29,17 @@ mod pasteboard;
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_ui_egui::{Services, SlideApp};
|
||||
|
||||
struct App(SlideApp);
|
||||
struct App(
|
||||
SlideApp,
|
||||
/// Files opened from Finder and quit requests (see `apple_events`).
|
||||
#[cfg(target_os = "macos")]
|
||||
fmv_macos_events::Inbox,
|
||||
);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events::poll(&self.1, &mut self.0, ctx);
|
||||
self.0.logic(ctx);
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
@@ -134,6 +144,8 @@ Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...]
|
||||
--show start the slide show
|
||||
--help, -h print this help and exit
|
||||
--version, -V print the version and exit
|
||||
|
||||
A PowerPoint Show (.ppsx) given last starts its slide show.
|
||||
";
|
||||
|
||||
enum Startup {
|
||||
@@ -155,6 +167,8 @@ fn startup(mut control_port: Option<u16>, args: impl IntoIterator<Item = String>
|
||||
"--show" => show = true,
|
||||
"--version" | "-V" => return Startup::Version,
|
||||
"--help" | "-h" => return Startup::Help,
|
||||
// The process serial number older macOS adds when Finder launches the app.
|
||||
a if a.starts_with("-psn_") => {}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
@@ -231,6 +245,12 @@ fn main() -> eframe::Result {
|
||||
// Before eframe creates the wgpu instance: default Windows to DirectX 12 with FXC (see graphics.rs).
|
||||
#[cfg(target_os = "windows")]
|
||||
graphics::configure(&mut options, eframe::wgpu::Backends::from_env(), eframe::wgpu::Dx12Compiler::from_env());
|
||||
// Registered before the event loop starts, so it catches the Finder event that launched us as
|
||||
// well as later ones. Lives until the event loop returns; the app creator only borrows it.
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = apple_events::AppleEvents::install();
|
||||
#[cfg(target_os = "macos")]
|
||||
let apple_events = &apple_events;
|
||||
eframe::run_native(
|
||||
"DeckCraft",
|
||||
options,
|
||||
@@ -254,15 +274,18 @@ fn main() -> eframe::Result {
|
||||
if sample && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
|
||||
log::error!("sample: {e}");
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.open_path(&f) {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
// A .ppsx opened last starts its slide show (#862).
|
||||
for (f, e) in app.open_from_system(&files) {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
if show {
|
||||
app.start_show(0, false);
|
||||
}
|
||||
Ok(Box::new(App(app)))
|
||||
Ok(Box::new(App(
|
||||
app,
|
||||
#[cfg(target_os = "macos")]
|
||||
apple_events.connect(&cc.egui_ctx),
|
||||
)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -276,7 +299,11 @@ mod tests {
|
||||
#[test]
|
||||
fn on_exit_ends_the_process_on_macos() {
|
||||
if std::env::var_os("DECKCRAFT_TEST_ON_EXIT").is_some() {
|
||||
let mut app = App(SlideApp::new(Session::new(), Services::default()));
|
||||
let mut app = App(
|
||||
SlideApp::new(Session::new(), Services::default()),
|
||||
#[cfg(target_os = "macos")]
|
||||
fmv_macos_events::Inbox::default(),
|
||||
);
|
||||
println!("calling on_exit");
|
||||
eframe::App::on_exit(&mut app);
|
||||
println!("on_exit returned");
|
||||
@@ -309,4 +336,86 @@ mod tests {
|
||||
assert!(!show);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finder_process_serial_number_is_not_a_file() {
|
||||
let launch = startup(None, ["-psn_0_1234567".to_string(), "talk.pptx".to_string()]);
|
||||
assert!(matches!(launch, Startup::Run { .. }));
|
||||
if let Startup::Run { files, .. } = launch {
|
||||
assert_eq!(files, ["talk.pptx".to_string()]);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `<string>` values of every `<array>` that follows a `<key>{key}</key>` line.
|
||||
fn plist_strings(plist: &str, key: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut in_array = false;
|
||||
for line in plist.lines() {
|
||||
let line = line.trim();
|
||||
if line == format!("<key>{key}</key>") {
|
||||
in_array = true;
|
||||
continue;
|
||||
}
|
||||
if in_array {
|
||||
for part in line.split("<string>").skip(1) {
|
||||
if let Some((s, _)) = part.split_once("</string>") {
|
||||
out.push(s.to_string());
|
||||
}
|
||||
}
|
||||
if line.ends_with("</array>") {
|
||||
in_array = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every desktop offers the same file types: macOS (`CFBundleTypeExtensions` and the native
|
||||
/// type's extension in `Info.plist.in`), Windows (`SupportedTypes` in `deckcraft.wxs`) and
|
||||
/// Linux (`MimeType` in the desktop file). A format added to one installer must reach the
|
||||
/// others, and each must be one Finder, Explorer or the file manager can hand to DeckCraft.
|
||||
#[test]
|
||||
fn every_desktop_offers_the_same_file_types() {
|
||||
let plist = include_str!("../../../packaging/macos/Info.plist.in");
|
||||
let wxs = include_str!("../../../packaging/windows/deckcraft.wxs");
|
||||
let desktop = include_str!("../../../packaging/linux/ai.storyteller.deckcraft.desktop");
|
||||
|
||||
let mut mac = plist_strings(plist, "CFBundleTypeExtensions");
|
||||
// The native type is declared by its UTI; its extension is in the exported type.
|
||||
mac.extend(plist_strings(plist, "public.filename-extension").into_iter().filter(|e| e == "deckcraft"));
|
||||
mac.sort();
|
||||
mac.dedup();
|
||||
assert!(mac.iter().any(|e| e == "pptx") && mac.iter().any(|e| e == "deckcraft"), "Info.plist.in: {mac:?}");
|
||||
// Every document type names a content type too, so "Change All" in Finder's Get Info works.
|
||||
let names = plist.matches("<key>CFBundleTypeName</key>").count();
|
||||
let content_types = plist.matches("<key>LSItemContentTypes</key>").count();
|
||||
assert!(names > 0 && names == content_types, "{names} document types, {content_types} with LSItemContentTypes");
|
||||
|
||||
let mut win: Vec<String> = wxs
|
||||
.lines()
|
||||
.filter(|l| l.contains("SupportedTypes\" Name=\"."))
|
||||
.filter_map(|l| l.split("Name=\".").nth(1)?.split('"').next().map(str::to_string))
|
||||
.collect();
|
||||
win.sort();
|
||||
|
||||
let mime_ext = |m: &str| match m {
|
||||
"application/x-deckcraft" => Some("deckcraft"),
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation" => Some("pptx"),
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.template" => Some("potx"),
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.slideshow" => Some("ppsx"),
|
||||
_ => None,
|
||||
};
|
||||
let mut linux: Vec<String> = desktop
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("MimeType="))
|
||||
.unwrap_or_default()
|
||||
.split(';')
|
||||
.filter(|m| !m.is_empty())
|
||||
.map(|m| mime_ext(m).unwrap_or(m).to_string()) // an unknown MIME type shows up in the diff
|
||||
.collect();
|
||||
linux.sort();
|
||||
|
||||
assert_eq!(mac, win, "macOS and Windows offer different file types");
|
||||
assert_eq!(mac, linux, "macOS and Linux offer different file types");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,3 +54,8 @@ real_name = "Nikita Rakitin"
|
||||
|
||||
[people.RCShoemaker]
|
||||
real_name = "Ryan C. Shoemaker"
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
@@ -49,7 +49,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
|
||||
n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit())
|
||||
} {
|
||||
i += 2;
|
||||
while cs.get(i).is_some_and(|d| d.is_ascii_digit()) {
|
||||
while cs.get(i).is_some_and(char::is_ascii_digit) {
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
|
||||
+11
-7
@@ -299,7 +299,8 @@ impl ShowState {
|
||||
ShowAction::FinishStep(n)
|
||||
}
|
||||
|
||||
/// ← / Backspace: undo the last step, else go to the previous slide fully built.
|
||||
/// ← / Backspace: undo the last step, else go to the previous slide fully built. An automatic
|
||||
/// first step (With/After Previous) is part of the slide appearing, so it is never undone.
|
||||
pub fn prev(&mut self, pres: &Presentation) -> ShowAction {
|
||||
if self.ended {
|
||||
self.ended = false;
|
||||
@@ -310,13 +311,16 @@ impl ShowState {
|
||||
self.playing = false;
|
||||
return ShowAction::Slide(self.slide);
|
||||
}
|
||||
if self.playing {
|
||||
// #844: undoing the automatic first step left the slide as before it appeared, and the show
|
||||
// played the step again at once, so Back never got past the slide.
|
||||
let undo = if self.playing { Some(self.step) } else { self.step.checked_sub(1) };
|
||||
let auto_first = undo == Some(0) && !pres.show.without_animation && ShowState::timeline(pres, self.slide).step_is_auto(0);
|
||||
if let Some(k) = undo
|
||||
&& !auto_first
|
||||
{
|
||||
self.playing = false;
|
||||
return ShowAction::StepBack(self.step);
|
||||
}
|
||||
if self.step > 0 {
|
||||
self.step -= 1;
|
||||
return ShowAction::StepBack(self.step);
|
||||
self.step = k;
|
||||
return ShowAction::StepBack(k);
|
||||
}
|
||||
let target = match self.prev_index(pres) {
|
||||
Some(i) => Some(i),
|
||||
|
||||
@@ -344,6 +344,36 @@ fn empty_slide() {
|
||||
assert_eq!(tl.locate(5.0), (0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_of_seeks_the_play_all_clock_to_an_effect() {
|
||||
let mut late = anim(4, AnimClass::Entrance, "fade", AnimStart::WithPrevious);
|
||||
late.delay_ms = 250;
|
||||
let mut triggered = anim(4, AnimClass::Emphasis, "pulse", AnimStart::OnClick);
|
||||
triggered.trigger = Some(ShapeId(1));
|
||||
let tl = Timeline::new(
|
||||
&slide_with(vec![
|
||||
anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick),
|
||||
anim(2, AnimClass::Exit, "fadeOut", AnimStart::OnClick),
|
||||
anim(3, AnimClass::Entrance, "fade", AnimStart::OnClick),
|
||||
late,
|
||||
triggered,
|
||||
]),
|
||||
W,
|
||||
H,
|
||||
);
|
||||
assert_eq!(tl.start_of(0), Some(0.0));
|
||||
assert_eq!(tl.start_of(2), Some(2.0));
|
||||
assert_eq!(tl.start_of(3), Some(2.25));
|
||||
assert_eq!(tl.start_of(4), None, "a triggered effect is not on the play-all clock");
|
||||
assert_eq!(tl.start_of(9), None);
|
||||
// Playing from the third effect: what came before has played, the rest has not.
|
||||
let at = tl.start_of(2).unwrap();
|
||||
assert!(tl.state_at(ShapeId(1), None, at).visible, "entered before");
|
||||
assert!(!tl.state_at(ShapeId(2), None, at).visible, "exited before");
|
||||
assert!(!tl.state_at(ShapeId(3), None, at).visible, "not entered yet");
|
||||
assert!(!tl.state_at(ShapeId(4), None, at).visible, "not entered yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_play_all_clock() {
|
||||
let tl = Timeline::new(
|
||||
@@ -808,6 +838,30 @@ fn show_steps_through_builds() {
|
||||
assert_eq!(s.step, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_back_leaves_a_slide_whose_first_step_is_automatic() {
|
||||
// #844: undoing the automatic first step left the slide as before it appeared, and the show
|
||||
// played that step again at once, so Back never reached the slide before.
|
||||
for start in [AnimStart::WithPrevious, AnimStart::AfterPrevious] {
|
||||
let mut p = pres(2);
|
||||
Arc::make_mut(&mut p.slides[1]).shapes = vec![shape(1, "A"), shape(2, "B")];
|
||||
Arc::make_mut(&mut p.slides[1]).animations =
|
||||
vec![anim(1, AnimClass::Entrance, "fade", start), anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)];
|
||||
let mut s = ShowState::new(&p);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(s.playing, "{start:?}");
|
||||
// While the automatic step plays.
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}");
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
// Once it has played, the click step is undone first, then Back leaves the slide.
|
||||
assert_eq!(s.next(&p), ShowAction::PlayStep(1));
|
||||
assert_eq!(s.step_done(&p), ShowAction::FinishStep(1));
|
||||
assert_eq!(s.prev(&p), ShowAction::StepBack(1), "{start:?}");
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}");
|
||||
assert_eq!((s.step, s.playing), (0, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_auto_advance() {
|
||||
let mut p = pres(2);
|
||||
|
||||
@@ -359,6 +359,19 @@ impl Timeline {
|
||||
(self.main.len(), 0.0)
|
||||
}
|
||||
|
||||
/// Where effect `anim` (index into `slide.animations`) starts on the play-everything clock:
|
||||
/// Animation Pane ▸ Play From. `None` for a triggered effect or one that isn't on the slide.
|
||||
pub fn start_of(&self, anim: usize) -> Option<f64> {
|
||||
let e = self
|
||||
.effects
|
||||
.iter()
|
||||
.map(|e| &e.timing)
|
||||
.filter(|e| e.anim == anim && e.sequence == Sequence::Main)
|
||||
.min_by(|a, b| a.step.cmp(&b.step).then(a.start.total_cmp(&b.start)))?;
|
||||
let before: f64 = self.main.iter().take(e.step).map(|s| s.duration).sum();
|
||||
Some(before + e.start)
|
||||
}
|
||||
|
||||
/// Shapes that start an interactive sequence when clicked.
|
||||
pub fn trigger_shapes(&self) -> Vec<ShapeId> {
|
||||
self.triggers.iter().map(|(s, _)| *s).collect()
|
||||
|
||||
@@ -2222,18 +2222,16 @@ fn neg_deinterleave(diff: i32, r: i32, max: i32) -> i32 {
|
||||
if diff <= 2 * r {
|
||||
if diff & 1 == 1 {
|
||||
return r + ((diff + 1) >> 1);
|
||||
} else {
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
diff
|
||||
} else {
|
||||
if diff <= 2 * (max - r - 1) {
|
||||
if diff & 1 == 1 {
|
||||
return r + ((diff + 1) >> 1);
|
||||
} else {
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
max - (diff + 1)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,4 @@ rust-version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
craft-media = { workspace = true, features = ["bitstream"] }
|
||||
|
||||
+4
-485
@@ -1,495 +1,14 @@
|
||||
//! Bit-level reading and writing shared by DeckCraft's codec crates (ported from FilmCraft).
|
||||
//! Bit-level reading and writing for DeckCraft's codec crates: a re-export of
|
||||
//! [`craft_media::bitstream`] from craft-libs, plus [`budget`].
|
||||
//!
|
||||
//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers.
|
||||
//! - [`BitWriter`]: the matching writer (for encoders and header rewriting).
|
||||
//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
|
||||
//! - [`budget`]: the memory budget video decoders check their picture store against.
|
||||
//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
|
||||
//! - [`budget`]: the memory budget video decoders check their picture store against.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
use std::fmt;
|
||||
pub use craft_media::bitstream::*;
|
||||
|
||||
pub mod budget;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BitError {
|
||||
/// Tried to read past the end of the buffer.
|
||||
Eof,
|
||||
/// An Exp-Golomb code longer than 32 leading zeros.
|
||||
InvalidExpGolomb,
|
||||
/// A NAL length prefix width outside 1..=4 bytes.
|
||||
InvalidLengthSize,
|
||||
}
|
||||
|
||||
impl fmt::Display for BitError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
BitError::Eof => f.write_str("unexpected end of bitstream"),
|
||||
BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"),
|
||||
BitError::InvalidLengthSize => f.write_str("unsupported NAL length prefix size"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for BitError {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, BitError>;
|
||||
|
||||
/// MSB-first bit reader over a byte slice.
|
||||
#[derive(Clone)]
|
||||
pub struct BitReader<'a> {
|
||||
data: &'a [u8],
|
||||
/// Position in bits.
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Self {
|
||||
Self { data, pos: 0 }
|
||||
}
|
||||
pub fn data(&self) -> &'a [u8] {
|
||||
self.data
|
||||
}
|
||||
/// Bit position from the start.
|
||||
pub fn position(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
pub fn bits_left(&self) -> usize {
|
||||
(self.data.len() * 8).saturating_sub(self.pos)
|
||||
}
|
||||
pub fn is_byte_aligned(&self) -> bool {
|
||||
self.pos.is_multiple_of(8)
|
||||
}
|
||||
pub fn byte_align(&mut self) {
|
||||
self.pos = self.pos.div_ceil(8) * 8;
|
||||
}
|
||||
/// Byte offset of the current position (rounded down).
|
||||
pub fn byte_pos(&self) -> usize {
|
||||
self.pos / 8
|
||||
}
|
||||
pub fn seek_bits(&mut self, pos: usize) {
|
||||
self.pos = pos;
|
||||
}
|
||||
|
||||
/// Peek up to 32 bits without consuming (zero-padded past the end).
|
||||
#[inline]
|
||||
pub fn peek(&self, n: u32) -> u32 {
|
||||
debug_assert!(n <= 32);
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
let byte = self.pos / 8;
|
||||
let shift = self.pos % 8;
|
||||
let mut v: u64 = 0;
|
||||
for i in 0..5 {
|
||||
v = (v << 8) | *self.data.get(byte + i).unwrap_or(&0) as u64;
|
||||
}
|
||||
// v holds 40 bits starting at `byte`
|
||||
((v << (24 + shift)) >> (64 - n)) as u32
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn read_bits(&mut self, n: u32) -> Result<u32> {
|
||||
if n as usize > self.bits_left() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
let v = self.peek(n);
|
||||
self.pos += n as usize;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub fn read_bits_u64(&mut self, n: u32) -> Result<u64> {
|
||||
if n <= 32 {
|
||||
return self.read_bits(n).map(u64::from);
|
||||
}
|
||||
let hi = self.read_bits(n - 32)? as u64;
|
||||
let lo = self.read_bits(32)? as u64;
|
||||
Ok((hi << 32) | lo)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn read_bit(&mut self) -> Result<bool> {
|
||||
self.read_bits(1).map(|b| b == 1)
|
||||
}
|
||||
pub fn read_flag(&mut self) -> Result<bool> {
|
||||
self.read_bit()
|
||||
}
|
||||
|
||||
pub fn skip(&mut self, n: usize) -> Result<()> {
|
||||
if n > self.bits_left() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
self.pos += n;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unsigned Exp-Golomb `ue(v)`.
|
||||
pub fn read_ue(&mut self) -> Result<u32> {
|
||||
let mut zeros = 0u32;
|
||||
while !self.read_bit()? {
|
||||
zeros += 1;
|
||||
if zeros > 32 {
|
||||
return Err(BitError::InvalidExpGolomb);
|
||||
}
|
||||
}
|
||||
if zeros == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let v = self.read_bits_u64(zeros)?;
|
||||
let r = (1u64 << zeros) - 1 + v;
|
||||
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// `ue(v) + add` (the `..._minus1` / `..._minus8` fields), failing instead of overflowing on a
|
||||
/// hostile value near `u32::MAX`.
|
||||
pub fn read_ue_plus(&mut self, add: u32) -> Result<u32> {
|
||||
self.read_ue()?.checked_add(add).ok_or(BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// Signed Exp-Golomb `se(v)`. Codes whose value does not fit an `i32` (the code for `2^31`) are rejected.
|
||||
pub fn read_se(&mut self) -> Result<i32> {
|
||||
let k = self.read_ue()? as i64;
|
||||
let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) };
|
||||
i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// Truncated Exp-Golomb `te(v)` with range `max`.
|
||||
pub fn read_te(&mut self, max: u32) -> Result<u32> {
|
||||
if max > 1 { self.read_ue() } else { Ok(!self.read_bit()? as u32) }
|
||||
}
|
||||
|
||||
/// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits.
|
||||
pub fn more_rbsp_data(&self) -> bool {
|
||||
let total = self.data.len() * 8;
|
||||
if self.pos >= total {
|
||||
return false;
|
||||
}
|
||||
// find last set bit (the rbsp_stop_one_bit)
|
||||
let mut last = None;
|
||||
for (i, &b) in self.data.iter().enumerate().rev() {
|
||||
if b != 0 {
|
||||
last = Some(i * 8 + 7 - b.trailing_zeros() as usize);
|
||||
break;
|
||||
}
|
||||
}
|
||||
match last {
|
||||
Some(stop) => self.pos < stop,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MSB-first bit writer.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct BitWriter {
|
||||
buf: Vec<u8>,
|
||||
acc: u64,
|
||||
nbits: u32,
|
||||
}
|
||||
|
||||
impl BitWriter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
pub fn write_bits(&mut self, value: u32, n: u32) {
|
||||
debug_assert!(n <= 32);
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let v = (value as u64) & ((1u64 << n) - 1);
|
||||
self.acc = (self.acc << n) | v;
|
||||
self.nbits += n;
|
||||
while self.nbits >= 8 {
|
||||
self.nbits -= 8;
|
||||
self.buf.push((self.acc >> self.nbits) as u8);
|
||||
}
|
||||
self.acc &= (1u64 << self.nbits) - 1;
|
||||
}
|
||||
pub fn write_bit(&mut self, b: bool) {
|
||||
self.write_bits(b as u32, 1);
|
||||
}
|
||||
pub fn write_ue(&mut self, v: u32) {
|
||||
self.write_ue_u64(v as u64);
|
||||
}
|
||||
fn write_ue_u64(&mut self, v: u64) {
|
||||
let x = v + 1;
|
||||
let len = 64 - x.leading_zeros();
|
||||
let zeros = len - 1;
|
||||
for _ in 0..zeros {
|
||||
self.write_bit(false);
|
||||
}
|
||||
if len > 32 {
|
||||
self.write_bits((x >> 32) as u32, len - 32);
|
||||
self.write_bits(x as u32, 32);
|
||||
} else {
|
||||
self.write_bits(x as u32, len);
|
||||
}
|
||||
}
|
||||
/// Signed Exp-Golomb `se(v)`. `i32::MIN` needs a code above `u32::MAX`, which `read_se` rejects.
|
||||
pub fn write_se(&mut self, v: i32) {
|
||||
let v = v as i64;
|
||||
let k = if v > 0 { (v as u64) * 2 - 1 } else { ((-v) as u64) * 2 };
|
||||
self.write_ue_u64(k);
|
||||
}
|
||||
pub fn bit_len(&self) -> usize {
|
||||
self.buf.len() * 8 + self.nbits as usize
|
||||
}
|
||||
pub fn is_byte_aligned(&self) -> bool {
|
||||
self.nbits == 0
|
||||
}
|
||||
/// Pad with zero bits to a byte boundary.
|
||||
pub fn align_zero(&mut self) {
|
||||
if self.nbits > 0 {
|
||||
self.write_bits(0, 8 - self.nbits);
|
||||
}
|
||||
}
|
||||
/// `rbsp_trailing_bits()`: a one bit then zeros to alignment.
|
||||
pub fn rbsp_trailing(&mut self) {
|
||||
self.write_bit(true);
|
||||
self.align_zero();
|
||||
}
|
||||
pub fn write_bytes(&mut self, bytes: &[u8]) {
|
||||
if self.nbits == 0 {
|
||||
self.buf.extend_from_slice(bytes);
|
||||
} else {
|
||||
for &b in bytes {
|
||||
self.write_bits(b as u32, 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn finish(mut self) -> Vec<u8> {
|
||||
self.align_zero();
|
||||
self.buf
|
||||
}
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
&self.buf
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove emulation-prevention bytes (`00 00 03` → `00 00`) from a NAL payload.
|
||||
pub fn unescape_rbsp(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
let mut zeros = 0;
|
||||
for &b in data {
|
||||
if zeros >= 2 && b == 3 {
|
||||
zeros = 0;
|
||||
continue;
|
||||
}
|
||||
out.push(b);
|
||||
zeros = if b == 0 { zeros + 1 } else { 0 };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Insert emulation-prevention bytes so no `00 00 0x` (x ≤ 3) sequence appears.
|
||||
pub fn escape_rbsp(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len() + data.len() / 64);
|
||||
let mut zeros = 0;
|
||||
for &b in data {
|
||||
if zeros >= 2 && b <= 3 {
|
||||
out.push(3);
|
||||
zeros = 0;
|
||||
}
|
||||
out.push(b);
|
||||
zeros = if b == 0 { zeros + 1 } else { 0 };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split an Annex-B byte stream (start codes `00 00 01` / `00 00 00 01`) into NAL units (escaped payloads).
|
||||
pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
|
||||
let mut starts = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 3 <= data.len() {
|
||||
if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
|
||||
starts.push(i + 3);
|
||||
i += 3;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(starts.len());
|
||||
for (k, &s) in starts.iter().enumerate() {
|
||||
let mut e = if k + 1 < starts.len() { starts[k + 1] - 3 } else { data.len() };
|
||||
// trim trailing zero bytes (part of a 4-byte start code or trailing_zero_8bits)
|
||||
while e > s && data[e - 1] == 0 {
|
||||
e -= 1;
|
||||
}
|
||||
if e > s {
|
||||
out.push(&data[s..e]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1..=4 bytes, as the 2-bit `lengthSizeMinusOne` allows) into NAL units.
|
||||
pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result<Vec<&[u8]>> {
|
||||
if !(1..=4).contains(&length_size) {
|
||||
return Err(BitError::InvalidLengthSize);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
if length_size > data.len() - i {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
let mut len = 0usize;
|
||||
for k in 0..length_size {
|
||||
len = (len << 8) | data[i + k] as usize;
|
||||
}
|
||||
i += length_size;
|
||||
if len > data.len() - i {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
out.push(&data[i..i + len]);
|
||||
i += len;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// CRC-32 (IEEE, reflected) — used by containers (e.g. Matroska CRC-32 elements, PNG).
|
||||
pub fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
crc ^= b as u32;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn read_ue_plus_rejects_overflow() {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(7);
|
||||
w.write_ue(u32::MAX);
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
assert_eq!(r.read_ue_plus(1).unwrap(), 8);
|
||||
assert!(r.read_ue_plus(1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_exp_golomb_extremes() {
|
||||
let mut w = BitWriter::new();
|
||||
for v in [i32::MAX, i32::MIN + 1, i32::MIN] {
|
||||
w.write_se(v);
|
||||
}
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
assert_eq!(r.read_se().unwrap(), i32::MAX);
|
||||
assert_eq!(r.read_se().unwrap(), i32::MIN + 1);
|
||||
assert_eq!(r.read_se(), Err(BitError::InvalidExpGolomb));
|
||||
// ue code 0xFFFF_FFFF maps to +2^31, which must not wrap to a negative i32.
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(u32::MAX);
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
assert_eq!(BitReader::new(&b).read_se(), Err(BitError::InvalidExpGolomb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_prefixed_rejects_bad_width_and_oversized_length() {
|
||||
assert_eq!(length_prefixed_nals(&[1, 2, 3, 4, 5, 6], 5), Err(BitError::InvalidLengthSize));
|
||||
assert_eq!(length_prefixed_nals(&[1, 2, 3], 0), Err(BitError::InvalidLengthSize));
|
||||
assert_eq!(length_prefixed_nals(&[0, 0, 1, 7], 3).unwrap(), vec![&[7u8][..]]);
|
||||
assert_eq!(length_prefixed_nals(&[0xFF, 0xFF, 0xFF, 0xFF, 1], 4), Err(BitError::Eof));
|
||||
assert_eq!(length_prefixed_nals(&[0, 0, 0, 1, 9], 4).unwrap(), vec![&[9u8][..]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_bits() {
|
||||
let d = [0b1010_1100, 0b0101_0011];
|
||||
let mut r = BitReader::new(&d);
|
||||
assert_eq!(r.read_bits(1).unwrap(), 1);
|
||||
assert_eq!(r.read_bits(3).unwrap(), 0b010);
|
||||
assert_eq!(r.read_bits(8).unwrap(), 0b1100_0101);
|
||||
assert_eq!(r.bits_left(), 4);
|
||||
assert_eq!(r.read_bits(5), Err(BitError::Eof));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exp_golomb_known() {
|
||||
// 1 | 010 | 011 | 00100 | 00101 -> 0,1,2,3,4
|
||||
let mut w = BitWriter::new();
|
||||
for v in 0..5 {
|
||||
w.write_ue(v);
|
||||
}
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for v in 0..5 {
|
||||
assert_eq!(r.read_ue().unwrap(), v);
|
||||
}
|
||||
let mut w = BitWriter::new();
|
||||
for v in [0, 1, -1, 2, -2, 1000, -1000] {
|
||||
w.write_se(v);
|
||||
}
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for v in [0, 1, -1, 2, -2, 1000, -1000] {
|
||||
assert_eq!(r.read_se().unwrap(), v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulation_prevention() {
|
||||
let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];
|
||||
let esc = escape_rbsp(&raw);
|
||||
assert_eq!(esc, vec![0, 0, 3, 1, 0, 0, 3, 0, 5, 0, 0, 3, 3]);
|
||||
assert_eq!(unescape_rbsp(&esc), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annexb_split() {
|
||||
let s = [0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 9, 9];
|
||||
let n = annexb_nals(&s);
|
||||
assert_eq!(n, vec![&[0x67, 1, 2][..], &[0x68, 3][..], &[0x65, 9, 9][..]]);
|
||||
let l = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 1, 0x68];
|
||||
assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_rbsp() {
|
||||
let d = [0b1100_0000];
|
||||
let mut r = BitReader::new(&d);
|
||||
assert!(r.more_rbsp_data());
|
||||
r.read_bit().unwrap();
|
||||
assert!(!r.more_rbsp_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc() {
|
||||
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn roundtrip(vals in proptest::collection::vec((0u32..u32::MAX, 1u32..=32), 0..200), ues in proptest::collection::vec(0u32..u32::MAX - 1, 0..50)) {
|
||||
let mut w = BitWriter::new();
|
||||
for &(v, n) in &vals { w.write_bits(v, n); }
|
||||
for &u in &ues { w.write_ue(u); }
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for &(v, n) in &vals {
|
||||
let mask = if n == 32 { u32::MAX } else { (1 << n) - 1 };
|
||||
prop_assert_eq!(r.read_bits(n).unwrap(), v & mask);
|
||||
}
|
||||
for &u in &ues { prop_assert_eq!(r.read_ue().unwrap(), u); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_roundtrip(data in proptest::collection::vec(0u8..4, 0..300)) {
|
||||
let e = escape_rbsp(&data);
|
||||
prop_assert_eq!(unescape_rbsp(&e), data);
|
||||
for w in e.windows(3) { prop_assert!(!(w[0] == 0 && w[1] == 0 && w[2] < 3)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ impl Rgba {
|
||||
/// `RRGGBB` or `#RRGGBB` (also `RRGGBBAA`).
|
||||
pub fn from_hex(s: &str) -> Option<Self> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
if !s.is_ascii() {
|
||||
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
@@ -519,6 +519,14 @@ pub fn preset(name: &str) -> Option<Rgba> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_hex_rejects_signs() {
|
||||
assert_eq!(Rgba::from_hex("+12345"), None);
|
||||
assert_eq!(Rgba::from_hex("#+1+2+3"), None);
|
||||
assert_eq!(Rgba::from_hex("+1+2+3+4"), None);
|
||||
assert_eq!(Rgba::from_hex("+12"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_roundtrip() {
|
||||
assert_eq!(Rgba::from_hex("#156082"), Some(Rgba::rgb(0x15, 0x60, 0x82)));
|
||||
|
||||
@@ -25,6 +25,7 @@ i_overlay = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
deckcraft-anim = { workspace = true }
|
||||
|
||||
@@ -525,4 +525,46 @@ mod tests {
|
||||
s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap();
|
||||
assert!(s.anim_painter.is_some());
|
||||
}
|
||||
|
||||
fn setup_triggers() -> (Session, [u64; 4]) {
|
||||
let mut s = Session::with_new();
|
||||
let mut ids = [0u64; 4];
|
||||
for (slot, x) in ids.iter_mut().zip([10.0, 150.0, 300.0, 450.0]) {
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10.0, 100.0, 100.0]})).unwrap();
|
||||
*slot = r.get("id").unwrap().as_u64().unwrap();
|
||||
}
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn triggers(s: &Session) -> Vec<Option<deckcraft_model::ShapeId>> {
|
||||
s.doc().unwrap().current_slide().unwrap().animations.iter().map(|x| x.trigger).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_trigger_shape_clears_the_trigger() {
|
||||
let (mut s, [a, b, c, d]) = setup_triggers();
|
||||
let id = |v: u64| Some(deckcraft_model::ShapeId(v as u32));
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [d]})).unwrap();
|
||||
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
|
||||
s.execute("animation.timing", &json!({"index": 1, "trigger": c})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![id(b), id(c)]);
|
||||
|
||||
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![None, id(c)], "only the deleted trigger is cleared");
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 2, "the animated shapes keep their effects");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_group_clears_triggers_of_its_children() {
|
||||
let (mut s, [a, b, c, _]) = setup_triggers();
|
||||
let g = s.execute("arrange.group", &json!({"ids": [b, c]})).unwrap().get("id").unwrap().as_u64().unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
|
||||
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![Some(deckcraft_model::ShapeId(b as u32))]);
|
||||
|
||||
s.execute("edit.delete", &json!({"ids": [g]})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group");
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,7 +208,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// A child's xfrm in slide space, given its group.
|
||||
pub(crate) fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
|
||||
pub fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
|
||||
let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 };
|
||||
let sy = if ch.h.abs() > 1e-9 { group.h / ch.h } else { 1.0 };
|
||||
let a = deckcraft_geom::group_child_affine(group, Rect::new(ch.x, ch.y, ch.x + ch.w, ch.y + ch.h));
|
||||
|
||||
+237
-13
@@ -1,6 +1,7 @@
|
||||
//! Design tab and masters: themes, colours, fonts, backgrounds, slide size, header & footer,
|
||||
//! Slide Master view.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_geom::Size;
|
||||
@@ -592,6 +593,11 @@ fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option<usize
|
||||
}
|
||||
}
|
||||
|
||||
fn used_master_indices(doc: &Presentation) -> BTreeSet<usize> {
|
||||
let fallback = (!doc.masters.is_empty()).then_some(0);
|
||||
doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect()
|
||||
}
|
||||
|
||||
fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: &Option<Background>) -> bool {
|
||||
match sel.target {
|
||||
Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg),
|
||||
@@ -599,7 +605,16 @@ fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option
|
||||
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg),
|
||||
Target::Slides => {
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
!list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg))
|
||||
let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg));
|
||||
if all && bg.is_some() {
|
||||
let masters = used_master_indices(doc);
|
||||
let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg));
|
||||
let layouts_match = !masters.is_empty()
|
||||
&& masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none())));
|
||||
slides_match && masters_match && layouts_match
|
||||
} else {
|
||||
slides_match
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -622,6 +637,17 @@ fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Optio
|
||||
}
|
||||
}
|
||||
Target::Slides => {
|
||||
if all && bg.is_some() {
|
||||
for idx in used_master_indices(doc) {
|
||||
if let Some(m) = doc.masters.get_mut(idx) {
|
||||
let m = Arc::make_mut(m);
|
||||
m.background = bg.clone();
|
||||
for l in &mut m.layouts {
|
||||
l.background = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
@@ -639,7 +665,14 @@ fn copy_slide_background(s: &mut Session) -> Result<Value> {
|
||||
return Err(bad("design.background", "copy applies to slides"));
|
||||
}
|
||||
let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone());
|
||||
if st.doc.slides.iter().all(|sl| sl.background == bg) {
|
||||
let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg);
|
||||
let masters_match = if bg.is_some() {
|
||||
let masters = used_master_indices(&st.doc);
|
||||
!masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg))
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if slides_match && masters_match {
|
||||
return ok();
|
||||
}
|
||||
bg
|
||||
@@ -943,18 +976,21 @@ pub fn date_field_text(kind: &str) -> String {
|
||||
DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm).
|
||||
/// Days since 1970-01-01 → (year, month, day), exact for every `i64`.
|
||||
pub fn civil(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
use chrono::Datelike;
|
||||
// `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400
|
||||
// years (146_097 days). chrono converts the day within its cycle and whole cycles go back
|
||||
// into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and
|
||||
// 0..146_097 days from it always fits.
|
||||
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1456,6 +1492,8 @@ fn slide_number_paragraph() -> deckcraft_model::Paragraph {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_color::SchemeSlot;
|
||||
use deckcraft_model::style::PictureMode;
|
||||
use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes};
|
||||
@@ -1495,6 +1533,11 @@ mod tests {
|
||||
fn civil_dates() {
|
||||
assert_eq!(super::civil(0), (1970, 1, 1));
|
||||
assert_eq!(super::civil(20_734), (2026, 10, 8));
|
||||
// Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes.
|
||||
assert_eq!(super::civil(95_026_602), (262_144, 1, 1));
|
||||
assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1));
|
||||
assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27));
|
||||
assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1727,6 +1770,187 @@ mod tests {
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_all_applies_to_master_and_new_slides_inherit() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s0["background"], "#121826");
|
||||
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
// Idempotency: re-applying the same background to all is a no-op (no extra undo step)
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step");
|
||||
|
||||
// Slide 0 has new background, master has new background, layouts cleared
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. })));
|
||||
assert_eq!(doc.masters[0].background, doc.slides[0].background);
|
||||
for l in &doc.masters[0].layouts {
|
||||
assert!(l.background.is_none());
|
||||
}
|
||||
|
||||
// Newly added slides inherit the master background (fixes #891)
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap();
|
||||
let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
|
||||
let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(s0_after["background"], "#0D0D0D");
|
||||
assert_eq!(s1_after["background"], "#0D0D0D");
|
||||
|
||||
// Undo restores previous slide and master backgrounds
|
||||
s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap(); // undos design.background
|
||||
let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s0_reverted["background"], "#121826");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
|
||||
|
||||
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) };
|
||||
s.edit(|doc, _| {
|
||||
let m = Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts[0].background = Some(custom_layout_bg.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Slide 0 follows master (background: None)
|
||||
assert!(s.doc().unwrap().doc.slides[0].background.is_none());
|
||||
// Give Slide 1 an explicit override
|
||||
s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
|
||||
|
||||
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
|
||||
assert!(orig_master_bg.is_some());
|
||||
|
||||
// Select slide 0 (which follows master) and apply copy to all
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
// Slide 1 has been reset to None (follows master), slide 0 remains None
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.slides[0].background.is_none());
|
||||
assert!(doc.slides[1].background.is_none());
|
||||
|
||||
// Master and layout backgrounds survived!
|
||||
assert_eq!(doc.masters[0].background, orig_master_bg);
|
||||
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
|
||||
|
||||
// Idempotency: re-applying copy is a no-op
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_reset_all_preserves_master_and_layout_backgrounds() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
|
||||
|
||||
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) };
|
||||
s.edit(|doc, _| {
|
||||
let m = Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts[0].background = Some(custom_layout_bg.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
|
||||
assert!(orig_master_bg.is_some());
|
||||
|
||||
// Set explicit backgrounds on both slides individually (master remains untouched)
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap();
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides[0].background.is_some());
|
||||
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
|
||||
|
||||
// Now perform reset with all
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
// Slides reset to None to follow master
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.slides[0].background.is_none());
|
||||
assert!(doc.slides[1].background.is_none());
|
||||
|
||||
// Master and layout backgrounds survived!
|
||||
assert_eq!(doc.masters[0].background, orig_master_bg);
|
||||
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
|
||||
|
||||
// Idempotency: re-running reset all is a no-op
|
||||
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_all_only_touches_used_masters() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
|
||||
// Add a second master to presentation
|
||||
let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) };
|
||||
s.edit(|doc, _| {
|
||||
let mut m2 = (*doc.masters[0]).clone();
|
||||
m2.id = deckcraft_model::MasterId(999);
|
||||
m2.background = Some(second_master_bg.clone());
|
||||
doc.masters.push(Arc::new(m2));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(s.doc().unwrap().doc.masters.len(), 2);
|
||||
// Slides only use master 0 (default layout)
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
// Master 0 updated
|
||||
assert_eq!(doc.masters[0].background, doc.slides[0].background);
|
||||
// Master 1 preserved!
|
||||
assert_eq!(doc.masters[1].background, Some(second_master_bg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
|
||||
// Set all slides and master to #AA0000
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background);
|
||||
|
||||
// Add a layout override on layout 0
|
||||
let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) };
|
||||
s.edit(|doc, _| {
|
||||
let m = Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts[0].background = Some(custom_bg);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override.
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op");
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.masters[0].layouts[0].background.is_none());
|
||||
|
||||
// Now that layout override is cleared, repeating is an actual no-op
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -51,6 +51,8 @@ fn restore(s: &mut Session, redo: bool) -> Result<Value> {
|
||||
st.doc = e.doc;
|
||||
st.selection = e.selection;
|
||||
st.interaction = None;
|
||||
// A later shape can get an undone copy's id: Duplicate starts over with the usual offset.
|
||||
st.last_duplicate = None;
|
||||
st.revision += 1;
|
||||
Ok(json!({"label": e.label}))
|
||||
}
|
||||
@@ -175,7 +177,11 @@ fn copied_html(s: &Session) -> String {
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let caret = copying_caret(s);
|
||||
let t = copy_inner(s, p)?;
|
||||
let html = if caret { String::new() } else { copied_html(s) };
|
||||
if caret {
|
||||
// Nothing is selected, so there is nothing to cut; `delete` would remove the next character.
|
||||
return Ok(json!({"text": t, "html": ""}));
|
||||
}
|
||||
let html = copied_html(s);
|
||||
delete(s, p)?;
|
||||
Ok(json!({"text": t, "html": html}))
|
||||
}
|
||||
@@ -530,15 +536,31 @@ fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let off = s.prefs.duplicate_offset;
|
||||
s.edit(|doc, sel| {
|
||||
let last = s.doc()?.last_duplicate.clone();
|
||||
let (new_ids, made) = s.edit(|doc, sel| {
|
||||
let list = super::current_shapes(doc, sel).cloned().unwrap_or_default();
|
||||
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();
|
||||
// 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
|
||||
.filter(|(slide, target, pairs)| {
|
||||
*slide == sel.slide && *target == sel.target && pairs.len() == sources.len() && pairs.iter().all(|(_, c)| sources.contains(c))
|
||||
})
|
||||
.and_then(|(_, _, pairs)| {
|
||||
let (src, copy) = pairs.first()?;
|
||||
let a = xfrm_of(doc, sel, list.iter().find(|x| x.id == *src)?);
|
||||
let b = xfrm_of(doc, sel, list.iter().find(|x| x.id == *copy)?);
|
||||
let d = (b.x - a.x, b.y - a.y);
|
||||
(d.0.is_finite() && d.1.is_finite()).then_some(d)
|
||||
})
|
||||
.unwrap_or((off, off));
|
||||
reid("edit.duplicate", doc, &mut copies)?;
|
||||
for (c, b) in copies.iter_mut().zip(boxes) {
|
||||
let mut x = b;
|
||||
x.x += off;
|
||||
x.y += off;
|
||||
x.x += dx;
|
||||
x.y += dy;
|
||||
c.xfrm = Some(x);
|
||||
if sel.target == crate::Target::Slides {
|
||||
c.ph = None;
|
||||
@@ -551,8 +573,11 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
|
||||
shapes.extend(copies);
|
||||
sel.shapes = new_ids.clone();
|
||||
Ok(json!({"ids": new_ids}))
|
||||
})
|
||||
let made = (sel.slide, sel.target, sources.into_iter().zip(new_ids.iter().copied()).collect());
|
||||
Ok((new_ids, made))
|
||||
})?;
|
||||
s.doc_mut()?.last_duplicate = Some(made);
|
||||
Ok(json!({"ids": new_ids}))
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -568,12 +593,24 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.delete", "no slide"))?;
|
||||
let mut before = Vec::new();
|
||||
deckcraft_model::walk(shapes, &mut |s, _| before.push(s.id));
|
||||
let n = remove_shapes(shapes, &ids);
|
||||
// Everything that is gone: the given ids plus group descendants and emptied groups.
|
||||
let mut left = Vec::new();
|
||||
deckcraft_model::walk(shapes, &mut |s, _| left.push(s.id));
|
||||
let mut gone = ids.clone();
|
||||
gone.extend(before.into_iter().filter(|i| !left.contains(i)));
|
||||
// Animations of deleted shapes go too.
|
||||
if sel.target == crate::Target::Slides
|
||||
&& let Some(sl) = doc.slides.get_mut(sel.slide)
|
||||
{
|
||||
Arc::make_mut(sl).animations.retain(|a| !ids.contains(&a.shape));
|
||||
let sl = Arc::make_mut(sl);
|
||||
sl.animations.retain(|a| !gone.contains(&a.shape));
|
||||
// Effects triggered by a deleted shape fall back to the main sequence.
|
||||
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| gone.contains(&t))) {
|
||||
a.trigger = None;
|
||||
}
|
||||
}
|
||||
sel.shapes.retain(|i| !ids.contains(i));
|
||||
sel.text = None;
|
||||
|
||||
+115
-29
@@ -1,6 +1,10 @@
|
||||
//! File menu: new, open, save, export, close, properties.
|
||||
|
||||
use deckcraft_model::{Presentation, defaults};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_media::MediaInfo;
|
||||
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -33,7 +37,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
|
||||
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
@@ -43,7 +47,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
|
||||
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool, transparentBackground?: bool} → {png: base64, width, height}", has_doc, render),
|
||||
cmd!(
|
||||
query "slide.colorAt",
|
||||
"Color At",
|
||||
@@ -128,19 +132,36 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
open_checked(name, bytes).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// [`open_presentation`], plus the embedded media a `.deckcraft` file had lost or damaged.
|
||||
pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
if deckcraft_format::sniff(bytes) {
|
||||
return deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()));
|
||||
}
|
||||
open_other(name, bytes).map(|p| (p, vec![]))
|
||||
/// What a file lost on opening.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Damage {
|
||||
/// Pictures and media a `.deckcraft` file had lost or damaged.
|
||||
pub media: Vec<String>,
|
||||
/// Slides a `.pptx` lists but that couldn't be read, so they are not in the presentation.
|
||||
pub slides: Vec<String>,
|
||||
}
|
||||
|
||||
fn open_other(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
impl Damage {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.media.is_empty() && self.slides.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`open_presentation`], plus what the file had lost or damaged.
|
||||
pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Damage)> {
|
||||
if deckcraft_format::sniff(bytes) {
|
||||
let (p, media) = deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
return Ok((p, Damage { media, slides: vec![] }));
|
||||
}
|
||||
open_other(name, bytes).map(|(p, slides)| (p, Damage { media: vec![], slides }))
|
||||
}
|
||||
|
||||
fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
if deckcraft_pptx::sniff(bytes) {
|
||||
let mut p = deckcraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
deckcraft_format::repair(&mut p);
|
||||
return Ok(p);
|
||||
probe_clips(&mut p);
|
||||
return Ok((p, lost_slides));
|
||||
}
|
||||
if deckcraft_pptx::is_encrypted(bytes) {
|
||||
return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet.")));
|
||||
@@ -157,11 +178,47 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
if p.slides.is_empty() {
|
||||
p = defaults::new_presentation(None);
|
||||
}
|
||||
return Ok(p);
|
||||
return Ok((p, vec![]));
|
||||
}
|
||||
Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read")))
|
||||
}
|
||||
|
||||
/// Fill in each audio and video clip's length and picture size from its bytes: a `.pptx` doesn't
|
||||
/// store them. Headers only, so opening never decodes a whole audio file.
|
||||
fn probe_clips(p: &mut Presentation) {
|
||||
fn rec(shapes: &mut [Shape], media: &[MediaItem], probed: &mut HashMap<MediaId, Option<MediaInfo>>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for s in shapes {
|
||||
if let ShapeKind::Media(m) = &mut s.kind
|
||||
&& (m.duration_ms == 0 || m.width == 0 && m.height == 0)
|
||||
&& let Some(info) = probed
|
||||
.entry(m.media)
|
||||
.or_insert_with(|| media.iter().find(|i| i.id == m.media).and_then(|i| deckcraft_media::probe_headers(&i.data).ok()))
|
||||
{
|
||||
if m.duration_ms == 0 {
|
||||
m.duration_ms = info.duration_ms.min(u32::MAX as u64) as u32;
|
||||
}
|
||||
if m.width == 0
|
||||
&& m.height == 0
|
||||
&& let Some(v) = &info.video
|
||||
{
|
||||
(m.width, m.height) = (v.width, v.height);
|
||||
}
|
||||
}
|
||||
if let Some(children) = s.children_mut() {
|
||||
rec(children, media, probed, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut probed = HashMap::new();
|
||||
// Just read, so no slide is shared yet and `make_mut` doesn't copy.
|
||||
for slide in &mut p.slides {
|
||||
rec(&mut Arc::make_mut(slide).shapes, &p.media, &mut probed, 0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
|
||||
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
crate::fonts::install_embedded_fonts(&p);
|
||||
@@ -180,8 +237,8 @@ pub struct LoadedFile {
|
||||
pub doc: Presentation,
|
||||
/// A native or PPTX file (saving goes back to it); an outline import has no file to save to.
|
||||
pub native: bool,
|
||||
/// Pictures and media a `.deckcraft` file had lost or damaged.
|
||||
pub damaged: Vec<String>,
|
||||
/// What the file had lost or damaged.
|
||||
pub damaged: Damage,
|
||||
}
|
||||
|
||||
/// Read and parse the presentation at `path`.
|
||||
@@ -202,7 +259,10 @@ fn adopt_loaded(s: &mut Session, f: LoadedFile) -> Result<Value> {
|
||||
let i = add_opened(s, &path, keep_path.then(|| path.clone()), doc);
|
||||
report_damage(s, &damaged)?;
|
||||
let st = s.doc()?;
|
||||
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts, "damagedMedia": damaged}))
|
||||
Ok(json!({
|
||||
"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts,
|
||||
"damagedMedia": damaged.media, "lostSlides": damaged.slides,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -236,23 +296,35 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (doc, damaged) = open_checked(name, &data)?;
|
||||
let i = add_opened(s, name, None, doc);
|
||||
report_damage(s, &damaged)?;
|
||||
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged}))
|
||||
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged.media, "lostSlides": damaged.slides}))
|
||||
}
|
||||
|
||||
/// Tell the user which pictures and media a damaged file lost, and mark the copy repaired.
|
||||
fn report_damage(s: &mut Session, damaged: &[String]) -> Result<()> {
|
||||
/// Tell the user which pictures, media and slides a damaged file lost, and mark the copy repaired.
|
||||
fn report_damage(s: &mut Session, damaged: &Damage) -> Result<()> {
|
||||
if damaged.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
st.title = format!("{} (Repaired)", st.title);
|
||||
let n = damaged.len();
|
||||
let text = format!(
|
||||
"{n} picture{} or media file{} in this presentation couldn't be read and {} empty. It was opened as a repaired copy; save it under a new name to keep the original file.",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "is" } else { "are" },
|
||||
);
|
||||
let mut text = String::new();
|
||||
let n = damaged.media.len();
|
||||
if n > 0 {
|
||||
text = format!(
|
||||
"{n} picture{} or media file{} in this presentation couldn't be read and {} empty. ",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "is" } else { "are" },
|
||||
);
|
||||
}
|
||||
let n = damaged.slides.len();
|
||||
if n > 0 {
|
||||
text.push_str(&format!(
|
||||
"{n} slide{} in this file couldn't be read and {} left out. ",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "was" } else { "were" },
|
||||
));
|
||||
}
|
||||
text.push_str("It was opened as a repaired copy; save it under a new name to keep the original file.");
|
||||
s.ui_requests.push(crate::UiRequest::Message { text });
|
||||
Ok(())
|
||||
}
|
||||
@@ -431,8 +503,14 @@ pub fn image_scale(doc: &Presentation, scale: f64) -> f64 {
|
||||
|
||||
/// Render slide `index` (current when absent) to PNG bytes.
|
||||
pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec<u8>, u32, u32) {
|
||||
render_png_with(doc, index, scale, edit, false)
|
||||
}
|
||||
|
||||
/// [`render_png`]; `transparent` leaves the slide background out (#784).
|
||||
pub fn render_png_with(doc: &Presentation, index: usize, scale: f64, edit: bool, transparent: bool) -> (Vec<u8>, u32, u32) {
|
||||
let scale = image_scale(doc, scale);
|
||||
let img = deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale, edit, ..Default::default() });
|
||||
let opts = deckcraft_render::RenderOpts { scale, edit, transparent_background: transparent, ..Default::default() };
|
||||
let img = deckcraft_render::render_slide(doc, index, &opts);
|
||||
(img.to_png_at(deckcraft_render::dpi_for_scale(scale)), img.width, img.height)
|
||||
}
|
||||
|
||||
@@ -451,7 +529,7 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("file.render", format!("no slide {i}")));
|
||||
}
|
||||
let (png, w, h) = render_png(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false));
|
||||
let (png, w, h) = render_png_with(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false), bool_or(p, "transparentBackground", false));
|
||||
Ok(json!({"png": base64_encode(&png), "width": w, "height": h}))
|
||||
}
|
||||
|
||||
@@ -483,6 +561,10 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string();
|
||||
let st = s.doc()?;
|
||||
let scale = finite_scale(f64_or(p, "scale", 2.0));
|
||||
let transparent = bool_or(p, "transparentBackground", false);
|
||||
if transparent && format != "png" {
|
||||
return Err(bad("file.export", format!("a transparent background is for PNG export, not {format}")));
|
||||
}
|
||||
match format.as_str() {
|
||||
"png" | "jpeg" | "jpg" => {
|
||||
let slides: Vec<usize> = match usize_param(p, "slide") {
|
||||
@@ -497,7 +579,11 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let scale = image_scale(&st.doc, scale);
|
||||
let mut written = vec![];
|
||||
for (k, i) in slides.iter().enumerate() {
|
||||
let img = deckcraft_render::render_slide(&st.doc, *i, &deckcraft_render::RenderOpts { scale, ..Default::default() });
|
||||
let img = deckcraft_render::render_slide(
|
||||
&st.doc,
|
||||
*i,
|
||||
&deckcraft_render::RenderOpts { scale, transparent_background: transparent, ..Default::default() },
|
||||
);
|
||||
let bytes = picture_bytes(&img, &format, scale);
|
||||
let out = if slides.len() == 1 {
|
||||
path.clone()
|
||||
|
||||
@@ -105,7 +105,7 @@ impl Opts {
|
||||
}
|
||||
|
||||
fn para_chars(p: &Paragraph) -> Vec<char> {
|
||||
p.runs.iter().flat_map(|r| r.text.chars()).collect()
|
||||
p.runs.iter().flat_map(|r| r.content().chars()).collect()
|
||||
}
|
||||
|
||||
/// Matches that lie wholly in plain text runs (never inside a field, equation or break).
|
||||
@@ -113,7 +113,7 @@ fn para_matches(p: &Paragraph, o: &Opts) -> Vec<(usize, usize)> {
|
||||
let mut spans = vec![];
|
||||
let mut off = 0;
|
||||
for r in &p.runs {
|
||||
let n = r.text.chars().count();
|
||||
let n = r.char_len();
|
||||
spans.push((off, off + n, matches!(r.kind, RunKind::Text)));
|
||||
off += n;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ fn splice(p: &mut Paragraph, a: usize, b: usize, with: &str) {
|
||||
let mut off = 0;
|
||||
let mut placed = false;
|
||||
for r in &mut p.runs {
|
||||
let chars: Vec<char> = r.text.chars().collect();
|
||||
let chars: Vec<char> = r.content().chars().collect();
|
||||
let (s, e) = (off, off + chars.len());
|
||||
off = e;
|
||||
if e <= a || s >= b {
|
||||
@@ -222,6 +222,34 @@ mod tests {
|
||||
assert_eq!(opts("cat", false, true).matches(&t), vec![(0, 3), (4, 7)]);
|
||||
}
|
||||
|
||||
fn text_run(t: &str) -> Run {
|
||||
Run { text: t.into(), props: RunProps::default(), kind: RunKind::Text }
|
||||
}
|
||||
|
||||
fn br() -> Run {
|
||||
Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_match_never_spans_a_line_break() {
|
||||
let p = Paragraph { runs: vec![text_run("a"), br(), text_run("b")], ..Default::default() };
|
||||
assert!(para_matches(&p, &opts("ab", true, false)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_count_a_line_break_as_one_char() {
|
||||
let p = Paragraph { runs: vec![text_run("ab"), br(), text_run("cd")], ..Default::default() };
|
||||
assert_eq!(para_matches(&p, &opts("cd", true, false)), vec![(3, 5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splice_after_a_line_break_keeps_the_break() {
|
||||
let mut p = Paragraph { runs: vec![text_run("ab"), br(), text_run("cd")], ..Default::default() };
|
||||
splice(&mut p, 3, 5, "X");
|
||||
assert_eq!(p.text(), "ab\u{b}X");
|
||||
assert_eq!(p.runs[1].kind, RunKind::Break);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splice_across_runs_keeps_the_first_runs_format() {
|
||||
let bold = RunProps { bold: Some(true), ..Default::default() };
|
||||
|
||||
@@ -698,21 +698,28 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = if pi == a.0 { a.1 } else { 0 };
|
||||
let to = if pi == b.0 { b.1 } else { n };
|
||||
let mut pos = 0;
|
||||
for r in &mut para.runs {
|
||||
let mut pieces = Vec::new();
|
||||
for (ri, r) in para.runs.iter().enumerate() {
|
||||
// Breaks and fields are read for context (a break ends a word) but not rewritten.
|
||||
let len = r.char_len();
|
||||
if r.kind == deckcraft_model::text::RunKind::Text {
|
||||
let rs = from.max(pos).saturating_sub(pos);
|
||||
let re = to.min(pos + len).saturating_sub(pos);
|
||||
if re > rs {
|
||||
let chars: Vec<char> = r.text.chars().collect();
|
||||
let mid: String = chars.get(rs..re).map(|c| c.iter().collect()).unwrap_or_default();
|
||||
let pre: String = chars.get(..rs).map(|c| c.iter().collect()).unwrap_or_default();
|
||||
let post: String = chars.get(re..).map(|c| c.iter().collect()).unwrap_or_default();
|
||||
r.text = format!("{pre}{}{post}", ed::change_case(&mid, &m));
|
||||
}
|
||||
let rs = from.max(pos).saturating_sub(pos);
|
||||
let re = to.min(pos + len).saturating_sub(pos);
|
||||
if re > rs {
|
||||
pieces.push((ri, rs, re));
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
let mids: Vec<String> = pieces
|
||||
.iter()
|
||||
.map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default())
|
||||
.collect();
|
||||
let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::<Vec<_>>(), &m);
|
||||
for (&(ri, rs, re), new) in pieces.iter().zip(changed) {
|
||||
let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue };
|
||||
let pre: String = r.text.chars().take(rs).collect();
|
||||
let post: String = r.text.chars().skip(re).collect();
|
||||
r.text = format!("{pre}{new}{post}");
|
||||
}
|
||||
}
|
||||
if collapsed {
|
||||
t.anchor = a;
|
||||
@@ -734,9 +741,12 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_shapes(s, &json!({}), "format.case", |sh| {
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
for para in &mut t.paragraphs {
|
||||
for r in &mut para.runs {
|
||||
// Breaks and fields are read for context (a break ends a word) but not rewritten.
|
||||
let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect();
|
||||
let changed = ed::change_case_parts(&texts, &mode);
|
||||
for (r, new) in para.runs.iter_mut().zip(changed) {
|
||||
if r.kind == deckcraft_model::text::RunKind::Text {
|
||||
r.text = ed::change_case(&r.text, &mode);
|
||||
r.text = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1160,6 +1170,31 @@ pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraphs the text selection covers whole: it starts at or before their start and
|
||||
/// reaches their end. A paragraph the selection only touches the start of (it ends at offset
|
||||
/// 0, as after a triple-click) is not covered.
|
||||
fn covered_paragraphs(s: &Session) -> Vec<usize> {
|
||||
let Ok(st) = s.doc() else { return vec![] };
|
||||
let Some(t) = &st.selection.text else { return vec![] };
|
||||
let Some(body) = super::text::body_of(st, t) else { return vec![] };
|
||||
let (a, b) = t.ordered();
|
||||
// A hostile selection can point past the body: walk only the paragraphs it has.
|
||||
(a.0..=b.0.min(body.paragraphs.len().saturating_sub(1)))
|
||||
.filter(|&pi| {
|
||||
let Some(para) = body.paragraphs.get(pi) else { return false };
|
||||
let starts = pi > a.0 || a.1 == 0;
|
||||
let ends = if pi < b.0 {
|
||||
true
|
||||
} else if b.0 > a.0 && b.1 == 0 {
|
||||
false
|
||||
} else {
|
||||
b.1 >= para.char_len()
|
||||
};
|
||||
starts && ends
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) };
|
||||
if !sticky {
|
||||
@@ -1169,8 +1204,17 @@ fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(r) = painted.run.clone() {
|
||||
format_run(s, &move |rp| *rp = r.clone())?;
|
||||
}
|
||||
if let Some(pp) = painted.para.clone() {
|
||||
format_para(s, &move |para| para.props = pp.clone())?;
|
||||
// Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the
|
||||
// selection covers whole; painting part of a bulleted line changes just its text.
|
||||
let covered = covered_paragraphs(s);
|
||||
if let (Some(pp), false) = (painted.para.clone(), covered.is_empty()) {
|
||||
super::text::edit_text(s, |body, _| {
|
||||
for &pi in &covered {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
para.props = pp.clone();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
|
||||
@@ -84,6 +84,22 @@ fn cover_crop(src: f64, dst: f64) -> [f64; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// The light fill behind the title in the full-bleed idea. Its opacity is an `alpha` transform
|
||||
/// (about 220/255), as PPTX stores it, so the fill compares equal after a save and reopen.
|
||||
fn band_fill() -> Fill {
|
||||
Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgb(255, 255, 255)).with(deckcraft_model::ColorTransform::Alpha(86274)))
|
||||
}
|
||||
|
||||
/// The band as released versions up to 0.10 stored it (opacity in the colour's own alpha).
|
||||
fn legacy_band_fill() -> Fill {
|
||||
Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)))
|
||||
}
|
||||
|
||||
/// Whether `f` is a Full-bleed band, in the current or the legacy form.
|
||||
fn is_band(f: &Fill) -> bool {
|
||||
*f == band_fill() || *f == legacy_band_fill()
|
||||
}
|
||||
|
||||
/// Apply idea `id` to slide `index` of `doc` (`sel` resolves inherited placeholder boxes).
|
||||
pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &str) -> Result<()> {
|
||||
if !ideas_for(doc, index).iter().any(|i| i.0 == id) {
|
||||
@@ -180,8 +196,13 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st
|
||||
let pic = slide.shapes.remove(p);
|
||||
slide.shapes.insert(0, pic);
|
||||
}
|
||||
if band && let Some(t) = slide.shapes.iter_mut().find(|s| s.ph.as_ref().is_some_and(|p| p.kind.is_title())) {
|
||||
t.fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220))));
|
||||
if let Some(t) = slide.shapes.iter_mut().find(|s| !s.hidden && s.ph.as_ref().is_some_and(|p| p.kind.is_title())) {
|
||||
if band {
|
||||
t.fill = Some(band_fill());
|
||||
} else if t.fill.as_ref().is_some_and(is_band) {
|
||||
// The band belongs to full-bleed only: another idea takes it away again.
|
||||
t.fill = None;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -314,6 +335,85 @@ mod tests {
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_from_full_bleed_removes_the_title_band() {
|
||||
let mut s = deck(true);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert!(shape(&s, 500).fill.is_none(), "the band belongs to full-bleed only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_band_goes_on_the_visible_title_not_a_hidden_one_before_it() {
|
||||
let mut s = deck(true);
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut st.doc).slides[0]);
|
||||
let hidden = Shape {
|
||||
id: ShapeId(503),
|
||||
hidden: true,
|
||||
ph: Some(Placeholder { kind: PhType::Title, ..Default::default() }),
|
||||
xfrm: Some(Xfrm::new(50.0, 30.0, 860.0, 90.0)),
|
||||
text: Some(deckcraft_model::text::TextBody::from_text("Old title")),
|
||||
..Default::default()
|
||||
};
|
||||
slide.shapes.insert(0, hidden);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()), "visible title gets the band");
|
||||
assert!(shape(&s, 503).fill.is_none(), "hidden title is untouched");
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert!(shape(&s, 500).fill.is_none(), "switching clears the visible title's band");
|
||||
assert!(shape(&s, 503).fill.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_ideas_removes_the_band_after_a_pptx_round_trip() {
|
||||
let mut s = deck(true);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
s.doc_mut().unwrap().doc = Arc::new(back);
|
||||
let title = s.doc().unwrap().doc.slides[0].shapes.iter().position(|x| x.ph.as_ref().is_some_and(|p| p.kind.is_title())).unwrap();
|
||||
let id = s.doc().unwrap().doc.slides[0].shapes[title].id.0;
|
||||
assert_eq!(shape(&s, id).fill, Some(band_fill()), "band survives the save");
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert!(shape(&s, id).fill.is_none(), "the band is removed after reopening");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_ideas_removes_a_band_saved_by_earlier_versions() {
|
||||
let mut s = deck(true);
|
||||
let legacy = Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)));
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut st.doc).slides[0]);
|
||||
slide.shapes.iter_mut().find(|x| x.id == ShapeId(500)).unwrap().fill = Some(legacy);
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert!(shape(&s, 500).fill.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_fill_set_by_the_user_survives_other_ideas() {
|
||||
// Set through the command, and set to exactly the band's own fill.
|
||||
let mut s = deck(true);
|
||||
s.execute("shape.fill", &json!({"id": 500, "color": "#ffffff", "transparency": 0.14})).unwrap();
|
||||
let user = shape(&s, 500).fill;
|
||||
assert!(user.is_some());
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, user);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_bleed_twice_keeps_the_band_and_undo_after_switching_brings_it_back() {
|
||||
let mut s = deck(true);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
|
||||
s.execute("design.applyIdea", &json!({"id": "framed"})).unwrap();
|
||||
assert!(shape(&s, 500).fill.is_none());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_crop_matches_the_box_aspect() {
|
||||
let c = cover_crop(2.0, 1.0);
|
||||
|
||||
@@ -1761,6 +1761,39 @@ fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option<Option<u32>>, bin_size: Option<Option<f64>>) {
|
||||
if let Some(n) = bin_count {
|
||||
c.bin_count = n;
|
||||
if n.is_some() {
|
||||
c.bin_size = None;
|
||||
}
|
||||
}
|
||||
if let Some(w) = bin_size {
|
||||
c.bin_size = w;
|
||||
if w.is_some() {
|
||||
c.bin_count = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) {
|
||||
if let Some(t) = subtotals {
|
||||
let n = c.series.first().map_or(0, |s| s.values.len());
|
||||
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
|
||||
t.sort_unstable();
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Histogram bins: a count or a width (setting one clears the other; null for automatic).
|
||||
let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32));
|
||||
@@ -1772,33 +1805,8 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let subtotals: Option<Vec<usize>> =
|
||||
p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect());
|
||||
with_chart(s, p, "chart.options", |c| {
|
||||
if let Some(n) = bin_count {
|
||||
c.bin_count = n;
|
||||
if n.is_some() {
|
||||
c.bin_size = None;
|
||||
}
|
||||
}
|
||||
if let Some(w) = bin_size {
|
||||
c.bin_size = w;
|
||||
if w.is_some() {
|
||||
c.bin_count = None;
|
||||
}
|
||||
}
|
||||
if let Some(t) = &subtotals {
|
||||
let n = c.series.first().map_or(0, |s| s.values.len());
|
||||
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
|
||||
t.sort_unstable();
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
}
|
||||
apply_bin_options(c, bin_count, bin_size);
|
||||
apply_subtotals_and_bars(c, p, subtotals.as_deref());
|
||||
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
|
||||
c.raw = None;
|
||||
}
|
||||
|
||||
@@ -228,6 +228,21 @@ mod tests {
|
||||
assert!(s.execute("media.posterFrame", &json!({})).is_err(), "audio has no poster frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_video_reopened_from_pptx_keeps_its_length_and_size() {
|
||||
let mut s = Session::with_new();
|
||||
let clip = include_bytes!("../../../media/tests/fixtures/clip-h264.mp4");
|
||||
s.execute("insert.video", &json!({"name": "clip.mp4", "data": base64_encode(clip)})).expect("insert");
|
||||
let before = s.execute("media.info", &json!({})).expect("info");
|
||||
assert!(before["durationMs"].as_u64().is_some_and(|ms| ms > 0));
|
||||
let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).expect("save");
|
||||
s.execute("file.openBytes", &json!({"name": "deck.pptx", "data": saved["data"]})).expect("open");
|
||||
let after = s.execute("media.info", &json!({})).expect("info");
|
||||
for key in ["durationMs", "width", "height"] {
|
||||
assert_eq!(after[key], before[key], "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_commands_need_media() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -318,6 +318,14 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
||||
pub(crate) fn usize_param(p: &Value, key: &str) -> Option<usize> {
|
||||
p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok())
|
||||
}
|
||||
/// An index param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
||||
/// present but not a valid index (negative, non-integer), so a bad index never acts on the selection.
|
||||
pub(crate) fn opt_usize_param(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` is not a valid index: {v}"))),
|
||||
}
|
||||
}
|
||||
/// A shape id param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
||||
/// present but not a valid id (negative, non-integer, beyond `u32`), so a bad id never edits the selection.
|
||||
pub(crate) fn id_param(p: &Value, key: &str, cmd: &str) -> Result<Option<ShapeId>> {
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{Result, Session};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
noundo "shape.editPoints",
|
||||
"shape.editPoints",
|
||||
"Edit Points",
|
||||
["Shape Format", "Insert Shapes", "Edit Shape"],
|
||||
None,
|
||||
@@ -355,8 +355,8 @@ fn refit(sh: &mut Shape) {
|
||||
/// The one shape whose points are edited: `id`, or the single selected shape.
|
||||
fn target(s: &Session, p: &Value, cmd: &str) -> Result<ShapeId> {
|
||||
let st = s.doc()?;
|
||||
let id = match p.get("id").and_then(Value::as_u64) {
|
||||
Some(i) => ShapeId(i as u32),
|
||||
let id = match id_param(p, "id", cmd)? {
|
||||
Some(i) => i,
|
||||
None => match st.selection.shapes.as_slice() {
|
||||
[one] => *one,
|
||||
_ => return Err(bad(cmd, "select one shape")),
|
||||
@@ -429,8 +429,8 @@ pub fn slide_vertices(doc: &deckcraft_model::Presentation, sel: &crate::Selectio
|
||||
|
||||
fn points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id = match p.get("id").and_then(Value::as_u64) {
|
||||
Some(i) => ShapeId(i as u32),
|
||||
let id = match id_param(p, "id", "shape.points")? {
|
||||
Some(i) => i,
|
||||
None => *st.selection.shapes.first().ok_or_else(|| bad("shape.points", "select a shape"))?,
|
||||
};
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Picture Format ▸ Color: Recolor presets and Set Transparent Color.
|
||||
|
||||
use deckcraft_color::{Rgba, SchemeSlot};
|
||||
use deckcraft_model::style::PictureAdjust;
|
||||
use deckcraft_model::{ColorRef, ShapeKind};
|
||||
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
|
||||
use deckcraft_model::style::{PictureAdjust, PictureMode};
|
||||
use deckcraft_model::{ColorRef, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -27,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Transparent Color",
|
||||
["Picture Format", "Adjust", "Color"],
|
||||
None,
|
||||
"{color: #RRGGBB | none: true, ids?} (pixels of that colour become transparent)",
|
||||
"{color: #RRGGBB | at: [x, y] | none: true, ids?} (pixels of that colour become transparent; `at` takes the colour of the picture's own pixel under that slide point, as a click does in PowerPoint)",
|
||||
has_selection,
|
||||
transparent_color
|
||||
),
|
||||
@@ -121,6 +122,15 @@ fn recolor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(at) = p.get("at") {
|
||||
let (id, c) = picture_pixel_at(s, p, at)?;
|
||||
return edit_shapes(s, &json!({"ids": [id]}), "picture.transparentColor", |sh| {
|
||||
if let ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
fill.adjust.clear_color = Some(c);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let c = if bool_or(p, "none", false) {
|
||||
None
|
||||
} else {
|
||||
@@ -134,3 +144,97 @@ fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// The topmost target picture under slide point `at` and the colour of its own pixel there: the
|
||||
/// picture as stored, before corrections and recolouring, so the colour matches the pixels that
|
||||
/// `clear_color` is compared with.
|
||||
fn picture_pixel_at(s: &Session, p: &Value, at: &Value) -> Result<(ShapeId, Rgba)> {
|
||||
const CMD: &str = "picture.transparentColor";
|
||||
let xy = |i: usize| at.get(i).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
let (Some(x), Some(y)) = (xy(0), xy(1)) else {
|
||||
return Err(bad(CMD, "`at` is [x, y] in slide units"));
|
||||
};
|
||||
let mut ids = targets(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?;
|
||||
// Same order as the hit-test: later in the shape list is in front, and a group's children sit
|
||||
// where the group is. The selection order says nothing about what is on top.
|
||||
ids.sort_by_cached_key(|id| std::cmp::Reverse(stack_path(shapes, *id)));
|
||||
for id in ids {
|
||||
let Some((sh, xf, to_slide)) = placed(&st.doc, &st.selection, shapes, id, Affine::IDENTITY, 0) else { continue };
|
||||
let ShapeKind::Picture { fill } = &sh.kind else { continue };
|
||||
// The camera turns the picture where it is drawn, so the click is mapped back through it too.
|
||||
let turn = text::ctx_for(&st.doc, &st.selection).and_then(|c| deckcraft_render::camera_turn(&c, sh, xf.w, xf.h)).unwrap_or(Affine::IDENTITY);
|
||||
let local = (to_slide * turn).inverse() * Point::new(x, y);
|
||||
if !(local.is_finite() && (0.0..=xf.w).contains(&local.x) && (0.0..=xf.h).contains(&local.y)) {
|
||||
continue;
|
||||
}
|
||||
let PictureMode::Stretch { fill_rect: [l, t, r, b] } = fill.mode else {
|
||||
return Err(bad(CMD, "a tiled picture has no single pixel under a point; give `color`"));
|
||||
};
|
||||
let inner = Rect::new(l * xf.w, t * xf.h, xf.w - r * xf.w, xf.h - b * xf.h);
|
||||
let dest = deckcraft_render::crop_dest(inner, fill.crop);
|
||||
let img =
|
||||
st.doc.media(fill.media).and_then(|m| deckcraft_render::decode_image(&m.data)).ok_or_else(|| bad(CMD, "the picture can't be read"))?;
|
||||
let (u, v) = ((local.x - dest.x0) / dest.width(), (local.y - dest.y0) / dest.height());
|
||||
if !((0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v)) {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let px = ((u * f64::from(w)) as u32).min(w.saturating_sub(1));
|
||||
let py = ((v * f64::from(h)) as u32).min(h.saturating_sub(1));
|
||||
let Some(c) = img.get_pixel_checked(px, py) else { continue };
|
||||
// A transparent pixel keeps a colour nobody sees (often black): clearing it would clear
|
||||
// that colour where it is drawn.
|
||||
if c[3] == 0 {
|
||||
return Err(bad(CMD, "that spot of the picture is already transparent"));
|
||||
}
|
||||
return Ok((id, Rgba::rgb(c[0], c[1], c[2])));
|
||||
}
|
||||
Err(bad(CMD, "the point is not on a selected picture"))
|
||||
}
|
||||
|
||||
/// Where `id` sits in the paint order: its index in each list from the slide down to it. A larger
|
||||
/// path is drawn later, so in front.
|
||||
fn stack_path(shapes: &[Shape], id: ShapeId) -> Option<Vec<usize>> {
|
||||
for (i, sh) in shapes.iter().enumerate() {
|
||||
if sh.id == id {
|
||||
return Some(vec![i]);
|
||||
}
|
||||
if let ShapeKind::Group { children, .. } = &sh.kind
|
||||
&& let Some(mut rest) = stack_path(children, id)
|
||||
{
|
||||
rest.insert(0, i);
|
||||
return Some(rest);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Shape `id` among `shapes` (searched into groups), its box, and the map from that box to slide
|
||||
/// space.
|
||||
fn placed<'a>(
|
||||
doc: &deckcraft_model::Presentation,
|
||||
sel: &crate::Selection,
|
||||
shapes: &'a [Shape],
|
||||
id: ShapeId,
|
||||
to_slide: Affine,
|
||||
depth: usize,
|
||||
) -> Option<(&'a Shape, Xfrm, Affine)> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
for sh in shapes {
|
||||
let x = xfrm_of(doc, sel, sh);
|
||||
if sh.id == id {
|
||||
return Some((sh, x, to_slide * x.affine()));
|
||||
}
|
||||
if let ShapeKind::Group { children, child } = &sh.kind {
|
||||
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
||||
if let Some(found) = placed(doc, sel, children, id, to_slide * deckcraft_geom::group_child_affine(&x, ch), depth + 1) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
//! The slides take the destination's look by default (each one moves to the destination layout
|
||||
//! with the same name, else the same kind); with `keepFormatting` the source master and its
|
||||
//! layouts come along. Pictures, media and sounds are copied under fresh ids, and shape ids are
|
||||
//! renumbered with animations, triggers and connector ends following them.
|
||||
//! renumbered with animations, triggers and connector ends following them. Links to another
|
||||
//! reused slide follow its copy.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -60,6 +62,57 @@ fn remap_connectors(shapes: &mut [deckcraft_model::Shape], map: &HashMap<ShapeId
|
||||
}
|
||||
}
|
||||
|
||||
/// Point a link to a reused slide at its copy (`map`: source → destination).
|
||||
fn relink(h: &mut Option<Hyperlink>, map: &HashMap<SlideId, SlideId>) {
|
||||
let Some(h) = h else { return };
|
||||
if let Action::Slide { slide } = &mut h.action {
|
||||
*slide = map.get(slide).copied().unwrap_or(*slide);
|
||||
}
|
||||
if let Some(z) = &mut h.zoom_return {
|
||||
*z = map.get(z).copied().unwrap_or(*z);
|
||||
}
|
||||
}
|
||||
|
||||
fn relink_run(r: &mut RunProps, map: &HashMap<SlideId, SlideId>) {
|
||||
relink(&mut r.link, map);
|
||||
relink(&mut r.hover, map);
|
||||
}
|
||||
|
||||
fn relink_text(t: &mut TextBody, map: &HashMap<SlideId, SlideId>) {
|
||||
for lv in t.list_style.levels.iter_mut().flatten() {
|
||||
relink_run(&mut lv.run, map);
|
||||
}
|
||||
for p in &mut t.paragraphs {
|
||||
relink_run(&mut p.def_props, map);
|
||||
relink_run(&mut p.end_props, map);
|
||||
for r in &mut p.runs {
|
||||
relink_run(&mut r.props, map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the slide links under `shapes` (click and hover actions, text links) at the copies.
|
||||
fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap<SlideId, SlideId>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
relink(&mut sh.click, map);
|
||||
relink(&mut sh.hover, map);
|
||||
if let Some(t) = &mut sh.text {
|
||||
relink_text(t, map);
|
||||
}
|
||||
if let ShapeKind::Table(t) = &mut sh.kind {
|
||||
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
relink_text(&mut c.text, map);
|
||||
}
|
||||
}
|
||||
if let Some(ch) = sh.children_mut() {
|
||||
relink_shapes(ch, map, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh shape ids for `shapes`, and the old → new map.
|
||||
fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
let old = shape_ids(shapes);
|
||||
@@ -134,8 +187,10 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
doc.masters.push(Arc::new(m));
|
||||
}
|
||||
}
|
||||
let mut new_ids = vec![];
|
||||
for (k, &i) in picked.iter().enumerate() {
|
||||
let mut copies: Vec<Slide> = vec![];
|
||||
// Source slide → its copy, so links between reused slides follow (forward ones too).
|
||||
let mut slide_map: HashMap<SlideId, SlideId> = HashMap::new();
|
||||
for &i in &picked {
|
||||
let Some(from) = src.slides.get(i) else { continue };
|
||||
copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
let mut sl: Slide = from.as_ref().clone();
|
||||
@@ -154,6 +209,13 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
matching_layout(doc, &lname, kind)
|
||||
}
|
||||
};
|
||||
slide_map.insert(from.id, sl.id);
|
||||
copies.push(sl);
|
||||
}
|
||||
let mut new_ids = vec![];
|
||||
for (k, mut sl) in copies.into_iter().enumerate() {
|
||||
relink_shapes(&mut sl.shapes, &slide_map, 0);
|
||||
relink_text(&mut sl.notes, &slide_map);
|
||||
new_ids.push(sl.id);
|
||||
doc.slides.insert((at + k).min(doc.slides.len()), Arc::new(sl));
|
||||
}
|
||||
@@ -263,4 +325,76 @@ mod tests {
|
||||
want.sort();
|
||||
assert_eq!(got, want, "every reference resolves to its own copied bytes");
|
||||
}
|
||||
|
||||
fn link_to(slide: SlideId) -> Hyperlink {
|
||||
Hyperlink {
|
||||
action: Action::Slide { slide },
|
||||
tooltip: String::new(),
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn linked_text(slide: SlideId) -> TextBody {
|
||||
let mut text = TextBody::from_text("go");
|
||||
if let Some(r) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
|
||||
r.props.link = Some(link_to(slide));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Links to a slide that is reused too follow the copy; links to a slide left behind stay.
|
||||
#[test]
|
||||
fn reused_slides_link_to_each_others_copies() {
|
||||
let mut src = defaults::new_presentation(None);
|
||||
let template = src.slides.first().map(|s| s.as_ref().clone()).expect("slide");
|
||||
for _ in 0..2 {
|
||||
let mut sl = template.clone();
|
||||
sl.id = SlideId(src.alloc_id().expect("id"));
|
||||
src.slides.push(Arc::new(sl));
|
||||
}
|
||||
let ids: Vec<SlideId> = src.slides.iter().map(|s| s.id).collect();
|
||||
let id = |i: usize| ids.get(i).copied().unwrap_or_default();
|
||||
if let Some(sl) = src.slides.first_mut().map(Arc::make_mut) {
|
||||
sl.shapes = vec![
|
||||
Shape { id: ShapeId(9001), click: Some(link_to(id(1))), ..Default::default() },
|
||||
Shape { id: ShapeId(9002), text: Some(linked_text(id(1))), ..Default::default() },
|
||||
Shape { id: ShapeId(9003), click: Some(link_to(id(2))), ..Default::default() },
|
||||
];
|
||||
}
|
||||
if let Some(sl) = src.slides.get_mut(1).map(Arc::make_mut) {
|
||||
sl.shapes = vec![Shape { id: ShapeId(9004), click: Some(link_to(id(0))), ..Default::default() }];
|
||||
}
|
||||
let bytes = deckcraft_format::save(&src).expect("save");
|
||||
let reopened = crate::cmd::file::open_presentation("src.deckcraft", &bytes).expect("open");
|
||||
let left_behind = reopened.slides.get(2).map(|s| s.id).expect("third slide");
|
||||
|
||||
let mut s = Session::with_new();
|
||||
for _ in 0..3 {
|
||||
s.execute("slide.new", &json!({})).expect("new");
|
||||
}
|
||||
let out = s
|
||||
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&bytes), "name": "src.deckcraft", "slides": [0, 1]}))
|
||||
.expect("reuse");
|
||||
let new: Vec<SlideId> = serde_json::from_value(out.get("ids").cloned().unwrap_or_default()).expect("ids");
|
||||
let doc = &s.doc().expect("doc").doc;
|
||||
let copy = |i: usize| new.get(i).and_then(|id| doc.slide(*id)).expect("copied slide");
|
||||
let click = |sh: Option<&Shape>| sh.and_then(|sh| sh.click.as_ref()).map(|h| h.action.clone());
|
||||
let first = copy(0);
|
||||
let to = |i: usize| Some(Action::Slide { slide: new.get(i).copied().unwrap_or_default() });
|
||||
assert_eq!(click(first.shapes.first()), to(1), "shape click follows the copy");
|
||||
let run_link = first
|
||||
.shapes
|
||||
.get(1)
|
||||
.and_then(|sh| sh.text.as_ref())
|
||||
.and_then(|t| t.paragraphs.first())
|
||||
.and_then(|p| p.runs.first())
|
||||
.and_then(|r| r.props.link.as_ref())
|
||||
.map(|h| h.action.clone());
|
||||
assert_eq!(run_link, to(1), "text link follows the copy");
|
||||
assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link");
|
||||
assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,14 @@ fn reply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let all = bool_or(p, "all", false);
|
||||
let i = usize_param(p, "index");
|
||||
let i = match p.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_u64()
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.ok_or_else(|| bad("comment.delete", format!("`index` is not a valid comment index: {v}")))?,
|
||||
),
|
||||
};
|
||||
s.edit(|doc, sel| {
|
||||
if all {
|
||||
for sl in &mut doc.slides {
|
||||
@@ -793,4 +800,18 @@ mod tests {
|
||||
s.execute("show.setup", &json!({"custom": ""})).unwrap();
|
||||
assert!(s.active().unwrap().doc.show.custom_show.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_rejects_an_invalid_index_and_keeps_the_comments() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("comment.add", &json!({"text": "a"})).unwrap();
|
||||
s.execute("comment.add", &json!({"text": "b"})).unwrap();
|
||||
for bad_index in [json!(-1), json!("2"), json!(1.5)] {
|
||||
let r = s.execute("comment.delete", &json!({"index": bad_index}));
|
||||
assert!(r.is_err(), "index {bad_index} gave {r:?}");
|
||||
assert_eq!(s.active().unwrap().doc.slides[0].comments.len(), 2, "index {bad_index}");
|
||||
}
|
||||
s.execute("comment.delete", &json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.slides[0].comments.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,8 +145,13 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
continue;
|
||||
}
|
||||
let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len());
|
||||
let text = decode_text(&html[i..end]);
|
||||
let mut text = decode_text(&html[i..end]);
|
||||
i = end;
|
||||
// A space at the start of a paragraph, or after a space, does not render in HTML.
|
||||
let after_space = paragraphs.last().and_then(|p| p.runs.last()).is_none_or(|r| r.text.ends_with(' '));
|
||||
if after_space {
|
||||
text = text.trim_start_matches(' ').to_string();
|
||||
}
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -155,6 +160,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 {
|
||||
paragraphs.pop();
|
||||
}
|
||||
trim_end_space(&mut paragraphs);
|
||||
if paragraphs.iter().all(Paragraph::is_empty) {
|
||||
return None;
|
||||
}
|
||||
@@ -189,8 +195,8 @@ fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
|
||||
let mut underline = i32::from(name == "u");
|
||||
if let Some(style) = attr(src, "style") {
|
||||
let s = style.to_ascii_lowercase();
|
||||
if s.contains("font-weight:bold") || s.contains("font-weight: bold") || weight_bold(&s) {
|
||||
bold = 1;
|
||||
if let Some(on) = weight_bold(&s) {
|
||||
bold = i32::from(on);
|
||||
}
|
||||
if s.contains("font-style:italic") || s.contains("font-style: italic") {
|
||||
italic = 1;
|
||||
@@ -206,10 +212,19 @@ fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
|
||||
(bold, italic, underline)
|
||||
}
|
||||
|
||||
fn weight_bold(style: &str) -> bool {
|
||||
let Some(at) = style.find("font-weight") else { return false };
|
||||
/// Whether a `font-weight` in `style` turns bold on (`Some(true)`) or off (`Some(false)`).
|
||||
fn weight_bold(style: &str) -> Option<bool> {
|
||||
let at = style.find("font-weight")?;
|
||||
let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace());
|
||||
rest.starts_with('6') || rest.starts_with('7') || rest.starts_with('8') || rest.starts_with('9')
|
||||
if rest.starts_with("bold") {
|
||||
return Some(true);
|
||||
}
|
||||
if rest.starts_with("normal") || rest.starts_with("lighter") {
|
||||
return Some(false);
|
||||
}
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
let weight: u32 = digits.parse().ok()?;
|
||||
Some(weight >= 600)
|
||||
}
|
||||
|
||||
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
|
||||
@@ -231,10 +246,19 @@ fn new_para(paragraphs: &mut Vec<Paragraph>) {
|
||||
return;
|
||||
}
|
||||
if paragraphs.last().is_some_and(|p| !p.is_empty()) {
|
||||
trim_end_space(paragraphs);
|
||||
paragraphs.push(Paragraph::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Whitespace at the end of a paragraph does not render in HTML.
|
||||
fn trim_end_space(paragraphs: &mut [Paragraph]) {
|
||||
if let Some(run) = paragraphs.last_mut().and_then(|p| p.runs.last_mut()) {
|
||||
let len = run.text.trim_end_matches(' ').len();
|
||||
run.text.truncate(len);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) {
|
||||
if *runs >= MAX_RUNS {
|
||||
return;
|
||||
@@ -254,35 +278,55 @@ fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold:
|
||||
*runs += 1;
|
||||
}
|
||||
|
||||
/// Text with each run of HTML whitespace (spaces, tabs, line breaks) collapsed to one space.
|
||||
fn collapse_space(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut space = false;
|
||||
for c in s.chars() {
|
||||
if matches!(c, ' ' | '\t' | '\n' | '\r') {
|
||||
if !space {
|
||||
out.push(' ');
|
||||
}
|
||||
space = true;
|
||||
} else {
|
||||
out.push(c);
|
||||
space = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_text(raw: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = raw;
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&rest[..at].replace(['\n', '\r'], ""));
|
||||
out.push_str(&collapse_space(&rest[..at]));
|
||||
rest = &rest[at..];
|
||||
let Some(end) = rest.find(';') else {
|
||||
out.push_str(&rest.replace(['\n', '\r'], ""));
|
||||
out.push_str(&collapse_space(rest));
|
||||
break;
|
||||
};
|
||||
let entity = &rest[1..end];
|
||||
rest = &rest[end + 1..];
|
||||
match entity {
|
||||
"amp" => out.push('&'),
|
||||
"lt" => out.push('<'),
|
||||
"gt" => out.push('>'),
|
||||
"quot" => out.push('"'),
|
||||
"apos" => out.push('\''),
|
||||
"nbsp" => out.push(' '),
|
||||
_ => {
|
||||
let ch = entity.strip_prefix('#').and_then(numeric_char);
|
||||
if let Some(c) = ch {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
let decoded = match entity {
|
||||
"amp" => Some('&'),
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"nbsp" => Some('\u{a0}'),
|
||||
_ => entity.strip_prefix('#').and_then(numeric_char),
|
||||
};
|
||||
if let Some(c) = decoded {
|
||||
out.push(c);
|
||||
rest = &rest[end + 1..];
|
||||
} else {
|
||||
// Not a well-formed entity (e.g. a bare "R&D"): keep the '&' as typed.
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() && !rest.starts_with('&') {
|
||||
out.push_str(&rest.replace(['\n', '\r'], ""));
|
||||
out.push_str(&collapse_space(rest));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -308,6 +352,20 @@ fn fold(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn google_docs_normal_weight_wrapper_is_not_bold() {
|
||||
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
|
||||
let body = html_to_body(html).unwrap();
|
||||
assert_eq!(body.text(), "plain words");
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, None);
|
||||
let light = html_to_body("<b style=\"font-weight:300\">thin</b>").unwrap();
|
||||
assert_eq!(light.paragraphs[0].runs[0].props.bold, None);
|
||||
let plain = html_to_body("<b>strong</b>").unwrap();
|
||||
assert_eq!(plain.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
let heavy = html_to_body("<span style=\"font-weight:700\">heavy</span>").unwrap();
|
||||
assert_eq!(heavy.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_italic_and_underline_round_trip() {
|
||||
let mut body = TextBody::from_text("Hi there");
|
||||
@@ -327,6 +385,14 @@ mod tests {
|
||||
assert!(last.props.underline.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_ampersand_keeps_text_up_to_next_semicolon() {
|
||||
let text = |html: &str| html_to_body(html).unwrap().text();
|
||||
assert_eq!(text("<p>R&D costs; up</p>"), "R&D costs; up");
|
||||
assert_eq!(text("<p>AT&T; Q&A</p>"), "AT&T; Q&A");
|
||||
assert_eq!(text("<p>A & B & C & D</p>"), "A & B & C & D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_style_and_escapes() {
|
||||
let html = "<html><head><style>p{}</style></head><body><p><span style=\"font-weight: bold\">A & B</span></p><p><span style=\"font-style: italic\">C</span></p></body></html>";
|
||||
@@ -338,6 +404,18 @@ mod tests {
|
||||
assert!(!same_words(&body.text(), "other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newline_in_html_text_is_a_space() {
|
||||
assert_eq!(decode_text("Hello\nworld"), "Hello world");
|
||||
let body = html_to_body("<p>Hello\r\n world</p>").unwrap();
|
||||
assert_eq!(body.text(), "Hello world");
|
||||
let body = html_to_body("<p>\n<b>Hello</b>\n<i>world</i>\n</p>\n<p>next</p>").unwrap();
|
||||
assert_eq!(body.text(), "Hello world\nnext");
|
||||
assert!(same_words(&body.text(), "Hello world\nnext"));
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(body.paragraphs[0].runs.last().unwrap().props.italic, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_fragment_is_refused() {
|
||||
let html = "x".repeat(MAX_HTML + 1);
|
||||
|
||||
+159
-38
@@ -1,6 +1,8 @@
|
||||
//! Shape Format: position and size, rotation, flips, geometry, fill, outline, effects, styles,
|
||||
//! names and alt text.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow};
|
||||
use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle};
|
||||
@@ -85,7 +87,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Fill",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill",
|
||||
"{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||||
has_selection,
|
||||
fill
|
||||
),
|
||||
@@ -94,7 +96,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Outline",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}",
|
||||
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}; a group also formats its members",
|
||||
has_selection,
|
||||
line
|
||||
),
|
||||
@@ -169,13 +171,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Keeps a side length in `0..=MAX_PT` so a huge value cannot exhaust memory when rendering.
|
||||
/// An overflow to infinity lands on the limit; NaN becomes 0.
|
||||
fn clamp_side(v: f64) -> f64 {
|
||||
if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_PT) }
|
||||
}
|
||||
|
||||
/// Keeps a coordinate within `±POS_LIMIT`.
|
||||
fn clamp_pos(v: f64) -> f64 {
|
||||
if v.is_nan() { 0.0 } else { v.clamp(-POS_LIMIT, POS_LIMIT) }
|
||||
}
|
||||
|
||||
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
|
||||
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
|
||||
edit_geometry(s, p, "shape.move", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
|
||||
x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
|
||||
x.x = clamp_pos(ax.unwrap_or(x.x) + dx.unwrap_or(0.0));
|
||||
x.y = clamp_pos(ay.unwrap_or(x.y) + dy.unwrap_or(0.0));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -202,10 +215,12 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
x.h = h.unwrap_or(x.h);
|
||||
}
|
||||
}
|
||||
x.w = clamp_side(x.w);
|
||||
x.h = clamp_side(x.h);
|
||||
// Rotated shapes resize about their centre; others keep the top-left.
|
||||
if x.rot != 0.0 {
|
||||
x.x = cx - x.w / 2.0;
|
||||
x.y = cy - x.h / 2.0;
|
||||
x.x = clamp_pos(cx - x.w / 2.0);
|
||||
x.y = clamp_pos(cy - x.h / 2.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -215,10 +230,10 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_geometry(s, p, "shape.setBounds", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = f64_param(p, "x").unwrap_or(x.x);
|
||||
x.y = f64_param(p, "y").unwrap_or(x.y);
|
||||
x.w = f64_param(p, "w").unwrap_or(x.w).max(0.0);
|
||||
x.h = f64_param(p, "h").unwrap_or(x.h).max(0.0);
|
||||
x.x = clamp_pos(f64_param(p, "x").unwrap_or(x.x));
|
||||
x.y = clamp_pos(f64_param(p, "y").unwrap_or(x.y));
|
||||
x.w = clamp_side(f64_param(p, "w").unwrap_or(x.w));
|
||||
x.h = clamp_side(f64_param(p, "h").unwrap_or(x.h));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -406,6 +421,8 @@ fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
x.flip_h = !x.flip_h;
|
||||
}
|
||||
// `Xfrm::affine` flips first and rotates second; a true mirror also negates the rotation.
|
||||
x.rot = (360.0 - x.rot_norm()) % 360.0;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -518,6 +535,24 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
|
||||
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
|
||||
}
|
||||
|
||||
/// Fill and outline apply to a group's visible members as well as its container. Other edits
|
||||
/// (notably geometry) still target only the selected shape. The whole traversal stays in one edit.
|
||||
fn edit_group_appearance(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
fn apply(sh: &mut deckcraft_model::Shape, depth: usize, f: &dyn Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Ok(());
|
||||
}
|
||||
f(sh)?;
|
||||
if let Some(children) = sh.children_mut() {
|
||||
for child in children {
|
||||
apply(child, depth + 1, f)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
edit_shapes(s, p, cmd, |sh| apply(sh, 0, &f))
|
||||
}
|
||||
|
||||
fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_fill: Option<Option<Fill>> = if bool_or(p, "reset", false) {
|
||||
Some(None)
|
||||
@@ -550,27 +585,26 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Transparency on an inherited (style) fill needs the effective colour.
|
||||
let st = s.doc()?;
|
||||
let ctx_fill: Vec<(deckcraft_model::ShapeId, Option<Fill>)> = {
|
||||
let ctx_fill = {
|
||||
let ctx = super::text::ctx_for(&st.doc, &st.selection);
|
||||
targets(s, p)?
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
st.shape(id).map(|sh| {
|
||||
(
|
||||
id,
|
||||
ctx.as_ref().and_then(|c| {
|
||||
let (f, ph) = deckcraft_model::resolve::fill(c, sh);
|
||||
f.map(|f| match f {
|
||||
Fill::Solid { color } => Fill::solid(ColorRef::rgb(c.color(&color, ph))),
|
||||
other => other,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
let mut fills = HashMap::new();
|
||||
for id in targets(s, p)? {
|
||||
if let Some(sh) = st.shape(id) {
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| {
|
||||
let fill = ctx.as_ref().and_then(|c| {
|
||||
let (f, ph) = deckcraft_model::resolve::fill(c, sh);
|
||||
f.map(|f| match f {
|
||||
Fill::Solid { color } => Fill::solid(ColorRef::rgb(c.color(&color, ph))),
|
||||
other => other,
|
||||
})
|
||||
});
|
||||
fills.insert(sh.id, fill);
|
||||
});
|
||||
}
|
||||
}
|
||||
fills
|
||||
};
|
||||
edit_shapes(s, p, "shape.fill", |sh| {
|
||||
edit_group_appearance(s, p, "shape.fill", |sh| {
|
||||
if let Some(f) = &new_fill {
|
||||
sh.fill.clone_from(f);
|
||||
}
|
||||
@@ -583,7 +617,7 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(t) = transparency {
|
||||
let base = sh.fill.clone().or_else(|| ctx_fill.iter().find(|(i, _)| *i == sh.id).and_then(|(_, f)| f.clone()));
|
||||
let base = sh.fill.clone().or_else(|| ctx_fill.get(&sh.id).cloned().flatten());
|
||||
if let Some(Fill::Solid { mut color }) = base {
|
||||
color.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
|
||||
if t > 0.0 {
|
||||
@@ -675,7 +709,7 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"miter" => deckcraft_model::style::LineJoin::Miter,
|
||||
_ => deckcraft_model::style::LineJoin::Round,
|
||||
});
|
||||
edit_shapes(s, p, "shape.line", |sh| {
|
||||
edit_group_appearance(s, p, "shape.line", |sh| {
|
||||
if reset {
|
||||
sh.line = None;
|
||||
return Ok(());
|
||||
@@ -1150,19 +1184,17 @@ fn media_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn shape_id(p: &Value, key: &str) -> Option<deckcraft_model::ShapeId> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| deckcraft_model::ShapeId(v as u32))
|
||||
}
|
||||
|
||||
fn sites(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = shape_id(p, "id").ok_or_else(|| bad("shape.sites", "missing `id`"))?;
|
||||
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<_>>()))
|
||||
}
|
||||
|
||||
fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (Some(from), Some(to)) = (shape_id(p, "from"), shape_id(p, "to")) else { return Err(bad("shape.connect", "missing `from` / `to`")) };
|
||||
let (Some(from), Some(to)) = (id_param(p, "from", "shape.connect")?, id_param(p, "to", "shape.connect")?) else {
|
||||
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));
|
||||
@@ -1186,7 +1218,7 @@ fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let (p0, p1) = (a[ia], b[ib]);
|
||||
let id = match shape_id(p, "id") {
|
||||
let id = match id_param(p, "id", "shape.connect")? {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
|
||||
@@ -1490,6 +1522,95 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn nested_group() -> (Session, Vec<ShapeId>, ShapeId, ShapeId) {
|
||||
let mut s = Session::with_new();
|
||||
let mut ids = vec![];
|
||||
for i in 0..4 {
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40 + i * 100, 100, 80, 60]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("shape.line", &json!({"width": i + 1, "dash": "dash"})).unwrap();
|
||||
ids.push(ShapeId(id as u32));
|
||||
}
|
||||
let group = |s: &mut Session, ids: &[ShapeId]| {
|
||||
s.execute("edit.select", &json!({"ids": ids})).unwrap();
|
||||
ShapeId(s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap() as u32)
|
||||
};
|
||||
let inner = group(&mut s, &ids[..2]);
|
||||
let outer = group(&mut s, &[inner, ids[2]]);
|
||||
(s, ids[..3].to_vec(), outer, ids[3])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_fill_reaches_nested_members_in_one_undo_step() {
|
||||
let (mut s, members, group, outside) = nested_group();
|
||||
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||||
let untouched = serde_json::to_value(s.doc().unwrap().shape(outside)).unwrap();
|
||||
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
|
||||
for id in &members {
|
||||
assert_eq!(s.doc().unwrap().shape(*id).unwrap().fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0)))));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, vec![group]);
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap().shape(outside)).unwrap(), untouched);
|
||||
let filled = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), filled);
|
||||
|
||||
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||||
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().fill == Some(Fill::None)));
|
||||
s.execute("shape.fill", &json!({"reset": true})).unwrap();
|
||||
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().fill.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_outline_reaches_members_without_resetting_their_other_properties() {
|
||||
let (mut s, members, group, outside) = nested_group();
|
||||
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||||
// Explicit ids work even when another object is selected.
|
||||
s.execute("edit.select", &json!({"ids": [outside]})).unwrap();
|
||||
s.execute("shape.line", &json!({"ids": [group], "color": "#00ff00"})).unwrap();
|
||||
for (i, id) in members.iter().enumerate() {
|
||||
let line = s.doc().unwrap().shape(*id).unwrap().line.as_ref().unwrap();
|
||||
assert_eq!(line.fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 255, 0)))));
|
||||
assert_eq!(line.width, Some((i + 1) as f64));
|
||||
assert_eq!(line.dash, Some(Dash::Preset { name: "dash".into() }));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, vec![outside]);
|
||||
let outlined = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), outlined);
|
||||
s.execute("shape.line", &json!({"ids": [group], "none": true})).unwrap();
|
||||
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.as_ref().unwrap().fill == Some(Fill::None)));
|
||||
s.execute("shape.line", &json!({"ids": [group], "reset": true})).unwrap();
|
||||
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_fill_transparency_keeps_each_members_inherited_colour() {
|
||||
let (mut s, members, group, _) = nested_group();
|
||||
s.execute("shape.fill", &json!({"ids": [members[1]], "color": "#00ff00"})).unwrap();
|
||||
let colours: Vec<_> = {
|
||||
let st = s.doc().unwrap();
|
||||
let ctx = super::super::text::ctx_for(&st.doc, &st.selection).unwrap();
|
||||
members
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let (fill, ph) = deckcraft_model::resolve::fill(&ctx, st.shape(*id).unwrap());
|
||||
let Some(Fill::Solid { color }) = fill else { panic!("solid theme fill") };
|
||||
ctx.color(&color, ph)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
s.execute("shape.fill", &json!({"ids": [group], "transparency": 0.5})).unwrap();
|
||||
for (id, colour) in members.iter().zip(colours) {
|
||||
let mut expected = ColorRef::rgb(colour);
|
||||
expected.mods.push(ColorTransform::Alpha(50000));
|
||||
assert_eq!(s.doc().unwrap().shape(*id).unwrap().fill, Some(Fill::solid(expected)));
|
||||
}
|
||||
}
|
||||
|
||||
fn rotation(s: &Session) -> f64 {
|
||||
let st = s.active().unwrap();
|
||||
let id = st.selection.shapes[0];
|
||||
|
||||
@@ -179,7 +179,7 @@ fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcr
|
||||
|
||||
fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "index").unwrap_or(st.selection.slide);
|
||||
let i = opt_usize_param(p, "index", "slide")?.unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("slide", format!("no slide {i}")));
|
||||
}
|
||||
@@ -187,7 +187,7 @@ fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let indices: Vec<usize> = match usize_param(p, "index") {
|
||||
let indices: Vec<usize> = match opt_usize_param(p, "index", "slide.duplicate")? {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
@@ -218,6 +218,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& let Some(sec) = doc.sections.get_mut(si)
|
||||
{
|
||||
sec.slides.push(nid);
|
||||
// Keep the section list in document order so the copy sits next to its source.
|
||||
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
|
||||
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
sel.slide = (last + new_ids.len()).min(doc.slides.len().saturating_sub(1));
|
||||
@@ -237,7 +240,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(SlideId).collect()
|
||||
} else if let Some(i) = usize_param(p, "index") {
|
||||
} else if let Some(i) = opt_usize_param(p, "index", "slide.delete")? {
|
||||
st.doc.slides.get(i).map(|x| vec![x.id]).unwrap_or_default()
|
||||
} else if !st.selection.slides.is_empty() {
|
||||
st.selection.slides.clone()
|
||||
@@ -263,7 +266,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = usize_param(p, "from").unwrap_or(s.doc()?.selection.slide);
|
||||
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
|
||||
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
|
||||
s.edit(|doc, sel| {
|
||||
if from >= doc.slides.len() {
|
||||
@@ -528,7 +531,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = usize_param(p, "at").unwrap_or(s.doc()?.selection.slide);
|
||||
let at = opt_usize_param(p, "at", "section.add")?.unwrap_or(s.doc()?.selection.slide);
|
||||
let name = str_param(p, "name").unwrap_or("Untitled Section").to_string();
|
||||
s.edit(|doc, _| {
|
||||
let at = at.min(doc.slides.len().saturating_sub(1));
|
||||
@@ -536,9 +539,9 @@ fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if doc.sections.is_empty() {
|
||||
// The slides before `at` go into a "Default Section".
|
||||
if at > 0 {
|
||||
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(|x| x.to_vec()).unwrap_or_default() });
|
||||
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(<[_]>::to_vec).unwrap_or_default() });
|
||||
}
|
||||
doc.sections.push(Section { name, slides: ids.get(at..).map(|x| x.to_vec()).unwrap_or_default() });
|
||||
doc.sections.push(Section { name, slides: ids.get(at..).map(<[_]>::to_vec).unwrap_or_default() });
|
||||
} else {
|
||||
let Some(start) = ids.get(at).copied() else { return Err(bad("section.add", "no slide")) };
|
||||
let si = doc.section_of(start).unwrap_or(0);
|
||||
@@ -624,6 +627,17 @@ fn from_outline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (k, sl) in new.into_iter().enumerate() {
|
||||
doc.slides.insert(at + k, sl);
|
||||
}
|
||||
// Join the section of the slide before, as slide.new does.
|
||||
let ids: Vec<SlideId> = doc.slides.iter().skip(at).take(n).map(|x| x.id).collect();
|
||||
let prev = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
|
||||
let section = prev.and_then(|p| doc.section_of(p));
|
||||
if let Some(sec) = section.and_then(|si| doc.sections.get_mut(si)) {
|
||||
let pos = prev.and_then(|p| sec.slides.iter().position(|x| *x == p)).map(|k| k + 1).unwrap_or(sec.slides.len());
|
||||
let pos = pos.min(sec.slides.len());
|
||||
sec.slides.splice(pos..pos, ids);
|
||||
} else if let Some(first) = doc.sections.first_mut() {
|
||||
first.slides.splice(0..0, ids);
|
||||
}
|
||||
Ok(json!({"slides": n}))
|
||||
})
|
||||
}
|
||||
@@ -634,6 +648,42 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < 3 {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
for (cmd, p) in [
|
||||
("slide.delete", json!({"index": bad})),
|
||||
("slide.duplicate", json!({"index": bad})),
|
||||
("slide.move", json!({"from": bad, "to": 0})),
|
||||
("section.add", json!({"at": bad})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
|
||||
assert_eq!(s.doc().expect("doc").doc.slides.len(), 3, "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keeps_section_order_in_step_with_slides() {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < 3 {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
s.execute("section.add", &json!({"at": 0})).expect("section");
|
||||
let orig = s.doc().expect("doc").doc.slides.first().expect("slide").id;
|
||||
s.execute("slide.duplicate", &json!({"index": 0})).expect("dup");
|
||||
let st = s.doc().expect("doc");
|
||||
let all: Vec<_> = st.doc.slides.iter().map(|x| x.id).collect();
|
||||
let sec = &st.doc.sections.first().expect("section").slides;
|
||||
assert_eq!(all.len(), 4);
|
||||
assert_eq!(sec, &all, "section order must match slide order");
|
||||
assert_eq!(sec.first(), Some(&orig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_slide_body_leading_tabs_become_levels() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -288,9 +288,9 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
with_table(s, p, "table", move |t| {
|
||||
match op.as_str() {
|
||||
"rowAbove" => t.insert_row(r),
|
||||
"rowBelow" => t.insert_row(r + 1),
|
||||
"rowBelow" => t.insert_row(r.saturating_add(1)),
|
||||
"colLeft" => t.insert_col(c),
|
||||
"colRight" => t.insert_col(c + 1),
|
||||
"colRight" => t.insert_col(c.saturating_add(1)),
|
||||
"delRow" => {
|
||||
if !t.delete_row(r) {
|
||||
return Err(bad("table.deleteRow", "can't delete the last row"));
|
||||
@@ -537,7 +537,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
with_table(s, p, "table.columnWidth", move |t| {
|
||||
// Keeping the table's width, the column to the right gives or takes the difference (dragging
|
||||
// an inner border). The last column has none: the table gets wider or narrower.
|
||||
let pair = t.cols.get(c).zip(t.cols.get(c + 1)).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
|
||||
let pair = t.cols.get(c).zip(t.cols.get(c.saturating_add(1))).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
|
||||
match pair {
|
||||
Some(pair) if keep_total => {
|
||||
let min = MIN_COLUMN_WIDTH.min(pair / 2.0);
|
||||
@@ -545,7 +545,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(x) = t.cols.get_mut(c) {
|
||||
*x = w;
|
||||
}
|
||||
if let Some(x) = t.cols.get_mut(c + 1) {
|
||||
if let Some(x) = t.cols.get_mut(c.saturating_add(1)) {
|
||||
*x = pair - w;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AutoCorrect Options",
|
||||
["Tools"],
|
||||
None,
|
||||
"{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
|
||||
"{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
|
||||
always,
|
||||
autocorrect_options
|
||||
),
|
||||
@@ -655,7 +655,12 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
} else {
|
||||
correct_word(&s.prefs, &word, sentence_start, typing)
|
||||
};
|
||||
let Some(rep) = rep else { return Ok(()) };
|
||||
let Some(rep) = rep else {
|
||||
if typing && s.prefs.ac_ordinals && is_ordinal(&word) {
|
||||
return superscript_suffix(s, &t, word_end);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let delta = rep.chars().count() as isize - word.chars().count() as isize;
|
||||
let st = s.doc_mut()?;
|
||||
let mut d = (*st.doc).clone();
|
||||
@@ -674,6 +679,43 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat
|
||||
/// superscripts it.
|
||||
fn is_ordinal(word: &str) -> bool {
|
||||
let digits = word.trim_end_matches(|c: char| c.is_ascii_lowercase());
|
||||
let suffix = word.get(digits.len()..).unwrap_or_default();
|
||||
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) || suffix.len() != 2 {
|
||||
return false;
|
||||
}
|
||||
let d = digits.as_bytes();
|
||||
let teen = d.len() >= 2 && d.get(d.len() - 2) == Some(&b'1');
|
||||
let want = match d.last() {
|
||||
_ if teen => "th",
|
||||
Some(b'1') => "st",
|
||||
Some(b'2') => "nd",
|
||||
Some(b'3') => "rd",
|
||||
_ => "th",
|
||||
};
|
||||
suffix == want
|
||||
}
|
||||
|
||||
/// Superscript the two letters before `end` in the caret's paragraph (an ordinal's suffix).
|
||||
fn superscript_suffix(s: &mut Session, t: &crate::TextSel, end: usize) -> Result<()> {
|
||||
let (a, b) = ((t.caret.0, end.saturating_sub(2)), (t.caret.0, end));
|
||||
let st = s.doc_mut()?;
|
||||
if body_of(st, t).is_none_or(|body| ed::props_at(body, b).baseline.unwrap_or(0.0) > 0.0) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
let sel = st.selection.clone();
|
||||
if let Some(body) = body_mut(&mut d, &sel, t) {
|
||||
ed::format(body, a, b, &|r| r.baseline = Some(0.3));
|
||||
}
|
||||
st.doc = Arc::new(d);
|
||||
st.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pr = &mut s.prefs;
|
||||
for (key, slot) in [
|
||||
@@ -683,6 +725,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
("days", &mut pr.ac_days),
|
||||
("capsLock", &mut pr.ac_caps_lock),
|
||||
("fractions", &mut pr.ac_fractions),
|
||||
("ordinals", &mut pr.ac_ordinals),
|
||||
] {
|
||||
if let Some(v) = p.get(key).and_then(Value::as_bool) {
|
||||
*slot = v;
|
||||
@@ -729,7 +772,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({
|
||||
"on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps,
|
||||
"days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions,
|
||||
"entries": list,
|
||||
"ordinals": pr.ac_ordinals, "entries": list,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -891,7 +934,7 @@ pub fn cell_range_underlines(
|
||||
return vec![];
|
||||
};
|
||||
let body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell);
|
||||
let tmp = deckcraft_model::Shape { text: Some(body.clone()), ..Default::default() };
|
||||
let tmp = deckcraft_model::Shape::table_cell(body.clone());
|
||||
let l = deckcraft_text::layout(
|
||||
&ctx,
|
||||
&tmp,
|
||||
@@ -960,7 +1003,7 @@ fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option<C
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
|
||||
}
|
||||
let tmp = deckcraft_model::Shape { text: Some(body.clone()), ..Default::default() };
|
||||
let tmp = deckcraft_model::Shape::table_cell(body.clone());
|
||||
let layout = deckcraft_text::layout(
|
||||
&ctx,
|
||||
&tmp,
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
|
||||
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| s.to_string())
|
||||
.map(ToString::to_string)
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "unknown panic".into())
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ pub fn stroke_width(icon: &Icon, w: f64, h: f64) -> f64 {
|
||||
}
|
||||
|
||||
fn parsed() -> &'static HashMap<&'static str, BezPath> {
|
||||
static CACHE: OnceLock<HashMap<&'static str, BezPath>> = OnceLock::new();
|
||||
static CACHE: OnceLock<HashMap<&str, BezPath>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| {
|
||||
let mut m = HashMap::new();
|
||||
for icon in ICONS {
|
||||
|
||||
@@ -222,6 +222,9 @@ pub struct DocState {
|
||||
pub composition: Option<Composition>,
|
||||
/// Review › Compare in progress: the revised copy.
|
||||
pub compare: Option<compare::Compare>,
|
||||
/// The last Duplicate: where it happened and each original with its copy, so duplicating the
|
||||
/// copies again repeats how far they were moved, as in PowerPoint (#966).
|
||||
pub last_duplicate: Option<(usize, Target, Vec<(ShapeId, ShapeId)>)>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
@@ -249,6 +252,7 @@ impl DocState {
|
||||
missing_fonts: Vec::new(),
|
||||
composition: None,
|
||||
compare: None,
|
||||
last_duplicate: None,
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -324,12 +328,13 @@ pub struct Prefs {
|
||||
pub smart_quotes: bool,
|
||||
/// AutoCorrect options (Tools ▸ AutoCorrect): fix TWo INitial CApitals, capitalize the first
|
||||
/// letter of sentences and the names of days, fix accidental cAPS lOCK, and replace 1/2, 1/4
|
||||
/// and 3/4 with fraction characters.
|
||||
/// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) as you type.
|
||||
pub ac_two_initials: bool,
|
||||
pub ac_sentence_caps: bool,
|
||||
pub ac_days: bool,
|
||||
pub ac_caps_lock: bool,
|
||||
pub ac_fractions: bool,
|
||||
pub ac_ordinals: bool,
|
||||
/// Your own "replace … with …" entries; they win over the built-in list.
|
||||
pub ac_entries: Vec<(String, String)>,
|
||||
/// Built-in entries you deleted.
|
||||
@@ -354,6 +359,7 @@ impl Default for Prefs {
|
||||
ac_days: true,
|
||||
ac_caps_lock: true,
|
||||
ac_fractions: true,
|
||||
ac_ordinals: true,
|
||||
ac_entries: Vec::new(),
|
||||
ac_removed: Vec::new(),
|
||||
author: "Presenter".into(),
|
||||
|
||||
@@ -44,6 +44,11 @@ pub fn instance_id() -> u64 {
|
||||
*ID.get_or_init(|| {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
||||
// No PIDs and no std::time on wasm (`std::process::id()` and
|
||||
// `SystemTime::now()` panic there): the browser clock stands in.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
h.write_u64(js_sys::Date::now() as u64);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
h.write_u32(std::process::id());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||
@@ -147,13 +152,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return out };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") {
|
||||
if p.extension().and_then(std::ffi::OsStr::to_str) != Some("deckcraft") {
|
||||
continue;
|
||||
}
|
||||
if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
|
||||
let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES)
|
||||
.and_then(|t| serde_json::from_slice::<Value>(&t).ok())
|
||||
@@ -311,7 +316,10 @@ mod tests {
|
||||
let opened = open(&mut s2, &dir).unwrap();
|
||||
assert!(s2.documents().iter().all(|d| d.uid != uid), "{opened:?}");
|
||||
assert!(lock_path(&dir, other).exists(), "a live owner's lock stays");
|
||||
// Once the owner is gone, its entry is recovered and its lock file cleaned up.
|
||||
// Once the owner is gone, its entry is recovered and its lock file cleaned up. Unlock
|
||||
// rather than only drop: a child spawned meanwhile by a parallel test (mkfifo) shares the
|
||||
// open file and would keep the lock held until it execs.
|
||||
lock.unlock().unwrap();
|
||||
drop(lock);
|
||||
let mut s3 = Session::new();
|
||||
open(&mut s3, &dir).unwrap();
|
||||
|
||||
+847
-1
@@ -94,6 +94,43 @@ fn text_editing_flow() {
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.len(), 1);
|
||||
}
|
||||
|
||||
/// `text.set` treats CRLF (or a lone CR) as one paragraph break and keeps no `\r` in the text.
|
||||
#[test]
|
||||
fn text_set_crlf_makes_clean_paragraphs() {
|
||||
let mut s = session();
|
||||
let r = s.execute("insert.textBox", &json!({"text": "x"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
s.execute("text.set", &json!({"id": id, "text": "one\r\ntwo"})).unwrap();
|
||||
let t = s.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(t["paragraphs"][0]["text"], "one");
|
||||
assert_eq!(t["paragraphs"][1]["text"], "two");
|
||||
assert_eq!(t["text"], "one\ntwo");
|
||||
// A lone CR (old Mac line ends) is a paragraph break too, as `edit::insert` treats it.
|
||||
s.execute("text.set", &json!({"id": id, "text": "a\rb"})).unwrap();
|
||||
let t = s.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(t["text"], "a\nb");
|
||||
}
|
||||
|
||||
/// Cut with only a caret copies nothing, so it must not delete the character after the caret.
|
||||
#[test]
|
||||
fn cut_with_only_a_caret_changes_nothing() {
|
||||
let mut s = session();
|
||||
let r = s.execute("insert.textBox", &json!({"text": "Hello"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
s.execute("text.edit", &json!({"id": id})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "start"})).unwrap();
|
||||
let out = s.execute("edit.cut", &json!({})).unwrap();
|
||||
assert_eq!(out["text"], "");
|
||||
let t = s.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(t["text"], "Hello", "cut with only a caret changed the text");
|
||||
// A real selection is still cut.
|
||||
s.execute("text.move", &json!({"to": "end", "extend": true})).unwrap();
|
||||
let out = s.execute("edit.cut", &json!({})).unwrap();
|
||||
assert_eq!(out["text"], "Hello");
|
||||
let t = s.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(t["text"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_text_refits_a_saved_shrink() {
|
||||
use deckcraft_model::text::AutoFit;
|
||||
@@ -229,6 +266,49 @@ fn bold_copy_is_html_and_pages_html_pastes_bold() {
|
||||
assert_ne!(plain.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_paste_with_a_source_newline_keeps_its_bold() {
|
||||
let mut s = session();
|
||||
s.execute("edit.paste", &json!({"text": "Hello world", "html": "<p><b>Hello</b>\nworld</p>"})).unwrap();
|
||||
let body = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Hello world"))
|
||||
.unwrap()
|
||||
.text
|
||||
.clone()
|
||||
.unwrap();
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{body:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn html_paste_keeps_a_blank_nbsp_paragraph_and_the_rich_path() {
|
||||
for blank in ["<p> </p>", "<p><o:p> </o:p></p>"] {
|
||||
let mut s = session();
|
||||
let html = format!("<p><b>A</b></p>{blank}<p>B</p>");
|
||||
s.execute("edit.paste", &json!({"text": "A\n\u{a0}\nB", "html": html})).unwrap();
|
||||
let body = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|sh| sh.text.as_ref().is_some_and(|t| t.paragraphs.len() == 3))
|
||||
.unwrap()
|
||||
.text
|
||||
.clone()
|
||||
.unwrap();
|
||||
assert_eq!(body.paragraphs[0].runs[0].text, "A", "{html}: {body:?}");
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true), "{html}: {body:?}");
|
||||
assert_eq!(body.paragraphs[2].runs[0].text, "B", "{html}: {body:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_ungroup_roundtrip_positions() {
|
||||
let mut s = session();
|
||||
@@ -2332,6 +2412,136 @@ fn picture_recolor_and_transparent_color() {
|
||||
assert_eq!(adj(&s), deckcraft_model::style::PictureAdjust::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_color_at_a_point_takes_the_pictures_own_pixel_there() {
|
||||
// Red on the left half, blue on the right.
|
||||
let img = image::RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([255, 0, 0, 255]) } else { image::Rgba([0, 0, 255, 255]) });
|
||||
let mut png = vec![];
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = session();
|
||||
let id =
|
||||
s.execute("insert.picture", &json!({"data": cmd::base64_encode(&png), "name": "a.png", "rect": [100, 100, 400, 200]})).unwrap()["id"].clone();
|
||||
let clear = |s: &Session| match &s.doc().unwrap().shapes().iter().find(|sh| json!(sh.id) == id).unwrap().kind {
|
||||
deckcraft_model::ShapeKind::Picture { fill } => fill.adjust.clear_color,
|
||||
_ => panic!("not a picture"),
|
||||
};
|
||||
let red = deckcraft_color::Rgba::rgb(255, 0, 0);
|
||||
let blue = deckcraft_color::Rgba::rgb(0, 0, 255);
|
||||
s.execute("picture.transparentColor", &json!({"ids": [id], "at": [450, 200]})).unwrap();
|
||||
assert_eq!(clear(&s), Some(blue));
|
||||
// The picture's own colour, not the one drawn after a correction brightened it.
|
||||
s.execute("picture.adjust", &json!({"ids": [id], "brightness": 0.4})).unwrap();
|
||||
s.execute("picture.transparentColor", &json!({"ids": [id], "at": [150, 200]})).unwrap();
|
||||
assert_eq!(clear(&s), Some(red));
|
||||
// Flipped, the blue half is drawn on the left.
|
||||
s.execute("shape.flip", &json!({"ids": [id], "axis": "horizontal"})).unwrap();
|
||||
s.execute("picture.transparentColor", &json!({"ids": [id], "at": [150, 200]})).unwrap();
|
||||
assert_eq!(clear(&s), Some(blue));
|
||||
// Beside the picture there is no colour to take, and nothing changes.
|
||||
assert!(s.execute("picture.transparentColor", &json!({"ids": [id], "at": [50, 50]})).is_err());
|
||||
assert_eq!(clear(&s), Some(blue));
|
||||
// A spot that is already transparent hides a colour (black here, as in most PNGs): taking it
|
||||
// would clear the black drawn elsewhere in the picture.
|
||||
let img = image::RgbaImage::from_fn(4, 2, |x, _| if x < 2 { image::Rgba([0, 0, 0, 0]) } else { image::Rgba([0, 0, 0, 255]) });
|
||||
let mut png = vec![];
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let logo =
|
||||
s.execute("insert.picture", &json!({"data": cmd::base64_encode(&png), "name": "b.png", "rect": [100, 400, 400, 200]})).unwrap()["id"].clone();
|
||||
assert!(s.execute("picture.transparentColor", &json!({"ids": [logo], "at": [150, 500]})).is_err());
|
||||
s.execute("picture.transparentColor", &json!({"ids": [logo], "at": [450, 500]})).unwrap();
|
||||
}
|
||||
|
||||
/// A picture of `n` one-pixel columns whose red channel is 20 * column, inserted at `rect`.
|
||||
fn ramp_picture(s: &mut Session, n: u32, rect: [u32; 4]) -> serde_json::Value {
|
||||
let img = image::RgbaImage::from_fn(n, 1, |x, _| image::Rgba([(20 * x) as u8, 0, 0, 255]));
|
||||
let mut png = vec![];
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
s.execute("insert.picture", &json!({"data": cmd::base64_encode(&png), "name": "r.png", "rect": rect})).unwrap()["id"].clone()
|
||||
}
|
||||
|
||||
fn clear_color_of(s: &Session, id: &serde_json::Value) -> Option<deckcraft_color::Rgba> {
|
||||
fn find(shapes: &[deckcraft_model::Shape], id: &serde_json::Value) -> Option<deckcraft_model::Shape> {
|
||||
shapes.iter().find_map(|sh| {
|
||||
if json!(sh.id) == *id {
|
||||
return Some(sh.clone());
|
||||
}
|
||||
match &sh.kind {
|
||||
deckcraft_model::ShapeKind::Group { children, .. } => find(children, id),
|
||||
_ => None,
|
||||
}
|
||||
})
|
||||
}
|
||||
match find(s.doc().unwrap().shapes(), id).unwrap().kind {
|
||||
deckcraft_model::ShapeKind::Picture { fill } => fill.adjust.clear_color,
|
||||
_ => panic!("not a picture"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_color_at_a_point_follows_the_pictures_3d_camera() {
|
||||
let mut s = session();
|
||||
let id = ramp_picture(&mut s, 10, [100, 100, 200, 100]);
|
||||
// Turned 60 degrees about the vertical axis the picture is drawn about half as wide, centred
|
||||
// where it was: 149..251 instead of 100..300.
|
||||
s.execute("shape.effects", &json!({"ids": [id], "threeD": {"camera": "orthographicFront", "rotX": 60}})).unwrap();
|
||||
// 175 is a quarter of the way across the drawn picture: column 2, not column 3 (the 75/200
|
||||
// of the unturned box).
|
||||
s.execute("picture.transparentColor", &json!({"ids": [id], "at": [175, 150]})).unwrap();
|
||||
assert_eq!(clear_color_of(&s, &id), Some(deckcraft_color::Rgba::rgb(40, 0, 0)));
|
||||
// Inside the unturned box but outside the drawn picture: nothing is under the pointer.
|
||||
assert!(s.execute("picture.transparentColor", &json!({"ids": [id], "at": [120, 150]})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_color_at_a_point_finds_a_grouped_picture_without_its_own_box() {
|
||||
let mut s = session();
|
||||
let pic = ramp_picture(&mut s, 10, [100, 100, 200, 100]);
|
||||
let other = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 300, 50, 50]})).unwrap()["id"].clone();
|
||||
s.execute("edit.select", &json!({"ids": [pic, other]})).unwrap();
|
||||
s.execute("arrange.group", &json!({})).unwrap();
|
||||
// A grouped picture that keeps no box of its own is drawn where the default box puts it.
|
||||
let sz = s.doc().unwrap().doc.slide_size;
|
||||
s.edit(|doc, sel| {
|
||||
fn clear(shapes: &mut [deckcraft_model::Shape], id: &serde_json::Value) {
|
||||
for sh in shapes {
|
||||
if json!(sh.id) == *id {
|
||||
sh.xfrm = None;
|
||||
}
|
||||
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind {
|
||||
clear(children, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
clear(crate::shapes_mut(doc, sel).unwrap(), &pic);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
// Left tenth of the default box (10%, 10%, 80% x 20% of the slide): column 0.
|
||||
let at = [sz.width * 0.1 + 1.0, sz.height * 0.2];
|
||||
s.execute("picture.transparentColor", &json!({"ids": [pic], "at": at})).unwrap();
|
||||
assert_eq!(clear_color_of(&s, &pic), Some(deckcraft_color::Rgba::rgb(0, 0, 0)));
|
||||
let at = [sz.width * 0.9 - 1.0, sz.height * 0.2];
|
||||
s.execute("picture.transparentColor", &json!({"ids": [pic], "at": at})).unwrap();
|
||||
assert_eq!(clear_color_of(&s, &pic), Some(deckcraft_color::Rgba::rgb(180, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_color_at_a_point_takes_the_topmost_selected_picture() {
|
||||
let mut s = session();
|
||||
// `under` is inserted first (drawn below), `over` after it (drawn on top); they overlap in x 150..300.
|
||||
let under = ramp_picture(&mut s, 10, [100, 100, 200, 100]);
|
||||
let over = ramp_picture(&mut s, 10, [150, 100, 200, 100]);
|
||||
// Selected bottom first: selection order is the opposite of the stacking order.
|
||||
s.execute("edit.select", &json!({"ids": [under, over]})).unwrap();
|
||||
// x = 250 is column 7 of `under` (140) and column 5 of `over` (100): the click is on `over`.
|
||||
s.execute("picture.transparentColor", &json!({"at": [250, 150]})).unwrap();
|
||||
assert_eq!(clear_color_of(&s, &over), Some(deckcraft_color::Rgba::rgb(100, 0, 0)));
|
||||
assert_eq!(clear_color_of(&s, &under), None);
|
||||
// Where `over` is not, the picture below is still found.
|
||||
s.execute("picture.transparentColor", &json!({"at": [120, 150]})).unwrap();
|
||||
assert_eq!(clear_color_of(&s, &under), Some(deckcraft_color::Rgba::rgb(20, 0, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_all_slides_scope() {
|
||||
let mut s = session();
|
||||
@@ -2777,6 +2987,33 @@ fn a_png_export_records_the_slide_resolution() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #784: `transparentBackground` leaves the slide background out of a PNG export, so only the
|
||||
/// slide's content is opaque; formats without transparency refuse it and write nothing.
|
||||
#[test]
|
||||
fn a_png_export_can_leave_the_background_transparent() {
|
||||
let mut s = session();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
s.execute("design.background", &json!({"color": "#0000C8"})).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-transparent-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let read = |path: &std::path::Path| image::load_from_memory(&std::fs::read(path).unwrap()).unwrap().to_rgba8();
|
||||
let opaque = dir.join("opaque.png");
|
||||
s.execute("file.export", &json!({"path": opaque.to_str().unwrap(), "scale": 1})).unwrap();
|
||||
assert_eq!(read(&opaque).get_pixel(5, 5).0, [0, 0, 200, 255], "the background draws by default");
|
||||
let clear = dir.join("clear.png");
|
||||
s.execute("file.export", &json!({"path": clear.to_str().unwrap(), "scale": 1, "transparentBackground": true})).unwrap();
|
||||
let img = read(&clear);
|
||||
assert_eq!(img.get_pixel(5, 5).0[3], 0, "no background: {:?}", img.get_pixel(5, 5));
|
||||
assert_eq!(img.get_pixel(200, 150).0[3], 255, "the shape stays opaque: {:?}", img.get_pixel(200, 150));
|
||||
for (name, format) in [("slide.jpg", "jpeg"), ("deck.pdf", "pdf"), ("deck.gif", "gif"), ("deck.pptx", "pptx")] {
|
||||
let path = dir.join(name);
|
||||
let e = s.execute("file.export", &json!({"path": path.to_str().unwrap(), "format": format, "transparentBackground": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("for PNG export"), "{format}: {e}");
|
||||
assert!(!path.exists(), "{format}: nothing is written");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let w: [u8; 4] = bytes.get(16..20)?.try_into().ok()?;
|
||||
let h: [u8; 4] = bytes.get(20..24)?.try_into().ok()?;
|
||||
@@ -3120,6 +3357,37 @@ fn autocorrect_options_as_you_type() {
|
||||
assert_eq!(s.execute("text.get", &json!({"id": title})).unwrap()["text"], "monday notes ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocorrect_superscripts_ordinals_as_you_type() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
// The typed text with each superscript run in ^(…).
|
||||
let typed = |s: &mut Session, text: &str| -> String {
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.selectAll", &json!({})).ok();
|
||||
s.execute("text.delete", &json!({"dir": "backward"})).ok();
|
||||
for c in text.chars() {
|
||||
s.execute("text.insert", &json!({"text": c.to_string()})).unwrap();
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
let sh = st.current_slide().unwrap().shapes.iter().find(|sh| sh.id.0 == title).unwrap();
|
||||
sh.text.as_ref().unwrap().paragraphs[0]
|
||||
.runs
|
||||
.iter()
|
||||
.map(|r| if r.props.baseline.unwrap_or(0.0) > 0.0 { format!("^({})", r.text) } else { r.text.clone() })
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(typed(&mut s, "1st, 22nd and 103rd. 4th 11th x"), "1^(st), 22^(nd) and 103^(rd). 4^(th) 11^(th) x");
|
||||
// Not a word, a number without a suffix it takes, or a suffix not yet followed by a space.
|
||||
assert_eq!(typed(&mut s, "first x1st 1th 2st 21st"), "First x1st 1th 2st 21st");
|
||||
assert_eq!(s.execute("text.autocorrect", &json!({})).unwrap()["ordinals"], true);
|
||||
s.execute("text.autocorrect", &json!({"ordinals": false})).unwrap();
|
||||
assert_eq!(typed(&mut s, "2nd "), "2nd ");
|
||||
// AutoCorrect off turns it off too.
|
||||
s.execute("text.autocorrect", &json!({"ordinals": true, "on": false})).unwrap();
|
||||
assert_eq!(typed(&mut s, "2nd "), "2nd ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_variants_apply_and_report_the_current_one() {
|
||||
let mut s = session();
|
||||
@@ -3417,6 +3685,37 @@ fn find_and_replace_across_shapes_and_notes() {
|
||||
assert_eq!(s.execute("text.find", &q).unwrap().as_array().unwrap().len(), n - 1);
|
||||
}
|
||||
|
||||
/// Find and Replace count a line break as one character, so offsets agree with the editor and no
|
||||
/// match spans the break.
|
||||
#[test]
|
||||
fn find_and_replace_after_a_line_break() {
|
||||
let mut s = session();
|
||||
let id = s.execute("insert.textBox", &json!({"rect": [10, 10, 300, 80], "text": "ab"})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "end": true})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "\u{b}cd"})).unwrap();
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
let text = |s: &Session| {
|
||||
s.doc()
|
||||
.unwrap()
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|x| u64::from(x.id.0) == id)
|
||||
.and_then(|x| x.text.as_ref())
|
||||
.map(|t| t.text())
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(text(&s), "ab\u{b}cd");
|
||||
assert!(s.execute("text.find", &json!({"text": "bc"})).unwrap().as_array().unwrap().is_empty(), "no match spans the break");
|
||||
let found = s.execute("text.find", &json!({"text": "cd"})).unwrap();
|
||||
assert_eq!((found[0]["start"].as_u64(), found[0]["end"].as_u64()), (Some(3), Some(5)), "{found}");
|
||||
s.execute("text.replace", &json!({"text": "cd", "with": "X"})).unwrap();
|
||||
assert_eq!(text(&s), "ab\u{b}X");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s), "ab\u{b}cd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pen_pressure_is_kept_and_a_mouse_draws_at_full_width() {
|
||||
let mut s = session();
|
||||
@@ -3517,7 +3816,8 @@ fn background_loads_are_adopted_through_a_command() {
|
||||
replay.execute(&id, ¶ms).unwrap();
|
||||
assert_eq!(replay.doc().unwrap().doc.slides.len(), 2);
|
||||
// A token staged for another path is not taken for this one.
|
||||
let other = s.stage_loaded(cmd::file::LoadedFile { path: "/elsewhere.pptx".into(), doc: Default::default(), native: true, damaged: vec![] });
|
||||
let other =
|
||||
s.stage_loaded(cmd::file::LoadedFile { path: "/elsewhere.pptx".into(), doc: Default::default(), native: true, damaged: Default::default() });
|
||||
s.execute("file.adopt", &json!({"path": path, "token": other})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), 2);
|
||||
assert!(s.execute("file.adopt", &json!({})).is_err());
|
||||
@@ -3557,3 +3857,549 @@ fn an_exhausted_id_space_refuses_growth() {
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), ids.len(), "no id repeats");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_ids_beyond_u32_are_rejected_not_wrapped() {
|
||||
let (mut s, a, b) = two_shapes();
|
||||
let wrap = |id: deckcraft_model::ShapeId| u64::from(id.0) + (1u64 << 32);
|
||||
let shapes_before = s.doc().unwrap().shapes().len();
|
||||
let r = s.execute("shape.connect", &json!({"from": wrap(a), "to": wrap(b)}));
|
||||
assert!(r.is_err(), "connect must reject ids beyond u32, got {r:?}");
|
||||
let r = s.execute("shape.sites", &json!({"id": wrap(a)}));
|
||||
assert!(r.is_err(), "sites must reject ids beyond u32, got {r:?}");
|
||||
let r = s.execute("shape.points", &json!({"id": wrap(a)}));
|
||||
assert!(r.is_err(), "points must reject ids beyond u32, got {r:?}");
|
||||
let r = s.execute("shape.movePoint", &json!({"id": wrap(a), "index": 0, "x": 1.0, "y": 1.0}));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("not a valid shape id")), "movePoint must reject ids beyond u32");
|
||||
assert_eq!(s.doc().unwrap().shapes().len(), shapes_before, "no connector was created");
|
||||
// In-range ids still work.
|
||||
assert!(s.execute("shape.sites", &json!({"id": a.0})).is_ok());
|
||||
assert!(s.execute("shape.connect", &json!({"from": a.0, "to": b.0})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_insert_below_or_right_of_a_huge_index_appends_at_the_end() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
|
||||
let r = s.execute("table.insertRowBelow", &json!({"id": id.0, "row": u64::MAX}));
|
||||
assert!(r.is_ok(), "insertRowBelow must clamp a huge row, got {r:?}");
|
||||
let t = table_of(&s, id);
|
||||
assert_eq!(t.n_rows(), 3);
|
||||
assert_eq!(t.cell(0, 0).unwrap().text.text(), "a", "the original rows keep their place");
|
||||
let r = s.execute("table.insertColumnRight", &json!({"id": id.0, "col": u64::MAX}));
|
||||
assert!(r.is_ok(), "insertColumnRight must clamp a huge col, got {r:?}");
|
||||
let t = table_of(&s, id);
|
||||
assert_eq!(t.n_cols(), 3);
|
||||
assert_eq!(t.cell(0, 1).unwrap().text.text(), "b", "the original columns keep their place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_column_width_of_a_huge_column_is_ignored() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
|
||||
for keep_total in [true, false] {
|
||||
let r = s.execute("table.columnWidth", &json!({"id": id.0, "col": u64::MAX, "width": 50.0, "keepTotal": keep_total}));
|
||||
assert!(r.is_ok(), "columnWidth must ignore a huge col (keepTotal {keep_total}), got {r:?}");
|
||||
assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0], "no column changes");
|
||||
}
|
||||
}
|
||||
|
||||
/// Slides imported from an outline join the section of the slide they follow, like `slide.new`.
|
||||
#[test]
|
||||
fn slides_from_outline_join_the_section_of_the_slide_they_follow() {
|
||||
for markdown in [false, true] {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"title": "A"})).unwrap();
|
||||
s.execute("section.add", &json!({"name": "B", "at": 1})).unwrap();
|
||||
let text = if markdown { "# Imported\n- bullet" } else { "Imported\n\tbullet" };
|
||||
s.execute("slide.fromOutline", &json!({"text": text, "markdown": markdown})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let orphans: Vec<_> = doc.slides.iter().map(|x| x.id).filter(|id| doc.section_of(*id).is_none()).collect();
|
||||
assert!(orphans.is_empty(), "markdown={markdown}: slides in no section: {orphans:?}; sections {:?}", doc.sections);
|
||||
let order: Vec<_> = doc.sections.iter().flat_map(|x| x.slides.iter().copied()).collect();
|
||||
let slides: Vec<_> = doc.slides.iter().map(|x| x.id).collect();
|
||||
assert_eq!(order, slides, "markdown={markdown}: sections follow the slide order");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_pptx_with_an_unreadable_slide_says_so() {
|
||||
use std::io::{Cursor, Read, Write};
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), 2);
|
||||
let good = deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap();
|
||||
let mut zin = zip::ZipArchive::new(Cursor::new(good.clone())).unwrap();
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..zin.len() {
|
||||
let mut f = zin.by_index(i).unwrap();
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).unwrap();
|
||||
if f.name() == "ppt/slides/slide2.xml" {
|
||||
d = b"<p:sld".to_vec();
|
||||
}
|
||||
out.start_file(f.name(), o).unwrap();
|
||||
out.write_all(&d).unwrap();
|
||||
}
|
||||
let broken = out.finish().unwrap().into_inner();
|
||||
|
||||
let mut t = session();
|
||||
let r = t.execute("file.openBytes", &json!({"name": "Broken.pptx", "data": cmd::base64_encode(&broken)})).unwrap();
|
||||
assert_eq!(r["lostSlides"], json!(["ppt/slides/slide2.xml"]), "{r}");
|
||||
assert_eq!(t.doc().unwrap().doc.slides.len(), 1);
|
||||
assert!(t.doc().unwrap().title.ends_with("(Repaired)"), "{}", t.doc().unwrap().title);
|
||||
assert!(t.ui_requests.iter().any(|u| matches!(u, crate::UiRequest::Message { text } if text.contains("1 slide in this file couldn't be read"))));
|
||||
// A good file reports nothing.
|
||||
let mut u = session();
|
||||
let r = u.execute("file.openBytes", &json!({"name": "Fine.pptx", "data": cmd::base64_encode(&good)})).unwrap();
|
||||
assert_eq!(r["lostSlides"], json!([]), "{r}");
|
||||
assert!(u.ui_requests.is_empty());
|
||||
}
|
||||
|
||||
/// A file with absurd effect sizes opens with them capped at what Format Shape allows: drawn as
|
||||
/// they were, a glow of 10^18 pt made the renderer ask for 34 GB and abort.
|
||||
#[test]
|
||||
fn opening_a_file_caps_absurd_effect_sizes() {
|
||||
use deckcraft_model::style::{Glow, Reflection};
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Glow"})).unwrap();
|
||||
s.execute("shape.effects", &json!({"shadow": "outer", "reflection": "tight", "softEdges": 4, "glow": {"radius": 8}})).unwrap();
|
||||
s.execute("insert.table", &json!({"rows": 1, "cols": 1, "rect": [400, 300, 100, 40]})).unwrap();
|
||||
let huge = 1e18;
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).unwrap();
|
||||
for sh in list.iter_mut() {
|
||||
if let Some(e) = sh.effects.as_mut() {
|
||||
e.glow.as_mut().unwrap().radius = huge;
|
||||
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 };
|
||||
}
|
||||
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 {
|
||||
deckcraft_model::ShapeKind::Table(t) => t
|
||||
.rows
|
||||
.iter_mut()
|
||||
.flat_map(|r| r.cells.iter_mut())
|
||||
.flat_map(|c| c.text.paragraphs.iter_mut())
|
||||
.flat_map(|p| p.runs.iter_mut())
|
||||
.collect(),
|
||||
_ => sh.text.iter_mut().flat_map(|t| t.paragraphs.iter_mut()).flat_map(|p| p.runs.iter_mut()).collect(),
|
||||
};
|
||||
for r in runs {
|
||||
r.props.glow = glow.clone();
|
||||
}
|
||||
}
|
||||
// Inherited styles too: the deck default, a master's body style, a text box's own list
|
||||
// style, and the theme's effect styles that shape styles point at.
|
||||
let level = deckcraft_model::text::LevelStyle {
|
||||
run: deckcraft_model::text::RunProps {
|
||||
glow: Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge }),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
doc.default_text_style.set(0, level.clone());
|
||||
let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
|
||||
m.body_style.set(0, level.clone());
|
||||
if let Some(fx) = m.theme.format.effects.first_mut() {
|
||||
fx.glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge });
|
||||
}
|
||||
let list = crate::shapes_mut(doc, sel).unwrap();
|
||||
if let Some(t) = list.iter_mut().find_map(|sh| sh.text.as_mut()) {
|
||||
t.list_style.set(0, level);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
for format in ["deckcraft", "pptx"] {
|
||||
let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, format).unwrap();
|
||||
let mut t = Session::new();
|
||||
t.execute("file.openBytes", &json!({"name": format!("fx.{format}"), "data": cmd::base64_encode(&bytes)})).unwrap();
|
||||
let st = t.doc().unwrap();
|
||||
let slide = st.doc.slides.last().unwrap();
|
||||
let mut glows = vec![];
|
||||
deckcraft_model::walk(&slide.shapes, &mut |sh, _| {
|
||||
if let Some(e) = &sh.effects {
|
||||
glows.extend(e.glow.as_ref().map(|g| g.radius));
|
||||
assert!(e.soft_edge.is_none_or(|v| (0.0..=100.0).contains(&v)), "{format}: {:?}", e.soft_edge);
|
||||
if let Some(sw) = &e.outer_shadow {
|
||||
assert!(sw.blur <= 200.0 && sw.dist <= 200.0 && sw.sx <= 10.0, "{format}: {sw:?}");
|
||||
}
|
||||
if let Some(r) = &e.reflection {
|
||||
assert!(r.blur <= 100.0 && r.dist <= 100.0, "{format}: {r:?}");
|
||||
}
|
||||
}
|
||||
for p in sh.text.iter().flat_map(|t| t.paragraphs.iter()) {
|
||||
glows.extend(p.runs.iter().filter_map(|r| r.props.glow.as_ref().map(|g| g.radius)));
|
||||
}
|
||||
if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
|
||||
let cells = tb.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.text.paragraphs.iter());
|
||||
glows.extend(cells.flat_map(|p| p.runs.iter()).filter_map(|r| r.props.glow.as_ref().map(|g| g.radius)));
|
||||
}
|
||||
});
|
||||
let inherited = |ls: &deckcraft_model::text::ListStyle| {
|
||||
ls.levels.iter().flatten().filter_map(|l| l.run.glow.as_ref().map(|g| g.radius)).collect::<Vec<_>>()
|
||||
};
|
||||
glows.extend(inherited(&st.doc.default_text_style));
|
||||
let m = &st.doc.masters[0];
|
||||
for ls in [&m.title_style, &m.body_style, &m.other_style] {
|
||||
glows.extend(inherited(ls));
|
||||
}
|
||||
glows.extend(m.theme.format.effects.iter().filter_map(|e| e.glow.as_ref().map(|g| g.radius)));
|
||||
deckcraft_model::walk(&slide.shapes, &mut |sh, _| glows.extend(sh.text.iter().flat_map(|t| inherited(&t.list_style))));
|
||||
assert!(!glows.is_empty(), "{format}: the glows were kept");
|
||||
assert!(glows.iter().all(|g| (0.0..=150.0).contains(g)), "{format}: {glows:?}");
|
||||
// Capped, the slide renders (checked last: uncapped, this is what ran out of memory).
|
||||
let i = st.doc.slides.len() - 1;
|
||||
t.execute("file.render", &json!({"slide": i, "scale": 0.1})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A .deckcraft file whose shapes repeat an id (groups included) opens with fresh ids for the
|
||||
/// repeats: the deck passes `validate`, the first shape keeps its id and none is lost.
|
||||
#[test]
|
||||
fn opening_a_file_gives_repeated_shape_ids_fresh_ones() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
for x in [0, 100, 200, 300] {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 80, 80]})).unwrap();
|
||||
}
|
||||
s.execute("edit.selectAll", &json!({})).unwrap();
|
||||
s.execute("arrange.group", &json!({})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [400, 300, 80, 80]})).unwrap();
|
||||
let first = s.doc().unwrap().shapes()[0].id;
|
||||
// Every shape, the group's children too, takes the group's id.
|
||||
s.edit(|doc, sel| {
|
||||
fn all(shapes: &mut [deckcraft_model::Shape], id: deckcraft_model::ShapeId) {
|
||||
for sh in shapes {
|
||||
sh.id = id;
|
||||
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind {
|
||||
all(children, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
all(crate::shapes_mut(doc, sel).unwrap(), first);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!s.doc().unwrap().doc.validate().is_empty(), "the deck repeats ids");
|
||||
let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "deckcraft").unwrap();
|
||||
let mut t = Session::new();
|
||||
t.execute("file.openBytes", &json!({"name": "dup.deckcraft", "data": cmd::base64_encode(&bytes)})).unwrap();
|
||||
let st = t.doc().unwrap();
|
||||
assert_eq!(st.doc.validate(), Vec::<String>::new());
|
||||
let slide = st.doc.slides.last().unwrap();
|
||||
let mut ids = vec![];
|
||||
deckcraft_model::walk(&slide.shapes, &mut |sh, _| ids.push(sh.id));
|
||||
assert_eq!(ids.len(), 6, "the group, its four children and the ellipse");
|
||||
assert_eq!(ids[0], first, "the first shape keeps its id");
|
||||
assert!(ids.iter().all(|id| id.0 <= st.doc.next_id), "new ids come from the deck's counter");
|
||||
// Each shape can be selected by its own id now.
|
||||
let last = *ids.last().unwrap();
|
||||
t.execute("slide.go", &json!({"index": st.doc.slides.len() - 1})).unwrap();
|
||||
t.execute("selection.select", &json!({"id": last.0})).unwrap();
|
||||
assert_eq!(t.doc().unwrap().selected_shapes()[0].id, last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_case_spans_formatting_runs() {
|
||||
let setup = |text: &str, bold_to: usize| {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 420.0, 160.0], "text": text})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap() as u32;
|
||||
s.execute("text.edit", &json!({"id": id})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, bold_to]})).unwrap();
|
||||
s.execute("format.bold", &json!({"on": true})).unwrap();
|
||||
(s, id)
|
||||
};
|
||||
let text_of = |s: &Session, id: u32| s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
|
||||
for (mode, input, want) in [("title", "hello world", "Hello World"), ("sentence", "hello world. bye", "Hello world. Bye")] {
|
||||
// Text range.
|
||||
let (mut s, id) = setup(input, 3);
|
||||
let n = input.chars().count();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, n]})).unwrap();
|
||||
s.execute("format.case", &json!({"mode": mode})).unwrap();
|
||||
assert_eq!(text_of(&s, id).text(), want, "{mode} range");
|
||||
// Whole shape.
|
||||
let (mut s, id) = setup(input, 3);
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("format.case", &json!({"mode": mode})).unwrap();
|
||||
assert_eq!(text_of(&s, id).text(), want, "{mode} shape");
|
||||
assert_eq!(text_of(&s, id).paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
// A length-changing mapping keeps each piece in its own run.
|
||||
let (mut s, id) = setup("straße", 4);
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 6]})).unwrap();
|
||||
s.execute("format.case", &json!({"mode": "upper"})).unwrap();
|
||||
let body = text_of(&s, id);
|
||||
assert_eq!(body.text(), "STRASSE");
|
||||
assert_eq!(body.paragraphs[0].runs[0].text, "STRA");
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipping_a_rotated_shape_mirrors_its_outline() {
|
||||
let corners = |s: &Session, id: deckcraft_model::ShapeId| {
|
||||
let x = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap();
|
||||
let a = x.affine();
|
||||
[(0.0, 0.0), (x.w, 0.0), (x.w, x.h), (0.0, x.h)].map(|(px, py)| {
|
||||
let p = a * deckcraft_geom::Point::new(px, py);
|
||||
((p.x * 100.0).round() as i64, (p.y * 100.0).round() as i64)
|
||||
})
|
||||
};
|
||||
let sorted = |mut c: [(i64, i64); 4]| {
|
||||
c.sort_unstable();
|
||||
c
|
||||
};
|
||||
for (cmd, horizontal) in [("arrange.flipHorizontal", true), ("arrange.flipVertical", false)] {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 50]})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
s.execute("shape.rotate", &json!({"deg": 30})).unwrap();
|
||||
let before = corners(&s, id);
|
||||
// Mirror about the box's centre line (centre is 200, 125).
|
||||
let mirrored = before.map(|(x, y)| if horizontal { (40000 - x, y) } else { (x, 25000 - y) });
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
assert_eq!(sorted(corners(&s, id)), sorted(mirrored), "{cmd} of a 30° rotated rectangle is not its mirror image");
|
||||
let rot = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().rot;
|
||||
assert!((rot - 330.0).abs() < 1e-9, "{cmd} negates the rotation, got {rot}");
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
assert_eq!(sorted(corners(&s, id)), sorted(before), "{cmd} twice restores the outline");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_geometry_commands_limit_size_and_position() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "moon", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
let xfrm = |s: &Session| s.doc().unwrap().selected_shapes()[0].xfrm.unwrap();
|
||||
let within = |s: &Session| {
|
||||
let x = xfrm(s);
|
||||
[x.w, x.h].iter().all(|v| v.is_finite() && (0.0..=15_840.0).contains(v)) && [x.x, x.y].iter().all(|v| v.is_finite() && v.abs() <= 100_000.0)
|
||||
};
|
||||
s.execute("shape.setBounds", &json!({"x": 1e100, "y": -1e100, "w": 100000, "h": 1e100})).unwrap();
|
||||
let x = xfrm(&s);
|
||||
assert!(within(&s), "setBounds stored {x:?}");
|
||||
s.execute("shape.resize", &json!({"w": 10, "h": 10})).unwrap();
|
||||
s.execute("shape.resize", &json!({"h": 1e100})).unwrap();
|
||||
let x = xfrm(&s);
|
||||
assert!(within(&s), "resize stored {x:?}");
|
||||
s.execute("shape.resize", &json!({"h": 1e100, "lockAspect": true})).unwrap();
|
||||
let x = xfrm(&s);
|
||||
assert!(within(&s), "locked resize stored {x:?}");
|
||||
s.execute("shape.move", &json!({"x": 1e100, "y": -1e100})).unwrap();
|
||||
let x = xfrm(&s);
|
||||
assert!(within(&s), "move stored {x:?}");
|
||||
// A move that overflows to infinity lands on the limit, not back at 0.
|
||||
s.execute("shape.move", &json!({"x": 1e308, "dx": 1e308})).unwrap();
|
||||
assert_eq!(xfrm(&s).x, 100_000.0);
|
||||
// A rotated shape resizes about its centre and still stays inside the limit.
|
||||
s.execute("shape.setBounds", &json!({"x": 99_000, "y": 0, "w": 10, "h": 10})).unwrap();
|
||||
s.execute("shape.rotate", &json!({"deg": 45})).unwrap();
|
||||
s.execute("shape.resize", &json!({"w": 1e100})).unwrap();
|
||||
let x = xfrm(&s);
|
||||
assert!(within(&s), "rotated resize stored {x:?}");
|
||||
}
|
||||
|
||||
/// Edit Points turns a preset into a freeform: one undo step, so Undo brings the preset back
|
||||
/// (and leaves Edit Points), Redo the freeform; leaving Edit Points adds no step.
|
||||
#[test]
|
||||
fn edit_points_conversion_is_one_undo_step() {
|
||||
use deckcraft_model::Geom;
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "star5", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
let preset = |s: &Session| matches!(s.doc().unwrap().selected_shapes()[0].geom, Geom::Preset { .. });
|
||||
s.execute("shape.editPoints", &json!({"on": true})).unwrap();
|
||||
assert!(!preset(&s), "Edit Points made a freeform");
|
||||
assert!(s.tool.editing_points(s.doc().unwrap()).is_some());
|
||||
s.execute("shape.editPoints", &json!({"on": false})).unwrap();
|
||||
// Leaving changed nothing, so the next undo is the conversion itself.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(preset(&s), "Undo brings the star back");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(!preset(&s), "Redo makes it a freeform again");
|
||||
// Undo while still editing points ends Edit Points: the star has no points to edit.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("shape.editPoints", &json!({"on": true})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(preset(&s));
|
||||
assert!(s.tool.editing_points(s.doc().unwrap()).is_none());
|
||||
// A freeform needs no conversion: entering Edit Points again adds no undo step.
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
s.execute("shape.editPoints", &json!({"on": false})).unwrap();
|
||||
s.execute("shape.editPoints", &json!({"on": true})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(preset(&s), "the only step was the conversion");
|
||||
}
|
||||
|
||||
/// #931: while the text of a placeholder is being edited, a shape in front of it still takes a
|
||||
/// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret.
|
||||
#[test]
|
||||
fn a_shape_in_front_of_edited_text_takes_the_click() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
|
||||
let square = s.execute("shape.insert", &json!({"preset": "rect", "rect": [380, 260, 200, 120]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0, pressure: None };
|
||||
let click = |s: &mut Session, x: f64, y: f64| {
|
||||
s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap();
|
||||
s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap();
|
||||
};
|
||||
// The content placeholder's edge: its (empty) text is now being edited.
|
||||
click(&mut s, 66.0, 300.0);
|
||||
let body = s.doc().unwrap().selection.text.as_ref().map(|t| t.shape).expect("editing the content placeholder");
|
||||
assert_ne!(body.0 as u64, square);
|
||||
// A click on the text away from the square keeps editing it.
|
||||
click(&mut s, 200.0, 300.0);
|
||||
assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape), Some(body));
|
||||
// A click on the square, in front of the text, selects the square.
|
||||
click(&mut s, 480.0, 320.0);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.shapes.iter().map(|id| id.0 as u64).collect::<Vec<_>>(), vec![square]);
|
||||
assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 == square), "{:?}", st.selection.text);
|
||||
|
||||
// A shape over the edited box's edge takes the click there too: the edge would otherwise drag
|
||||
// the box.
|
||||
let edge = {
|
||||
let st = s.doc().unwrap();
|
||||
let x = cmd::xfrm_of(&st.doc, &st.selection, st.shape(body).unwrap());
|
||||
(x.x, x.y + x.h / 2.0)
|
||||
};
|
||||
let corner =
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [edge.0 - 10.0, edge.1 - 10.0, 20, 20]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": body.0})).unwrap();
|
||||
// On the box's left edge, inside the band that drags it, clear of its middle handle (handles
|
||||
// stay on top, as in PowerPoint).
|
||||
click(&mut s, edge.0 + 1.0, edge.1 - 9.0);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.shapes.iter().map(|id| id.0 as u64).collect::<Vec<_>>(), vec![corner], "{:?}", st.selection);
|
||||
|
||||
// Text inside a group: a member in front of it takes the click instead of the caret.
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let tb =
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 150], "text": "Grouped text"})).unwrap()["id"].as_u64().unwrap();
|
||||
let cover = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 150, 60, 40]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("arrange.group", &json!({"ids": [tb, cover]})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": tb})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape.0 as u64), Some(tb));
|
||||
click(&mut s, 230.0, 170.0);
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 != tb), "the click on the member in front left the text: {:?}", st.selection);
|
||||
}
|
||||
|
||||
/// A box at `rect` and a second box at `other` on a fresh slide; smart guides on, the grid off.
|
||||
/// The first box is selected.
|
||||
fn snap_scene(rect: [f64; 4], other: [f64; 4]) -> (Session, u64) {
|
||||
let mut s = session();
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": other})).unwrap();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": rect})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
s.prefs.smart_guides = true;
|
||||
(s, id)
|
||||
}
|
||||
|
||||
/// The slide box (`x`, `y`, `w`, `h`) of shape `id`, as `shape.inspect` reports it.
|
||||
fn box_of(s: &mut Session, id: u64) -> serde_json::Value {
|
||||
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
|
||||
}
|
||||
|
||||
/// #961: dragging the east handle snaps the dragged (right) edge, even when the fixed left edge
|
||||
/// already lines up with another shape. The nearest line used to be the fixed edge's, at distance 0.
|
||||
#[test]
|
||||
fn resizing_snaps_the_dragged_edge_when_the_fixed_edge_is_already_aligned() {
|
||||
// The box spans x 100..200; the other box starts at 100 too and ends at 300.
|
||||
let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [100.0, 300.0, 200.0, 80.0]);
|
||||
drag_gesture(&mut s, (200.0, 140.0), (297.0, 140.0));
|
||||
let b = box_of(&mut s, id);
|
||||
assert_eq!(b["x"], 100.0, "the fixed edge stays: {b}");
|
||||
assert_eq!(b["w"], 200.0, "the right edge snaps to the other box's right edge at 300: {b}");
|
||||
}
|
||||
|
||||
/// #961: only the dragged edge looks for a line. A fixed edge near a line must not shift the
|
||||
/// edge being dragged by its own distance.
|
||||
#[test]
|
||||
fn resizing_is_not_shifted_by_a_fixed_edge_near_a_line() {
|
||||
// The other box starts at 103, 3 pt from the fixed left edge; the dragged edge ends far from any line.
|
||||
let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [103.0, 300.0, 50.0, 80.0]);
|
||||
drag_gesture(&mut s, (200.0, 140.0), (421.0, 140.0));
|
||||
let b = box_of(&mut s, id);
|
||||
assert_eq!(b["x"], 100.0, "the fixed edge stays: {b}");
|
||||
assert_eq!(b["w"], 321.0, "the dragged edge goes where the pointer went: {b}");
|
||||
}
|
||||
|
||||
/// #961: dragging a handle past the opposite edge flips the box, and the dragged edge becomes the
|
||||
/// other side of it: that side snaps, not the fixed one.
|
||||
#[test]
|
||||
fn resizing_past_the_opposite_edge_snaps_the_dragged_edge() {
|
||||
// East handle of the box at x 100..200, dragged left past its left edge to 3, 3 pt from the
|
||||
// other box's left edge at 0.
|
||||
let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [0.0, 300.0, 50.0, 80.0]);
|
||||
drag_gesture(&mut s, (200.0, 140.0), (3.0, 140.0));
|
||||
let b = box_of(&mut s, id);
|
||||
assert_eq!((b["x"].clone(), b["w"].clone()), (json!(0.0), json!(100.0)), "the dragged edge snaps to 0, the fixed edge stays at 100: {b}");
|
||||
// South handle of the box at y 100..180, dragged up past its top edge to 3.
|
||||
let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [300.0, 0.0, 80.0, 50.0]);
|
||||
drag_gesture(&mut s, (150.0, 180.0), (150.0, 3.0));
|
||||
let b = box_of(&mut s, id);
|
||||
assert_eq!((b["y"].clone(), b["h"].clone()), (json!(0.0), json!(100.0)), "the dragged edge snaps to 0, the fixed edge stays at 100: {b}");
|
||||
}
|
||||
|
||||
/// #961: the handles of a flipped box sit on the other side (the east handle draws on the left),
|
||||
/// and the edge under the dragged handle snaps.
|
||||
#[test]
|
||||
fn resizing_a_flipped_box_snaps_the_dragged_edge() {
|
||||
// Flipped box at x 100..200: its east handle is at x 100. Dragged to 3, 3 pt from the other box's left edge at 0.
|
||||
let (mut s, id) = snap_scene([100.0, 100.0, 100.0, 80.0], [0.0, 300.0, 50.0, 80.0]);
|
||||
s.execute("shape.flip", &json!({"axis": "horizontal"})).unwrap();
|
||||
drag_gesture(&mut s, (100.0, 140.0), (3.0, 140.0));
|
||||
let b = box_of(&mut s, id);
|
||||
assert_eq!((b["x"].clone(), b["w"].clone()), (json!(0.0), json!(200.0)), "the dragged edge snaps to 0, the fixed edge stays at 200: {b}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicating_a_moved_copy_repeats_the_move() {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 50, 50]})).unwrap();
|
||||
let a = r["id"].as_u64().unwrap();
|
||||
let boxed = |s: &mut Session, id: u64| {
|
||||
let v = s.execute("shape.inspect", &json!({"id": id})).unwrap();
|
||||
(v["box"]["x"].as_f64().unwrap(), v["box"]["y"].as_f64().unwrap())
|
||||
};
|
||||
let dup = |s: &mut Session| s.execute("edit.duplicate", &json!({})).unwrap()["ids"][0].as_u64().unwrap();
|
||||
// The first copy gets the usual offset.
|
||||
let b = dup(&mut s);
|
||||
let off = s.prefs.duplicate_offset;
|
||||
assert_eq!(boxed(&mut s, b), (100.0 + off, 100.0 + off));
|
||||
// Moved, then duplicated again: the next copy repeats the move from the original (#966).
|
||||
s.execute("shape.move", &json!({"dx": 80.0 - off, "dy": -off})).unwrap();
|
||||
assert_eq!(boxed(&mut s, b), (180.0, 100.0));
|
||||
let c = dup(&mut s);
|
||||
assert_eq!(boxed(&mut s, c), (260.0, 100.0));
|
||||
let d = dup(&mut s);
|
||||
assert_eq!(boxed(&mut s, d), (340.0, 100.0));
|
||||
// Duplicating something else goes back to the usual offset.
|
||||
s.doc_mut().unwrap().selection.shapes = vec![ShapeId(a as u32)];
|
||||
let e = dup(&mut s);
|
||||
assert_eq!(boxed(&mut s, e), (100.0 + off, 100.0 + off));
|
||||
// Undone, its id can go to a new shape: duplicating that one uses the usual offset too.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [400, 300, 40, 40]})).unwrap();
|
||||
let g = r["id"].as_u64().unwrap();
|
||||
assert_eq!(g, e, "the new shape reuses the undone copy's id");
|
||||
let h = dup(&mut s);
|
||||
assert_eq!(boxed(&mut s, h), (400.0 + off, 300.0 + off));
|
||||
// After Undo removed that copy, duplicating the last moved copy gets the usual offset too.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.doc_mut().unwrap().selection.shapes = vec![ShapeId(d as u32)];
|
||||
let f = dup(&mut s);
|
||||
assert_eq!(boxed(&mut s, f), (340.0 + off, 100.0 + off));
|
||||
assert_eq!(s.doc().unwrap().doc.validate(), Vec::<String>::new());
|
||||
}
|
||||
|
||||
@@ -437,3 +437,147 @@ fn select_into_another_table_cell_settles_the_composition() {
|
||||
let cell = |c| t.cell(0, c).map(|x| x.text.text()).unwrap_or_default();
|
||||
assert_eq!((cell(0), cell(1)), ("abhello".to_string(), "X0123456789".to_string()));
|
||||
}
|
||||
|
||||
/// Bold the first paragraph, select part of it and arm the Format Painter.
|
||||
fn painter_armed(sticky: bool) -> (Session, u32) {
|
||||
let (mut s, id) = editing("Hello\nworld", (0, 0));
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();
|
||||
s.execute("format.bold", &json!({"on": true})).unwrap();
|
||||
s.execute("format.painter", &json!({"sticky": sticky})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn bold_at(s: &mut Session, at: Pos) -> bool {
|
||||
s.execute("text.select", &json!({"anchor": [at.0, at.1], "caret": [at.0, at.1 + 1]})).unwrap();
|
||||
s.execute("format.state", &json!({})).unwrap()["bold"] == true
|
||||
}
|
||||
|
||||
fn pointer(s: &mut Session, kind: crate::tools::PointerKind, x: f64, y: f64) {
|
||||
s.pointer(crate::tools::PointerEvent { kind, x, y, tol: 4.0, ..Default::default() }).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_applies_to_a_dragged_text_selection() {
|
||||
use crate::tools::PointerKind::{Down, Drag, Up};
|
||||
let (mut s, _) = painter_armed(false);
|
||||
assert!(!bold_at(&mut s, (1, 0)));
|
||||
s.execute("text.move", &json!({"to": "start"})).unwrap();
|
||||
// Drag across the second line's text.
|
||||
pointer(&mut s, Down, 68.0, 90.0);
|
||||
pointer(&mut s, Drag, 110.0, 90.0);
|
||||
pointer(&mut s, Up, 110.0, 90.0);
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert!(t.is_range(), "the drag should select text");
|
||||
assert!(s.painter.is_none(), "the painter is used up");
|
||||
assert!(bold_at(&mut s, (1, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_applies_to_a_triple_clicked_paragraph() {
|
||||
use crate::tools::PointerKind::TripleClick;
|
||||
let (mut s, _) = painter_armed(true);
|
||||
s.execute("text.select", &json!({"anchor": [1, 2], "caret": [1, 2]})).unwrap();
|
||||
pointer(&mut s, TripleClick, 70.0, 90.0);
|
||||
assert!(s.painter.is_some(), "a sticky painter stays armed");
|
||||
assert!(bold_at(&mut s, (1, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_waits_for_a_range_on_a_plain_click() {
|
||||
use crate::tools::PointerKind::{Down, Up};
|
||||
let (mut s, _) = painter_armed(false);
|
||||
pointer(&mut s, Down, 68.0, 90.0);
|
||||
pointer(&mut s, Up, 68.0, 90.0);
|
||||
assert!(s.painter.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_on_part_of_a_bulleted_line_keeps_its_bullet() {
|
||||
use crate::tools::PointerKind::{Down, Drag, Up};
|
||||
let (mut s, _) = painter_armed(false);
|
||||
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 5]})).unwrap();
|
||||
s.execute("format.bullets", &json!({"on": true})).unwrap();
|
||||
let bulleted = |s: &mut Session| s.execute("format.state", &json!({})).unwrap()["bullets"] == true;
|
||||
assert!(bulleted(&mut s));
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();
|
||||
s.execute("format.painter", &json!({})).unwrap();
|
||||
pointer(&mut s, Down, 68.0, 90.0);
|
||||
pointer(&mut s, Drag, 100.0, 90.0);
|
||||
pointer(&mut s, Up, 100.0, 90.0);
|
||||
assert!(s.painter.is_none());
|
||||
assert!(bold_at(&mut s, (1, 0)));
|
||||
assert!(bulleted(&mut s), "the bullet stays");
|
||||
}
|
||||
|
||||
/// Three bulleted paragraphs "one", "two", "three" and the painter armed from bold plain text.
|
||||
fn painter_over_bullets() -> Session {
|
||||
let (mut s, _) = editing("one\ntwo\nthree\nplain", (0, 0));
|
||||
s.execute("text.select", &json!({"anchor": [3, 0], "caret": [3, 5]})).unwrap();
|
||||
s.execute("format.bold", &json!({"on": true})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [2, 5]})).unwrap();
|
||||
s.execute("format.bullets", &json!({"on": true})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [3, 0], "caret": [3, 5]})).unwrap();
|
||||
s.execute("format.painter", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn bulleted(s: &mut Session, para: usize) -> bool {
|
||||
s.execute("text.select", &json!({"anchor": [para, 0], "caret": [para, 0]})).unwrap();
|
||||
s.execute("format.state", &json!({})).unwrap()["bullets"] == true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_paints_only_the_paragraphs_a_selection_covers() {
|
||||
// Mid paragraph 0 to mid paragraph 2: only paragraph 1 is covered, so only it loses its bullet.
|
||||
let mut s = painter_over_bullets();
|
||||
s.execute("text.select", &json!({"anchor": [0, 1], "caret": [2, 2]})).unwrap();
|
||||
s.execute("format.painterApply", &json!({})).unwrap();
|
||||
assert_eq!([bulleted(&mut s, 0), bulleted(&mut s, 1), bulleted(&mut s, 2)], [true, false, true]);
|
||||
// Start of paragraph 0 to mid paragraph 2: the partly covered last paragraph keeps its bullet.
|
||||
let mut s = painter_over_bullets();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [2, 2]})).unwrap();
|
||||
s.execute("format.painterApply", &json!({})).unwrap();
|
||||
assert_eq!([bulleted(&mut s, 0), bulleted(&mut s, 1), bulleted(&mut s, 2)], [false, false, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_triple_click_leaves_the_next_paragraph_alone() {
|
||||
use crate::tools::PointerKind::TripleClick;
|
||||
let mut s = painter_over_bullets();
|
||||
s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap();
|
||||
pointer(&mut s, TripleClick, 70.0, 70.0);
|
||||
assert!(s.painter.is_none());
|
||||
assert_eq!([bulleted(&mut s, 0), bulleted(&mut s, 1), bulleted(&mut s, 2)], [false, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_ignores_a_double_click_that_selects_nothing() {
|
||||
use crate::tools::PointerKind::DoubleClick;
|
||||
let mut s = painter_over_bullets();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [1, 2]})).unwrap();
|
||||
let before = sel(&s);
|
||||
pointer(&mut s, DoubleClick, 900.0, 520.0);
|
||||
assert_eq!(sel(&s), before);
|
||||
assert!(s.painter.is_some(), "a click that left the selection alone keeps the painter");
|
||||
assert!(bulleted(&mut s, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_ignores_a_shift_press_that_leaves_the_selection() {
|
||||
use crate::tools::PointerKind::{Down, Up};
|
||||
let (mut s, _) = painter_armed(false);
|
||||
// Find the caret position under the point, then select from the line start to it.
|
||||
pointer(&mut s, Down, 110.0, 90.0);
|
||||
pointer(&mut s, Up, 110.0, 90.0);
|
||||
let k = caret(&s);
|
||||
assert!(k.1 > 0);
|
||||
s.execute("text.select", &json!({"anchor": [k.0, 0], "caret": [k.0, k.1]})).unwrap();
|
||||
// Shift-pressing at the caret keeps the range, so there is nothing new to paint.
|
||||
let shift = Mods { shift: true, ..NONE };
|
||||
for kind in [Down, Up] {
|
||||
s.pointer(crate::tools::PointerEvent { kind, x: 110.0, y: 90.0, mods: shift, tol: 4.0, ..Default::default() }).unwrap();
|
||||
}
|
||||
assert_eq!(sel(&s), ((k.0, 0), k));
|
||||
assert!(s.painter.is_some(), "the painter stays armed");
|
||||
assert!(!bold_at(&mut s, (1, 0)));
|
||||
}
|
||||
|
||||
+110
-18
@@ -34,6 +34,9 @@ pub struct Mods {
|
||||
pub cmd: bool,
|
||||
}
|
||||
|
||||
/// A text selection's shape, table cell, notes flag, anchor and caret.
|
||||
type TextTarget = (ShapeId, Option<(usize, usize)>, bool, (usize, usize), (usize, usize));
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PointerEvent {
|
||||
@@ -191,6 +194,8 @@ enum Drag {
|
||||
},
|
||||
TextSelect {
|
||||
shape: ShapeId,
|
||||
/// The text selection before the press, to tell whether the drag changed it.
|
||||
before: Option<TextTarget>,
|
||||
},
|
||||
/// Dragging a table border: the right edge of column `index`, or the bottom edge of row
|
||||
/// `index`. `start` is the table-local press point, `size` the column's width or the row's
|
||||
@@ -280,7 +285,12 @@ impl ToolState {
|
||||
|
||||
/// The shape in Edit Points, while it is still the one selected shape.
|
||||
pub fn editing_points(&self, st: &crate::DocState) -> Option<ShapeId> {
|
||||
self.edit_points.filter(|id| st.selection.shapes.as_slice() == [*id] && st.selection.text.is_none() && st.shape(*id).is_some())
|
||||
// Only a freeform has points: after Undo takes the conversion back, Edit Points is over.
|
||||
self.edit_points.filter(|id| {
|
||||
st.selection.shapes.as_slice() == [*id]
|
||||
&& st.selection.text.is_none()
|
||||
&& st.shape(*id).is_some_and(|sh| matches!(sh.geom, deckcraft_model::Geom::Custom { .. }))
|
||||
})
|
||||
}
|
||||
|
||||
/// A freeform path is being drawn (Enter/Escape/double-click finish it).
|
||||
@@ -409,10 +419,12 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The frame of the text being edited: grabbing it moves the box.
|
||||
// The frame of the text being edited: grabbing it moves the box, unless a shape in front
|
||||
// of the text covers that spot (#931).
|
||||
if let Some(e) = edited_box(st)
|
||||
&& e.movable
|
||||
&& edit_zone(&e.xfrm, p, tol) == EditZone::Frame
|
||||
&& !shape_in_front(st, st.shapes(), e.id, p, tol, 0)
|
||||
{
|
||||
return Some(Hit::Frame { id: e.id });
|
||||
}
|
||||
@@ -467,11 +479,17 @@ impl Session {
|
||||
PointerKind::Down => self.down(p, ev.mods, tol),
|
||||
PointerKind::Drag => self.drag(p, ev.mods, tol),
|
||||
PointerKind::Up => self.up(p, ev.mods),
|
||||
PointerKind::DoubleClick => self.double_click(p, tol),
|
||||
PointerKind::DoubleClick => {
|
||||
let before = self.text_selection();
|
||||
let r = self.double_click(p, tol)?;
|
||||
self.paint_if_selection_changed(before, r)
|
||||
}
|
||||
PointerKind::ContextClick => self.context_click(p, tol),
|
||||
PointerKind::TripleClick => {
|
||||
if self.active().is_some_and(|d| d.selection.text.is_some()) {
|
||||
self.execute("text.selectParagraph", &json!({}))
|
||||
let before = self.text_selection();
|
||||
let r = self.execute("text.selectParagraph", &json!({}))?;
|
||||
self.paint_if_selection_changed(before, r)
|
||||
} else {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -479,6 +497,27 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format Painter applies once a text range is selected (drag, word or paragraph click).
|
||||
fn paint_text_selection(&mut self) -> Result<Option<Value>> {
|
||||
if self.painter.is_some() && self.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) {
|
||||
return self.execute("format.painterApply", &json!({})).map(Some);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Where the text selection is: its shape, cell or notes, anchor and caret.
|
||||
fn text_selection(&self) -> Option<TextTarget> {
|
||||
self.active().and_then(|d| d.selection.text.as_ref()).map(|t| (t.shape, t.cell, t.notes, t.anchor, t.caret))
|
||||
}
|
||||
|
||||
/// A double or triple click paints only the range it selected, not an older one it left alone.
|
||||
fn paint_if_selection_changed(&mut self, before: Option<TextTarget>, r: Value) -> Result<Value> {
|
||||
if self.text_selection() == before {
|
||||
return Ok(r);
|
||||
}
|
||||
self.paint_text_selection().map(|a| a.unwrap_or(r))
|
||||
}
|
||||
|
||||
pub(crate) fn begin(&mut self, label: &str) {
|
||||
if let Some(st) = self.active_mut()
|
||||
&& st.interaction.is_none()
|
||||
@@ -545,8 +584,9 @@ impl Session {
|
||||
ToolKind::Select => {}
|
||||
}
|
||||
// Clicking inside the text being edited moves the caret / starts a text selection.
|
||||
let before = self.text_selection();
|
||||
if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? {
|
||||
self.tool.drag = Some(Drag::TextSelect { shape });
|
||||
self.tool.drag = Some(Drag::TextSelect { shape, before });
|
||||
return Ok(json!({"caret": [pos.0, pos.1]}));
|
||||
}
|
||||
let hit = self.hit_test(p, tol);
|
||||
@@ -902,7 +942,7 @@ impl Session {
|
||||
};
|
||||
self.tool.preview = Some(x);
|
||||
}
|
||||
Drag::TextSelect { shape } => {
|
||||
Drag::TextSelect { shape, .. } => {
|
||||
// The selection stays in the shape or table cell where it started.
|
||||
let st = self.doc()?;
|
||||
if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) {
|
||||
@@ -1097,6 +1137,10 @@ impl Session {
|
||||
let _ = cmd::text::fit_text_box(self, id);
|
||||
self.refit_after_resize(id)?;
|
||||
}
|
||||
Some(Drag::TextSelect { before, .. }) => {
|
||||
out = self.paint_if_selection_changed(before, out)?;
|
||||
self.commit();
|
||||
}
|
||||
Some(_) => self.commit(),
|
||||
None => {}
|
||||
}
|
||||
@@ -1228,7 +1272,10 @@ impl Session {
|
||||
None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside),
|
||||
Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p),
|
||||
};
|
||||
// A shape in front of the edited text takes the click, as in PowerPoint (#931).
|
||||
let in_front = shape_in_front(st, st.shapes(), t.shape, p, tol, 0);
|
||||
if !inside
|
||||
|| in_front
|
||||
|| matches!(
|
||||
self.hit_test(p, tol),
|
||||
Some(
|
||||
@@ -1439,6 +1486,13 @@ impl Session {
|
||||
/// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to
|
||||
/// apply and the guides to draw.
|
||||
fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec<Guide>) {
|
||||
self.snap_edges(r, exclude, tol, enabled, [true; 3], [true; 3])
|
||||
}
|
||||
|
||||
/// `snap_rect` looking only at some of `r`'s edges: `xs_on` is left, centre, right and `ys_on`
|
||||
/// top, centre, bottom. Resizing passes just the edges being dragged, so a fixed edge that
|
||||
/// already sits on a line (distance 0) doesn't win and leave the dragged edge unsnapped.
|
||||
fn snap_edges(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool, xs_on: [bool; 3], ys_on: [bool; 3]) -> (Vec2, Vec<Guide>) {
|
||||
let Some(st) = self.active() else { return (Vec2::ZERO, vec![]) };
|
||||
if !enabled || !self.prefs.smart_guides {
|
||||
return (Vec2::ZERO, vec![]);
|
||||
@@ -1476,7 +1530,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
let mut best_x: Option<(f64, f64, Rect)> = None;
|
||||
for edge in [r.x0, r.center().x, r.x1] {
|
||||
for edge in [r.x0, r.center().x, r.x1].into_iter().zip(xs_on).filter_map(|(e, on)| on.then_some(e)) {
|
||||
for (x, b) in &xs {
|
||||
let d = x - edge;
|
||||
if d.abs() <= tol && best_x.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) {
|
||||
@@ -1485,7 +1539,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
let mut best_y: Option<(f64, f64, Rect)> = None;
|
||||
for edge in [r.y0, r.center().y, r.y1] {
|
||||
for edge in [r.y0, r.center().y, r.y1].into_iter().zip(ys_on).filter_map(|(e, on)| on.then_some(e)) {
|
||||
for (y, b) in &ys {
|
||||
let d = y - edge;
|
||||
if d.abs() <= tol && best_y.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) {
|
||||
@@ -1499,32 +1553,49 @@ impl Session {
|
||||
off.x = d;
|
||||
let rr = r + Vec2::new(d, 0.0);
|
||||
guides.push(Guide { vertical: true, pos: x, from: b.y0.min(rr.y0), to: b.y1.max(rr.y1) });
|
||||
} else if self.prefs.snap_to_grid {
|
||||
off.x = deckcraft_geom::snap(r.x0, self.prefs.grid_spacing) - r.x0;
|
||||
} else if self.prefs.snap_to_grid && xs_on.contains(&true) {
|
||||
let at = if xs_on[0] {
|
||||
r.x0
|
||||
} else if xs_on[2] {
|
||||
r.x1
|
||||
} else {
|
||||
r.center().x
|
||||
};
|
||||
off.x = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at;
|
||||
}
|
||||
if let Some((d, y, b)) = best_y {
|
||||
off.y = d;
|
||||
let rr = r + Vec2::new(0.0, d);
|
||||
guides.push(Guide { vertical: false, pos: y, from: b.x0.min(rr.x0), to: b.x1.max(rr.x1) });
|
||||
} else if self.prefs.snap_to_grid {
|
||||
off.y = deckcraft_geom::snap(r.y0, self.prefs.grid_spacing) - r.y0;
|
||||
} else if self.prefs.snap_to_grid && ys_on.contains(&true) {
|
||||
let at = if ys_on[0] {
|
||||
r.y0
|
||||
} else if ys_on[2] {
|
||||
r.y1
|
||||
} else {
|
||||
r.center().y
|
||||
};
|
||||
off.y = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at;
|
||||
}
|
||||
(off, guides)
|
||||
}
|
||||
|
||||
/// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.
|
||||
fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
|
||||
if x.rot != 0.0 {
|
||||
self.tool.guides.clear();
|
||||
return x;
|
||||
}
|
||||
let r = x.rect();
|
||||
let (off, guides) = self.snap_rect(r, &[id], tol * 1.5, true);
|
||||
// Only the dragged edges move, so only they look for a line to snap to. The handles sit in
|
||||
// the box's unflipped frame: in a flipped box, also one that a drag past the opposite edge
|
||||
// just flipped, the dragged handle is on the other side.
|
||||
let (left, right) = (matches!(handle, 0 | 6 | 7), matches!(handle, 2..=4));
|
||||
let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6));
|
||||
let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) };
|
||||
let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) };
|
||||
let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
|
||||
let mut nx = x;
|
||||
// Only the dragged edges move.
|
||||
let moves_left = matches!(handle, 0 | 6 | 7);
|
||||
let moves_right = matches!(handle, 2..=4);
|
||||
let moves_top = matches!(handle, 0..=2);
|
||||
let moves_bottom = matches!(handle, 4..=6);
|
||||
if moves_left {
|
||||
nx.x += off.x;
|
||||
nx.w -= off.x;
|
||||
@@ -1650,6 +1721,27 @@ struct EditedBox {
|
||||
movable: bool,
|
||||
}
|
||||
|
||||
/// Whether a visible shape drawn in front of `target` is under `p`: the shapes above it in its
|
||||
/// list, topmost first, and inside a group that holds it, the members above it.
|
||||
fn shape_in_front(st: &crate::DocState, shapes: &[Shape], target: ShapeId, p: Point, tol: f64, depth: usize) -> bool {
|
||||
if depth > 64 {
|
||||
return false;
|
||||
}
|
||||
for s in shapes.iter().rev() {
|
||||
if s.id == target {
|
||||
return false;
|
||||
}
|
||||
if deckcraft_model::find_shape(std::slice::from_ref(s), target).is_some() {
|
||||
// The group holding the text: look among its members.
|
||||
return shape_in_front(st, s.children(), target, p, tol, depth + 1);
|
||||
}
|
||||
if !s.hidden && hit_shape(st, s, p, tol).is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn edited_box(st: &crate::DocState) -> Option<EditedBox> {
|
||||
let t = st.selection.text.as_ref()?;
|
||||
if t.notes || t.cell.is_some() {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Native saves must not discard an imported chart's opaque package parts.
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_engine::cmd::{base64_decode, base64_encode};
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, defaults};
|
||||
use serde_json::json;
|
||||
|
||||
const WORKBOOK: &[u8] = b"original-synthetic-workbook\0\xff\x80";
|
||||
const DRAWING: &[u8] = br#"<c:userShapes xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"/>"#;
|
||||
const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
|
||||
fn chart(p: &Presentation) -> &Chart {
|
||||
let ShapeKind::Chart(c) = &p.slides[0].shapes[0].kind else { panic!("chart") };
|
||||
c
|
||||
}
|
||||
|
||||
fn source() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id().expect("id"));
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape {
|
||||
id,
|
||||
name: "Owned chart".into(),
|
||||
xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)),
|
||||
kind: ShapeKind::Chart(Box::new(Chart::sample(ChartType::Pie))),
|
||||
..Default::default()
|
||||
}];
|
||||
// Start with our own chart XML, then give it two original, synthetic package parts.
|
||||
let mut p = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
|
||||
let ShapeKind::Chart(c) = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("chart") };
|
||||
c.raw = c
|
||||
.raw
|
||||
.as_ref()
|
||||
.map(|raw| raw.replace("</c:chartSpace>", r#"<c:externalData r:id="rWorkbook"/><c:userShapes r:id="rDrawing"/></c:chartSpace>"#));
|
||||
c.kept = ChartKept {
|
||||
rels: vec![
|
||||
ChartRel {
|
||||
id: "rWorkbook".into(),
|
||||
rel_type: format!("{RT}/package"),
|
||||
target: "../embeddings/owned.xlsx".into(),
|
||||
part: "ppt/embeddings/owned.xlsx".into(),
|
||||
external: false,
|
||||
},
|
||||
ChartRel {
|
||||
id: "rDrawing".into(),
|
||||
rel_type: format!("{RT}/chartUserShapes"),
|
||||
target: "../drawings/owned.xml".into(),
|
||||
part: "ppt/drawings/owned.xml".into(),
|
||||
external: false,
|
||||
},
|
||||
],
|
||||
parts: vec![
|
||||
ChartPart {
|
||||
name: "ppt/embeddings/owned.xlsx".into(),
|
||||
content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(),
|
||||
bytes: WORKBOOK.to_vec(),
|
||||
native_entry: None,
|
||||
},
|
||||
ChartPart {
|
||||
name: "ppt/drawings/owned.xml".into(),
|
||||
content_type: "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml".into(),
|
||||
bytes: DRAWING.to_vec(),
|
||||
native_entry: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
deckcraft_pptx::export(&p).expect("PPTX fixture")
|
||||
}
|
||||
|
||||
fn open(bytes: &[u8], name: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.openBytes", &json!({"name":name,"data":base64_encode(bytes)})).expect("open command");
|
||||
s
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, format: &str) -> Vec<u8> {
|
||||
let out = s.execute("file.saveBytes", &json!({"format":format})).expect("save command");
|
||||
base64_decode(out["data"].as_str().expect("data")).expect("base64")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pptx_native_edit_save_reopen_keeps_chart_parts() {
|
||||
let mut s = open(&source(), "owned.pptx");
|
||||
let kept = chart(&s.doc().expect("doc").doc).kept.clone();
|
||||
assert_eq!(kept.rels.len(), 2, "fixture relationships must have been imported");
|
||||
assert_eq!(kept.parts.len(), 2);
|
||||
s.execute("file.properties", &json!({"title":"Edited title"})).expect("edit command");
|
||||
for _ in 0..2 {
|
||||
s = open(&save(&mut s, "deckcraft"), "owned.deckcraft");
|
||||
let p = &s.doc().expect("doc").doc;
|
||||
assert_eq!(p.props.title, "Edited title");
|
||||
assert_eq!(chart(p).kept, kept);
|
||||
}
|
||||
let bytes = save(&mut s, "pptx");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).expect("zip");
|
||||
let mut payloads = vec![];
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut data = vec![];
|
||||
f.read_to_end(&mut data).expect("read");
|
||||
payloads.push((f.name().to_string(), data));
|
||||
}
|
||||
assert!(payloads.iter().any(|(n, d)| n.starts_with("ppt/embeddings/") && d == WORKBOOK));
|
||||
assert!(payloads.iter().any(|(n, d)| n.starts_with("ppt/drawings/") && d == DRAWING));
|
||||
let back = deckcraft_pptx::import(&bytes).expect("reopen PPTX");
|
||||
assert_eq!(chart(&back).kept.rels.len(), 2);
|
||||
for part in &kept.parts {
|
||||
assert!(chart(&back).kept.parts.iter().any(|p| p.content_type == part.content_type && p.bytes == part.bytes));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_chart_after_native_reopen_does_not_export_a_stale_workbook() {
|
||||
let mut s = open(&source(), "owned.pptx");
|
||||
s = open(&save(&mut s, "deckcraft"), "owned.deckcraft");
|
||||
let doc = s.doc_mut().expect("doc");
|
||||
let p = Arc::make_mut(&mut doc.doc);
|
||||
let ShapeKind::Chart(c) = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("chart") };
|
||||
assert_eq!(c.kept.rels.len(), 2);
|
||||
c.series[0].values[0] = Some(99.0);
|
||||
let bytes = save(&mut s, "pptx");
|
||||
let back = deckcraft_pptx::import(&bytes).expect("PPTX");
|
||||
assert_eq!(chart(&back).series[0].values[0], Some(99.0));
|
||||
assert!(chart(&back).kept.parts.is_empty(), "stale source workbook must not be attached to edited chart data");
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
mod chart_native;
|
||||
mod show_clicks;
|
||||
@@ -59,6 +59,34 @@ fn start(p: &mut Presentation) {
|
||||
p.slides.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_trigger_reveals_then_hides_on_separate_clicks() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
start(&mut p);
|
||||
let button = shape(&mut p, "Button", "Toggle", 20.0, 20.0, 100.0, 40.0);
|
||||
let target = shape(&mut p, "Target", "Answer", 200.0, 100.0, 200.0, 80.0);
|
||||
let animations = vec![
|
||||
Animation {
|
||||
shape: target.id,
|
||||
trigger: Some(button.id),
|
||||
effect: "appear".into(),
|
||||
start: AnimStart::OnClick,
|
||||
duration_ms: 0,
|
||||
..Default::default()
|
||||
},
|
||||
disappear_on_click(button.id, target.id),
|
||||
];
|
||||
add_slide(&mut p, vec![button, target], animations, false);
|
||||
let mut show = Show::open(&p);
|
||||
assert!(!show.shown("Target"));
|
||||
for _ in 0..2 {
|
||||
assert_eq!(show.click("Button"), Click::Animated);
|
||||
assert!(show.shown("Target"), "first click reveals the answer until the next click");
|
||||
assert_eq!(show.click("Button"), Click::Animated);
|
||||
assert!(!show.shown("Target"), "second click hides the answer");
|
||||
}
|
||||
}
|
||||
|
||||
/// The slide show window's click handling, on a deck read from a PPTX file.
|
||||
struct Show {
|
||||
pres: Presentation,
|
||||
+28
-27
@@ -161,12 +161,12 @@ pub struct FaceRef(&'static FontFace);
|
||||
impl FaceRef {
|
||||
/// The handle for a loaded face (memoised per face).
|
||||
pub fn of(face: &Arc<FontFace>) -> FaceRef {
|
||||
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
|
||||
static LEAKED: RwLock<Vec<Option<&FontFace>>> = RwLock::new(Vec::new());
|
||||
let id = face.id as usize;
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(std::sync::PoisonError::into_inner).get(id) {
|
||||
return FaceRef(f);
|
||||
}
|
||||
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut w = LEAKED.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if w.len() <= id {
|
||||
w.resize(id + 1, None);
|
||||
}
|
||||
@@ -743,7 +743,7 @@ pub fn base_style(style: &str) -> &str {
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// Weight implied by a style name.
|
||||
@@ -807,7 +807,7 @@ impl FontDb {
|
||||
pub fn set_system_fallback(&self, on: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
|
||||
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled = on;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = on;
|
||||
@@ -822,14 +822,14 @@ impl FontDb {
|
||||
}
|
||||
|
||||
pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
self.faces.read().unwrap_or_else(|e| e.into_inner())
|
||||
self.faces.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// The system font catalog, scanned first if it hasn't been yet.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec<CatalogEntry>> {
|
||||
self.ensure_catalog();
|
||||
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
||||
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated.
|
||||
@@ -885,7 +885,7 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut faces = self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut added = 0;
|
||||
for f in new {
|
||||
// Checked again under the lock: another thread may have added the same face.
|
||||
@@ -923,7 +923,7 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_system_fonts(&self) -> usize {
|
||||
// Families load again from the new catalog when next looked up.
|
||||
self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
|
||||
// The first scan, or a rescan once it is done (never both at once).
|
||||
let mut first = None;
|
||||
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
|
||||
@@ -959,7 +959,7 @@ impl FontDb {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
@@ -975,7 +975,7 @@ impl FontDb {
|
||||
}
|
||||
let n = found.len();
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
|
||||
*self.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner) = found;
|
||||
n
|
||||
}
|
||||
|
||||
@@ -983,7 +983,7 @@ impl FontDb {
|
||||
/// it is loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_cataloged(&self, family: &str) -> bool {
|
||||
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
self.load_family(family, &loading)
|
||||
}
|
||||
|
||||
@@ -1047,7 +1047,7 @@ impl FontDb {
|
||||
// them rather than finding none and falling back to another family.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut loaded = self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let key = family.to_lowercase();
|
||||
if !loaded.contains(&key) {
|
||||
self.load_cataloged(family);
|
||||
@@ -1112,7 +1112,7 @@ impl FontDb {
|
||||
}
|
||||
let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
|
||||
let f = Arc::new(f);
|
||||
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone());
|
||||
self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(f.clone());
|
||||
Some(f)
|
||||
}
|
||||
|
||||
@@ -1214,20 +1214,20 @@ impl FontDb {
|
||||
}
|
||||
let seen = self.read_faces().len();
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !sys.enabled || sys.misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f));
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Another thread may have loaded a face for `c` (faces are only ever added), or found
|
||||
// there is none, while this one waited.
|
||||
if self.read_faces().len() != seen && covered(self) {
|
||||
return true;
|
||||
}
|
||||
if self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.contains(&c) {
|
||||
if self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
for fam in SYSTEM_FALLBACKS {
|
||||
@@ -1238,7 +1238,8 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
|
||||
let mut paths: Vec<std::path::PathBuf> =
|
||||
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
@@ -1253,7 +1254,7 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
if remember_miss {
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.insert(c);
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -1326,10 +1327,10 @@ impl FontDb {
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
|
||||
if !self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled {
|
||||
return false;
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
@@ -1347,7 +1348,7 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1359,12 +1360,12 @@ impl FontDb {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !sys.enabled {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
@@ -1382,14 +1383,14 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
|
||||
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(|e| e.into_inner()).get(&key) {
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
return p.clone();
|
||||
}
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
@@ -1399,7 +1400,7 @@ impl FontDb {
|
||||
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(|e| e.into_inner());
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Keep opaque chart payloads out of the JSON manifest and within the media load budget.
|
||||
use std::collections::HashSet;
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::chart::ChartPart;
|
||||
use deckcraft_model::{Master, Presentation, Shape, ShapeKind};
|
||||
|
||||
use crate::{FormatError, MAX_ENTRY, Result, read_entry_max};
|
||||
|
||||
const PREFIX: &str = "chart-parts/";
|
||||
type Payload = (String, String, Vec<u8>);
|
||||
|
||||
pub(super) fn separate(p: &mut Presentation) -> Result<Vec<Payload>> {
|
||||
let mut payloads = Vec::new();
|
||||
visit_parts(p, |part| {
|
||||
// The native namespace is independent of the original OPC names, which may repeat
|
||||
// when charts from several decks are combined. No user-supplied path is extracted.
|
||||
let entry = format!("{PREFIX}{}", payloads.len());
|
||||
part.native_entry = Some(entry.clone());
|
||||
payloads.push((entry, part.content_type.clone(), std::mem::take(&mut part.bytes)));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(payloads)
|
||||
}
|
||||
|
||||
pub(super) fn restore(
|
||||
p: &mut Presentation,
|
||||
z: &mut zip::ZipArchive<Cursor<&[u8]>>,
|
||||
left: &mut u64,
|
||||
mut items: usize,
|
||||
max_items: usize,
|
||||
damaged: &mut Vec<String>,
|
||||
) -> Result<()> {
|
||||
let mut seen = HashSet::new();
|
||||
visit_parts(p, |part| {
|
||||
if items >= max_items {
|
||||
return Err(FormatError::Damaged(format!("media and chart parts exceed {max_items} items")));
|
||||
}
|
||||
items += 1;
|
||||
if let Some(entry) = part.native_entry.take() {
|
||||
// Accept only names produced by separate(), and inflate each member once.
|
||||
let canonical = entry.strip_prefix(PREFIX).and_then(|s| s.parse::<usize>().ok()).is_some_and(|n| entry == format!("{PREFIX}{n}"));
|
||||
if !canonical || !seen.insert(entry.clone()) || !part.bytes.is_empty() {
|
||||
return Err(FormatError::Damaged("invalid or ambiguous chart part reference".into()));
|
||||
}
|
||||
if z.index_for_name(&entry).is_none() {
|
||||
// Missing members consume no inflation work and can be reported for recovery.
|
||||
damaged.push(format!("{entry}: missing chart part"));
|
||||
return Ok(());
|
||||
}
|
||||
if z.by_name(&entry).is_ok_and(|f| f.size() > *left) {
|
||||
return Err(FormatError::Damaged("embedded media and chart parts are larger than DeckCraft can open".into()));
|
||||
}
|
||||
// A CRC/decode error may already have inflated bytes. Stop on that first error
|
||||
// rather than letting later corrupt members reuse the unchanged byte budget.
|
||||
let bytes = read_entry_max(z, &entry, (*left).min(MAX_ENTRY))?;
|
||||
*left -= bytes.len() as u64;
|
||||
part.bytes = bytes;
|
||||
} else {
|
||||
// Older manifests used JSON byte arrays. Charge those too, after the existing
|
||||
// bounded manifest parser has read them, so resaving migrates them losslessly.
|
||||
let size = part.bytes.len() as u64;
|
||||
if size > (*left).min(MAX_ENTRY) {
|
||||
return Err(FormatError::Damaged("inline chart part is larger than DeckCraft can open".into()));
|
||||
}
|
||||
*left -= size;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_parts(p: &mut Presentation, mut visit: impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
|
||||
// Use an explicit stack for groups. Charts can also live on masters and their layouts,
|
||||
// including the notes and handout masters; none of these owners may leak inline bytes.
|
||||
for slide in &mut p.slides {
|
||||
shapes(&mut Arc::make_mut(slide).shapes, &mut visit)?;
|
||||
}
|
||||
for master in &mut p.masters {
|
||||
master_parts(Arc::make_mut(master), &mut visit)?;
|
||||
}
|
||||
for master in [&mut p.notes_master, &mut p.handout_master].into_iter().flatten() {
|
||||
master_parts(Arc::make_mut(master), &mut visit)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn master_parts(master: &mut Master, visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
|
||||
shapes(&mut master.shapes, visit)?;
|
||||
for layout in &mut master.layouts {
|
||||
shapes(&mut layout.shapes, visit)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shapes(root: &mut [Shape], visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
|
||||
let mut pending = vec![root];
|
||||
while let Some(shapes) = pending.pop() {
|
||||
for shape in shapes {
|
||||
match &mut shape.kind {
|
||||
ShapeKind::Group { children, .. } => pending.push(children),
|
||||
ShapeKind::Chart(chart) => {
|
||||
for part in &mut chart.kept.parts {
|
||||
visit(part)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::*;
|
||||
use deckcraft_model::chart::{Chart, ChartPart, ChartRel, ChartType};
|
||||
use deckcraft_model::{Master, Shape, ShapeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const PART: &str = "ppt/embeddings/owned.xlsx";
|
||||
|
||||
fn fixture(payload: Vec<u8>) -> Presentation {
|
||||
// Construct through the old manifest representation: this also exercises compatibility.
|
||||
let mut part: ChartPart = serde_json::from_value(json!({
|
||||
"name": PART, "contentType": "application/zip", "bytes": []
|
||||
}))
|
||||
.unwrap();
|
||||
part.bytes = payload;
|
||||
let mut chart = Chart::sample(ChartType::Pie);
|
||||
chart.kept.rels.push(ChartRel {
|
||||
id: "rWorkbook".into(),
|
||||
rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(),
|
||||
target: "../embeddings/owned.xlsx".into(),
|
||||
part: PART.into(),
|
||||
external: false,
|
||||
});
|
||||
chart.kept.parts.push(part);
|
||||
let mut p = Presentation::default();
|
||||
let child = Shape { id: ShapeId(p.alloc_id().unwrap()), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() };
|
||||
let group = Shape {
|
||||
id: ShapeId(p.alloc_id().unwrap()),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = vec![group];
|
||||
p
|
||||
}
|
||||
|
||||
fn part_json(value: &mut Value) -> Option<&mut Value> {
|
||||
if value.get("name").and_then(Value::as_str) == Some(PART) && value.get("contentType").is_some() {
|
||||
return Some(value);
|
||||
}
|
||||
match value {
|
||||
Value::Array(values) => values.iter_mut().find_map(part_json),
|
||||
Value::Object(values) => values.values_mut().find_map(part_json),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_manifest(bytes: &[u8], edit: impl FnOnce(&mut Value)) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut json: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
|
||||
edit(&mut json);
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let f = z.by_index(i).unwrap();
|
||||
if f.name() == "presentation.json" {
|
||||
out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&serde_json::to_vec(&json).unwrap()).unwrap();
|
||||
} else {
|
||||
out.raw_copy_file(f).unwrap();
|
||||
}
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_chart_payload_is_a_referenced_zip_entry() {
|
||||
let payload = vec![200; 3 * 1024 * 1024];
|
||||
let p = fixture(payload.clone());
|
||||
let bytes = save(&p).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
assert!(z.by_name("presentation.json").unwrap().size() < 128 * 1024, "binary chart bytes must not become JSON numbers");
|
||||
let mut manifest: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
|
||||
let part = part_json(&mut manifest).unwrap();
|
||||
assert!(part.get("bytes").is_none(), "new manifests keep only metadata and a reference");
|
||||
let entry = part["nativeEntry"].as_str().unwrap();
|
||||
assert!(entry.starts_with("chart-parts/"));
|
||||
assert_eq!(read_entry(&mut z, entry).unwrap(), payload);
|
||||
assert!(read_entry_max(&mut z, entry, payload.len() as u64 - 1).is_err());
|
||||
assert_eq!(load(&bytes).unwrap(), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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");
|
||||
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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_inline_chart_bytes_are_budgeted_and_preserved() {
|
||||
let mut p = fixture(vec![9; 600]);
|
||||
p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
|
||||
let legacy = rewrite_manifest(&save(&p).unwrap(), |manifest| {
|
||||
let part = part_json(manifest).unwrap().as_object_mut().unwrap();
|
||||
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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_payloads_in_every_shape_owner_get_distinct_entries() {
|
||||
let mut p = fixture(vec![42; 4096]);
|
||||
let shapes = p.slides[0].shapes.clone();
|
||||
let master = Arc::make_mut(&mut p.masters[0]);
|
||||
master.shapes = shapes.clone();
|
||||
master.layouts[0].shapes = shapes.clone();
|
||||
p.notes_master = Some(Arc::new(Master { shapes: shapes.clone(), ..Default::default() }));
|
||||
p.handout_master = Some(Arc::new(Master { shapes, ..Default::default() }));
|
||||
let bytes = save(&p).unwrap();
|
||||
let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
assert_eq!(z.file_names().filter(|name| name.starts_with("chart-parts/")).count(), 5);
|
||||
assert_eq!(load(&bytes).unwrap(), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_corrupt_chart_payload_is_reported() {
|
||||
let p = fixture(b"SYNTHETIC-CHART-PAYLOAD-0123456789".to_vec());
|
||||
let bytes = save(&p).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
let mut json: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
|
||||
let name = part_json(&mut json).unwrap()["nativeEntry"].as_str().unwrap().to_owned();
|
||||
let start = z.by_name(&name).unwrap().data_start() as usize;
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let f = z.by_index(i).unwrap();
|
||||
if f.name() != name {
|
||||
out.raw_copy_file(f).unwrap();
|
||||
}
|
||||
}
|
||||
let missing = out.finish().unwrap().into_inner();
|
||||
let (q, damaged) = load_checked(&missing).unwrap();
|
||||
assert_eq!(damaged.len(), 1, "{damaged:?}");
|
||||
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
|
||||
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
|
||||
assert!(chart.kept.parts[0].bytes.is_empty());
|
||||
let mut corrupt = bytes.clone();
|
||||
corrupt[start] ^= 0xff;
|
||||
assert!(load_checked(&corrupt).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_references_cannot_alias_other_members_or_mix_inline_bytes() {
|
||||
let p = fixture(vec![3; 10]);
|
||||
let bytes = save(&p).unwrap();
|
||||
for entry in ["media/1", "chart-parts/../0", "chart-parts/00", "chart-parts/-1"] {
|
||||
let changed = rewrite_manifest(&bytes, |manifest| {
|
||||
part_json(manifest).unwrap()["nativeEntry"] = json!(entry);
|
||||
});
|
||||
assert!(load(&changed).is_err(), "accepted {entry}");
|
||||
}
|
||||
let ambiguous = rewrite_manifest(&bytes, |manifest| {
|
||||
part_json(manifest).unwrap()["bytes"] = json!([3]);
|
||||
});
|
||||
assert!(load(&ambiguous).is_err());
|
||||
|
||||
let mut p = p;
|
||||
let ShapeKind::Group { children, .. } = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("group") };
|
||||
let ShapeKind::Chart(chart) = &mut children[0].kind else { panic!("chart") };
|
||||
chart.kept.parts.push(chart.kept.parts[0].clone());
|
||||
let aliased = rewrite_manifest(&save(&p).unwrap(), |manifest| {
|
||||
fn alias(value: &mut Value) {
|
||||
if let Some(entry) = value.get_mut("nativeEntry") {
|
||||
*entry = json!("chart-parts/0");
|
||||
}
|
||||
match value {
|
||||
Value::Array(values) => values.iter_mut().for_each(alias),
|
||||
Value::Object(values) => values.values_mut().for_each(alias),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
alias(manifest);
|
||||
});
|
||||
assert!(load(&aliased).is_err(), "one ZIP member must not be inflated repeatedly");
|
||||
}
|
||||
|
||||
fn with_two_parts() -> Presentation {
|
||||
let mut p = fixture(vec![3; 10]);
|
||||
let ShapeKind::Group { children, .. } = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("group") };
|
||||
let ShapeKind::Chart(chart) = &mut children[0].kind else { panic!("chart") };
|
||||
let mut second = chart.kept.parts[0].clone();
|
||||
second.bytes = vec![4; 10];
|
||||
chart.kept.parts.push(second);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() {
|
||||
let mut bytes = save(&with_two_parts()).unwrap();
|
||||
let offsets: Vec<_> = {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
(0..2).map(|i| z.by_name(&format!("chart-parts/{i}")).unwrap().data_start() as usize).collect()
|
||||
};
|
||||
for offset in offsets {
|
||||
bytes[offset] ^= 0xff;
|
||||
}
|
||||
// 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 });
|
||||
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"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_physical_zip_names_are_indexed_and_inflated_once() {
|
||||
let mut bytes = save(&with_two_parts()).unwrap();
|
||||
// ZipWriter refuses duplicate names. Forge equal-length local/central-directory names
|
||||
// instead, to exercise ZipArchive's duplicate-name indexing on an untrusted archive.
|
||||
let original = b"chart-parts/1";
|
||||
let mut replaced = 0;
|
||||
for offset in 0..=bytes.len() - original.len() {
|
||||
if &bytes[offset..offset + original.len()] == original {
|
||||
bytes[offset + original.len() - 1] = b'0';
|
||||
replaced += 1;
|
||||
}
|
||||
}
|
||||
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();
|
||||
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") };
|
||||
assert_eq!(chart.kept.parts[0].bytes, vec![4; 10]);
|
||||
assert!(chart.kept.parts[1].bytes.is_empty());
|
||||
}
|
||||
+289
-19
@@ -1,16 +1,20 @@
|
||||
//! The native `.deckcraft` format: a zip holding `presentation.json` (the model, minus media
|
||||
//! bytes), `media/<id>` files with the embedded media, and `mimetype`. Readers ignore unknown
|
||||
//! bytes), `media/<id>` and `chart-parts/<index>` binary entries, and `mimetype`. Readers ignore unknown
|
||||
//! fields, so newer files open in older builds with what those builds understand.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod chart_parts;
|
||||
mod finite;
|
||||
|
||||
#[cfg(test)]
|
||||
mod chart_parts_tests;
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Paragraph, TextBody};
|
||||
use deckcraft_model::{LayoutType, Presentation, defaults};
|
||||
use deckcraft_model::{LayoutType, Presentation, ShapeKind, defaults};
|
||||
|
||||
pub const MIMETYPE: &str = "application/vnd.storyteller.deckcraft+zip";
|
||||
/// Files written before the SlideCraft → DeckCraft rename (`.slidecraft`) are still read.
|
||||
@@ -20,9 +24,9 @@ pub const EXTENSION: &str = "deckcraft";
|
||||
pub const VERSION: u32 = 1;
|
||||
/// Largest entry we will inflate (guards against zip bombs).
|
||||
const MAX_ENTRY: u64 = 2 << 30;
|
||||
/// All embedded media together, inflated (#393).
|
||||
/// All embedded media and chart sidecars together, inflated (#393).
|
||||
const MAX_TOTAL_MEDIA: u64 = 4 << 30;
|
||||
/// Media items one file may list.
|
||||
/// Media items and chart sidecar parts one file may list.
|
||||
const MAX_MEDIA_ITEMS: usize = 100_000;
|
||||
|
||||
/// What one load may inflate.
|
||||
@@ -62,12 +66,10 @@ pub fn save(p: &Presentation) -> Result<Vec<u8>> {
|
||||
let w = |e: zip::result::ZipError| FormatError::Write(e.to_string());
|
||||
z.start_file("mimetype", stored).map_err(w)?;
|
||||
z.write_all(MIMETYPE.as_bytes()).map_err(|e| FormatError::Write(e.to_string()))?;
|
||||
let m = Manifest {
|
||||
format: "deckcraft".into(),
|
||||
version: VERSION,
|
||||
generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")),
|
||||
presentation: p.clone(),
|
||||
};
|
||||
let mut presentation = p.clone();
|
||||
let parts = chart_parts::separate(&mut presentation)?;
|
||||
let m =
|
||||
Manifest { format: "deckcraft".into(), version: VERSION, generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")), presentation };
|
||||
let json = serde_json::to_vec(&finite::Finite(&m)).map_err(|e| FormatError::Write(e.to_string()))?;
|
||||
z.start_file("presentation.json", deflate).map_err(w)?;
|
||||
z.write_all(&json).map_err(|e| FormatError::Write(e.to_string()))?;
|
||||
@@ -80,6 +82,11 @@ pub fn save(p: &Presentation) -> Result<Vec<u8>> {
|
||||
z.start_file(format!("media/{}", item.id.0), opts).map_err(w)?;
|
||||
z.write_all(&item.data).map_err(|e| FormatError::Write(e.to_string()))?;
|
||||
}
|
||||
for (name, content_type, bytes) in parts {
|
||||
let opts = if is_compressed(&content_type) { stored } else { deflate };
|
||||
z.start_file(name, opts).map_err(w)?;
|
||||
z.write_all(&bytes).map_err(|e| FormatError::Write(e.to_string()))?;
|
||||
}
|
||||
z.finish().map_err(w)?;
|
||||
}
|
||||
Ok(buf.into_inner())
|
||||
@@ -119,14 +126,15 @@ fn read_entry_max(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read a presentation. Embedded media that is missing or damaged is left empty; use
|
||||
/// [`load_checked`] to learn which.
|
||||
/// Read a presentation. Missing media/chart parts are left empty; use [`load_checked`] to
|
||||
/// learn which. A damaged chart member refuses the file before further chart inflation.
|
||||
pub fn load(bytes: &[u8]) -> Result<Presentation> {
|
||||
load_checked(bytes).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Read a presentation and list the embedded media that couldn't be read (missing, too large
|
||||
/// or failing its CRC), one message each. Linked media saved without its bytes isn't damage.
|
||||
/// Read a presentation and list media that couldn't be read, plus missing chart members.
|
||||
/// 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 })
|
||||
}
|
||||
@@ -140,7 +148,8 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String
|
||||
}
|
||||
let mut p = m.presentation;
|
||||
let mut damaged = vec![];
|
||||
if p.media.len() > budget.items {
|
||||
let items = p.media.len();
|
||||
if items > budget.items {
|
||||
return Err(FormatError::Damaged(format!("{} media items (at most {})", p.media.len(), budget.items)));
|
||||
}
|
||||
// A repeated id names the same member: keep the first item, so the member is inflated once
|
||||
@@ -163,6 +172,7 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String
|
||||
Err(e) => damaged.push(e.to_string()),
|
||||
}
|
||||
}
|
||||
chart_parts::restore(&mut p, &mut z, &mut left, items, budget.items, &mut damaged)?;
|
||||
repair(&mut p);
|
||||
Ok((p, damaged))
|
||||
}
|
||||
@@ -197,6 +207,7 @@ pub fn repair(p: &mut Presentation) {
|
||||
}
|
||||
}
|
||||
}
|
||||
reid_duplicate_shapes(p);
|
||||
p.slide_size = sane_slide_size(p.slide_size);
|
||||
// The notes page only lays out notes, but its size divides too.
|
||||
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) {
|
||||
@@ -205,6 +216,57 @@ pub fn repair(p: &mut Presentation) {
|
||||
sane_numbers(p);
|
||||
}
|
||||
|
||||
/// Give a shape that repeats an id already used on its slide, layout or master (groups included)
|
||||
/// a fresh one, so edits, selection and the Selection Pane find one shape per id. The first keeps
|
||||
/// its id, and with it what animations and connectors point at. An exhausted id space leaves the
|
||||
/// rest as they are (#407).
|
||||
fn reid_duplicate_shapes(p: &mut Presentation) {
|
||||
fn has_duplicates(shapes: &[deckcraft_model::Shape]) -> bool {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut dup = false;
|
||||
deckcraft_model::walk(shapes, &mut |sh, _| dup |= !seen.insert(sh.id));
|
||||
dup
|
||||
}
|
||||
fn reid(shapes: &mut [deckcraft_model::Shape], seen: &mut std::collections::HashSet<deckcraft_model::ShapeId>, next: &mut u32, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
if !seen.insert(sh.id)
|
||||
&& let Some(id) = (*next).max(1).checked_add(1)
|
||||
{
|
||||
*next = id;
|
||||
sh.id = deckcraft_model::ShapeId(id);
|
||||
seen.insert(sh.id);
|
||||
}
|
||||
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind {
|
||||
reid(children, seen, next, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut next = p.next_id;
|
||||
let mut fix = |shapes: &mut Vec<deckcraft_model::Shape>| {
|
||||
if has_duplicates(shapes) {
|
||||
reid(shapes, &mut std::collections::HashSet::new(), &mut next, 0);
|
||||
}
|
||||
};
|
||||
for s in &mut p.slides {
|
||||
if has_duplicates(&s.shapes) {
|
||||
fix(&mut Arc::make_mut(s).shapes);
|
||||
}
|
||||
}
|
||||
for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) {
|
||||
if has_duplicates(&m.shapes) || m.layouts.iter().any(|l| has_duplicates(&l.shapes)) {
|
||||
let m = Arc::make_mut(m);
|
||||
fix(&mut m.shapes);
|
||||
for l in &mut m.layouts {
|
||||
fix(&mut l.shapes);
|
||||
}
|
||||
}
|
||||
}
|
||||
p.next_id = next;
|
||||
}
|
||||
|
||||
/// Smallest and largest slide side, in points: PowerPoint's 1–56 in, as Design › Slide Size allows.
|
||||
pub const SLIDE_SIDE: std::ops::RangeInclusive<f64> = 72.0..=4032.0;
|
||||
|
||||
@@ -350,6 +412,66 @@ fn sane_xfrm(x: &mut deckcraft_model::Xfrm) {
|
||||
|
||||
fn sane_run(p: &mut deckcraft_model::text::RunProps) {
|
||||
p.size = p.size.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.clamp(1.0, 4000.0));
|
||||
for s in p.shadow.iter_mut().chain(p.inner_shadow.iter_mut()) {
|
||||
sane_shadow(s);
|
||||
}
|
||||
if let Some(g) = p.glow.as_mut() {
|
||||
g.radius = effect_size(g.radius, MAX_GLOW);
|
||||
}
|
||||
if let Some(r) = p.reflection.as_mut() {
|
||||
sane_reflection(r);
|
||||
}
|
||||
p.soft_edge = p.soft_edge.map(|v| effect_size(v, MAX_SOFT_EDGE));
|
||||
}
|
||||
|
||||
/// The largest effect sizes, in points: what Format Shape and `shape.effects` allow. A file can
|
||||
/// say anything, and the renderer's memory grows with these (a glow of 10^18 pt asked for 34 GB).
|
||||
const MAX_GLOW: f64 = 150.0;
|
||||
const MAX_SOFT_EDGE: f64 = 100.0;
|
||||
const MAX_SHADOW: f64 = 200.0;
|
||||
const MAX_REFLECTION: f64 = 100.0;
|
||||
|
||||
/// `v` within 0..=`max`; not a number is 0.
|
||||
fn effect_size(v: f64, max: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(0.0, max) } else { 0.0 }
|
||||
}
|
||||
|
||||
fn sane_shadow(s: &mut deckcraft_model::style::Shadow) {
|
||||
s.blur = effect_size(s.blur, MAX_SHADOW);
|
||||
s.dist = effect_size(s.dist, MAX_SHADOW);
|
||||
s.dir = if s.dir.is_finite() { s.dir.rem_euclid(360.0) } else { 0.0 };
|
||||
// Perspective scale (1 = 100%) and skew (degrees).
|
||||
for v in [&mut s.sx, &mut s.sy] {
|
||||
*v = if v.is_finite() { v.clamp(-10.0, 10.0) } else { 1.0 };
|
||||
}
|
||||
for v in [&mut s.kx, &mut s.ky] {
|
||||
*v = if v.is_finite() { v.clamp(-89.0, 89.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// Run properties a list style hands down to the text that inherits it.
|
||||
fn sane_list_style(ls: &mut deckcraft_model::text::ListStyle) {
|
||||
for level in ls.levels.iter_mut().flatten() {
|
||||
sane_run(&mut level.run);
|
||||
}
|
||||
}
|
||||
|
||||
fn sane_effects(e: &mut deckcraft_model::Effects) {
|
||||
for s in e.outer_shadow.iter_mut().chain(e.inner_shadow.iter_mut()) {
|
||||
sane_shadow(s);
|
||||
}
|
||||
if let Some(g) = e.glow.as_mut() {
|
||||
g.radius = effect_size(g.radius, MAX_GLOW);
|
||||
}
|
||||
e.soft_edge = e.soft_edge.map(|v| effect_size(v, MAX_SOFT_EDGE));
|
||||
if let Some(r) = e.reflection.as_mut() {
|
||||
sane_reflection(r);
|
||||
}
|
||||
}
|
||||
|
||||
fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
|
||||
@@ -363,7 +485,11 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
|
||||
if let Some(l) = sh.line.as_mut() {
|
||||
l.width = l.width.filter(|v| v.is_finite() && *v >= 0.0).map(|v| v.min(1584.0));
|
||||
}
|
||||
if let Some(e) = sh.effects.as_mut() {
|
||||
sane_effects(e);
|
||||
}
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
sane_list_style(&mut t.list_style);
|
||||
for para in &mut t.paragraphs {
|
||||
sane_run(&mut para.end_props);
|
||||
for r in &mut para.runs {
|
||||
@@ -372,8 +498,9 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
|
||||
}
|
||||
}
|
||||
match &mut sh.kind {
|
||||
deckcraft_model::ShapeKind::Picture { fill } => {
|
||||
for c in &mut fill.crop {
|
||||
deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop, .. } }
|
||||
| deckcraft_model::ShapeKind::Media(deckcraft_model::MediaClip { crop, .. }) => {
|
||||
for c in crop {
|
||||
*c = if c.is_finite() { c.clamp(-10.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
@@ -381,6 +508,17 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
|
||||
sane_xfrm(child);
|
||||
sane_shapes(children, depth + 1);
|
||||
}
|
||||
deckcraft_model::ShapeKind::Table(t) => {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
sane_list_style(&mut cell.text.list_style);
|
||||
}
|
||||
for para in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()).flat_map(|c| c.text.paragraphs.iter_mut()) {
|
||||
sane_run(&mut para.end_props);
|
||||
for r in &mut para.runs {
|
||||
sane_run(&mut r.props);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -395,12 +533,21 @@ pub fn sane_numbers(p: &mut Presentation) {
|
||||
*slide = Arc::new(fixed);
|
||||
}
|
||||
}
|
||||
// What text and shapes inherit: the deck's default text style, each master's title, body and
|
||||
// other styles, and the theme's effect styles that shape styles point at.
|
||||
sane_list_style(&mut p.default_text_style);
|
||||
for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) {
|
||||
let mut fixed = (**m).clone();
|
||||
sane_shapes(&mut fixed.shapes, 0);
|
||||
for l in &mut fixed.layouts {
|
||||
sane_shapes(&mut l.shapes, 0);
|
||||
}
|
||||
for ls in [&mut fixed.title_style, &mut fixed.body_style, &mut fixed.other_style] {
|
||||
sane_list_style(ls);
|
||||
}
|
||||
for e in &mut fixed.theme.format.effects {
|
||||
sane_effects(e);
|
||||
}
|
||||
if fixed != **m {
|
||||
*m = Arc::new(fixed);
|
||||
}
|
||||
@@ -418,7 +565,12 @@ pub fn outline_to_slides(p: &mut Presentation, text: &str) -> usize {
|
||||
let tabs = raw.chars().take_while(|c| *c == '\t').count();
|
||||
let spaces = raw.chars().take_while(|c| *c == ' ').count() / 2;
|
||||
let level = tabs.max(spaces);
|
||||
let line = raw.trim().trim_start_matches(['-', '*', '•']).trim().to_string();
|
||||
let t = raw.trim();
|
||||
let line = match t.strip_prefix(['-', '*', '•']) {
|
||||
Some(rest) if rest.starts_with([' ', '\t']) => rest.trim(),
|
||||
_ => t,
|
||||
}
|
||||
.to_string();
|
||||
if level == 0 {
|
||||
slides.push((line, Vec::new()));
|
||||
} else if let Some((_, body)) = slides.last_mut() {
|
||||
@@ -487,7 +639,8 @@ pub fn markdown_to_slides(p: &mut Presentation, text: &str) -> usize {
|
||||
pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
let mut out = String::new();
|
||||
for s in &p.slides {
|
||||
out.push_str(&s.title());
|
||||
// One line per title: a newline would start another slide on re-import.
|
||||
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()) {
|
||||
@@ -505,6 +658,13 @@ pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
if let ShapeKind::Table(t) = &sh.kind {
|
||||
for row in &t.rows {
|
||||
let cells: Vec<String> = row.cells.iter().map(|c| c.text.text().replace(['\u{b}', '\n'], " ")).collect();
|
||||
out.push_str(&cells.join("\t"));
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -514,6 +674,54 @@ pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chart_sidecars_are_optional_for_older_native_files() {
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
let chart = Chart::sample(ChartType::Pie);
|
||||
let mut json = serde_json::to_value(&chart).expect("chart JSON");
|
||||
assert!(json.get("kept").is_none(), "empty sidecars should not enlarge ordinary files");
|
||||
assert_eq!(serde_json::from_value::<Chart>(json.clone()).expect("old chart without kept"), chart);
|
||||
json["kept"] = serde_json::json!({});
|
||||
assert_eq!(serde_json::from_value::<Chart>(json).expect("empty sidecar"), chart);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_save_keeps_chart_relationships_and_payloads() {
|
||||
use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
let kept = ChartKept {
|
||||
rels: vec![ChartRel {
|
||||
id: "rWorkbook".into(),
|
||||
rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(),
|
||||
target: "../embeddings/data.xlsx".into(),
|
||||
part: "ppt/embeddings/data.xlsx".into(),
|
||||
external: false,
|
||||
}],
|
||||
parts: vec![ChartPart {
|
||||
name: "ppt/embeddings/data.xlsx".into(),
|
||||
content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(),
|
||||
bytes: vec![0, 255, 128, 42],
|
||||
native_entry: None,
|
||||
}],
|
||||
};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let chart = Chart { kept: kept.clone(), ..Chart::sample(ChartType::Pie) };
|
||||
let s = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() };
|
||||
// Serialization must reach charts inside groups as well as top-level charts.
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape {
|
||||
id: ShapeId(p.alloc_id().expect("group id")),
|
||||
kind: ShapeKind::Group { children: vec![s], child: Default::default() },
|
||||
..Default::default()
|
||||
}];
|
||||
let mut q = load(&save(&p).expect("save")).expect("load");
|
||||
for _ in 0..2 {
|
||||
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
|
||||
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
|
||||
assert_eq!(chart.kept, kept);
|
||||
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();
|
||||
@@ -620,6 +828,18 @@ mod tests {
|
||||
assert!(load(&b).is_err());
|
||||
}
|
||||
|
||||
/// A video's crop is brought into the same range as a picture's (#972).
|
||||
#[test]
|
||||
fn a_video_crop_is_repaired_like_a_picture_crop() {
|
||||
use deckcraft_model::{MediaClip, Shape};
|
||||
let mut p = Presentation::default();
|
||||
let clip = MediaClip { video: true, crop: [f64::NAN, 0.25, -1e9, 0.0], ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(Shape { kind: ShapeKind::Media(clip), ..Default::default() });
|
||||
let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}"));
|
||||
let ShapeKind::Media(m) = &back.slides[0].shapes.last().unwrap().kind else { panic!("media") };
|
||||
assert_eq!(m.crop, [0.0, 0.25, -10.0, 0.0]);
|
||||
}
|
||||
|
||||
/// Non-finite numbers are written as `null` by serde_json; the file still opens, with each
|
||||
/// such number repaired (it failed as "invalid type: null, expected f64").
|
||||
#[test]
|
||||
@@ -811,6 +1031,27 @@ mod tests {
|
||||
assert!(o.contains("Intro\n\tPoint one\n\t\tSub point\nSecond"), "{o}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_keeps_leading_dash_text() {
|
||||
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
|
||||
let n = outline_to_slides(&mut p, "-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\n- Bullets\n\t- Point\n\t• Other");
|
||||
assert_eq!(n, 2);
|
||||
let o = slides_to_outline(&p);
|
||||
assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{o}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_export_keeps_multi_paragraph_title_on_one_line() {
|
||||
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
|
||||
assert_eq!(outline_to_slides(&mut p, "Intro\n\tPoint one\nSecond\n\tMore"), 2);
|
||||
let title = Arc::make_mut(&mut p.slides[0]).shapes.iter_mut().find(|s| s.ph_type().is_some_and(|k| k.is_title())).expect("title placeholder");
|
||||
title.text = Some(TextBody::from_text("Line one\nLine two"));
|
||||
let o = slides_to_outline(&p);
|
||||
assert_eq!(o, "Line one Line two\n\tPoint one\nSecond\n\tMore\n");
|
||||
let mut q = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
|
||||
assert_eq!(outline_to_slides(&mut q, &o), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_headings_and_list_items() {
|
||||
assert_eq!(md_heading("# Intro"), Some("Intro"));
|
||||
@@ -829,4 +1070,33 @@ mod tests {
|
||||
assert_eq!(md_list_item(not), None, "{not:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #814: slides_to_outline includes table cell text, which it used to skip.
|
||||
#[test]
|
||||
fn outline_includes_table_text() {
|
||||
use deckcraft_model::table::{Cell, Row, Table};
|
||||
use deckcraft_model::{Shape, ShapeKind, Xfrm};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
|
||||
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title()));
|
||||
let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() };
|
||||
body.text = Some(TextBody::from_text("Text box body"));
|
||||
s.shapes.push(body);
|
||||
|
||||
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
|
||||
for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] {
|
||||
let cells: Vec<Cell> = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect();
|
||||
table.rows.push(Row { height: 30.0, cells });
|
||||
}
|
||||
let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() };
|
||||
tbl.kind = ShapeKind::Table(table);
|
||||
s.shapes.push(tbl);
|
||||
p.slides = vec![std::sync::Arc::new(s)];
|
||||
|
||||
let out = slides_to_outline(&p);
|
||||
assert!(out.contains("Text box body"), "{out}");
|
||||
assert!(out.contains("Cell A1\tCell B1"), "{out}");
|
||||
assert!(out.contains("Cell A2\tCell B2"), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
+163
-6
@@ -33,7 +33,8 @@ pub struct SubPath {
|
||||
}
|
||||
|
||||
/// A draggable adjust handle (the yellow dot). `pos = origin + dir * value`; dragging projects
|
||||
/// the pointer onto `dir`.
|
||||
/// the pointer onto `dir`. An `angle` handle instead measures the pointer's angle about `origin`
|
||||
/// (1/60 000 degree, y down, 0° at +x).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Handle {
|
||||
pub index: usize,
|
||||
@@ -42,14 +43,29 @@ pub struct Handle {
|
||||
pub dir: Vec2,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
pub angle: bool,
|
||||
}
|
||||
|
||||
impl Handle {
|
||||
fn new(index: usize, origin: Point, dir: Vec2, value: f64, min: f64, max: f64) -> Self {
|
||||
Handle { index, pos: origin + dir * value, origin, dir, min, max }
|
||||
Handle { index, pos: origin + dir * value, origin, dir, min, max, angle: false }
|
||||
}
|
||||
/// An angle handle on the `rx`×`ry` ellipse around `center` at visual angle `value`.
|
||||
fn angle(index: usize, center: Point, rx: f64, ry: f64, value: f64) -> Self {
|
||||
let t = vis(value / 60000.0, rx, ry).to_radians();
|
||||
let pos = Point::new(center.x + rx * t.cos(), center.y + ry * t.sin());
|
||||
Handle { index, pos, origin: center, dir: Vec2::ZERO, min: 0.0, max: 21600000.0, angle: true }
|
||||
}
|
||||
/// The adjust value for a pointer at `p` (shape-local space).
|
||||
pub fn value_at(&self, p: Point) -> f64 {
|
||||
if self.angle {
|
||||
let d = p - self.origin;
|
||||
if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 {
|
||||
return self.min;
|
||||
}
|
||||
let v = (d.y.atan2(d.x).to_degrees().rem_euclid(360.0) * 60000.0).round();
|
||||
return if v >= self.max { self.min } else { v };
|
||||
}
|
||||
let l2 = self.dir.hypot2();
|
||||
if l2 < 1e-18 {
|
||||
return self.min;
|
||||
@@ -1082,7 +1098,15 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||
b.m(p0.x, p0.y).arc(rx, ry, s0, sweep);
|
||||
let pi = Point::new(w / 2.0 + irx * si.to_radians().cos(), h / 2.0 + iry * si.to_radians().sin());
|
||||
b.l(pi.x, pi.y).arc(irx, iry, si, isweep).z();
|
||||
simple(b.take(), full, w, h)
|
||||
let mut g = simple(b.take(), full, w, h);
|
||||
let c = Point::new(rx, ry);
|
||||
g.handles.push(Handle::angle(0, c, rx - t / 2.0, ry - t / 2.0, a(0)));
|
||||
// Thickness: slides inward from the outer edge along the end angle's ray.
|
||||
let pe = Point::new(rx + rx * e0.to_radians().cos(), ry + ry * e0.to_radians().sin());
|
||||
let u = (c - pe).normalize();
|
||||
let u = if u.x.is_finite() && u.y.is_finite() { u } else { Vec2::ZERO };
|
||||
g.handles.push(Handle::new(2, pe, u * (ss / 100000.0), a(2).clamp(0.0, 50000.0), 0.0, 50000.0));
|
||||
g
|
||||
}
|
||||
"pie" | "chord" | "arc" => {
|
||||
let (rx, ry) = (w / 2.0, h / 2.0);
|
||||
@@ -1095,7 +1119,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||
let p0 = Point::new(rx + rx * s.to_radians().cos(), ry + ry * s.to_radians().sin());
|
||||
b.m(p0.x, p0.y).arc(rx, ry, s, sw);
|
||||
let text = Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355);
|
||||
match name {
|
||||
let mut g = match name {
|
||||
"pie" => {
|
||||
b.l(rx, ry).z();
|
||||
simple(b.take(), text, w, h)
|
||||
@@ -1111,7 +1135,11 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||
f.close_path();
|
||||
Geometry { paths: vec![fill_only(f), stroke_only(open)], text_rect: text, handles: vec![], sites: mid_sites(w, h) }
|
||||
}
|
||||
}
|
||||
};
|
||||
let c = Point::new(rx, ry);
|
||||
g.handles.push(Handle::angle(0, c, rx, ry, a(0)));
|
||||
g.handles.push(Handle::angle(1, c, rx, ry, a(1)));
|
||||
g
|
||||
}
|
||||
"teardrop" => {
|
||||
let k = a(0).clamp(0.0, 200000.0) / 100000.0;
|
||||
@@ -1821,7 +1849,7 @@ fn ellipse_sites(w: f64, h: f64) -> Vec<Point> {
|
||||
|
||||
fn tip_handle(w: f64, h: f64, tip: Point) -> Handle {
|
||||
// A free 2-D handle is represented as index 0 with zero direction; callers move both values.
|
||||
Handle { index: 0, pos: tip, origin: Point::new(w / 2.0, h / 2.0), dir: Vec2::new(w / 1e5, h / 1e5), min: -1e6, max: 1e6 }
|
||||
Handle { index: 0, pos: tip, origin: Point::new(w / 2.0, h / 2.0), dir: Vec2::new(w / 1e5, h / 1e5), min: -1e6, max: 1e6, angle: false }
|
||||
}
|
||||
|
||||
fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry {
|
||||
@@ -2252,6 +2280,135 @@ mod tests {
|
||||
assert_eq!(h.value_at(Point::new(500.0, 0.0)), 50000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_circle_angle_handles_map_back() {
|
||||
// Start 0° sits at the right of the ellipse, end 270° at its top.
|
||||
for name in ["pie", "chord", "arc"] {
|
||||
let g = build(name, 200.0, 100.0, &[0.0, 16200000.0]).unwrap();
|
||||
assert_eq!(g.handles.iter().map(|h| h.index).collect::<Vec<_>>(), vec![0, 1], "{name}");
|
||||
let (s, e) = (&g.handles[0], &g.handles[1]);
|
||||
assert!((s.pos - Point::new(200.0, 50.0)).hypot() < 1e-6, "{name} {:?}", s.pos);
|
||||
assert!((e.pos - Point::new(100.0, 0.0)).hypot() < 1e-6, "{name} {:?}", e.pos);
|
||||
assert_eq!(s.value_at(s.pos), 0.0, "{name}");
|
||||
assert_eq!(e.value_at(e.pos), 16200000.0, "{name}");
|
||||
// Dragging to the bottom of the ellipse gives 90°.
|
||||
assert_eq!(s.value_at(Point::new(100.0, 100.0)), 5400000.0, "{name}");
|
||||
// Off-axis angles on a non-square ellipse: the visual 45° ray meets the 200×100
|
||||
// ellipse at offset (√2000, √2000) from the centre.
|
||||
let k = 2000f64.sqrt();
|
||||
let g = build(name, 200.0, 100.0, &[2700000.0, 8100000.0]).unwrap();
|
||||
let (s, e) = (&g.handles[0], &g.handles[1]);
|
||||
assert!((s.pos - Point::new(100.0 + k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", s.pos);
|
||||
assert!((e.pos - Point::new(100.0 - k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", e.pos);
|
||||
for (deg, h) in [(45.0, s), (135.0, e)] {
|
||||
let on = ((h.pos.x - 100.0) / 100.0).powi(2) + ((h.pos.y - 50.0) / 50.0).powi(2);
|
||||
assert!((on - 1.0).abs() < 1e-9, "{name} {deg}° off ellipse: {:?}", h.pos);
|
||||
assert!((h.value_at(h.pos) - deg * 60000.0).abs() <= 1.0, "{name} {deg}°: {}", h.value_at(h.pos));
|
||||
}
|
||||
}
|
||||
for deg in [45.0, 135.0, 200.0, 315.0] {
|
||||
let g = build("blockArc", 200.0, 100.0, &[deg * 60000.0, 0.0, 25000.0]).unwrap();
|
||||
let start = g.handles.iter().find(|h| h.index == 0).unwrap();
|
||||
// Thickness 25 → the handle runs on the 87.5×37.5 mid-ring ellipse.
|
||||
let on = ((start.pos.x - 100.0) / 87.5).powi(2) + ((start.pos.y - 50.0) / 37.5).powi(2);
|
||||
assert!((on - 1.0).abs() < 1e-9, "blockArc {deg}° off ring: {:?}", start.pos);
|
||||
let d = start.pos - Point::new(100.0, 50.0);
|
||||
assert!((d.y.atan2(d.x).to_degrees().rem_euclid(360.0) - deg).abs() < 1e-6, "blockArc {deg}°: {:?}", start.pos);
|
||||
assert!((start.value_at(start.pos) - deg * 60000.0).abs() <= 1.0, "blockArc {deg}°");
|
||||
}
|
||||
let g = build("blockArc", 200.0, 100.0, &[10800000.0, 0.0, 25000.0]).unwrap();
|
||||
let start = g.handles.iter().find(|h| h.index == 0).unwrap();
|
||||
assert_eq!(start.value_at(start.pos), 10800000.0);
|
||||
let thick = g.handles.iter().find(|h| h.index == 2).unwrap();
|
||||
assert!((thick.value_at(thick.pos) - 25000.0).abs() <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_with_adjust_values_expose_handles() {
|
||||
// Presets whose adjust values have no handle of their own yet.
|
||||
const NO_HANDLES: &[&str] = &[
|
||||
"curvedConnector3",
|
||||
"snip2SameRect",
|
||||
"snip2DiagRect",
|
||||
"snipRoundRect",
|
||||
"round1Rect",
|
||||
"round2SameRect",
|
||||
"round2DiagRect",
|
||||
"teardrop",
|
||||
"halfFrame",
|
||||
"corner",
|
||||
"diagStripe",
|
||||
"foldedCorner",
|
||||
"smileyFace",
|
||||
"moon",
|
||||
"gear6",
|
||||
"gear9",
|
||||
"bracketPair",
|
||||
"bracePair",
|
||||
"leftBracket",
|
||||
"rightBracket",
|
||||
"leftBrace",
|
||||
"rightBrace",
|
||||
"leftRightArrow",
|
||||
"upDownArrow",
|
||||
"quadArrow",
|
||||
"leftRightUpArrow",
|
||||
"bentArrow",
|
||||
"uturnArrow",
|
||||
"curvedRightArrow",
|
||||
"curvedLeftArrow",
|
||||
"curvedUpArrow",
|
||||
"curvedDownArrow",
|
||||
"leftUpArrow",
|
||||
"bentUpArrow",
|
||||
"circularArrow",
|
||||
"leftCircularArrow",
|
||||
"leftRightCircularArrow",
|
||||
"swooshArrow",
|
||||
"rightArrowCallout",
|
||||
"downArrowCallout",
|
||||
"leftArrowCallout",
|
||||
"upArrowCallout",
|
||||
"leftRightArrowCallout",
|
||||
"upDownArrowCallout",
|
||||
"quadArrowCallout",
|
||||
"mathPlus",
|
||||
"mathMinus",
|
||||
"mathMultiply",
|
||||
"mathDivide",
|
||||
"mathEqual",
|
||||
"mathNotEqual",
|
||||
"ribbon2",
|
||||
"ribbon",
|
||||
"ellipseRibbon2",
|
||||
"ellipseRibbon",
|
||||
"leftRightRibbon",
|
||||
"verticalScroll",
|
||||
"horizontalScroll",
|
||||
"wave",
|
||||
"doubleWave",
|
||||
"borderCallout1",
|
||||
"borderCallout2",
|
||||
"borderCallout3",
|
||||
"accentCallout1",
|
||||
"accentCallout2",
|
||||
"accentCallout3",
|
||||
"callout1",
|
||||
"callout2",
|
||||
"callout3",
|
||||
"accentBorderCallout1",
|
||||
"accentBorderCallout2",
|
||||
"accentBorderCallout3",
|
||||
];
|
||||
for p in CATALOG {
|
||||
if p.defaults.is_empty() || NO_HANDLES.contains(&p.name) {
|
||||
continue;
|
||||
}
|
||||
let g = build(p.name, 200.0, 100.0, &[]).unwrap();
|
||||
assert!(!g.handles.is_empty(), "{} has adjust values but no handles", p.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_area_and_ellipse_area() {
|
||||
let g = build("rect", 100.0, 50.0, &[]).unwrap();
|
||||
|
||||
@@ -10,5 +10,4 @@ rust-version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
deckcraft-bitstream = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
craft-media = { workspace = true, features = ["isobmff"] }
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
//! Low-level helpers: four-character codes, a bounds-checked big-endian cursor, box iteration
|
||||
//! over in-memory payloads, and a box builder for the muxers.
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use std::fmt;
|
||||
|
||||
/// A four-character code (box type, sample entry format, brand, handler type…).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)]
|
||||
pub struct FourCc(pub [u8; 4]);
|
||||
|
||||
impl FourCc {
|
||||
pub const fn new(b: &[u8; 4]) -> Self {
|
||||
FourCc(*b)
|
||||
}
|
||||
pub fn as_u32(self) -> u32 {
|
||||
u32::from_be_bytes(self.0)
|
||||
}
|
||||
/// Lossy string form; `©` (0xA9) is mapped to U+00A9, other non-printables to `?`.
|
||||
pub fn to_string_lossy(self) -> String {
|
||||
self.0
|
||||
.iter()
|
||||
.map(|&b| match b {
|
||||
0xA9 => '©',
|
||||
0x20..=0x7E => b as char,
|
||||
_ => '?',
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FourCc {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(&self.to_string_lossy())
|
||||
}
|
||||
}
|
||||
impl fmt::Debug for FourCc {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "FourCc({:?})", self.to_string_lossy())
|
||||
}
|
||||
}
|
||||
impl From<&[u8; 4]> for FourCc {
|
||||
fn from(b: &[u8; 4]) -> Self {
|
||||
FourCc(*b)
|
||||
}
|
||||
}
|
||||
impl PartialEq<[u8; 4]> for FourCc {
|
||||
fn eq(&self, other: &[u8; 4]) -> bool {
|
||||
&self.0 == other
|
||||
}
|
||||
}
|
||||
impl PartialEq<&[u8; 4]> for FourCc {
|
||||
fn eq(&self, other: &&[u8; 4]) -> bool {
|
||||
&self.0 == *other
|
||||
}
|
||||
}
|
||||
impl PartialEq<&&[u8; 4]> for FourCc {
|
||||
fn eq(&self, other: &&&[u8; 4]) -> bool {
|
||||
&self.0 == **other
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounds-checked big-endian cursor over a byte slice.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct Cur<'a> {
|
||||
pub data: &'a [u8],
|
||||
pub pos: usize,
|
||||
what: &'static str,
|
||||
}
|
||||
|
||||
impl<'a> Cur<'a> {
|
||||
pub fn new(data: &'a [u8], what: &'static str) -> Self {
|
||||
Cur { data, pos: 0, what }
|
||||
}
|
||||
pub fn remaining(&self) -> usize {
|
||||
self.data.len().saturating_sub(self.pos)
|
||||
}
|
||||
pub fn rest(&self) -> &'a [u8] {
|
||||
self.data.get(self.pos..).unwrap_or(&[])
|
||||
}
|
||||
pub fn bytes(&mut self, n: usize) -> Result<&'a [u8]> {
|
||||
let end = self.pos.checked_add(n).ok_or(Error::Truncated(self.what))?;
|
||||
let s = self.data.get(self.pos..end).ok_or(Error::Truncated(self.what))?;
|
||||
self.pos = end;
|
||||
Ok(s)
|
||||
}
|
||||
pub fn skip(&mut self, n: usize) -> Result<()> {
|
||||
self.bytes(n).map(|_| ())
|
||||
}
|
||||
pub fn array<const N: usize>(&mut self) -> Result<[u8; N]> {
|
||||
let s = self.bytes(N)?;
|
||||
let mut a = [0u8; N];
|
||||
a.copy_from_slice(s);
|
||||
Ok(a)
|
||||
}
|
||||
pub fn u8(&mut self) -> Result<u8> {
|
||||
Ok(self.array::<1>()?[0])
|
||||
}
|
||||
pub fn u16(&mut self) -> Result<u16> {
|
||||
Ok(u16::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn i16(&mut self) -> Result<i16> {
|
||||
Ok(i16::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn u24(&mut self) -> Result<u32> {
|
||||
let a = self.array::<3>()?;
|
||||
Ok(((a[0] as u32) << 16) | ((a[1] as u32) << 8) | a[2] as u32)
|
||||
}
|
||||
pub fn u32(&mut self) -> Result<u32> {
|
||||
Ok(u32::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn i32(&mut self) -> Result<i32> {
|
||||
Ok(i32::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn u64(&mut self) -> Result<u64> {
|
||||
Ok(u64::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn i64(&mut self) -> Result<i64> {
|
||||
Ok(i64::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn f64(&mut self) -> Result<f64> {
|
||||
Ok(f64::from_be_bytes(self.array()?))
|
||||
}
|
||||
pub fn fourcc(&mut self) -> Result<FourCc> {
|
||||
Ok(FourCc(self.array()?))
|
||||
}
|
||||
/// Full-box header: (version, flags).
|
||||
pub fn full_header(&mut self) -> Result<(u8, u32)> {
|
||||
let v = self.u32()?;
|
||||
Ok(((v >> 24) as u8, v & 0x00FF_FFFF))
|
||||
}
|
||||
/// Read a count and verify that `count * entry_size` bytes remain (allocation guard).
|
||||
pub fn count(&mut self, entry_size: usize) -> Result<usize> {
|
||||
let n = self.u32()? as usize;
|
||||
self.check_count(n, entry_size)?;
|
||||
Ok(n)
|
||||
}
|
||||
pub fn check_count(&self, n: usize, entry_size: usize) -> Result<()> {
|
||||
match n.checked_mul(entry_size) {
|
||||
Some(b) if b <= self.remaining() => Ok(()),
|
||||
_ => Err(Error::Truncated(self.what)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Header of one box as found in a byte stream.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct BoxHeader {
|
||||
pub kind: FourCc,
|
||||
/// Header length including largesize and uuid extended type.
|
||||
pub header_len: u64,
|
||||
/// Total box size including header; `None` means "extends to end of the enclosing container".
|
||||
pub size: Option<u64>,
|
||||
pub uuid: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
/// Parse a box header from `buf` (needs up to 32 bytes; fewer if available).
|
||||
pub(crate) fn parse_box_header(buf: &[u8]) -> Result<BoxHeader> {
|
||||
let mut c = Cur::new(buf, "box header");
|
||||
let size32 = c.u32()?;
|
||||
let kind = c.fourcc()?;
|
||||
let mut header_len = 8u64;
|
||||
let size = match size32 {
|
||||
0 => None,
|
||||
1 => {
|
||||
header_len += 8;
|
||||
let s = c.u64()?;
|
||||
Some(s)
|
||||
}
|
||||
s => Some(s as u64),
|
||||
};
|
||||
let uuid = if kind == b"uuid" {
|
||||
header_len += 16;
|
||||
Some(c.array::<16>()?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(s) = size
|
||||
&& s < header_len
|
||||
{
|
||||
return Err(Error::Invalid(format!("box '{kind}' size {s} smaller than its header")));
|
||||
}
|
||||
Ok(BoxHeader { kind, header_len, size, uuid })
|
||||
}
|
||||
|
||||
/// One child box inside an in-memory payload.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) struct RawBox<'a> {
|
||||
pub kind: FourCc,
|
||||
pub payload: &'a [u8],
|
||||
#[allow(dead_code)]
|
||||
pub uuid: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
/// Iterator over the child boxes of a payload. Yields an error for a malformed box and then stops.
|
||||
pub(crate) struct Boxes<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn boxes(data: &[u8]) -> Boxes<'_> {
|
||||
Boxes { data, pos: 0, done: false }
|
||||
}
|
||||
|
||||
impl<'a> Iterator for Boxes<'a> {
|
||||
type Item = Result<RawBox<'a>>;
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if self.done {
|
||||
return None;
|
||||
}
|
||||
let rest = &self.data[self.pos..];
|
||||
if rest.is_empty() {
|
||||
self.done = true;
|
||||
return None;
|
||||
}
|
||||
// QuickTime allows a 32-bit zero terminator at the end of some containers.
|
||||
if rest.len() < 8 {
|
||||
self.done = true;
|
||||
if rest.iter().all(|&b| b == 0) {
|
||||
return None;
|
||||
}
|
||||
return Some(Err(Error::Truncated("box header")));
|
||||
}
|
||||
let h = match parse_box_header(rest) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
self.done = true;
|
||||
return Some(Err(e));
|
||||
}
|
||||
};
|
||||
let size = h.size.unwrap_or(rest.len() as u64);
|
||||
if size > rest.len() as u64 {
|
||||
self.done = true;
|
||||
return Some(Err(Error::Invalid(format!("box '{}' size {} exceeds its container ({} bytes left)", h.kind, size, rest.len()))));
|
||||
}
|
||||
let size = size as usize;
|
||||
let payload = &rest[h.header_len as usize..size];
|
||||
self.pos += size;
|
||||
Some(Ok(RawBox { kind: h.kind, payload, uuid: h.uuid }))
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the first child of the given type (malformed trailing children are ignored).
|
||||
pub(crate) fn find<'a>(data: &'a [u8], kind: &[u8; 4]) -> Option<&'a [u8]> {
|
||||
boxes(data).map_while(|b| b.ok()).find(|b| b.kind == kind).map(|b| b.payload)
|
||||
}
|
||||
|
||||
/// Growable buffer for building boxes.
|
||||
#[derive(Default, Clone, Debug)]
|
||||
pub(crate) struct BoxBuf {
|
||||
pub buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl BoxBuf {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
/// Begin a box; returns a marker to pass to [`end`](Self::end).
|
||||
pub fn start(&mut self, kind: &[u8; 4]) -> usize {
|
||||
let at = self.buf.len();
|
||||
self.buf.extend_from_slice(&[0, 0, 0, 0]);
|
||||
self.buf.extend_from_slice(kind);
|
||||
at
|
||||
}
|
||||
pub fn start_full(&mut self, kind: &[u8; 4], version: u8, flags: u32) -> usize {
|
||||
let at = self.start(kind);
|
||||
self.u32(((version as u32) << 24) | (flags & 0xFF_FFFF));
|
||||
at
|
||||
}
|
||||
pub fn end(&mut self, at: usize) {
|
||||
let size = (self.buf.len() - at) as u32;
|
||||
self.buf[at..at + 4].copy_from_slice(&size.to_be_bytes());
|
||||
}
|
||||
/// Append a complete box with the given payload.
|
||||
pub fn leaf(&mut self, kind: &[u8; 4], payload: &[u8]) {
|
||||
let at = self.start(kind);
|
||||
self.bytes(payload);
|
||||
self.end(at);
|
||||
}
|
||||
pub fn u8(&mut self, v: u8) {
|
||||
self.buf.push(v);
|
||||
}
|
||||
pub fn u16(&mut self, v: u16) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn i16(&mut self, v: i16) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn u32(&mut self, v: u32) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn i32(&mut self, v: i32) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn u64(&mut self, v: u64) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn i64(&mut self, v: i64) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn f64(&mut self, v: f64) {
|
||||
self.buf.extend_from_slice(&v.to_be_bytes());
|
||||
}
|
||||
pub fn bytes(&mut self, b: &[u8]) {
|
||||
self.buf.extend_from_slice(b);
|
||||
}
|
||||
pub fn zeros(&mut self, n: usize) {
|
||||
self.buf.resize(self.buf.len() + n, 0);
|
||||
}
|
||||
pub fn len(&self) -> usize {
|
||||
self.buf.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn header_forms() {
|
||||
let h = parse_box_header(&[0, 0, 0, 16, b'f', b'r', b'e', b'e']).unwrap();
|
||||
assert_eq!((h.kind, h.header_len, h.size), (FourCc(*b"free"), 8, Some(16)));
|
||||
let h = parse_box_header(&[0, 0, 0, 0, b'm', b'd', b'a', b't']).unwrap();
|
||||
assert_eq!(h.size, None);
|
||||
let mut v = vec![0, 0, 0, 1, b'm', b'd', b'a', b't'];
|
||||
v.extend_from_slice(&100u64.to_be_bytes());
|
||||
let h = parse_box_header(&v).unwrap();
|
||||
assert_eq!((h.header_len, h.size), (16, Some(100)));
|
||||
let mut v = vec![0, 0, 0, 30, b'u', b'u', b'i', b'd'];
|
||||
v.extend_from_slice(&[7u8; 16]);
|
||||
let h = parse_box_header(&v).unwrap();
|
||||
assert_eq!((h.header_len, h.uuid), (24, Some([7u8; 16])));
|
||||
assert!(parse_box_header(&[0, 0, 0, 4, b'f', b'r', b'e', b'e']).is_err());
|
||||
assert!(parse_box_header(&[0, 0, 0]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iterate_children() {
|
||||
let mut b = BoxBuf::new();
|
||||
b.leaf(b"aaaa", &[1, 2]);
|
||||
let m = b.start(b"bbbb");
|
||||
b.leaf(b"cccc", &[]);
|
||||
b.end(m);
|
||||
b.u32(0); // QT terminator
|
||||
let kids: Vec<_> = boxes(&b.buf).collect::<Result<Vec<_>>>().unwrap();
|
||||
assert_eq!(kids.len(), 2);
|
||||
assert_eq!(kids[0].payload, &[1, 2]);
|
||||
assert_eq!(find(&b.buf, b"bbbb").map(|p| p.len()), Some(8));
|
||||
// Oversized child → error, no panic.
|
||||
let bad = [0u8, 0, 0, 99, b'x', b'x', b'x', b'x', 0];
|
||||
assert!(boxes(&bad).next().unwrap().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_bounds() {
|
||||
let mut c = Cur::new(&[0, 0, 0, 5, 1], "t");
|
||||
assert!(c.count(1).is_err());
|
||||
let mut c = Cur::new(&[0xFF, 0xFF, 0xFF, 0xFF], "t");
|
||||
assert!(c.count(usize::MAX).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
use std::io;
|
||||
|
||||
/// Errors produced by the demuxer and muxer.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum Error {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] io::Error),
|
||||
/// A box or structure ended before all required fields were read.
|
||||
#[error("truncated data while reading {0}")]
|
||||
Truncated(&'static str),
|
||||
/// Structurally invalid data.
|
||||
#[error("invalid data: {0}")]
|
||||
Invalid(String),
|
||||
/// Valid but unsupported feature (e.g. compressed `cmov` movie header).
|
||||
#[error("unsupported: {0}")]
|
||||
Unsupported(String),
|
||||
/// A count or size exceeds sanity limits relative to the file size.
|
||||
#[error("size limit exceeded: {0}")]
|
||||
TooLarge(&'static str),
|
||||
/// No `moov` box was found.
|
||||
#[error("no moov box found")]
|
||||
NoMoov,
|
||||
/// A track index was out of range.
|
||||
#[error("track index {0} out of range")]
|
||||
NoSuchTrack(usize),
|
||||
/// A sample index was out of range.
|
||||
#[error("sample index {0} out of range")]
|
||||
NoSuchSample(usize),
|
||||
/// Muxer usage error (e.g. invalid track config, bad sample).
|
||||
#[error("muxer: {0}")]
|
||||
Mux(String),
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
pub(crate) fn invalid<T>(msg: impl Into<String>) -> Result<T> {
|
||||
Err(Error::Invalid(msg.into()))
|
||||
}
|
||||
@@ -1,28 +1,6 @@
|
||||
//! Clean-room ISO Base Media File Format (MP4, ISO/IEC 14496-12/14/15) and QuickTime (MOV) support.
|
||||
//!
|
||||
//! - **Demux:** [`open`] parses box headers plus `moov` (and `moof` for fragmented files) from a
|
||||
//! [`ByteSource`] and builds per-track [`Sample`] tables; media data is read on demand with
|
||||
//! [`Mp4File::read_sample`].
|
||||
//! - **Mux:** [`Mp4Writer`] writes progressive MP4/MOV (optionally faststart);
|
||||
//! [`FragmentedWriter`] writes fragmented MP4.
|
||||
//!
|
||||
//! See the crate README for supported boxes and limitations.
|
||||
//! ISO BMFF (MP4) and QuickTime (MOV) demuxer and muxer: a re-export of [`craft_media::isobmff`]
|
||||
//! from craft-libs. Supported boxes and limitations are in the craft-media README.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod bytes;
|
||||
mod codec;
|
||||
mod demux;
|
||||
mod error;
|
||||
mod mux;
|
||||
mod source;
|
||||
|
||||
pub use bytes::FourCc;
|
||||
pub use codec::{
|
||||
AacConfig, AudioParams, Av1Config, AvcConfig, BitRate, CleanAperture, CodecConfig, ColorInfo, FieldInfo, FlacConfig, HevcConfig, HevcNalArray,
|
||||
MasteringDisplay, OpusConfig, PcmConfig, SampleEntry, TimecodeConfig, VideoParams, VpcConfig, parse_asc,
|
||||
};
|
||||
pub use demux::{Edit, Metadata, Mp4File, Sample, SampleGroup, Track, TrackKind, open};
|
||||
pub use error::{Error, Result};
|
||||
pub use mux::{Brand, FragmentedWriter, Mp4Writer, TrackConfig, WriteSample, WriterOptions};
|
||||
pub use source::ByteSource;
|
||||
pub use craft_media::isobmff::*;
|
||||
|
||||
@@ -1,969 +0,0 @@
|
||||
//! Muxers: progressive MP4/MOV ([`Mp4Writer`], optional faststart) and fragmented MP4 ([`FragmentedWriter`]).
|
||||
|
||||
use crate::bytes::BoxBuf;
|
||||
use crate::codec::{CodecConfig, SampleEntry, TimecodeConfig};
|
||||
use crate::demux::Edit;
|
||||
use crate::error::{Error, Result};
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
|
||||
/// Output flavour: determines brands and some sample-entry encodings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Brand {
|
||||
/// ISO MP4: `isom` major brand, compatible `isom iso2 avc1 mp41 mp42`.
|
||||
Mp4,
|
||||
/// QuickTime MOV: `qt ` major brand.
|
||||
Mov,
|
||||
}
|
||||
|
||||
/// Movie-level writer options.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct WriterOptions {
|
||||
pub brand: Brand,
|
||||
/// Movie timescale (`mvhd`); default 1000.
|
||||
pub movie_timescale: u32,
|
||||
/// Text metadata written to `moov/udta` (keys like `©nam`, `©ART`, `©cmt`, `©too`).
|
||||
pub metadata: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl WriterOptions {
|
||||
pub fn new(brand: Brand) -> Self {
|
||||
WriterOptions { brand, movie_timescale: 1000, metadata: Vec::new() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-track configuration.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrackConfig {
|
||||
pub entry: SampleEntry,
|
||||
/// Media timescale. For PCM it must equal the sample rate (one tick per audio frame).
|
||||
pub timescale: u32,
|
||||
/// ISO 639-2/T language (`und` by default).
|
||||
pub language: String,
|
||||
/// Explicit edit list (segment durations in the movie timescale). Takes precedence over `media_start`.
|
||||
pub edits: Vec<Edit>,
|
||||
/// Convenience: create one edit starting at this media time (e.g. AAC encoder delay / priming,
|
||||
/// or the first pts of a B-frame stream) spanning the rest of the media.
|
||||
pub media_start: Option<i64>,
|
||||
/// `hdlr` name; a sensible default is used when `None`.
|
||||
pub handler_name: Option<String>,
|
||||
}
|
||||
|
||||
impl TrackConfig {
|
||||
pub fn new(entry: SampleEntry, timescale: u32) -> Self {
|
||||
TrackConfig { entry, timescale, language: "und".into(), edits: Vec::new(), media_start: None, handler_name: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// A sample handed to the muxers.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct WriteSample<'a> {
|
||||
pub data: &'a [u8],
|
||||
/// Duration in the media timescale. Ignored for PCM (each frame lasts one tick).
|
||||
pub duration: u32,
|
||||
/// pts − dts in the media timescale.
|
||||
pub composition_offset: i32,
|
||||
pub is_sync: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Handler {
|
||||
Video,
|
||||
Audio,
|
||||
Timecode,
|
||||
Other,
|
||||
}
|
||||
|
||||
fn handler_of(e: &SampleEntry) -> Handler {
|
||||
if matches!(e.codec, CodecConfig::Timecode(_)) {
|
||||
Handler::Timecode
|
||||
} else if e.video.is_some() {
|
||||
Handler::Video
|
||||
} else if e.audio.is_some() {
|
||||
Handler::Audio
|
||||
} else {
|
||||
Handler::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Accumulated sample tables for one track.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Tables {
|
||||
stts: Vec<(u32, u32)>,
|
||||
ctts: Vec<(u32, i32)>,
|
||||
sizes: Vec<u32>,
|
||||
/// Constant sample size (PCM) — then `sizes` is unused.
|
||||
const_size: Option<u32>,
|
||||
sample_count: u64,
|
||||
/// 1-based sync sample numbers.
|
||||
sync: Vec<u32>,
|
||||
all_sync: bool,
|
||||
/// (offset relative to file start before shifting, sample count)
|
||||
chunks: Vec<(u64, u32)>,
|
||||
media_duration: u64,
|
||||
/// End of the last sample on the composition timeline (decode time + composition offset +
|
||||
/// duration, maximum over samples).
|
||||
comp_end: u64,
|
||||
}
|
||||
|
||||
impl Tables {
|
||||
fn new() -> Self {
|
||||
Tables { all_sync: true, ..Default::default() }
|
||||
}
|
||||
fn push_run<T: PartialEq + Copy>(runs: &mut Vec<(u32, T)>, count: u32, v: T) {
|
||||
if let Some(last) = runs.last_mut()
|
||||
&& last.1 == v
|
||||
&& last.0.checked_add(count).is_some()
|
||||
{
|
||||
last.0 += count;
|
||||
return;
|
||||
}
|
||||
runs.push((count, v));
|
||||
}
|
||||
}
|
||||
|
||||
struct WTrack {
|
||||
cfg: TrackConfig,
|
||||
id: u32,
|
||||
handler: Handler,
|
||||
pcm_frame_bytes: Option<u32>,
|
||||
t: Tables,
|
||||
/// Track id of a linked timecode track (`tref/tmcd`).
|
||||
tmcd_ref: Option<u32>,
|
||||
/// For timecode tracks: (video track index, start frame).
|
||||
tmcd_source: Option<(usize, u32)>,
|
||||
}
|
||||
|
||||
fn pack_language(lang: &str) -> u16 {
|
||||
let b = lang.as_bytes();
|
||||
if b.len() == 3 && b.iter().all(|c| c.is_ascii_lowercase()) {
|
||||
(((b[0] - 0x60) as u16) << 10) | (((b[1] - 0x60) as u16) << 5) | ((b[2] - 0x60) as u16)
|
||||
} else {
|
||||
0x55C4 // "und"
|
||||
}
|
||||
}
|
||||
|
||||
fn rescale(v: u64, from: u32, to: u32) -> u64 {
|
||||
if from == 0 {
|
||||
return 0;
|
||||
}
|
||||
(v as u128 * to as u128 / from as u128).min(u64::MAX as u128) as u64
|
||||
}
|
||||
|
||||
const MATRIX: [i32; 9] = [0x10000, 0, 0, 0, 0x10000, 0, 0, 0, 0x4000_0000];
|
||||
|
||||
fn write_ftyp(b: &mut BoxBuf, brand: Brand) {
|
||||
let m = b.start(b"ftyp");
|
||||
match brand {
|
||||
Brand::Mp4 => {
|
||||
b.bytes(b"isom");
|
||||
b.u32(0x200);
|
||||
for c in [b"isom", b"iso2", b"avc1", b"mp41", b"mp42"] {
|
||||
b.bytes(c);
|
||||
}
|
||||
}
|
||||
Brand::Mov => {
|
||||
b.bytes(b"qt ");
|
||||
b.u32(0x200);
|
||||
b.bytes(b"qt ");
|
||||
}
|
||||
}
|
||||
b.end(m);
|
||||
}
|
||||
|
||||
fn movie_duration(tracks: &[WTrack], movie_ts: u32) -> u64 {
|
||||
tracks.iter().map(|t| presentation_duration(t, movie_ts)).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn effective_edits(t: &WTrack, movie_ts: u32) -> Vec<Edit> {
|
||||
if !t.cfg.edits.is_empty() {
|
||||
return t.cfg.edits.clone();
|
||||
}
|
||||
if let Some(start) = t.cfg.media_start {
|
||||
// presentation runs from `start` to the end of the last sample in composition order (with
|
||||
// B-frame reordering that is past the end of the decode timeline)
|
||||
let rest = t.t.comp_end.max(t.t.media_duration).saturating_sub(start.max(0) as u64);
|
||||
return vec![Edit { segment_duration: rescale(rest, t.cfg.timescale, movie_ts), media_time: start, media_rate: 0x10000 }];
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn presentation_duration(t: &WTrack, movie_ts: u32) -> u64 {
|
||||
let e = effective_edits(t, movie_ts);
|
||||
if e.is_empty() { rescale(t.t.media_duration, t.cfg.timescale, movie_ts) } else { e.iter().map(|e| e.segment_duration).sum() }
|
||||
}
|
||||
|
||||
fn write_mvhd(b: &mut BoxBuf, timescale: u32, duration: u64, next_id: u32) {
|
||||
let v1 = duration > u32::MAX as u64;
|
||||
let m = b.start_full(b"mvhd", v1 as u8, 0);
|
||||
if v1 {
|
||||
b.u64(0);
|
||||
b.u64(0);
|
||||
b.u32(timescale);
|
||||
b.u64(duration);
|
||||
} else {
|
||||
b.u32(0);
|
||||
b.u32(0);
|
||||
b.u32(timescale);
|
||||
b.u32(duration as u32);
|
||||
}
|
||||
b.u32(0x10000);
|
||||
b.u16(0x100);
|
||||
b.zeros(10);
|
||||
for v in MATRIX {
|
||||
b.i32(v);
|
||||
}
|
||||
b.zeros(24);
|
||||
b.u32(next_id);
|
||||
b.end(m);
|
||||
}
|
||||
|
||||
fn write_trak(b: &mut BoxBuf, t: &WTrack, movie_ts: u32, qt: bool, shift: u64, force_co64: bool) -> Result<()> {
|
||||
let trak = b.start(b"trak");
|
||||
let dur = presentation_duration(t, movie_ts);
|
||||
let v1 = dur > u32::MAX as u64;
|
||||
let m = b.start_full(b"tkhd", v1 as u8, 3);
|
||||
if v1 {
|
||||
b.u64(0);
|
||||
b.u64(0);
|
||||
b.u32(t.id);
|
||||
b.u32(0);
|
||||
b.u64(dur);
|
||||
} else {
|
||||
b.u32(0);
|
||||
b.u32(0);
|
||||
b.u32(t.id);
|
||||
b.u32(0);
|
||||
b.u32(dur as u32);
|
||||
}
|
||||
b.zeros(8);
|
||||
b.i16(0);
|
||||
b.u16(0);
|
||||
b.i16(if t.handler == Handler::Audio { 0x100 } else { 0 });
|
||||
b.u16(0);
|
||||
for v in MATRIX {
|
||||
b.i32(v);
|
||||
}
|
||||
let (w, h) = match &t.cfg.entry.video {
|
||||
Some(v) => {
|
||||
let w = match v.pixel_aspect {
|
||||
Some((hs, vs)) if hs > 0 && vs > 0 => (v.width as u64 * hs as u64 / vs as u64) as u32,
|
||||
_ => v.width as u32,
|
||||
};
|
||||
(w, v.height as u32)
|
||||
}
|
||||
None => (0, 0),
|
||||
};
|
||||
b.u32(w << 16);
|
||||
b.u32(h << 16);
|
||||
b.end(m);
|
||||
let edits = effective_edits(t, movie_ts);
|
||||
if !edits.is_empty() {
|
||||
let e = b.start(b"edts");
|
||||
let v1 = edits.iter().any(|e| e.segment_duration > u32::MAX as u64 || e.media_time > i32::MAX as i64 || e.media_time < i32::MIN as i64);
|
||||
let l = b.start_full(b"elst", v1 as u8, 0);
|
||||
b.u32(edits.len() as u32);
|
||||
for ed in &edits {
|
||||
if v1 {
|
||||
b.u64(ed.segment_duration);
|
||||
b.i64(ed.media_time);
|
||||
} else {
|
||||
b.u32(ed.segment_duration as u32);
|
||||
b.i32(ed.media_time as i32);
|
||||
}
|
||||
b.i32(ed.media_rate);
|
||||
}
|
||||
b.end(l);
|
||||
b.end(e);
|
||||
}
|
||||
if let Some(id) = t.tmcd_ref {
|
||||
let r = b.start(b"tref");
|
||||
let m = b.start(b"tmcd");
|
||||
b.u32(id);
|
||||
b.end(m);
|
||||
b.end(r);
|
||||
}
|
||||
let mdia = b.start(b"mdia");
|
||||
let mv1 = t.t.media_duration > u32::MAX as u64;
|
||||
let m = b.start_full(b"mdhd", mv1 as u8, 0);
|
||||
if mv1 {
|
||||
b.u64(0);
|
||||
b.u64(0);
|
||||
b.u32(t.cfg.timescale);
|
||||
b.u64(t.t.media_duration);
|
||||
} else {
|
||||
b.u32(0);
|
||||
b.u32(0);
|
||||
b.u32(t.cfg.timescale);
|
||||
b.u32(t.t.media_duration as u32);
|
||||
}
|
||||
b.u16(pack_language(&t.cfg.language));
|
||||
b.u16(0);
|
||||
b.end(m);
|
||||
let (htype, default_name) = match t.handler {
|
||||
Handler::Video => (b"vide", "VideoHandler"),
|
||||
Handler::Audio => (b"soun", "SoundHandler"),
|
||||
Handler::Timecode => (b"tmcd", "TimeCodeHandler"),
|
||||
Handler::Other => (b"meta", "MetaHandler"),
|
||||
};
|
||||
let name = t.cfg.handler_name.clone().unwrap_or_else(|| default_name.into());
|
||||
let m = b.start_full(b"hdlr", 0, 0);
|
||||
b.bytes(if qt { b"mhlr" } else { &[0, 0, 0, 0] });
|
||||
b.bytes(htype);
|
||||
b.zeros(12);
|
||||
if qt {
|
||||
let n = name.len().min(255);
|
||||
b.u8(n as u8);
|
||||
b.bytes(&name.as_bytes()[..n]);
|
||||
} else {
|
||||
b.bytes(name.as_bytes());
|
||||
b.u8(0);
|
||||
}
|
||||
b.end(m);
|
||||
let minf = b.start(b"minf");
|
||||
match t.handler {
|
||||
Handler::Video => {
|
||||
let m = b.start_full(b"vmhd", 0, 1);
|
||||
b.zeros(8);
|
||||
b.end(m);
|
||||
}
|
||||
Handler::Audio => {
|
||||
let m = b.start_full(b"smhd", 0, 0);
|
||||
b.zeros(4);
|
||||
b.end(m);
|
||||
}
|
||||
Handler::Timecode => {
|
||||
let g = b.start(b"gmhd");
|
||||
let m = b.start_full(b"gmin", 0, 0);
|
||||
b.u16(0x40);
|
||||
b.u16(0x8000);
|
||||
b.u16(0x8000);
|
||||
b.u16(0x8000);
|
||||
b.i16(0);
|
||||
b.u16(0);
|
||||
b.end(m);
|
||||
let tm = b.start(b"tmcd");
|
||||
let m = b.start_full(b"tcmi", 0, 0);
|
||||
b.u16(0);
|
||||
b.u16(0);
|
||||
b.u16(12);
|
||||
b.u16(0);
|
||||
b.bytes(&[0xFF; 6]);
|
||||
b.zeros(6);
|
||||
b.u8(0);
|
||||
b.end(m);
|
||||
b.end(tm);
|
||||
b.end(g);
|
||||
}
|
||||
Handler::Other => {
|
||||
let m = b.start_full(b"nmhd", 0, 0);
|
||||
b.end(m);
|
||||
}
|
||||
}
|
||||
if qt {
|
||||
// QuickTime data handler reference.
|
||||
let m = b.start_full(b"hdlr", 0, 0);
|
||||
b.bytes(b"dhlr");
|
||||
b.bytes(b"url ");
|
||||
b.zeros(12);
|
||||
b.u8(0);
|
||||
b.end(m);
|
||||
}
|
||||
let d = b.start(b"dinf");
|
||||
let r = b.start_full(b"dref", 0, 0);
|
||||
b.u32(1);
|
||||
let u = b.start_full(b"url ", 0, 1);
|
||||
b.end(u);
|
||||
b.end(r);
|
||||
b.end(d);
|
||||
let stbl = b.start(b"stbl");
|
||||
let m = b.start_full(b"stsd", 0, 0);
|
||||
b.u32(1);
|
||||
t.cfg.entry.write(b, qt)?;
|
||||
b.end(m);
|
||||
let tb = &t.t;
|
||||
let m = b.start_full(b"stts", 0, 0);
|
||||
b.u32(tb.stts.len() as u32);
|
||||
for &(c, d) in &tb.stts {
|
||||
b.u32(c);
|
||||
b.u32(d);
|
||||
}
|
||||
b.end(m);
|
||||
if tb.ctts.iter().any(|&(_, o)| o != 0) {
|
||||
let neg = tb.ctts.iter().any(|&(_, o)| o < 0);
|
||||
let m = b.start_full(b"ctts", neg as u8, 0);
|
||||
b.u32(tb.ctts.len() as u32);
|
||||
for &(c, o) in &tb.ctts {
|
||||
b.u32(c);
|
||||
b.i32(o);
|
||||
}
|
||||
b.end(m);
|
||||
}
|
||||
if !tb.all_sync {
|
||||
let m = b.start_full(b"stss", 0, 0);
|
||||
b.u32(tb.sync.len() as u32);
|
||||
for &s in &tb.sync {
|
||||
b.u32(s);
|
||||
}
|
||||
b.end(m);
|
||||
}
|
||||
let m = b.start_full(b"stsc", 0, 0);
|
||||
let mut runs: Vec<(u32, u32)> = Vec::new();
|
||||
for (i, &(_, n)) in tb.chunks.iter().enumerate() {
|
||||
if runs.last().is_none_or(|r| r.1 != n) {
|
||||
runs.push((i as u32 + 1, n));
|
||||
}
|
||||
}
|
||||
b.u32(runs.len() as u32);
|
||||
for (first, n) in runs {
|
||||
b.u32(first);
|
||||
b.u32(n);
|
||||
b.u32(1);
|
||||
}
|
||||
b.end(m);
|
||||
let m = b.start_full(b"stsz", 0, 0);
|
||||
match tb.const_size {
|
||||
Some(s) => {
|
||||
b.u32(s);
|
||||
b.u32(tb.sample_count.min(u32::MAX as u64) as u32);
|
||||
}
|
||||
None => {
|
||||
let first = tb.sizes.first().copied().unwrap_or(0);
|
||||
// A uniform size of 0 would mean "table follows" (ISO/IEC 14496-12 §8.7.3), so
|
||||
// all-empty samples must still be written as a table.
|
||||
if first != 0 && !tb.sizes.is_empty() && tb.sizes.iter().all(|&s| s == first) {
|
||||
b.u32(first);
|
||||
b.u32(tb.sizes.len() as u32);
|
||||
} else {
|
||||
b.u32(0);
|
||||
b.u32(tb.sizes.len() as u32);
|
||||
for &s in &tb.sizes {
|
||||
b.u32(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b.end(m);
|
||||
let co64 = force_co64 || tb.chunks.iter().any(|&(o, _)| o + shift > u32::MAX as u64);
|
||||
let m = b.start_full(if co64 { b"co64" } else { b"stco" }, 0, 0);
|
||||
b.u32(tb.chunks.len() as u32);
|
||||
for &(o, _) in &tb.chunks {
|
||||
if co64 {
|
||||
b.u64(o + shift);
|
||||
} else {
|
||||
b.u32((o + shift) as u32);
|
||||
}
|
||||
}
|
||||
b.end(m);
|
||||
b.end(stbl);
|
||||
b.end(minf);
|
||||
b.end(mdia);
|
||||
b.end(trak);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_udta(b: &mut BoxBuf, md: &[(String, String)]) {
|
||||
let items: Vec<([u8; 4], &str)> = md
|
||||
.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let mut chars = k.chars();
|
||||
if chars.next()? != '©' {
|
||||
return None;
|
||||
}
|
||||
let rest: Vec<u8> = chars.map(|c| c as u32 as u8).collect();
|
||||
(rest.len() == 3).then(|| ([0xA9, rest[0], rest[1], rest[2]], v.as_str()))
|
||||
})
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let u = b.start(b"udta");
|
||||
for (k, v) in items {
|
||||
let m = b.start(&k);
|
||||
b.u16(v.len() as u16);
|
||||
b.u16(0x55C4);
|
||||
b.bytes(v.as_bytes());
|
||||
b.end(m);
|
||||
}
|
||||
b.end(u);
|
||||
}
|
||||
|
||||
fn validate_track(cfg: &TrackConfig) -> Result<Option<u32>> {
|
||||
if cfg.timescale == 0 {
|
||||
return Err(Error::Mux("timescale must be non-zero".into()));
|
||||
}
|
||||
if let CodecConfig::Pcm(p) = &cfg.entry.codec {
|
||||
if p.bits == 0 || p.channels == 0 {
|
||||
return Err(Error::Mux("PCM needs bits and channels".into()));
|
||||
}
|
||||
if (p.sample_rate - cfg.timescale as f64).abs() > 0.5 {
|
||||
return Err(Error::Mux("PCM track timescale must equal the sample rate".into()));
|
||||
}
|
||||
let Some(n) = p.bytes_per_frame() else {
|
||||
return Err(Error::Mux("PCM frame size overflows".into()));
|
||||
};
|
||||
return Ok(Some(n));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Progressive MP4/MOV writer: `ftyp`, then one `mdat` streamed to `W`, then `moov` at [`finish`](Self::finish)
|
||||
/// (or moved in front of `mdat` by [`finish_faststart`](Self::finish_faststart)).
|
||||
pub struct Mp4Writer<W: Write + Seek> {
|
||||
w: W,
|
||||
opts: WriterOptions,
|
||||
tracks: Vec<WTrack>,
|
||||
/// Offset of the placeholder box (8 bytes) preceding the mdat header.
|
||||
pad_pos: u64,
|
||||
mdat_pos: u64,
|
||||
pos: u64,
|
||||
last_track: Option<usize>,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> Mp4Writer<W> {
|
||||
/// Start a file: writes `ftyp`, a placeholder box and the `mdat` header.
|
||||
pub fn new(mut w: W, opts: WriterOptions) -> Result<Self> {
|
||||
let start = w.stream_position()?;
|
||||
let mut b = BoxBuf::new();
|
||||
write_ftyp(&mut b, opts.brand);
|
||||
let pad_pos = start + b.len() as u64;
|
||||
b.leaf(if opts.brand == Brand::Mov { b"wide" } else { b"free" }, &[]);
|
||||
let mdat_pos = start + b.len() as u64;
|
||||
b.leaf(b"mdat", &[]);
|
||||
w.write_all(&b.buf)?;
|
||||
let pos = start + b.len() as u64;
|
||||
Ok(Mp4Writer { w, opts, tracks: Vec::new(), pad_pos, mdat_pos, pos, last_track: None })
|
||||
}
|
||||
|
||||
/// Add a track; returns its index (track ids are index + 1).
|
||||
pub fn add_track(&mut self, cfg: TrackConfig) -> Result<usize> {
|
||||
let pcm = validate_track(&cfg)?;
|
||||
let handler = handler_of(&cfg.entry);
|
||||
let mut t = Tables::new();
|
||||
t.const_size = pcm;
|
||||
self.tracks.push(WTrack { id: self.tracks.len() as u32 + 1, handler, pcm_frame_bytes: pcm, t, cfg, tmcd_ref: None, tmcd_source: None });
|
||||
Ok(self.tracks.len() - 1)
|
||||
}
|
||||
|
||||
/// Add a QuickTime timecode track referenced by `video_track`. Its single sample (the start
|
||||
/// frame number) is written at finish, spanning the video track's duration.
|
||||
pub fn add_timecode_track(&mut self, video_track: usize, tc: TimecodeConfig, start_frame: u32) -> Result<usize> {
|
||||
if video_track >= self.tracks.len() {
|
||||
return Err(Error::NoSuchTrack(video_track));
|
||||
}
|
||||
if tc.timescale == 0 || tc.frame_duration == 0 || tc.frames_per_second == 0 {
|
||||
return Err(Error::Mux("timecode needs timescale, frame duration and fps".into()));
|
||||
}
|
||||
let entry = SampleEntry {
|
||||
format: crate::bytes::FourCc(*b"tmcd"),
|
||||
data_reference_index: 1,
|
||||
codec: CodecConfig::Timecode(tc.clone()),
|
||||
video: None,
|
||||
audio: None,
|
||||
bitrate: None,
|
||||
};
|
||||
let idx = self.add_track(TrackConfig::new(entry, tc.timescale))?;
|
||||
self.tracks[idx].tmcd_source = Some((video_track, start_frame));
|
||||
let id = self.tracks[idx].id;
|
||||
self.tracks[video_track].tmcd_ref = Some(id);
|
||||
Ok(idx)
|
||||
}
|
||||
|
||||
/// Append one sample to track `track` (decode order). For PCM tracks `data` may hold any whole
|
||||
/// number of audio frames.
|
||||
pub fn write_sample(&mut self, track: usize, s: WriteSample) -> Result<()> {
|
||||
let tr = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?;
|
||||
if tr.tmcd_source.is_some() {
|
||||
return Err(Error::Mux("timecode track samples are written automatically".into()));
|
||||
}
|
||||
let (count, dur) = match tr.pcm_frame_bytes {
|
||||
Some(fb) => {
|
||||
if !s.data.len().is_multiple_of(fb as usize) {
|
||||
return Err(Error::Mux("PCM data is not a whole number of frames".into()));
|
||||
}
|
||||
((s.data.len() / fb as usize) as u32, 1u32)
|
||||
}
|
||||
None => {
|
||||
if s.data.len() > u32::MAX as usize {
|
||||
return Err(Error::Mux("sample larger than 4 GiB".into()));
|
||||
}
|
||||
(1, s.duration)
|
||||
}
|
||||
};
|
||||
if count == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
self.w.write_all(s.data)?;
|
||||
let offset = self.pos;
|
||||
self.pos += s.data.len() as u64;
|
||||
let contiguous = self.last_track == Some(track);
|
||||
self.last_track = Some(track);
|
||||
let tr = &mut self.tracks[track];
|
||||
let t = &mut tr.t;
|
||||
Tables::push_run(&mut t.stts, count, dur);
|
||||
Tables::push_run(&mut t.ctts, count, if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset });
|
||||
if tr.pcm_frame_bytes.is_none() {
|
||||
t.sizes.push(s.data.len() as u32);
|
||||
if s.is_sync {
|
||||
t.sync.push(t.sample_count as u32 + 1);
|
||||
} else {
|
||||
t.all_sync = false;
|
||||
}
|
||||
}
|
||||
t.sample_count += count as u64;
|
||||
let ct = if tr.pcm_frame_bytes.is_some() { 0 } else { s.composition_offset as i64 };
|
||||
t.comp_end = t.comp_end.max((t.media_duration as i64 + ct + count as i64 * dur as i64).max(0) as u64);
|
||||
t.media_duration += count as u64 * dur as u64;
|
||||
match t.chunks.last_mut() {
|
||||
Some(c) if contiguous => c.1 += count,
|
||||
_ => t.chunks.push((offset, count)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_mdat(&mut self) -> Result<()> {
|
||||
// Timecode samples.
|
||||
for i in 0..self.tracks.len() {
|
||||
let Some((vi, start)) = self.tracks[i].tmcd_source else { continue };
|
||||
let v = &self.tracks[vi];
|
||||
let vd = v.t.media_duration;
|
||||
let vts = v.cfg.timescale;
|
||||
let tts = self.tracks[i].cfg.timescale;
|
||||
let tc_dur = match &self.tracks[i].cfg.entry.codec {
|
||||
CodecConfig::Timecode(tc) => tc.frame_duration,
|
||||
_ => 1,
|
||||
};
|
||||
let d = rescale(vd, vts, tts).max(tc_dur as u64).min(u32::MAX as u64) as u32;
|
||||
self.w.write_all(&start.to_be_bytes())?;
|
||||
let off = self.pos;
|
||||
self.pos += 4;
|
||||
self.last_track = None;
|
||||
let t = &mut self.tracks[i].t;
|
||||
t.stts.push((1, d));
|
||||
t.sizes.push(4);
|
||||
t.sync.push(1);
|
||||
t.sample_count = 1;
|
||||
t.media_duration = d as u64;
|
||||
t.chunks.push((off, 1));
|
||||
}
|
||||
let size = self.pos - self.mdat_pos;
|
||||
if size <= u32::MAX as u64 {
|
||||
self.w.seek(SeekFrom::Start(self.mdat_pos))?;
|
||||
self.w.write_all(&(size as u32).to_be_bytes())?;
|
||||
} else {
|
||||
// Merge the placeholder into a 16-byte large-size mdat header.
|
||||
let mut h = [0u8; 16];
|
||||
h[..4].copy_from_slice(&1u32.to_be_bytes());
|
||||
h[4..8].copy_from_slice(b"mdat");
|
||||
h[8..].copy_from_slice(&(self.pos - self.pad_pos).to_be_bytes());
|
||||
self.w.seek(SeekFrom::Start(self.pad_pos))?;
|
||||
self.w.write_all(&h)?;
|
||||
}
|
||||
self.w.seek(SeekFrom::Start(self.pos))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_moov(&self, shift: u64, force_co64: bool) -> Result<Vec<u8>> {
|
||||
let qt = self.opts.brand == Brand::Mov;
|
||||
let ts = self.opts.movie_timescale.max(1);
|
||||
let mut b = BoxBuf::new();
|
||||
let m = b.start(b"moov");
|
||||
write_mvhd(&mut b, ts, movie_duration(&self.tracks, ts), self.tracks.len() as u32 + 1);
|
||||
for t in &self.tracks {
|
||||
write_trak(&mut b, t, ts, qt, shift, force_co64)?;
|
||||
}
|
||||
write_udta(&mut b, &self.opts.metadata);
|
||||
b.end(m);
|
||||
Ok(b.buf)
|
||||
}
|
||||
|
||||
/// Finish the file with `moov` after `mdat`. Returns the underlying writer.
|
||||
pub fn finish(mut self) -> Result<W> {
|
||||
self.finish_mdat()?;
|
||||
let moov = self.build_moov(0, false)?;
|
||||
self.w.write_all(&moov)?;
|
||||
self.w.flush()?;
|
||||
Ok(self.w)
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Read + Write + Seek> Mp4Writer<W> {
|
||||
/// Finish with `moov` in front of `mdat` ("fast start"): media data is shifted forward in place.
|
||||
pub fn finish_faststart(mut self) -> Result<W> {
|
||||
self.finish_mdat()?;
|
||||
let mut moov = self.build_moov(0, false)?;
|
||||
let mut force = false;
|
||||
loop {
|
||||
let shift = moov.len() as u64;
|
||||
let next = self.build_moov(shift, force)?;
|
||||
if next.len() == moov.len() {
|
||||
moov = next;
|
||||
break;
|
||||
}
|
||||
// Offsets crossed the 32-bit boundary: switch to co64 (size only grows, so this converges).
|
||||
force = true;
|
||||
moov = next;
|
||||
}
|
||||
let shift = moov.len() as u64;
|
||||
let start = self.pad_pos;
|
||||
let end = self.pos;
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
let mut cur = end;
|
||||
while cur > start {
|
||||
let n = (cur - start).min(buf.len() as u64) as usize;
|
||||
cur -= n as u64;
|
||||
self.w.seek(SeekFrom::Start(cur))?;
|
||||
self.w.read_exact(&mut buf[..n])?;
|
||||
self.w.seek(SeekFrom::Start(cur + shift))?;
|
||||
self.w.write_all(&buf[..n])?;
|
||||
}
|
||||
self.w.seek(SeekFrom::Start(start))?;
|
||||
self.w.write_all(&moov)?;
|
||||
self.w.seek(SeekFrom::Start(end + shift))?;
|
||||
self.w.flush()?;
|
||||
Ok(self.w)
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffered fragment sample: (data, duration, composition offset, sync).
|
||||
type PendingSample = (Vec<u8>, u32, i32, bool);
|
||||
|
||||
/// Fragmented MP4 writer: an init segment (`ftyp` + `moov` with `mvex`) followed by
|
||||
/// `moof`+`mdat` fragments. Samples are buffered until [`flush_fragment`](Self::flush_fragment).
|
||||
pub struct FragmentedWriter<W: Write> {
|
||||
w: W,
|
||||
opts: WriterOptions,
|
||||
tracks: Vec<WTrack>,
|
||||
pending: Vec<Vec<PendingSample>>,
|
||||
next_dts: Vec<u64>,
|
||||
seq: u32,
|
||||
init_written: bool,
|
||||
}
|
||||
|
||||
impl<W: Write> FragmentedWriter<W> {
|
||||
pub fn new(w: W, opts: WriterOptions) -> Self {
|
||||
FragmentedWriter { w, opts, tracks: Vec::new(), pending: Vec::new(), next_dts: Vec::new(), seq: 0, init_written: false }
|
||||
}
|
||||
|
||||
/// Add a track (before the first fragment). PCM is not supported in fragments.
|
||||
pub fn add_track(&mut self, cfg: TrackConfig) -> Result<usize> {
|
||||
if self.init_written {
|
||||
return Err(Error::Mux("tracks must be added before the first fragment".into()));
|
||||
}
|
||||
if validate_track(&cfg)?.is_some() {
|
||||
return Err(Error::Mux("PCM is not supported by the fragmented writer".into()));
|
||||
}
|
||||
let handler = handler_of(&cfg.entry);
|
||||
self.tracks.push(WTrack {
|
||||
id: self.tracks.len() as u32 + 1,
|
||||
handler,
|
||||
pcm_frame_bytes: None,
|
||||
t: Tables::new(),
|
||||
cfg,
|
||||
tmcd_ref: None,
|
||||
tmcd_source: None,
|
||||
});
|
||||
self.pending.push(Vec::new());
|
||||
self.next_dts.push(0);
|
||||
Ok(self.tracks.len() - 1)
|
||||
}
|
||||
|
||||
fn write_init(&mut self) -> Result<()> {
|
||||
let qt = self.opts.brand == Brand::Mov;
|
||||
let ts = self.opts.movie_timescale.max(1);
|
||||
let mut b = BoxBuf::new();
|
||||
write_ftyp(&mut b, self.opts.brand);
|
||||
let m = b.start(b"moov");
|
||||
write_mvhd(&mut b, ts, 0, self.tracks.len() as u32 + 1);
|
||||
for t in &self.tracks {
|
||||
write_trak(&mut b, t, ts, qt, 0, false)?;
|
||||
}
|
||||
let mv = b.start(b"mvex");
|
||||
for t in &self.tracks {
|
||||
let x = b.start_full(b"trex", 0, 0);
|
||||
b.u32(t.id);
|
||||
b.u32(1);
|
||||
b.u32(0);
|
||||
b.u32(0);
|
||||
b.u32(0);
|
||||
b.end(x);
|
||||
}
|
||||
b.end(mv);
|
||||
write_udta(&mut b, &self.opts.metadata);
|
||||
b.end(m);
|
||||
self.w.write_all(&b.buf)?;
|
||||
self.init_written = true;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Buffer a sample for the next fragment.
|
||||
pub fn write_sample(&mut self, track: usize, s: WriteSample) -> Result<()> {
|
||||
let p = self.pending.get_mut(track).ok_or(Error::NoSuchTrack(track))?;
|
||||
if s.data.len() > u32::MAX as usize {
|
||||
return Err(Error::Mux("sample larger than 4 GiB".into()));
|
||||
}
|
||||
p.push((s.data.to_vec(), s.duration, s.composition_offset, s.is_sync));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_moof(&self, data_offsets: &[i32]) -> Vec<u8> {
|
||||
let mut b = BoxBuf::new();
|
||||
let m = b.start(b"moof");
|
||||
let h = b.start_full(b"mfhd", 0, 0);
|
||||
b.u32(self.seq);
|
||||
b.end(h);
|
||||
for (i, t) in self.tracks.iter().enumerate() {
|
||||
let p = &self.pending[i];
|
||||
if p.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let tf = b.start(b"traf");
|
||||
let h = b.start_full(b"tfhd", 0, 0x20000);
|
||||
b.u32(t.id);
|
||||
b.end(h);
|
||||
let h = b.start_full(b"tfdt", 1, 0);
|
||||
b.u64(self.next_dts[i]);
|
||||
b.end(h);
|
||||
let neg = p.iter().any(|s| s.2 < 0);
|
||||
let h = b.start_full(b"trun", neg as u8, 0x1 | 0x100 | 0x200 | 0x400 | 0x800);
|
||||
b.u32(p.len() as u32);
|
||||
b.i32(data_offsets[i]);
|
||||
for (d, dur, cto, sync) in p {
|
||||
b.u32(*dur);
|
||||
b.u32(d.len() as u32);
|
||||
b.u32(if *sync { 0x0200_0000 } else { 0x0101_0000 });
|
||||
b.i32(*cto);
|
||||
}
|
||||
b.end(h);
|
||||
b.end(tf);
|
||||
}
|
||||
b.end(m);
|
||||
b.buf
|
||||
}
|
||||
|
||||
/// Write all buffered samples as one `moof` + `mdat` fragment (no-op if nothing is buffered).
|
||||
pub fn flush_fragment(&mut self) -> Result<()> {
|
||||
if !self.init_written {
|
||||
self.write_init()?;
|
||||
}
|
||||
if self.pending.iter().all(|p| p.is_empty()) {
|
||||
return Ok(());
|
||||
}
|
||||
self.seq += 1;
|
||||
let probe = self.build_moof(&vec![0; self.tracks.len()]);
|
||||
let data_len: u64 = self.pending.iter().flatten().map(|s| s.0.len() as u64).sum();
|
||||
let large = data_len + 8 > u32::MAX as u64;
|
||||
let hdr = if large { 16 } else { 8 };
|
||||
let mut offs = Vec::with_capacity(self.tracks.len());
|
||||
let mut acc = probe.len() as u64 + hdr;
|
||||
for p in &self.pending {
|
||||
offs.push(i32::try_from(acc).map_err(|_| Error::Mux("fragment too large".into()))?);
|
||||
acc += p.iter().map(|s| s.0.len() as u64).sum::<u64>();
|
||||
}
|
||||
let moof = self.build_moof(&offs);
|
||||
self.w.write_all(&moof)?;
|
||||
if large {
|
||||
self.w.write_all(&1u32.to_be_bytes())?;
|
||||
self.w.write_all(b"mdat")?;
|
||||
self.w.write_all(&(data_len + 16).to_be_bytes())?;
|
||||
} else {
|
||||
self.w.write_all(&((data_len + 8) as u32).to_be_bytes())?;
|
||||
self.w.write_all(b"mdat")?;
|
||||
}
|
||||
for (i, p) in self.pending.iter_mut().enumerate() {
|
||||
for (d, dur, _, _) in p.drain(..) {
|
||||
self.w.write_all(&d)?;
|
||||
self.next_dts[i] += dur as u64;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Flush remaining samples and return the writer.
|
||||
pub fn finish(mut self) -> Result<W> {
|
||||
self.flush_fragment()?;
|
||||
self.w.flush()?;
|
||||
Ok(self.w)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::codec::{AvcConfig, PcmConfig};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn avc_entry() -> SampleEntry {
|
||||
SampleEntry::avc(AvcConfig::new(vec![vec![0x67, 0x42, 0, 0x1E]], vec![vec![0x68, 0xCE]], 4), 64, 48)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_pack() {
|
||||
assert_eq!(pack_language("und"), 0x55C4);
|
||||
assert_eq!(pack_language("eng"), 0x15C7);
|
||||
assert_eq!(pack_language("EN"), 0x55C4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_roundtrip_and_faststart() {
|
||||
for fast in [false, true] {
|
||||
let mut w = Mp4Writer::new(Cursor::new(Vec::new()), WriterOptions::new(Brand::Mp4)).unwrap();
|
||||
let v = w.add_track(TrackConfig::new(avc_entry(), 12800)).unwrap();
|
||||
let pcm = PcmConfig { bits: 16, float: false, big_endian: false, signed: true, channels: 2, sample_rate: 48000.0 };
|
||||
let a = w.add_track(TrackConfig::new(SampleEntry::pcm(pcm), 48000)).unwrap();
|
||||
for i in 0..10u8 {
|
||||
let data = vec![i; 100 + i as usize];
|
||||
w.write_sample(
|
||||
v,
|
||||
WriteSample { data: &data, duration: 512, composition_offset: if i % 2 == 0 { 1024 } else { 0 }, is_sync: i % 5 == 0 },
|
||||
)
|
||||
.unwrap();
|
||||
w.write_sample(a, WriteSample { data: &[i; 400], duration: 0, composition_offset: 0, is_sync: true }).unwrap();
|
||||
}
|
||||
let out = if fast { w.finish_faststart() } else { w.finish() }.unwrap().into_inner();
|
||||
let f = crate::open(out.as_slice()).unwrap();
|
||||
assert_eq!(f.tracks.len(), 2);
|
||||
let t = &f.tracks[0];
|
||||
assert_eq!(t.samples.len(), 10);
|
||||
for (i, s) in t.samples.iter().enumerate() {
|
||||
assert_eq!(s.size as usize, 100 + i);
|
||||
assert_eq!(s.dts, 512 * i as i64);
|
||||
assert_eq!(s.is_sync, i % 5 == 0);
|
||||
assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), vec![i as u8; 100 + i]);
|
||||
}
|
||||
let at = &f.tracks[1];
|
||||
assert!(at.pcm_chunked);
|
||||
assert_eq!(at.samples.len(), 10);
|
||||
assert_eq!(at.samples[3].size, 400);
|
||||
assert_eq!(at.samples[3].duration, 100);
|
||||
assert_eq!(f.read_sample(out.as_slice(), 1, 3).unwrap(), vec![3u8; 400]);
|
||||
if fast {
|
||||
assert!(f.moov_offset < t.samples[0].offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fragmented_roundtrip() {
|
||||
let mut w = FragmentedWriter::new(Vec::new(), WriterOptions::new(Brand::Mp4));
|
||||
let v = w.add_track(TrackConfig::new(avc_entry(), 1000)).unwrap();
|
||||
for i in 0..6u8 {
|
||||
w.write_sample(v, WriteSample { data: &[i; 10], duration: 40, composition_offset: -(i as i32 % 2), is_sync: i % 3 == 0 }).unwrap();
|
||||
if i % 3 == 2 {
|
||||
w.flush_fragment().unwrap();
|
||||
}
|
||||
}
|
||||
let out = w.finish().unwrap();
|
||||
let f = crate::open(out.as_slice()).unwrap();
|
||||
assert!(f.fragmented);
|
||||
assert_eq!(f.fragment_count, 2);
|
||||
let t = &f.tracks[0];
|
||||
assert_eq!(t.samples.len(), 6);
|
||||
for (i, s) in t.samples.iter().enumerate() {
|
||||
assert_eq!(s.dts, 40 * i as i64);
|
||||
assert_eq!(s.pts, s.dts - (i as i64 % 2));
|
||||
assert_eq!(s.is_sync, i % 3 == 0);
|
||||
assert_eq!(f.read_sample(out.as_slice(), 0, i).unwrap(), vec![i as u8; 10]);
|
||||
}
|
||||
assert_eq!(t.sync_sample_before(5), 3);
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
//! Random-access byte sources for the demuxer.
|
||||
|
||||
use std::io;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Random-access, read-only byte source (file, in-memory buffer, web Blob…).
|
||||
///
|
||||
/// Implementations must be able to serve reads at arbitrary offsets through `&self`.
|
||||
pub trait ByteSource {
|
||||
/// Total length in bytes.
|
||||
fn len(&self) -> u64;
|
||||
/// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end.
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>;
|
||||
/// True if the source holds no bytes.
|
||||
fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
fn eof() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source")
|
||||
}
|
||||
|
||||
impl ByteSource for [u8] {
|
||||
fn len(&self) -> u64 {
|
||||
<[u8]>::len(self) as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
let start = usize::try_from(offset).map_err(|_| eof())?;
|
||||
let end = start.checked_add(buf.len()).ok_or_else(eof)?;
|
||||
let src = self.get(start..end).ok_or_else(eof)?;
|
||||
buf.copy_from_slice(src);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ByteSource for Vec<u8> {
|
||||
fn len(&self) -> u64 {
|
||||
self.as_slice().len() as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
self.as_slice().read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ByteSource + ?Sized> ByteSource for &T {
|
||||
fn len(&self) -> u64 {
|
||||
(**self).len()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
(**self).read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ByteSource + ?Sized> ByteSource for Arc<T> {
|
||||
fn len(&self) -> u64 {
|
||||
(**self).len()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
(**self).read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ByteSource + ?Sized> ByteSource for Box<T> {
|
||||
fn len(&self) -> u64 {
|
||||
(**self).len()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
(**self).read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl ByteSource for std::fs::File {
|
||||
fn len(&self) -> u64 {
|
||||
self.metadata().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
std::os::unix::fs::FileExt::read_exact_at(self, buf, offset)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> {
|
||||
use std::os::windows::fs::FileExt;
|
||||
while !buf.is_empty() {
|
||||
let n = self.seek_read(buf, offset)?;
|
||||
if n == 0 {
|
||||
return Err(eof());
|
||||
}
|
||||
offset += n as u64;
|
||||
buf = &mut buf[n..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
use std::io::{Read, Seek, SeekFrom};
|
||||
let mut f = self;
|
||||
f.seek(SeekFrom::Start(offset))?;
|
||||
f.read_exact(buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slice_reads() {
|
||||
let v = vec![1u8, 2, 3, 4];
|
||||
let mut b = [0u8; 2];
|
||||
v.read_at(1, &mut b).unwrap();
|
||||
assert_eq!(b, [2, 3]);
|
||||
assert!(v.read_at(3, &mut b).is_err());
|
||||
assert!(v.read_at(u64::MAX, &mut b).is_err());
|
||||
let a: Arc<[u8]> = Arc::from(v.clone());
|
||||
a.read_at(2, &mut b).unwrap();
|
||||
assert_eq!(b, [3, 4]);
|
||||
assert_eq!(ByteSource::len(&a), 4);
|
||||
let s: &[u8] = &v;
|
||||
assert_eq!(ByteSource::len(&s), 4);
|
||||
}
|
||||
}
|
||||
@@ -10,3 +10,4 @@ rust-version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-media = { workspace = true, features = ["matroska"] }
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
//! Matroska CodecID → codec mapping (Matroska codec specifications, RFC 9559 §5.1.4.1.21 and the
|
||||
//! codec mappings registry).
|
||||
|
||||
/// Codec of a track, derived from `CodecID` and `CodecPrivate`.
|
||||
///
|
||||
/// Configuration records are passed through as the raw bytes found in `CodecPrivate`, which for
|
||||
/// the ISO codecs are exactly the ISOBMFF configuration boxes' payloads (avcC, hvcC, av1C, ASC…).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum Codec {
|
||||
/// `V_MPEG4/ISO/AVC`; payload is an `AVCDecoderConfigurationRecord` (avcC).
|
||||
Avc { avcc: Vec<u8> },
|
||||
/// `V_MPEGH/ISO/HEVC`; payload is an `HEVCDecoderConfigurationRecord` (hvcC).
|
||||
Hevc { hvcc: Vec<u8> },
|
||||
/// `V_VP8`.
|
||||
Vp8,
|
||||
/// `V_VP9`; `CodecPrivate` (optional) holds VP9 codec feature metadata.
|
||||
Vp9 { private: Vec<u8> },
|
||||
/// `V_AV1`; payload is an `AV1CodecConfigurationRecord` (av1C).
|
||||
Av1 { av1c: Vec<u8> },
|
||||
/// `V_PRORES`; `fourcc` from `CodecPrivate` (e.g. `apcn`), if present.
|
||||
ProRes { fourcc: Option<[u8; 4]> },
|
||||
/// `V_MJPEG`, or `V_MS/VFW/FOURCC` with an `MJPG` compression FourCC.
|
||||
Mjpeg,
|
||||
/// `V_MS/VFW/FOURCC` with any other FourCC; `bitmap_info_header` is the whole `CodecPrivate`.
|
||||
VfwFourcc { fourcc: [u8; 4], bitmap_info_header: Vec<u8> },
|
||||
/// `A_AAC` (and the legacy `A_AAC/MPEG{2,4}/<profile>` IDs); `asc` is the
|
||||
/// `AudioSpecificConfig` (synthesised for legacy IDs without `CodecPrivate`).
|
||||
Aac { asc: Vec<u8> },
|
||||
/// `A_OPUS`; `head` is the `OpusHead` identification header.
|
||||
Opus { head: Vec<u8> },
|
||||
/// `A_VORBIS`; the three Vorbis headers (identification, comment, setup), split from the
|
||||
/// Xiph-laced `CodecPrivate`.
|
||||
Vorbis { headers: Vec<Vec<u8>> },
|
||||
/// `A_FLAC`; `private` is `fLaC` + metadata blocks (STREAMINFO first).
|
||||
Flac { private: Vec<u8> },
|
||||
/// `A_PCM/INT/LIT`, `A_PCM/INT/BIG`, `A_PCM/FLOAT/IEEE`. Bit depth comes from the Audio element.
|
||||
Pcm { float: bool, big_endian: bool, bits: u16 },
|
||||
/// `A_AC3`.
|
||||
Ac3,
|
||||
/// `A_EAC3`.
|
||||
Eac3,
|
||||
/// `A_MPEG/L3`.
|
||||
Mp3,
|
||||
/// `A_MPEG/L2`.
|
||||
Mp2,
|
||||
/// `S_TEXT/UTF8` (SubRip-style plain text).
|
||||
SubRip,
|
||||
/// `S_TEXT/WEBVTT`, or WebM's `D_WEBVTT/{SUBTITLES,CAPTIONS,DESCRIPTIONS,METADATA}`.
|
||||
WebVtt,
|
||||
/// `S_TEXT/ASS` / `S_TEXT/SSA`; `header` is the script header from `CodecPrivate`.
|
||||
Ass { header: Vec<u8>, ssa: bool },
|
||||
/// Any other CodecID (kept verbatim along with `Track::codec_private`).
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl Codec {
|
||||
/// Short codec name, matching FFmpeg's `codec_name` where one exists (useful for logs/UI).
|
||||
pub fn name(&self) -> String {
|
||||
match self {
|
||||
Codec::Avc { .. } => "h264".into(),
|
||||
Codec::Hevc { .. } => "hevc".into(),
|
||||
Codec::Vp8 => "vp8".into(),
|
||||
Codec::Vp9 { .. } => "vp9".into(),
|
||||
Codec::Av1 { .. } => "av1".into(),
|
||||
Codec::ProRes { .. } => "prores".into(),
|
||||
Codec::Mjpeg => "mjpeg".into(),
|
||||
Codec::VfwFourcc { fourcc, .. } => String::from_utf8_lossy(fourcc).trim().to_ascii_lowercase(),
|
||||
Codec::Aac { .. } => "aac".into(),
|
||||
Codec::Opus { .. } => "opus".into(),
|
||||
Codec::Vorbis { .. } => "vorbis".into(),
|
||||
Codec::Flac { .. } => "flac".into(),
|
||||
Codec::Pcm { float, big_endian, bits } => {
|
||||
let k = if *float {
|
||||
'f'
|
||||
} else if *bits == 8 {
|
||||
'u'
|
||||
} else {
|
||||
's'
|
||||
};
|
||||
if *bits == 8 { format!("pcm_{k}8") } else { format!("pcm_{k}{bits}{}", if *big_endian { "be" } else { "le" }) }
|
||||
}
|
||||
Codec::Ac3 => "ac3".into(),
|
||||
Codec::Eac3 => "eac3".into(),
|
||||
Codec::Mp3 => "mp3".into(),
|
||||
Codec::Mp2 => "mp2".into(),
|
||||
Codec::SubRip => "subrip".into(),
|
||||
Codec::WebVtt => "webvtt".into(),
|
||||
Codec::Ass { ssa, .. } => if *ssa { "ssa" } else { "ass" }.into(),
|
||||
Codec::Other(id) => id.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const AAC_RATES: [u32; 13] = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
|
||||
|
||||
/// Build a 2-byte (or 5-byte with SBR extension) AudioSpecificConfig for legacy `A_AAC/...` IDs.
|
||||
fn synth_asc(object_type: u8, rate: f64, out_rate: Option<f64>, channels: u64, sbr: bool) -> Vec<u8> {
|
||||
let rate_index = |r: f64| AAC_RATES.iter().position(|&x| x as f64 == r).unwrap_or(4) as u8;
|
||||
let ri = rate_index(rate);
|
||||
let ch = channels.min(7) as u8;
|
||||
let mut v = vec![(object_type << 3) | (ri >> 1), ((ri & 1) << 7) | (ch << 3)];
|
||||
if sbr {
|
||||
// backward-compatible explicit signalling: sync 0x2b7, SBR object type 5, flag, ext rate index
|
||||
let ext = rate_index(out_rate.unwrap_or(rate * 2.0));
|
||||
v.push(0x56);
|
||||
v.push(0xE5);
|
||||
v.push(0x80 | (ext << 3));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Split a Xiph-laced `CodecPrivate` (Vorbis/Theora headers).
|
||||
fn split_xiph(p: &[u8]) -> Vec<Vec<u8>> {
|
||||
let Some(&n) = p.first() else { return Vec::new() };
|
||||
let mut pos = 1;
|
||||
let mut sizes = Vec::new();
|
||||
for _ in 0..n {
|
||||
let mut s = 0usize;
|
||||
loop {
|
||||
let Some(&b) = p.get(pos) else { return Vec::new() };
|
||||
pos += 1;
|
||||
s += b as usize;
|
||||
if b != 255 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sizes.push(s);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for s in sizes {
|
||||
let Some(h) = p.get(pos..pos + s) else { return Vec::new() };
|
||||
out.push(h.to_vec());
|
||||
pos += s;
|
||||
}
|
||||
out.push(p[pos.min(p.len())..].to_vec());
|
||||
out
|
||||
}
|
||||
|
||||
/// Map a CodecID + CodecPrivate (+ audio params for legacy AAC / PCM) to a [`Codec`].
|
||||
pub(crate) fn map_codec(id: &str, private: &[u8], audio: Option<&crate::AudioInfo>) -> Codec {
|
||||
let p = private.to_vec();
|
||||
match id {
|
||||
"V_MPEG4/ISO/AVC" => Codec::Avc { avcc: p },
|
||||
"V_MPEGH/ISO/HEVC" => Codec::Hevc { hvcc: p },
|
||||
"V_VP8" => Codec::Vp8,
|
||||
"V_VP9" => Codec::Vp9 { private: p },
|
||||
"V_AV1" => Codec::Av1 { av1c: p },
|
||||
"V_PRORES" => Codec::ProRes { fourcc: private.get(..4).and_then(|f| f.try_into().ok()) },
|
||||
"V_MJPEG" => Codec::Mjpeg,
|
||||
"V_MS/VFW/FOURCC" => {
|
||||
let fourcc: [u8; 4] = private.get(16..20).and_then(|f| f.try_into().ok()).unwrap_or(*b" ");
|
||||
match &fourcc {
|
||||
b"MJPG" | b"mjpg" | b"AVRn" | b"AVDJ" => Codec::Mjpeg,
|
||||
b"H264" | b"h264" | b"avc1" | b"X264" | b"x264" => Codec::Avc { avcc: Vec::new() },
|
||||
b"apch" | b"apcn" | b"apcs" | b"apco" | b"ap4h" | b"ap4x" => Codec::ProRes { fourcc: Some(fourcc) },
|
||||
_ => Codec::VfwFourcc { fourcc, bitmap_info_header: p },
|
||||
}
|
||||
}
|
||||
"A_AAC" => Codec::Aac { asc: p },
|
||||
"A_OPUS" => Codec::Opus { head: p },
|
||||
"A_VORBIS" => Codec::Vorbis { headers: split_xiph(private) },
|
||||
"A_FLAC" => Codec::Flac { private: p },
|
||||
"A_PCM/INT/LIT" | "A_PCM/INT/BIG" | "A_PCM/FLOAT/IEEE" => {
|
||||
let bits = audio.and_then(|a| a.bit_depth).unwrap_or(if id == "A_PCM/FLOAT/IEEE" { 32 } else { 16 }) as u16;
|
||||
Codec::Pcm { float: id == "A_PCM/FLOAT/IEEE", big_endian: id == "A_PCM/INT/BIG", bits }
|
||||
}
|
||||
"A_AC3" | "A_AC3/BSID9" | "A_AC3/BSID10" => Codec::Ac3,
|
||||
"A_EAC3" => Codec::Eac3,
|
||||
"A_MPEG/L3" => Codec::Mp3,
|
||||
"A_MPEG/L2" => Codec::Mp2,
|
||||
"S_TEXT/UTF8" => Codec::SubRip,
|
||||
"S_TEXT/WEBVTT" | "D_WEBVTT/SUBTITLES" | "D_WEBVTT/CAPTIONS" | "D_WEBVTT/DESCRIPTIONS" | "D_WEBVTT/METADATA" => Codec::WebVtt,
|
||||
"S_TEXT/ASS" | "S_ASS" => Codec::Ass { header: p, ssa: false },
|
||||
"S_TEXT/SSA" | "S_SSA" => Codec::Ass { header: p, ssa: true },
|
||||
_ if id.starts_with("A_AAC/") => {
|
||||
if !private.is_empty() {
|
||||
return Codec::Aac { asc: p };
|
||||
}
|
||||
let (rate, out_rate, ch) = audio.map_or((48000.0, None, 2), |a| (a.sampling_frequency, a.output_sampling_frequency, a.channels));
|
||||
let object_type = if id.ends_with("/MAIN") {
|
||||
1
|
||||
} else if id.ends_with("/SSR") {
|
||||
3
|
||||
} else if id.ends_with("/LTP") {
|
||||
4
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let sbr = id.ends_with("/SBR");
|
||||
Codec::Aac { asc: synth_asc(object_type, rate, out_rate, ch, sbr) }
|
||||
}
|
||||
_ => Codec::Other(id.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mapping() {
|
||||
assert_eq!(map_codec("V_MPEG4/ISO/AVC", &[1, 2], None), Codec::Avc { avcc: vec![1, 2] });
|
||||
assert_eq!(map_codec("V_PRORES", b"apcn", None), Codec::ProRes { fourcc: Some(*b"apcn") });
|
||||
let mut bih = vec![0u8; 40];
|
||||
bih[16..20].copy_from_slice(b"MJPG");
|
||||
assert_eq!(map_codec("V_MS/VFW/FOURCC", &bih, None), Codec::Mjpeg);
|
||||
bih[16..20].copy_from_slice(b"FFV1");
|
||||
assert!(matches!(map_codec("V_MS/VFW/FOURCC", &bih, None), Codec::VfwFourcc { fourcc, .. } if &fourcc == b"FFV1"));
|
||||
let a = crate::AudioInfo { sampling_frequency: 44100.0, channels: 2, bit_depth: Some(24), output_sampling_frequency: None };
|
||||
assert_eq!(map_codec("A_PCM/INT/LIT", &[], Some(&a)).name(), "pcm_s24le");
|
||||
assert_eq!(map_codec("A_PCM/FLOAT/IEEE", &[], Some(&a)), Codec::Pcm { float: true, big_endian: false, bits: 24 });
|
||||
// legacy AAC LC 44.1k stereo → 0x12 0x10
|
||||
assert_eq!(map_codec("A_AAC/MPEG4/LC", &[], Some(&a)), Codec::Aac { asc: vec![0x12, 0x10] });
|
||||
assert_eq!(map_codec("S_TEXT/UTF8", &[], None).name(), "subrip");
|
||||
assert_eq!(map_codec("X_WHATEVER", &[], None), Codec::Other("X_WHATEVER".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xiph_split() {
|
||||
let mut p = vec![2u8, 3, 2];
|
||||
p.extend_from_slice(&[1, 1, 1, 2, 2, 3, 3, 3, 3]);
|
||||
let h = split_xiph(&p);
|
||||
assert_eq!(h, vec![vec![1, 1, 1], vec![2, 2], vec![3, 3, 3, 3]]);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,476 +0,0 @@
|
||||
//! EBML primitives (RFC 8794): variable-length integers, element headers, typed values, CRC-32,
|
||||
//! and Matroska block lacing (RFC 9559 §10.3).
|
||||
|
||||
use crate::error::{Result, invalid};
|
||||
|
||||
/// Length in bytes of a VINT whose first byte is `b` (1..=8), or `None` for a zero first byte.
|
||||
#[inline]
|
||||
pub fn vint_len(b: u8) -> Option<usize> {
|
||||
if b == 0 { None } else { Some(b.leading_zeros() as usize + 1) }
|
||||
}
|
||||
|
||||
/// Element data size: known, or "unknown" (all value bits set).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Size {
|
||||
Known(u64),
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Size {
|
||||
pub fn known(self) -> Option<u64> {
|
||||
match self {
|
||||
Size::Known(n) => Some(n),
|
||||
Size::Unknown => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode an unsigned VINT (marker bit stripped). Returns `(value, length, all_ones)`.
|
||||
pub fn read_vint(data: &[u8]) -> Option<(u64, usize, bool)> {
|
||||
let first = *data.first()?;
|
||||
let len = vint_len(first)?;
|
||||
if data.len() < len {
|
||||
return None;
|
||||
}
|
||||
let mask = if len == 8 { 0 } else { 0xFFu8 >> len };
|
||||
let mut v = (first & mask) as u64;
|
||||
for &b in &data[1..len] {
|
||||
v = (v << 8) | b as u64;
|
||||
}
|
||||
let all_ones = v == (1u64 << (7 * len)) - 1;
|
||||
Some((v, len, all_ones))
|
||||
}
|
||||
|
||||
/// Decode an element data size. All-ones is the reserved "unknown size".
|
||||
pub fn read_size(data: &[u8]) -> Option<(Size, usize)> {
|
||||
let (v, len, ones) = read_vint(data)?;
|
||||
Some((if ones { Size::Unknown } else { Size::Known(v) }, len))
|
||||
}
|
||||
|
||||
/// Decode an element ID (marker bits kept, 1..=4 bytes). Rejects all-ones value bits (reserved).
|
||||
/// All-zero value bits are accepted, because Matroska's `ChapterDisplay` uses ID `0x80`.
|
||||
pub fn read_id(data: &[u8]) -> Option<(u32, usize)> {
|
||||
let first = *data.first()?;
|
||||
let len = vint_len(first)?;
|
||||
if len > 4 || data.len() < len {
|
||||
return None;
|
||||
}
|
||||
let (v, _, ones) = read_vint(&data[..len])?;
|
||||
if ones || (v == 0 && len > 1) {
|
||||
return None;
|
||||
}
|
||||
let mut id = 0u32;
|
||||
for &b in &data[..len] {
|
||||
id = (id << 8) | b as u32;
|
||||
}
|
||||
Some((id, len))
|
||||
}
|
||||
|
||||
/// Decode a signed VINT as used by EBML lacing (value − (2^(7·len−1) − 1)).
|
||||
pub fn read_svint(data: &[u8]) -> Option<(i64, usize)> {
|
||||
let (v, len, _) = read_vint(data)?;
|
||||
let bias = (1i64 << (7 * len - 1)) - 1;
|
||||
Some((v as i64 - bias, len))
|
||||
}
|
||||
|
||||
/// Encode an element ID (already carrying its marker bits).
|
||||
pub fn write_id(out: &mut Vec<u8>, id: u32) {
|
||||
let n = if id >= 0x100_0000 {
|
||||
4
|
||||
} else if id >= 0x1_0000 {
|
||||
3
|
||||
} else if id >= 0x100 {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
out.extend_from_slice(&id.to_be_bytes()[4 - n..]);
|
||||
}
|
||||
|
||||
/// Encode a data size as a VINT using the shortest length (or `min_len` if larger).
|
||||
pub fn write_size(out: &mut Vec<u8>, size: u64, min_len: usize) {
|
||||
let mut len = 1;
|
||||
while len < 8 && size >= (1u64 << (7 * len)) - 1 {
|
||||
len += 1;
|
||||
}
|
||||
let len = len.max(min_len).min(8);
|
||||
let v = size | (1u64 << (7 * len));
|
||||
out.extend_from_slice(&v.to_be_bytes()[8 - len..]);
|
||||
}
|
||||
|
||||
/// Encode the "unknown size" marker of the given length.
|
||||
pub fn write_unknown_size(out: &mut Vec<u8>, len: usize) {
|
||||
out.push(0xFFu8 >> (len - 1));
|
||||
out.extend(std::iter::repeat_n(0xFF, len - 1));
|
||||
}
|
||||
|
||||
/// Big-endian unsigned integer of 0..=8 bytes.
|
||||
pub fn uint(data: &[u8]) -> u64 {
|
||||
data.iter().take(8).fold(0u64, |a, &b| (a << 8) | b as u64)
|
||||
}
|
||||
|
||||
/// Big-endian two's-complement signed integer of 0..=8 bytes.
|
||||
pub fn int(data: &[u8]) -> i64 {
|
||||
let n = data.len().min(8);
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
let v = uint(&data[..n]);
|
||||
let shift = 64 - 8 * n as u32;
|
||||
((v << shift) as i64) >> shift
|
||||
}
|
||||
|
||||
/// IEEE float of 0, 4 or 8 bytes.
|
||||
pub fn float(data: &[u8]) -> f64 {
|
||||
match data.len() {
|
||||
4 => f32::from_be_bytes(data.try_into().unwrap_or([0; 4])) as f64,
|
||||
8 => f64::from_be_bytes(data.try_into().unwrap_or([0; 8])),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// String / UTF-8 value: bytes up to the first NUL, lossily decoded.
|
||||
pub fn string(data: &[u8]) -> String {
|
||||
let end = data.iter().position(|&b| b == 0).unwrap_or(data.len());
|
||||
String::from_utf8_lossy(&data[..end]).into_owned()
|
||||
}
|
||||
|
||||
/// One element inside an in-memory master element.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Elem<'a> {
|
||||
pub id: u32,
|
||||
/// Offset of the element header, relative to the start of the parent's data.
|
||||
pub offset: usize,
|
||||
pub header_len: usize,
|
||||
pub data: &'a [u8],
|
||||
/// The size field was "unknown" (data runs to the end of the parent).
|
||||
pub unknown_size: bool,
|
||||
/// The declared size overran the parent and was truncated.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// Iterator over the children of an in-memory master element.
|
||||
///
|
||||
/// Damaged data ends the iteration (see [`Children::damaged`]); oversize elements are truncated to
|
||||
/// the parent's end.
|
||||
pub struct Children<'a> {
|
||||
data: &'a [u8],
|
||||
pos: usize,
|
||||
damaged: bool,
|
||||
}
|
||||
|
||||
impl<'a> Children<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Self {
|
||||
Self { data, pos: 0, damaged: false }
|
||||
}
|
||||
/// True if iteration stopped on an undecodable header.
|
||||
pub fn damaged(&self) -> bool {
|
||||
self.damaged
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> Iterator for Children<'a> {
|
||||
type Item = Elem<'a>;
|
||||
fn next(&mut self) -> Option<Elem<'a>> {
|
||||
let rest = self.data.get(self.pos..)?;
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let Some((id, il)) = read_id(rest) else {
|
||||
self.damaged = true;
|
||||
self.pos = self.data.len();
|
||||
return None;
|
||||
};
|
||||
let Some((size, sl)) = read_size(&rest[il..]) else {
|
||||
self.damaged = true;
|
||||
self.pos = self.data.len();
|
||||
return None;
|
||||
};
|
||||
let hl = il + sl;
|
||||
let avail = (rest.len() - hl) as u64;
|
||||
let (len, unknown, truncated) = match size {
|
||||
Size::Known(n) if n <= avail => (n as usize, false, false),
|
||||
Size::Known(_) => (avail as usize, false, true),
|
||||
Size::Unknown => (avail as usize, true, false),
|
||||
};
|
||||
let e = Elem { id, offset: self.pos, header_len: hl, data: &rest[hl..hl + len], unknown_size: unknown, truncated };
|
||||
self.pos += hl + len;
|
||||
Some(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// CRC-32 (ISO 3309 / IEEE 802.3, reflected polynomial 0xEDB88320), as used by EBML CRC-32 elements.
|
||||
pub fn crc32(data: &[u8]) -> u32 {
|
||||
crc32_update(0xFFFF_FFFF, data) ^ 0xFFFF_FFFF
|
||||
}
|
||||
|
||||
/// Incremental form: start with `0xFFFF_FFFF`, finish with `^ 0xFFFF_FFFF`.
|
||||
pub fn crc32_update(mut crc: u32, data: &[u8]) -> u32 {
|
||||
static TABLE: std::sync::OnceLock<[u32; 256]> = std::sync::OnceLock::new();
|
||||
let t = TABLE.get_or_init(|| {
|
||||
let mut t = [0u32; 256];
|
||||
for (i, e) in t.iter_mut().enumerate() {
|
||||
let mut c = i as u32;
|
||||
for _ in 0..8 {
|
||||
c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 };
|
||||
}
|
||||
*e = c;
|
||||
}
|
||||
t
|
||||
});
|
||||
for &b in data {
|
||||
crc = t[((crc ^ b as u32) & 0xFF) as usize] ^ (crc >> 8);
|
||||
}
|
||||
crc
|
||||
}
|
||||
|
||||
/// Check a master element's leading CRC-32 child, if present. `Some(true/false)` if there is one.
|
||||
pub fn verify_crc(master_data: &[u8]) -> Option<bool> {
|
||||
let mut it = Children::new(master_data);
|
||||
let first = it.next()?;
|
||||
if first.id != crate::ids::CRC32 || first.data.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
let stored = u32::from_le_bytes(first.data.try_into().ok()?);
|
||||
let rest = &master_data[first.offset + first.header_len + 4..];
|
||||
Some(crc32(rest) == stored)
|
||||
}
|
||||
|
||||
/// Block lacing mode (flags bits 1–2).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Lacing {
|
||||
None,
|
||||
Xiph,
|
||||
FixedSize,
|
||||
Ebml,
|
||||
}
|
||||
|
||||
impl Lacing {
|
||||
pub fn from_flags(flags: u8) -> Lacing {
|
||||
match (flags >> 1) & 3 {
|
||||
0 => Lacing::None,
|
||||
1 => Lacing::Xiph,
|
||||
2 => Lacing::FixedSize,
|
||||
_ => Lacing::Ebml,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Split laced block payload. `data` starts right after the block's flags byte and runs to the end
|
||||
/// of the block. Returns `(offset, len)` of each frame relative to `data`.
|
||||
pub fn parse_lacing(lacing: Lacing, data: &[u8]) -> Result<Vec<(usize, usize)>> {
|
||||
if lacing == Lacing::None {
|
||||
return Ok(vec![(0, data.len())]);
|
||||
}
|
||||
let Some(&n1) = data.first() else { return invalid("laced block without frame count") };
|
||||
let count = n1 as usize + 1;
|
||||
let mut pos = 1usize;
|
||||
let mut sizes = Vec::with_capacity(count);
|
||||
match lacing {
|
||||
Lacing::None => return Ok(vec![(0, data.len())]),
|
||||
Lacing::Xiph => {
|
||||
for _ in 0..count - 1 {
|
||||
let mut s = 0usize;
|
||||
loop {
|
||||
let Some(&b) = data.get(pos) else { return invalid("truncated Xiph lace header") };
|
||||
pos += 1;
|
||||
s += b as usize;
|
||||
if b != 255 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
sizes.push(s);
|
||||
}
|
||||
}
|
||||
Lacing::Ebml => {
|
||||
if count > 1 {
|
||||
let Some((first, l, _)) = read_vint(&data[pos..]) else { return invalid("bad EBML lace size") };
|
||||
pos += l;
|
||||
let mut prev = first as i64;
|
||||
sizes.push(first as usize);
|
||||
for _ in 1..count - 1 {
|
||||
let Some((d, l)) = read_svint(&data[pos..]) else { return invalid("bad EBML lace delta") };
|
||||
pos += l;
|
||||
prev += d;
|
||||
if prev < 0 {
|
||||
return invalid("negative EBML lace size");
|
||||
}
|
||||
sizes.push(prev as usize);
|
||||
}
|
||||
}
|
||||
}
|
||||
Lacing::FixedSize => {
|
||||
let rest = data.len() - pos;
|
||||
if !rest.is_multiple_of(count) {
|
||||
return invalid("fixed-size lacing does not divide block");
|
||||
}
|
||||
sizes.extend(std::iter::repeat_n(rest / count, count - 1));
|
||||
}
|
||||
}
|
||||
let used: usize = sizes.iter().try_fold(0usize, |a, &s| a.checked_add(s)).unwrap_or(usize::MAX);
|
||||
if pos > data.len() || used > data.len() - pos {
|
||||
return invalid("lace sizes exceed block");
|
||||
}
|
||||
sizes.push(data.len() - pos - used);
|
||||
let mut out = Vec::with_capacity(count);
|
||||
for s in sizes {
|
||||
out.push((pos, s));
|
||||
pos += s;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn vint_roundtrip() {
|
||||
for &v in &[0u64, 1, 126, 127, 128, 16382, 16383, 16384, 1 << 20, (1 << 56) - 2] {
|
||||
let mut b = Vec::new();
|
||||
write_size(&mut b, v, 1);
|
||||
let (s, l) = read_size(&b).unwrap();
|
||||
assert_eq!(s, Size::Known(v), "value {v}");
|
||||
assert_eq!(l, b.len());
|
||||
}
|
||||
// 127 needs 2 bytes because 0xFF is "unknown"
|
||||
let mut b = Vec::new();
|
||||
write_size(&mut b, 127, 1);
|
||||
assert_eq!(b, [0x40, 0x7F]);
|
||||
// padded length
|
||||
let mut b = Vec::new();
|
||||
write_size(&mut b, 5, 8);
|
||||
assert_eq!(b, [1, 0, 0, 0, 0, 0, 0, 5]);
|
||||
assert_eq!(read_size(&b).unwrap(), (Size::Known(5), 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vint_unknown_and_invalid() {
|
||||
assert_eq!(read_size(&[0xFF]).unwrap(), (Size::Unknown, 1));
|
||||
assert_eq!(read_size(&[0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF]).unwrap(), (Size::Unknown, 8));
|
||||
for l in 1..=8 {
|
||||
let mut b = Vec::new();
|
||||
write_unknown_size(&mut b, l);
|
||||
assert_eq!(read_size(&b).unwrap(), (Size::Unknown, l));
|
||||
}
|
||||
assert!(read_size(&[0x00, 1, 2]).is_none());
|
||||
assert!(read_size(&[0x40]).is_none()); // truncated
|
||||
assert_eq!(read_vint(&[0x81]).unwrap(), (1, 1, false));
|
||||
assert_eq!(read_vint(&[0x10, 0x00, 0x00, 0x01]).unwrap(), (1, 4, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids() {
|
||||
assert_eq!(read_id(&[0x1A, 0x45, 0xDF, 0xA3, 0x99]).unwrap(), (crate::ids::EBML, 4));
|
||||
assert_eq!(read_id(&[0xA3]).unwrap(), (0xA3, 1));
|
||||
assert_eq!(read_id(&[0x42, 0x86]).unwrap(), (0x4286, 2));
|
||||
assert!(read_id(&[0xFF]).is_none()); // reserved
|
||||
assert_eq!(read_id(&[0x80]).unwrap(), (0x80, 1)); // ChapterDisplay
|
||||
assert!(read_id(&[0x40, 0x00]).is_none()); // zero value
|
||||
assert!(read_id(&[0x08, 0, 0, 0, 1]).is_none()); // 5-byte ID
|
||||
for id in [0xA3u32, 0x4286, 0x2AD7B1, 0x1F43B675] {
|
||||
let mut b = Vec::new();
|
||||
write_id(&mut b, id);
|
||||
assert_eq!(read_id(&b).unwrap(), (id, b.len()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_vint() {
|
||||
assert_eq!(read_svint(&[0xBF]).unwrap(), (0, 1)); // 63 - 63
|
||||
assert_eq!(read_svint(&[0x80]).unwrap(), (-63, 1));
|
||||
assert_eq!(read_svint(&[0xC0]).unwrap(), (1, 1));
|
||||
assert_eq!(read_svint(&[0x5F, 0xFF]).unwrap(), (0, 2));
|
||||
assert_eq!(read_svint(&[0x60, 0x00]).unwrap(), (1, 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn values() {
|
||||
assert_eq!(uint(&[]), 0);
|
||||
assert_eq!(uint(&[1, 0]), 256);
|
||||
assert_eq!(int(&[0xFF]), -1);
|
||||
assert_eq!(int(&[0xFF, 0xFE]), -2);
|
||||
assert_eq!(int(&[0x7F]), 127);
|
||||
assert_eq!(float(&1.5f32.to_be_bytes()), 1.5);
|
||||
assert_eq!(float(&2.25f64.to_be_bytes()), 2.25);
|
||||
assert_eq!(string(b"abc\0\0"), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc() {
|
||||
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
|
||||
let payload = [0x42u8, 0x86, 0x81, 0x01];
|
||||
let mut m = vec![0xBF, 0x84];
|
||||
m.extend_from_slice(&crc32(&payload).to_le_bytes());
|
||||
m.extend_from_slice(&payload);
|
||||
assert_eq!(verify_crc(&m), Some(true));
|
||||
m[7] ^= 1;
|
||||
assert_eq!(verify_crc(&m), Some(false));
|
||||
assert_eq!(verify_crc(&payload), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn children_iter() {
|
||||
// two elements, then one whose size overruns
|
||||
let d = [0x42, 0x86, 0x81, 0x01, 0xEC, 0x82, 0, 0, 0xA3, 0x85, 1, 2];
|
||||
let v: Vec<_> = Children::new(&d).collect();
|
||||
assert_eq!(v.len(), 3);
|
||||
assert_eq!(v[0].id, 0x4286);
|
||||
assert_eq!(v[0].data, &[1]);
|
||||
assert_eq!(v[1].id, 0xEC);
|
||||
assert_eq!(v[2].data, &[1, 2]);
|
||||
assert!(v[2].truncated);
|
||||
let mut it = Children::new(&[0x00, 0x01]);
|
||||
assert!(it.next().is_none());
|
||||
assert!(it.damaged());
|
||||
}
|
||||
|
||||
fn frames<'a>(d: &'a [u8], f: &[(usize, usize)]) -> Vec<&'a [u8]> {
|
||||
f.iter().map(|&(o, l)| &d[o..o + l]).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lacing_xiph() {
|
||||
// 3 frames: 300, 2, rest(3)
|
||||
let mut d = vec![2u8, 255, 45, 2];
|
||||
d.extend(std::iter::repeat_n(7u8, 300));
|
||||
d.extend_from_slice(&[1, 2]);
|
||||
d.extend_from_slice(&[3, 4, 5]);
|
||||
let f = parse_lacing(Lacing::Xiph, &d).unwrap();
|
||||
assert_eq!(f.iter().map(|x| x.1).collect::<Vec<_>>(), [300, 2, 3]);
|
||||
assert_eq!(frames(&d, &f)[2], &[3, 4, 5]);
|
||||
// exact multiple of 255 needs a terminating 0
|
||||
let mut d = vec![1u8, 255, 0];
|
||||
d.extend(std::iter::repeat_n(1u8, 255));
|
||||
d.push(9);
|
||||
let f = parse_lacing(Lacing::Xiph, &d).unwrap();
|
||||
assert_eq!(f.iter().map(|x| x.1).collect::<Vec<_>>(), [255, 1]);
|
||||
assert!(parse_lacing(Lacing::Xiph, &[1, 200, 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lacing_ebml() {
|
||||
// 4 frames: 5, 3 (delta -2), 4 (delta +1), rest 2
|
||||
let mut d = vec![3u8, 0x85, 0xBF - 2, 0xBF + 1];
|
||||
d.extend_from_slice(&[1; 5]);
|
||||
d.extend_from_slice(&[2; 3]);
|
||||
d.extend_from_slice(&[3; 4]);
|
||||
d.extend_from_slice(&[4; 2]);
|
||||
let f = parse_lacing(Lacing::Ebml, &d).unwrap();
|
||||
assert_eq!(f.iter().map(|x| x.1).collect::<Vec<_>>(), [5, 3, 4, 2]);
|
||||
assert_eq!(frames(&d, &f), [&[1u8; 5][..], &[2; 3], &[3; 4], &[4; 2]]);
|
||||
assert!(parse_lacing(Lacing::Ebml, &[1, 0x90]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lacing_fixed_and_none() {
|
||||
let d = [2u8, 1, 1, 2, 2, 3, 3];
|
||||
let f = parse_lacing(Lacing::FixedSize, &d).unwrap();
|
||||
assert_eq!(frames(&d, &f), [&[1u8, 1][..], &[2, 2], &[3, 3]]);
|
||||
assert!(parse_lacing(Lacing::FixedSize, &[2, 1, 1]).is_err());
|
||||
assert_eq!(parse_lacing(Lacing::None, &[9, 9]).unwrap(), [(0, 2)]);
|
||||
assert_eq!(Lacing::from_flags(0x80 | 0x06), Lacing::Ebml);
|
||||
assert_eq!(Lacing::from_flags(0x02), Lacing::Xiph);
|
||||
assert_eq!(Lacing::from_flags(0x04), Lacing::FixedSize);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
|
||||
/// Errors produced by the demuxer.
|
||||
#[derive(Debug)]
|
||||
pub enum Error {
|
||||
/// Underlying I/O failure.
|
||||
Io(io::Error),
|
||||
/// Structurally invalid data that could not be recovered from.
|
||||
Invalid(String),
|
||||
/// Valid but unsupported feature (e.g. zlib-compressed or encrypted tracks when reading data).
|
||||
Unsupported(String),
|
||||
/// Not an EBML file, or an EBML file whose DocType is not `matroska`/`webm`.
|
||||
NotMatroska(String),
|
||||
/// A track index was out of range.
|
||||
NoSuchTrack(usize),
|
||||
/// A sample index was out of range.
|
||||
NoSuchSample(usize),
|
||||
}
|
||||
|
||||
impl fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Error::Io(e) => write!(f, "I/O error: {e}"),
|
||||
Error::Invalid(s) => write!(f, "invalid data: {s}"),
|
||||
Error::Unsupported(s) => write!(f, "unsupported: {s}"),
|
||||
Error::NotMatroska(s) => write!(f, "not a Matroska/WebM file: {s}"),
|
||||
Error::NoSuchTrack(i) => write!(f, "track index {i} out of range"),
|
||||
Error::NoSuchSample(i) => write!(f, "sample index {i} out of range"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {
|
||||
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
|
||||
match self {
|
||||
Error::Io(e) => Some(e),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<io::Error> for Error {
|
||||
fn from(e: io::Error) -> Self {
|
||||
Error::Io(e)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
pub(crate) fn invalid<T>(msg: impl Into<String>) -> Result<T> {
|
||||
Err(Error::Invalid(msg.into()))
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
//! EBML / Matroska element IDs (RFC 8794, RFC 9559). IDs keep their VINT marker bits.
|
||||
#![allow(dead_code)]
|
||||
|
||||
// EBML header + global elements
|
||||
pub const EBML: u32 = 0x1A45_DFA3;
|
||||
pub const EBML_VERSION: u32 = 0x4286;
|
||||
pub const EBML_READ_VERSION: u32 = 0x42F7;
|
||||
pub const EBML_MAX_ID_LENGTH: u32 = 0x42F2;
|
||||
pub const EBML_MAX_SIZE_LENGTH: u32 = 0x42F3;
|
||||
pub const DOC_TYPE: u32 = 0x4282;
|
||||
pub const DOC_TYPE_VERSION: u32 = 0x4287;
|
||||
pub const DOC_TYPE_READ_VERSION: u32 = 0x4285;
|
||||
pub const CRC32: u32 = 0xBF;
|
||||
pub const VOID: u32 = 0xEC;
|
||||
|
||||
// Segment and level-1 elements
|
||||
pub const SEGMENT: u32 = 0x1853_8067;
|
||||
pub const SEEK_HEAD: u32 = 0x114D_9B74;
|
||||
pub const INFO: u32 = 0x1549_A966;
|
||||
pub const TRACKS: u32 = 0x1654_AE6B;
|
||||
pub const CLUSTER: u32 = 0x1F43_B675;
|
||||
pub const CUES: u32 = 0x1C53_BB6B;
|
||||
pub const ATTACHMENTS: u32 = 0x1941_A469;
|
||||
pub const CHAPTERS: u32 = 0x1043_A770;
|
||||
pub const TAGS: u32 = 0x1254_C367;
|
||||
|
||||
/// True for IDs that can only appear directly inside a Segment (or are the EBML/Segment headers):
|
||||
/// used to find the end of unknown-size elements.
|
||||
pub fn is_top_level(id: u32) -> bool {
|
||||
matches!(id, EBML | SEGMENT | SEEK_HEAD | INFO | TRACKS | CLUSTER | CUES | ATTACHMENTS | CHAPTERS | TAGS)
|
||||
}
|
||||
|
||||
// SeekHead
|
||||
pub const SEEK: u32 = 0x4DBB;
|
||||
pub const SEEK_ID: u32 = 0x53AB;
|
||||
pub const SEEK_POSITION: u32 = 0x53AC;
|
||||
|
||||
// Info
|
||||
pub const SEGMENT_UUID: u32 = 0x73A4;
|
||||
pub const SEGMENT_FILENAME: u32 = 0x7384;
|
||||
pub const TIMESTAMP_SCALE: u32 = 0x2A_D7B1;
|
||||
pub const DURATION: u32 = 0x4489;
|
||||
pub const DATE_UTC: u32 = 0x4461;
|
||||
pub const TITLE: u32 = 0x7BA9;
|
||||
pub const MUXING_APP: u32 = 0x4D80;
|
||||
pub const WRITING_APP: u32 = 0x5741;
|
||||
|
||||
// Cluster
|
||||
pub const TIMESTAMP: u32 = 0xE7;
|
||||
pub const POSITION: u32 = 0xA7;
|
||||
pub const PREV_SIZE: u32 = 0xAB;
|
||||
pub const SIMPLE_BLOCK: u32 = 0xA3;
|
||||
pub const BLOCK_GROUP: u32 = 0xA0;
|
||||
pub const ENCRYPTED_BLOCK: u32 = 0xAF;
|
||||
pub const SILENT_TRACKS: u32 = 0x5854;
|
||||
pub const BLOCK: u32 = 0xA1;
|
||||
pub const BLOCK_ADDITIONS: u32 = 0x75A1;
|
||||
pub const BLOCK_DURATION: u32 = 0x9B;
|
||||
pub const REFERENCE_PRIORITY: u32 = 0xFA;
|
||||
pub const REFERENCE_BLOCK: u32 = 0xFB;
|
||||
pub const CODEC_STATE: u32 = 0xA4;
|
||||
pub const DISCARD_PADDING: u32 = 0x75A2;
|
||||
|
||||
pub fn is_cluster_child(id: u32) -> bool {
|
||||
matches!(id, TIMESTAMP | POSITION | PREV_SIZE | SIMPLE_BLOCK | BLOCK_GROUP | ENCRYPTED_BLOCK | SILENT_TRACKS | CRC32 | VOID)
|
||||
}
|
||||
|
||||
// Tracks
|
||||
pub const TRACK_ENTRY: u32 = 0xAE;
|
||||
pub const TRACK_NUMBER: u32 = 0xD7;
|
||||
pub const TRACK_UID: u32 = 0x73C5;
|
||||
pub const TRACK_TYPE: u32 = 0x83;
|
||||
pub const FLAG_ENABLED: u32 = 0xB9;
|
||||
pub const FLAG_DEFAULT: u32 = 0x88;
|
||||
pub const FLAG_FORCED: u32 = 0x55AA;
|
||||
pub const FLAG_HEARING_IMPAIRED: u32 = 0x55AB;
|
||||
pub const FLAG_VISUAL_IMPAIRED: u32 = 0x55AC;
|
||||
pub const FLAG_ORIGINAL: u32 = 0x55AE;
|
||||
pub const FLAG_COMMENTARY: u32 = 0x55AF;
|
||||
pub const FLAG_LACING: u32 = 0x9C;
|
||||
pub const DEFAULT_DURATION: u32 = 0x23_E383;
|
||||
pub const TRACK_TIMESTAMP_SCALE: u32 = 0x23_314F;
|
||||
pub const NAME: u32 = 0x536E;
|
||||
pub const LANGUAGE: u32 = 0x22_B59C;
|
||||
pub const LANGUAGE_BCP47: u32 = 0x22_B59D;
|
||||
pub const CODEC_ID: u32 = 0x86;
|
||||
pub const CODEC_PRIVATE: u32 = 0x63A2;
|
||||
pub const CODEC_NAME: u32 = 0x25_8688;
|
||||
pub const CODEC_DELAY: u32 = 0x56AA;
|
||||
pub const SEEK_PRE_ROLL: u32 = 0x56BB;
|
||||
|
||||
// Video
|
||||
pub const VIDEO: u32 = 0xE0;
|
||||
pub const FLAG_INTERLACED: u32 = 0x9A;
|
||||
pub const FIELD_ORDER: u32 = 0x9D;
|
||||
pub const STEREO_MODE: u32 = 0x53B8;
|
||||
pub const ALPHA_MODE: u32 = 0x53C0;
|
||||
pub const PIXEL_WIDTH: u32 = 0xB0;
|
||||
pub const PIXEL_HEIGHT: u32 = 0xBA;
|
||||
pub const PIXEL_CROP_BOTTOM: u32 = 0x54AA;
|
||||
pub const PIXEL_CROP_TOP: u32 = 0x54BB;
|
||||
pub const PIXEL_CROP_LEFT: u32 = 0x54CC;
|
||||
pub const PIXEL_CROP_RIGHT: u32 = 0x54DD;
|
||||
pub const DISPLAY_WIDTH: u32 = 0x54B0;
|
||||
pub const DISPLAY_HEIGHT: u32 = 0x54BA;
|
||||
pub const DISPLAY_UNIT: u32 = 0x54B2;
|
||||
pub const COLOUR_SPACE: u32 = 0x2E_B524;
|
||||
pub const COLOUR: u32 = 0x55B0;
|
||||
pub const PROJECTION: u32 = 0x7670;
|
||||
pub const PROJECTION_TYPE: u32 = 0x7671;
|
||||
pub const PROJECTION_POSE_YAW: u32 = 0x7673;
|
||||
pub const PROJECTION_POSE_PITCH: u32 = 0x7674;
|
||||
pub const PROJECTION_POSE_ROLL: u32 = 0x7675;
|
||||
|
||||
// Colour
|
||||
pub const MATRIX_COEFFICIENTS: u32 = 0x55B1;
|
||||
pub const BITS_PER_CHANNEL: u32 = 0x55B2;
|
||||
pub const CHROMA_SUBSAMPLING_HORZ: u32 = 0x55B3;
|
||||
pub const CHROMA_SUBSAMPLING_VERT: u32 = 0x55B4;
|
||||
pub const CB_SUBSAMPLING_HORZ: u32 = 0x55B5;
|
||||
pub const CB_SUBSAMPLING_VERT: u32 = 0x55B6;
|
||||
pub const CHROMA_SITING_HORZ: u32 = 0x55B7;
|
||||
pub const CHROMA_SITING_VERT: u32 = 0x55B8;
|
||||
pub const RANGE: u32 = 0x55B9;
|
||||
pub const TRANSFER_CHARACTERISTICS: u32 = 0x55BA;
|
||||
pub const PRIMARIES: u32 = 0x55BB;
|
||||
pub const MAX_CLL: u32 = 0x55BC;
|
||||
pub const MAX_FALL: u32 = 0x55BD;
|
||||
pub const MASTERING_METADATA: u32 = 0x55D0;
|
||||
pub const PRIMARY_R_X: u32 = 0x55D1;
|
||||
pub const PRIMARY_R_Y: u32 = 0x55D2;
|
||||
pub const PRIMARY_G_X: u32 = 0x55D3;
|
||||
pub const PRIMARY_G_Y: u32 = 0x55D4;
|
||||
pub const PRIMARY_B_X: u32 = 0x55D5;
|
||||
pub const PRIMARY_B_Y: u32 = 0x55D6;
|
||||
pub const WHITE_POINT_X: u32 = 0x55D7;
|
||||
pub const WHITE_POINT_Y: u32 = 0x55D8;
|
||||
pub const LUMINANCE_MAX: u32 = 0x55D9;
|
||||
pub const LUMINANCE_MIN: u32 = 0x55DA;
|
||||
|
||||
// Audio
|
||||
pub const AUDIO: u32 = 0xE1;
|
||||
pub const SAMPLING_FREQUENCY: u32 = 0xB5;
|
||||
pub const OUTPUT_SAMPLING_FREQUENCY: u32 = 0x78B5;
|
||||
pub const CHANNELS: u32 = 0x9F;
|
||||
pub const BIT_DEPTH: u32 = 0x6264;
|
||||
|
||||
// ContentEncodings
|
||||
pub const CONTENT_ENCODINGS: u32 = 0x6D80;
|
||||
pub const CONTENT_ENCODING: u32 = 0x6240;
|
||||
pub const CONTENT_ENCODING_ORDER: u32 = 0x5031;
|
||||
pub const CONTENT_ENCODING_SCOPE: u32 = 0x5032;
|
||||
pub const CONTENT_ENCODING_TYPE: u32 = 0x5033;
|
||||
pub const CONTENT_COMPRESSION: u32 = 0x5034;
|
||||
pub const CONTENT_COMP_ALGO: u32 = 0x4254;
|
||||
pub const CONTENT_COMP_SETTINGS: u32 = 0x4255;
|
||||
pub const CONTENT_ENCRYPTION: u32 = 0x5035;
|
||||
|
||||
// Cues
|
||||
pub const CUE_POINT: u32 = 0xBB;
|
||||
pub const CUE_TIME: u32 = 0xB3;
|
||||
pub const CUE_TRACK_POSITIONS: u32 = 0xB7;
|
||||
pub const CUE_TRACK: u32 = 0xF7;
|
||||
pub const CUE_CLUSTER_POSITION: u32 = 0xF1;
|
||||
pub const CUE_RELATIVE_POSITION: u32 = 0xF0;
|
||||
pub const CUE_DURATION: u32 = 0xB2;
|
||||
pub const CUE_BLOCK_NUMBER: u32 = 0x5378;
|
||||
|
||||
// Attachments
|
||||
pub const ATTACHED_FILE: u32 = 0x61A7;
|
||||
pub const FILE_DESCRIPTION: u32 = 0x467E;
|
||||
pub const FILE_NAME: u32 = 0x466E;
|
||||
pub const FILE_MEDIA_TYPE: u32 = 0x4660;
|
||||
pub const FILE_DATA: u32 = 0x465C;
|
||||
pub const FILE_UID: u32 = 0x46AE;
|
||||
|
||||
// Chapters
|
||||
pub const EDITION_ENTRY: u32 = 0x45B9;
|
||||
pub const EDITION_UID: u32 = 0x45BC;
|
||||
pub const EDITION_FLAG_HIDDEN: u32 = 0x45BD;
|
||||
pub const EDITION_FLAG_DEFAULT: u32 = 0x45DB;
|
||||
pub const EDITION_FLAG_ORDERED: u32 = 0x45DD;
|
||||
pub const CHAPTER_ATOM: u32 = 0xB6;
|
||||
pub const CHAPTER_UID: u32 = 0x73C4;
|
||||
pub const CHAPTER_STRING_UID: u32 = 0x5654;
|
||||
pub const CHAPTER_TIME_START: u32 = 0x91;
|
||||
pub const CHAPTER_TIME_END: u32 = 0x92;
|
||||
pub const CHAPTER_FLAG_HIDDEN: u32 = 0x98;
|
||||
pub const CHAPTER_FLAG_ENABLED: u32 = 0x4598;
|
||||
pub const CHAPTER_DISPLAY: u32 = 0x80;
|
||||
pub const CHAP_STRING: u32 = 0x85;
|
||||
pub const CHAP_LANGUAGE: u32 = 0x437C;
|
||||
pub const CHAP_LANGUAGE_BCP47: u32 = 0x437D;
|
||||
pub const CHAP_COUNTRY: u32 = 0x437E;
|
||||
|
||||
// Tags
|
||||
pub const TAG: u32 = 0x7373;
|
||||
pub const TARGETS: u32 = 0x63C0;
|
||||
pub const TARGET_TYPE_VALUE: u32 = 0x68CA;
|
||||
pub const TARGET_TYPE: u32 = 0x63CA;
|
||||
pub const TAG_TRACK_UID: u32 = 0x63C5;
|
||||
pub const TAG_EDITION_UID: u32 = 0x63C9;
|
||||
pub const TAG_CHAPTER_UID: u32 = 0x63C4;
|
||||
pub const TAG_ATTACHMENT_UID: u32 = 0x63C6;
|
||||
pub const SIMPLE_TAG: u32 = 0x67C8;
|
||||
pub const TAG_NAME: u32 = 0x45A3;
|
||||
pub const TAG_LANGUAGE: u32 = 0x447A;
|
||||
pub const TAG_LANGUAGE_BCP47: u32 = 0x447B;
|
||||
pub const TAG_DEFAULT: u32 = 0x4484;
|
||||
pub const TAG_STRING: u32 = 0x4487;
|
||||
pub const TAG_BINARY: u32 = 0x4485;
|
||||
@@ -1,40 +1,6 @@
|
||||
//! Clean-room Matroska (MKV) and WebM demuxer, written from RFC 8794 (EBML), RFC 9559 (Matroska)
|
||||
//! and the WebM container guidelines.
|
||||
//!
|
||||
//! - [`open`] / [`open_with`] parse the EBML header and the first Segment (Info, Tracks, SeekHead,
|
||||
//! Cues, Chapters, Tags, Attachments) and, by default, scan every Cluster's block headers to build
|
||||
//! per-track [`Sample`] tables — the same shape as `deckcraft-isobmff`, so callers can treat both
|
||||
//! containers alike. Frame data is read on demand with [`MkvFile::read_sample`].
|
||||
//! - [`Demuxer`] iterates [`Packet`]s in file order (from any [`ByteSource`], `Read + Seek`, or a
|
||||
//! byte slice) and seeks to the keyframe preceding a time, via the index, Cues, or a cluster scan.
|
||||
//!
|
||||
//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`.
|
||||
//! Matroska (MKV) and WebM demuxer and muxer: a re-export of [`craft_media::matroska`] from
|
||||
//! craft-libs. Supported elements and limitations are in the craft-media README.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
mod codec;
|
||||
mod demux;
|
||||
pub mod ebml;
|
||||
mod error;
|
||||
mod ids;
|
||||
mod meta;
|
||||
mod mux;
|
||||
mod source;
|
||||
#[cfg(test)]
|
||||
mod synthetic_tests;
|
||||
mod track;
|
||||
|
||||
pub use codec::Codec;
|
||||
pub use demux::{Demuxer, Keyframe, MkvFile, OpenOptions, Packet, SeekPoint, open, open_with};
|
||||
pub use error::{Error, Result};
|
||||
pub use meta::{
|
||||
Attachment, Chapter, ChapterDisplay, ClusterInfo, CuePoint, CuePosition, Edition, SeekEntry, SegmentInfo, SimpleTag, Tag, TagTargets,
|
||||
};
|
||||
pub use mux::{LacingMode, MkvWriter, MuxOptions, TrackSpec};
|
||||
pub use source::{ByteSource, ReadSeekSource};
|
||||
pub use track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Projection, Sample, Track, TrackKind, VideoInfo};
|
||||
|
||||
/// Element IDs used by the demuxer (marker bits included), for callers inspecting raw elements.
|
||||
pub mod element_ids {
|
||||
pub use crate::ids::*;
|
||||
}
|
||||
pub use craft_media::matroska::*;
|
||||
|
||||
@@ -1,612 +0,0 @@
|
||||
//! Segment-level metadata: Info, SeekHead, Cues, Chapters, Tags, Attachments — types and parsers
|
||||
//! for their in-memory element data.
|
||||
|
||||
use crate::codec::map_codec;
|
||||
use crate::ebml::{Children, float, int, string, uint};
|
||||
use crate::ids::*;
|
||||
use crate::track::{AudioInfo, Colour, ContentEncoding, MasteringMetadata, Projection, Track, TrackKind, VideoInfo, gcd};
|
||||
|
||||
/// `Info` element.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SegmentInfo {
|
||||
/// Nanoseconds per timestamp tick (default 1 000 000 = 1 ms).
|
||||
pub timestamp_scale: u64,
|
||||
/// `Duration` in timestamp ticks (float), if present.
|
||||
pub duration: Option<f64>,
|
||||
pub title: Option<String>,
|
||||
pub muxing_app: String,
|
||||
pub writing_app: String,
|
||||
/// `DateUTC`: nanoseconds since 2001-01-01T00:00:00 UTC.
|
||||
pub date_utc: Option<i64>,
|
||||
pub segment_uuid: Option<[u8; 16]>,
|
||||
}
|
||||
|
||||
impl Default for SegmentInfo {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
timestamp_scale: 1_000_000,
|
||||
duration: None,
|
||||
title: None,
|
||||
muxing_app: String::new(),
|
||||
writing_app: String::new(),
|
||||
date_utc: None,
|
||||
segment_uuid: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `SeekHead` entry: level-1 element ID and its position relative to the Segment data start.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct SeekEntry {
|
||||
pub id: u32,
|
||||
pub position: u64,
|
||||
}
|
||||
|
||||
/// One `CueTrackPositions` of a `CuePoint`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct CuePosition {
|
||||
pub track: u64,
|
||||
/// Cluster position relative to the Segment data start.
|
||||
pub cluster_position: u64,
|
||||
/// Block position relative to the Cluster data start.
|
||||
pub relative_position: Option<u64>,
|
||||
pub block_number: Option<u64>,
|
||||
pub duration: Option<u64>,
|
||||
}
|
||||
|
||||
/// One `CuePoint`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CuePoint {
|
||||
/// In timestamp ticks.
|
||||
pub time: u64,
|
||||
pub positions: Vec<CuePosition>,
|
||||
}
|
||||
|
||||
/// A cluster found while scanning.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ClusterInfo {
|
||||
/// Absolute offset of the Cluster element header.
|
||||
pub offset: u64,
|
||||
/// Absolute offset of the first child.
|
||||
pub data_start: u64,
|
||||
/// Absolute end offset (for unknown-size clusters, where the next top-level element starts).
|
||||
pub end: u64,
|
||||
/// Cluster `Timestamp` in ticks.
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
/// One `ChapterDisplay`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ChapterDisplay {
|
||||
pub string: String,
|
||||
pub languages: Vec<String>,
|
||||
pub languages_bcp47: Vec<String>,
|
||||
pub countries: Vec<String>,
|
||||
}
|
||||
|
||||
/// `ChapterAtom` (possibly nested).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Chapter {
|
||||
pub uid: u64,
|
||||
pub string_uid: Option<String>,
|
||||
pub start_ns: u64,
|
||||
pub end_ns: Option<u64>,
|
||||
pub hidden: bool,
|
||||
pub enabled: bool,
|
||||
pub displays: Vec<ChapterDisplay>,
|
||||
pub children: Vec<Chapter>,
|
||||
}
|
||||
|
||||
impl Chapter {
|
||||
/// First display string, if any.
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
self.displays.first().map(|d| d.string.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// `EditionEntry`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Edition {
|
||||
pub uid: Option<u64>,
|
||||
pub hidden: bool,
|
||||
pub default: bool,
|
||||
pub ordered: bool,
|
||||
pub chapters: Vec<Chapter>,
|
||||
}
|
||||
|
||||
/// `Targets` of a `Tag`. Empty UID lists mean "the whole segment".
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct TagTargets {
|
||||
pub type_value: u64,
|
||||
pub type_name: Option<String>,
|
||||
pub track_uids: Vec<u64>,
|
||||
pub edition_uids: Vec<u64>,
|
||||
pub chapter_uids: Vec<u64>,
|
||||
pub attachment_uids: Vec<u64>,
|
||||
}
|
||||
|
||||
impl Default for TagTargets {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
type_value: 50,
|
||||
type_name: None,
|
||||
track_uids: Vec::new(),
|
||||
edition_uids: Vec::new(),
|
||||
chapter_uids: Vec::new(),
|
||||
attachment_uids: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `SimpleTag` (possibly nested).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SimpleTag {
|
||||
pub name: String,
|
||||
pub language: String,
|
||||
pub default: bool,
|
||||
pub string: Option<String>,
|
||||
pub binary: Option<Vec<u8>>,
|
||||
pub children: Vec<SimpleTag>,
|
||||
}
|
||||
|
||||
/// `Tag`.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Tag {
|
||||
pub targets: TagTargets,
|
||||
pub simple: Vec<SimpleTag>,
|
||||
}
|
||||
|
||||
/// `AttachedFile` (listed only; the payload is located by `data_offset`/`data_size`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Attachment {
|
||||
pub uid: u64,
|
||||
pub name: String,
|
||||
pub media_type: String,
|
||||
pub description: Option<String>,
|
||||
pub data_offset: u64,
|
||||
pub data_size: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_info(d: &[u8]) -> SegmentInfo {
|
||||
let mut info = SegmentInfo::default();
|
||||
for e in Children::new(d) {
|
||||
match e.id {
|
||||
TIMESTAMP_SCALE => {
|
||||
let v = uint(e.data);
|
||||
if v > 0 {
|
||||
info.timestamp_scale = v;
|
||||
}
|
||||
}
|
||||
DURATION => info.duration = Some(float(e.data)).filter(|v| v.is_finite() && *v >= 0.0),
|
||||
TITLE => info.title = Some(string(e.data)),
|
||||
MUXING_APP => info.muxing_app = string(e.data),
|
||||
WRITING_APP => info.writing_app = string(e.data),
|
||||
DATE_UTC => info.date_utc = Some(int(e.data)),
|
||||
SEGMENT_UUID => info.segment_uuid = e.data.try_into().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
info
|
||||
}
|
||||
|
||||
pub(crate) fn parse_seekhead(d: &[u8]) -> Vec<SeekEntry> {
|
||||
let mut out = Vec::new();
|
||||
for s in Children::new(d).filter(|e| e.id == SEEK) {
|
||||
let (mut id, mut pos) = (None, None);
|
||||
for e in Children::new(s.data) {
|
||||
match e.id {
|
||||
SEEK_ID => id = Some(uint(e.data) as u32),
|
||||
SEEK_POSITION => pos = Some(uint(e.data)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let (Some(id), Some(position)) = (id, pos) {
|
||||
out.push(SeekEntry { id, position });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn parse_cues(d: &[u8], out: &mut Vec<CuePoint>) {
|
||||
for cp in Children::new(d).filter(|e| e.id == CUE_POINT) {
|
||||
let mut time = None;
|
||||
let mut positions = Vec::new();
|
||||
for e in Children::new(cp.data) {
|
||||
match e.id {
|
||||
CUE_TIME => time = Some(uint(e.data)),
|
||||
CUE_TRACK_POSITIONS => {
|
||||
let mut p = CuePosition { track: 0, cluster_position: u64::MAX, relative_position: None, block_number: None, duration: None };
|
||||
for c in Children::new(e.data) {
|
||||
match c.id {
|
||||
CUE_TRACK => p.track = uint(c.data),
|
||||
CUE_CLUSTER_POSITION => p.cluster_position = uint(c.data),
|
||||
CUE_RELATIVE_POSITION => p.relative_position = Some(uint(c.data)),
|
||||
CUE_BLOCK_NUMBER => p.block_number = Some(uint(c.data)),
|
||||
CUE_DURATION => p.duration = Some(uint(c.data)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if p.track != 0 && p.cluster_position != u64::MAX {
|
||||
positions.push(p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(time) = time
|
||||
&& !positions.is_empty()
|
||||
{
|
||||
out.push(CuePoint { time, positions });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Deepest nesting of `ChapterAtom` / `SimpleTag` we follow. Deeper children are ignored so a hostile file cannot overflow the stack.
|
||||
const MAX_NESTING_DEPTH: usize = 32;
|
||||
|
||||
fn parse_chapter(d: &[u8], depth: usize) -> Chapter {
|
||||
let mut c =
|
||||
Chapter { uid: 0, string_uid: None, start_ns: 0, end_ns: None, hidden: false, enabled: true, displays: Vec::new(), children: Vec::new() };
|
||||
for e in Children::new(d) {
|
||||
match e.id {
|
||||
CHAPTER_UID => c.uid = uint(e.data),
|
||||
CHAPTER_STRING_UID => c.string_uid = Some(string(e.data)),
|
||||
CHAPTER_TIME_START => c.start_ns = uint(e.data),
|
||||
CHAPTER_TIME_END => c.end_ns = Some(uint(e.data)),
|
||||
CHAPTER_FLAG_HIDDEN => c.hidden = uint(e.data) != 0,
|
||||
CHAPTER_FLAG_ENABLED => c.enabled = uint(e.data) != 0,
|
||||
CHAPTER_DISPLAY => {
|
||||
let mut disp = ChapterDisplay::default();
|
||||
for x in Children::new(e.data) {
|
||||
match x.id {
|
||||
CHAP_STRING => disp.string = string(x.data),
|
||||
CHAP_LANGUAGE => disp.languages.push(string(x.data)),
|
||||
CHAP_LANGUAGE_BCP47 => disp.languages_bcp47.push(string(x.data)),
|
||||
CHAP_COUNTRY => disp.countries.push(string(x.data)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if disp.languages.is_empty() {
|
||||
disp.languages.push("eng".into());
|
||||
}
|
||||
c.displays.push(disp);
|
||||
}
|
||||
CHAPTER_ATOM if depth + 1 < MAX_NESTING_DEPTH => c.children.push(parse_chapter(e.data, depth + 1)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
pub(crate) fn parse_chapters(d: &[u8], out: &mut Vec<Edition>) {
|
||||
for ed in Children::new(d).filter(|e| e.id == EDITION_ENTRY) {
|
||||
let mut edition = Edition::default();
|
||||
for e in Children::new(ed.data) {
|
||||
match e.id {
|
||||
EDITION_UID => edition.uid = Some(uint(e.data)),
|
||||
EDITION_FLAG_HIDDEN => edition.hidden = uint(e.data) != 0,
|
||||
EDITION_FLAG_DEFAULT => edition.default = uint(e.data) != 0,
|
||||
EDITION_FLAG_ORDERED => edition.ordered = uint(e.data) != 0,
|
||||
CHAPTER_ATOM => edition.chapters.push(parse_chapter(e.data, 0)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(edition);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_simple_tag(d: &[u8], depth: usize) -> SimpleTag {
|
||||
let mut t = SimpleTag { name: String::new(), language: "und".into(), default: true, string: None, binary: None, children: Vec::new() };
|
||||
for e in Children::new(d) {
|
||||
match e.id {
|
||||
TAG_NAME => t.name = string(e.data),
|
||||
TAG_LANGUAGE => t.language = string(e.data),
|
||||
TAG_DEFAULT => t.default = uint(e.data) != 0,
|
||||
TAG_STRING => t.string = Some(string(e.data)),
|
||||
TAG_BINARY => t.binary = Some(e.data.to_vec()),
|
||||
SIMPLE_TAG if depth + 1 < MAX_NESTING_DEPTH => t.children.push(parse_simple_tag(e.data, depth + 1)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
pub(crate) fn parse_tags(d: &[u8], out: &mut Vec<Tag>) {
|
||||
for tg in Children::new(d).filter(|e| e.id == TAG) {
|
||||
let mut tag = Tag::default();
|
||||
for e in Children::new(tg.data) {
|
||||
match e.id {
|
||||
TARGETS => {
|
||||
for x in Children::new(e.data) {
|
||||
match x.id {
|
||||
TARGET_TYPE_VALUE => tag.targets.type_value = uint(x.data),
|
||||
TARGET_TYPE => tag.targets.type_name = Some(string(x.data)),
|
||||
TAG_TRACK_UID => tag.targets.track_uids.push(uint(x.data)),
|
||||
TAG_EDITION_UID => tag.targets.edition_uids.push(uint(x.data)),
|
||||
TAG_CHAPTER_UID => tag.targets.chapter_uids.push(uint(x.data)),
|
||||
TAG_ATTACHMENT_UID => tag.targets.attachment_uids.push(uint(x.data)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
SIMPLE_TAG => tag.simple.push(parse_simple_tag(e.data, 0)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_colour(d: &[u8]) -> Colour {
|
||||
let mut c = Colour::default();
|
||||
for e in Children::new(d) {
|
||||
let v = || Some(uint(e.data));
|
||||
match e.id {
|
||||
MATRIX_COEFFICIENTS => c.matrix_coefficients = v(),
|
||||
BITS_PER_CHANNEL => c.bits_per_channel = v(),
|
||||
CHROMA_SUBSAMPLING_HORZ => c.chroma_subsampling_horz = v(),
|
||||
CHROMA_SUBSAMPLING_VERT => c.chroma_subsampling_vert = v(),
|
||||
CB_SUBSAMPLING_HORZ => c.cb_subsampling_horz = v(),
|
||||
CB_SUBSAMPLING_VERT => c.cb_subsampling_vert = v(),
|
||||
CHROMA_SITING_HORZ => c.chroma_siting_horz = v(),
|
||||
CHROMA_SITING_VERT => c.chroma_siting_vert = v(),
|
||||
RANGE => c.range = v(),
|
||||
TRANSFER_CHARACTERISTICS => c.transfer_characteristics = v(),
|
||||
PRIMARIES => c.primaries = v(),
|
||||
MAX_CLL => c.max_cll = v(),
|
||||
MAX_FALL => c.max_fall = v(),
|
||||
MASTERING_METADATA => {
|
||||
let mut m = MasteringMetadata::default();
|
||||
for x in Children::new(e.data) {
|
||||
let f = float(x.data);
|
||||
match x.id {
|
||||
PRIMARY_R_X => m.primaries[0].0 = f,
|
||||
PRIMARY_R_Y => m.primaries[0].1 = f,
|
||||
PRIMARY_G_X => m.primaries[1].0 = f,
|
||||
PRIMARY_G_Y => m.primaries[1].1 = f,
|
||||
PRIMARY_B_X => m.primaries[2].0 = f,
|
||||
PRIMARY_B_Y => m.primaries[2].1 = f,
|
||||
WHITE_POINT_X => m.white_point.0 = f,
|
||||
WHITE_POINT_Y => m.white_point.1 = f,
|
||||
LUMINANCE_MAX => m.luminance_max = f,
|
||||
LUMINANCE_MIN => m.luminance_min = f,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
c.mastering = Some(m);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn parse_video(d: &[u8]) -> VideoInfo {
|
||||
let mut v = VideoInfo::default();
|
||||
for e in Children::new(d) {
|
||||
let u = uint(e.data);
|
||||
match e.id {
|
||||
PIXEL_WIDTH => v.pixel_width = u as u32,
|
||||
PIXEL_HEIGHT => v.pixel_height = u as u32,
|
||||
DISPLAY_WIDTH => v.display_width = Some(u as u32),
|
||||
DISPLAY_HEIGHT => v.display_height = Some(u as u32),
|
||||
DISPLAY_UNIT => v.display_unit = u,
|
||||
PIXEL_CROP_TOP => v.crop.0 = u as u32,
|
||||
PIXEL_CROP_BOTTOM => v.crop.1 = u as u32,
|
||||
PIXEL_CROP_LEFT => v.crop.2 = u as u32,
|
||||
PIXEL_CROP_RIGHT => v.crop.3 = u as u32,
|
||||
FLAG_INTERLACED => v.flag_interlaced = u,
|
||||
FIELD_ORDER => v.field_order = Some(u),
|
||||
STEREO_MODE => v.stereo_mode = Some(u),
|
||||
ALPHA_MODE => v.alpha_mode = u,
|
||||
COLOUR_SPACE => v.colour_space = e.data.try_into().ok(),
|
||||
COLOUR => v.colour = Some(parse_colour(e.data)),
|
||||
PROJECTION => {
|
||||
let mut p = Projection::default();
|
||||
for x in Children::new(e.data) {
|
||||
match x.id {
|
||||
PROJECTION_TYPE => p.projection_type = uint(x.data),
|
||||
PROJECTION_POSE_YAW => p.yaw = float(x.data),
|
||||
PROJECTION_POSE_PITCH => p.pitch = float(x.data),
|
||||
PROJECTION_POSE_ROLL => p.roll = float(x.data),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
v.projection = Some(p);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
fn parse_audio(d: &[u8]) -> AudioInfo {
|
||||
let mut a = AudioInfo::default();
|
||||
for e in Children::new(d) {
|
||||
match e.id {
|
||||
SAMPLING_FREQUENCY => a.sampling_frequency = float(e.data),
|
||||
OUTPUT_SAMPLING_FREQUENCY => a.output_sampling_frequency = Some(float(e.data)),
|
||||
CHANNELS => a.channels = uint(e.data),
|
||||
BIT_DEPTH => a.bit_depth = Some(uint(e.data)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
a
|
||||
}
|
||||
|
||||
fn parse_encodings(d: &[u8]) -> Vec<ContentEncoding> {
|
||||
let mut list: Vec<(u64, ContentEncoding)> = Vec::new();
|
||||
for ce in Children::new(d).filter(|e| e.id == CONTENT_ENCODING) {
|
||||
let (mut order, mut scope, mut ty) = (0u64, 1u64, 0u64);
|
||||
let mut comp: Option<(u64, Vec<u8>)> = None;
|
||||
for e in Children::new(ce.data) {
|
||||
match e.id {
|
||||
CONTENT_ENCODING_ORDER => order = uint(e.data),
|
||||
CONTENT_ENCODING_SCOPE => scope = uint(e.data),
|
||||
CONTENT_ENCODING_TYPE => ty = uint(e.data),
|
||||
CONTENT_COMPRESSION => {
|
||||
let (mut algo, mut settings) = (0u64, Vec::new());
|
||||
for x in Children::new(e.data) {
|
||||
match x.id {
|
||||
CONTENT_COMP_ALGO => algo = uint(x.data),
|
||||
CONTENT_COMP_SETTINGS => settings = x.data.to_vec(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
comp = Some((algo, settings));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let enc = if ty == 1 {
|
||||
ContentEncoding::Encryption { scope }
|
||||
} else {
|
||||
let (algo, settings) = comp.unwrap_or((0, Vec::new()));
|
||||
if algo == 3 {
|
||||
ContentEncoding::HeaderStripping { bytes: settings, scope }
|
||||
} else {
|
||||
ContentEncoding::Compression { algo, settings, scope }
|
||||
}
|
||||
};
|
||||
list.push((order, enc));
|
||||
}
|
||||
// highest order is applied last on muxing → first to undo; keep decode order
|
||||
list.sort_by_key(|e| std::cmp::Reverse(e.0));
|
||||
list.into_iter().map(|e| e.1).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn parse_tracks(d: &[u8], timestamp_scale: u64) -> Vec<Track> {
|
||||
let g = gcd(timestamp_scale, 1_000_000_000);
|
||||
let timebase = (timestamp_scale / g, 1_000_000_000 / g);
|
||||
let mut out = Vec::new();
|
||||
for te in Children::new(d).filter(|e| e.id == TRACK_ENTRY) {
|
||||
let mut t = Track {
|
||||
number: 0,
|
||||
uid: 0,
|
||||
kind: TrackKind::Other(0),
|
||||
enabled: true,
|
||||
default: true,
|
||||
forced: false,
|
||||
hearing_impaired: false,
|
||||
lacing: true,
|
||||
default_duration_ns: None,
|
||||
name: String::new(),
|
||||
language: "eng".into(),
|
||||
language_bcp47: None,
|
||||
codec_id: String::new(),
|
||||
codec_private: Vec::new(),
|
||||
codec_name: String::new(),
|
||||
codec: crate::Codec::Other(String::new()),
|
||||
codec_delay_ns: 0,
|
||||
seek_pre_roll_ns: 0,
|
||||
video: None,
|
||||
audio: None,
|
||||
encodings: Vec::new(),
|
||||
timebase,
|
||||
samples: Vec::new(),
|
||||
sync: Vec::new(),
|
||||
};
|
||||
for e in Children::new(te.data) {
|
||||
match e.id {
|
||||
TRACK_NUMBER => t.number = uint(e.data),
|
||||
TRACK_UID => t.uid = uint(e.data),
|
||||
TRACK_TYPE => t.kind = TrackKind::from_type(uint(e.data)),
|
||||
FLAG_ENABLED => t.enabled = uint(e.data) != 0,
|
||||
FLAG_DEFAULT => t.default = uint(e.data) != 0,
|
||||
FLAG_FORCED => t.forced = uint(e.data) != 0,
|
||||
FLAG_HEARING_IMPAIRED => t.hearing_impaired = uint(e.data) != 0,
|
||||
FLAG_LACING => t.lacing = uint(e.data) != 0,
|
||||
DEFAULT_DURATION => t.default_duration_ns = Some(uint(e.data)).filter(|&v| v > 0),
|
||||
NAME => t.name = string(e.data),
|
||||
LANGUAGE => t.language = string(e.data),
|
||||
LANGUAGE_BCP47 => t.language_bcp47 = Some(string(e.data)),
|
||||
CODEC_ID => t.codec_id = string(e.data),
|
||||
CODEC_PRIVATE => t.codec_private = e.data.to_vec(),
|
||||
CODEC_NAME => t.codec_name = string(e.data),
|
||||
CODEC_DELAY => t.codec_delay_ns = uint(e.data),
|
||||
SEEK_PRE_ROLL => t.seek_pre_roll_ns = uint(e.data),
|
||||
VIDEO => t.video = Some(parse_video(e.data)),
|
||||
AUDIO => t.audio = Some(parse_audio(e.data)),
|
||||
CONTENT_ENCODINGS => t.encodings = parse_encodings(e.data),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if t.number == 0 {
|
||||
continue;
|
||||
}
|
||||
if t.kind == TrackKind::Audio && t.audio.is_none() {
|
||||
t.audio = Some(AudioInfo::default());
|
||||
}
|
||||
// CodecPrivate may itself be header-stripped (scope bit 2 = private data)
|
||||
for enc in &t.encodings {
|
||||
if let ContentEncoding::HeaderStripping { bytes, scope } = enc
|
||||
&& scope & 2 != 0
|
||||
{
|
||||
let mut p = bytes.clone();
|
||||
p.extend_from_slice(&t.codec_private);
|
||||
t.codec_private = p;
|
||||
}
|
||||
}
|
||||
t.codec = map_codec(&t.codec_id, &t.codec_private, t.audio.as_ref());
|
||||
out.push(t);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ebml::{write_id, write_size};
|
||||
|
||||
/// `levels` elements with id `id`, each holding the next; the innermost is empty.
|
||||
fn nested(id: u32, levels: usize) -> Vec<u8> {
|
||||
let mut inner = Vec::new();
|
||||
for _ in 0..levels {
|
||||
let mut e = Vec::new();
|
||||
write_id(&mut e, id);
|
||||
write_size(&mut e, inner.len() as u64, 1);
|
||||
e.extend_from_slice(&inner);
|
||||
inner = e;
|
||||
}
|
||||
inner
|
||||
}
|
||||
|
||||
fn chapter_depth(c: &Chapter) -> usize {
|
||||
1 + c.children.iter().map(chapter_depth).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn tag_depth(t: &SimpleTag) -> usize {
|
||||
1 + t.children.iter().map(tag_depth).max().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn chapters_of(levels: usize) -> usize {
|
||||
let mut edition = Vec::new();
|
||||
write_id(&mut edition, EDITION_ENTRY);
|
||||
let atoms = nested(CHAPTER_ATOM, levels);
|
||||
write_size(&mut edition, atoms.len() as u64, 1);
|
||||
edition.extend_from_slice(&atoms);
|
||||
let mut out = Vec::new();
|
||||
parse_chapters(&edition, &mut out);
|
||||
chapter_depth(&out[0].chapters[0])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chapter_nesting_is_bounded() {
|
||||
assert_eq!(chapters_of(MAX_NESTING_DEPTH), MAX_NESTING_DEPTH);
|
||||
assert_eq!(chapters_of(MAX_NESTING_DEPTH + 50), MAX_NESTING_DEPTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simple_tag_nesting_is_bounded() {
|
||||
for (levels, expected) in [(MAX_NESTING_DEPTH, MAX_NESTING_DEPTH), (MAX_NESTING_DEPTH + 50, MAX_NESTING_DEPTH)] {
|
||||
let mut tag = Vec::new();
|
||||
write_id(&mut tag, TAG);
|
||||
let simple = nested(SIMPLE_TAG, levels);
|
||||
write_size(&mut tag, simple.len() as u64, 1);
|
||||
tag.extend_from_slice(&simple);
|
||||
let mut out = Vec::new();
|
||||
parse_tags(&tag, &mut out);
|
||||
assert_eq!(tag_depth(&out[0].simple[0]), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
//! Minimal Matroska / WebM muxer: SimpleBlocks (optionally laced), BlockGroups for explicit
|
||||
//! durations, Cues, SeekHead, Info with Duration. Output must be `Write + Seek` (sizes and the
|
||||
//! SeekHead are back-patched on [`MkvWriter::finish`]).
|
||||
|
||||
use std::io::{Seek, SeekFrom, Write};
|
||||
|
||||
use crate::ebml::{write_id, write_size, write_unknown_size};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::ids::*;
|
||||
use crate::track::TrackKind;
|
||||
|
||||
/// Lacing used for audio tracks.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum LacingMode {
|
||||
None,
|
||||
Xiph,
|
||||
Ebml,
|
||||
/// Fixed-size lacing when consecutive frames have equal sizes, otherwise EBML lacing.
|
||||
FixedOrEbml,
|
||||
}
|
||||
|
||||
/// Track description for [`MkvWriter`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TrackSpec {
|
||||
pub kind: TrackKind,
|
||||
pub codec_id: String,
|
||||
pub codec_private: Vec<u8>,
|
||||
pub default_duration_ns: Option<u64>,
|
||||
pub language: Option<String>,
|
||||
pub name: Option<String>,
|
||||
/// Pixel width/height for video tracks.
|
||||
pub video_size: Option<(u32, u32)>,
|
||||
/// (sampling frequency, channels, bit depth) for audio tracks.
|
||||
pub audio: Option<(f64, u64, Option<u64>)>,
|
||||
pub codec_delay_ns: u64,
|
||||
pub seek_pre_roll_ns: u64,
|
||||
}
|
||||
|
||||
impl TrackSpec {
|
||||
pub fn new(kind: TrackKind, codec_id: impl Into<String>) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
codec_id: codec_id.into(),
|
||||
codec_private: Vec::new(),
|
||||
default_duration_ns: None,
|
||||
language: None,
|
||||
name: None,
|
||||
video_size: None,
|
||||
audio: None,
|
||||
codec_delay_ns: 0,
|
||||
seek_pre_roll_ns: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Muxer options.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MuxOptions {
|
||||
/// `webm` or `matroska`.
|
||||
pub doc_type: String,
|
||||
/// Nanoseconds per tick (default 1 000 000).
|
||||
pub timestamp_scale: u64,
|
||||
/// Start a new cluster at the next video keyframe (or any frame without video) after this long.
|
||||
pub cluster_duration_ns: u64,
|
||||
pub lacing: LacingMode,
|
||||
/// Maximum frames per laced block.
|
||||
pub max_lace_frames: usize,
|
||||
pub title: Option<String>,
|
||||
pub writing_app: String,
|
||||
}
|
||||
|
||||
impl Default for MuxOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
doc_type: "matroska".into(),
|
||||
timestamp_scale: 1_000_000,
|
||||
cluster_duration_ns: 1_000_000_000,
|
||||
lacing: LacingMode::None,
|
||||
max_lace_frames: 8,
|
||||
title: None,
|
||||
writing_app: "deckcraft".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn el(out: &mut Vec<u8>, id: u32, data: &[u8]) {
|
||||
write_id(out, id);
|
||||
write_size(out, data.len() as u64, 1);
|
||||
out.extend_from_slice(data);
|
||||
}
|
||||
|
||||
pub(crate) fn el_uint(out: &mut Vec<u8>, id: u32, v: u64) {
|
||||
let n = (8 - v.leading_zeros() as usize / 8).max(1);
|
||||
el(out, id, &v.to_be_bytes()[8 - n..]);
|
||||
}
|
||||
|
||||
pub(crate) fn el_float(out: &mut Vec<u8>, id: u32, v: f64) {
|
||||
el(out, id, &v.to_be_bytes());
|
||||
}
|
||||
|
||||
pub(crate) fn el_str(out: &mut Vec<u8>, id: u32, s: &str) {
|
||||
el(out, id, s.as_bytes());
|
||||
}
|
||||
|
||||
fn void(out: &mut Vec<u8>, total: usize) {
|
||||
debug_assert!(total >= 2);
|
||||
let sl = if total - 2 <= 126 { 1 } else { 8 };
|
||||
write_id(out, VOID);
|
||||
write_size(out, (total - 1 - sl) as u64, sl);
|
||||
out.resize(out.len() + total - 1 - sl, 0);
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Pending {
|
||||
track: usize,
|
||||
ts: i64,
|
||||
frames: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
const SEEK_HEAD_SPACE: usize = 128;
|
||||
|
||||
/// Streaming Matroska/WebM writer.
|
||||
pub struct MkvWriter<W: Write + Seek> {
|
||||
w: W,
|
||||
opts: MuxOptions,
|
||||
tracks: Vec<TrackSpec>,
|
||||
segment_size_pos: u64,
|
||||
segment_data_start: u64,
|
||||
seek_head_pos: u64,
|
||||
duration_pos: u64,
|
||||
info_pos: u64,
|
||||
tracks_pos: u64,
|
||||
cluster: Vec<u8>,
|
||||
cluster_ts: Option<i64>,
|
||||
cluster_start_ns: i64,
|
||||
pending: Option<Pending>,
|
||||
cues: Vec<(u64, u64, u64)>, // (time ticks, track number, cluster position)
|
||||
cued_tracks_in_cluster: Vec<bool>,
|
||||
end_ns: i64,
|
||||
has_video: bool,
|
||||
}
|
||||
|
||||
impl<W: Write + Seek> MkvWriter<W> {
|
||||
pub fn new(mut w: W, tracks: Vec<TrackSpec>, opts: MuxOptions) -> Result<Self> {
|
||||
if tracks.is_empty() || opts.timestamp_scale == 0 {
|
||||
return Err(Error::Invalid("muxer needs at least one track and a non-zero timestamp scale".into()));
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut h = Vec::new();
|
||||
el_uint(&mut h, EBML_VERSION, 1);
|
||||
el_uint(&mut h, EBML_READ_VERSION, 1);
|
||||
el_uint(&mut h, EBML_MAX_ID_LENGTH, 4);
|
||||
el_uint(&mut h, EBML_MAX_SIZE_LENGTH, 8);
|
||||
el_str(&mut h, DOC_TYPE, &opts.doc_type);
|
||||
el_uint(&mut h, DOC_TYPE_VERSION, 4);
|
||||
el_uint(&mut h, DOC_TYPE_READ_VERSION, 2);
|
||||
el(&mut out, EBML, &h);
|
||||
write_id(&mut out, SEGMENT);
|
||||
let base = w.stream_position()?;
|
||||
let segment_size_pos = base + out.len() as u64;
|
||||
write_unknown_size(&mut out, 8);
|
||||
let segment_data_start = base + out.len() as u64;
|
||||
let seek_head_pos = segment_data_start;
|
||||
void(&mut out, SEEK_HEAD_SPACE);
|
||||
// Info
|
||||
let info_pos = base + out.len() as u64;
|
||||
let mut info = Vec::new();
|
||||
el_uint(&mut info, TIMESTAMP_SCALE, opts.timestamp_scale);
|
||||
el_str(&mut info, MUXING_APP, "deckcraft-matroska");
|
||||
el_str(&mut info, WRITING_APP, &opts.writing_app);
|
||||
if let Some(t) = &opts.title {
|
||||
el_str(&mut info, TITLE, t);
|
||||
}
|
||||
let dur_off_in_info = info.len() + 2 + 1; // ID (2) + size (1)
|
||||
el_float(&mut info, DURATION, 0.0);
|
||||
write_id(&mut out, INFO);
|
||||
write_size(&mut out, info.len() as u64, 1);
|
||||
let duration_pos = base + out.len() as u64 + dur_off_in_info as u64;
|
||||
out.extend_from_slice(&info);
|
||||
// Tracks
|
||||
let tracks_pos = base + out.len() as u64;
|
||||
let mut tr = Vec::new();
|
||||
for (i, t) in tracks.iter().enumerate() {
|
||||
let mut e = Vec::new();
|
||||
el_uint(&mut e, TRACK_NUMBER, i as u64 + 1);
|
||||
el_uint(&mut e, TRACK_UID, 0x1000 + i as u64 + 1);
|
||||
let ty = match t.kind {
|
||||
TrackKind::Video => 1,
|
||||
TrackKind::Audio => 2,
|
||||
TrackKind::Subtitle => 0x11,
|
||||
TrackKind::Complex => 3,
|
||||
TrackKind::Logo => 0x10,
|
||||
TrackKind::Buttons => 0x12,
|
||||
TrackKind::Control => 0x20,
|
||||
TrackKind::Metadata => 0x21,
|
||||
TrackKind::Other(n) => n as u64,
|
||||
};
|
||||
el_uint(&mut e, TRACK_TYPE, ty);
|
||||
el_uint(&mut e, FLAG_LACING, (t.kind == TrackKind::Audio && opts.lacing != LacingMode::None) as u64);
|
||||
if let Some(l) = &t.language {
|
||||
el_str(&mut e, LANGUAGE, l);
|
||||
}
|
||||
if let Some(n) = &t.name {
|
||||
el_str(&mut e, NAME, n);
|
||||
}
|
||||
el_str(&mut e, CODEC_ID, &t.codec_id);
|
||||
if !t.codec_private.is_empty() {
|
||||
el(&mut e, CODEC_PRIVATE, &t.codec_private);
|
||||
}
|
||||
if let Some(d) = t.default_duration_ns {
|
||||
el_uint(&mut e, DEFAULT_DURATION, d);
|
||||
}
|
||||
if t.codec_delay_ns > 0 {
|
||||
el_uint(&mut e, CODEC_DELAY, t.codec_delay_ns);
|
||||
}
|
||||
if t.seek_pre_roll_ns > 0 {
|
||||
el_uint(&mut e, SEEK_PRE_ROLL, t.seek_pre_roll_ns);
|
||||
}
|
||||
if let Some((pw, ph)) = t.video_size {
|
||||
let mut v = Vec::new();
|
||||
el_uint(&mut v, PIXEL_WIDTH, pw as u64);
|
||||
el_uint(&mut v, PIXEL_HEIGHT, ph as u64);
|
||||
el(&mut e, VIDEO, &v);
|
||||
}
|
||||
if let Some((rate, ch, bits)) = t.audio {
|
||||
let mut a = Vec::new();
|
||||
el_float(&mut a, SAMPLING_FREQUENCY, rate);
|
||||
el_uint(&mut a, CHANNELS, ch);
|
||||
if let Some(b) = bits {
|
||||
el_uint(&mut a, BIT_DEPTH, b);
|
||||
}
|
||||
el(&mut e, AUDIO, &a);
|
||||
}
|
||||
el(&mut tr, TRACK_ENTRY, &e);
|
||||
}
|
||||
el(&mut out, TRACKS, &tr);
|
||||
w.write_all(&out)?;
|
||||
let has_video = tracks.iter().any(|t| t.kind == TrackKind::Video);
|
||||
let n = tracks.len();
|
||||
Ok(Self {
|
||||
w,
|
||||
opts,
|
||||
tracks,
|
||||
segment_size_pos,
|
||||
segment_data_start,
|
||||
seek_head_pos,
|
||||
duration_pos,
|
||||
info_pos,
|
||||
tracks_pos,
|
||||
cluster: Vec::new(),
|
||||
cluster_ts: None,
|
||||
cluster_start_ns: 0,
|
||||
pending: None,
|
||||
cues: Vec::new(),
|
||||
cued_tracks_in_cluster: vec![false; n],
|
||||
end_ns: 0,
|
||||
has_video,
|
||||
})
|
||||
}
|
||||
|
||||
/// Add one frame. `pts_ns` is the presentation time (codec delay is added back for storage);
|
||||
/// frames must be supplied in the desired file order. `duration_ns` forces a BlockGroup with a
|
||||
/// BlockDuration when it differs from the track's DefaultDuration.
|
||||
pub fn write_frame(&mut self, track: usize, pts_ns: i64, keyframe: bool, data: &[u8], duration_ns: Option<u64>) -> Result<()> {
|
||||
let spec = self.tracks.get(track).ok_or(Error::NoSuchTrack(track))?;
|
||||
// the Matroska ProRes mapping stores frames without the 8-byte `size`+`icpf` header
|
||||
let data = if spec.codec_id == "V_PRORES" && data.get(4..8) == Some(b"icpf") { &data[8..] } else { data };
|
||||
let scale = self.opts.timestamp_scale as i64;
|
||||
let stored_ns = pts_ns + spec.codec_delay_ns as i64;
|
||||
if stored_ns < 0 {
|
||||
return Err(Error::Invalid("negative timestamp".into()));
|
||||
}
|
||||
let ts = (stored_ns + scale / 2) / scale;
|
||||
let dur_end = pts_ns + duration_ns.or(spec.default_duration_ns).unwrap_or(0) as i64;
|
||||
self.end_ns = self.end_ns.max(dur_end).max(pts_ns);
|
||||
let is_video = spec.kind == TrackKind::Video;
|
||||
let lacing = spec.kind == TrackKind::Audio && self.opts.lacing != LacingMode::None;
|
||||
let explicit_dur = duration_ns.filter(|&d| Some(d) != spec.default_duration_ns);
|
||||
// new cluster?
|
||||
let start_new = match self.cluster_ts {
|
||||
None => true,
|
||||
Some(cts) => {
|
||||
let long = stored_ns - self.cluster_start_ns >= self.opts.cluster_duration_ns as i64;
|
||||
(long && (!self.has_video || (is_video && keyframe))) || ts - cts > 30_000 || ts < cts
|
||||
}
|
||||
};
|
||||
if start_new {
|
||||
self.flush_cluster()?;
|
||||
self.cluster_ts = Some(ts);
|
||||
self.cluster_start_ns = stored_ns;
|
||||
el_uint(&mut self.cluster, TIMESTAMP, ts as u64);
|
||||
}
|
||||
let cts = self.cluster_ts.unwrap_or(0);
|
||||
// cue: video keyframes, or the first frame of each track per cluster when there is no video
|
||||
if (is_video && keyframe) || (!self.has_video && !self.cued_tracks_in_cluster[track]) {
|
||||
self.cued_tracks_in_cluster[track] = true;
|
||||
let pos = self.w.stream_position()? - self.segment_data_start;
|
||||
if self.cues.last().is_none_or(|c| c.2 != pos || c.1 != track as u64 + 1) {
|
||||
self.cues.push((ts as u64, track as u64 + 1, pos));
|
||||
}
|
||||
}
|
||||
if lacing && explicit_dur.is_none() {
|
||||
let same = self.pending.as_ref().is_some_and(|p| p.track == track && p.frames.len() < self.opts.max_lace_frames);
|
||||
if !same {
|
||||
self.flush_pending();
|
||||
}
|
||||
match &mut self.pending {
|
||||
Some(p) => p.frames.push(data.to_vec()),
|
||||
None => self.pending = Some(Pending { track, ts: ts - cts, frames: vec![data.to_vec()] }),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
self.flush_pending();
|
||||
let mut block = Vec::new();
|
||||
write_size(&mut block, track as u64 + 1, 1);
|
||||
block.extend_from_slice(&((ts - cts) as i16).to_be_bytes());
|
||||
if let Some(d) = explicit_dur {
|
||||
block.push(0);
|
||||
block.extend_from_slice(data);
|
||||
let mut g = Vec::new();
|
||||
el(&mut g, BLOCK, &block);
|
||||
el_uint(&mut g, BLOCK_DURATION, (d as i64 / scale) as u64);
|
||||
if !keyframe {
|
||||
el(&mut g, REFERENCE_BLOCK, &[0xFF]);
|
||||
}
|
||||
el(&mut self.cluster, BLOCK_GROUP, &g);
|
||||
} else {
|
||||
block.push(if keyframe { 0x80 } else { 0 });
|
||||
block.extend_from_slice(data);
|
||||
el(&mut self.cluster, SIMPLE_BLOCK, &block);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn flush_pending(&mut self) {
|
||||
let Some(p) = self.pending.take() else { return };
|
||||
let mut block = Vec::new();
|
||||
write_size(&mut block, p.track as u64 + 1, 1);
|
||||
block.extend_from_slice(&(p.ts as i16).to_be_bytes());
|
||||
let n = p.frames.len();
|
||||
let mode = if n == 1 { LacingMode::None } else { self.opts.lacing };
|
||||
let fixed = p.frames.iter().all(|f| f.len() == p.frames[0].len());
|
||||
match mode {
|
||||
LacingMode::None => block.push(0x80),
|
||||
LacingMode::Xiph => {
|
||||
block.push(0x80 | 0x02);
|
||||
block.push((n - 1) as u8);
|
||||
for f in &p.frames[..n - 1] {
|
||||
let mut s = f.len();
|
||||
while s >= 255 {
|
||||
block.push(255);
|
||||
s -= 255;
|
||||
}
|
||||
block.push(s as u8);
|
||||
}
|
||||
}
|
||||
LacingMode::FixedOrEbml if fixed => {
|
||||
block.push(0x80 | 0x04);
|
||||
block.push((n - 1) as u8);
|
||||
}
|
||||
LacingMode::Ebml | LacingMode::FixedOrEbml => {
|
||||
block.push(0x80 | 0x06);
|
||||
block.push((n - 1) as u8);
|
||||
write_size(&mut block, p.frames[0].len() as u64, 1);
|
||||
for i in 1..n - 1 {
|
||||
let d = p.frames[i].len() as i64 - p.frames[i - 1].len() as i64;
|
||||
// signed VINT: smallest length whose range holds d
|
||||
let mut len = 1;
|
||||
while len < 8 && d.unsigned_abs() >= (1u64 << (7 * len - 1)) - 1 {
|
||||
len += 1;
|
||||
}
|
||||
let v = (d + (1i64 << (7 * len - 1)) - 1) as u64 | (1u64 << (7 * len));
|
||||
block.extend_from_slice(&v.to_be_bytes()[8 - len..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
for f in &p.frames {
|
||||
block.extend_from_slice(f);
|
||||
}
|
||||
el(&mut self.cluster, SIMPLE_BLOCK, &block);
|
||||
}
|
||||
|
||||
fn flush_cluster(&mut self) -> Result<()> {
|
||||
self.flush_pending();
|
||||
if self.cluster_ts.is_some() {
|
||||
let mut out = Vec::new();
|
||||
el(&mut out, CLUSTER, &self.cluster);
|
||||
self.w.write_all(&out)?;
|
||||
}
|
||||
self.cluster.clear();
|
||||
self.cluster_ts = None;
|
||||
self.cued_tracks_in_cluster.iter_mut().for_each(|c| *c = false);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write Cues, back-patch SeekHead / Segment size / Duration, and return the writer.
|
||||
pub fn finish(mut self) -> Result<W> {
|
||||
self.flush_cluster()?;
|
||||
let cues_pos = self.w.stream_position()?;
|
||||
let mut cues = Vec::new();
|
||||
for &(time, track, pos) in &self.cues {
|
||||
let mut ctp = Vec::new();
|
||||
el_uint(&mut ctp, CUE_TRACK, track);
|
||||
el_uint(&mut ctp, CUE_CLUSTER_POSITION, pos);
|
||||
let mut cp = Vec::new();
|
||||
el_uint(&mut cp, CUE_TIME, time);
|
||||
el(&mut cp, CUE_TRACK_POSITIONS, &ctp);
|
||||
el(&mut cues, CUE_POINT, &cp);
|
||||
}
|
||||
let has_cues = !cues.is_empty();
|
||||
if has_cues {
|
||||
let mut out = Vec::new();
|
||||
el(&mut out, CUES, &cues);
|
||||
self.w.write_all(&out)?;
|
||||
}
|
||||
let end = self.w.stream_position()?;
|
||||
// SeekHead
|
||||
let mut sh = Vec::new();
|
||||
let mut entries = vec![(INFO, self.info_pos), (TRACKS, self.tracks_pos)];
|
||||
if has_cues {
|
||||
entries.push((CUES, cues_pos));
|
||||
}
|
||||
for (id, pos) in entries {
|
||||
let mut s = Vec::new();
|
||||
let mut idb = Vec::new();
|
||||
write_id(&mut idb, id);
|
||||
el(&mut s, SEEK_ID, &idb);
|
||||
el_uint(&mut s, SEEK_POSITION, pos - self.segment_data_start);
|
||||
el(&mut sh, SEEK, &s);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
el(&mut out, SEEK_HEAD, &sh);
|
||||
if out.len() + 2 > SEEK_HEAD_SPACE {
|
||||
return Err(Error::Invalid("SeekHead does not fit its reserved space".into()));
|
||||
}
|
||||
let rest = SEEK_HEAD_SPACE - out.len();
|
||||
void(&mut out, rest);
|
||||
self.w.seek(SeekFrom::Start(self.seek_head_pos))?;
|
||||
self.w.write_all(&out)?;
|
||||
// Duration
|
||||
let dur = self.end_ns as f64 / self.opts.timestamp_scale as f64;
|
||||
self.w.seek(SeekFrom::Start(self.duration_pos))?;
|
||||
self.w.write_all(&dur.to_be_bytes())?;
|
||||
// Segment size
|
||||
let mut sz = Vec::new();
|
||||
write_size(&mut sz, end - self.segment_data_start, 8);
|
||||
self.w.seek(SeekFrom::Start(self.segment_size_pos))?;
|
||||
self.w.write_all(&sz)?;
|
||||
self.w.seek(SeekFrom::Start(end))?;
|
||||
self.w.flush()?;
|
||||
Ok(self.w)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Demuxer, TrackKind};
|
||||
use std::io::Cursor;
|
||||
|
||||
fn roundtrip(lacing: LacingMode) {
|
||||
let mut v = TrackSpec::new(TrackKind::Video, "V_MJPEG");
|
||||
v.video_size = Some((16, 16));
|
||||
v.default_duration_ns = Some(40_000_000);
|
||||
let mut a = TrackSpec::new(TrackKind::Audio, "A_PCM/INT/LIT");
|
||||
a.audio = Some((48000.0, 1, Some(16)));
|
||||
a.default_duration_ns = Some(10_000_000);
|
||||
let mut s = TrackSpec::new(TrackKind::Subtitle, "S_TEXT/UTF8");
|
||||
s.language = Some("fre".into());
|
||||
let opts = MuxOptions { lacing, ..Default::default() };
|
||||
let mut m = MkvWriter::new(Cursor::new(Vec::new()), vec![v, a, s], opts).unwrap();
|
||||
let mut expect = Vec::new();
|
||||
for i in 0..50i64 {
|
||||
let vf = vec![i as u8; 100 + i as usize];
|
||||
m.write_frame(0, i * 40_000_000, i % 10 == 0, &vf, None).unwrap();
|
||||
expect.push((0usize, i * 40, vf, i % 10 == 0));
|
||||
for k in 0..4 {
|
||||
let n = if lacing == LacingMode::FixedOrEbml { 960 } else { 900 + (i as usize * 7 + k * 13) % 300 };
|
||||
let af = vec![(i * 4 + k as i64) as u8; n];
|
||||
let t = i * 40 + k as i64 * 10;
|
||||
m.write_frame(1, t * 1_000_000, true, &af, None).unwrap();
|
||||
expect.push((1, t, af, true));
|
||||
}
|
||||
if i % 20 == 5 {
|
||||
m.write_frame(2, i * 40_000_000, true, b"hello", Some(500_000_000)).unwrap();
|
||||
expect.push((2, i * 40, b"hello".to_vec(), true));
|
||||
}
|
||||
}
|
||||
let bytes = m.finish().unwrap().into_inner();
|
||||
let mut d = Demuxer::from_slice(&bytes).unwrap();
|
||||
assert!(d.file().warnings.is_empty(), "{:?}", d.file().warnings);
|
||||
assert_eq!(d.tracks()[2].language, "fre");
|
||||
assert!(!d.file().cues.is_empty());
|
||||
assert_eq!(d.duration_ns(), Some(2_300_000_000));
|
||||
let got: Vec<_> = d.by_ref().map(|p| p.unwrap()).collect();
|
||||
assert_eq!(got.len(), expect.len());
|
||||
for (g, e) in got.iter().zip(&expect) {
|
||||
assert_eq!((g.track, g.pts, &g.data, g.keyframe), (e.0, e.1, &e.2, e.3));
|
||||
}
|
||||
let sub = got.iter().find(|p| p.track == 2).unwrap();
|
||||
assert_eq!(sub.duration, 500);
|
||||
// the index agrees with the packet stream
|
||||
let f = d.file();
|
||||
for (ti, t) in f.tracks.iter().enumerate() {
|
||||
let pk: Vec<_> = got.iter().filter(|p| p.track == ti).collect();
|
||||
assert_eq!(t.samples.len(), pk.len());
|
||||
for (i, p) in pk.iter().enumerate() {
|
||||
assert_eq!(f.read_sample(&bytes[..], ti, i).unwrap(), p.data);
|
||||
assert_eq!(t.samples[i].pts, p.pts);
|
||||
}
|
||||
}
|
||||
// seeking
|
||||
let sp = d.seek(0, 1_230_000_000).unwrap();
|
||||
assert_eq!(sp.pts, 1200);
|
||||
let p = d.next_packet().unwrap().unwrap();
|
||||
assert_eq!((p.track, p.pts, p.keyframe), (0, 1200, true));
|
||||
let sp = d.seek(1, 1_235_000_000).unwrap();
|
||||
assert_eq!(sp.pts, 1230);
|
||||
let p = d.next_packet().unwrap().unwrap();
|
||||
assert_eq!((p.track, p.pts), (1, 1230));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_unlaced() {
|
||||
roundtrip(LacingMode::None);
|
||||
}
|
||||
#[test]
|
||||
fn roundtrip_xiph() {
|
||||
roundtrip(LacingMode::Xiph);
|
||||
}
|
||||
#[test]
|
||||
fn roundtrip_ebml() {
|
||||
roundtrip(LacingMode::Ebml);
|
||||
}
|
||||
#[test]
|
||||
fn roundtrip_fixed() {
|
||||
roundtrip(LacingMode::FixedOrEbml);
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
//! Random-access byte sources for the demuxer.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::io::{self, Read, Seek, SeekFrom};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Random-access, read-only byte source (file, in-memory buffer, web Blob…).
|
||||
///
|
||||
/// Same shape as `deckcraft_isobmff::ByteSource`, so one adapter can serve both demuxers.
|
||||
pub trait ByteSource {
|
||||
/// Total length in bytes.
|
||||
fn len(&self) -> u64;
|
||||
/// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end.
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>;
|
||||
/// True if the source holds no bytes.
|
||||
fn is_empty(&self) -> bool {
|
||||
self.len() == 0
|
||||
}
|
||||
}
|
||||
|
||||
fn eof() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source")
|
||||
}
|
||||
|
||||
impl ByteSource for [u8] {
|
||||
fn len(&self) -> u64 {
|
||||
<[u8]>::len(self) as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
let start = usize::try_from(offset).map_err(|_| eof())?;
|
||||
let end = start.checked_add(buf.len()).ok_or_else(eof)?;
|
||||
let src = self.get(start..end).ok_or_else(eof)?;
|
||||
buf.copy_from_slice(src);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl ByteSource for Vec<u8> {
|
||||
fn len(&self) -> u64 {
|
||||
self.as_slice().len() as u64
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
self.as_slice().read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ByteSource + ?Sized> ByteSource for &T {
|
||||
fn len(&self) -> u64 {
|
||||
(**self).len()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
(**self).read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ByteSource + ?Sized> ByteSource for Arc<T> {
|
||||
fn len(&self) -> u64 {
|
||||
(**self).len()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
(**self).read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: ByteSource + ?Sized> ByteSource for Box<T> {
|
||||
fn len(&self) -> u64 {
|
||||
(**self).len()
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
(**self).read_at(offset, buf)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl ByteSource for std::fs::File {
|
||||
fn len(&self) -> u64 {
|
||||
self.metadata().map(|m| m.len()).unwrap_or(0)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
std::os::unix::fs::FileExt::read_exact_at(self, buf, offset)
|
||||
}
|
||||
#[cfg(windows)]
|
||||
fn read_at(&self, mut offset: u64, mut buf: &mut [u8]) -> io::Result<()> {
|
||||
use std::os::windows::fs::FileExt;
|
||||
while !buf.is_empty() {
|
||||
let n = self.seek_read(buf, offset)?;
|
||||
if n == 0 {
|
||||
return Err(eof());
|
||||
}
|
||||
offset += n as u64;
|
||||
buf = &mut buf[n..];
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
let mut f = self;
|
||||
f.seek(SeekFrom::Start(offset))?;
|
||||
f.read_exact(buf)
|
||||
}
|
||||
}
|
||||
|
||||
/// Adapts any `Read + Seek` (e.g. `BufReader<File>`, `Cursor<Vec<u8>>`) into a [`ByteSource`].
|
||||
///
|
||||
/// The length is taken once at construction; the stream position is tracked so sequential reads
|
||||
/// do not issue redundant seeks.
|
||||
pub struct ReadSeekSource<R> {
|
||||
inner: RefCell<(R, u64)>,
|
||||
len: u64,
|
||||
}
|
||||
|
||||
impl<R: Read + Seek> ReadSeekSource<R> {
|
||||
/// Wrap a reader; seeks to the end once to learn the length.
|
||||
pub fn new(mut r: R) -> io::Result<Self> {
|
||||
let len = r.seek(SeekFrom::End(0))?;
|
||||
Ok(Self { inner: RefCell::new((r, len)), len })
|
||||
}
|
||||
/// Unwrap the reader.
|
||||
pub fn into_inner(self) -> R {
|
||||
self.inner.into_inner().0
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Read + Seek> ByteSource for ReadSeekSource<R> {
|
||||
fn len(&self) -> u64 {
|
||||
self.len
|
||||
}
|
||||
fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> {
|
||||
if offset.checked_add(buf.len() as u64).is_none_or(|e| e > self.len) {
|
||||
return Err(eof());
|
||||
}
|
||||
let mut g = self.inner.borrow_mut();
|
||||
let (r, pos) = &mut *g;
|
||||
if *pos != offset {
|
||||
r.seek(SeekFrom::Start(offset))?;
|
||||
*pos = offset;
|
||||
}
|
||||
let res = r.read_exact(buf);
|
||||
match res {
|
||||
Ok(()) => {
|
||||
*pos = offset + buf.len() as u64;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
*pos = u64::MAX; // unknown; force a seek next time
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn slice_and_reader_sources() {
|
||||
let v = vec![1u8, 2, 3, 4];
|
||||
let mut b = [0u8; 2];
|
||||
v.read_at(1, &mut b).unwrap();
|
||||
assert_eq!(b, [2, 3]);
|
||||
assert!(v.read_at(3, &mut b).is_err());
|
||||
assert!(v.read_at(u64::MAX, &mut b).is_err());
|
||||
let r = ReadSeekSource::new(io::Cursor::new(v.clone())).unwrap();
|
||||
assert_eq!(r.len(), 4);
|
||||
r.read_at(2, &mut b).unwrap();
|
||||
assert_eq!(b, [3, 4]);
|
||||
r.read_at(0, &mut b).unwrap();
|
||||
assert_eq!(b, [1, 2]);
|
||||
assert!(r.read_at(3, &mut b).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
//! Hand-built files exercising features FFmpeg does not write: unknown-size Segment/Clusters,
|
||||
//! header stripping, BlockGroups, Colour + mastering metadata, Chapters, Tags, Attachments, CRC-32.
|
||||
|
||||
use crate::codec::Codec;
|
||||
use crate::ebml::{self, write_id, write_unknown_size};
|
||||
use crate::ids::*;
|
||||
use crate::mux::{el, el_float, el_str, el_uint};
|
||||
use crate::track::ContentEncoding;
|
||||
use crate::{Demuxer, OpenOptions, Packet, open_with};
|
||||
|
||||
fn block(track: u8, rel: i16, flags: u8, data: &[u8]) -> Vec<u8> {
|
||||
let mut b = vec![0x80 | track];
|
||||
b.extend_from_slice(&rel.to_be_bytes());
|
||||
b.push(flags);
|
||||
b.extend_from_slice(data);
|
||||
b
|
||||
}
|
||||
|
||||
fn synthetic() -> Vec<u8> {
|
||||
let mut f = Vec::new();
|
||||
let mut h = Vec::new();
|
||||
el_str(&mut h, DOC_TYPE, "matroska");
|
||||
el_uint(&mut h, DOC_TYPE_VERSION, 4);
|
||||
el(&mut f, EBML, &h);
|
||||
write_id(&mut f, SEGMENT);
|
||||
write_unknown_size(&mut f, 8);
|
||||
// Info with a correct CRC-32 and a Void
|
||||
let mut info = Vec::new();
|
||||
el_uint(&mut info, TIMESTAMP_SCALE, 1_000_000);
|
||||
el_str(&mut info, TITLE, "Synthetic");
|
||||
el(&mut info, VOID, &[0; 3]);
|
||||
let mut crc_info = Vec::new();
|
||||
el(&mut crc_info, CRC32, &ebml::crc32(&info).to_le_bytes());
|
||||
crc_info.extend_from_slice(&info);
|
||||
el(&mut f, INFO, &crc_info);
|
||||
// Tracks
|
||||
let mut v = Vec::new();
|
||||
el_uint(&mut v, TRACK_NUMBER, 1);
|
||||
el_uint(&mut v, TRACK_UID, 11);
|
||||
el_uint(&mut v, TRACK_TYPE, 1);
|
||||
el_str(&mut v, CODEC_ID, "V_MPEG4/ISO/AVC");
|
||||
el(&mut v, CODEC_PRIVATE, &[1, 0x64, 0, 0x1F]);
|
||||
el_uint(&mut v, DEFAULT_DURATION, 40_000_000);
|
||||
let mut mm = Vec::new();
|
||||
for (id, val) in
|
||||
[(PRIMARY_R_X, 0.708), (PRIMARY_R_Y, 0.292), (PRIMARY_G_X, 0.17), (PRIMARY_G_Y, 0.797), (PRIMARY_B_X, 0.131), (PRIMARY_B_Y, 0.046)]
|
||||
{
|
||||
el_float(&mut mm, id, val);
|
||||
}
|
||||
el_float(&mut mm, WHITE_POINT_X, 0.3127);
|
||||
el_float(&mut mm, WHITE_POINT_Y, 0.329);
|
||||
el_float(&mut mm, LUMINANCE_MAX, 1000.0);
|
||||
el_float(&mut mm, LUMINANCE_MIN, 0.0001);
|
||||
let mut col = Vec::new();
|
||||
el_uint(&mut col, MATRIX_COEFFICIENTS, 9);
|
||||
el_uint(&mut col, RANGE, 2);
|
||||
el_uint(&mut col, TRANSFER_CHARACTERISTICS, 16);
|
||||
el_uint(&mut col, PRIMARIES, 9);
|
||||
el_uint(&mut col, MAX_CLL, 1000);
|
||||
el_uint(&mut col, MAX_FALL, 400);
|
||||
el(&mut col, MASTERING_METADATA, &mm);
|
||||
let mut vid = Vec::new();
|
||||
el_uint(&mut vid, PIXEL_WIDTH, 1440);
|
||||
el_uint(&mut vid, PIXEL_HEIGHT, 1080);
|
||||
el_uint(&mut vid, DISPLAY_WIDTH, 1920);
|
||||
el_uint(&mut vid, DISPLAY_HEIGHT, 1080);
|
||||
el(&mut vid, COLOUR, &col);
|
||||
// rectangular, a quarter turn clockwise (portrait phone video)
|
||||
let mut proj = Vec::new();
|
||||
el_uint(&mut proj, PROJECTION_TYPE, 0);
|
||||
el_float(&mut proj, PROJECTION_POSE_ROLL, -90.0);
|
||||
el(&mut vid, PROJECTION, &proj);
|
||||
el(&mut v, VIDEO, &vid);
|
||||
// header stripping: every frame starts with 00 00 00 01
|
||||
let mut comp = Vec::new();
|
||||
el_uint(&mut comp, CONTENT_COMP_ALGO, 3);
|
||||
el(&mut comp, CONTENT_COMP_SETTINGS, &[0, 0, 0, 1]);
|
||||
let mut enc = Vec::new();
|
||||
el(&mut enc, CONTENT_COMPRESSION, &comp);
|
||||
let mut encs = Vec::new();
|
||||
el(&mut encs, CONTENT_ENCODING, &enc);
|
||||
el(&mut v, CONTENT_ENCODINGS, &encs);
|
||||
let mut a = Vec::new();
|
||||
el_uint(&mut a, TRACK_NUMBER, 2);
|
||||
el_uint(&mut a, TRACK_TYPE, 2);
|
||||
el_str(&mut a, CODEC_ID, "A_AAC/MPEG4/LC/SBR");
|
||||
el_str(&mut a, LANGUAGE, "ger");
|
||||
let mut au = Vec::new();
|
||||
el_float(&mut au, SAMPLING_FREQUENCY, 24000.0);
|
||||
el_float(&mut au, OUTPUT_SAMPLING_FREQUENCY, 48000.0);
|
||||
el_uint(&mut au, CHANNELS, 2);
|
||||
el(&mut a, AUDIO, &au);
|
||||
let mut tr = Vec::new();
|
||||
el(&mut tr, TRACK_ENTRY, &v);
|
||||
el(&mut tr, TRACK_ENTRY, &a);
|
||||
el(&mut f, TRACKS, &tr);
|
||||
// Chapters
|
||||
let mut disp = Vec::new();
|
||||
el_str(&mut disp, CHAP_STRING, "Intro");
|
||||
let mut atom = Vec::new();
|
||||
el_uint(&mut atom, CHAPTER_UID, 5);
|
||||
el_uint(&mut atom, CHAPTER_TIME_START, 0);
|
||||
el_uint(&mut atom, CHAPTER_TIME_END, 40_000_000);
|
||||
el(&mut atom, CHAPTER_DISPLAY, &disp);
|
||||
let mut ed = Vec::new();
|
||||
el(&mut ed, CHAPTER_ATOM, &atom);
|
||||
let mut ch = Vec::new();
|
||||
el(&mut ch, EDITION_ENTRY, &ed);
|
||||
el(&mut f, CHAPTERS, &ch);
|
||||
// Attachments
|
||||
let mut af = Vec::new();
|
||||
el_str(&mut af, FILE_NAME, "font.ttf");
|
||||
el_str(&mut af, FILE_MEDIA_TYPE, "font/ttf");
|
||||
el_uint(&mut af, FILE_UID, 77);
|
||||
el(&mut af, FILE_DATA, &[9; 100]);
|
||||
let mut att = Vec::new();
|
||||
el(&mut att, ATTACHED_FILE, &af);
|
||||
el(&mut f, ATTACHMENTS, &att);
|
||||
// Cluster 1 (unknown size): keyframe SimpleBlock, BlockGroup with ReferenceBlock + duration
|
||||
write_id(&mut f, CLUSTER);
|
||||
write_unknown_size(&mut f, 1);
|
||||
el_uint(&mut f, TIMESTAMP, 1000);
|
||||
el(&mut f, SIMPLE_BLOCK, &block(1, 0, 0x80, &[0x65, 1, 2]));
|
||||
el(&mut f, SIMPLE_BLOCK, &block(2, 5, 0x80, &[0xAA; 10]));
|
||||
let mut g = Vec::new();
|
||||
el(&mut g, BLOCK, &block(1, 40, 0, &[0x41, 3]));
|
||||
el(&mut g, REFERENCE_BLOCK, &[0xD8]);
|
||||
el_uint(&mut g, BLOCK_DURATION, 80);
|
||||
el(&mut f, BLOCK_GROUP, &g);
|
||||
el(&mut f, SIMPLE_BLOCK, &block(9, 0, 0x80, &[1])); // unknown track: ignored
|
||||
// Cluster 2 (unknown size), ended by Tags
|
||||
write_id(&mut f, CLUSTER);
|
||||
write_unknown_size(&mut f, 8);
|
||||
el_uint(&mut f, TIMESTAMP, 1120);
|
||||
el(&mut f, SIMPLE_BLOCK, &block(1, 0, 0x80, &[0x65, 4]));
|
||||
let mut st = Vec::new();
|
||||
el_str(&mut st, TAG_NAME, "ARTIST");
|
||||
el_str(&mut st, TAG_STRING, "Nobody");
|
||||
let mut tg = Vec::new();
|
||||
let mut tgt = Vec::new();
|
||||
el_uint(&mut tgt, TAG_TRACK_UID, 11);
|
||||
el(&mut tg, TARGETS, &tgt);
|
||||
el(&mut tg, SIMPLE_TAG, &st);
|
||||
let mut tags = Vec::new();
|
||||
el(&mut tags, TAG, &tg);
|
||||
el(&mut f, TAGS, &tags);
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_file() {
|
||||
let bytes = synthetic();
|
||||
let f = open_with(&bytes[..], &OpenOptions { index: true, verify_crc: true }).unwrap();
|
||||
assert!(f.live);
|
||||
assert!(f.warnings.is_empty(), "{:?}", f.warnings);
|
||||
assert_eq!((f.crc_checked, f.crc_errors.len()), (1, 0));
|
||||
assert_eq!(f.info.title.as_deref(), Some("Synthetic"));
|
||||
let v = &f.tracks[0];
|
||||
assert!(matches!(&v.codec, Codec::Avc { avcc } if avcc[1] == 0x64));
|
||||
let vi = v.video.as_ref().unwrap();
|
||||
assert_eq!(vi.pixel_aspect(), (4, 3));
|
||||
assert_eq!(vi.projection.as_ref().map(|p| p.roll), Some(-90.0));
|
||||
assert_eq!(vi.display_rotation(), Some(1));
|
||||
let c = vi.colour.as_ref().unwrap();
|
||||
assert!(c.full_range());
|
||||
assert_eq!(
|
||||
(c.matrix_coefficients, c.transfer_characteristics, c.primaries, c.max_cll, c.max_fall),
|
||||
(Some(9), Some(16), Some(9), Some(1000), Some(400))
|
||||
);
|
||||
let m = c.mastering.as_ref().unwrap();
|
||||
assert_eq!(m.primaries[1], (0.17, 0.797));
|
||||
assert_eq!(m.white_point, (0.3127, 0.329));
|
||||
assert_eq!((m.luminance_max, m.luminance_min), (1000.0, 0.0001));
|
||||
assert_eq!(v.encodings, vec![ContentEncoding::HeaderStripping { bytes: vec![0, 0, 0, 1], scope: 1 }]);
|
||||
// legacy AAC ID: ASC synthesised with explicit SBR signalling (LC, 24 kHz, stereo → 48 kHz)
|
||||
assert_eq!(f.tracks[1].codec, Codec::Aac { asc: vec![0x13, 0x10, 0x56, 0xE5, 0x98] });
|
||||
assert_eq!(f.tracks[1].language, "ger");
|
||||
assert_eq!(f.chapters[0].chapters[0].title(), Some("Intro"));
|
||||
assert_eq!(f.chapters[0].chapters[0].end_ns, Some(40_000_000));
|
||||
assert_eq!(f.attachments.len(), 1);
|
||||
let at = &f.attachments[0];
|
||||
assert_eq!((at.name.as_str(), at.media_type.as_str(), at.uid), ("font.ttf", "font/ttf", 77));
|
||||
assert_eq!(&bytes[at.data_offset as usize..][..100], &[9; 100]);
|
||||
assert_eq!(f.tags[0].targets.track_uids, vec![11]);
|
||||
assert_eq!(f.tags[0].simple[0].string.as_deref(), Some("Nobody"));
|
||||
assert_eq!(f.clusters.len(), 2);
|
||||
assert_eq!(v.samples.len(), 3);
|
||||
assert_eq!(v.keyframes(), &[0, 2]);
|
||||
|
||||
let d = Demuxer::from_slice(&bytes).unwrap();
|
||||
let p: Vec<Packet> = d.map(|p| p.unwrap()).collect();
|
||||
assert_eq!(p.len(), 4);
|
||||
assert_eq!((p[0].track, p[0].pts, p[0].keyframe, p[0].duration), (0, 1000, true, 40));
|
||||
assert_eq!(p[0].data, [0, 0, 0, 1, 0x65, 1, 2]);
|
||||
assert_eq!((p[1].track, p[1].pts, p[1].data.len()), (1, 1005, 10));
|
||||
assert_eq!((p[2].pts, p[2].keyframe, p[2].duration), (1040, false, 80));
|
||||
assert_eq!(p[2].data, [0, 0, 0, 1, 0x41, 3]);
|
||||
assert_eq!((p[3].pts, p[3].pts_ns), (1120, 1_120_000_000));
|
||||
assert_eq!(f.read_sample(&bytes[..], 0, 1).unwrap(), p[2].data);
|
||||
|
||||
// CRC mismatch is reported, not fatal
|
||||
let mut bad = bytes.clone();
|
||||
let title = bad.windows(9).position(|w| w == b"Synthetic").unwrap();
|
||||
bad[title] = b's';
|
||||
let f = open_with(&bad[..], &OpenOptions { index: true, verify_crc: true }).unwrap();
|
||||
assert_eq!(f.crc_errors.len(), 1);
|
||||
|
||||
// seeking without index or cues builds the index
|
||||
let mut d = Demuxer::with_options(&bytes[..], OpenOptions { index: false, verify_crc: false }).unwrap();
|
||||
assert_eq!(d.file().tracks.len(), 2);
|
||||
assert_eq!(d.seek(0, 1_100_000_000).unwrap().pts, 1000);
|
||||
assert_eq!(d.seek(0, 1_130_000_000).unwrap().pts, 1120);
|
||||
assert_eq!(d.next_packet().unwrap().unwrap().pts, 1120);
|
||||
assert!(d.file().indexed);
|
||||
}
|
||||
|
||||
fn one_block(ts: u64, rel: i16) -> Vec<u8> {
|
||||
let mut f = Vec::new();
|
||||
let mut h = Vec::new();
|
||||
el_str(&mut h, DOC_TYPE, "matroska");
|
||||
el(&mut f, EBML, &h);
|
||||
write_id(&mut f, SEGMENT);
|
||||
write_unknown_size(&mut f, 8);
|
||||
let mut info = Vec::new();
|
||||
el_uint(&mut info, TIMESTAMP_SCALE, 1_000_000);
|
||||
el(&mut f, INFO, &info);
|
||||
let mut v = Vec::new();
|
||||
el_uint(&mut v, TRACK_NUMBER, 1);
|
||||
el_uint(&mut v, TRACK_UID, 1);
|
||||
el_uint(&mut v, TRACK_TYPE, 1);
|
||||
el_str(&mut v, CODEC_ID, "V_UNCOMPRESSED");
|
||||
let mut tr = Vec::new();
|
||||
el(&mut tr, TRACK_ENTRY, &v);
|
||||
el(&mut f, TRACKS, &tr);
|
||||
write_id(&mut f, CLUSTER);
|
||||
write_unknown_size(&mut f, 8);
|
||||
el_uint(&mut f, TIMESTAMP, ts);
|
||||
el(&mut f, SIMPLE_BLOCK, &block(1, rel, 0x80, &[0]));
|
||||
f
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_timestamp_addition_does_not_overflow() {
|
||||
let bytes = one_block(1000, 5);
|
||||
let d = Demuxer::from_slice(&bytes).unwrap();
|
||||
let p: Vec<Packet> = d.map(|p| p.unwrap()).collect();
|
||||
assert_eq!(p[0].pts, 1005);
|
||||
let edge = one_block(i64::MAX as u64, 0);
|
||||
let d = Demuxer::from_slice(&edge).unwrap();
|
||||
let p: Vec<Packet> = d.map(|p| p.unwrap()).collect();
|
||||
assert_eq!(p[0].pts, i64::MAX);
|
||||
assert!(Demuxer::from_slice(&one_block(i64::MAX as u64, 1)).is_err());
|
||||
assert!(Demuxer::from_slice(&one_block(u64::MAX, 0)).is_err());
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
//! Track descriptions (RFC 9559 §5.1.4).
|
||||
|
||||
use crate::codec::Codec;
|
||||
|
||||
/// Track type (`TrackType`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TrackKind {
|
||||
Video,
|
||||
Audio,
|
||||
Complex,
|
||||
Logo,
|
||||
Subtitle,
|
||||
Buttons,
|
||||
Control,
|
||||
Metadata,
|
||||
Other(u8),
|
||||
}
|
||||
|
||||
impl TrackKind {
|
||||
pub(crate) fn from_type(t: u64) -> TrackKind {
|
||||
match t {
|
||||
1 => TrackKind::Video,
|
||||
2 => TrackKind::Audio,
|
||||
3 => TrackKind::Complex,
|
||||
0x10 => TrackKind::Logo,
|
||||
0x11 => TrackKind::Subtitle,
|
||||
0x12 => TrackKind::Buttons,
|
||||
0x20 => TrackKind::Control,
|
||||
0x21 => TrackKind::Metadata,
|
||||
n => TrackKind::Other(n as u8),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SMPTE ST 2086 mastering display metadata (`MasteringMetadata`). Chromaticities are CIE 1931 xy,
|
||||
/// luminance in cd/m².
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct MasteringMetadata {
|
||||
/// Red, green, blue primaries as (x, y).
|
||||
pub primaries: [(f64, f64); 3],
|
||||
pub white_point: (f64, f64),
|
||||
pub luminance_max: f64,
|
||||
pub luminance_min: f64,
|
||||
}
|
||||
|
||||
/// `Colour` element. Code points use ITU-T H.273 values (as Matroska does).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Colour {
|
||||
/// `MatrixCoefficients` (H.273; 2 = unspecified).
|
||||
pub matrix_coefficients: Option<u64>,
|
||||
pub bits_per_channel: Option<u64>,
|
||||
pub chroma_subsampling_horz: Option<u64>,
|
||||
pub chroma_subsampling_vert: Option<u64>,
|
||||
pub cb_subsampling_horz: Option<u64>,
|
||||
pub cb_subsampling_vert: Option<u64>,
|
||||
pub chroma_siting_horz: Option<u64>,
|
||||
pub chroma_siting_vert: Option<u64>,
|
||||
/// `Range`: 0 unspecified, 1 broadcast (limited), 2 full, 3 defined by matrix/transfer.
|
||||
pub range: Option<u64>,
|
||||
/// `TransferCharacteristics` (H.273).
|
||||
pub transfer_characteristics: Option<u64>,
|
||||
/// `Primaries` (H.273).
|
||||
pub primaries: Option<u64>,
|
||||
/// Maximum content light level (cd/m²).
|
||||
pub max_cll: Option<u64>,
|
||||
/// Maximum frame-average light level (cd/m²).
|
||||
pub max_fall: Option<u64>,
|
||||
pub mastering: Option<MasteringMetadata>,
|
||||
}
|
||||
|
||||
impl Colour {
|
||||
/// True if `Range` says full range.
|
||||
pub fn full_range(&self) -> bool {
|
||||
self.range == Some(2)
|
||||
}
|
||||
}
|
||||
|
||||
/// `Video` element.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct VideoInfo {
|
||||
pub pixel_width: u32,
|
||||
pub pixel_height: u32,
|
||||
/// `DisplayWidth`/`DisplayHeight`, in `display_unit` (0 = pixels, 3 = display aspect ratio).
|
||||
pub display_width: Option<u32>,
|
||||
pub display_height: Option<u32>,
|
||||
pub display_unit: u64,
|
||||
/// Crop (top, bottom, left, right) in pixels.
|
||||
pub crop: (u32, u32, u32, u32),
|
||||
/// `FlagInterlaced`: 0 undetermined, 1 interlaced, 2 progressive.
|
||||
pub flag_interlaced: u64,
|
||||
/// `FieldOrder` (0 progressive, 1 tff, 2 undetermined, 6 bff, 9/14 interleaved).
|
||||
pub field_order: Option<u64>,
|
||||
pub stereo_mode: Option<u64>,
|
||||
/// `AlphaMode`: 1 if BlockAdditions carry alpha.
|
||||
pub alpha_mode: u64,
|
||||
/// `ColourSpace` FourCC (uncompressed video).
|
||||
pub colour_space: Option<[u8; 4]>,
|
||||
pub colour: Option<Colour>,
|
||||
pub projection: Option<Projection>,
|
||||
}
|
||||
|
||||
/// `Projection` element (RFC 9559 §5.1.4.1.28.41): pose angles in degrees.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Projection {
|
||||
/// 0 rectangular, 1 equirectangular, 2 cubemap, 3 mesh.
|
||||
pub projection_type: u64,
|
||||
/// Clockwise around the up vector.
|
||||
pub yaw: f64,
|
||||
/// Counter-clockwise around the right vector.
|
||||
pub pitch: f64,
|
||||
/// Counter-clockwise around the forward vector.
|
||||
pub roll: f64,
|
||||
}
|
||||
|
||||
impl VideoInfo {
|
||||
/// Clockwise quarter turns (0–3) to display a rectangular video, from `ProjectionPoseRoll`
|
||||
/// (a counter-clockwise angle, so −90 is a quarter turn clockwise). None for other
|
||||
/// projections, flips (non-zero yaw / pitch) or angles that are not a multiple of 90°.
|
||||
pub fn display_rotation(&self) -> Option<u8> {
|
||||
let Some(p) = &self.projection else { return Some(0) };
|
||||
if p.projection_type != 0 || p.yaw != 0.0 || p.pitch != 0.0 || p.roll % 90.0 != 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some((-(p.roll / 90.0) as i64).rem_euclid(4) as u8)
|
||||
}
|
||||
|
||||
/// Pixel aspect ratio derived from the display size (pixels or aspect-ratio units), reduced.
|
||||
pub fn pixel_aspect(&self) -> (u32, u32) {
|
||||
let (pw, ph) =
|
||||
(self.pixel_width.saturating_sub(self.crop.2 + self.crop.3) as u64, self.pixel_height.saturating_sub(self.crop.0 + self.crop.1) as u64);
|
||||
let (Some(dw), Some(dh)) = (self.display_width, self.display_height) else { return (1, 1) };
|
||||
if pw == 0 || ph == 0 || dw == 0 || dh == 0 || !matches!(self.display_unit, 0 | 3) {
|
||||
return (1, 1);
|
||||
}
|
||||
// PAR = (dw/dh) / (pw/ph)
|
||||
let (n, d) = (dw as u64 * ph, dh as u64 * pw);
|
||||
let g = gcd(n, d);
|
||||
((n / g) as u32, (d / g) as u32)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn gcd(mut a: u64, mut b: u64) -> u64 {
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
a.max(1)
|
||||
}
|
||||
|
||||
/// `Audio` element.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AudioInfo {
|
||||
/// `SamplingFrequency` (Hz).
|
||||
pub sampling_frequency: f64,
|
||||
/// `OutputSamplingFrequency` (e.g. SBR output rate).
|
||||
pub output_sampling_frequency: Option<f64>,
|
||||
pub channels: u64,
|
||||
pub bit_depth: Option<u64>,
|
||||
}
|
||||
|
||||
impl Default for AudioInfo {
|
||||
fn default() -> Self {
|
||||
Self { sampling_frequency: 8000.0, output_sampling_frequency: None, channels: 1, bit_depth: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// One `ContentEncoding`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ContentEncoding {
|
||||
/// `ContentCompAlgo` 3: the given bytes were stripped from the start of every frame.
|
||||
HeaderStripping { bytes: Vec<u8>, scope: u64 },
|
||||
/// Other compression (0 zlib, 1 bzlib, 2 lzo1x): not supported for reading frame data.
|
||||
Compression { algo: u64, settings: Vec<u8>, scope: u64 },
|
||||
/// Encryption (not supported for reading frame data).
|
||||
Encryption { scope: u64 },
|
||||
}
|
||||
|
||||
/// One entry of a track's sample (frame) table.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Sample {
|
||||
/// Byte offset of the (stored) frame data in the file.
|
||||
pub offset: u64,
|
||||
/// Stored size (excluding header-stripped bytes; see [`Track::sample_size`]).
|
||||
pub size: u32,
|
||||
/// Presentation timestamp in the track timebase (`TimestampScale` ticks).
|
||||
pub pts: i64,
|
||||
/// Duration in timebase ticks (from `BlockDuration` or `DefaultDuration`), 0 if unknown.
|
||||
pub duration: u64,
|
||||
pub keyframe: bool,
|
||||
/// Index of the owning cluster in [`crate::MkvFile::clusters`].
|
||||
pub cluster: u32,
|
||||
/// Offset of the block element (SimpleBlock / BlockGroup) header.
|
||||
pub block_offset: u64,
|
||||
/// Index of this frame within a laced block.
|
||||
pub lace: u16,
|
||||
}
|
||||
|
||||
/// A Matroska track (`TrackEntry`) plus, once indexed, its sample table.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Track {
|
||||
pub number: u64,
|
||||
pub uid: u64,
|
||||
pub kind: TrackKind,
|
||||
pub enabled: bool,
|
||||
pub default: bool,
|
||||
pub forced: bool,
|
||||
pub hearing_impaired: bool,
|
||||
pub lacing: bool,
|
||||
/// `DefaultDuration` in nanoseconds.
|
||||
pub default_duration_ns: Option<u64>,
|
||||
pub name: String,
|
||||
/// `Language` (ISO 639-2, default `eng`).
|
||||
pub language: String,
|
||||
pub language_bcp47: Option<String>,
|
||||
pub codec_id: String,
|
||||
pub codec_private: Vec<u8>,
|
||||
pub codec_name: String,
|
||||
pub codec: Codec,
|
||||
/// `CodecDelay` in nanoseconds.
|
||||
pub codec_delay_ns: u64,
|
||||
/// `SeekPreRoll` in nanoseconds.
|
||||
pub seek_pre_roll_ns: u64,
|
||||
pub video: Option<VideoInfo>,
|
||||
pub audio: Option<AudioInfo>,
|
||||
pub encodings: Vec<ContentEncoding>,
|
||||
/// Timebase numerator/denominator in seconds: `TimestampScale / 1e9`, reduced.
|
||||
pub timebase: (u64, u64),
|
||||
/// Frame table (empty unless the file was opened with indexing).
|
||||
pub samples: Vec<Sample>,
|
||||
pub(crate) sync: Vec<u32>,
|
||||
}
|
||||
|
||||
impl Track {
|
||||
/// Bytes prepended to every frame by header-stripping compression, if any.
|
||||
pub fn stripped_header(&self) -> Option<&[u8]> {
|
||||
self.encodings.iter().find_map(|e| match e {
|
||||
ContentEncoding::HeaderStripping { bytes, scope } if scope & 1 != 0 => Some(bytes.as_slice()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// True if frames can be returned verbatim or with header stripping undone.
|
||||
pub fn frames_readable(&self) -> bool {
|
||||
self.encodings.iter().all(|e| match e {
|
||||
ContentEncoding::HeaderStripping { .. } => true,
|
||||
ContentEncoding::Compression { scope, .. } | ContentEncoding::Encryption { scope } => scope & 1 == 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Bytes to put in front of a stored frame of `stored_size` bytes to get the codec frame:
|
||||
/// header-stripped bytes, or for `V_PRORES` the 8-byte frame header (`size` + `icpf`) that the
|
||||
/// Matroska ProRes mapping omits.
|
||||
pub fn frame_prefix(&self, stored_size: u64) -> Vec<u8> {
|
||||
let mut p = self.stripped_header().map(<[u8]>::to_vec).unwrap_or_default();
|
||||
if matches!(self.codec, Codec::ProRes { .. }) {
|
||||
let total = stored_size + p.len() as u64 + 8;
|
||||
let mut h = (total as u32).to_be_bytes().to_vec();
|
||||
h.extend_from_slice(b"icpf");
|
||||
h.extend_from_slice(&p);
|
||||
p = h;
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
/// Frame size of sample `i` as returned by `read_sample` / packets (stored size plus prefix).
|
||||
pub fn sample_size(&self, i: usize) -> Option<u64> {
|
||||
let s = self.samples.get(i)?;
|
||||
Some(s.size as u64 + self.frame_prefix(s.size as u64).len() as u64)
|
||||
}
|
||||
|
||||
/// Indices of keyframe samples, ascending by file order.
|
||||
pub fn keyframes(&self) -> &[u32] {
|
||||
&self.sync
|
||||
}
|
||||
|
||||
/// Timebase ticks → nanoseconds.
|
||||
pub fn ticks_to_ns(&self, t: i64) -> i64 {
|
||||
(t as i128 * self.timebase.0 as i128 * 1_000_000_000 / self.timebase.1 as i128) as i64
|
||||
}
|
||||
|
||||
/// Nanoseconds → timebase ticks (floor).
|
||||
pub fn ns_to_ticks(&self, ns: i64) -> i64 {
|
||||
let n = ns as i128 * self.timebase.1 as i128;
|
||||
let d = self.timebase.0 as i128 * 1_000_000_000;
|
||||
n.div_euclid(d) as i64
|
||||
}
|
||||
|
||||
/// Index of the last sample with `pts <= t` (in file order), or the first sample.
|
||||
pub fn sample_at_pts(&self, t: i64) -> Option<usize> {
|
||||
if self.samples.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut best: Option<usize> = None;
|
||||
for (i, s) in self.samples.iter().enumerate() {
|
||||
if s.pts <= t && best.is_none_or(|b| s.pts >= self.samples[b].pts) {
|
||||
best = Some(i);
|
||||
}
|
||||
}
|
||||
Some(best.unwrap_or(0))
|
||||
}
|
||||
|
||||
/// Index of the keyframe with the greatest `pts <= t` (the first keyframe if none precedes `t`).
|
||||
pub fn keyframe_before_pts(&self, t: i64) -> Option<usize> {
|
||||
let mut best: Option<usize> = None;
|
||||
for &k in &self.sync {
|
||||
let s = &self.samples[k as usize];
|
||||
if s.pts <= t && best.is_none_or(|b| s.pts >= self.samples[b].pts) {
|
||||
best = Some(k as usize);
|
||||
}
|
||||
}
|
||||
best.or_else(|| self.sync.first().map(|&k| k as usize))
|
||||
}
|
||||
|
||||
/// Index of the nearest keyframe at or before sample `index` in file order.
|
||||
pub fn sync_sample_before(&self, index: usize) -> usize {
|
||||
match self.sync.binary_search(&(index as u32)) {
|
||||
Ok(i) => self.sync[i] as usize,
|
||||
Err(0) => 0,
|
||||
Err(i) => self.sync[i - 1] as usize,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rotation_tests {
|
||||
use super::{Projection, VideoInfo};
|
||||
|
||||
fn with_roll(projection_type: u64, yaw: f64, pitch: f64, roll: f64) -> VideoInfo {
|
||||
VideoInfo { projection: Some(Projection { projection_type, yaw, pitch, roll }), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_rotation_maps_rectangular_roll_to_clockwise_quarter_turns() {
|
||||
assert_eq!(VideoInfo::default().display_rotation(), Some(0), "no Projection: as stored");
|
||||
// ProjectionPoseRoll is counter-clockwise: -90 is a quarter turn clockwise
|
||||
for (roll, turns) in [(0.0, 0), (-90.0, 1), (180.0, 2), (-180.0, 2), (90.0, 3), (-270.0, 3), (270.0, 1), (360.0, 0)] {
|
||||
assert_eq!(with_roll(0, 0.0, 0.0, roll).display_rotation(), Some(turns), "roll {roll}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_rotation_leaves_non_rectangular_flipped_and_odd_angles_alone() {
|
||||
assert_eq!(with_roll(1, 0.0, 0.0, -90.0).display_rotation(), None, "equirectangular");
|
||||
assert_eq!(with_roll(0, 180.0, 0.0, 0.0).display_rotation(), None, "yaw flip");
|
||||
assert_eq!(with_roll(0, 0.0, 180.0, 0.0).display_rotation(), None, "pitch flip");
|
||||
assert_eq!(with_roll(0, 0.0, 0.0, 45.0).display_rotation(), None, "not a quarter turn");
|
||||
assert_eq!(with_roll(0, 0.0, 0.0, f64::NAN).display_rotation(), None, "malformed angle");
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
//! protocol, files open and load only from inside the read root and are saved and exported only
|
||||
//! inside the write root. Paths that leave a root (`..`, another folder, a sibling with the same
|
||||
//! prefix, a link) are refused, and nothing is written outside.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod automation_roots;
|
||||
@@ -31,7 +31,7 @@ pub use audio::Pcm;
|
||||
pub use clip::ClipParams;
|
||||
pub use feed::VideoFeed;
|
||||
pub use player::{AudioOut, PlayState, Player, VoiceSpec, VoiceStatus};
|
||||
pub use probe::{AudioInfo, MediaInfo, VideoInfo, probe};
|
||||
pub use probe::{AudioInfo, MediaInfo, VideoInfo, probe, probe_headers};
|
||||
pub use video::{Frame, VideoDecoder, poster_frame, poster_png};
|
||||
|
||||
/// Shared media bytes (the model's `MediaItem::data`).
|
||||
|
||||
@@ -254,7 +254,7 @@ impl Player {
|
||||
}
|
||||
|
||||
fn with<R>(&self, f: impl FnOnce(&mut Mixer) -> R) -> R {
|
||||
let mut g = self.mixer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut g = self.mixer.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
f(&mut g)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,19 @@ fn ms(secs: f64) -> u64 {
|
||||
|
||||
/// Probe `bytes`.
|
||||
pub fn probe(bytes: &Bytes) -> Result<MediaInfo> {
|
||||
let mut info = probe_headers(bytes)?;
|
||||
if info.duration_ms == 0 && info.audio.as_ref().is_some_and(|a| a.decodable) && info.video.is_none() {
|
||||
// No length in the header (raw MP3 / ADTS without an index): decode to measure.
|
||||
if let Ok(pcm) = crate::audio::decode(bytes) {
|
||||
info.duration_ms = ms(pcm.duration());
|
||||
}
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// [`probe`] from the headers alone, without decoding: a raw MP3 or ADTS stream without an
|
||||
/// index reports a `duration_ms` of 0.
|
||||
pub fn probe_headers(bytes: &Bytes) -> Result<MediaInfo> {
|
||||
let c = Container::sniff(bytes);
|
||||
let mut info = match c {
|
||||
Container::Mp4 => probe_mp4(bytes)?,
|
||||
@@ -73,12 +86,6 @@ pub fn probe(bytes: &Bytes) -> Result<MediaInfo> {
|
||||
info.duration_ms = ms(n as f64 / rate as f64);
|
||||
}
|
||||
}
|
||||
if info.duration_ms == 0 && info.audio.as_ref().is_some_and(|a| a.decodable) && info.video.is_none() {
|
||||
// No length in the header (raw MP3 / ADTS without an index): decode to measure.
|
||||
if let Ok(pcm) = crate::audio::decode(bytes) {
|
||||
info.duration_ms = ms(pcm.duration());
|
||||
}
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
|
||||
@@ -330,6 +330,7 @@ pub(crate) fn codec_name(c: &CodecConfig) -> String {
|
||||
CodecConfig::Hevc(_) => "HEVC".into(),
|
||||
CodecConfig::Av1(_) => "AV1".into(),
|
||||
CodecConfig::Vp9(_) => "VP9".into(),
|
||||
CodecConfig::Apv(_) => "APV".into(),
|
||||
CodecConfig::ProRes { .. } => "Apple ProRes".into(),
|
||||
CodecConfig::Jpeg { .. } => "Motion JPEG".into(),
|
||||
CodecConfig::Dnx { .. } => "DNxHD".into(),
|
||||
|
||||
Vendored
+3
-2
@@ -20,12 +20,13 @@ $FF $TONE44 -c:a wmav2 -b:a 64k $META tone.wma
|
||||
# Video: 64x48 (AV1: 64x64), 10 fps, 6 frames of our own moving colour gradient (raw RGB from
|
||||
# python3 below), so every pixel is contributor-original.
|
||||
frames() {
|
||||
width="$1" height="$2" out="$3"
|
||||
python3 -c "
|
||||
import sys
|
||||
w, h = $1, $2
|
||||
w, h = $width, $height
|
||||
for f in range(6):
|
||||
sys.stdout.buffer.write(bytes(v for y in range(h) for x in range(w) for v in ((x * 4 + f * 20) % 256, (y * 5) % 256, (f * 40) % 256)))
|
||||
" > "$3"
|
||||
" > "$out"
|
||||
}
|
||||
frames 64 48 /tmp/deckcraft-fixture-64x48.rgb
|
||||
frames 64 64 /tmp/deckcraft-fixture-64x64.rgb
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
mod decode;
|
||||
@@ -1187,7 +1187,7 @@ fn image(r: Bytes) -> Option<ImageData> {
|
||||
fn metafile(r: Bytes) -> Option<Picture> {
|
||||
let size = r.u32(12)? as usize;
|
||||
let data = r.slice(16, size).or_else(|| r.tail(16).map(|t| t.0))?;
|
||||
let depth = NESTING.with(|n| n.get());
|
||||
let depth = NESTING.with(std::cell::Cell::get);
|
||||
if depth >= MAX_NESTING {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user