mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 18:46:57 -06:00
Compare commits
190
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c632bfdd6b | ||
|
|
e181444b86 | ||
|
|
da1733e0a1 | ||
|
|
c955407373 | ||
|
|
54962c6389 | ||
|
|
6189fa8bf6 | ||
|
|
713f605566 | ||
|
|
9641817a15 | ||
|
|
1e097e3edd | ||
|
|
d94790d97a | ||
|
|
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 | ||
|
|
f71ce3fa9d | ||
|
|
e03debfd4b | ||
|
|
906898f662 | ||
|
|
2741d3236b | ||
|
|
96697f7612 | ||
|
|
7b0252f291 | ||
|
|
2303efe7d5 | ||
|
|
2d6582163f | ||
|
|
e89a893fe1 | ||
|
|
72a9e4456e | ||
|
|
309d9f5b2c | ||
|
|
6b91132ce7 | ||
|
|
5e0a60b15e | ||
|
|
02b3940efd | ||
|
|
98b6d04413 | ||
|
|
e5a407d6e2 | ||
|
|
6f83ed464d | ||
|
|
e37267f548 | ||
|
|
18f5e17b36 | ||
|
|
65564cfc1b | ||
|
|
2071ff599d | ||
|
|
1df1d2e724 | ||
|
|
e843309280 | ||
|
|
a9031d63e1 | ||
|
|
0dc39b9efc | ||
|
|
57d65faf4f | ||
|
|
7040bb0619 | ||
|
|
900f62f5a3 | ||
|
|
f8c8223ff0 | ||
|
|
883cc6e391 | ||
|
|
ba194e35d8 | ||
|
|
1090ca2d70 | ||
|
|
9412d3ccaf | ||
|
|
c90ea7d9ae | ||
|
|
5beca8c1b6 | ||
|
|
5ac14f2015 | ||
|
|
9b56f83929 | ||
|
|
935724850b | ||
|
|
a49570e45b | ||
|
|
b75ea712e9 | ||
|
|
e34169ff75 | ||
|
|
86e7879f5e | ||
|
|
0a0ad04f47 | ||
|
|
74bc27f27e | ||
|
|
445e7c6b55 | ||
|
|
0298201b2c | ||
|
|
03993057a4 | ||
|
|
172952f6e0 | ||
|
|
e54bd2ccb7 | ||
|
|
8e9c198c7c | ||
|
|
601823d98c | ||
|
|
46dec4a660 | ||
|
|
b0006d6af8 | ||
|
|
8f4b0d4c3f | ||
|
|
271c6c4037 | ||
|
|
432016fc96 | ||
|
|
79088b7060 |
+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,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
|
||||
@@ -10,8 +10,9 @@ on:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
# Nightly, so dependency and runner-image drift shows up within a day.
|
||||
schedule:
|
||||
- cron: "41 3 * * 1"
|
||||
- cron: "3 5 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -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
+52
-166
@@ -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,9 +885,10 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
"cpal",
|
||||
"deckcraft-engine",
|
||||
"deckcraft-media",
|
||||
@@ -912,7 +906,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -921,7 +915,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -930,14 +924,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -947,15 +941,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
"deckcraft-fonts",
|
||||
@@ -979,7 +974,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -991,7 +986,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1002,7 +997,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1010,7 +1005,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1019,7 +1014,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1028,19 +1023,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1050,7 +1047,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1068,7 +1065,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1077,7 +1074,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1089,15 +1086,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1115,7 +1112,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1131,7 +1128,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1150,7 +1147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1164,7 +1161,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1186,7 +1183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1195,7 +1192,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1643,12 +1640,6 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
@@ -2260,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]]
|
||||
@@ -2438,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",
|
||||
@@ -2647,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",
|
||||
@@ -3398,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"
|
||||
@@ -3437,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"
|
||||
@@ -3504,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"
|
||||
@@ -3656,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"
|
||||
@@ -3748,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"
|
||||
@@ -4397,7 +4298,7 @@ dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error 2.0.1",
|
||||
"quick-error",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
@@ -4544,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"
|
||||
@@ -4705,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"
|
||||
@@ -5042,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",
|
||||
@@ -5115,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",
|
||||
@@ -5733,7 +5619,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.9.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.9.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,6 +7,10 @@
|
||||
</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">
|
||||
@@ -220,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 |
|
||||
|
||||
+32
-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-18 · **Change:** 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-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,15 +134,34 @@ 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 |
|
||||
@@ -240,11 +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 |
|
||||
|
||||
@@ -407,7 +407,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
|
||||
return Ok(vec![out.to_string()]);
|
||||
}
|
||||
let p = std::path::Path::new(out);
|
||||
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame");
|
||||
let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("frame");
|
||||
let mut paths: Vec<String> = Vec::with_capacity(ts.len());
|
||||
for t in ts {
|
||||
let path = p.with_file_name(format!("{stem}-t{t:.3}.png")).to_string_lossy().into_owned();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -41,8 +41,9 @@ pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) {
|
||||
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}"));
|
||||
let safe_p = p.replace(['\r', '\n'], " ");
|
||||
log::error!("{safe_p}: {e}");
|
||||
app.set_status(format!("Couldn't open {safe_p}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
//! 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};
|
||||
@@ -61,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);
|
||||
@@ -170,6 +180,35 @@ fn is_http_request_line(text: &str) -> bool {
|
||||
|| 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;
|
||||
@@ -182,6 +221,7 @@ 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,
|
||||
@@ -189,8 +229,11 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
let reply = match parse(&line) {
|
||||
Parsed::Blank => continue,
|
||||
Parsed::Reject(e) => {
|
||||
// The connection closes either way; a peer that can't read the reason loses nothing.
|
||||
// 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;
|
||||
}
|
||||
Parsed::Request { id, method, params } => {
|
||||
@@ -234,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;
|
||||
|
||||
@@ -327,6 +382,27 @@ mod tests {
|
||||
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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,7 +7,10 @@ use deckcraft_model::{
|
||||
AnimClass, AnimStart, Animation, ColorRef, CustomShow, Presentation, Rgba, Shape, ShapeId, Slide, SlideId, TextBody, Transition, Xfrm,
|
||||
};
|
||||
|
||||
use crate::*;
|
||||
use crate::{
|
||||
AnimState, Layer, LinkJump, MotionPath, ShowAction, ShowState, Source, Timeline, TriggerPlay, default_path, ease, morph_crop, morph_frame,
|
||||
morph_pairs, morph_xfrm, show_order, transition_frame, transition_layers,
|
||||
};
|
||||
|
||||
const W: f64 = 960.0;
|
||||
const H: f64 = 540.0;
|
||||
@@ -344,6 +347,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(
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
use deckcraft_model::text::Action;
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, Presentation, Shape, ShapeId, Slide, SlideId, Transition, Xfrm};
|
||||
|
||||
use crate::*;
|
||||
use crate::{Click, HitShape, LinkJump, ShowAction, ShowState, Target, Timeline};
|
||||
|
||||
fn shape(id: u32) -> Shape {
|
||||
Shape { id: ShapeId(id), name: format!("S{id}"), xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)), ..Default::default() }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -9,7 +9,10 @@ use std::f64::consts::PI;
|
||||
|
||||
use crate::easing::ease;
|
||||
use crate::transition::{FULL, Layer, R, Source};
|
||||
use crate::transition_geom::*;
|
||||
use crate::transition_geom::{
|
||||
Cam, V3, boundary, card, center, clip, dir, edge, ein, eout, facing_shade, fan, field, hash64, moved, offset, poly_layers, pow_inout, q2,
|
||||
rect_poly, rnd, rot, rot_quad, scale_rect, scaled, soft_rect, ss, v3, win,
|
||||
};
|
||||
|
||||
use Source::{Black, New, NewBack, NewContent, Old, OldBack, OldContent, White};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! CDEF (7.15).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{CDEF_DIRECTIONS, CDEF_PRI_TAPS, CDEF_SEC_TAPS, CDEF_UV_DIR, DIV_TABLE};
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
|
||||
@@ -242,14 +242,14 @@ fn filter_rows<const W: usize>(
|
||||
}
|
||||
for i in 0..h {
|
||||
let ci = ((i + 2) * PAD_STRIDE + 2) as isize;
|
||||
let Some(&x) = b16.get(ci as usize..).and_then(|r| r.first_chunk::<W>()) else {
|
||||
let Some(&x) = b16.get(ci as usize..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
let (mut sum, mut max, mut min) = ([0i16; W], x, x);
|
||||
for &(off, weight, thr, adj) in taps {
|
||||
let (weight, thr) = (weight as i16, thr as i16);
|
||||
let start = (ci + off) as usize;
|
||||
let Some(p) = b16.get(start..).and_then(|r| r.first_chunk::<W>()) else {
|
||||
let Some(p) = b16.get(start..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
|
||||
+21
-1
@@ -1,6 +1,26 @@
|
||||
//! The adaptive CDF arrays (spec 6.8.2 init_non_coeff_cdfs / init_coeff_cdfs, load_cdfs).
|
||||
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
DEFAULT_ANGLE_DELTA_CDF, DEFAULT_CFL_ALPHA_CDF, DEFAULT_CFL_SIGN_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF,
|
||||
DEFAULT_COMP_BWD_REF_CDF, DEFAULT_COMP_GROUP_IDX_CDF, DEFAULT_COMP_MODE_CDF, DEFAULT_COMP_REF_CDF, DEFAULT_COMP_REF_TYPE_CDF,
|
||||
DEFAULT_COMPOUND_IDX_CDF, DEFAULT_COMPOUND_MODE_CDF, DEFAULT_COMPOUND_TYPE_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DELTA_LF_CDF, DEFAULT_DELTA_Q_CDF,
|
||||
DEFAULT_DRL_MODE_CDF, DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF,
|
||||
DEFAULT_EOB_PT_256_CDF, DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_FILTER_INTRA_CDF, DEFAULT_FILTER_INTRA_MODE_CDF,
|
||||
DEFAULT_INTER_INTRA_CDF, DEFAULT_INTER_INTRA_MODE_CDF, DEFAULT_INTER_TX_TYPE_SET1_CDF, DEFAULT_INTER_TX_TYPE_SET2_CDF,
|
||||
DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTERP_FILTER_CDF, DEFAULT_INTRA_TX_TYPE_SET1_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_INTRABC_CDF,
|
||||
DEFAULT_IS_INTER_CDF, DEFAULT_MOTION_MODE_CDF, DEFAULT_MV_BIT_CDF, DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF,
|
||||
DEFAULT_MV_CLASS0_HP_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_HP_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, DEFAULT_NEW_MV_CDF,
|
||||
DEFAULT_PALETTE_SIZE_2_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_2_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_4_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_4_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_6_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_6_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_8_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_8_Y_COLOR_CDF, DEFAULT_PALETTE_UV_MODE_CDF, DEFAULT_PALETTE_UV_SIZE_CDF,
|
||||
DEFAULT_PALETTE_Y_MODE_CDF, DEFAULT_PALETTE_Y_SIZE_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF,
|
||||
DEFAULT_PARTITION_W64_CDF, DEFAULT_PARTITION_W128_CDF, DEFAULT_REF_MV_CDF, DEFAULT_RESTORATION_TYPE_CDF, DEFAULT_SEGMENT_ID_CDF,
|
||||
DEFAULT_SEGMENT_ID_PREDICTED_CDF, DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_SKIP_MODE_CDF, DEFAULT_TX_8X8_CDF, DEFAULT_TX_16X16_CDF,
|
||||
DEFAULT_TX_32X32_CDF, DEFAULT_TX_64X64_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_TXFM_SPLIT_CDF, DEFAULT_UNI_COMP_REF_CDF, DEFAULT_USE_OBMC_CDF,
|
||||
DEFAULT_USE_SGRPROJ_CDF, DEFAULT_USE_WIENER_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_WEDGE_INDEX_CDF,
|
||||
DEFAULT_WEDGE_INTER_INTRA_CDF, DEFAULT_Y_MODE_CDF, DEFAULT_ZERO_MV_CDF, FRAME_LF_COUNT, MV_CONTEXTS,
|
||||
};
|
||||
|
||||
/// Every CDF array listed by init_non_coeff_cdfs and init_coeff_cdfs. Each innermost array holds
|
||||
/// N cumulative values (the last is 32768) plus the adaptation counter.
|
||||
|
||||
@@ -17,7 +17,11 @@ use crate::cdf::Cdfs;
|
||||
use crate::frame::FrameBuf;
|
||||
use crate::header::{FilmGrainParams, FrameHeader, HeaderState, LoopFilterDeltas, RefInfo, SegmentationFeatures, SequenceHeader, default_gm_params};
|
||||
use crate::par::Pool;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, DEFAULT_INTRA_FRAME_Y_MODE_CDF, DIV_MULT, GOLDEN_FRAME, INTRA_FRAME, INTRA_ONLY_FRAME, KEY_FRAME,
|
||||
LAST_FRAME, LAST2_FRAME, MAX_FRAME_DISTANCE, MAX_OFFSET_HEIGHT, MAX_OFFSET_WIDTH, MFMV_STACK_SIZE, NUM_REF_FRAMES, PRIMARY_REF_NONE,
|
||||
REFMVS_LIMIT, REFS_PER_FRAME,
|
||||
};
|
||||
use crate::state::{FrameShared, FrameState, TileState};
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
use crate::tile::TileDecoder;
|
||||
@@ -61,7 +65,7 @@ impl<T: Clone> Slot<T> {
|
||||
}
|
||||
|
||||
fn set(&self, v: Result<T>) {
|
||||
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*g = Some(v);
|
||||
self.cv.notify_all();
|
||||
}
|
||||
@@ -71,12 +75,12 @@ impl<T: Clone> Slot<T> {
|
||||
}
|
||||
|
||||
fn wait(&self) -> Result<T> {
|
||||
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
loop {
|
||||
if let Some(v) = g.as_ref() {
|
||||
return v.clone();
|
||||
}
|
||||
g = self.cv.wait(g).unwrap_or_else(|e| e.into_inner());
|
||||
g = self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const POOL_MAX_SAMPLES: usize = 96 << 20;
|
||||
fn zeroed(len: usize) -> Vec<u16> {
|
||||
if len >= POOL_MIN_LEN {
|
||||
let found = {
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
pool.iter().position(|v| v.capacity() >= len && v.capacity() <= 2 * len).map(|i| pool.swap_remove(i))
|
||||
};
|
||||
if let Some(mut v) = found {
|
||||
@@ -52,8 +52,8 @@ impl Drop for Plane {
|
||||
fn drop(&mut self) {
|
||||
if self.data.capacity() >= POOL_MIN_LEN {
|
||||
let v = std::mem::take(&mut self.data);
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let kept: usize = pool.iter().map(|b| b.capacity()).sum();
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let kept: usize = pool.iter().map(Vec::capacity).sum();
|
||||
if kept + v.capacity() <= POOL_MAX_SAMPLES {
|
||||
pool.push(v);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
//! Sequence header (5.5) and uncompressed frame header (5.9) parsing.
|
||||
|
||||
use crate::bits::BitReader;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
AFFINE, ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, GM_ABS_ALPHA_BITS, GM_ABS_TRANS_BITS, GM_ABS_TRANS_ONLY_BITS, GM_ALPHA_PREC_BITS,
|
||||
GM_TRANS_ONLY_PREC_BITS, GM_TRANS_PREC_BITS, GOLDEN_FRAME, IDENTITY, INTER_FRAME, INTRA_ONLY_FRAME, KEY_FRAME, LAST_FRAME, MAX_SEGMENTS,
|
||||
MAX_TILE_AREA, MAX_TILE_COLS, MAX_TILE_ROWS, MAX_TILE_WIDTH, NUM_REF_FRAMES, ONLY_4X4, PRIMARY_REF_NONE, REF_FRAME_LIST, REFS_PER_FRAME,
|
||||
RESTORATION_TILESIZE_MAX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_SWITCHABLE, RESTORE_WIENER, ROTZOOM, SEG_LVL_ALT_Q, SEG_LVL_MAX,
|
||||
SEG_LVL_REF_FRAME, SELECT_INTEGER_MV, SELECT_SCREEN_CONTENT_TOOLS, SUPERRES_DENOM_BITS, SUPERRES_DENOM_MIN, SUPERRES_NUM, SWITCH_FRAME,
|
||||
TOTAL_REFS_PER_FRAME, TRANSLATION, TX_MODE_LARGEST, TX_MODE_SELECT, WARPEDMODEL_PREC_BITS,
|
||||
};
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const EIGHTTAP: u8 = 0;
|
||||
|
||||
+12
-3
@@ -5,7 +5,16 @@ use std::sync::OnceLock;
|
||||
|
||||
use crate::frame::Plane;
|
||||
use crate::mvpred::round2_signed64;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
BLOCK_8X8, BLOCK_SIZES, COMPOUND_AVERAGE, COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_WEDGE, DIV_LUT, DIV_LUT_BITS,
|
||||
DIV_LUT_PREC_BITS, EIGHTTAP, EIGHTTAP_SHARP, EIGHTTAP_SMOOTH, FILTER_BITS, GLOBAL_GLOBALMV, GLOBALMV, II_H_PRED, II_SMOOTH_PRED, II_V_PRED,
|
||||
II_WEIGHTS_1D, INTRA_FRAME, LAST_FRAME, LOCALWARP, LS_MV_MAX, MASK_MASTER_SIZE, MAX_FRAME_DISTANCE, MAX_SB_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2,
|
||||
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, OBMC_MASK_2, OBMC_MASK_4, OBMC_MASK_8, OBMC_MASK_16, OBMC_MASK_32, QUANT_DIST_LOOKUP,
|
||||
QUANT_DIST_WEIGHT, REF_SCALE_SHIFT, SCALE_SUBPEL_BITS, SUBPEL_BITS, SUBPEL_FILTERS, SUBPEL_MASK, TRANSLATION, WARP_PARAM_REDUCE_BITS,
|
||||
WARPED_FILTERS, WARPEDDIFF_PREC_BITS, WARPEDMODEL_NONDIAGAFFINE_CLAMP, WARPEDMODEL_PREC_BITS, WARPEDMODEL_TRANS_CLAMP, WARPEDPIXEL_PREC_SHIFTS,
|
||||
WEDGE_BITS, WEDGE_CODEBOOK, WEDGE_HORIZONTAL, WEDGE_MASTER_OBLIQUE_EVEN, WEDGE_MASTER_OBLIQUE_ODD, WEDGE_MASTER_VERTICAL, WEDGE_OBLIQUE27,
|
||||
WEDGE_OBLIQUE63, WEDGE_OBLIQUE117, WEDGE_OBLIQUE153, WEDGE_TYPES, WEDGE_VERTICAL,
|
||||
};
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
const PRED_STRIDE: usize = 128;
|
||||
@@ -678,7 +687,7 @@ fn hpass<const W: usize>(src: &[u16], hf: &[i16; 8], out: &mut [i32], half: i32,
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = hf[t] as i32;
|
||||
let Some(p) = src.get(t..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
let Some(p) = src.get(t..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
@@ -699,7 +708,7 @@ fn vpass<const W: usize>(rows: &[i32], vf: &[i16; 8], out: &mut [i32], half: i32
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = vf[t] as i32;
|
||||
let Some(p) = rows.get(t * W..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
let Some(p) = rows.get(t * W..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
use crate::Result;
|
||||
use crate::header::{EIGHTTAP, SWITCHABLE};
|
||||
use crate::mvpred::MvP;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_32X32, BLOCK_64X64, BLOCK_128X128, BWDREF_FRAME, CLASS0_SIZE, COMP_NEWMV_CTXS, COMPOUND_AVERAGE,
|
||||
COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_MODE_CTX_MAP, COMPOUND_REFERENCE, COMPOUND_WEDGE, GLOBAL_GLOBALMV, GLOBALMV,
|
||||
GOLDEN_FRAME, INTRA_FRAME, INTRABC_DELAY_PIXELS, LAST_FRAME, LAST2_FRAME, LAST3_FRAME, LOCALWARP, MV_CLASS_0, MV_INTRABC_CONTEXT,
|
||||
MV_JOINT_HNZVNZ, MV_JOINT_HNZVZ, MV_JOINT_HZVNZ, NEAR_NEARMV, NEAR_NEWMV, NEAREST_NEARESTMV, NEAREST_NEWMV, NEARESTMV, NEARMV, NEW_NEARESTMV,
|
||||
NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, REF_SCALE_SHIFT, SEG_LVL_GLOBALMV, SEG_LVL_REF_FRAME, SEG_LVL_SKIP,
|
||||
SIMPLE, SINGLE_REFERENCE, SIZE_GROUP, TRANSLATION, UNIDIR_COMP_REFERENCE, UV_CFL_PRED, WEDGE_BITS,
|
||||
};
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
fn block_width(bs: usize) -> usize {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Intra prediction (spec 7.11.2), palette prediction (7.11.4) and chroma from luma (7.11.5).
|
||||
|
||||
use crate::frame::Plane;
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ANGLE_STEP, D67_PRED, DC_PRED, DR_INTRA_DERIVATIVE, INTRA_EDGE_KERNEL, INTRA_EDGE_TAPS, INTRA_FILTER_SCALE_BITS, INTRA_FILTER_TAPS,
|
||||
MODE_TO_ANGLE, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SM_WEIGHTS_TX_64X64, SMOOTH_H_PRED, SMOOTH_PRED,
|
||||
SMOOTH_V_PRED, V_PRED,
|
||||
};
|
||||
use crate::transform::round2;
|
||||
|
||||
/// Inputs of the intra prediction process that come from the block.
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! Motion vector prediction processes (spec 7.10): find_mv_stack, has_overlappable_candidates
|
||||
//! and find_warp_samples.
|
||||
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
GLOBAL_GLOBALMV, GLOBALMV, IDENTITY, INTRA_FRAME, LEAST_SQUARES_SAMPLES_MAX, MAX_REF_MV_STACK_SIZE, MV_BORDER, NEAR_NEWMV, NEAREST_NEWMV,
|
||||
NEW_NEARESTMV, NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, REF_CAT_LEVEL, TRANSLATION, WARPEDMODEL_PREC_BITS,
|
||||
};
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
pub(crate) type MvP = [i32; 2];
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
|
||||
//! super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
GLOBAL_GLOBALMV, GLOBALMV, INTRA_FRAME, MAX_LOOP_FILTER, NEARESTMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SEG_LVL_ALT_LF_Y_V, TX_HEIGHT,
|
||||
TX_WIDTH,
|
||||
};
|
||||
use crate::state::FrameState;
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
FILTER_BITS, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER, SGR_PARAMS, SGRPROJ_MTABLE_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_RECIP_BITS,
|
||||
SGRPROJ_RST_BITS, SGRPROJ_SGR_BITS, SUPERRES_EXTRA_BITS, SUPERRES_FILTER_OFFSET, SUPERRES_FILTER_TAPS, SUPERRES_SCALE_BITS, SUPERRES_SCALE_MASK,
|
||||
UPSCALE_FILTER,
|
||||
};
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Upscale a frame horizontally from FrameWidth to UpscaledWidth (7.16).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use crate::decoder::RefFrame;
|
||||
use crate::frame::{FrameBuf, MiInfo};
|
||||
use crate::header::{FrameHeader, RefInfo, SequenceHeader};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{DEFAULT_INTRA_FRAME_Y_MODE_CDF, NUM_REF_FRAMES, RESTORE_NONE, SUBSAMPLED_SIZE};
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
+23
-5
@@ -6,7 +6,27 @@ use crate::cdf::Cdfs;
|
||||
use crate::frame::{MiInfo, Mv};
|
||||
use crate::header::{FrameHeader, NONE, SequenceHeader};
|
||||
use crate::intra::{IntraParams, is_directional_mode, predict_intra, round2_signed};
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
AC_QLOOKUP, ADJUSTED_TX_SIZE, ADST_FLIPADST, BLOCK_4X4, BLOCK_8X8, BLOCK_64X64, BLOCK_128X128, BR_CDF_SIZE, CFL_SIGN_NEG, CFL_SIGN_ZERO,
|
||||
COEFF_BASE_CTX_OFFSET, COEFF_BASE_POS_CTX_OFFSET, COEFF_BASE_RANGE, COMPOUND_AVERAGE, DC_PRED, DC_QLOOKUP, DCT_DCT, DCT_FLIPADST,
|
||||
DEFAULT_SCAN_4X4, DEFAULT_SCAN_4X8, DEFAULT_SCAN_4X16, DEFAULT_SCAN_8X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_8X16, DEFAULT_SCAN_8X32,
|
||||
DEFAULT_SCAN_16X4, DEFAULT_SCAN_16X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_16X32, DEFAULT_SCAN_32X8, DEFAULT_SCAN_32X16, DEFAULT_SCAN_32X32,
|
||||
DELTA_LF_SMALL, DELTA_Q_SMALL, FILTER_INTRA_MODE_TO_INTRA_DIR, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, FRAME_LF_COUNT, H_ADST, H_DCT,
|
||||
H_FLIPADST, H_PRED, IDTX, II_DC_PRED, II_H_PRED, II_V_PRED, INTRA_FRAME, INTRA_MODE_CONTEXT, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA,
|
||||
MAX_LOOP_FILTER, MAX_TX_DEPTH_TABLE, MAX_TX_SIZE_RECT, MAX_VARTX_DEPTH, MCOL_SCAN_4X4, MCOL_SCAN_4X8, MCOL_SCAN_4X16, MCOL_SCAN_8X4,
|
||||
MCOL_SCAN_8X8, MCOL_SCAN_8X16, MCOL_SCAN_16X4, MCOL_SCAN_16X8, MCOL_SCAN_16X16, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MODE_TO_TXFM, MROW_SCAN_4X4,
|
||||
MROW_SCAN_4X8, MROW_SCAN_4X16, MROW_SCAN_8X4, MROW_SCAN_8X8, MROW_SCAN_8X16, MROW_SCAN_16X4, MROW_SCAN_16X8, MROW_SCAN_16X16,
|
||||
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PALETTE_COLOR_CONTEXT, PALETTE_COLOR_HASH_MULTIPLIERS, PALETTE_COLORS,
|
||||
PALETTE_NUM_NEIGHBORS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE, PARTITION_SPLIT, PARTITION_SUBSIZE,
|
||||
PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, QM_OFFSET, QUANTIZER_MATRIX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER,
|
||||
SEG_LVL_SKIP, SGR_PARAMS, SGRPROJ_PARAMS_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_PRJ_SUBEXP_K, SIG_REF_DIFF_OFFSET, SIG_REF_DIFF_OFFSET_NUM, SIMPLE,
|
||||
SMOOTH_H_PRED, SMOOTH_PRED, SMOOTH_V_PRED, SPLIT_TX_SIZE, SUPERRES_NUM, TX_4X4, TX_4X8, TX_4X16, TX_8X4, TX_8X8, TX_8X16, TX_8X32, TX_16X4,
|
||||
TX_16X8, TX_16X16, TX_16X32, TX_16X64, TX_32X8, TX_32X16, TX_32X32, TX_32X64, TX_64X16, TX_64X32, TX_64X64, TX_CLASS_2D, TX_CLASS_HORIZ,
|
||||
TX_CLASS_VERT, TX_HEIGHT, TX_HEIGHT_LOG2, TX_MODE_SELECT, TX_SET_DCTONLY, TX_SET_INTER_1, TX_SET_INTER_2, TX_SET_INTER_3, TX_SET_INTRA_1,
|
||||
TX_SET_INTRA_2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_SIZES, TX_SIZES_ALL, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA, TX_TYPE_INTER_INV_SET1,
|
||||
TX_TYPE_INTER_INV_SET2, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET1, TX_TYPE_INTRA_INV_SET2, TX_WIDTH, TX_WIDTH_LOG2, UV_CFL_PRED, V_ADST,
|
||||
V_DCT, V_FLIPADST, V_PRED,
|
||||
};
|
||||
use crate::state::{FrameShared, TileState};
|
||||
use crate::symbol::SymbolDecoder;
|
||||
use crate::transform::inverse_transform_2d;
|
||||
@@ -2222,18 +2242,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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Inverse transforms (spec 7.13), implemented step by step as specified.
|
||||
|
||||
use crate::spec_tables::*;
|
||||
use crate::spec_tables::{
|
||||
ADST_ADST, ADST_DCT, ADST_FLIPADST, COS128_LOOKUP, DCT_ADST, DCT_DCT, DCT_FLIPADST, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, H_ADST,
|
||||
H_DCT, H_FLIPADST, TRANSFORM_ROW_SHIFT, TX_HEIGHT_LOG2, TX_WIDTH_LOG2, V_ADST, V_DCT, V_FLIPADST,
|
||||
};
|
||||
|
||||
#[inline(always)]
|
||||
fn round2_64(x: i64, n: u32) -> i32 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use deckcraft_model::anim::{ANIMATIONS, AnimClass, AnimStart, Animation, TextBuild, animation_info};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, bool_param, id_param, ok, str_param, targets, usize_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use deckcraft_geom::{Point, Rect, Xfrm};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, edit_geometry, id_param, ok, str_param, targets, usize_param, with_param, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -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));
|
||||
|
||||
@@ -9,7 +9,7 @@ use deckcraft_model::{MediaId, ShapeId, ShapeKind};
|
||||
use image::{GrayImage, Rgba, RgbaImage};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, str_param, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// The effects offered: `a14` element name and label.
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::VecDeque;
|
||||
use deckcraft_model::{ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, base64_encode, f64_or, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Larger pictures are refused rather than matted slowly (16 megapixels).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
use crate::Session;
|
||||
|
||||
fn deck() -> Session {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -8,7 +8,7 @@ use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{MediaId, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, f64_param, targets};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
+240
-14
@@ -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;
|
||||
@@ -9,7 +10,9 @@ use deckcraft_model::theme::{builtin_color_schemes, builtin_font_schemes, builti
|
||||
use deckcraft_model::{Background, Fill, PhType, Presentation, Shape, ShapeId, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, color_param, f64_param, ok, rect_param, shape, str_param, text, usize_param, with_param,
|
||||
};
|
||||
use crate::{Result, Session, Target};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -592,6 +595,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 +607,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 +639,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 +667,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 +978,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 +1494,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 +1535,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 +1772,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)]
|
||||
|
||||
@@ -7,7 +7,7 @@ use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{MediaId, Shape, ShapeId, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, base64_encode, bool_or, id_param, ids_param, ok, richclip, str_param, targets, text, xfrm_of};
|
||||
use crate::{HistoryEntry, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -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}))
|
||||
}
|
||||
@@ -534,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;
|
||||
@@ -555,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> {
|
||||
@@ -572,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;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
//! 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::*;
|
||||
use super::{CommandSpec, bad, base64_encode, bool_or, f64_or, ok, str_param, usize_param};
|
||||
use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -156,6 +160,7 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
if deckcraft_pptx::sniff(bytes) {
|
||||
let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
deckcraft_format::repair(&mut p);
|
||||
probe_clips(&mut p);
|
||||
return Ok((p, lost_slides));
|
||||
}
|
||||
if deckcraft_pptx::is_encrypted(bytes) {
|
||||
@@ -178,6 +183,42 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
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);
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::text::RunKind;
|
||||
use deckcraft_model::{Paragraph, Shape, ShapeKind, Slide, TextBody};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, str_param, text};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -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() };
|
||||
|
||||
@@ -8,7 +8,7 @@ use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot,
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::text::{format_body, format_para, format_run};
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, bool_param, color_param, edit_shapes, f64_or, f64_param, ids_param, ok, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -1170,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 {
|
||||
@@ -1179,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();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, str_param, text, usize_param, xfrm_of};
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -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);
|
||||
|
||||
@@ -8,7 +8,10 @@ use deckcraft_model::text::{AutoFit, RunKind, TextBody};
|
||||
use deckcraft_model::{Chart, ColorRef, Fill, Geom, Line, MediaClip, Shape, ShapeId, ShapeKind, ShapeStyle, Table};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, base64_decode, base64_encode, bool_or, bool_param, edit_shapes, format, ok, rect_param, str_param, text,
|
||||
usize_param, xfrm_of,
|
||||
};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -577,7 +580,7 @@ fn commit_album(s: &mut Session, items: Vec<AlbumItem>, opts: AlbumOpts, replace
|
||||
}
|
||||
|
||||
fn album_norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
|
||||
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn album_layout(p: &Value) -> Result<AlbumLayout> {
|
||||
@@ -770,7 +773,7 @@ fn image_px(bytes: &[u8]) -> (f64, f64) {
|
||||
}
|
||||
|
||||
fn file_stem(name: &str) -> String {
|
||||
let base = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name);
|
||||
let base = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name);
|
||||
let stem = match base.rsplit_once('.') {
|
||||
Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s,
|
||||
_ => base,
|
||||
@@ -1761,6 +1764,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 +1808,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;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, id_param, str_param, usize_param, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use deckcraft_model::{MediaClip, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, base64_encode, bool_param, edit_shapes, targets};
|
||||
use crate::{EngineError, MediaStatus, Result, Session, UiRequest};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -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();
|
||||
|
||||
@@ -9,7 +9,7 @@ use i_overlay::core::overlay_rule::OverlayRule;
|
||||
use i_overlay::float::single::SingleFloatOverlay;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, base64_encode, str_param, targets, text, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
/// Closed polygons (slide points) describing a filled area (non-zero winding).
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::style::{Effects, Reflection, Shadow};
|
||||
use deckcraft_model::{ColorRef, Geom, Line, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, edit_shapes, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -10,7 +10,7 @@ use deckcraft_geom::{PathEl, Point, Xfrm};
|
||||
use deckcraft_model::{CustomPath, Geom, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, f64_param, id_param, ok, str_param, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! 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::*;
|
||||
use super::{CommandSpec, bad, bool_or, current_shapes, edit_shapes, str_param, targets, text, xfrm_of};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, slide, text, usize_param};
|
||||
use crate::{Result, Session, media_refs};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
||||
use deckcraft_model::Comment;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, base64_decode, bool_or, bool_param, f64_or, id_param, ok, str_param, text, usize_param};
|
||||
use crate::{Result, Session, Target, UiRequest};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -107,14 +107,16 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
}
|
||||
if closing {
|
||||
let kind = tag_kind(&name);
|
||||
if !kind.is_empty()
|
||||
&& let Some(pos) = stack.iter().rposition(|e| e.0 == kind)
|
||||
{
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
if !kind.is_empty() {
|
||||
let search_start = stack.len().saturating_sub(64);
|
||||
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0 == kind) {
|
||||
let pos = search_start + offset;
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
@@ -130,7 +132,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
bold += db;
|
||||
italic += di;
|
||||
underline += du;
|
||||
if !token.ends_with('/') {
|
||||
if !token.ends_with('/') && stack.len() < 128 {
|
||||
stack.push((tag_kind(&name), db, di, du));
|
||||
} else {
|
||||
bold -= db;
|
||||
@@ -145,8 +147,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 +162,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;
|
||||
}
|
||||
@@ -240,10 +248,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;
|
||||
@@ -263,15 +280,35 @@ 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'], ""));
|
||||
break;
|
||||
let search_window = rest.len().min(32);
|
||||
let Some(end) = rest[..search_window].find(';') else {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
continue;
|
||||
};
|
||||
let entity = &rest[1..end];
|
||||
let decoded = match entity {
|
||||
@@ -280,7 +317,7 @@ fn decode_text(raw: &str) -> String {
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"nbsp" => Some(' '),
|
||||
"nbsp" => Some('\u{a0}'),
|
||||
_ => entity.strip_prefix('#').and_then(numeric_char),
|
||||
};
|
||||
if let Some(c) = decoded {
|
||||
@@ -293,7 +330,7 @@ fn decode_text(raw: &str) -> String {
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() && !rest.starts_with('&') {
|
||||
out.push_str(&rest.replace(['\n', '\r'], ""));
|
||||
out.push_str(&collapse_space(rest));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -358,6 +395,10 @@ mod tests {
|
||||
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");
|
||||
|
||||
// Verify many bare ampersands do not cause quadratic scan freeze
|
||||
let hostile = format!("<p>{}</p>", "&".repeat(2000));
|
||||
assert_eq!(text(&hostile), "&".repeat(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -371,6 +412,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);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use deckcraft_model::{Shape, ShapeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, bad, bool_or, id_param, ids_param, ok};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::Session;
|
||||
use crate::tools::{Mods, PointerEvent, PointerKind};
|
||||
use crate::*;
|
||||
|
||||
/// A blank slide with A, B, C (back to front) and G = group [D, H = group [E, F]] in front.
|
||||
fn deck() -> (Session, Vec<u32>) {
|
||||
|
||||
+135
-24
@@ -1,12 +1,17 @@
|
||||
//! 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};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, color_param, color_value, edit_geometry, edit_shapes, f64_param, format, id_param, ok,
|
||||
str_param, targets, text, three_d, usize_param, with_param, xfrm_of,
|
||||
};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -85,7 +90,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 +99,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
|
||||
),
|
||||
@@ -533,6 +538,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)
|
||||
@@ -565,27 +588,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);
|
||||
}
|
||||
@@ -598,7 +620,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 {
|
||||
@@ -690,7 +712,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(());
|
||||
@@ -1503,6 +1525,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];
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{LayoutId, LayoutType, Presentation, Section, SlideId, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, ok, opt_usize_param, str_param, text, usize_param};
|
||||
use crate::{Result, Session, Target};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -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()?;
|
||||
@@ -240,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()
|
||||
@@ -266,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() {
|
||||
@@ -531,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));
|
||||
@@ -539,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);
|
||||
@@ -648,6 +648,25 @@ 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();
|
||||
|
||||
@@ -14,7 +14,7 @@ use deckcraft_model::{
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, current_shapes, shape_of, str_param, targets, text, xfrm_of};
|
||||
use crate::{EngineError, Result, Session};
|
||||
|
||||
/// Deepest outline level the Text Pane allows.
|
||||
|
||||
@@ -4,7 +4,10 @@ use deckcraft_geom::{Affine, Point, Rect};
|
||||
use deckcraft_model::{Shape, ShapeKind, Table};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, bad, bool_or, bool_param, color_param, edit_shapes, f64_param, id_param, ok, str_param, table_drawn_heights, targets, usize_param,
|
||||
xfrm_of,
|
||||
};
|
||||
use crate::{DocState, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -9,7 +9,9 @@ use deckcraft_model::text::{Align, AutoFit, Hyperlink, Paragraph, Run, RunKind,
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
CommandSpec, NO_TEXT_POINT, SELECT_TEXT_OR_SHAPE, bad, bool_or, cmd, edit_shapes, format, id_param, ok, shape_of, str_param, text, xfrm_of,
|
||||
};
|
||||
use crate::{DocState, Result, Selection, Session, Target, TextSel};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -934,7 +936,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,
|
||||
@@ -1003,7 +1005,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,
|
||||
|
||||
@@ -6,7 +6,7 @@ use deckcraft_model::Transition;
|
||||
use deckcraft_model::anim::TRANSITIONS;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_param, ok, str_param, usize_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -13,7 +13,7 @@ use deckcraft_model::text::{Action, Hyperlink, TextBody};
|
||||
use deckcraft_model::{LayoutType, Shape, ShapeId, ShapeKind, SlideId, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, str_param};
|
||||
use crate::{Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+59
-5
@@ -8,6 +8,14 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
crate::file_access::check_read(path)?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{path}: not a regular file"));
|
||||
}
|
||||
const MAX_FILE_SIZE: u64 = 2 << 30; // 2 GiB
|
||||
if meta.len() > MAX_FILE_SIZE {
|
||||
return Err(format!("{path}: file size {} exceeds 2 GiB limit", meta.len()));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
@@ -28,21 +36,29 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
crate::file_access::check_write(path)?;
|
||||
let err = |e: std::io::Error| format!("{path}: {e}");
|
||||
// A uniquely named temporary sibling, created exclusively (never following or reusing an
|
||||
// existing file or symlink), is renamed over the destination: concurrent saves never share
|
||||
// temporary bytes.
|
||||
|
||||
let target_path = match std::fs::canonicalize(path) {
|
||||
Ok(canonical) => canonical,
|
||||
Err(_) => std::path::PathBuf::from(path),
|
||||
};
|
||||
let target_str = target_path.to_str().unwrap_or(path);
|
||||
let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions());
|
||||
|
||||
let mut attempts = 0;
|
||||
let (tmp, mut file) = loop {
|
||||
let tmp = format!("{path}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => break (tmp, f),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
|
||||
Err(e) => return Err(err(e)),
|
||||
}
|
||||
};
|
||||
if let Some(perms) = existing_perms {
|
||||
let _ = file.set_permissions(perms);
|
||||
}
|
||||
let written = file.write_all(bytes).and_then(|()| file.sync_all());
|
||||
drop(file);
|
||||
let result = written.and_then(|()| std::fs::rename(&tmp, path));
|
||||
let result = written.and_then(|()| std::fs::rename(&tmp, &target_path));
|
||||
if let Err(e) = result {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(err(e));
|
||||
@@ -67,3 +83,41 @@ pub fn file_len(path: &str) -> Option<u64> {
|
||||
pub fn file_len(_path: &str) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_file_rejects_directory() {
|
||||
let dir = std::env::temp_dir();
|
||||
let res = read_file(dir.to_str().unwrap());
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_preserves_permissions_and_writes_content() {
|
||||
let tmp_dir = std::env::temp_dir().join("deckcraft_io_test");
|
||||
let _ = std::fs::create_dir_all(&tmp_dir);
|
||||
let test_file = tmp_dir.join("test_write.txt");
|
||||
let path_str = test_file.to_str().unwrap();
|
||||
|
||||
assert!(write_file(path_str, b"hello world").is_ok());
|
||||
let read = read_file(path_str).unwrap();
|
||||
assert_eq!(read, b"hello world");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o640);
|
||||
std::fs::set_permissions(&test_file, perms).unwrap();
|
||||
|
||||
assert!(write_file(path_str, b"updated world").is_ok());
|
||||
let new_meta = std::fs::metadata(&test_file).unwrap();
|
||||
assert_eq!(new_meta.permissions().mode() & 0o777, 0o640);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&test_file);
|
||||
let _ = std::fs::remove_dir(&tmp_dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
@@ -80,8 +85,20 @@ fn owner_alive(dir: &Path, owner: u64) -> bool {
|
||||
|
||||
/// Write `bytes` to `path` through a temporary file and a rename, so a crash never leaves it torn.
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
let tmp = path.with_extension(format!("{}.tmp", path.extension().and_then(|e| e.to_str()).unwrap_or("")));
|
||||
std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut file = opts.open(&tmp).map_err(|e| EngineError::Other(format!("{}: {e}", tmp.display())))?;
|
||||
file.write_all(bytes).and_then(|()| file.sync_all()).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{}: {e}", path.display()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Presentations reopened per start; the rest stay on disk for the next one, so a folder
|
||||
@@ -147,13 +164,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())
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::collections::VecDeque;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::*;
|
||||
use crate::{EngineError, Session, command_specs, sample};
|
||||
|
||||
/// Commands that touch the file system, other windows or real time; the soak drives the rest.
|
||||
fn skipped(id: &str) -> bool {
|
||||
|
||||
+334
-1
@@ -1,6 +1,8 @@
|
||||
use serde_json::json;
|
||||
|
||||
use crate::*;
|
||||
use crate::{
|
||||
DocState, History, Presentation, Session, Target, TextSel, ToolKind, Value, cmd, command_specs, connect, find_command, model, sample, tools,
|
||||
};
|
||||
|
||||
fn session() -> Session {
|
||||
Session::with_new()
|
||||
@@ -94,6 +96,23 @@ 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() {
|
||||
@@ -249,6 +268,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();
|
||||
@@ -2352,6 +2414,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();
|
||||
@@ -3495,6 +3687,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();
|
||||
@@ -4072,3 +4295,113 @@ fn a_shape_in_front_of_edited_text_takes_the_click() {
|
||||
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)));
|
||||
}
|
||||
|
||||
+77
-16
@@ -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
|
||||
@@ -474,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)
|
||||
}
|
||||
@@ -486,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()
|
||||
@@ -552,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);
|
||||
@@ -909,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) {
|
||||
@@ -1104,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 => {}
|
||||
}
|
||||
@@ -1449,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![]);
|
||||
@@ -1486,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()) {
|
||||
@@ -1495,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()) {
|
||||
@@ -1509,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;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
mod chart_native;
|
||||
mod show_clicks;
|
||||
+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();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::*;
|
||||
use super::Presentation;
|
||||
use deckcraft_model::chart::{Chart, ChartPart, ChartRel, ChartType};
|
||||
use deckcraft_model::{Master, Shape, ShapeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
+41
-28
@@ -498,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 };
|
||||
}
|
||||
}
|
||||
@@ -827,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]
|
||||
@@ -1057,33 +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);
|
||||
/// #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 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}");
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//! ribbons, gears, tabs and chart markers. Like the rest of `preset`, every outline is our own
|
||||
//! construction, fitted to how the shapes look and to what their adjust values mean.
|
||||
|
||||
use super::*;
|
||||
use super::{
|
||||
Corner, Dir, FillMode, Geometry, Handle, Pb, Point, Rect, Vec2, corner_rect, fill, fill_eo, fill_only, mid_sites, orient, shade, simple,
|
||||
stroke_only, vis,
|
||||
};
|
||||
|
||||
/// Builds the presets this module knows; `None` for any other name.
|
||||
pub(super) fn build(name: &str, w: f64, h: f64, a: &dyn Fn(usize) -> f64) -> Option<Geometry> {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use super::{
|
||||
Band, BezPath, CATALOG, Guide, Point, Rect, WarpLine, adjust_count, adjust_defaults, band_of_line, handles, is_follow, is_supported, warp,
|
||||
};
|
||||
|
||||
const W: f64 = 420.0;
|
||||
const H: f64 = 200.0;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use crate::cabac::Cabac;
|
||||
use crate::error::{Result, ensure, invalid};
|
||||
use crate::mbtypes::*;
|
||||
use crate::mbtypes::{MbTypeInfo, Part, SubMbInfo, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type};
|
||||
use crate::picture::MbKind;
|
||||
use crate::slice::SliceType;
|
||||
use crate::slicedec::{MbCur, SliceDecoder};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! CAVLC residual block parsing (9.2).
|
||||
|
||||
use crate::cavlc_tables::*;
|
||||
use crate::cavlc_tables::{COEFF_TOKEN, RUN_BEFORE, TOTAL_ZEROS_2X2, TOTAL_ZEROS_2X4, TOTAL_ZEROS_4X4};
|
||||
use crate::error::{Result, ensure};
|
||||
use deckcraft_bitstream::BitReader;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -232,7 +232,7 @@ fn filter_vertical<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y: u
|
||||
let mut w = [0u64; N];
|
||||
for (i, w) in w.iter_mut().enumerate() {
|
||||
let o = (y + i) * stride + x - 4;
|
||||
let Some(&b) = pix.get(o..).and_then(|p| p.first_chunk::<8>()) else {
|
||||
let Some(&b) = pix.get(o..).and_then(<[_]>::first_chunk::<8>) else {
|
||||
return;
|
||||
};
|
||||
*w = u64::from_le_bytes(b);
|
||||
@@ -259,7 +259,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
|
||||
let base = (y - 4) * stride + x;
|
||||
let mut s = [[0i16; N]; 8];
|
||||
for (k, row) in s.iter_mut().enumerate() {
|
||||
let Some(src) = pix.get(base + k * stride..).and_then(|p| p.first_chunk::<N>()) else {
|
||||
let Some(src) = pix.get(base + k * stride..).and_then(<[_]>::first_chunk::<N>) else {
|
||||
return;
|
||||
};
|
||||
for i in 0..N {
|
||||
@@ -269,7 +269,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
|
||||
filter_lanes(&mut s, ep, chroma);
|
||||
let (k0, k1) = if chroma { (3, 5) } else { (1, 7) };
|
||||
for (k, row) in s.iter().enumerate().take(k1).skip(k0) {
|
||||
let Some(dst) = pix.get_mut(base + k * stride..).and_then(|p| p.first_chunk_mut::<N>()) else {
|
||||
let Some(dst) = pix.get_mut(base + k * stride..).and_then(<[_]>::first_chunk_mut::<N>) else {
|
||||
return;
|
||||
};
|
||||
for i in 0..N {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sequence and picture parameter sets (7.3.2.1, 7.3.2.2) including VUI and scaling lists.
|
||||
|
||||
use crate::error::{Result, ensure, unsupported};
|
||||
use crate::tables::*;
|
||||
use crate::tables::{DEFAULT_4X4_INTER, DEFAULT_4X4_INTRA, DEFAULT_8X8_INTER, DEFAULT_8X8_INTRA, ZIGZAG4, ZIGZAG8};
|
||||
use deckcraft_bitstream::BitReader;
|
||||
|
||||
/// Scaling matrices after applying fall-back rules, stored in raster order (weightScale).
|
||||
|
||||
@@ -301,7 +301,7 @@ fn plane_allocator() -> fn(usize) -> Vec<u8> {
|
||||
fn copy_line<const N: usize>(line: &[u8], x0: i32, out: &mut [u8], w: usize) {
|
||||
if w <= N
|
||||
&& x0 >= 0
|
||||
&& let Some(src) = line.get(x0 as usize..).and_then(|l| l.first_chunk::<N>())
|
||||
&& let Some(src) = line.get(x0 as usize..).and_then(<[_]>::first_chunk::<N>)
|
||||
&& let Some(dst) = out.first_chunk_mut::<N>()
|
||||
{
|
||||
*dst = *src;
|
||||
|
||||
@@ -5,11 +5,11 @@ use crate::deblock;
|
||||
use crate::error::{Result, ensure, invalid};
|
||||
use crate::inter::{self, Weight};
|
||||
use crate::intra::{self, Avail};
|
||||
use crate::mbtypes::*;
|
||||
use crate::mbtypes::{CBP_ME, MbTypeInfo, Part, SubMbInfo, SubPart, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type};
|
||||
use crate::params::{Pps, Sps};
|
||||
use crate::picture::*;
|
||||
use crate::picture::{Frame, FrameRef, MbKind, MbState, Planes, RefPic};
|
||||
use crate::slice::{SliceHeader, SliceType};
|
||||
use crate::tables::*;
|
||||
use crate::tables::{BLK4_XY, QPC_TABLE, RASTER_TO_BLK4, ZIGZAG4, ZIGZAG8};
|
||||
use crate::transform::{self, LevelScale};
|
||||
use deckcraft_bitstream::BitReader;
|
||||
|
||||
@@ -907,7 +907,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
let stride = self.pic.planes.width;
|
||||
let px = self.mb_x * 16 + (raster & 3) * 4;
|
||||
let py = self.mb_y * 16 + (raster >> 2) * 4;
|
||||
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
|
||||
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(<[_]>::first_chunk_mut::<16>) else {
|
||||
return;
|
||||
};
|
||||
transform::idct4_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
|
||||
@@ -921,7 +921,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
let stride = self.pic.planes.width;
|
||||
let px = self.mb_x * 16 + (b8 & 1) * 8;
|
||||
let py = self.mb_y * 16 + (b8 >> 1) * 8;
|
||||
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(|c| c.first_chunk_mut::<64>()) else {
|
||||
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(<[_]>::first_chunk_mut::<64>) else {
|
||||
return;
|
||||
};
|
||||
transform::idct8_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
|
||||
@@ -940,7 +940,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
let px = self.mb_x * 8 + (b & 1) * 4;
|
||||
let py = self.mb_y * 8 + (b >> 1) * 4;
|
||||
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
|
||||
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(<[_]>::first_chunk_mut::<16>) else {
|
||||
continue;
|
||||
};
|
||||
let plane = if c == 0 { &mut self.pic.planes.cb } else { &mut self.pic.planes.cr };
|
||||
|
||||
@@ -107,7 +107,7 @@ struct Pic {
|
||||
|
||||
impl Pic {
|
||||
fn is_p(&self) -> bool {
|
||||
!self.idr && !self.b && self.mbs.iter().any(|m| m.is_none())
|
||||
!self.idr && !self.b && self.mbs.iter().any(Option::is_none)
|
||||
}
|
||||
fn inter(&self) -> bool {
|
||||
self.b || self.is_p()
|
||||
|
||||
@@ -117,7 +117,7 @@ pub fn idct4_add(d: &[i32; 16], dst: &mut [u8], stride: usize) {
|
||||
o[3][j] = e - h;
|
||||
}
|
||||
for (i, row) in o.iter().enumerate() {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<4>()) else {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(<[_]>::first_chunk_mut::<4>) else {
|
||||
return;
|
||||
};
|
||||
for (p, &v) in line.iter_mut().zip(row) {
|
||||
@@ -173,7 +173,7 @@ pub fn idct8_add(d: &[i32; 64], dst: &mut [u8], stride: usize) {
|
||||
}
|
||||
}
|
||||
for (i, row) in o.iter().enumerate() {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<8>()) else {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(<[_]>::first_chunk_mut::<8>) else {
|
||||
return;
|
||||
};
|
||||
for (p, &v) in line.iter_mut().zip(row) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user