mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 05:56:55 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad4d746131 |
@@ -21,27 +21,3 @@ jobs:
|
||||
lint:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main
|
||||
|
||||
windows-install-policy:
|
||||
timeout-minutes: 10
|
||||
name: Windows installation policy (no app build)
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install the pinned packaging toolchain
|
||||
shell: pwsh
|
||||
run: |
|
||||
$tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix'
|
||||
dotnet tool install --tool-path $tools wix --version 5.0.2
|
||||
if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' }
|
||||
$tools >> $env:GITHUB_PATH
|
||||
$policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json
|
||||
foreach ($extension in @($policy.extensions)) {
|
||||
& (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2"
|
||||
if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." }
|
||||
}
|
||||
- name: Build and check non-executable installer fixtures
|
||||
shell: pwsh
|
||||
run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests')
|
||||
|
||||
@@ -29,5 +29,3 @@ jobs:
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-dir: DeckCraft
|
||||
install-relative-path: Programs/ArtCraft/DeckCraft
|
||||
install-scope: perUser
|
||||
|
||||
@@ -192,8 +192,3 @@ the `ROADMAP.md` progress log, and update each touched doc's status line and rev
|
||||
GitHub profiles). Never hand-edit `contributors.json`.
|
||||
- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also
|
||||
re-verifies who wrote each `people.toml` entry; `--check` only verifies).
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
@@ -3,10 +3,6 @@
|
||||
Read [AGENTS.md](AGENTS.md) first: its rules (asset policy, clean room, never crash) bind you and
|
||||
win over anything here.
|
||||
|
||||
New workspace crates must include `[lints] workspace = true` in `Cargo.toml` to inherit
|
||||
`[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes
|
||||
before modules or other crate items; crate-level `//!` documentation may precede them.
|
||||
|
||||
## Start every session
|
||||
1. Read `plan/STATUS.md`, then the current milestone in `plan/execution-plan.md`.
|
||||
2. Read `../../craftrules/AGENTS.md` and the standards it lists for your task.
|
||||
@@ -19,8 +15,3 @@ before modules or other crate items; crate-level `//!` documentation may precede
|
||||
- Commit after each landed arc (`M<n>.<k>: what`) and push to `origin main`.
|
||||
- Keep `ROADMAP.md`, the progress docs in `docs/` (AGENTS.md §7) and `plan/STATUS.md` current.
|
||||
- Don't ask the user questions; record genuinely-theirs decisions in `plan/STATUS.md`.
|
||||
|
||||
## CI workflows
|
||||
- Workflows in `.github/workflows/` call the shared workflows in [`storytold/craft-common-actions`](https://github.com/storytold/craft-common-actions) at `@main`, with their inputs and secrets. No other jobs: if no shared workflow does what's needed, it doesn't go here.
|
||||
- Don't add or change CI in a feature or fix PR. If the existing workflows can't build or test a change, link an existing craft-common-actions issue or PR, or open a new issue, or a PR only for a small fix that is safe for every app and does not increase CI time.
|
||||
- Some workflow files here still run their own jobs. Don't add to them. Moving a job into craft-common-actions is its own task, never part of another PR, and the job stays here until a shared workflow runs it.
|
||||
|
||||
Generated
+41
-263
@@ -222,7 +222,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
@@ -642,15 +642,6 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cobs"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.13.1"
|
||||
@@ -820,38 +811,10 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-fonts-build"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
|
||||
[[package]]
|
||||
name = "craft-i18n"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback",
|
||||
"icu_normalizer",
|
||||
"icu_plurals",
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"icu_provider_adapters",
|
||||
"icu_provider_blob",
|
||||
"log",
|
||||
"objc2-foundation 0.3.2",
|
||||
"sys-locale",
|
||||
"unicode-bidi",
|
||||
"windows-registry",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4#f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4"
|
||||
dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -859,43 +822,22 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
dependencies = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
"egui",
|
||||
"epaint",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"skrifa 0.44.0",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
"egui",
|
||||
"flate2",
|
||||
"getrandom 0.3.4",
|
||||
"image",
|
||||
"log",
|
||||
"plist",
|
||||
"semver",
|
||||
@@ -984,7 +926,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1006,7 +948,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -1015,7 +957,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1024,14 +966,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -1041,14 +983,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
@@ -1074,7 +1016,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1086,7 +1028,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1097,7 +1039,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1105,7 +1047,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1114,7 +1056,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1123,21 +1065,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1147,7 +1089,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1165,7 +1107,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1174,7 +1116,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1186,15 +1128,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1212,7 +1154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1228,7 +1170,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1247,7 +1189,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1261,9 +1203,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1286,7 +1227,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1295,7 +1236,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1652,7 +1593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1739,17 +1680,6 @@ version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
|
||||
|
||||
[[package]]
|
||||
name = "fixed_decimal"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"smallvec",
|
||||
"writeable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
@@ -2320,23 +2250,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_decimal"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_locale_core",
|
||||
"icu_pattern",
|
||||
"icu_plurals",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_core"
|
||||
version = "2.3.0"
|
||||
@@ -2345,32 +2258,11 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde",
|
||||
"tinystr",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
|
||||
dependencies = [
|
||||
"icu_locale_core",
|
||||
"icu_locale_fallback_data",
|
||||
"icu_provider",
|
||||
"potential_utf",
|
||||
"tinystr",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_locale_fallback_data"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
|
||||
|
||||
[[package]]
|
||||
name = "icu_normalizer"
|
||||
version = "2.3.0"
|
||||
@@ -2382,9 +2274,6 @@ dependencies = [
|
||||
"icu_properties",
|
||||
"icu_provider",
|
||||
"smallvec",
|
||||
"utf16_iter",
|
||||
"utf8_iter",
|
||||
"write16",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -2394,32 +2283,6 @@ version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
|
||||
|
||||
[[package]]
|
||||
name = "icu_pattern"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"either",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_plurals"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"fixed_decimal",
|
||||
"icu_provider",
|
||||
"serde",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_properties"
|
||||
version = "2.3.0"
|
||||
@@ -2449,9 +2312,6 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"icu_locale_core",
|
||||
"postcard",
|
||||
"serde",
|
||||
"stable_deref_trait",
|
||||
"writeable",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
@@ -2459,30 +2319,6 @@ dependencies = [
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_adapters"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820"
|
||||
dependencies = [
|
||||
"icu_locale_fallback",
|
||||
"icu_provider",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_provider_blob"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e"
|
||||
dependencies = [
|
||||
"icu_provider",
|
||||
"postcard",
|
||||
"serde",
|
||||
"writeable",
|
||||
"zerotrie",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "1.1.0"
|
||||
@@ -2708,7 +2544,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef#9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -2809,9 +2645,6 @@ name = "litemap"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
@@ -3014,7 +2847,7 @@ version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3487,7 +3320,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3729,24 +3562,12 @@ dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postcard"
|
||||
version = "1.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
|
||||
dependencies = [
|
||||
"cobs",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"writeable",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -4042,7 +3863,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4099,7 +3920,7 @@ dependencies = [
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4703,18 +4524,6 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sys-locale"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
@@ -4732,10 +4541,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.3",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix 1.1.5",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4858,7 +4667,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"serde_core",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
@@ -5112,12 +4920,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf16_iter"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246"
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
@@ -5659,7 +5461,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5765,17 +5567,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.1.2"
|
||||
@@ -6091,20 +5882,11 @@ dependencies = [
|
||||
"read-fonts 0.39.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "write16"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936"
|
||||
|
||||
[[package]]
|
||||
name = "writeable"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11-dl"
|
||||
@@ -6177,7 +5959,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6365,11 +6147,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
|
||||
dependencies = [
|
||||
"displaydoc",
|
||||
"litemap",
|
||||
"serde_core",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6378,7 +6157,6 @@ version = "0.11.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"yoke",
|
||||
"zerofrom",
|
||||
"zerovec-derive",
|
||||
|
||||
+5
-7
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.13.1"
|
||||
version = "0.11.1"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -59,11 +59,9 @@ harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false }
|
||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0" }
|
||||
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"] }
|
||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -109,7 +107,7 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.8.2" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "9f75d3dc19b4b514c4f3d4847ed1ec1b0e86b4ef", version = "=0.8.2" }
|
||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
|
||||
+3
-32
@@ -2,18 +2,9 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (progress log: the Picture Styles row leaves room for Crop and Size, #1232; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-29 · **Change:** trivial (accessibility contrast checks now include every text run, paragraph and nested group descendant; parity summary unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, 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-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, 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-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, 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-25 · **Change:** trivial (progress log: a picture in a picture placeholder is cut into the layout's shape; previous: trivial, progress log: picture and embedded object fills; previous: trivial, 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)
|
||||
@@ -52,7 +43,7 @@ Calibration is the same as for the full number (see [docs/target-app-parity.md](
|
||||
`.pptx`, present, add tables and charts, share as PDF, review. Core workflows work end to end (build a deck, open and save `.pptx`, present with
|
||||
transitions, animations, media and presenter view, export PDF), but depth, fidelity and polish
|
||||
have known gaps, and PPTX has never been tested on a corpus of real decks. Beta needs ~75% ready
|
||||
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74),
|
||||
and no blocking gap in `.pptx`: the real-deck corpus, the round-trip losses (#70, #74, #77),
|
||||
EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the beta list in
|
||||
[docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
@@ -125,11 +116,11 @@ ships 26.
|
||||
Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
|
||||
|
||||
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
|
||||
image paste on Windows (#1414), editor soak tests. 10–20 h.
|
||||
image paste (#53), editor soak tests. 10–20 h.
|
||||
2. **PPTX on real decks:** the owned corpus now round-trips comments, links and actions, custom
|
||||
shows, rotated groups, inherited placeholders, text effects and body layout, theme effects,
|
||||
fills, bevels, connectors, tables, charts, animations, media, sections, masters, 3-D models
|
||||
and animation bounce end. Still open: a corpus of real decks, #70, #74, EMF+ records,
|
||||
and animation bounce end. Still open: a corpus of real decks, #70, #74, #77, EMF+ records,
|
||||
MicroType Express font subsets and SVG. 36–64 h.
|
||||
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
|
||||
4. **Output:** vector PDF, printing. 10–16 h.
|
||||
@@ -146,16 +137,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
|
||||
| 2026-10-27 | Set Up Slide Show ▸ Keep mouse cursor visible: a full-screen show no longer hides a still pointer, so the choices of a branching deck stay visible and clickable; the setting is kept with the user's preferences (`ui.json`, `showKeepCursor`), off by default (#1445) |
|
||||
| 2026-10-25 | A photo in a picture placeholder is cut into the shape its layout gives the placeholder, as PowerPoint draws it: a layout's round picture placeholder keeps a slide's photo in a circle with the layout's white border, where it came out as a rectangle (the reader only passed `p:sp` placeholders to `inherit_placeholder_geometry`, not `p:pic`) |
|
||||
| 2026-10-28 | CLI `run --cmd-file FILE` reads UTF-8 commands in order alongside `--cmd`, skips blank/comment lines, and reports file/line errors before final save/export (#1098) |
|
||||
| 2026-10-28 | MCP accepts repeated `--automation-read-root` and `--automation-write-root` flags for access to multiple folders (#1154) |
|
||||
| 2026-10-26 | Slide reorder: dragging one of several selected slides in the thumbnail pane or the Slide Sorter moves the whole selection, as one run in deck order and one undo step; the slides stay selected and join the section they land in. `slide.move {ids, to}` does the same from the control channel and MCP; dragging an unselected slide still moves only that slide |
|
||||
| 2026-10-26 | A space, digit or punctuation after a symbol-font character (`a:sym`, e.g. a Wingdings arrow typed in a run) draws in the run's own font, as PowerPoint does; it stayed in the symbol font, whose space is about four times as wide (synthetic probe) |
|
||||
| 2026-10-29 | Review ▸ Accessibility checks text runs in every paragraph and nested group descendants for low contrast, reporting each affected shape once; the all-black control stays clear |
|
||||
| 2026-10-25 | Windows installer: the Start menu shortcut takes the icon embedded in `deckcraft.exe` instead of an MSI `<Icon>`, so a taskbar pin keeps its icon after an update (#1114); a pin that already lost it has to be unpinned and pinned again once |
|
||||
| 2026-10-29 | Picture Format: the Picture Styles row shows as many styles as leave room for the groups after it, and its arrow opens all 24. With every style in the row, Crop and Size sat past the ribbon's edge in any window narrower than about 2500 px, so Crop could not be seen without scrolling the ribbon (#1232). In the default 1440 px window Crop, Height and Width are now in view, and from 1600 px the whole tab fits |
|
||||
| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off |
|
||||
| 2026-10-24 | Tables: while the caret is in a cell, dragging into another cell selects that range of cells (shaded, merged cells taken in whole), and Shift+arrow grows or shrinks it; formatting applies to the selected cells only, and Delete clears their text and keeps the table |
|
||||
| 2026-10-24 | Preferences ▸ Measurement units: inches or centimetres. The Format pane's Size and Position fields, Shape Format ▸ Size on the ribbon, the Slide Size dialog and the ruler show and take the chosen unit; the deck still stores points (#1326) |
|
||||
@@ -166,7 +148,6 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 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-24 | Columns dialog: Home › Columns › More Columns… and Format Shape › Text Box › Columns… set the number of text columns (1 to 16) and the spacing between them in inches, in one undo step; `format.columns` with no fields opens it, and a key left out keeps the text box's own value |
|
||||
| 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-26 | Duplicate (Ctrl+D) names the copy as PowerPoint does: a shape with an automatic name ("Rectangle 1") gets the next one ("Rectangle 5", also for the shapes inside a duplicated group), and a name you chose ("Hero") stays as it is instead of becoming "Hero copy" |
|
||||
| 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) |
|
||||
@@ -180,7 +161,6 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 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-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
|
||||
| 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names |
|
||||
| 2026-10-25 | Format Shape ▸ Fill: Slide background fill shows the slide background through the shape (`shape.fill {background: true}`, saved as `useBgFill`). A shape opened from a file with this fill showed it as Automatic, and the pane offered no way to set it |
|
||||
| 2026-10-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
|
||||
| 2026-10-21 | Slide list and slide sorter: Shift+click selects a range of slides and Ctrl+Shift+click adds it to the selection (`slide.selectRange`, #1105) |
|
||||
| 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 |
|
||||
@@ -314,15 +294,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: a picture in a picture placeholder takes the layout's shape. Scores and milestone names unchanged |
|
||||
| 2026-10-28 | trivial | Progress log: CLI command-file input (#1098). Scores and milestone scope unchanged. |
|
||||
| 2026-10-28 | trivial | Progress log: MCP accepts multiple automation read and write roots (#1154). Scores and milestone names unchanged |
|
||||
| 2026-10-26 | trivial | Progress log: dragging several selected slides moves them all. Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Review: accessibility contrast checks all runs, paragraphs and nested group descendants; checklist score and headline figures unchanged |
|
||||
| 2026-10-25 | trivial | Progress log: Windows shortcut icon survives updates (#1114). Scores and milestone names unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: measurement units in centimetres (#1326). Scores unchanged |
|
||||
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
|
||||
@@ -340,7 +312,6 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 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-26 | trivial | Progress log: spaces after symbol-font characters |
|
||||
| 2026-10-24 | trivial | Progress log: picture and embedded object fills |
|
||||
| 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 |
|
||||
|
||||
@@ -4,24 +4,29 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_roots: Vec<PathBuf>,
|
||||
pub write_roots: Vec<PathBuf>,
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_roots: read_root.into_iter().collect(), write_roots: write_root.into_iter().collect() }
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. Both spellings (`--flag DIR` and
|
||||
/// `--flag=DIR`) may be repeated.
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let roots = match flag {
|
||||
"--automation-read-root" => &mut self.read_roots,
|
||||
"--automation-write-root" => &mut self.write_roots,
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
@@ -35,7 +40,8 @@ impl AutomationArgs {
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
roots.push(value.into());
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
@@ -45,16 +51,17 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_repeated_and_other_arguments_are_left() {
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut args = ["a folder".to_owned(), "deck.pptx".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--sample", &mut args).unwrap());
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("a folder"), PathBuf::from("other")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("out")]);
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(args.next().as_deref(), Some("deck.pptx"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -64,8 +71,8 @@ mod tests {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("read")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("write")]);
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
@@ -77,7 +84,7 @@ mod tests {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_roots, [PathBuf::from("-folder")]);
|
||||
assert_eq!(roots.write_roots, [PathBuf::from("./-folder")]);
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
|
||||
},
|
||||
Subcommand {
|
||||
name: "run",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS] | --cmd-file FILE]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
|
||||
summary: "run commands on a presentation, then save or export it",
|
||||
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
|
||||
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
|
||||
@@ -71,15 +71,6 @@ slide). --export-params passes file.export options as one JSON object, such as
|
||||
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
|
||||
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
|
||||
|
||||
--cmd-file FILE reads the same commands from a UTF-8 text file, one per line, avoiding shell quoting
|
||||
and command-line length limits. Each line can be up to 16 MiB, including commands with embedded
|
||||
media data. Blank lines and full-line # comments are ignored, as is surrounding whitespace. CRLF
|
||||
line endings and an optional UTF-8 BOM at the start of the file are accepted.
|
||||
Repeat --cmd-file or mix it with --cmd: commands execute in the order those options appear.
|
||||
File errors name the path; line-reading, JSON and command errors also name the line number.
|
||||
|
||||
deckcraft-cli run --cmd-file commands.txt --cmd slide.next --save result.deckcraft
|
||||
|
||||
deckcraft-cli run \\
|
||||
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
|
||||
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
|
||||
@@ -136,16 +127,16 @@ and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd doe
|
||||
},
|
||||
Subcommand {
|
||||
name: "mcp",
|
||||
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR]... [--automation-write-root DIR]...",
|
||||
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]",
|
||||
summary: "serve the commands to an agent over MCP on stdio",
|
||||
details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
|
||||
window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
|
||||
--sample opens the sample presentation.
|
||||
|
||||
--automation-read-root DIR and --automation-write-root DIR confine every file the agent's commands read
|
||||
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed. Repeat either
|
||||
flag to grant access to more folders; a root left out grants none of its access. They run the in-process
|
||||
server, so they conflict with --connect (a running app is not confined by them).",
|
||||
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed; a root left
|
||||
out grants none of its access. They run the in-process server, so they conflict with --connect (a running
|
||||
app is not confined by them).",
|
||||
},
|
||||
Subcommand {
|
||||
name: "bench",
|
||||
@@ -189,10 +180,7 @@ A presentation is edited by running commands on it:
|
||||
deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft
|
||||
|
||||
`deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;
|
||||
`deckcraft-cli help run` explains how to write a command.
|
||||
|
||||
If an output reader closes early, run, render, render-transition and convert continue; their exit
|
||||
status reflects the work, not output delivery. Stop the process to cancel them.",
|
||||
`deckcraft-cli help run` explains how to write a command.",
|
||||
list.join("\n")
|
||||
)
|
||||
}
|
||||
|
||||
+24
-121
@@ -7,38 +7,23 @@ mod automation_args;
|
||||
mod help;
|
||||
|
||||
use std::process::ExitCode;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
static STDOUT_CLOSED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
macro_rules! outln {
|
||||
($($arg:tt)*) => {{
|
||||
if !STDOUT_CLOSED.load(Ordering::Relaxed) && let Err(e) = stdout_line(format_args!($($arg)*)) {
|
||||
use std::io::Write as _;
|
||||
if let Err(e) = writeln!(std::io::stdout(), $($arg)*) {
|
||||
if e.kind() == std::io::ErrorKind::BrokenPipe {
|
||||
STDOUT_CLOSED.store(true, Ordering::Relaxed);
|
||||
} else {
|
||||
eprintln!("deckcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
std::process::exit(0);
|
||||
}
|
||||
eprintln!("deckcraft-cli: can't write to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
fn stdout_line(args: std::fmt::Arguments<'_>) -> std::io::Result<()> {
|
||||
use std::io::Write as _;
|
||||
#[cfg(unix)]
|
||||
let mut stdout = {
|
||||
use std::os::fd::AsFd as _;
|
||||
std::fs::File::from(std::io::stdout().as_fd().try_clone_to_owned()?)
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let mut stdout = std::io::stdout();
|
||||
stdout.write_all(format!("{args}\n").as_bytes())
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest = args.get(1..).unwrap_or(&[]);
|
||||
@@ -88,7 +73,7 @@ fn main() -> ExitCode {
|
||||
Some("app") => report(app(rest)),
|
||||
Some("mcp") => report(mcp(rest)),
|
||||
Some("links") => {
|
||||
use deckcraft_engine::links::{APP_PAGE, DISCORD, GITHUB, WEBSITE};
|
||||
use deckcraft_engine::links::*;
|
||||
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
@@ -161,13 +146,9 @@ fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f6
|
||||
f()?;
|
||||
ms.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
}
|
||||
Ok(summarize_ms(ms))
|
||||
}
|
||||
|
||||
/// Summarize one population of measurements; each maximum must come from that population.
|
||||
fn summarize_ms(mut ms: Vec<f64>) -> (f64, f64) {
|
||||
ms.sort_by(f64::total_cmp);
|
||||
(ms.get(ms.len() / 2).copied().unwrap_or(0.0), ms.last().copied().unwrap_or(0.0))
|
||||
let median = ms.get(ms.len() / 2).copied().unwrap_or(0.0);
|
||||
Ok((median, ms.last().copied().unwrap_or(0.0)))
|
||||
}
|
||||
|
||||
/// Peak resident memory of this process in MiB (Linux `VmHWM`); `None` elsewhere.
|
||||
@@ -227,15 +208,11 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
("save .deckcraft", time_ms(runs, || save_bytes(&doc, "deckcraft").map(|_| ()).map_err(e))?),
|
||||
("save .pptx", time_ms(runs, || save_bytes(&doc, "pptx").map(|_| ()).map_err(e))?),
|
||||
("open .pptx", time_ms(runs, || open_presentation("bench.pptx", &pptx).map(|_| ()).map_err(e))?),
|
||||
("export .pdf", time_ms(runs, || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
|
||||
("export .pdf", time_ms(runs.min(3), || save_bytes(&doc, "pdf").map(|_| ()).map_err(e))?),
|
||||
];
|
||||
let render_all = time_ms(runs, || {
|
||||
for i in 0..slides {
|
||||
deckcraft_engine::render::render_slide(&doc, i, &opts);
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
steps.insert(1, ("render all slides (1×)", render_all));
|
||||
let render_total: f64 = per_slide.iter().map(|m| m.0).sum();
|
||||
let render_max = per_slide.iter().map(|m| m.1).fold(0.0, f64::max);
|
||||
steps.insert(1, ("render all slides (1×)", (render_total, render_max)));
|
||||
let peak = peak_rss_mib();
|
||||
if as_json {
|
||||
let v = json!({
|
||||
@@ -473,69 +450,6 @@ fn output_format(path: &str) -> Result<&'static str, String> {
|
||||
})
|
||||
}
|
||||
|
||||
enum CommandSource {
|
||||
Inline(String),
|
||||
File(String),
|
||||
}
|
||||
|
||||
const MAX_COMMAND_FILE_LINE_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
fn read_command_file_line(reader: &mut impl std::io::BufRead, line: &mut Vec<u8>) -> std::io::Result<bool> {
|
||||
line.clear();
|
||||
loop {
|
||||
let available = reader.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
return Ok(!line.is_empty());
|
||||
}
|
||||
let newline = available.iter().position(|byte| *byte == b'\n');
|
||||
let count = newline.unwrap_or(available.len());
|
||||
line.len().checked_add(count).filter(|length| *length <= MAX_COMMAND_FILE_LINE_BYTES).ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, format!("command line exceeds {MAX_COMMAND_FILE_LINE_BYTES} byte limit"))
|
||||
})?;
|
||||
line.extend_from_slice(&available[..count]);
|
||||
reader.consume(count + usize::from(newline.is_some()));
|
||||
if newline.is_some() {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The same command syntax and execution path for --cmd and each command-file line.
|
||||
fn run_command(s: &mut Session, command: &str, print: bool) -> Result<(), String> {
|
||||
let (id, params) = match command.split_once('=') {
|
||||
Some((id, j)) => (id, serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
|
||||
None => (command, json!({})),
|
||||
};
|
||||
let r = s.execute(id, ¶ms).map_err(|e| e.to_string())?;
|
||||
if print {
|
||||
outln!("{id} → {r}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_command_file(s: &mut Session, path: &str, print: bool) -> Result<(), String> {
|
||||
use std::io::BufReader;
|
||||
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut bytes = Vec::new();
|
||||
let mut line_number = 0usize;
|
||||
loop {
|
||||
line_number = line_number.checked_add(1).ok_or_else(|| format!("{path}: too many lines"))?;
|
||||
if !read_command_file_line(&mut reader, &mut bytes).map_err(|e| format!("{path}:{line_number}: {e}"))? {
|
||||
break;
|
||||
}
|
||||
let line = std::str::from_utf8(&bytes).map_err(|e| format!("{path}:{line_number}: invalid UTF-8: {e}"))?;
|
||||
let line = if line_number == 1 { line.trim_start_matches('\u{feff}') } else { line };
|
||||
let command = line.trim();
|
||||
if command.is_empty() || command.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
run_command(s, command, print).map_err(|e| format!("{path}:{line_number}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut input = None;
|
||||
let mut sample = false;
|
||||
@@ -549,8 +463,7 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
match a.as_str() {
|
||||
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
|
||||
"--sample" => sample = true,
|
||||
"--cmd" => cmds.push(CommandSource::Inline(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?)),
|
||||
"--cmd-file" => cmds.push(CommandSource::File(it.next().cloned().ok_or("--cmd-file needs a file")?)),
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
|
||||
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
|
||||
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
|
||||
"--export-params" => {
|
||||
@@ -573,10 +486,14 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
let mut s = Session::new();
|
||||
open(&mut s, input.as_deref(), sample)?;
|
||||
for source in cmds {
|
||||
match source {
|
||||
CommandSource::Inline(command) => run_command(&mut s, &command, print)?,
|
||||
CommandSource::File(path) => run_command_file(&mut s, &path, print)?,
|
||||
for c in cmds {
|
||||
let (id, p) = match c.split_once('=') {
|
||||
Some((id, j)) => (id.to_string(), serde_json::from_str::<Value>(j).map_err(|e| format!("{id}: bad JSON: {e}"))?),
|
||||
None => (c.clone(), json!({})),
|
||||
};
|
||||
let r = s.execute(&id, &p).map_err(|e| e.to_string())?;
|
||||
if print {
|
||||
outln!("{id} → {r}");
|
||||
}
|
||||
}
|
||||
if let Some(p) = save {
|
||||
@@ -707,7 +624,7 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
other => return Err(format!("unknown mcp option `{other}`")),
|
||||
}
|
||||
}
|
||||
let roots = AutomationRoots::new(&roots_args.read_roots, &roots_args.write_roots)?;
|
||||
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
||||
so drop --connect (or use --connect without the roots)"
|
||||
@@ -724,12 +641,8 @@ so drop --connect (or use --connect without the roots)"
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |roots: Vec<String>| if roots.is_empty() { "none".to_string() } else { roots.join(", ") };
|
||||
eprintln!(
|
||||
"deckcraft-cli: files confined: read roots {}, write roots {}",
|
||||
show(roots.read_roots().map(|r| r.display().to_string()).collect()),
|
||||
show(roots.write_roots().map(|r| r.display().to_string()).collect())
|
||||
);
|
||||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("deckcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
Box::new(h)
|
||||
@@ -750,16 +663,6 @@ mod tests {
|
||||
a.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timing_summary_keeps_median_and_max_from_one_population() {
|
||||
let slide_medians = [8.99, 16.565];
|
||||
let whole_deck = summarize_ms(vec![17.0, 18.0, 16.0]);
|
||||
assert_eq!(whole_deck, (17.0, 18.0));
|
||||
assert!(whole_deck.1 >= whole_deck.0);
|
||||
assert!(slide_medians.iter().sum::<f64>() > whole_deck.1, "fixture must expose summing independent slide medians");
|
||||
assert_eq!(summarize_ms(vec![]), (0.0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_help_prints_usage_and_writes_nothing() {
|
||||
assert!(render(&args(&["--help"])).is_ok());
|
||||
|
||||
@@ -20,153 +20,6 @@ fn words(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn command_file_dir(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-command-file-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
fn inspected_titles(out: &str) -> Vec<String> {
|
||||
let answer = out.lines().last().unwrap().strip_prefix("document.inspect → ").unwrap();
|
||||
let doc: serde_json::Value = serde_json::from_str(answer).unwrap();
|
||||
doc["slides"].as_array().unwrap().iter().map(|s| s["title"].as_str().unwrap().to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_file_runs_utf8_lines_in_order_and_skips_comments() {
|
||||
let dir = command_file_dir("lines");
|
||||
let file = dir.join("commands with spaces.txt");
|
||||
std::fs::write(
|
||||
&file,
|
||||
"\u{feff}# heading\r\n\r\n # indented comment\r\n slide.new={\"title\":\"Résumé = # 测试\"} \r\nslide.new\r\nslide.new={\"title\":\"Last\"}",
|
||||
)
|
||||
.unwrap();
|
||||
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy(), "--cmd", "document.inspect"]);
|
||||
let out = stdout(&o);
|
||||
let commands: Vec<_> = out.lines().map(|l| l.split_once(" → ").unwrap().0).collect();
|
||||
assert_eq!(commands, ["slide.new", "slide.new", "slide.new", "document.inspect"]);
|
||||
let titles = inspected_titles(&out);
|
||||
assert_eq!(titles.len(), 4);
|
||||
assert_eq!(titles[1], "Résumé = # 测试");
|
||||
assert_eq!(titles[3], "Last");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_files_and_inline_commands_keep_argument_order() {
|
||||
let dir = command_file_dir("mixed");
|
||||
let first = dir.join("first.txt");
|
||||
let second = dir.join("second.txt");
|
||||
let saved = dir.join("result.deckcraft");
|
||||
std::fs::write(&first, "slide.new={\"title\":\"First file\"}\n").unwrap();
|
||||
std::fs::write(&second, "slide.new={\"title\":\"Second file\"}\n").unwrap();
|
||||
let o = cli(&[
|
||||
"run",
|
||||
"--cmd",
|
||||
"slide.new={\"title\":\"Inline first\"}",
|
||||
"--cmd-file",
|
||||
&first.to_string_lossy(),
|
||||
"--cmd",
|
||||
"slide.new={\"title\":\"Inline middle\"}",
|
||||
"--cmd-file",
|
||||
&second.to_string_lossy(),
|
||||
"--cmd",
|
||||
"document.inspect",
|
||||
"--print",
|
||||
"--save",
|
||||
&saved.to_string_lossy(),
|
||||
]);
|
||||
let out = stdout(&o);
|
||||
let titles = inspected_titles(&out);
|
||||
assert_eq!(&titles[1..], ["Inline first", "First file", "Inline middle", "Second file"]);
|
||||
assert_eq!(out.lines().count(), 5);
|
||||
let reopened = cli(&["run", "--in", &saved.to_string_lossy(), "--cmd", "document.inspect", "--print"]);
|
||||
assert_eq!(inspected_titles(&stdout(&reopened)), titles);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_file_command_stops_later_commands_and_final_outputs() {
|
||||
let dir = command_file_dir("failure");
|
||||
let file = dir.join("commands.txt");
|
||||
let later = dir.join("later-in-file.deckcraft");
|
||||
let inline = dir.join("later-inline.deckcraft");
|
||||
let save = dir.join("saved.deckcraft");
|
||||
let export = dir.join("exported.png");
|
||||
let inline_command = format!("file.saveAs={}", serde_json::json!({"path": inline.to_string_lossy()}));
|
||||
for (command, message) in [("slide.new={", "bad JSON"), ("no.such.command", "unknown command"), ("slide.go={\"index\":999}", "slide")] {
|
||||
let later_command = format!("file.saveAs={}", serde_json::json!({"path": later.to_string_lossy()}));
|
||||
std::fs::write(&file, format!("# header\nslide.new\n{command}\n{later_command}\n")).unwrap();
|
||||
std::fs::write(&save, b"keep saved file").unwrap();
|
||||
std::fs::write(&export, b"keep exported file").unwrap();
|
||||
let o = cli(&[
|
||||
"run",
|
||||
"--print",
|
||||
"--cmd-file",
|
||||
&file.to_string_lossy(),
|
||||
"--cmd",
|
||||
&inline_command,
|
||||
"--save",
|
||||
&save.to_string_lossy(),
|
||||
"--export",
|
||||
&export.to_string_lossy(),
|
||||
]);
|
||||
let err = stderr(&o);
|
||||
assert!(err.contains(&format!("{}:3:", file.display())) && err.contains(message), "{err}");
|
||||
assert_eq!(String::from_utf8_lossy(&o.stdout).lines().count(), 1, "only the first command ran");
|
||||
assert!(!later.exists() && !inline.exists(), "commands after the failure must not run");
|
||||
assert_eq!(std::fs::read(&save).unwrap(), b"keep saved file");
|
||||
assert_eq!(std::fs::read(&export).unwrap(), b"keep exported file");
|
||||
}
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_file_read_errors_name_the_path_and_utf8_line() {
|
||||
let dir = command_file_dir("read-errors");
|
||||
let missing = dir.join("missing.txt");
|
||||
let invalid = dir.join("invalid.txt");
|
||||
let save = dir.join("out.deckcraft");
|
||||
std::fs::write(&invalid, b"slide.new\n\xff\n").unwrap();
|
||||
for file in [&missing, &dir, &invalid] {
|
||||
let o = cli(&["run", "--cmd-file", &file.to_string_lossy(), "--save", &save.to_string_lossy()]);
|
||||
let err = stderr(&o);
|
||||
assert!(err.contains(&file.display().to_string()), "{err}");
|
||||
if file == &invalid {
|
||||
assert!(err.contains(&format!("{}:2:", invalid.display())) && err.contains("UTF-8"), "{err}");
|
||||
}
|
||||
assert!(!save.exists());
|
||||
}
|
||||
assert!(stderr(&cli(&["run", "--cmd-file"])).contains("--cmd-file needs a file"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_file_rejects_an_oversized_line_before_running_it() {
|
||||
let dir = command_file_dir("line-limit");
|
||||
let file = dir.join("commands.txt");
|
||||
let command = format!("slide.new={{\"title\":\"{}\"}}", "x".repeat(16 * 1024 * 1024));
|
||||
std::fs::write(&file, format!("{command}\nslide.new\n")).unwrap();
|
||||
|
||||
let o = cli(&["run", "--print", "--cmd-file", &file.to_string_lossy()]);
|
||||
let err = stderr(&o);
|
||||
assert!(err.contains(&format!("{}:1:", file.display())) && err.contains("command line exceeds 16777216 byte limit"), "{err}");
|
||||
assert!(o.stdout.is_empty(), "the oversized command must not run");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_commands_keep_their_existing_syntax_and_errors() {
|
||||
let o = cli(&["run", "--cmd", "slide.new", "--cmd", "slide.new={\"title\":\"Inline = #\"}", "--cmd", "document.inspect", "--print"]);
|
||||
let out = stdout(&o);
|
||||
let titles = inspected_titles(&out);
|
||||
assert_eq!(titles.len(), 3);
|
||||
assert_eq!(titles[2], "Inline = #");
|
||||
assert!(stderr(&cli(&["run", "--cmd", "slide.new={"])).starts_with("deckcraft-cli: slide.new: bad JSON:"));
|
||||
assert!(stderr(&cli(&["run", "--cmd", "no.such.command"])).contains("unknown command `no.such.command`"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_subcommand_is_named_before_the_overview() {
|
||||
for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
|
||||
@@ -221,9 +74,6 @@ fn run_help_says_what_the_ids_do_not() {
|
||||
"Shape ids are looked up on the current slide only.",
|
||||
"A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
|
||||
"format.* acts on the selected text or shapes",
|
||||
"--cmd-file FILE reads the same commands from a UTF-8 text file, one per line",
|
||||
"commands execute in the order those options appear.",
|
||||
"If a command fails, run stops there and saves nothing.",
|
||||
] {
|
||||
assert!(out.contains(fact), "{fact}: {out}");
|
||||
}
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
use std::fs::{self, File};
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output, Stdio};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
struct Scratch(PathBuf);
|
||||
|
||||
impl Scratch {
|
||||
fn new(name: &str) -> Self {
|
||||
let nonce = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let path = std::env::temp_dir().join(format!("deckcraft-closed-stdout-{name}-{}-{nonce}", std::process::id()));
|
||||
fs::create_dir(&path).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Scratch {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn closed_stdout() -> Stdio {
|
||||
let (writer, reader) = UnixStream::pair().unwrap();
|
||||
drop(reader);
|
||||
Stdio::from(OwnedFd::from(writer))
|
||||
}
|
||||
|
||||
fn run(dir: &Path, args: &[&str], stdout: Stdio) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(dir).args(args).stdout(stdout).stderr(Stdio::piped()).output().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_skip_later_commands_or_save() {
|
||||
let dir = Scratch::new("save");
|
||||
let out = run(
|
||||
&dir.0,
|
||||
&[
|
||||
"run",
|
||||
"--cmd",
|
||||
r#"slide.new={"layout":"titleOnly","title":"First"}"#,
|
||||
"--cmd",
|
||||
r#"slide.new={"layout":"titleOnly","title":"Later"}"#,
|
||||
"--print",
|
||||
"--save",
|
||||
"out.deckcraft",
|
||||
],
|
||||
closed_stdout(),
|
||||
);
|
||||
assert!(out.status.success(), "exit {:?}: {}", out.status, String::from_utf8_lossy(&out.stderr));
|
||||
let saved = dir.0.join("out.deckcraft");
|
||||
assert!(saved.is_file(), "requested save was skipped");
|
||||
let info = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).arg("info").arg(saved).output().unwrap();
|
||||
assert!(info.status.success(), "{}", String::from_utf8_lossy(&info.stderr));
|
||||
let data: serde_json::Value = serde_json::from_slice(&info.stdout).unwrap();
|
||||
let slides = data["slides"].as_array().unwrap();
|
||||
assert!(slides.iter().any(|slide| slide["title"] == "Later"), "later command did not persist: {data}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_hide_a_later_command_failure() {
|
||||
let dir = Scratch::new("command-failure");
|
||||
let out = run(
|
||||
&dir.0,
|
||||
&["run", "--cmd", r#"slide.new={"title":"First"}"#, "--cmd", r#"slide.go={"index":999}"#, "--print", "--save", "out.deckcraft"],
|
||||
closed_stdout(),
|
||||
);
|
||||
assert!(!out.status.success(), "a later command failure returned success");
|
||||
assert!(!dir.0.join("out.deckcraft").exists(), "failed run unexpectedly saved");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("no slide 999"), "missing command failure diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_hide_a_save_failure() {
|
||||
let dir = Scratch::new("save-failure");
|
||||
fs::create_dir(dir.0.join("out.deckcraft")).unwrap();
|
||||
let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print", "--save", "out.deckcraft"], closed_stdout());
|
||||
assert!(!out.status.success(), "a failed save returned success");
|
||||
assert!(!out.stderr.is_empty(), "missing save failure diagnostic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broken_pipe_does_not_skip_later_rendered_slides() {
|
||||
let dir = Scratch::new("render-all");
|
||||
let baseline = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "normal"], Stdio::piped());
|
||||
assert!(baseline.status.success(), "{}", String::from_utf8_lossy(&baseline.stderr));
|
||||
let closed = run(&dir.0, &["render", "--sample", "--all", "--scale", "0.1", "closed"], closed_stdout());
|
||||
assert!(closed.status.success(), "{}", String::from_utf8_lossy(&closed.stderr));
|
||||
let names = |folder: &str| {
|
||||
let mut files: Vec<_> = fs::read_dir(dir.0.join(folder)).unwrap().map(|entry| entry.unwrap().file_name()).collect();
|
||||
files.sort();
|
||||
files
|
||||
};
|
||||
let expected = names("normal");
|
||||
assert!(expected.len() > 1, "sample must have multiple slides");
|
||||
assert_eq!(names("closed"), expected, "rendering stopped after stdout closed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_stdout_write_errors_fail() {
|
||||
let dir = Scratch::new("write-failure");
|
||||
let path = dir.0.join("readonly");
|
||||
fs::write(&path, b"").unwrap();
|
||||
let readonly = File::open(path).unwrap();
|
||||
let out = run(&dir.0, &["run", "--cmd", r#"slide.new={"title":"First"}"#, "--print"], Stdio::from(readonly));
|
||||
assert!(!out.status.success(), "a non-broken stdout error returned success");
|
||||
assert!(String::from_utf8_lossy(&out.stderr).contains("can't write to stdout"));
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
mod cli;
|
||||
#[cfg(unix)]
|
||||
mod closed_stdout;
|
||||
mod convert;
|
||||
mod damaged;
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
|
||||
@@ -87,45 +87,6 @@ fn mcp_automation_roots_confine_the_server() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_accepts_multiple_read_and_write_roots() {
|
||||
let base = tmpdir("multiple-roots");
|
||||
let read1 = base.join("read1");
|
||||
let read2 = base.join("read2");
|
||||
let write1 = base.join("write1");
|
||||
let write2 = base.join("write2");
|
||||
for dir in [&read1, &read2, &write1, &write2] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
let (input1, input2) = (read1.join("one.txt"), read2.join("two.txt"));
|
||||
std::fs::write(&input1, "one").unwrap();
|
||||
std::fs::write(&input2, "two").unwrap();
|
||||
let (output1, output2) = (write1.join("one.deckcraft"), write2.join("two.deckcraft"));
|
||||
let args = [
|
||||
"--automation-read-root".to_owned(),
|
||||
read1.to_string_lossy().into_owned(),
|
||||
format!("--automation-read-root={}", read2.display()),
|
||||
"--automation-write-root".to_owned(),
|
||||
write1.to_string_lossy().into_owned(),
|
||||
format!("--automation-write-root={}", write2.display()),
|
||||
];
|
||||
let args = args.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
let calls = [
|
||||
("open_presentation", serde_json::json!({"path": input1})),
|
||||
("open_presentation", serde_json::json!({"path": input2})),
|
||||
("save_presentation", serde_json::json!({"path": output1})),
|
||||
("save_presentation", serde_json::json!({"path": output2})),
|
||||
];
|
||||
let (success, stderr, replies) = mcp_session(&args, &calls);
|
||||
assert!(success, "{stderr}");
|
||||
for reply in &replies {
|
||||
assert_eq!(reply["result"]["isError"], false, "{reply}");
|
||||
}
|
||||
assert!(output1.exists());
|
||||
assert!(output2.exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
let base = tmpdir("roots-args");
|
||||
@@ -142,12 +103,8 @@ fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
// The flags confine the in-process server, so they cannot be combined with --connect.
|
||||
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
|
||||
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
let mut full = vec!["mcp", flag, dir, flag];
|
||||
full.push(dir);
|
||||
let out = run(&full);
|
||||
assert!(out.status.success(), "{flag}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
|
||||
fails(&["--automation-read-root"], "needs a folder");
|
||||
fails(&["--automation-write-root", "--sample"], "needs a folder");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
|
||||
@@ -187,7 +187,7 @@ fn main() -> eframe::Result {
|
||||
// First, so every start-up record (and the engine's panic hook, installed with the first
|
||||
// Session) is captured; see `logging`.
|
||||
let logger = logging::install();
|
||||
if let Some(done) = craft_update::self_update::finish(&deckcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Some(done) = craft_update::self_update::finish(&craft_update::suite::DECKCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
|
||||
if let Err(e) = done {
|
||||
eprintln!("DeckCraft update helper failed: {e}");
|
||||
std::process::exit(1);
|
||||
|
||||
@@ -2,7 +2,4 @@ allow-unwrap-in-tests = true
|
||||
allow-expect-in-tests = true
|
||||
allow-panic-in-tests = true
|
||||
allow-indexing-slicing-in-tests = true
|
||||
disallowed-types = [
|
||||
{ path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
|
||||
]
|
||||
cognitive-complexity-threshold = 30
|
||||
|
||||
@@ -68,6 +68,3 @@ sync_github_name = true
|
||||
|
||||
[people.SofiaAFigueredo]
|
||||
real_name = "Sofia Alves Figueredo"
|
||||
|
||||
[people.dylanmtaylor]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -58,9 +58,6 @@ pub struct ShowState {
|
||||
pub playing: bool,
|
||||
/// On the end screen.
|
||||
pub ended: bool,
|
||||
/// The current slide was reached by going back (← / Backspace), so the show plays the
|
||||
/// transition of the slide it left, in reverse, as PowerPoint does (#1531).
|
||||
pub backward: bool,
|
||||
/// The show has entered a slide (so `slide` and `step` are meaningful and can be saved).
|
||||
active: bool,
|
||||
/// The time of the last [`ShowState::tick`] (seconds), for the triggered animations' clocks.
|
||||
@@ -211,7 +208,6 @@ impl ShowState {
|
||||
self.active = true;
|
||||
self.slide = i;
|
||||
self.ended = false;
|
||||
self.backward = false;
|
||||
// Time spent elsewhere never counts for this slide's triggered animations.
|
||||
self.last_now = None;
|
||||
let steps = ShowState::steps(pres, i);
|
||||
@@ -313,7 +309,6 @@ impl ShowState {
|
||||
}
|
||||
self.step = ShowState::steps(pres, self.slide);
|
||||
self.playing = false;
|
||||
self.backward = false;
|
||||
return ShowAction::Slide(self.slide);
|
||||
}
|
||||
// #844: undoing the automatic first step left the slide as before it appeared, and the show
|
||||
@@ -342,7 +337,6 @@ impl ShowState {
|
||||
self.step = ShowState::steps(pres, i);
|
||||
self.playing = false;
|
||||
}
|
||||
self.backward = true;
|
||||
ShowAction::Slide(i)
|
||||
}
|
||||
None => ShowAction::None,
|
||||
|
||||
@@ -763,26 +763,6 @@ fn show_skips_hidden_slides() {
|
||||
assert_eq!(s.goto(&p, 99), ShowAction::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_remembers_a_slide_was_reached_by_going_back() {
|
||||
// #1531: the UI plays the left slide's transition reversed only for Back.
|
||||
let p = pres(3);
|
||||
let mut s = ShowState::new(&p);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(!s.backward);
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(0));
|
||||
assert!(s.backward);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(!s.backward);
|
||||
assert_eq!(s.goto(&p, 0), ShowAction::Slide(0));
|
||||
assert!(!s.backward, "a jump is not Back");
|
||||
// Back from the end screen shows the last slide again, without a transition to reverse.
|
||||
assert_eq!(s.goto(&p, 2), ShowAction::Slide(2));
|
||||
assert_eq!(s.next(&p), ShowAction::End);
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(2));
|
||||
assert!(!s.backward);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_loops() {
|
||||
let mut p = pres(3);
|
||||
|
||||
+178
-218
@@ -429,38 +429,6 @@ pub struct FrameHeader {
|
||||
pub spatial_id: u32,
|
||||
}
|
||||
|
||||
/// mark_ref_frames( idLen ): invalidates reference slots whose frame id is too far from the current one.
|
||||
fn mark_ref_frames(st: &mut HeaderState, cur: u32, id_len: u32) {
|
||||
let diff_len = st.seq.delta_frame_id_length_minus_2 + 2;
|
||||
for rs in st.refs.iter_mut() {
|
||||
if cur > (1 << diff_len) {
|
||||
if rs.frame_id > cur || rs.frame_id < cur - (1 << diff_len) {
|
||||
rs.valid = false;
|
||||
}
|
||||
} else if rs.frame_id > cur && rs.frame_id < (1 << id_len) + cur - (1 << diff_len) {
|
||||
rs.valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Skips buffer_removal_time for every operating point that includes this layer.
|
||||
fn skip_buffer_removal_times(r: &mut BitReader, seq: &SequenceHeader, temporal_id: u32, spatial_id: u32) -> Result<()> {
|
||||
if !(seq.decoder_model_info_present && r.flag()?) {
|
||||
return Ok(());
|
||||
}
|
||||
for op in 0..=seq.operating_points_cnt_minus_1 {
|
||||
if seq.decoder_model_present_for_this_op[op] {
|
||||
let idc = seq.operating_point_idc[op];
|
||||
let in_t = (idc >> temporal_id) & 1;
|
||||
let in_s = (idc >> (spatial_id + 8)) & 1;
|
||||
if idc == 0 || (in_t == 1 && in_s == 1) {
|
||||
r.f(seq.buffer_removal_time_length_minus_1 + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl FrameHeader {
|
||||
pub fn get_relative_dist(&self, seq: &SequenceHeader, a: u32, b: u32) -> i32 {
|
||||
relative_dist(seq, a, b)
|
||||
@@ -526,10 +494,35 @@ impl FrameHeader {
|
||||
} else {
|
||||
h.show_existing_frame = r.flag()?;
|
||||
if h.show_existing_frame {
|
||||
h.parse_show_existing_frame(r, st, id_len)?;
|
||||
h.frame_to_show_map_idx = r.f(3)? as usize;
|
||||
if seq.decoder_model_info_present && !seq.equal_picture_interval {
|
||||
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
r.f(id_len)?;
|
||||
}
|
||||
let rs = &st.refs[h.frame_to_show_map_idx];
|
||||
if !rs.valid {
|
||||
return Err(Error::Invalid("show_existing_frame of an empty slot"));
|
||||
}
|
||||
h.frame_type = rs.frame_type;
|
||||
if h.frame_type == KEY_FRAME as u8 {
|
||||
h.refresh_frame_flags = all_frames;
|
||||
}
|
||||
if seq.film_grain_params_present {
|
||||
h.film_grain = rs.grain.clone();
|
||||
}
|
||||
return Ok(h);
|
||||
}
|
||||
h.parse_frame_type_and_flags(r, seq)?;
|
||||
h.frame_type = r.f(2)? as u8;
|
||||
h.frame_is_intra = h.frame_type == INTRA_ONLY_FRAME as u8 || h.frame_type == KEY_FRAME as u8;
|
||||
h.show_frame = r.flag()?;
|
||||
if h.show_frame && seq.decoder_model_info_present && !seq.equal_picture_interval {
|
||||
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
|
||||
}
|
||||
h.showable_frame = if h.show_frame { h.frame_type != KEY_FRAME as u8 } else { r.flag()? };
|
||||
h.error_resilient_mode =
|
||||
if h.frame_type == SWITCH_FRAME as u8 || (h.frame_type == KEY_FRAME as u8 && h.show_frame) { true } else { r.flag()? };
|
||||
}
|
||||
if h.frame_type == KEY_FRAME as u8 && h.show_frame {
|
||||
for rs in st.refs.iter_mut() {
|
||||
@@ -540,11 +533,33 @@ impl FrameHeader {
|
||||
h.order_hints[LAST_FRAME + i] = 0;
|
||||
}
|
||||
}
|
||||
h.parse_screen_content_flags(r, seq)?;
|
||||
h.disable_cdf_update = r.flag()?;
|
||||
h.allow_screen_content_tools = if seq.seq_force_screen_content_tools == SELECT_SCREEN_CONTENT_TOOLS as u32 {
|
||||
r.flag()?
|
||||
} else {
|
||||
seq.seq_force_screen_content_tools != 0
|
||||
};
|
||||
if h.allow_screen_content_tools {
|
||||
h.force_integer_mv = if seq.seq_force_integer_mv == SELECT_INTEGER_MV as u32 { r.flag()? } else { seq.seq_force_integer_mv != 0 };
|
||||
}
|
||||
if h.frame_is_intra {
|
||||
h.force_integer_mv = true;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
h.current_frame_id = r.f(id_len)?;
|
||||
mark_ref_frames(st, h.current_frame_id, id_len);
|
||||
st.current_frame_id = h.current_frame_id;
|
||||
// mark_ref_frames( idLen )
|
||||
let diff_len = seq.delta_frame_id_length_minus_2 + 2;
|
||||
let cur = h.current_frame_id;
|
||||
for rs in st.refs.iter_mut() {
|
||||
if cur > (1 << diff_len) {
|
||||
if rs.frame_id > cur || rs.frame_id < cur - (1 << diff_len) {
|
||||
rs.valid = false;
|
||||
}
|
||||
} else if rs.frame_id > cur && rs.frame_id < (1 << id_len) + cur - (1 << diff_len) {
|
||||
rs.valid = false;
|
||||
}
|
||||
}
|
||||
st.current_frame_id = cur;
|
||||
}
|
||||
h.frame_size_override_flag = if h.frame_type == SWITCH_FRAME as u8 {
|
||||
true
|
||||
@@ -555,7 +570,18 @@ impl FrameHeader {
|
||||
};
|
||||
h.order_hint = r.f(seq.order_hint_bits)?;
|
||||
h.primary_ref_frame = if h.frame_is_intra || h.error_resilient_mode { PRIMARY_REF_NONE } else { r.f(3)? as usize };
|
||||
skip_buffer_removal_times(r, seq, temporal_id, spatial_id)?;
|
||||
if seq.decoder_model_info_present && r.flag()? {
|
||||
for op in 0..=seq.operating_points_cnt_minus_1 {
|
||||
if seq.decoder_model_present_for_this_op[op] {
|
||||
let idc = seq.operating_point_idc[op];
|
||||
let in_t = (idc >> temporal_id) & 1;
|
||||
let in_s = (idc >> (spatial_id + 8)) & 1;
|
||||
if idc == 0 || (in_t == 1 && in_s == 1) {
|
||||
r.f(seq.buffer_removal_time_length_minus_1 + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
h.refresh_frame_flags =
|
||||
if h.frame_type == SWITCH_FRAME as u8 || (h.frame_type == KEY_FRAME as u8 && h.show_frame) { all_frames } else { r.f(8)? };
|
||||
if (!h.frame_is_intra || h.refresh_frame_flags != all_frames) && h.error_resilient_mode && seq.enable_order_hint {
|
||||
@@ -573,16 +599,126 @@ impl FrameHeader {
|
||||
h.allow_intrabc = r.flag()?;
|
||||
}
|
||||
} else {
|
||||
h.parse_inter_frame_refs(r, st)?;
|
||||
let mut short = false;
|
||||
if seq.enable_order_hint {
|
||||
short = r.flag()?;
|
||||
if short {
|
||||
let last = r.f(3)? as usize;
|
||||
let gold = r.f(3)? as usize;
|
||||
h.set_frame_refs(seq, st.refs, last, gold);
|
||||
}
|
||||
}
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if !short {
|
||||
h.ref_frame_idx[i] = r.f(3)? as usize;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
r.f(seq.delta_frame_id_length_minus_2 + 2)?;
|
||||
}
|
||||
}
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if !st.refs[h.ref_frame_idx[i]].valid {
|
||||
return Err(Error::Invalid("reference to an empty slot"));
|
||||
}
|
||||
}
|
||||
if h.frame_size_override_flag && !h.error_resilient_mode {
|
||||
// frame_size_with_refs( )
|
||||
let mut found = false;
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if r.flag()? {
|
||||
let rs = &st.refs[h.ref_frame_idx[i]];
|
||||
h.upscaled_width = rs.upscaled_width;
|
||||
h.frame_width = h.upscaled_width;
|
||||
h.frame_height = rs.frame_height;
|
||||
h.render_width = rs.render_width;
|
||||
h.render_height = rs.render_height;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
h.frame_size(r, seq)?;
|
||||
h.render_size(r)?;
|
||||
} else {
|
||||
h.superres_params(r, seq)?;
|
||||
h.compute_image_size();
|
||||
}
|
||||
} else {
|
||||
h.frame_size(r, seq)?;
|
||||
h.render_size(r)?;
|
||||
}
|
||||
h.allow_high_precision_mv = if h.force_integer_mv { false } else { r.flag()? };
|
||||
h.interpolation_filter = if r.flag()? { SWITCHABLE } else { r.f(2)? as u8 };
|
||||
h.is_motion_mode_switchable = r.flag()?;
|
||||
h.use_ref_frame_mvs = if h.error_resilient_mode || !seq.enable_ref_frame_mvs { false } else { r.flag()? };
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
let rf = LAST_FRAME + i;
|
||||
let hint = st.refs[h.ref_frame_idx[i]].order_hint;
|
||||
h.order_hints[rf] = hint;
|
||||
h.ref_frame_sign_bias[rf] = seq.enable_order_hint && relative_dist(seq, hint, h.order_hint) > 0;
|
||||
}
|
||||
}
|
||||
h.disable_frame_end_update_cdf = if seq.reduced_still_picture_header || h.disable_cdf_update { true } else { r.flag()? };
|
||||
h.setup_or_load_previous(st);
|
||||
if h.primary_ref_frame == PRIMARY_REF_NONE {
|
||||
// setup_past_independence( )
|
||||
st.seg_features = SegmentationFeatures::default();
|
||||
st.prev_gm_params = default_gm_params();
|
||||
st.lf_deltas = LoopFilterDeltas::defaults();
|
||||
h.lf.delta_enabled = true;
|
||||
} else {
|
||||
// load_previous( )
|
||||
let prev = &st.refs[h.ref_frame_idx[h.primary_ref_frame]];
|
||||
st.prev_gm_params = prev.gm_params;
|
||||
st.lf_deltas = prev.lf_deltas;
|
||||
st.seg_features = prev.seg_features;
|
||||
}
|
||||
h.prev_gm_params = st.prev_gm_params;
|
||||
h.tile_info(r, seq)?;
|
||||
h.quantization_params(r, seq)?;
|
||||
h.segmentation_params(r, st)?;
|
||||
h.delta_q_lf_params(r)?;
|
||||
h.compute_lossless();
|
||||
// delta_q_params( )
|
||||
if h.quant.base_q_idx > 0 {
|
||||
h.delta_q_present = r.flag()?;
|
||||
}
|
||||
if h.delta_q_present {
|
||||
h.delta_q_res = r.f(2)?;
|
||||
}
|
||||
// delta_lf_params( )
|
||||
if h.delta_q_present {
|
||||
if !h.allow_intrabc {
|
||||
h.delta_lf_present = r.flag()?;
|
||||
}
|
||||
if h.delta_lf_present {
|
||||
h.delta_lf_res = r.f(2)?;
|
||||
h.delta_lf_multi = r.flag()?;
|
||||
}
|
||||
}
|
||||
h.coded_lossless = true;
|
||||
for seg_id in 0..MAX_SEGMENTS {
|
||||
let q = h.get_qindex(true, seg_id, 0);
|
||||
let ll = q == 0
|
||||
&& h.quant.delta_q_y_dc == 0
|
||||
&& h.quant.delta_q_u_ac == 0
|
||||
&& h.quant.delta_q_u_dc == 0
|
||||
&& h.quant.delta_q_v_ac == 0
|
||||
&& h.quant.delta_q_v_dc == 0;
|
||||
h.lossless_array[seg_id] = ll;
|
||||
if !ll {
|
||||
h.coded_lossless = false;
|
||||
}
|
||||
if h.quant.using_qmatrix {
|
||||
if ll {
|
||||
h.seg_qm_level[0][seg_id] = 15;
|
||||
h.seg_qm_level[1][seg_id] = 15;
|
||||
h.seg_qm_level[2][seg_id] = 15;
|
||||
} else {
|
||||
h.seg_qm_level[0][seg_id] = h.quant.qm_y;
|
||||
h.seg_qm_level[1][seg_id] = h.quant.qm_u;
|
||||
h.seg_qm_level[2][seg_id] = h.quant.qm_v;
|
||||
}
|
||||
}
|
||||
}
|
||||
h.all_lossless = h.coded_lossless && h.frame_width == h.upscaled_width;
|
||||
h.loop_filter_params(r, seq, st)?;
|
||||
h.cdef_params(r, seq)?;
|
||||
h.lr_params(r, seq)?;
|
||||
@@ -603,182 +739,6 @@ impl FrameHeader {
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
/// The show_existing_frame branch: picks the slot to show and inherits its frame type and grain.
|
||||
fn parse_show_existing_frame(&mut self, r: &mut BitReader, st: &HeaderState, id_len: u32) -> Result<()> {
|
||||
let seq = st.seq;
|
||||
self.frame_to_show_map_idx = r.f(3)? as usize;
|
||||
if seq.decoder_model_info_present && !seq.equal_picture_interval {
|
||||
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
r.f(id_len)?;
|
||||
}
|
||||
let rs = &st.refs[self.frame_to_show_map_idx];
|
||||
if !rs.valid {
|
||||
return Err(Error::Invalid("show_existing_frame of an empty slot"));
|
||||
}
|
||||
self.frame_type = rs.frame_type;
|
||||
if self.frame_type == KEY_FRAME as u8 {
|
||||
self.refresh_frame_flags = (1u32 << NUM_REF_FRAMES) - 1;
|
||||
}
|
||||
if seq.film_grain_params_present {
|
||||
self.film_grain = rs.grain.clone();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// frame_type, show_frame, showable_frame and error_resilient_mode.
|
||||
fn parse_frame_type_and_flags(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> {
|
||||
self.frame_type = r.f(2)? as u8;
|
||||
self.frame_is_intra = self.frame_type == INTRA_ONLY_FRAME as u8 || self.frame_type == KEY_FRAME as u8;
|
||||
self.show_frame = r.flag()?;
|
||||
if self.show_frame && seq.decoder_model_info_present && !seq.equal_picture_interval {
|
||||
r.f(seq.frame_presentation_time_length_minus_1 + 1)?;
|
||||
}
|
||||
self.showable_frame = if self.show_frame { self.frame_type != KEY_FRAME as u8 } else { r.flag()? };
|
||||
self.error_resilient_mode =
|
||||
if self.frame_type == SWITCH_FRAME as u8 || (self.frame_type == KEY_FRAME as u8 && self.show_frame) { true } else { r.flag()? };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// disable_cdf_update, allow_screen_content_tools and force_integer_mv.
|
||||
fn parse_screen_content_flags(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> {
|
||||
self.disable_cdf_update = r.flag()?;
|
||||
self.allow_screen_content_tools = if seq.seq_force_screen_content_tools == SELECT_SCREEN_CONTENT_TOOLS as u32 {
|
||||
r.flag()?
|
||||
} else {
|
||||
seq.seq_force_screen_content_tools != 0
|
||||
};
|
||||
if self.allow_screen_content_tools {
|
||||
self.force_integer_mv = if seq.seq_force_integer_mv == SELECT_INTEGER_MV as u32 { r.flag()? } else { seq.seq_force_integer_mv != 0 };
|
||||
}
|
||||
if self.frame_is_intra {
|
||||
self.force_integer_mv = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reference selection, frame size, motion vector precision and filter flags of an inter frame.
|
||||
fn parse_inter_frame_refs(&mut self, r: &mut BitReader, st: &HeaderState) -> Result<()> {
|
||||
let seq = st.seq;
|
||||
let mut short = false;
|
||||
if seq.enable_order_hint {
|
||||
short = r.flag()?;
|
||||
if short {
|
||||
let last = r.f(3)? as usize;
|
||||
let gold = r.f(3)? as usize;
|
||||
self.set_frame_refs(seq, st.refs, last, gold);
|
||||
}
|
||||
}
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if !short {
|
||||
self.ref_frame_idx[i] = r.f(3)? as usize;
|
||||
}
|
||||
if seq.frame_id_numbers_present {
|
||||
r.f(seq.delta_frame_id_length_minus_2 + 2)?;
|
||||
}
|
||||
}
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if !st.refs[self.ref_frame_idx[i]].valid {
|
||||
return Err(Error::Invalid("reference to an empty slot"));
|
||||
}
|
||||
}
|
||||
if self.frame_size_override_flag && !self.error_resilient_mode {
|
||||
self.frame_size_with_refs(r, seq, st.refs)?;
|
||||
} else {
|
||||
self.frame_size(r, seq)?;
|
||||
self.render_size(r)?;
|
||||
}
|
||||
self.allow_high_precision_mv = if self.force_integer_mv { false } else { r.flag()? };
|
||||
self.interpolation_filter = if r.flag()? { SWITCHABLE } else { r.f(2)? as u8 };
|
||||
self.is_motion_mode_switchable = r.flag()?;
|
||||
self.use_ref_frame_mvs = if self.error_resilient_mode || !seq.enable_ref_frame_mvs { false } else { r.flag()? };
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
let rf = LAST_FRAME + i;
|
||||
let hint = st.refs[self.ref_frame_idx[i]].order_hint;
|
||||
self.order_hints[rf] = hint;
|
||||
self.ref_frame_sign_bias[rf] = seq.enable_order_hint && relative_dist(seq, hint, self.order_hint) > 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// frame_size_with_refs( ).
|
||||
fn frame_size_with_refs(&mut self, r: &mut BitReader, seq: &SequenceHeader, refs: &[RefInfo; NUM_REF_FRAMES]) -> Result<()> {
|
||||
for i in 0..REFS_PER_FRAME {
|
||||
if r.flag()? {
|
||||
let rs = &refs[self.ref_frame_idx[i]];
|
||||
self.upscaled_width = rs.upscaled_width;
|
||||
self.frame_width = self.upscaled_width;
|
||||
self.frame_height = rs.frame_height;
|
||||
self.render_width = rs.render_width;
|
||||
self.render_height = rs.render_height;
|
||||
self.superres_params(r, seq)?;
|
||||
self.compute_image_size();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.frame_size(r, seq)?;
|
||||
self.render_size(r)
|
||||
}
|
||||
|
||||
/// setup_past_independence( ) or load_previous( ), depending on primary_ref_frame.
|
||||
fn setup_or_load_previous(&mut self, st: &mut HeaderState) {
|
||||
if self.primary_ref_frame == PRIMARY_REF_NONE {
|
||||
st.seg_features = SegmentationFeatures::default();
|
||||
st.prev_gm_params = default_gm_params();
|
||||
st.lf_deltas = LoopFilterDeltas::defaults();
|
||||
self.lf.delta_enabled = true;
|
||||
} else {
|
||||
let prev = &st.refs[self.ref_frame_idx[self.primary_ref_frame]];
|
||||
st.prev_gm_params = prev.gm_params;
|
||||
st.lf_deltas = prev.lf_deltas;
|
||||
st.seg_features = prev.seg_features;
|
||||
}
|
||||
}
|
||||
|
||||
/// delta_q_params( ) and delta_lf_params( ).
|
||||
fn delta_q_lf_params(&mut self, r: &mut BitReader) -> Result<()> {
|
||||
if self.quant.base_q_idx > 0 {
|
||||
self.delta_q_present = r.flag()?;
|
||||
}
|
||||
if self.delta_q_present {
|
||||
self.delta_q_res = r.f(2)?;
|
||||
if !self.allow_intrabc {
|
||||
self.delta_lf_present = r.flag()?;
|
||||
}
|
||||
if self.delta_lf_present {
|
||||
self.delta_lf_res = r.f(2)?;
|
||||
self.delta_lf_multi = r.flag()?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Per-segment lossless flags, quantizer matrix levels and the frame-wide lossless flags.
|
||||
fn compute_lossless(&mut self) {
|
||||
self.coded_lossless = true;
|
||||
for seg_id in 0..MAX_SEGMENTS {
|
||||
let q = self.get_qindex(true, seg_id, 0);
|
||||
let ll = q == 0
|
||||
&& self.quant.delta_q_y_dc == 0
|
||||
&& self.quant.delta_q_u_ac == 0
|
||||
&& self.quant.delta_q_u_dc == 0
|
||||
&& self.quant.delta_q_v_ac == 0
|
||||
&& self.quant.delta_q_v_dc == 0;
|
||||
self.lossless_array[seg_id] = ll;
|
||||
if !ll {
|
||||
self.coded_lossless = false;
|
||||
}
|
||||
if self.quant.using_qmatrix {
|
||||
let levels = if ll { [15; 3] } else { [self.quant.qm_y, self.quant.qm_u, self.quant.qm_v] };
|
||||
for (plane, level) in levels.into_iter().enumerate() {
|
||||
self.seg_qm_level[plane][seg_id] = level;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.all_lossless = self.coded_lossless && self.frame_width == self.upscaled_width;
|
||||
}
|
||||
|
||||
fn superres_params(&mut self, r: &mut BitReader, seq: &SequenceHeader) -> Result<()> {
|
||||
self.use_superres = if seq.enable_superres { r.flag()? } else { false };
|
||||
self.superres_denom = if self.use_superres { r.f(SUPERRES_DENOM_BITS as u32)? + SUPERRES_DENOM_MIN as u32 } else { SUPERRES_NUM as u32 };
|
||||
|
||||
+47
-60
@@ -420,8 +420,20 @@ impl TileDecoder<'_, '_> {
|
||||
let fh = &self.fs.fh;
|
||||
for ref_list in 0..1 + is_compound as usize {
|
||||
let ref_frame = cand_refs[ref_list];
|
||||
let ym = self.b.y_mode;
|
||||
let global =
|
||||
(ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION;
|
||||
let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0;
|
||||
let ref_idx: i32 = if self.b.use_intrabc { -1 } else { fh.ref_frame_idx[ref_frame as usize - LAST_FRAME] as i32 };
|
||||
let use_warp = self.warp_mode(ref_frame, ref_idx, w, h);
|
||||
let use_warp = if w < 8 || h < 8 || fh.force_integer_mv {
|
||||
0
|
||||
} else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid {
|
||||
1
|
||||
} else if global && !self.is_scaled_ref(ref_idx) && global_valid {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let mv = [cand_mvs[ref_list].row as i32, cand_mvs[ref_list].col as i32];
|
||||
if use_warp != 0 {
|
||||
let params = if use_warp == 1 { self.b.local_warp_params } else { fh.gm_params[ref_frame as usize] };
|
||||
@@ -436,7 +448,40 @@ impl TileDecoder<'_, '_> {
|
||||
}
|
||||
}
|
||||
let ct = self.b.compound_type;
|
||||
self.fill_blend_mask(plane, w, h, &preds, rnd.2);
|
||||
if ct == COMPOUND_WEDGE && plane == 0 {
|
||||
let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign];
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32;
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_INTRA {
|
||||
let size_scale = MAX_SB_SIZE / h.max(w);
|
||||
let im = self.b.interintra_mode;
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED {
|
||||
II_WEIGHTS_1D[i * size_scale] as i32
|
||||
} else if im == II_H_PRED {
|
||||
II_WEIGHTS_1D[j * size_scale] as i32
|
||||
} else if im == II_SMOOTH_PRED {
|
||||
II_WEIGHTS_1D[i.min(j) * size_scale] as i32
|
||||
} else {
|
||||
32
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_DIFFWTD && plane == 0 {
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
let k = i * PRED_STRIDE + j;
|
||||
let diff = (preds[0][k] - preds[1][k]).abs();
|
||||
let diff = round2(diff, (bd - 8) + rnd.2);
|
||||
let m = (38 + diff / 16).clamp(0, 64);
|
||||
self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m };
|
||||
}
|
||||
}
|
||||
}
|
||||
let (fwd, bck) = if ct == COMPOUND_DISTANCE { self.distance_weights(cand_refs) } else { (0, 0) };
|
||||
let max = (1i32 << bd) - 1;
|
||||
let is_inter_intra = self.b.is_inter && self.b.ref_frame[1] == INTRA_FRAME as i8;
|
||||
@@ -476,64 +521,6 @@ impl TileDecoder<'_, '_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Warp selection for one reference: 0 none, 1 local warp, 2 global warp.
|
||||
fn warp_mode(&self, ref_frame: i8, ref_idx: i32, w: usize, h: usize) -> u8 {
|
||||
let fh = &self.fs.fh;
|
||||
let ym = self.b.y_mode;
|
||||
let global =
|
||||
(ym == GLOBALMV || ym == GLOBAL_GLOBALMV) && ref_frame > INTRA_FRAME as i8 && fh.gm_type[ref_frame as usize] as usize > TRANSLATION;
|
||||
let global_valid = global && setup_shear(&fh.gm_params[ref_frame as usize]).0;
|
||||
if w < 8 || h < 8 || fh.force_integer_mv {
|
||||
0
|
||||
} else if self.b.motion_mode as usize == LOCALWARP && self.b.local_valid {
|
||||
1
|
||||
} else if global && !self.is_scaled_ref(ref_idx) && global_valid {
|
||||
2
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the blend mask (wedge, inter-intra or difference-weighted) for the current compound type.
|
||||
fn fill_blend_mask(&mut self, plane: usize, w: usize, h: usize, preds: &[Vec<i32>; 2], round_post: u32) {
|
||||
let bd = self.fs.bit_depth;
|
||||
let ct = self.b.compound_type;
|
||||
if ct == COMPOUND_WEDGE && plane == 0 {
|
||||
let m = &wedge_masks()[self.b.mi_size][self.b.wedge_index][self.b.wedge_sign];
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = m[i * w + j] as i32;
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_INTRA {
|
||||
let size_scale = MAX_SB_SIZE / h.max(w);
|
||||
let im = self.b.interintra_mode;
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
self.mask[i * PRED_STRIDE + j] = if im == II_V_PRED {
|
||||
II_WEIGHTS_1D[i * size_scale] as i32
|
||||
} else if im == II_H_PRED {
|
||||
II_WEIGHTS_1D[j * size_scale] as i32
|
||||
} else if im == II_SMOOTH_PRED {
|
||||
II_WEIGHTS_1D[i.min(j) * size_scale] as i32
|
||||
} else {
|
||||
32
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if ct == COMPOUND_DIFFWTD && plane == 0 {
|
||||
for i in 0..h {
|
||||
for j in 0..w {
|
||||
let k = i * PRED_STRIDE + j;
|
||||
let diff = (preds[0][k] - preds[1][k]).abs();
|
||||
let diff = round2(diff, (bd - 8) + round_post);
|
||||
let m = (38 + diff / 16).clamp(0, 64);
|
||||
self.mask[k] = if self.b.mask_type == 1 { 64 - m } else { m };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_scaled_ref(&self, ref_idx: i32) -> bool {
|
||||
if ref_idx < 0 {
|
||||
return false;
|
||||
|
||||
+64
-83
@@ -1682,87 +1682,6 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
|
||||
}
|
||||
}
|
||||
|
||||
/// eob_pt, eob_extra and the remaining eob bits.
|
||||
fn read_eob(&mut self, tx_sz: usize, tx_sz_ctx: usize, ptype: usize, tx_class: usize) -> usize {
|
||||
let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4;
|
||||
let ectx = (tx_class != TX_CLASS_2D) as usize;
|
||||
let eob_pt = 1 + match eob_multisize {
|
||||
0 => self.sd.read_symbol(&mut self.cdf.eob_pt_16[ptype][ectx]),
|
||||
1 => self.sd.read_symbol(&mut self.cdf.eob_pt_32[ptype][ectx]),
|
||||
2 => self.sd.read_symbol(&mut self.cdf.eob_pt_64[ptype][ectx]),
|
||||
3 => self.sd.read_symbol(&mut self.cdf.eob_pt_128[ptype][ectx]),
|
||||
4 => self.sd.read_symbol(&mut self.cdf.eob_pt_256[ptype][ectx]),
|
||||
5 => self.sd.read_symbol(&mut self.cdf.eob_pt_512[ptype]),
|
||||
_ => self.sd.read_symbol(&mut self.cdf.eob_pt_1024[ptype]),
|
||||
};
|
||||
let mut eob = if eob_pt < 2 { eob_pt } else { (1 << (eob_pt - 2)) + 1 };
|
||||
let eob_shift = eob_pt as i32 - 3;
|
||||
if eob_shift >= 0 {
|
||||
let extra = self.sd.read_symbol(&mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]);
|
||||
if extra == 1 {
|
||||
eob += 1 << eob_shift;
|
||||
}
|
||||
for i in 1..(eob_pt as i32 - 2).max(0) {
|
||||
let sh = (eob_pt as i32 - 2).max(0) - 1 - i;
|
||||
if self.sd.read_literal(1) == 1 {
|
||||
eob += 1 << sh;
|
||||
}
|
||||
}
|
||||
}
|
||||
eob
|
||||
}
|
||||
|
||||
/// Exp-Golomb coded remainder of a coefficient above the base range (read_golomb).
|
||||
fn read_golomb(&mut self) -> u32 {
|
||||
let mut length = 0;
|
||||
loop {
|
||||
length += 1;
|
||||
if self.sd.read_literal(1) == 1 || length > 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut x: u32 = 1;
|
||||
for _ in (0..length - 1).rev() {
|
||||
x = (x << 1) | self.sd.read_literal(1);
|
||||
}
|
||||
x
|
||||
}
|
||||
|
||||
/// Reads the signs and Golomb remainders of the first `scan.len()` (= eob) coefficients, applies
|
||||
/// them to `self.quant`, and returns the culLevel and dcCategory contexts. `blk4` is (x4, y4, w4, h4).
|
||||
fn read_signs_and_golomb(&mut self, plane: usize, blk4: (usize, usize, usize, usize), scan: &[u16]) -> (u32, u8) {
|
||||
let (x4, y4, w4, h4) = blk4;
|
||||
let ptype = (plane > 0) as usize;
|
||||
let mut cul_level = 0u32;
|
||||
let mut dc_category = 0u8;
|
||||
for (c, &p) in scan.iter().enumerate() {
|
||||
let pos = p as usize;
|
||||
let sign = if self.quant[pos] != 0 {
|
||||
if c == 0 {
|
||||
let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4);
|
||||
self.sd.read_symbol(&mut self.cdf.dc_sign[ptype][ctx]) == 1
|
||||
} else {
|
||||
self.sd.read_literal(1) == 1
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if self.quant[pos] > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as i32 {
|
||||
let x = self.read_golomb();
|
||||
self.quant[pos] = (x as i64 + COEFF_BASE_RANGE as i64 + NUM_BASE_LEVELS as i64).min(i32::MAX as i64) as i32;
|
||||
}
|
||||
if pos == 0 && self.quant[pos] > 0 {
|
||||
dc_category = if sign { 1 } else { 2 };
|
||||
}
|
||||
self.quant[pos] &= 0xFFFFF;
|
||||
cul_level += self.quant[pos] as u32;
|
||||
if sign {
|
||||
self.quant[pos] = -self.quant[pos];
|
||||
}
|
||||
}
|
||||
(cul_level.min(63), dc_category)
|
||||
}
|
||||
|
||||
/// coeffs( ): returns eob.
|
||||
fn coeffs(&mut self, plane: usize, start_x: usize, start_y: usize, tx_sz: usize) -> usize {
|
||||
let x4 = start_x >> 2;
|
||||
@@ -1788,8 +1707,32 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
|
||||
}
|
||||
self.plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4);
|
||||
let scan = self.get_scan(tx_sz);
|
||||
let eob_multisize = (TX_WIDTH_LOG2[tx_sz] as usize).min(5) + (TX_HEIGHT_LOG2[tx_sz] as usize).min(5) - 4;
|
||||
let tx_class = get_tx_class(self.plane_tx_type);
|
||||
eob = self.read_eob(tx_sz, tx_sz_ctx, ptype, tx_class);
|
||||
let ectx = (tx_class != TX_CLASS_2D) as usize;
|
||||
let eob_pt = 1 + match eob_multisize {
|
||||
0 => self.sd.read_symbol(&mut self.cdf.eob_pt_16[ptype][ectx]),
|
||||
1 => self.sd.read_symbol(&mut self.cdf.eob_pt_32[ptype][ectx]),
|
||||
2 => self.sd.read_symbol(&mut self.cdf.eob_pt_64[ptype][ectx]),
|
||||
3 => self.sd.read_symbol(&mut self.cdf.eob_pt_128[ptype][ectx]),
|
||||
4 => self.sd.read_symbol(&mut self.cdf.eob_pt_256[ptype][ectx]),
|
||||
5 => self.sd.read_symbol(&mut self.cdf.eob_pt_512[ptype]),
|
||||
_ => self.sd.read_symbol(&mut self.cdf.eob_pt_1024[ptype]),
|
||||
};
|
||||
eob = if eob_pt < 2 { eob_pt } else { (1 << (eob_pt - 2)) + 1 };
|
||||
let eob_shift = eob_pt as i32 - 3;
|
||||
if eob_shift >= 0 {
|
||||
let extra = self.sd.read_symbol(&mut self.cdf.eob_extra[tx_sz_ctx][ptype][eob_pt - 3]);
|
||||
if extra == 1 {
|
||||
eob += 1 << eob_shift;
|
||||
}
|
||||
for i in 1..(eob_pt as i32 - 2).max(0) {
|
||||
let sh = (eob_pt as i32 - 2).max(0) - 1 - i;
|
||||
if self.sd.read_literal(1) == 1 {
|
||||
eob += 1 << sh;
|
||||
}
|
||||
}
|
||||
}
|
||||
let adj = ADJUSTED_TX_SIZE[tx_sz] as usize;
|
||||
let bwl = TX_WIDTH_LOG2[adj] as usize;
|
||||
let height = TX_HEIGHT[adj] as usize;
|
||||
@@ -1837,7 +1780,45 @@ impl<'a, 'f> TileDecoder<'a, 'f> {
|
||||
lev[li] = level as u8;
|
||||
}
|
||||
self.levels = lev;
|
||||
(cul_level, dc_category) = self.read_signs_and_golomb(plane, (x4, y4, w4, h4), &scan[..eob]);
|
||||
for c in 0..eob {
|
||||
let pos = scan[c] as usize;
|
||||
let sign = if self.quant[pos] != 0 {
|
||||
if c == 0 {
|
||||
let ctx = self.dc_sign_ctx(plane, x4, y4, w4, h4);
|
||||
self.sd.read_symbol(&mut self.cdf.dc_sign[ptype][ctx]) == 1
|
||||
} else {
|
||||
self.sd.read_literal(1) == 1
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if self.quant[pos] > (NUM_BASE_LEVELS + COEFF_BASE_RANGE) as i32 {
|
||||
let mut length = 0;
|
||||
loop {
|
||||
length += 1;
|
||||
if self.sd.read_literal(1) == 1 {
|
||||
break;
|
||||
}
|
||||
if length > 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut x: u32 = 1;
|
||||
for _ in (0..length - 1).rev() {
|
||||
x = (x << 1) | self.sd.read_literal(1);
|
||||
}
|
||||
self.quant[pos] = (x as i64 + COEFF_BASE_RANGE as i64 + NUM_BASE_LEVELS as i64).min(i32::MAX as i64) as i32;
|
||||
}
|
||||
if pos == 0 && self.quant[pos] > 0 {
|
||||
dc_category = if sign { 1 } else { 2 };
|
||||
}
|
||||
self.quant[pos] &= 0xFFFFF;
|
||||
cul_level += self.quant[pos] as u32;
|
||||
if sign {
|
||||
self.quant[pos] = -self.quant[pos];
|
||||
}
|
||||
}
|
||||
cul_level = cul_level.min(63);
|
||||
}
|
||||
for i in 0..w4 {
|
||||
if x4 + i < self.t.above_level[plane].len() {
|
||||
|
||||
@@ -515,18 +515,8 @@ pub(crate) fn current_shapes<'a>(doc: &'a deckcraft_model::Presentation, sel: &c
|
||||
}
|
||||
}
|
||||
|
||||
/// Every name on the slide, group members included, and the number of shapes (which exceeds the
|
||||
/// number of names when two shapes share one).
|
||||
pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> (std::collections::HashSet<String>, usize) {
|
||||
let mut names = std::collections::HashSet::new();
|
||||
let mut count = 0;
|
||||
if let Some(v) = current_shapes(doc, sel) {
|
||||
deckcraft_model::walk(v, &mut |sh, _| {
|
||||
names.insert(sh.name.clone());
|
||||
count += 1;
|
||||
});
|
||||
}
|
||||
(names, count)
|
||||
pub(crate) fn current_names(doc: &deckcraft_model::Presentation, sel: &crate::Selection) -> std::collections::HashSet<String> {
|
||||
current_shapes(doc, sel).map(|v| v.iter().map(|s| s.name.clone()).collect()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The command palette runs a command with `{}`. True when the params doc names a value that
|
||||
|
||||
@@ -339,10 +339,7 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = match opt_usize_param(p, "index", "animation.clear")? {
|
||||
Some(i) => i,
|
||||
None => s.doc()?.selection.slide,
|
||||
};
|
||||
let i = usize_param(p, "index").unwrap_or(s.doc()?.selection.slide);
|
||||
s.edit(|doc, _| {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).animations.clear();
|
||||
@@ -437,19 +434,6 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn clear_rejects_invalid_index() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]})).unwrap();
|
||||
let id = r.get("id").unwrap().as_u64().unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id]})).unwrap();
|
||||
for bad_index in [json!(-1), json!(1.5)] {
|
||||
assert!(s.execute("animation.clear", &json!({"index": bad_index})).is_err());
|
||||
let left = s.execute("animation.get", &json!({})).unwrap();
|
||||
assert_eq!(left.as_array().unwrap().len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_painter_copies_effects_between_shapes() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -229,98 +229,6 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// After an edit: refit every group whose children's geometry changed to the union of its
|
||||
/// children (PowerPoint keeps a group's box on its children, so moving one child grows or
|
||||
/// shrinks the group). The children keep their place on the slide: the group's box and its child
|
||||
/// space change together. `old` is the presentation before the edit; only slides the edit
|
||||
/// touched are looked at, and a group whose own box changed (it was moved or resized) is left to
|
||||
/// that edit.
|
||||
pub(crate) fn refit_groups(old: &deckcraft_model::Presentation, new: &mut deckcraft_model::Presentation) {
|
||||
// Each slide, master and layout is compared with its own earlier state, found by id: a move,
|
||||
// insertion or deletion shifts the indices, and shape ids repeat across slides.
|
||||
let old_slides: std::collections::HashMap<_, _> = old.slides.iter().map(|s| (s.id, s)).collect();
|
||||
for slide in new.slides.iter_mut() {
|
||||
let Some(before) = old_slides.get(&slide.id) else { continue };
|
||||
if std::sync::Arc::ptr_eq(before, slide) {
|
||||
continue;
|
||||
}
|
||||
let shapes = &mut std::sync::Arc::make_mut(slide).shapes;
|
||||
refit_list(Some(&before.shapes), shapes, 0);
|
||||
}
|
||||
// A child edited in Slide Master, layout, notes or handout view refits its group there too.
|
||||
for master in new.masters.iter_mut() {
|
||||
if let Some(before) = old.masters.iter().find(|m| m.id == master.id)
|
||||
&& !std::sync::Arc::ptr_eq(before, master)
|
||||
{
|
||||
refit_master(before, std::sync::Arc::make_mut(master));
|
||||
}
|
||||
}
|
||||
for (before, after) in [(old.notes_master.as_ref(), new.notes_master.as_mut()), (old.handout_master.as_ref(), new.handout_master.as_mut())] {
|
||||
if let (Some(before), Some(after)) = (before, after)
|
||||
&& !std::sync::Arc::ptr_eq(before, after)
|
||||
{
|
||||
refit_master(before, std::sync::Arc::make_mut(after));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refit_master(old: &deckcraft_model::Master, new: &mut deckcraft_model::Master) {
|
||||
refit_list(Some(&old.shapes), &mut new.shapes, 0);
|
||||
for layout in new.layouts.iter_mut() {
|
||||
refit_list(old.layouts.iter().find(|l| l.id == layout.id).map(|l| &l.shapes), &mut layout.shapes, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn refit_list(old: Option<&Vec<Shape>>, list: &mut [Shape], depth: usize) {
|
||||
// Most edits touch a slide without groups: nothing to refit, and no per-shape lookup. Nesting
|
||||
// is bounded like the model's other walks.
|
||||
if depth > 64 || !list.iter().any(|sh| matches!(sh.kind, ShapeKind::Group { .. })) {
|
||||
return;
|
||||
}
|
||||
let by_id: std::collections::HashMap<ShapeId, &Shape> = old.map(|o| o.iter().map(|x| (x.id, x)).collect()).unwrap_or_default();
|
||||
for sh in list.iter_mut() {
|
||||
let before = by_id.get(&sh.id).copied();
|
||||
let old_children = before.and_then(|b| match &b.kind {
|
||||
ShapeKind::Group { children, .. } => Some(children),
|
||||
_ => None,
|
||||
});
|
||||
let own_before = before.map(|b| {
|
||||
(
|
||||
b.xfrm,
|
||||
match &b.kind {
|
||||
ShapeKind::Group { child, .. } => Some(*child),
|
||||
_ => None,
|
||||
},
|
||||
)
|
||||
});
|
||||
let smart = sh.diagram.is_some();
|
||||
let ShapeKind::Group { children, child } = &mut sh.kind else { continue };
|
||||
refit_list(old_children, children, depth + 1);
|
||||
if smart {
|
||||
// A SmartArt graphic's box is its own; its shapes are regenerated inside it.
|
||||
continue;
|
||||
}
|
||||
let (Some(old_children), Some((old_x, old_child))) = (old_children, own_before) else { continue };
|
||||
if old_x != sh.xfrm || old_child != Some(*child) || children.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let same = old_children.len() == children.len() && old_children.iter().zip(children.iter()).all(|(a, b)| a.id == b.id && a.xfrm == b.xfrm);
|
||||
if same {
|
||||
continue;
|
||||
}
|
||||
let Some(group) = sh.xfrm else { continue };
|
||||
let xs: Vec<Xfrm> = children.iter().filter_map(|c| c.xfrm).collect();
|
||||
let Some(u) = union_bounds(&xs) else { continue };
|
||||
if ![u.x0, u.y0, u.x1, u.y1].iter().all(|v| v.is_finite()) {
|
||||
continue;
|
||||
}
|
||||
let union = Xfrm::new(u.x0, u.y0, u.width(), u.height());
|
||||
let placed = child_to_parent(&group, child, &union);
|
||||
sh.xfrm = Some(Xfrm { x: placed.x, y: placed.y, w: placed.w, h: placed.h, rot: group.rot, flip_h: group.flip_h, flip_v: group.flip_v });
|
||||
*child = union;
|
||||
}
|
||||
}
|
||||
|
||||
/// A child's xfrm in slide space, given its group.
|
||||
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 };
|
||||
|
||||
@@ -214,10 +214,6 @@ fn preserve_and_placeholders_edit_the_open_master_or_layout() {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(has_footers(&s));
|
||||
|
||||
master_view_closed_rejects(&mut s, blank);
|
||||
}
|
||||
|
||||
fn master_view_closed_rejects(s: &mut Session, blank: usize) {
|
||||
s.execute("view.closeMaster", &json!({})).unwrap();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
let err = s.execute("master.preserve", &json!({})).unwrap_err();
|
||||
@@ -228,15 +224,11 @@ fn master_view_closed_rejects(s: &mut Session, blank: usize) {
|
||||
assert!(err.to_string().contains("give title or footers"), "{err}");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
|
||||
layout_placeholders_edit_one_undo_step(s, blank);
|
||||
}
|
||||
|
||||
fn layout_placeholders_edit_one_undo_step(s: &mut Session, blank: usize) {
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": blank})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.target, Target::Layout { master: 0, layout: blank });
|
||||
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
assert!(!has_title(s));
|
||||
assert!(has_footers(s));
|
||||
assert!(!has_title(&s));
|
||||
assert!(has_footers(&s));
|
||||
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("master.placeholders", &json!({"title": false, "footers": true})).unwrap();
|
||||
@@ -245,19 +237,19 @@ fn layout_placeholders_edit_one_undo_step(s: &mut Session, blank: usize) {
|
||||
s.execute("master.placeholders", &json!({"title": true, "footers": false})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "both keys are one undo step");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Layout Placeholders");
|
||||
assert!(has_title(s));
|
||||
assert!(!has_footers(s));
|
||||
let title = shapes(s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
assert!(has_title(&s));
|
||||
assert!(!has_footers(&s));
|
||||
let title = shapes(&s).into_iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
assert_eq!(title.name, "Title 1");
|
||||
assert_eq!(title.ph.unwrap().idx, 0);
|
||||
assert!(title.text.unwrap().paragraphs.iter().all(|para| para.runs.is_empty()));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(!has_title(s));
|
||||
assert!(has_footers(s));
|
||||
assert!(!has_title(&s));
|
||||
assert!(has_footers(&s));
|
||||
|
||||
s.execute("master.placeholders", &json!({"footers": false})).unwrap();
|
||||
s.execute("master.placeholders", &json!({"footers": true})).unwrap();
|
||||
let restored = shapes(s);
|
||||
let restored = shapes(&s);
|
||||
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Date)));
|
||||
assert!(restored.iter().any(|sh| sh.ph_type() == Some(PhType::Footer)));
|
||||
let num = restored.iter().find(|sh| sh.ph_type() == Some(PhType::SlideNum)).unwrap();
|
||||
@@ -272,10 +264,6 @@ fn layout_placeholders_edit_one_undo_step(s: &mut Session, blank: usize) {
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.masters[0].layouts[blank].preserve);
|
||||
|
||||
missing_title_placeholder_is_recreated(s);
|
||||
}
|
||||
|
||||
fn missing_title_placeholder_is_recreated(s: &mut Session) {
|
||||
s.edit(|doc, _| {
|
||||
let m = std::sync::Arc::make_mut(doc.masters.get_mut(0).unwrap());
|
||||
m.shapes.retain(|sh| !sh.ph_type().is_some_and(PhType::is_title));
|
||||
@@ -283,7 +271,7 @@ fn missing_title_placeholder_is_recreated(s: &mut Session) {
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("master.placeholders", &json!({"title": true})).unwrap();
|
||||
let title = shapes(s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap();
|
||||
let title = shapes(&s).into_iter().find(|sh| sh.ph_type() == Some(PhType::Title)).unwrap();
|
||||
assert_eq!(title.name, "Title");
|
||||
let box_ = title.xfrm.unwrap();
|
||||
assert!((box_.x - 60.0).abs() < 0.01 && (box_.y - 29.0).abs() < 0.01);
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use super::{
|
||||
Arc, Result, Session, Value, bad, builtin_color_schemes, builtin_font_schemes, builtin_format_schemes, json, master_index, ok, str_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn colors(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name_v = p.get("name");
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json,
|
||||
master_index, ok, opt_usize_param, rect_param, str_param, usize_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value,
|
||||
f64_param, ok, usize_param, with_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "copy", false) {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Arc, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, TextBody, Value, bad, bool_or, bool_param, defaults, f64_param,
|
||||
json, ok, str_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn scale_shapes(shapes: &mut [Shape], sx: f64, sy: f64, k: f64, ox: f64, oy: f64) {
|
||||
for sh in shapes {
|
||||
|
||||
@@ -95,10 +95,7 @@ fn colors_dialog_apply_and_reject() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Colors");
|
||||
s.execute("design.colors", &json!({"name": "Harbor"})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "the same scheme is not an undo step");
|
||||
colors_dialog_rejects(&mut s, n, &harbor);
|
||||
}
|
||||
|
||||
fn colors_dialog_rejects(s: &mut Session, n: usize, harbor: &deckcraft_color::ColorScheme) {
|
||||
let err = s.execute("design.colors", &json!({"name": "Nope"})).unwrap_err().to_string();
|
||||
assert!(err.contains("no colour scheme `Nope`"), "{err}");
|
||||
assert!(s.execute("design.colors", &json!({"name": " "})).is_err());
|
||||
@@ -111,35 +108,32 @@ fn colors_dialog_rejects(s: &mut Session, n: usize, harbor: &deckcraft_color::Co
|
||||
assert!(s.execute("design.colors", &json!({"colors": {"nope": "#112233"}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"colors": {"accent1": "zz"}})).is_err());
|
||||
assert!(s.execute("design.colors", &json!({"name": "Harbor", "colors": {}})).is_err());
|
||||
assert_eq!(scheme(s).name, "Harbor");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1));
|
||||
assert_eq!(undo_len(s), n + 1);
|
||||
colors_dialog_custom_overrides(s, n, harbor);
|
||||
}
|
||||
assert_eq!(scheme(&s).name, "Harbor");
|
||||
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), harbor.get(SchemeSlot::Accent1));
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
fn colors_dialog_custom_overrides(s: &mut Session, n: usize, harbor: &deckcraft_color::ColorScheme) {
|
||||
s.execute("design.colors", &json!({"colors": {"accent1": "112233"}})).unwrap();
|
||||
assert_eq!(scheme(s).name, "Custom");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent1).hex(), "112233");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2));
|
||||
assert_eq!(undo_len(s), n + 2);
|
||||
assert_eq!(scheme(&s).name, "Custom");
|
||||
assert_eq!(scheme(&s).get(SchemeSlot::Accent1).hex(), "112233");
|
||||
assert_eq!(scheme(&s).get(SchemeSlot::Accent2), harbor.get(SchemeSlot::Accent2));
|
||||
assert_eq!(undo_len(&s), n + 2);
|
||||
|
||||
let meadow = builtin_color_schemes().into_iter().find(|c| c.name == "Meadow").unwrap();
|
||||
s.execute("design.colors", &json!({"name": "Meadow", "colors": {"lt1": "#010203"}})).unwrap();
|
||||
assert_eq!(scheme(s).name, "Meadow");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Lt1).hex(), "010203");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
|
||||
assert_eq!(undo_len(s), n + 3);
|
||||
assert_eq!(scheme(&s).name, "Meadow");
|
||||
assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203");
|
||||
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
|
||||
assert_eq!(undo_len(&s), n + 3);
|
||||
|
||||
s.execute("design.colors", &json!({"name": "Talk", "colors": {"dk1": "#ABCDEF"}})).unwrap();
|
||||
assert_eq!(scheme(s).name, "Talk");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Dk1).hex(), "ABCDEF");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Lt1).hex(), "010203");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
|
||||
assert_eq!(scheme(&s).name, "Talk");
|
||||
assert_eq!(scheme(&s).get(SchemeSlot::Dk1).hex(), "ABCDEF");
|
||||
assert_eq!(scheme(&s).get(SchemeSlot::Lt1).hex(), "010203");
|
||||
assert_eq!(scheme(&s).get(SchemeSlot::Accent1), meadow.get(SchemeSlot::Accent1));
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(scheme(s).name, "Meadow");
|
||||
assert_eq!(scheme(s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1));
|
||||
assert_eq!(scheme(&s).name, "Meadow");
|
||||
assert_eq!(scheme(&s).get(SchemeSlot::Dk1), meadow.get(SchemeSlot::Dk1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -181,10 +175,7 @@ fn fonts_dialog_apply_and_reject() {
|
||||
assert!(s.execute("design.fonts", &json!({"name": "Classic", "major": ""})).is_err());
|
||||
assert_eq!(font_scheme(&s), classic);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
fonts_dialog_custom_overrides(&mut s);
|
||||
}
|
||||
|
||||
fn fonts_dialog_custom_overrides(s: &mut Session) {
|
||||
s.edit(|doc, _| {
|
||||
let fonts = &mut std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts;
|
||||
fonts.major.ea = "EA Major".into();
|
||||
@@ -193,33 +184,33 @@ fn fonts_dialog_custom_overrides(s: &mut Session) {
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let marked = font_scheme(s);
|
||||
let n = undo_len(s);
|
||||
let marked = font_scheme(&s);
|
||||
let n = undo_len(&s);
|
||||
|
||||
s.execute("design.fonts", &json!({"major": " Lato "})).unwrap();
|
||||
let custom = font_scheme(s);
|
||||
let custom = font_scheme(&s);
|
||||
assert_eq!(custom.name, "Custom");
|
||||
assert_eq!(custom.major.latin, "Lato");
|
||||
assert_eq!(custom.minor.latin, "Lato", "an omitted minor copies major");
|
||||
assert_eq!(custom.major.ea, "EA Major");
|
||||
assert_eq!(custom.minor.cs, "CS Minor");
|
||||
assert_eq!(custom.major.scripts, marked.major.scripts);
|
||||
assert_eq!(undo_len(s), n + 1);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
s.execute("design.fonts", &json!({"minor": "Roboto"})).unwrap();
|
||||
assert_eq!(font_scheme(s).name, "Custom");
|
||||
assert_eq!(font_scheme(s).major.latin, "Lato");
|
||||
assert_eq!(font_scheme(s).minor.latin, "Roboto");
|
||||
assert_eq!(font_scheme(s).major.ea, "EA Major");
|
||||
assert_eq!(undo_len(s), n + 2);
|
||||
assert_eq!(font_scheme(&s).name, "Custom");
|
||||
assert_eq!(font_scheme(&s).major.latin, "Lato");
|
||||
assert_eq!(font_scheme(&s).minor.latin, "Roboto");
|
||||
assert_eq!(font_scheme(&s).major.ea, "EA Major");
|
||||
assert_eq!(undo_len(&s), n + 2);
|
||||
|
||||
s.execute("design.fonts", &json!({"name": "Classic", "major": "Inter"})).unwrap();
|
||||
assert_eq!(font_scheme(s).name, "Classic");
|
||||
assert_eq!(font_scheme(s).major.latin, "Inter");
|
||||
assert_eq!(font_scheme(s).minor.latin, "Open Sans");
|
||||
assert_eq!(font_scheme(s).major.ea, "");
|
||||
assert!(font_scheme(s).major.scripts.is_empty(), "a builtin is applied before the latin overlay");
|
||||
assert_eq!(undo_len(s), n + 3);
|
||||
assert_eq!(font_scheme(&s).name, "Classic");
|
||||
assert_eq!(font_scheme(&s).major.latin, "Inter");
|
||||
assert_eq!(font_scheme(&s).minor.latin, "Open Sans");
|
||||
assert_eq!(font_scheme(&s).major.ea, "");
|
||||
assert!(font_scheme(&s).major.scripts.is_empty(), "a builtin is applied before the latin overlay");
|
||||
assert_eq!(undo_len(&s), n + 3);
|
||||
|
||||
s.edit(|doc, _| {
|
||||
std::sync::Arc::make_mut(&mut doc.masters[0]).theme.fonts.major.ea = "Kept".into();
|
||||
@@ -227,16 +218,16 @@ fn fonts_dialog_custom_overrides(s: &mut Session) {
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("design.fonts", &json!({"name": "Talk", "minor": "Nunito Sans"})).unwrap();
|
||||
assert_eq!(font_scheme(s).name, "Talk");
|
||||
assert_eq!(font_scheme(s).major.latin, "Inter");
|
||||
assert_eq!(font_scheme(s).minor.latin, "Nunito Sans");
|
||||
assert_eq!(font_scheme(s).major.ea, "Kept");
|
||||
assert_eq!(font_scheme(&s).name, "Talk");
|
||||
assert_eq!(font_scheme(&s).major.latin, "Inter");
|
||||
assert_eq!(font_scheme(&s).minor.latin, "Nunito Sans");
|
||||
assert_eq!(font_scheme(&s).major.ea, "Kept");
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Fonts");
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(font_scheme(s).name, "Classic");
|
||||
assert_eq!(font_scheme(s).major.ea, "Kept");
|
||||
assert_eq!(font_scheme(s).minor.latin, "Open Sans");
|
||||
assert_eq!(font_scheme(&s).name, "Classic");
|
||||
assert_eq!(font_scheme(&s).major.ea, "Kept");
|
||||
assert_eq!(font_scheme(&s).minor.latin, "Open Sans");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -321,7 +321,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut shapes = clip.shapes.clone();
|
||||
reid("edit.paste", doc, &mut shapes)?;
|
||||
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
|
||||
let (mut taken, mut count) = super::current_names(doc, sel);
|
||||
let mut taken = super::current_names(doc, sel);
|
||||
for sh in &mut shapes {
|
||||
// Only a name already on the slide is changed (two shapes with one name are what the
|
||||
// issue is about); otherwise it stays, as Morph pairs shapes across slides by name.
|
||||
@@ -330,12 +330,9 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& !n.is_empty()
|
||||
&& n.bytes().all(|b| b.is_ascii_digit())
|
||||
{
|
||||
sh.name = super::insert::first_free_name(base, &taken, count);
|
||||
sh.name = super::insert::first_free_name(base, &taken);
|
||||
}
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |m, _| {
|
||||
taken.insert(m.name.clone());
|
||||
count += 1;
|
||||
});
|
||||
taken.insert(sh.name.clone());
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x += off;
|
||||
x.y += off;
|
||||
@@ -511,19 +508,6 @@ fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
paste_plain(s, &t)
|
||||
}
|
||||
|
||||
/// Duplicate gives a copy the next automatic name ("Rectangle 4" after "Rectangle 1"), as
|
||||
/// PowerPoint does, and keeps a name the user chose. PowerPoint tells the two apart by comparing
|
||||
/// the number with the shape's id; ids here are numbered across the deck, so any "{kind} {n}"
|
||||
/// counts as automatic.
|
||||
fn rename_automatic(sh: &mut Shape, taken: &mut std::collections::HashSet<String>, count: usize) {
|
||||
let base = if sh.is_group() { "Group".to_string() } else { super::insert::auto_name_base(sh) };
|
||||
let n = sh.name.strip_prefix(&base).and_then(|r| r.strip_prefix(' '));
|
||||
if n.is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) {
|
||||
sh.name = super::insert::first_free_name(&base, taken, count);
|
||||
taken.insert(sh.name.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let off = s.prefs.duplicate_offset;
|
||||
@@ -548,17 +532,6 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
.unwrap_or((off, off));
|
||||
reid("edit.duplicate", doc, &mut copies)?;
|
||||
// Every name on the slide, group members included, so a copy never repeats one.
|
||||
let mut taken = std::collections::HashSet::new();
|
||||
let mut count = 0;
|
||||
deckcraft_model::walk(&list, &mut |sh, _| {
|
||||
taken.insert(sh.name.clone());
|
||||
count += 1;
|
||||
});
|
||||
walk_mut(&mut copies, &mut |c| {
|
||||
rename_automatic(c, &mut taken, count);
|
||||
count += 1;
|
||||
});
|
||||
for (c, b) in copies.iter_mut().zip(boxes) {
|
||||
let mut x = b;
|
||||
x.x += dx;
|
||||
@@ -567,6 +540,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if sel.target == crate::Target::Slides {
|
||||
c.ph = None;
|
||||
}
|
||||
if !c.name.is_empty() {
|
||||
c.name = format!("{} copy", c.name.trim_end_matches(" copy"));
|
||||
}
|
||||
}
|
||||
let new_ids: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use deckcraft_media::MediaInfo;
|
||||
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, opt_usize_param, str_param, usize_param};
|
||||
use super::{CommandSpec, always, bad, base64_decode, base64_encode, bool_or, cmd, f64_or, has_doc, ok, str_param, usize_param};
|
||||
use crate::{DocState, EngineError, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -395,10 +395,6 @@ pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
|
||||
Some((a, b)) => (a.trim(), b.trim()),
|
||||
None => (part, part),
|
||||
};
|
||||
let digits = |s: &str| !s.is_empty() && s.bytes().all(|c| c.is_ascii_digit());
|
||||
if !digits(a) || !digits(b) {
|
||||
return Err(invalid());
|
||||
}
|
||||
let (a, b) = (a.parse::<usize>().map_err(|_| invalid())?, b.parse::<usize>().map_err(|_| invalid())?);
|
||||
let (lo, hi) = (a.min(b), a.max(b));
|
||||
if lo == 0 || hi > count {
|
||||
@@ -540,7 +536,7 @@ fn picture_bytes(img: &deckcraft_render::Image, format: &str, scale: f64) -> Vec
|
||||
|
||||
fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = opt_usize_param(p, "slide", "file.render")?.unwrap_or(st.selection.slide);
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("file.render", format!("no slide {i}")));
|
||||
}
|
||||
@@ -551,7 +547,7 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// The colour the slide is drawn with at one point: what the Eyedropper takes.
|
||||
fn color_at(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = opt_usize_param(p, "slide", "slide.colorAt")?.unwrap_or(st.selection.slide);
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("slide.colorAt", format!("no slide {i}")));
|
||||
}
|
||||
@@ -582,7 +578,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
match format.as_str() {
|
||||
"png" | "jpeg" | "jpg" => {
|
||||
let slides: Vec<usize> = match opt_usize_param(p, "slide", "file.export")? {
|
||||
let slides: Vec<usize> = match usize_param(p, "slide") {
|
||||
Some(i) => vec![i],
|
||||
None if bool_or(p, "all", false) => (0..st.doc.slides.len()).collect(),
|
||||
None => vec![st.selection.slide],
|
||||
@@ -767,14 +763,7 @@ mod color_at_tests;
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::{parse_slide_range, write_file};
|
||||
|
||||
#[test]
|
||||
fn slide_range_rejects_plus_sign() {
|
||||
assert!(parse_slide_range("+2", 3).is_err());
|
||||
assert!(parse_slide_range("+1-+3", 3).is_err());
|
||||
assert_eq!(parse_slide_range("1-3", 3).unwrap(), vec![0, 1, 2]);
|
||||
}
|
||||
use super::write_file;
|
||||
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-write-file-{}-{name}", std::process::id()));
|
||||
|
||||
@@ -179,7 +179,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Numbering",
|
||||
["Home", "Paragraph"],
|
||||
None,
|
||||
"{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n (a numbered paragraph whose start differs from that of the last numbered paragraph at its level begins a new run of numbers at n, as PowerPoint's Start at does), color?, size?: % of text}",
|
||||
"{on?: bool, scheme?: arabicPeriod|arabicParenR|romanUcPeriod|romanLcPeriod|alphaUcPeriod|alphaLcParenR|alphaLcPeriod, start?: n, color?, size?: % of text}",
|
||||
has_text_or_shapes,
|
||||
numbering
|
||||
),
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Arc, Caps, ColorRef, ColorTransform, Fill, Result, RunProps, SchemeSlot, Session, Shape, ShapeId, ShapeKind, Strike, Value, bad, bool_or,
|
||||
bool_param, color_param, current_run, ed, edit_shapes, f64_param, format_run, json, ok, on_param, resolve, str_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn bold(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = on_param(p, current_run(s).bold);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Bullet, Effects, Fill, Line, ParaProps, Result, RunProps, Session, Shape, Strike, Value, bad, base64_decode, bool_or, current_para, current_run,
|
||||
ed, edit_shapes, format_run, ids_param, json, ok, resolve, str_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// Formatting picked up by the Format Painter.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Align, Anchor, AutoFit, Bullet, Result, Session, Spacing, TextDir, Value, bad, bool_or, color_param, current_para, f64_or, f64_param,
|
||||
format_body, format_para, json, ok, on_param, resolve, str_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn align_to(s: &mut Session, a: Align) -> Result<Value> {
|
||||
format_para(s, &move |p| p.props.align = Some(a))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::paragraph::{clamp_pt, finite_key, tab_align, tabs_params};
|
||||
use super::{Result, Session, TabStop, TextDir, Value, bad, bool_or, current_para, format_para, json, ok, resolve};
|
||||
use super::*;
|
||||
|
||||
/// What the horizontal ruler shows while editing text: the paragraph's indents and tab stops,
|
||||
/// and where its text area sits on the slide.
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use super::{
|
||||
Line, Result, Session, Value, bad, color_param, current_run, ed, edit_shapes, f64_or, format_body, format_run, json, ok, on_param, str_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn text_shadow(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = on_param(p, Some(current_run(s).shadow.is_some()));
|
||||
|
||||
@@ -8,7 +8,7 @@ use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{CommandSpec, bad, cmd, has_slide, opt_usize_param, str_param, xfrm_of};
|
||||
use super::{CommandSpec, bad, cmd, has_slide, str_param, usize_param, xfrm_of};
|
||||
use crate::{Result, Selection, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
@@ -245,13 +245,13 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st
|
||||
|
||||
fn ideas_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let i = opt_usize_param(p, "slide", "design.ideas")?.unwrap_or(st.selection.slide);
|
||||
let i = usize_param(p, "slide").unwrap_or(st.selection.slide);
|
||||
Ok(json!(ideas_for(&st.doc, i).iter().map(|(id, label)| json!({"id": id, "label": label})).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
fn apply_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = str_param(p, "id").ok_or_else(|| bad("design.applyIdea", "missing `id`"))?.to_string();
|
||||
let slide = opt_usize_param(p, "slide", "design.applyIdea")?;
|
||||
let slide = usize_param(p, "slide");
|
||||
s.edit(|doc, sel| {
|
||||
let i = slide.unwrap_or(sel.slide);
|
||||
apply_idea(doc, sel, i, &id)?;
|
||||
@@ -372,19 +372,6 @@ mod tests {
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_slide_index_is_refused_not_replaced_by_the_selection() {
|
||||
let mut s = deck(false);
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
for bad_slide in [json!(-1), json!(1.5), json!("0")] {
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "centred", "slide": bad_slide})).is_err(), "{bad_slide}");
|
||||
assert!(s.execute("design.ideas", &json!({"slide": bad_slide})).is_err(), "{bad_slide}");
|
||||
}
|
||||
assert!(Arc::ptr_eq(&before, &s.doc().unwrap().doc) || *before == *s.doc().unwrap().doc);
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "centred", "slide": 0})).is_ok());
|
||||
assert!(s.execute("design.ideas", &json!({})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_from_full_bleed_removes_the_title_band() {
|
||||
let mut s = deck(true);
|
||||
|
||||
@@ -98,7 +98,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Date & Time",
|
||||
["Insert", "Text"],
|
||||
None,
|
||||
"{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog; in the Handout or Notes Master view it fills the selected date placeholder (else the master's own) and answers where: \"master\"",
|
||||
"{format?: datetime1..datetime13, update?: bool, language?: en-US|en-GB|fr-FR|ja-JP|sv-SE} — no keys opens the dialog",
|
||||
has_slide,
|
||||
date_time
|
||||
),
|
||||
@@ -187,10 +187,9 @@ pub(crate) fn auto_name_base(shape: &Shape) -> String {
|
||||
}
|
||||
|
||||
/// "{base} n" with the first `n` from the shape count + 1 that no shape on the slide already uses,
|
||||
/// so a gap left by a deleted shape never makes a second shape with the same name. `count` is the
|
||||
/// number of shapes, which exceeds the number of names when two shapes share one.
|
||||
pub(crate) fn first_free_name(base: &str, taken: &std::collections::HashSet<String>, count: usize) -> String {
|
||||
(count + 1..).map(|n| format!("{base} {n}")).find(|c| !taken.contains(c)).unwrap_or_default()
|
||||
/// so a gap left by a deleted shape never makes a second shape with the same name.
|
||||
pub(crate) fn first_free_name(base: &str, taken: &std::collections::HashSet<String>) -> String {
|
||||
(taken.len() + 1..).map(|n| format!("{base} {n}")).find(|c| !taken.contains(c)).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bool) -> Result<ShapeId> {
|
||||
@@ -198,8 +197,8 @@ pub(crate) fn add_shape(s: &mut Session, cmd: &str, mut shape: Shape, select: bo
|
||||
shape.id = ShapeId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
if shape.name.is_empty() {
|
||||
let base = auto_name_base(&shape);
|
||||
let (taken, count) = super::current_names(doc, sel);
|
||||
shape.name = first_free_name(&base, &taken, count);
|
||||
let taken = super::current_names(doc, sel);
|
||||
shape.name = first_free_name(&base, &taken);
|
||||
}
|
||||
let id = shape.id;
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("insert", "no slide"))?;
|
||||
@@ -450,17 +449,6 @@ mod tests {
|
||||
assert_eq!(undo(&s), before + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_british_date_puts_the_day_first() {
|
||||
let mut s = session();
|
||||
edit_title(&mut s);
|
||||
let v = s.execute("insert.dateTime", &json!({"format": "datetime4", "update": false, "language": "en-GB"})).unwrap();
|
||||
let text = v["text"].as_str().unwrap().to_string();
|
||||
assert!(text.chars().next().is_some_and(|c| c.is_ascii_digit()) && !text.contains(','), "{text}");
|
||||
let v = s.execute("insert.dateTime", &json!({"format": "datetime4", "update": false, "language": "en-US"})).unwrap();
|
||||
assert!(v["text"].as_str().is_some_and(|t| t.contains(',')), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_named_format_in_french_and_a_bad_value_change_nothing() {
|
||||
let mut s = session();
|
||||
@@ -485,70 +473,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// ZZ1: in the handout and notes master views the date goes into the master's date placeholder
|
||||
/// (not the current slide), so the exported handout and notes pages show it.
|
||||
#[test]
|
||||
fn on_a_page_master_the_date_fills_its_date_placeholder() {
|
||||
for view in ["view.handoutMaster", "view.notesMaster"] {
|
||||
let mut s = session();
|
||||
s.execute(view, &json!({})).unwrap();
|
||||
let (id, master) = {
|
||||
let st = s.doc().unwrap();
|
||||
let m = crate::page_master(&st.doc, st.selection.target).unwrap().clone();
|
||||
(m.shapes.iter().find(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)).unwrap().id.0, m)
|
||||
};
|
||||
assert!(master.shapes.iter().find(|x| x.id.0 == id).and_then(|x| x.text.as_ref()).is_none_or(|t| t.text().is_empty()));
|
||||
s.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
let v = s.execute("insert.dateTime", &json!({"format": "datetime10", "update": true})).unwrap();
|
||||
assert_eq!(v["where"], "master", "{view}: {v}");
|
||||
assert!(date_run(&s).is_none(), "the slide keeps no date");
|
||||
let st = s.doc().unwrap();
|
||||
let m = crate::page_master(&st.doc, st.selection.target).unwrap();
|
||||
let run = m
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|x| x.id.0 == id)
|
||||
.and_then(|x| x.text.as_ref())
|
||||
.and_then(|t| t.paragraphs.first())
|
||||
.and_then(|p| p.runs.first())
|
||||
.cloned()
|
||||
.unwrap();
|
||||
assert!(matches!(run.kind, RunKind::Field { ref field } if field == "datetime10"), "{view}: {run:?}");
|
||||
let placed = deckcraft_render::place_master_texts(&st.doc, m, 1);
|
||||
assert!(placed.iter().any(|p| p.body.text() == run.text && run.text.contains(':')), "{view}: the page shows the date");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let m = crate::page_master(&st.doc, st.selection.target).unwrap();
|
||||
assert!(m.shapes.iter().find(|x| x.id.0 == id).and_then(|x| x.text.as_ref()).is_none_or(|t| t.text().is_empty()), "one undo");
|
||||
// A fixed date in a placeholder whose run is en-GB reads day first, and the answer
|
||||
// reports the text that was written, not the en-US sample.
|
||||
s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false, "language": "en-GB"})).unwrap();
|
||||
let v = s.execute("insert.dateTime", &json!({"format": "datetime1", "update": false})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let m = crate::page_master(&st.doc, st.selection.target).unwrap();
|
||||
let run = m
|
||||
.shapes
|
||||
.iter()
|
||||
.find(|x| x.id.0 == id)
|
||||
.and_then(|x| x.text.as_ref())
|
||||
.and_then(|t| t.paragraphs.first())
|
||||
.and_then(|p| p.runs.first())
|
||||
.cloned()
|
||||
.unwrap();
|
||||
assert_eq!(run.props.lang.as_deref(), Some("en-GB"), "{view}: {run:?}");
|
||||
assert_eq!(v["text"].as_str(), Some(run.text.as_str()), "{view}: the answer reports the written text: {v}");
|
||||
let now = deckcraft_text::datetime::DateTime::now_local().unwrap();
|
||||
let gb = deckcraft_text::datetime::field_text("datetime1", Some("en-GB"), now);
|
||||
assert_eq!(Some(run.text.as_str()), gb.as_deref(), "{view}: the date in en-GB order (dd/mm/yyyy)");
|
||||
// No date placeholder: refused, nothing changed.
|
||||
let key = if view == "view.handoutMaster" { "handout.placeholders" } else { "notes.placeholders" };
|
||||
s.execute(key, &json!({"date": false})).unwrap();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
assert!(s.execute("insert.dateTime", &json!({"format": "datetime1"})).is_err());
|
||||
assert_eq!(s.doc().unwrap().revision, rev);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_a_caret_the_date_lands_on_this_slide_only() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::picture::{content_type, media_bytes};
|
||||
use super::{NO_ID_SPACE, Result, Session, ShapeKind, Value, bad, base64_encode, json};
|
||||
use super::*;
|
||||
|
||||
const ALBUM_MAX_ITEMS: usize = 48;
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Chart, ChartType, Result, RunKind, Session, Shape, ShapeKind, Value, Xfrm, add_shape, bad, bool_param, color_value, edit_shapes, json,
|
||||
rect_param, str_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub fn chart_type(name: &str) -> ChartType {
|
||||
match name.to_ascii_lowercase().replace(['-', '_', ' '], "").as_str() {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
Geom, Result, SchemeSlot, Session, Shape, ShapeStyle, Value, add_shape, bad, bool_or, bool_param, default_rect, edit_shapes, json, rect_param,
|
||||
str_param, usize_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
EngineError, MediaClip, NO_ID_SPACE, PictureFill, Result, Session, Shape, ShapeKind, Value, Xfrm, add_shape, bad, base64_decode, default_rect,
|
||||
json, rect_param, str_param, xfrm_of,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// Read media bytes from `data` (base64) or `path`.
|
||||
pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec<u8>)> {
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
use super::{
|
||||
ColorRef, Fill, Result, RunKind, SchemeSlot, Session, TextBody, Value, Xfrm, add_shape, bad, bool_or, bool_param, json, new_text_box, ok,
|
||||
str_param, usize_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn word_art(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let text = str_param(p, "text").unwrap_or("Your text here");
|
||||
@@ -152,11 +149,6 @@ fn apply_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
}
|
||||
return Ok(json!({"where": "caret", "format": o.format, "update": o.update, "text": o.sample}));
|
||||
}
|
||||
// Handout or Notes Master view: the master's date placeholder (the selected one, else the
|
||||
// master's own), which every handout or notes page then shows.
|
||||
if s.doc()?.selection.target.is_page_master() {
|
||||
return write_master_date(s, o);
|
||||
}
|
||||
// Not editing: the slide's date placeholder (this slide only). A layout with no date
|
||||
// placeholder gets a text box instead, so the chosen format is still inserted.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "all": false}))?;
|
||||
@@ -166,49 +158,6 @@ fn apply_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
Ok(json!({"where": "footer", "format": o.format, "update": o.update, "text": o.sample}))
|
||||
}
|
||||
|
||||
/// Put the date in the date placeholder of the handout or notes master being edited.
|
||||
fn write_master_date(s: &mut Session, o: &DateOpts) -> Result<Value> {
|
||||
let (format, sample, lang, update) = (o.format.clone(), o.sample.clone(), o.lang.clone(), o.update);
|
||||
let has_date = {
|
||||
let st = s.doc()?;
|
||||
crate::page_master(&st.doc, st.selection.target).is_some_and(|m| m.shapes.iter().any(|x| x.ph_type() == Some(deckcraft_model::PhType::Date)))
|
||||
};
|
||||
if !has_date {
|
||||
return Err(bad(
|
||||
"insert.dateTime",
|
||||
"this master has no date placeholder; show it with handout.placeholders or notes.placeholders {date: true}",
|
||||
));
|
||||
}
|
||||
// The text the master holds afterwards, which the answer reports (it can differ from the
|
||||
// caller's sample when the placeholder's own language formats a fixed date).
|
||||
let mut written = o.sample.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let Some(m) = crate::page_master_mut(doc, sel.target) else { return Ok(false) };
|
||||
let m = std::sync::Arc::make_mut(m);
|
||||
let date = |x: &deckcraft_model::Shape| x.ph_type() == Some(deckcraft_model::PhType::Date);
|
||||
let chosen = sel.shapes.iter().find_map(|id| m.shapes.iter().position(|x| x.id == *id && date(x)));
|
||||
let Some(i) = chosen.or_else(|| m.shapes.iter().position(date)) else { return Ok(false) };
|
||||
let Some(sh) = m.shapes.get_mut(i) else { return Ok(false) };
|
||||
let mut props = sh.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).map(|r| r.props.clone()).unwrap_or_default();
|
||||
// A fixed date reads in the placeholder's own language when the caller named none.
|
||||
let text = match (&lang, &props.lang) {
|
||||
(None, Some(l)) if !update => date_sample(&format, Some(l)).unwrap_or_else(|_| sample.clone()),
|
||||
_ => sample.clone(),
|
||||
};
|
||||
if let Some(lang) = lang {
|
||||
props.lang = Some(lang);
|
||||
}
|
||||
let kind = if update { RunKind::Field { field: format } } else { RunKind::Text };
|
||||
let body = sh.text.clone().unwrap_or_default();
|
||||
let mut para = body.paragraphs.first().cloned().unwrap_or_default();
|
||||
written.clone_from(&text);
|
||||
para.runs = vec![deckcraft_model::Run { text, props, kind }];
|
||||
sh.text = Some(TextBody { paragraphs: vec![para], ..body });
|
||||
Ok(true)
|
||||
})?;
|
||||
Ok(json!({"where": "master", "format": o.format, "update": o.update, "text": written}))
|
||||
}
|
||||
|
||||
/// Point the current slide's date placeholder at this format (a field, or the sample as plain text).
|
||||
fn write_date_placeholder(s: &mut Session, o: &DateOpts) -> Result<bool> {
|
||||
let format = o.format.clone();
|
||||
|
||||
+12
-110
@@ -58,17 +58,9 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text,
|
||||
insert_synonym
|
||||
),
|
||||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare the open deck with another file → {name, changes: [{kind, slide, number, onlyIn?, revised, details}]}; each change is described from the open deck: `onlyIn: \"deck\"` is in the deck and not in the file, `onlyIn: \"file\"` is in the file and not in the deck, `changed` is in both and differs; `slide` is 0-based, `number` the editor's slide number; review.accept takes the FILE's side, so it deletes what is only in the deck (Accept All keeps the deck's last remaining slide rather than deleting it)", has_doc, compare),
|
||||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide (0-based), number (editor's), onlyIn?: deck|file, revised, details}]}; `onlyIn: deck` = only in the open deck, `file` = only in the compared file; accepting takes the file's side", has_doc, revisions),
|
||||
cmd!(
|
||||
"review.accept",
|
||||
"Accept Revision",
|
||||
["Review", "Compare"],
|
||||
None,
|
||||
"{index} | {all: true} — accept the compared file's side of a change (or all): what is only in the file is added, what is only in the open deck is deleted (all: true leaves the deck's last remaining slide in place), a changed slide becomes the file's",
|
||||
comparing,
|
||||
accept
|
||||
),
|
||||
cmd!(noundo "review.compare", "Compare", ["Review", "Compare"], None, "{path} | {name, data: base64} — compare with a revised copy → {name, changes}", has_doc, compare),
|
||||
cmd!(query "review.revisions", "Revisions", [], None, "{} → {name, changes: [{kind: inserted|deleted|changed, slide, revised, details}]}", has_doc, revisions),
|
||||
cmd!("review.accept", "Accept Revision", ["Review", "Compare"], None, "{index} | {all: true}", comparing, accept),
|
||||
cmd!(
|
||||
noundo "review.reject",
|
||||
"Reject Revision",
|
||||
@@ -272,31 +264,19 @@ fn accessibility(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
_ => None,
|
||||
};
|
||||
if let Some(bg) = bg {
|
||||
deckcraft_model::walk(&sl.shapes, &mut |sh, _| {
|
||||
let Some(t) = &sh.text else { return };
|
||||
for sh in &sl.shapes {
|
||||
let Some(t) = &sh.text else { continue };
|
||||
let Some(para) = t.paragraphs.iter().find(|p| !p.is_empty()) else { continue };
|
||||
let rp = deckcraft_model::resolve::run(&ctx, sh, para, ¶.runs.first().map(|r| r.props.clone()).unwrap_or_default());
|
||||
let (fill, _) = deckcraft_model::resolve::fill(&ctx, sh);
|
||||
if fill.as_ref().is_some_and(|f| !f.is_none()) {
|
||||
return;
|
||||
continue;
|
||||
}
|
||||
let low = t
|
||||
.paragraphs
|
||||
.iter()
|
||||
.flat_map(|para| {
|
||||
// A line break or blank run shows no glyphs, so its colour can't be hard to read.
|
||||
para.runs.iter().filter(|run| run.kind != deckcraft_model::text::RunKind::Break && !run.text.trim().is_empty()).map(
|
||||
|run| {
|
||||
let rp = deckcraft_model::resolve::run(&ctx, sh, para, &run.props);
|
||||
deckcraft_model::resolve::text_color(&ctx, &rp).contrast(bg)
|
||||
},
|
||||
)
|
||||
})
|
||||
.find(|contrast| *contrast < 3.0);
|
||||
if let Some(contrast) = low {
|
||||
out.push(
|
||||
json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({contrast:.1}:1)")}),
|
||||
);
|
||||
let fg = deckcraft_model::resolve::text_color(&ctx, &rp);
|
||||
if fg.contrast(bg) < 3.0 {
|
||||
out.push(json!({"slide": i, "shape": sh.id, "name": sh.name, "issue": format!("Hard-to-read text contrast ({:.1}:1)", fg.contrast(bg))}));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -880,81 +860,3 @@ mod spelling_change_range_tests {
|
||||
assert!(matches!(err, EngineError::BadParams { .. }), "{err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod accessibility_contrast_tests {
|
||||
use super::*;
|
||||
use deckcraft_model::text::{Paragraph, Run, RunKind, RunProps};
|
||||
use deckcraft_model::{ColorRef, Fill, Rgba, Shape, ShapeId, ShapeKind, TextBody};
|
||||
use serde_json::json;
|
||||
|
||||
fn run(text: &str, color: Rgba) -> Run {
|
||||
Run::with(text, RunProps { fill: Some(Fill::solid(ColorRef::rgb(color))), ..Default::default() })
|
||||
}
|
||||
|
||||
fn text_shape(id: u32, name: &str, paragraphs: Vec<Paragraph>) -> Shape {
|
||||
Shape { id: ShapeId(id), name: name.into(), text: Some(TextBody { paragraphs, ..Default::default() }), ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contrast_checks_mixed_runs_later_paragraphs_and_group_descendants_once() {
|
||||
let mut s = Session::with_new();
|
||||
s.edit(|doc, _| {
|
||||
let black = Rgba::BLACK;
|
||||
let white = Rgba::WHITE;
|
||||
let mixed = Paragraph { runs: vec![run("visible", black), run("hidden", white)], ..Default::default() };
|
||||
let later = vec![
|
||||
Paragraph { runs: vec![run("visible", black)], ..Default::default() },
|
||||
Paragraph { runs: vec![run("hidden", white)], ..Default::default() },
|
||||
];
|
||||
let child = text_shape(5, "nested text", vec![Paragraph { runs: vec![run("hidden", white)], ..Default::default() }]);
|
||||
let group = Shape {
|
||||
id: ShapeId(4),
|
||||
name: "group".into(),
|
||||
descr: "group of text".into(),
|
||||
kind: ShapeKind::Group {
|
||||
children: vec![Shape {
|
||||
id: ShapeId(7),
|
||||
name: "nested group".into(),
|
||||
descr: "nested group of text".into(),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
|
||||
..Default::default()
|
||||
}],
|
||||
child: Default::default(),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let sl = Arc::make_mut(doc.slides.get_mut(0).ok_or_else(|| bad("test", "no slide"))?);
|
||||
sl.shapes = vec![
|
||||
text_shape(2, "mixed", vec![mixed]),
|
||||
text_shape(3, "later paragraph", later),
|
||||
group,
|
||||
text_shape(6, "black control", vec![Paragraph { runs: vec![run("visible", black)], ..Default::default() }]),
|
||||
text_shape(
|
||||
8,
|
||||
"invisible white runs",
|
||||
vec![Paragraph {
|
||||
runs: vec![run("visible", black), Run { kind: RunKind::Break, ..run("\n", white) }, run(" ", white), run("visible", black)],
|
||||
..Default::default()
|
||||
}],
|
||||
),
|
||||
];
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let issues = s.execute("review.accessibility", &json!({})).unwrap();
|
||||
let contrasts: Vec<_> = issues
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|issue| issue["issue"].as_str().unwrap_or("").starts_with("Hard-to-read text contrast"))
|
||||
.collect();
|
||||
assert_eq!(contrasts.len(), 3, "{issues}");
|
||||
for id in [2, 3, 5] {
|
||||
assert!(contrasts.iter().any(|issue| issue["shape"] == id), "missing shape {id}: {issues}");
|
||||
}
|
||||
assert!(!contrasts.iter().any(|issue| issue["shape"] == 6), "black control warned: {issues}");
|
||||
assert!(!contrasts.iter().any(|issue| issue["shape"] == 8), "white break or spaces warned: {issues}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
//! the same pair when it copies, and reads it back on paste: bold, italic and underline survive.
|
||||
//! Anything else in the markup is skipped. A hostile fragment is capped, never executed.
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
|
||||
|
||||
const MAX_HTML: usize = 1 << 20;
|
||||
@@ -16,51 +14,20 @@ const MAX_RUNS: usize = 10_000;
|
||||
pub fn body_to_html(body: &TextBody) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, p) in body.paragraphs.iter().enumerate() {
|
||||
let prefix = if i > 0 { "<br><p>" } else { "<p>" };
|
||||
if !push_if_fits(&mut out, prefix, 4) {
|
||||
return plain_html(body);
|
||||
if i > 0 {
|
||||
out.push_str("<br>");
|
||||
}
|
||||
out.push_str("<p>");
|
||||
for r in &p.runs {
|
||||
let (open, close) = tags(&r.props);
|
||||
if !push_if_fits(&mut out, &open, close.len() + 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
out.push_str(&open);
|
||||
match &r.kind {
|
||||
deckcraft_model::text::RunKind::Break => {
|
||||
if !push_if_fits(&mut out, "<br>", close.len() + 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if !push_escaped_bounded(&mut out, &r.text, close.len() + 4) {
|
||||
return plain_html(body);
|
||||
}
|
||||
}
|
||||
deckcraft_model::text::RunKind::Break => out.push_str("<br>"),
|
||||
_ => push_escaped(&mut out, &r.text),
|
||||
}
|
||||
out.push_str(&close);
|
||||
}
|
||||
out.push_str("</p>");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The text without formatting, or "" (no HTML on the clipboard) when even that is too big: a
|
||||
/// cut-off prefix would paste as if it were the whole text.
|
||||
fn plain_html(body: &TextBody) -> String {
|
||||
let mut out = String::new();
|
||||
for (i, p) in body.paragraphs.iter().enumerate() {
|
||||
let prefix = if i > 0 { "<br><p>" } else { "<p>" };
|
||||
if !push_if_fits(&mut out, prefix, 4) {
|
||||
return String::new();
|
||||
}
|
||||
for r in &p.runs {
|
||||
let fits = if matches!(&r.kind, deckcraft_model::text::RunKind::Break) {
|
||||
push_if_fits(&mut out, "<br>", 4)
|
||||
} else {
|
||||
push_escaped_bounded(&mut out, &r.text, 4)
|
||||
};
|
||||
if !fits {
|
||||
return String::new();
|
||||
if out.len() > MAX_HTML {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push_str("</p>");
|
||||
@@ -68,43 +35,6 @@ fn plain_html(body: &TextBody) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
fn push_if_fits(out: &mut String, value: &str, reserve: usize) -> bool {
|
||||
if out.len().checked_add(value.len()).and_then(|n| n.checked_add(reserve)).is_none_or(|n| n > MAX_HTML) {
|
||||
return false;
|
||||
}
|
||||
out.push_str(value);
|
||||
true
|
||||
}
|
||||
|
||||
fn push_escaped_bounded(out: &mut String, text: &str, reserve: usize) -> bool {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => {
|
||||
if !push_if_fits(out, "&", reserve) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
'<' => {
|
||||
if !push_if_fits(out, "<", reserve) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
'>' => {
|
||||
if !push_if_fits(out, ">", reserve) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if out.len().checked_add(c.len_utf8()).and_then(|n| n.checked_add(reserve)).is_none_or(|n| n > MAX_HTML) {
|
||||
return false;
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn tags(props: &RunProps) -> (String, String) {
|
||||
let mut open = String::new();
|
||||
let mut close = String::new();
|
||||
@@ -123,6 +53,17 @@ fn tags(props: &RunProps) -> (String, String) {
|
||||
(open, close)
|
||||
}
|
||||
|
||||
fn push_escaped(out: &mut String, text: &str) {
|
||||
for c in text.chars() {
|
||||
match c {
|
||||
'&' => out.push_str("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The formatted text of an HTML fragment, or `None` when it has no text.
|
||||
///
|
||||
/// Understands `<b>`, `<strong>`, `<i>`, `<em>`, `<u>`, `<br>`, paragraphs, and a `style` that
|
||||
@@ -136,7 +77,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
let mut bold = 0i32;
|
||||
let mut italic = 0i32;
|
||||
let mut underline = 0i32;
|
||||
let mut stack: Vec<(Cow<'static, str>, i32, i32, i32)> = Vec::new();
|
||||
let mut stack: Vec<(&'static str, i32, i32, i32)> = Vec::new();
|
||||
let mut paragraphs: Vec<Paragraph> = vec![Paragraph::default()];
|
||||
let mut runs = 0usize;
|
||||
let mut skip = 0i32;
|
||||
@@ -165,27 +106,23 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
continue;
|
||||
}
|
||||
if closing {
|
||||
let kind = match tag_kind(&name) {
|
||||
"" => name.as_str(),
|
||||
kind => kind,
|
||||
};
|
||||
let search_start = stack.len().saturating_sub(64);
|
||||
if let Some(offset) = stack[search_start..].iter().rposition(|e| e.0.as_ref() == kind) {
|
||||
let pos = search_start + offset;
|
||||
let (db, di, du) = (stack[pos].1, stack[pos].2, stack[pos].3);
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
let kind = tag_kind(&name);
|
||||
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);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
}
|
||||
if is_break(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
continue;
|
||||
@@ -196,11 +133,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
italic += di;
|
||||
underline += du;
|
||||
if !token.ends_with('/') && stack.len() < 128 {
|
||||
let kind = match tag_kind(&name) {
|
||||
"" => Cow::Owned(name.clone()),
|
||||
kind => Cow::Borrowed(kind),
|
||||
};
|
||||
stack.push((kind, db, di, du));
|
||||
stack.push((tag_kind(&name), db, di, du));
|
||||
} else {
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
@@ -435,34 +368,6 @@ fn fold(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn style_on_other_tags_ends_at_their_close() {
|
||||
type Prop = fn(&RunProps) -> bool;
|
||||
let cases: [(&str, &str, Prop); 3] = [
|
||||
("a", "font-weight:bold", |p| p.bold == Some(true)),
|
||||
("td", "font-style:italic", |p| p.italic == Some(true)),
|
||||
("mark", "text-decoration:underline", |p| p.underline.is_some()),
|
||||
];
|
||||
for (tag, style, prop) in cases {
|
||||
let body = html_to_body(&format!("<p><{tag} style=\"{style}\">link</{tag}> plain</p>")).unwrap();
|
||||
let runs = &body.paragraphs[0].runs;
|
||||
assert_eq!(runs.len(), 2, "{tag}");
|
||||
assert!(prop(&runs[0].props), "{tag}");
|
||||
assert_eq!(runs[0].text, "link");
|
||||
assert_eq!(runs[1].text, " plain");
|
||||
assert!(runs[1].props.bold.is_none() && runs[1].props.italic.is_none() && runs[1].props.underline.is_none(), "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_before_opening_block_tag_is_separate_paragraph() {
|
||||
for tag in ["div", "p", "li", "h1"] {
|
||||
let body = html_to_body(&format!("Hello<{tag}>world</{tag}>")).unwrap();
|
||||
let texts: Vec<String> = body.paragraphs.iter().map(|p| p.runs.iter().map(|r| r.text.as_str()).collect()).collect();
|
||||
assert_eq!(texts, ["Hello", "world"], "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multibyte_after_ampersand_is_kept() {
|
||||
let text = "é".repeat(20);
|
||||
@@ -531,16 +436,6 @@ mod tests {
|
||||
assert!(last.props.underline.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_escaped_rich_text_puts_no_html_on_the_clipboard() {
|
||||
let mut body = TextBody::from_text(&"&<🦀".repeat(100_000));
|
||||
body.paragraphs[0].runs[0].props.bold = Some(true);
|
||||
body.paragraphs.push(Paragraph::new("later paragraph"));
|
||||
|
||||
// Neither the formatted nor the plain HTML fits, and a truncated prefix would lose text.
|
||||
assert!(body_to_html(&body).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_ampersand_keeps_text_up_to_next_semicolon() {
|
||||
let text = |html: &str| html_to_body(html).unwrap().text();
|
||||
|
||||
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Fill",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
|
||||
"{color? | none?: true | 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
|
||||
),
|
||||
@@ -121,7 +121,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Effects",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, threeD?: {camera?: preset, rotX?, rotY?, rotZ?: deg, z?: pt, bevelTop?, bevelBottom?: none|circle|…, bevelTopW?, bevelTopH?, bevelBottomW?, bevelBottomH?, depth?, contour?: pt, material?, lighting?, reset?: true}, shadowProps?: {color?, transparency?: 0..1, size?: 0.01..2 (1 = 100%), blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)",
|
||||
"{shadow?: none|outer|inner|perspective|{color, blur, dist, dir}, glow?: none|{color, radius}, softEdges?: pt, reflection?: none|tight|half|full, bevel?: none|circle|…, threeD?: {camera?: preset, rotX?, rotY?, rotZ?: deg, z?: pt, bevelTop?, bevelBottom?: none|circle|…, bevelTopW?, bevelTopH?, bevelBottomW?, bevelBottomH?, depth?, contour?: pt, material?, lighting?, reset?: true}, shadowProps?: {color?, transparency?: 0..1, blur?: pt, dist?: pt, dir?: deg}, glowProps?: {color?, transparency?, radius?: pt}, reflectionProps?: {transparency?, size?: 0..1, dist?: pt, blur?: pt}, reset?, ids?} (the *Props keys adjust the current effect, adding a default one when there is none)",
|
||||
has_selection,
|
||||
effects
|
||||
),
|
||||
@@ -165,15 +165,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
picture_adjust
|
||||
),
|
||||
cmd!(
|
||||
"picture.reset",
|
||||
"Reset Picture",
|
||||
["Picture Format", "Adjust"],
|
||||
None,
|
||||
"{ids?} (discards the picture's formatting: adjustments, artistic effect, crop, transparency, picture-style effects and outline; the frame shape from Change Shape or Crop to Shape, the size and the position stay)",
|
||||
has_selection,
|
||||
picture_reset
|
||||
),
|
||||
cmd!("picture.reset", "Reset Picture", ["Picture Format", "Adjust"], None, "{ids?}", has_selection, picture_reset),
|
||||
cmd!(
|
||||
"picture.change",
|
||||
"Change Picture",
|
||||
@@ -261,9 +253,6 @@ fn requested_fill(s: &mut Session, p: &Value) -> Result<Option<Option<Fill>>> {
|
||||
if bool_or(p, "none", false) {
|
||||
return Ok(Some(Some(Fill::None)));
|
||||
}
|
||||
if bool_or(p, "background", false) {
|
||||
return Ok(Some(Some(Fill::Background)));
|
||||
}
|
||||
if let Some(c) = color_param(p, "color") {
|
||||
return Ok(Some(Some(Fill::solid(c))));
|
||||
}
|
||||
@@ -506,103 +495,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_fill_shows_the_slide_background_and_survives_a_save() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("design.background", &json!({"color": "#0000c8"})).unwrap();
|
||||
let id = ShapeId(s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap() as u32);
|
||||
let inside = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 200, "y": 150})).unwrap()["color"].clone();
|
||||
assert_ne!(inside(&mut s), "0000C8", "a new shape takes the theme's fill");
|
||||
s.execute("shape.fill", &json!({"background": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, Some(Fill::Background));
|
||||
assert_eq!(inside(&mut s), "0000C8", "the slide background shows through the shape");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, None, "one undo step returns to the theme's fill");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
// Transparency changes a solid or picture fill only.
|
||||
s.execute("shape.fill", &json!({"transparency": 0.5})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().fill, Some(Fill::Background));
|
||||
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
let filled = back.slides.iter().flat_map(|sl| sl.shapes.iter()).filter(|sh| sh.fill == Some(Fill::Background)).count();
|
||||
assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen");
|
||||
}
|
||||
|
||||
/// A picture cannot save a slide background fill, so `background` leaves its fill alone.
|
||||
#[test]
|
||||
fn background_fill_skips_pictures() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||
let png = deckcraft_render::Image { width: 2, height: 2, pixels: [255, 0, 0, 255].repeat(4) }.to_png();
|
||||
let pic = s
|
||||
.execute("insert.picture", &json!({"data": crate::cmd::base64_encode(&png), "name": "red.png", "rect": [400, 100, 100, 100]}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let fill = |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().fill.clone();
|
||||
let err = s.execute("shape.fill", &json!({"background": true, "ids": [pic]})).unwrap_err();
|
||||
assert!(err.to_string().contains("shapes and text boxes"), "{err}");
|
||||
assert_eq!(fill(&s, pic), None);
|
||||
|
||||
s.execute("edit.select", &json!({"ids": [shape, pic]})).unwrap();
|
||||
let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
|
||||
s.execute("shape.fill", &json!({"background": true})).unwrap();
|
||||
assert_eq!(fill(&s, shape), Some(Fill::Background));
|
||||
assert!(matches!(fill(&s, pic), Some(Fill::Solid { .. })), "the picture keeps its fill");
|
||||
assert_eq!(fill(&s, group), None, "the group's own fill is cleared");
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
let mut filled = 0;
|
||||
for slide in &back.slides {
|
||||
deckcraft_model::walk(&slide.shapes, &mut |sh, _| filled += usize::from(sh.fill == Some(Fill::Background)));
|
||||
}
|
||||
assert_eq!(filled, 1, "the shape keeps the background fill after a save and reopen");
|
||||
}
|
||||
|
||||
/// A picture filled with its group's fill keeps that fill behind its transparent pixels when
|
||||
/// the group's shapes take the slide background fill.
|
||||
#[test]
|
||||
fn background_fill_keeps_the_group_fill_a_picture_uses() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let shape = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap()["id"].as_u64().unwrap();
|
||||
let png = deckcraft_render::Image { width: 2, height: 2, pixels: [0, 0, 0, 0].repeat(4) }.to_png();
|
||||
let pic = s
|
||||
.execute("insert.picture", &json!({"data": crate::cmd::base64_encode(&png), "name": "clear.png", "rect": [400, 100, 100, 100]}))
|
||||
.unwrap()["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
s.execute("edit.select", &json!({"ids": [shape, pic]})).unwrap();
|
||||
let group = s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap();
|
||||
let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0))));
|
||||
s.edit(|doc, _| {
|
||||
for slide in &mut doc.slides {
|
||||
let slide = std::sync::Arc::make_mut(slide);
|
||||
if let Some(g) = slide.shape_mut(ShapeId(group as u32)) {
|
||||
g.fill.clone_from(&red);
|
||||
}
|
||||
if let Some(p) = slide.shape_mut(ShapeId(pic as u32)) {
|
||||
p.fill = Some(Fill::Group);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let behind = |s: &mut Session| s.execute("slide.colorAt", &json!({"x": 450, "y": 150})).unwrap()["color"].clone();
|
||||
assert_eq!(behind(&mut s), "FF0000", "the group's fill shows through the picture");
|
||||
s.execute("edit.select", &json!({"ids": [group]})).unwrap();
|
||||
s.execute("shape.fill", &json!({"background": true})).unwrap();
|
||||
let fill = |s: &Session, id: u64| s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().fill.clone();
|
||||
assert_eq!(fill(&s, shape), Some(Fill::Background));
|
||||
assert_eq!(fill(&s, pic), Some(Fill::Group));
|
||||
assert_eq!(fill(&s, group), red, "the group keeps the fill its picture uses");
|
||||
assert_eq!(behind(&mut s), "FF0000", "the group's fill still shows through the picture");
|
||||
}
|
||||
|
||||
fn rotation(s: &Session) -> f64 {
|
||||
let st = s.active().unwrap();
|
||||
let id = st.selection.shapes[0];
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
use super::{
|
||||
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
|
||||
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
|
||||
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
||||
@@ -134,47 +130,11 @@ pub(super) fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let transparency = f64_param(p, "transparency");
|
||||
let pic_props = p.get("pictureProps").filter(|v| v.is_object());
|
||||
if new_fill.is_none() && transparency.is_none() && pic_props.is_none() {
|
||||
return Err(bad(
|
||||
"shape.fill",
|
||||
"give `color`, `none`, `background`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`",
|
||||
));
|
||||
return Err(bad("shape.fill", "give `color`, `none`, `gradient`, `pattern`, `picture`, `pictureProps`, `transparency` or `reset`"));
|
||||
}
|
||||
// Transparency on an inherited (style) fill needs the effective colour.
|
||||
let inherited = inherited_fills(s, p)?;
|
||||
let background = matches!(new_fill, Some(Some(Fill::Background)));
|
||||
if background {
|
||||
let st = s.doc()?;
|
||||
let mut any = false;
|
||||
for id in targets(s, p)? {
|
||||
if let Some(sh) = st.shape(id) {
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| any |= matches!(sh.kind, deckcraft_model::ShapeKind::Shape));
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Err(bad("shape.fill", "slide background fill applies to shapes and text boxes"));
|
||||
}
|
||||
}
|
||||
edit_group_appearance(s, p, "shape.fill", |sh| {
|
||||
if background && !matches!(sh.kind, deckcraft_model::ShapeKind::Shape) {
|
||||
if matches!(sh.kind, deckcraft_model::ShapeKind::Group { .. }) && !group_fill_used(sh, 0) {
|
||||
sh.fill = None;
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited)
|
||||
})
|
||||
}
|
||||
|
||||
/// Only a shape or text box (`p:sp`) saves a slide background fill (`useBgFill`). Pictures,
|
||||
/// connectors, tables and charts keep their fill, and a group's own fill is cleared unless one
|
||||
/// of them is filled with it (`grpFill`).
|
||||
fn group_fill_used(sh: &deckcraft_model::Shape, depth: usize) -> bool {
|
||||
depth <= 64
|
||||
&& sh.children().iter().any(|c| match c.kind {
|
||||
deckcraft_model::ShapeKind::Shape => false,
|
||||
deckcraft_model::ShapeKind::Group { .. } => matches!(c.fill, None | Some(Fill::Group)) && group_fill_used(c, depth + 1),
|
||||
_ => c.fill == Some(Fill::Group),
|
||||
})
|
||||
edit_group_appearance(s, p, "shape.fill", |sh| apply_fill(sh, new_fill.as_ref(), pic_props, transparency, &inherited))
|
||||
}
|
||||
|
||||
/// Applies Format Shape ▸ Picture or texture fill options to a picture fill.
|
||||
@@ -437,12 +397,6 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(d) = num(v, "dir") {
|
||||
x.dir = d.rem_euclid(360.0);
|
||||
}
|
||||
// PowerPoint's Size: 0–200%, both axes; a zero scale would collapse the shadow's matrix.
|
||||
if let Some(z) = num(v, "size") {
|
||||
let z = z.clamp(0.01, 2.0);
|
||||
x.sx = z;
|
||||
x.sy = z;
|
||||
}
|
||||
*slot = Some(x);
|
||||
}
|
||||
if let Some(v) = &glow_props {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad, bool_or, id_param, json, str_param, usize_param};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn sites(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{NO_ID_SPACE, Result, Session, Value, bad, bool_or, bool_param, edit_geometry, edit_shapes, f64_param, json};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn crop(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = [f64_param(p, "left"), f64_param(p, "top"), f64_param(p, "right"), f64_param(p, "bottom")];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Result, Session, Value, bad, edit_geometry, f64_param, json, str_param, targets, xfrm_of};
|
||||
use super::*;
|
||||
|
||||
/// Keeps a side length in `0..=MAX_PT` so a huge value cannot exhaust memory when rendering.
|
||||
/// An overflow to infinity lands on the limit; NaN becomes 0.
|
||||
|
||||
+23
-176
@@ -1,6 +1,5 @@
|
||||
//! Slides: new, duplicate, delete, move, layout, reset, hide, notes, sections, navigation.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::TextBody;
|
||||
@@ -23,15 +22,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!("slide.duplicate", "Duplicate Slide", ["Insert"], None, "{index?}", has_slide, duplicate),
|
||||
cmd!("slide.delete", "Delete Slide", ["Edit"], None, "{index?, ids?: [slide id]}", has_slide, delete),
|
||||
cmd!(
|
||||
"slide.move",
|
||||
"Move Slide",
|
||||
[],
|
||||
None,
|
||||
"{from?: index, ids?: [slide id], to: index (counted without the moved slides)}",
|
||||
has_slide,
|
||||
move_slide
|
||||
),
|
||||
cmd!("slide.move", "Move Slide", [], None, "{from?: index, to: index}", has_slide, move_slide),
|
||||
cmd!(noundo "slide.go", "Go to Slide", [], None, "{index} | {id}", has_slide, go),
|
||||
cmd!(noundo "slide.next", "Next Slide", [], Some("PageDown"), "{}", has_slide, next),
|
||||
cmd!(noundo "slide.previous", "Previous Slide", [], Some("PageUp"), "{}", has_slide, prev),
|
||||
@@ -275,75 +266,36 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Moves one slide (`from`, else the current slide) or several (`ids`) to `to`, in one undo step.
|
||||
/// `to` counts in the deck without the moved slides; they land there as one run, in the order
|
||||
/// they had, join the section of the slide before them, and stay selected.
|
||||
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = p.get("ids").filter(|v| !v.is_null());
|
||||
let from = match ids {
|
||||
None => opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide),
|
||||
Some(_) => 0,
|
||||
};
|
||||
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| {
|
||||
let moving: HashSet<SlideId> = match ids {
|
||||
Some(v) => slide_ids_param(v, doc, "slide.move")?,
|
||||
None => {
|
||||
let id = doc.slides.get(from).map(|x| x.id).ok_or_else(|| bad("slide.move", format!("no slide {from}")))?;
|
||||
HashSet::from([id])
|
||||
if from >= doc.slides.len() {
|
||||
return Err(bad("slide.move", format!("no slide {from}")));
|
||||
}
|
||||
let sl = doc.slides.remove(from);
|
||||
let to = to.min(doc.slides.len());
|
||||
let id = sl.id;
|
||||
doc.slides.insert(to, sl);
|
||||
// Keep sections consistent with order: move the id into the section of its new neighbour.
|
||||
if !doc.sections.is_empty() {
|
||||
for sec in &mut doc.sections {
|
||||
sec.slides.retain(|x| *x != id);
|
||||
}
|
||||
};
|
||||
let (block, mut rest): (Vec<_>, Vec<_>) = std::mem::take(&mut doc.slides).into_iter().partition(|x| moving.contains(&x.id));
|
||||
let to = to.min(rest.len());
|
||||
let tail = rest.split_off(to);
|
||||
let moved: Vec<SlideId> = block.iter().map(|x| x.id).collect();
|
||||
rest.extend(block);
|
||||
rest.extend(tail);
|
||||
doc.slides = rest;
|
||||
reassign_sections(doc, &moved, to);
|
||||
let neighbour = to.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
|
||||
let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0);
|
||||
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
|
||||
if let Some(sec) = doc.sections.get_mut(si) {
|
||||
sec.slides.push(id);
|
||||
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
sel.slide = to;
|
||||
sel.slides = moved;
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
Ok(json!({"index": to, "moved": moving.len()}))
|
||||
sel.slides = vec![id];
|
||||
Ok(json!({"index": to}))
|
||||
})
|
||||
}
|
||||
|
||||
/// The slides an `ids` param names: a non-empty list of ids of slides in `doc` (repeats fold).
|
||||
/// The first id that is not a slide of `doc` is an error, so the set never outgrows the deck.
|
||||
fn slide_ids_param(v: &Value, doc: &Presentation, cmd: &str) -> Result<HashSet<SlideId>> {
|
||||
let a = v.as_array().ok_or_else(|| bad(cmd, "`ids` must be a list of slide ids"))?;
|
||||
let known: HashSet<SlideId> = doc.slides.iter().map(|x| x.id).collect();
|
||||
let mut out = HashSet::new();
|
||||
for x in a {
|
||||
let id = x.as_u64().and_then(|n| u32::try_from(n).ok()).map(SlideId).filter(|id| known.contains(id));
|
||||
out.insert(id.ok_or_else(|| bad(cmd, format!("no slide with id {x}")))?);
|
||||
}
|
||||
if out.is_empty() {
|
||||
return Err(bad(cmd, "`ids` is empty"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Keeps sections in step after `moved` landed as a run at `at`: the run joins the section of
|
||||
/// the slide just before it (the first section when it opens the deck), in document order.
|
||||
fn reassign_sections(doc: &mut Presentation, moved: &[SlideId], at: usize) {
|
||||
if doc.sections.is_empty() {
|
||||
return;
|
||||
}
|
||||
let moving: HashSet<SlideId> = moved.iter().copied().collect();
|
||||
for sec in &mut doc.sections {
|
||||
sec.slides.retain(|x| !moving.contains(x));
|
||||
}
|
||||
let neighbour = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
|
||||
let si = neighbour.and_then(|n| doc.section_of(n)).unwrap_or(0);
|
||||
let order: HashMap<SlideId, usize> = doc.slides.iter().enumerate().map(|(i, x)| (x.id, i)).collect();
|
||||
if let Some(sec) = doc.sections.get_mut(si) {
|
||||
sec.slides.extend_from_slice(moved);
|
||||
sec.slides.sort_by_key(|x| order.get(x).copied().unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
|
||||
fn go_to(s: &mut Session, i: usize) -> Result<Value> {
|
||||
s.select(|doc, sel| {
|
||||
let n = doc.slides.len();
|
||||
@@ -758,111 +710,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A deck of `n` slides and its slide ids, in order.
|
||||
fn deck(n: usize) -> (Session, Vec<u32>) {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < n {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
let ids = order(&s);
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn order(s: &Session) -> Vec<u32> {
|
||||
s.doc().expect("doc").doc.slides.iter().map(|x| x.id.0).collect()
|
||||
}
|
||||
|
||||
fn selected(s: &Session) -> (usize, Vec<u32>) {
|
||||
let sel = &s.doc().expect("doc").selection;
|
||||
(sel.slide, sel.slides.iter().map(|x| x.0).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_from_to_moves_one_slide() {
|
||||
let (mut s, ids) = deck(4);
|
||||
s.execute("slide.move", &json!({"from": 3, "to": 0})).expect("move");
|
||||
assert_eq!(order(&s), [ids[3], ids[0], ids[1], ids[2]]);
|
||||
assert_eq!(selected(&s), (0, vec![ids[3]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn move_ids_lands_them_as_one_run_in_deck_order() {
|
||||
let (mut s, ids) = deck(6);
|
||||
// Listed backwards: they still land in the order they had.
|
||||
s.execute("slide.move", &json!({"ids": [ids[4], ids[1]], "to": 0})).expect("to the front");
|
||||
assert_eq!(order(&s), [ids[1], ids[4], ids[0], ids[2], ids[3], ids[5]]);
|
||||
assert_eq!(selected(&s), (0, vec![ids[1], ids[4]]), "the moved slides stay selected");
|
||||
|
||||
let (mut s, ids) = deck(6);
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[2]], "to": 2})).expect("to the middle");
|
||||
assert_eq!(order(&s), [ids[1], ids[3], ids[0], ids[2], ids[4], ids[5]]);
|
||||
assert_eq!(selected(&s), (2, vec![ids[0], ids[2]]));
|
||||
|
||||
let (mut s, ids) = deck(6);
|
||||
s.execute("slide.move", &json!({"ids": [ids[1], ids[3]], "to": 1000})).expect("past the end clamps");
|
||||
assert_eq!(order(&s), [ids[0], ids[2], ids[4], ids[5], ids[1], ids[3]]);
|
||||
assert_eq!(selected(&s), (4, vec![ids[1], ids[3]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_several_slides_is_one_undo_step() {
|
||||
let (mut s, ids) = deck(5);
|
||||
s.execute("slide.selectSlides", &json!({"ids": [ids[0], ids[3]]})).expect("select");
|
||||
let before = selected(&s);
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[3]], "to": 3})).expect("move");
|
||||
let after = order(&s);
|
||||
assert_eq!(after, [ids[1], ids[2], ids[4], ids[0], ids[3]]);
|
||||
s.execute("edit.undo", &json!({})).expect("undo");
|
||||
assert_eq!(order(&s), ids);
|
||||
assert_eq!(selected(&s), before);
|
||||
s.execute("edit.redo", &json!({})).expect("redo");
|
||||
assert_eq!(order(&s), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_slides_join_the_section_before_them() {
|
||||
let (mut s, ids) = deck(5);
|
||||
s.execute("section.add", &json!({"name": "A", "at": 0})).expect("A");
|
||||
s.execute("section.add", &json!({"name": "B", "at": 3})).expect("B");
|
||||
// A = 0,1,2; B = 3,4. Move 0 and 1 to the end: B gets them, A keeps only slide 2.
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[1]], "to": 3})).expect("move");
|
||||
let sections =
|
||||
|s: &Session| -> Vec<Vec<u32>> { s.doc().expect("doc").doc.sections.iter().map(|x| x.slides.iter().map(|id| id.0).collect()).collect() };
|
||||
assert_eq!(sections(&s), [vec![ids[2]], vec![ids[3], ids[4], ids[0], ids[1]]]);
|
||||
// Move the whole of A away: A stays, empty; the run at the front joins the first section.
|
||||
s.execute("slide.move", &json!({"ids": [ids[2]], "to": 4})).expect("empty A");
|
||||
assert_eq!(sections(&s), [vec![], vec![ids[3], ids[4], ids[0], ids[1], ids[2]]]);
|
||||
s.execute("slide.move", &json!({"ids": [ids[0], ids[1]], "to": 0})).expect("to the front");
|
||||
assert_eq!(sections(&s), [vec![ids[0], ids[1]], vec![ids[3], ids[4], ids[2]]]);
|
||||
let all: Vec<u32> = sections(&s).concat();
|
||||
assert_eq!(all, order(&s), "sections follow slide order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_ids_are_rejected_and_change_nothing() {
|
||||
let (mut s, ids) = deck(3);
|
||||
let lots_of_unknown: Vec<u64> = (1_000_000..1_100_000).collect();
|
||||
for p in [
|
||||
json!({"ids": [], "to": 0}),
|
||||
json!({"ids": [ids[0], 999_999], "to": 0}),
|
||||
json!({"ids": [-1], "to": 0}),
|
||||
json!({"ids": ["1"], "to": 0}),
|
||||
json!({"ids": [1.5], "to": 0}),
|
||||
json!({"ids": [u64::MAX], "to": 0}),
|
||||
json!({"ids": ids[0], "to": 0}),
|
||||
json!({"ids": lots_of_unknown, "to": 0}),
|
||||
json!({"ids": [ids[1]]}),
|
||||
] {
|
||||
assert!(s.execute("slide.move", &p).is_err(), "slide.move must reject {p}");
|
||||
assert_eq!(order(&s), ids, "{p}");
|
||||
}
|
||||
// Repeats fold into one.
|
||||
let repeated = vec![ids[2]; 100_000];
|
||||
s.execute("slide.move", &json!({"ids": repeated, "to": 0})).expect("repeats");
|
||||
assert_eq!(order(&s), [ids[2], ids[0], ids[1]]);
|
||||
assert_eq!(selected(&s), (0, vec![ids[2]]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keeps_section_order_in_step_with_slides() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::nodes::Item;
|
||||
use super::paint::{accent, deco, line_shape, paint, text_shape};
|
||||
use super::{ColorRef, ColorTransform, Diagram, DiagramNode, Fill, Geom, Line, MAX_SHAPES, SchemeSlot, Xfrm};
|
||||
use super::*;
|
||||
|
||||
/// Organization Chart: [`hierarchy`] with elbow connectors, and assistants beside their parent on
|
||||
/// a row of their own between the parent and its other children. `half_circle` draws each node as
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use super::nodes::{add, demote_node, nodes_from_lines, normalize, promote_node, remove, sync_text};
|
||||
use super::paint::layout;
|
||||
use super::{
|
||||
AutoFit, COLORS, Diagram, DiagramNode, EngineError, Geom, MAX_LEVEL, NO_ID_SPACE, Paragraph, Presentation, Result, STYLES, Session, Shape,
|
||||
ShapeId, ShapeKind, TextBody, Value, Xfrm, bad, bool_or, current_shapes, is_kind, json, shape_of, str_param, targets, xfrm_of,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// The SmartArt group that `id` is, or is inside.
|
||||
fn diagram_group(shapes: &[Shape], id: ShapeId) -> Option<&Shape> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
use super::paint::layout_items;
|
||||
use super::{Diagram, DiagramNode, MAX_LEVEL, MAX_SHAPES, Shape, Xfrm};
|
||||
use super::*;
|
||||
|
||||
/// Nodes from text lines: each leading tab is one outline level.
|
||||
pub fn nodes_from_lines<'a>(lines: impl IntoIterator<Item = &'a str>) -> Vec<DiagramNode> {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use super::layout::layout_raw;
|
||||
use super::nodes::Item;
|
||||
use super::{
|
||||
Align, AutoFit, ColorRef, ColorTransform, Diagram, DiagramNode, Effects, Fill, Geom, Gradient, GradientShape, GradientStop, Line, MAX_LEVEL,
|
||||
Paragraph, SchemeSlot, Shadow, Shape, ShapeStyle, TextBody, Xfrm,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
fn slot(i: usize) -> SchemeSlot {
|
||||
[SchemeSlot::Accent1, SchemeSlot::Accent2, SchemeSlot::Accent3, SchemeSlot::Accent4, SchemeSlot::Accent5, SchemeSlot::Accent6][i % 6]
|
||||
|
||||
@@ -108,7 +108,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Align Cell Text",
|
||||
["Table Layout", "Alignment"],
|
||||
None,
|
||||
"{anchor?: top|middle|bottom (vertical; top when neither anchor nor align is given), align?: left|center|right|justify (horizontal, applied to every paragraph of the cells), cells?, id?}",
|
||||
"{anchor: top|middle|bottom, cells?, id?}",
|
||||
has_selection,
|
||||
cell_align
|
||||
),
|
||||
@@ -535,43 +535,18 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use deckcraft_model::text::{Align, Anchor};
|
||||
// A value that is not a string is refused, not taken as absent.
|
||||
for k in ["align", "anchor"] {
|
||||
if p.get(k).is_some_and(|v| !v.is_null() && !v.is_string()) {
|
||||
return Err(bad("table.cellAlign", format!("`{k}` is a name such as {}", if k == "align" { "left or center" } else { "top or middle" })));
|
||||
}
|
||||
}
|
||||
// Without `align`, the vertical anchor is the whole command (top when not given).
|
||||
let align = match str_param(p, "align") {
|
||||
None => None,
|
||||
Some("left" | "l") => Some(Align::Left),
|
||||
Some("center" | "centre" | "ctr") => Some(Align::Center),
|
||||
Some("right" | "r") => Some(Align::Right),
|
||||
Some("justify" | "just") => Some(Align::Justify),
|
||||
Some(o) => return Err(bad("table.cellAlign", format!("unknown align {o:?}; one of left, center, right, justify"))),
|
||||
};
|
||||
let anchor = match (str_param(p, "anchor"), align) {
|
||||
(None, Some(_)) => None,
|
||||
(a, _) => Some(match a.unwrap_or("top") {
|
||||
"middle" | "center" | "ctr" => Anchor::Middle,
|
||||
"bottom" | "b" => Anchor::Bottom,
|
||||
"top" | "t" => Anchor::Top,
|
||||
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
|
||||
}),
|
||||
use deckcraft_model::text::Anchor;
|
||||
let a = match str_param(p, "anchor").unwrap_or("top") {
|
||||
"middle" | "center" | "ctr" => Anchor::Middle,
|
||||
"bottom" | "b" => Anchor::Bottom,
|
||||
"top" | "t" => Anchor::Top,
|
||||
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
|
||||
};
|
||||
let targets = target_cells(s, p)?;
|
||||
with_table(s, p, "table.cellAlign", move |t| {
|
||||
for (r, c) in targets(t) {
|
||||
if let Some(cell) = t.cell_mut(r, c) {
|
||||
if anchor.is_some() {
|
||||
cell.anchor = anchor;
|
||||
}
|
||||
if let Some(a) = align {
|
||||
for para in &mut cell.text.paragraphs {
|
||||
para.props.align = Some(a);
|
||||
}
|
||||
}
|
||||
cell.anchor = Some(a);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -24,6 +24,8 @@ mod layout;
|
||||
|
||||
use autocorrect::autocorrect_options;
|
||||
pub use autocorrect::{AUTOCORRECT, AutoList, correct_word};
|
||||
#[cfg(test)]
|
||||
use caret::count_param;
|
||||
pub(crate) use caret::{apply_leading_tabs, delete_dir};
|
||||
use caret::{delete, delete_surrounding, get, insert, move_caret, select, select_all, select_para, select_word, set};
|
||||
use editing::{edit, exit};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Arc, Result, Session, TextSel, Value, bad, body_mut, body_of, ed, edit_text, json, str_param};
|
||||
use super::*;
|
||||
|
||||
/// Built-in AutoCorrect replacements (common slips and symbols).
|
||||
pub const AUTOCORRECT: &[(&str, &str)] = &[
|
||||
@@ -142,7 +142,7 @@ pub fn correct_word(prefs: &crate::Prefs, word: &str, sentence_start: bool, typi
|
||||
|
||||
/// After a space or punctuation, correct the word just typed.
|
||||
pub(super) fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
if !s.prefs.autocorrect && !(typing && s.prefs.ac_lists) {
|
||||
if !s.prefs.autocorrect {
|
||||
return Ok(());
|
||||
}
|
||||
let st = s.doc()?;
|
||||
@@ -160,9 +160,6 @@ pub(super) fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if !s.prefs.autocorrect {
|
||||
return Ok(());
|
||||
}
|
||||
let mut word_end = end - 1;
|
||||
let mut start = word_end;
|
||||
while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) {
|
||||
|
||||
@@ -2,10 +2,7 @@ use super::autocorrect::undo_auto_list;
|
||||
use super::editing::{inherited_aligns, insert_text, promote_rtl_if_needed};
|
||||
use super::ime::{composition_range, model_pos, walk_backward, walk_forward};
|
||||
use super::layout::{ctx_for, fit_text_box, layout_for, pos_param};
|
||||
use super::{
|
||||
DocState, HistoryEntry, Pos, Result, Session, ShapeId, ShapeKind, TextBody, Value, bad, body_of, bool_or, ed, edit_shapes, edit_text, id_param,
|
||||
json, ok, push_undo, str_param, xfrm_of,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// A table cell's text laid out the way it is drawn, mapped into slide coordinates.
|
||||
pub(super) fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option<CellText> {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
use super::autocorrect::autocorrect;
|
||||
use super::layout::{ctx_for, pos_param};
|
||||
use super::{
|
||||
Align, Pos, Result, Run, RunKind, RunProps, Session, ShapeId, ShapeKind, TextBody, TextSel, Value, bad, body_of, bool_or, ed, edit_text,
|
||||
id_param, json, ok, resolve, shape_of,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if bool_or(p, "notes", false) {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
use super::layout::fit_text_box;
|
||||
use super::{
|
||||
DocState, Hyperlink, Pos, Result, RunProps, SELECT_TEXT_OR_SHAPE, Session, ShapeId, ShapeKind, TextBody, TextSel, Value, bad, body_of, ed,
|
||||
edit_shapes, edit_text, json, ok, str_param,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// Apply run formatting to the text selection (or, when not editing, to all text of the
|
||||
/// selected shapes). A caret without a range formats the word it's in (PowerPoint behaviour),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::caret::cell_on_slide;
|
||||
use super::editing::unlinked_at_link_end;
|
||||
use super::layout::{ctx_for, layout_for};
|
||||
use super::{DocState, Paragraph, Result, Session, TextBody, TextSel, Value, bad, body_of, ed, edit_text, json, str_param, xfrm_of};
|
||||
use super::*;
|
||||
|
||||
/// Maximum number of UTF-8 bytes in an outgoing IME surrounding-text query.
|
||||
///
|
||||
@@ -598,9 +598,9 @@ mod ime_surrounding_tests {
|
||||
s.delete_surrounding(usize::MAX, 0, false).unwrap();
|
||||
assert_eq!(text(&s, id), "é😀b", "everything before the caret goes, no panic");
|
||||
let expected = usize::try_from(4_294_967_296u64).unwrap_or(usize::MAX);
|
||||
assert_eq!(super::super::caret::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected);
|
||||
assert_eq!(super::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected);
|
||||
assert!(expected > 0, "the count is clamped, not wrapped to nothing");
|
||||
assert_eq!(super::super::caret::count_param(&json!({}), "before"), 0);
|
||||
assert_eq!(super::count_param(&json!({}), "before"), 0);
|
||||
}
|
||||
|
||||
/// A non-composing input-method deletion is exactly one undo step; the manual history entry
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
use super::caret::cell_on_slide;
|
||||
use super::{
|
||||
Affine, Arc, AutoFit, Ctx, DocState, Paragraph, Point, Pos, Presentation, Rect, Result, Selection, Session, Shape, ShapeId, Target, TextBody,
|
||||
TextSel, Value, ed, resolve, xfrm_of,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
/// Refit a shape after its text changed: remember the shrink-on-overflow scale the layout
|
||||
/// computes (#724), then grow/shrink an auto-sizing text box (Resize shape to fit text) to its
|
||||
|
||||
+20
-221
@@ -7,7 +7,6 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::resolve::Ctx;
|
||||
use deckcraft_model::{MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde::Serialize;
|
||||
|
||||
@@ -93,12 +92,6 @@ pub struct Change {
|
||||
pub kind: ChangeKind,
|
||||
/// Index in the open deck (for inserted: where it would go).
|
||||
pub slide: usize,
|
||||
/// The editor's number of that slide (`slide` + 1).
|
||||
pub number: usize,
|
||||
/// Which side has it alone: `deck` (the open presentation; accepting deletes it) or `file`
|
||||
/// (the compared file; accepting adds it). Absent when both have it and differ.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub only_in: Option<&'static str>,
|
||||
/// Index in the revised deck (none for deleted).
|
||||
pub revised: Option<usize>,
|
||||
/// What differs, readable: `Title 1: text`, `Picture 3 inserted`, `notes`…
|
||||
@@ -125,33 +118,19 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
|
||||
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
|
||||
let details = slide_details(cur, a, rev, b);
|
||||
if !details.is_empty() {
|
||||
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
|
||||
out.push(Change { kind: ChangeKind::Changed, slide: *i, revised: Some(*j), details });
|
||||
}
|
||||
}
|
||||
for (i, s) in cur.slides.iter().enumerate() {
|
||||
if !cur_matched.contains(&i) {
|
||||
out.push(Change {
|
||||
kind: ChangeKind::Deleted,
|
||||
slide: i,
|
||||
number: i.saturating_add(1),
|
||||
only_in: Some("deck"),
|
||||
revised: None,
|
||||
details: vec![slide_title(s)],
|
||||
});
|
||||
out.push(Change { kind: ChangeKind::Deleted, slide: i, revised: None, details: vec![slide_title(s)] });
|
||||
}
|
||||
}
|
||||
for (j, s) in rev.slides.iter().enumerate() {
|
||||
if !rev_matched.contains(&j) {
|
||||
// After the open slide that matches the revised slide before it.
|
||||
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
|
||||
out.push(Change {
|
||||
kind: ChangeKind::Inserted,
|
||||
slide: at,
|
||||
number: at.saturating_add(1),
|
||||
only_in: Some("file"),
|
||||
revised: Some(j),
|
||||
details: vec![slide_title(s)],
|
||||
});
|
||||
out.push(Change { kind: ChangeKind::Inserted, slide: at, revised: Some(j), details: vec![slide_title(s)] });
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|c| (c.slide, c.revised.unwrap_or(0)));
|
||||
@@ -193,18 +172,7 @@ fn plain_shape(s: &Shape) -> Shape {
|
||||
c
|
||||
}
|
||||
|
||||
/// The box a shape is drawn with: its own, else the one it inherits from its layout or master
|
||||
/// placeholder (`ctx`).
|
||||
fn drawn_box(ctx: Option<&Ctx>, s: &Shape) -> Option<deckcraft_model::Xfrm> {
|
||||
s.xfrm.or_else(|| ctx.map(|c| deckcraft_model::resolve::xfrm(c, s)))
|
||||
}
|
||||
|
||||
/// The rotation and flips a shape is drawn with (none without a box).
|
||||
fn turn(ctx: Option<&Ctx>, s: &Shape) -> (f64, bool, bool) {
|
||||
drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v))
|
||||
}
|
||||
|
||||
fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentation, cb: Option<&Ctx>, b: &Shape, out: &mut Vec<String>) {
|
||||
fn shape_details(cp: &Presentation, a: &Shape, rp: &Presentation, b: &Shape, out: &mut Vec<String>) {
|
||||
let label = shape_label(b);
|
||||
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
|
||||
let mut what = vec![];
|
||||
@@ -213,24 +181,12 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
|
||||
} else if a.text != b.text {
|
||||
what.push("text formatting");
|
||||
}
|
||||
// Boxes as drawn: a stored box equal to the inherited one is no change.
|
||||
match (&drawn_box(ca, a), &drawn_box(cb, b)) {
|
||||
match (&a.xfrm, &b.xfrm) {
|
||||
(Some(x), Some(y)) if (x.x, x.y) != (y.x, y.y) && (x.w, x.h) == (y.w, y.h) => what.push("moved"),
|
||||
(Some(x), Some(y)) if (x.w, x.h) != (y.w, y.h) => what.push("resized"),
|
||||
(x, y) if x.as_ref().map(|x| x.rot) != y.as_ref().map(|y| y.rot) => what.push("rotated"),
|
||||
_ => {}
|
||||
}
|
||||
// Rotation and flips are reported alongside a move or resize, not hidden behind it. No stored
|
||||
// box means the inherited one (the layout's or master's), as the shape is drawn.
|
||||
let (ta, tb) = (turn(ca, a), turn(cb, b));
|
||||
if ta.0 != tb.0 {
|
||||
what.push("rotated");
|
||||
}
|
||||
if (ta.1, ta.2) != (tb.1, tb.2) {
|
||||
what.push("flipped");
|
||||
}
|
||||
if a.geom != b.geom {
|
||||
what.push("shape");
|
||||
}
|
||||
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
|
||||
what.push("formatting");
|
||||
}
|
||||
@@ -241,24 +197,17 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
|
||||
if pic(cp, a) != pic(rp, b) {
|
||||
what.push("picture");
|
||||
}
|
||||
// Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also
|
||||
// beside them: the fields they compared are left out of this comparison.
|
||||
let (mut x, mut y) = (plain_shape(a), plain_shape(b));
|
||||
for z in [&mut x, &mut y] {
|
||||
z.text = None;
|
||||
z.xfrm = None;
|
||||
z.geom = Default::default();
|
||||
z.fill = None;
|
||||
z.line = None;
|
||||
z.style = None;
|
||||
z.effects = Default::default();
|
||||
}
|
||||
if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) {
|
||||
x.kind = ShapeKind::Shape;
|
||||
y.kind = ShapeKind::Shape;
|
||||
}
|
||||
if x != y {
|
||||
what.push("properties");
|
||||
if what.is_empty() {
|
||||
let (mut x, mut y) = (plain_shape(a), plain_shape(b));
|
||||
x.text = None;
|
||||
y.text = None;
|
||||
if let (ShapeKind::Picture { .. }, ShapeKind::Picture { .. }) = (&x.kind, &y.kind) {
|
||||
x.kind = ShapeKind::Shape;
|
||||
y.kind = ShapeKind::Shape;
|
||||
}
|
||||
if x != y {
|
||||
what.push("properties");
|
||||
}
|
||||
}
|
||||
if !what.is_empty() {
|
||||
out.push(format!("{label}: {}", what.join(", ")));
|
||||
@@ -268,7 +217,6 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
|
||||
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
|
||||
let mut out = vec![];
|
||||
let mut used: HashSet<usize> = HashSet::new();
|
||||
let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b));
|
||||
for sa in &a.shapes {
|
||||
let k = b
|
||||
.shapes
|
||||
@@ -279,15 +227,15 @@ fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) ->
|
||||
Some(k) if !used.contains(&k) => {
|
||||
used.insert(k);
|
||||
if let Some(sb) = b.shapes.get(k) {
|
||||
shape_details(cp, ca.as_ref(), sa, rp, cb.as_ref(), sb, &mut out);
|
||||
shape_details(cp, sa, rp, sb, &mut out);
|
||||
}
|
||||
}
|
||||
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
|
||||
_ => out.push(format!("{} deleted", shape_label(sa))),
|
||||
}
|
||||
}
|
||||
for (k, sb) in b.shapes.iter().enumerate() {
|
||||
if !used.contains(&k) {
|
||||
out.push(format!("{} inserted (only in the file)", shape_label(sb)));
|
||||
out.push(format!("{} inserted", shape_label(sb)));
|
||||
}
|
||||
}
|
||||
let order = |s: &Slide| s.shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
|
||||
@@ -406,25 +354,6 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
|
||||
let by_name = name.and_then(|n| doc.masters.iter().flat_map(|m| &m.layouts).find(|l| l.name == n).map(|l| l.id));
|
||||
sl.layout = by_name.or_else(|| doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id)).unwrap_or_default();
|
||||
}
|
||||
// A placeholder without a box of its own is drawn with its layout's; where this deck's layout
|
||||
// draws it otherwise (placed or turned differently), the file's box is stored on it, so the
|
||||
// accepted slide looks as the file does and compares equal to it.
|
||||
if let (Some(file), Some(here)) = (Ctx::for_slide(rev, slide), Ctx::for_slide(doc, &sl)) {
|
||||
let boxes: Vec<Option<deckcraft_model::Xfrm>> = slide
|
||||
.shapes
|
||||
.iter()
|
||||
.zip(&sl.shapes)
|
||||
.map(|(f, h)| {
|
||||
let want = deckcraft_model::resolve::xfrm(&file, f);
|
||||
(h.xfrm.is_none() && deckcraft_model::resolve::xfrm(&here, h) != want).then_some(want)
|
||||
})
|
||||
.collect();
|
||||
for (h, b) in sl.shapes.iter_mut().zip(boxes) {
|
||||
if b.is_some() {
|
||||
h.xfrm = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
match replacing {
|
||||
Some(id) => sl.id = id,
|
||||
None => {
|
||||
@@ -565,136 +494,6 @@ mod tests {
|
||||
assert_eq!(bytes(back.media), b"open background");
|
||||
}
|
||||
|
||||
/// NN6: a slide reset drops the placeholders' stored boxes; comparing it with the copy from
|
||||
/// before the reset must not call that a rotation (no stored box is rotation 0).
|
||||
#[test]
|
||||
fn a_reset_slide_is_not_rotated_against_its_earlier_copy() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "b"})).unwrap();
|
||||
let ids: Vec<u32> = s.doc().unwrap().doc.slides[1].shapes.iter().map(|x| x.id.0).collect();
|
||||
for id in &ids {
|
||||
s.execute("shape.move", &json!({"ids": [id], "dx": 10, "dy": 0})).unwrap();
|
||||
s.execute("shape.move", &json!({"ids": [id], "dx": -10, "dy": 0})).unwrap();
|
||||
}
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
s.execute("slide.reset", &json!({"index": 1})).unwrap();
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap();
|
||||
assert!(!v.to_string().contains("rotated"), "{v}");
|
||||
}
|
||||
|
||||
/// A shape both moved and rotated reports both, not only the move.
|
||||
#[test]
|
||||
fn a_moved_and_rotated_shape_reports_both() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
s.execute("shape.move", &json!({"ids": [id], "dx": 40, "dy": 0})).unwrap();
|
||||
s.execute("shape.rotate", &json!({"ids": [id], "deg": 30})).unwrap();
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap().to_string();
|
||||
assert!(v.contains("moved") && v.contains("rotated"), "{v}");
|
||||
}
|
||||
|
||||
/// A property no other check covers (here the alt text) is reported beside a flip.
|
||||
#[test]
|
||||
fn a_flipped_shape_with_new_alt_text_reports_both() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
s.execute("shape.flip", &json!({"ids": [id]})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let sl = std::sync::Arc::make_mut(&mut doc.slides[0]);
|
||||
sl.shapes.iter_mut().find(|x| x.id.0 as u64 == id).unwrap().descr = "A red box".into();
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap().to_string();
|
||||
assert!(v.contains("flipped") && v.contains("properties"), "{v}");
|
||||
}
|
||||
|
||||
/// A flip and a change of shape (rectangle to ellipse) are both reported.
|
||||
#[test]
|
||||
fn a_flipped_shape_that_changed_shape_reports_both() {
|
||||
let mut s = Session::with_new();
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
s.execute("shape.flip", &json!({"ids": [id]})).unwrap();
|
||||
s.execute("shape.change", &json!({"ids": [id], "preset": "ellipse"})).unwrap();
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap().to_string();
|
||||
assert!(v.contains("flipped") && v.contains("shape\""), "{v}");
|
||||
}
|
||||
|
||||
/// A placeholder with no box of its own is drawn with its layout's rotation; setting it to 0
|
||||
/// on the slide is a rotation change, and compare says so.
|
||||
#[test]
|
||||
fn an_inherited_rotation_counts_when_compared() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleOnly", "title": "T"})).unwrap();
|
||||
s.execute("slide.reset", &json!({"index": 1})).unwrap();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let layout = doc.slides[1].layout;
|
||||
let title = |sh: &deckcraft_model::Shape| sh.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title));
|
||||
let (mi, li) = doc.masters.iter().enumerate().find_map(|(m, x)| x.layouts.iter().position(|l| l.id == layout).map(|l| (m, l))).unwrap();
|
||||
let ph = std::sync::Arc::make_mut(&mut doc.masters[mi]).layouts[li].shapes.iter_mut().find(|x| title(x)).unwrap();
|
||||
let mut x = ph.xfrm.unwrap();
|
||||
x.rot = 30.0;
|
||||
ph.xfrm = Some(x);
|
||||
assert!(doc.slides[1].shapes.iter().find(|x| title(x)).unwrap().xfrm.is_none(), "the title inherits its box");
|
||||
let before = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let sl = std::sync::Arc::make_mut(&mut doc.slides[1]);
|
||||
let t = sl.shapes.iter_mut().find(|x| title(x)).unwrap();
|
||||
x.rot = 0.0;
|
||||
t.xfrm = Some(x);
|
||||
let v = s.execute("review.compare", &json!({"name": "before.deckcraft", "data": before})).unwrap();
|
||||
assert!(v.to_string().contains("rotated"), "{v}");
|
||||
}
|
||||
|
||||
/// The file's placeholder inherits an unturned box from its layout while this deck's layout
|
||||
/// turns it: accepting stores the file's box on the placeholder, so the slide is drawn as in the
|
||||
/// file and nothing is left to accept.
|
||||
#[test]
|
||||
fn accepting_an_inherited_box_takes_the_files_box() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleOnly", "title": "T"})).unwrap();
|
||||
s.execute("slide.reset", &json!({"index": 1})).unwrap();
|
||||
let file = s.execute("file.saveBytes", &json!({})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let title = |sh: &deckcraft_model::Shape| sh.ph.as_ref().is_some_and(|p| matches!(p.kind, deckcraft_model::PhType::Title));
|
||||
let st = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let layout = doc.slides[1].layout;
|
||||
let (mi, li) = doc.masters.iter().enumerate().find_map(|(m, x)| x.layouts.iter().position(|l| l.id == layout).map(|l| (m, l))).unwrap();
|
||||
let ph = std::sync::Arc::make_mut(&mut doc.masters[mi]).layouts[li].shapes.iter_mut().find(|x| title(x)).unwrap();
|
||||
let mut x = ph.xfrm.unwrap();
|
||||
x.rot = 30.0;
|
||||
ph.xfrm = Some(x);
|
||||
let v = s.execute("review.compare", &json!({"name": "file.deckcraft", "data": file})).unwrap();
|
||||
assert!(v.to_string().contains("rotated"), "{v}");
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.doc.slides[1].shapes.iter().find(|x| title(x)).unwrap();
|
||||
assert_eq!(t.xfrm.map(|x| x.rot), Some(0.0), "the file's box is stored: {:?}", t.xfrm);
|
||||
let v = s.execute("review.compare", &json!({"name": "file.deckcraft", "data": file})).unwrap();
|
||||
assert!(v["changes"].as_array().is_some_and(|c| c.is_empty()), "nothing left to accept: {v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_change_says_which_side_it_is_on() {
|
||||
let (mut s, rev, _) = decks();
|
||||
let v = s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
|
||||
for c in v["changes"].as_array().unwrap() {
|
||||
assert_eq!(c["number"].as_u64().unwrap(), c["slide"].as_u64().unwrap() + 1, "{c}");
|
||||
match c["kind"].as_str().unwrap() {
|
||||
"deleted" => assert_eq!(c["onlyIn"], "deck", "{c}"),
|
||||
"inserted" => assert_eq!(c["onlyIn"], "file", "{c}"),
|
||||
_ => assert!(c.get("onlyIn").is_none(), "{c}"),
|
||||
}
|
||||
}
|
||||
// A shape only in the open deck reads as only in the deck.
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
|
||||
let v = s.execute("review.revisions", &json!({})).unwrap();
|
||||
assert!(v.to_string().contains("deleted (only in the deck)"), "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_one_then_all_makes_the_decks_equal_and_undoes() {
|
||||
let (mut s, rev, n) = decks();
|
||||
|
||||
@@ -35,8 +35,8 @@ pub enum Access {
|
||||
/// The folders automation may read and write; a root left out grants no access of its kind.
|
||||
#[derive(Debug)]
|
||||
pub struct AutomationRoots {
|
||||
read: Vec<Root>,
|
||||
write: Vec<Root>,
|
||||
read: Option<Root>,
|
||||
write: Option<Root>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -62,47 +62,46 @@ impl Root {
|
||||
}
|
||||
|
||||
impl AutomationRoots {
|
||||
/// The read and write roots, which must be existing folders; `None` when both are empty
|
||||
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
||||
/// (nothing is confined then).
|
||||
pub fn new(read: &[PathBuf], write: &[PathBuf]) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_empty() && write.is_empty() {
|
||||
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_none() && write.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = read.iter().map(|p| Root::open(p, Access::Read)).collect::<Result<_, _>>()?;
|
||||
let write = write.iter().map(|p| Root::open(p, Access::Write)).collect::<Result<_, _>>()?;
|
||||
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
||||
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
||||
Ok(Some(Arc::new(Self { read, write })))
|
||||
}
|
||||
|
||||
/// The read roots, absolute with their links followed.
|
||||
pub fn read_roots(&self) -> impl Iterator<Item = &Path> {
|
||||
self.read.iter().map(|r| r.shown.as_path())
|
||||
/// The read root, absolute with its links followed.
|
||||
pub fn read_root(&self) -> Option<&Path> {
|
||||
self.read.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// The write roots, absolute with their links followed.
|
||||
pub fn write_roots(&self) -> impl Iterator<Item = &Path> {
|
||||
self.write.iter().map(|r| r.shown.as_path())
|
||||
/// The write root, absolute with its links followed.
|
||||
pub fn write_root(&self) -> Option<&Path> {
|
||||
self.write.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||
let roots = match access {
|
||||
Access::Read => &self.read,
|
||||
Access::Write => &self.write,
|
||||
let root = match access {
|
||||
Access::Read => self.read.as_ref(),
|
||||
Access::Write => self.write.as_ref(),
|
||||
};
|
||||
if roots.is_empty() {
|
||||
let Some(Root { resolved: folder, shown }) = root else {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
||||
};
|
||||
}
|
||||
let resolved = resolve(path)?;
|
||||
if roots.iter().any(|root| inside(&resolved, &root.resolved)) {
|
||||
};
|
||||
if inside(&resolve(path)?, folder) {
|
||||
return Ok(());
|
||||
}
|
||||
let roots = roots.iter().map(|root| root.shown.display().to_string()).collect::<Vec<_>>().join(", ");
|
||||
let root = shown.display();
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read roots {roots}: {path}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write roots {roots}: {path}")),
|
||||
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,7 +276,7 @@ mod tests {
|
||||
}
|
||||
std::fs::write(base.join("in/a.deckcraft"), "x").unwrap();
|
||||
std::fs::write(base.join("outside/secret.deckcraft"), "x").unwrap();
|
||||
let roots = AutomationRoots::new(&[base.join("in")], &[base.join("out")]).unwrap().unwrap();
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
||||
(base, roots)
|
||||
}
|
||||
|
||||
@@ -301,17 +300,6 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_configured_root_grants_access() {
|
||||
let (base, _) = folders("multiple");
|
||||
let roots = AutomationRoots::new(&[base.join("in"), base.join("in2")], &[base.join("out"), base.join("outside")]).unwrap().unwrap();
|
||||
assert_eq!(roots.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("in2/b.deckcraft")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("out/new.deckcraft")), Access::Write), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("outside/new.deckcraft")), Access::Write), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_with_the_same_prefix_is_outside() {
|
||||
let (base, roots) = folders("prefix");
|
||||
@@ -348,18 +336,18 @@ mod tests {
|
||||
#[test]
|
||||
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
||||
let (base, _) = folders("missing");
|
||||
let e = AutomationRoots::new(&[base.join("nope")], &[]).unwrap_err();
|
||||
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let e = AutomationRoots::new(&[], &[base.join("in/a.deckcraft")]).unwrap_err();
|
||||
let e = AutomationRoots::new(None, Some(&base.join("in/a.deckcraft"))).unwrap_err();
|
||||
assert!(
|
||||
e.starts_with("cannot open the automation write root")
|
||||
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert!(AutomationRoots::new(&[PathBuf::new()], &[]).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(&[], &[]).unwrap().is_none());
|
||||
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
||||
// One root alone grants only its own access.
|
||||
let read_only = AutomationRoots::new(&[base.join("in")], &[]).unwrap().unwrap();
|
||||
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
||||
let e = read_only.check(&s(base.join("in/x.deckcraft")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("write authority is absent"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
@@ -396,7 +384,7 @@ mod tests {
|
||||
assert_eq!(roots.check(&s(base.join("in/alias/x.deckcraft")), Access::Read), Ok(()));
|
||||
// A root given through a link is the folder it leads to.
|
||||
assert!(made(&base.join("in"), &base.join("in-link")));
|
||||
let linked = AutomationRoots::new(&[base.join("in-link")], &[]).unwrap().unwrap();
|
||||
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
||||
assert_eq!(linked.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
@@ -22,14 +22,12 @@ pub fn install_embedded_fonts(p: &Presentation) {
|
||||
}
|
||||
|
||||
/// The font families the slides' text is drawn in (theme fonts resolved, bullet fonts included)
|
||||
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive). Names that
|
||||
/// start with a dot are macOS hidden system fonts (`.SF NS`); they never appear in a font list
|
||||
/// and can't be installed, so they aren't reported.
|
||||
/// that aren't installed or bundled, sorted, without duplicates (case-insensitive).
|
||||
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
let db = FontDb::global();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for family in used_fonts(p) {
|
||||
if !family.starts_with('.') && !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
|
||||
if !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
|
||||
out.push(family);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,28 +682,7 @@ impl Session {
|
||||
self.journal.drain(..1000);
|
||||
}
|
||||
}
|
||||
Ok(if outer { self.with_editing(id, r) } else { r })
|
||||
}
|
||||
|
||||
/// In Slide Master / layout (or handout / notes master) view the insert commands edit the
|
||||
/// master or layout, not a slide; their answer says so with `editing: {kind, name}`, as
|
||||
/// `slide.inspect` does. Nothing is added on slides, so those answers stay as they were.
|
||||
fn with_editing(&self, id: &str, mut r: Value) -> Value {
|
||||
let inserts = id.starts_with("insert.") || matches!(id, "shape.insert" | "shape.freeform" | "shape.connect" | "slide.new");
|
||||
let (true, Some(st), Some(o)) = (inserts, self.active(), r.as_object_mut()) else { return r };
|
||||
let doc = &st.doc;
|
||||
let edited = match st.selection.target {
|
||||
Target::Master { master } => doc.masters.get(master).map(|m| ("master", m.name.clone())),
|
||||
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).map(|l| ("layout", l.name.clone())),
|
||||
t @ (Target::Handout | Target::NotesMaster) => {
|
||||
page_master(doc, t).map(|m| (if matches!(t, Target::Handout) { "handout" } else { "notesMaster" }, m.name.clone()))
|
||||
}
|
||||
Target::Slides => None,
|
||||
};
|
||||
if let Some((kind, name)) = edited {
|
||||
o.entry("editing").or_insert_with(|| serde_json::json!({"kind": kind, "name": name}));
|
||||
}
|
||||
r
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> Vec<CommandInfo> {
|
||||
@@ -716,7 +695,6 @@ impl Session {
|
||||
let mut doc = (*st.doc).clone();
|
||||
let mut sel = st.selection.clone();
|
||||
let r = f(&mut doc, &mut sel)?;
|
||||
cmd::arrange::refit_groups(&st.doc, &mut doc);
|
||||
connect::reroute(&mut doc, &sel);
|
||||
st.doc = Arc::new(doc);
|
||||
st.selection = sel;
|
||||
|
||||
@@ -356,14 +356,7 @@ impl Dictionary {
|
||||
}
|
||||
|
||||
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
|
||||
/// Words longer than 100 characters are not checked (they pass).
|
||||
pub fn check(&self, word: &str) -> bool {
|
||||
// Compound splitting tries every split point, so checking costs O(n²) in the word's
|
||||
// length: a 100,000-character "word" took seconds per check under a compounding
|
||||
// dictionary, on every edit (#1175).
|
||||
if word.chars().nth(MAX_CHECK_CHARS).is_some() {
|
||||
return true;
|
||||
}
|
||||
if word.contains('\u{2019}') {
|
||||
return self.check(&word.replace('\u{2019}', "'"));
|
||||
}
|
||||
@@ -401,18 +394,12 @@ impl Dictionary {
|
||||
return vec![];
|
||||
}
|
||||
let cap = word.chars().next().is_some_and(char::is_uppercase);
|
||||
let upper = word.chars().count() > 1 && word.chars().any(char::is_uppercase) && !word.chars().any(char::is_lowercase);
|
||||
let lower = if upper || (cap && word.chars().skip(1).all(|c| !c.is_uppercase())) { word.to_lowercase() } else { word.to_string() };
|
||||
let lower = if cap && word.chars().skip(1).all(|c| !c.is_uppercase()) { word.to_lowercase() } else { word.to_string() };
|
||||
let w: Vec<char> = lower.chars().collect();
|
||||
let mut out: Vec<String> = vec![];
|
||||
let mut seen = HashSet::new();
|
||||
let mut push = |s: String, out: &mut Vec<String>| {
|
||||
if s == lower {
|
||||
return;
|
||||
}
|
||||
// Checked in capitals, so a KEEPCASE entry ("NASA") is offered for an all-caps word.
|
||||
let s = if upper { s.to_uppercase() } else { s };
|
||||
if seen.insert(s.to_lowercase()) && s.split(' ').all(|p| self.suggestible(p)) {
|
||||
if s != lower && seen.insert(s.to_lowercase()) && s.split(' ').all(|p| self.suggestible(p)) {
|
||||
out.push(s);
|
||||
}
|
||||
};
|
||||
@@ -456,7 +443,7 @@ impl Dictionary {
|
||||
push(format!("{} {}", s(&w[..i]), s(&w[i..])), &mut out);
|
||||
}
|
||||
out.truncate(n);
|
||||
if cap && !upper {
|
||||
if cap {
|
||||
out = out.into_iter().map(|x| capitalise(&x)).collect();
|
||||
}
|
||||
out
|
||||
@@ -566,9 +553,6 @@ pub fn find_in(dirs: &[PathBuf], lang: &str) -> Option<(PathBuf, PathBuf)> {
|
||||
/// Longest word `Dictionary::suggest` generates corrections for.
|
||||
const MAX_SUGGEST_CHARS: usize = 64;
|
||||
|
||||
/// Longest word `Dictionary::check` checks, as Hunspell's word length limit.
|
||||
const MAX_CHECK_CHARS: usize = 100;
|
||||
|
||||
type Cache = Mutex<HashMap<String, Option<Arc<Dictionary>>>>;
|
||||
|
||||
fn cache() -> &'static Cache {
|
||||
@@ -859,28 +843,6 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string()));
|
||||
}
|
||||
|
||||
/// #1175: compound splitting is quadratic in the word's length, so a word longer than
|
||||
/// `MAX_CHECK_CHARS` is not checked; shorter ones still are.
|
||||
#[test]
|
||||
fn a_word_longer_than_the_check_limit_is_not_checked() {
|
||||
let d = Dictionary::parse(b"COMPOUNDFLAG X\nCOMPOUNDMIN 3\n", b"2\nfoot/X\nball/X\n".as_slice());
|
||||
assert!(d.check("footballfoot"), "a compound is checked");
|
||||
let under: String = "footb".repeat(MAX_CHECK_CHARS / 5);
|
||||
assert_eq!(under.chars().count(), MAX_CHECK_CHARS);
|
||||
assert!(!d.check(&under), "a word at the limit is still checked");
|
||||
// Unchecked, not seconds of compound splitting.
|
||||
assert!(d.check(&"footb".repeat(20_000)));
|
||||
assert!(d.check(&format!("{under}x")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_caps_words_get_uppercase_suggestions() {
|
||||
let d = d();
|
||||
assert!(d.suggest("SLDE", 5).contains(&"SLIDE".to_string()));
|
||||
assert!(d.suggest("FOTO", 5).contains(&"PHOTO".to_string()));
|
||||
assert!(d.suggest("NSSA", 5).contains(&"NASA".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compounds_join_flagged_parts() {
|
||||
let aff = "COMPOUNDFLAG X\nCOMPOUNDMIN 3\nONLYINCOMPOUND O\nSFX S Y 1\nSFX S 0 en .\n";
|
||||
|
||||
@@ -237,132 +237,6 @@ fn pasted_shapes_get_the_next_automatic_name() {
|
||||
assert_eq!(names(&s).last().unwrap(), "Hero");
|
||||
}
|
||||
|
||||
/// PowerPoint 16.0 (Windows): on "Rectangle 1, Oval 2, TextBox 3, Hero" Duplicate gives
|
||||
/// "Rectangle 5" and then "Rectangle 6", "TextBox 7"; the copy of "Hero" (and of its copy) is
|
||||
/// "Hero". Never "Hero copy".
|
||||
#[test]
|
||||
fn duplicate_gives_the_next_automatic_name_and_keeps_a_chosen_one() {
|
||||
let mut s = session();
|
||||
let names = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes.iter().map(|x| x.name.clone()).collect::<Vec<_>>();
|
||||
let add = |s: &mut Session, cmd: &str, p: serde_json::Value| {
|
||||
s.execute(cmd, &p).unwrap();
|
||||
s.doc().unwrap().selection.shapes[0].0
|
||||
};
|
||||
let rect = add(&mut s, "shape.insert", json!({"preset": "rect", "rect": [10, 10, 50, 50]}));
|
||||
let tb = add(&mut s, "shape.insert", json!({"preset": "rect", "rect": [100, 10, 50, 50]}));
|
||||
s.execute("shape.rename", &json!({"id": tb, "name": "Hero"})).unwrap();
|
||||
let n = names(&s).len();
|
||||
let auto = names(&s)[n - 2].clone();
|
||||
let (base, _) = auto.rsplit_once(' ').unwrap();
|
||||
|
||||
s.execute("edit.duplicate", &json!({"ids": [rect]})).unwrap();
|
||||
s.execute("edit.duplicate", &json!({"ids": [rect]})).unwrap();
|
||||
let all = names(&s);
|
||||
assert_eq!(all[n], format!("{base} {}", n + 1), "{all:?}");
|
||||
assert_eq!(all[n + 1], format!("{base} {}", n + 2), "{all:?}");
|
||||
|
||||
s.execute("edit.duplicate", &json!({"ids": [tb]})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("edit.duplicate", &json!({"ids": [copy]})).unwrap();
|
||||
let all = names(&s);
|
||||
assert_eq!(&all[n + 2..], ["Hero", "Hero"], "{all:?}");
|
||||
}
|
||||
|
||||
/// Two shapes sharing a chosen name are still two shapes: the next number follows the count.
|
||||
#[test]
|
||||
fn duplicate_numbers_from_the_shape_count_when_names_repeat() {
|
||||
let mut s = session();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap();
|
||||
let rect = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 10, 50, 50]})).unwrap();
|
||||
let hero = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("shape.rename", &json!({"id": hero, "name": "Hero"})).unwrap();
|
||||
s.execute("edit.duplicate", &json!({"ids": [hero]})).unwrap();
|
||||
let before = s.doc().unwrap().current_slide().unwrap().shapes.len();
|
||||
s.execute("edit.duplicate", &json!({"ids": [rect]})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let shapes = &doc.current_slide().unwrap().shapes;
|
||||
let source = shapes.iter().find(|x| x.id.0 == rect).unwrap();
|
||||
let base = source.name.rsplit_once(' ').unwrap().0;
|
||||
let names: Vec<_> = shapes.iter().map(|x| x.name.as_str()).collect();
|
||||
assert_eq!(shapes[before].name, format!("{base} {}", before + 1), "{names:?}");
|
||||
}
|
||||
|
||||
/// The next automatic name looks at the names inside groups too.
|
||||
#[test]
|
||||
fn duplicating_a_group_does_not_repeat_the_names_of_its_members() {
|
||||
let mut s = session();
|
||||
let mut ids = vec![];
|
||||
for x in [10, 70, 130, 190] {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap();
|
||||
ids.push(s.doc().unwrap().selection.shapes[0].0);
|
||||
}
|
||||
s.execute("arrange.group", &json!({"ids": [ids[2], ids[3]]})).unwrap();
|
||||
let group = s.doc().unwrap().selection.shapes[0].0;
|
||||
// Fewer top-level shapes than names in use: the next number is one a member already has.
|
||||
s.execute("edit.delete", &json!({"ids": [ids[0], ids[1]]})).unwrap();
|
||||
s.execute("edit.duplicate", &json!({"ids": [group]})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let mut all = vec![];
|
||||
deckcraft_model::walk(&doc.current_slide().unwrap().shapes, &mut |sh, _| all.push(sh.name.clone()));
|
||||
let mut unique = all.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), all.len(), "{all:?}");
|
||||
}
|
||||
|
||||
/// A shape inserted or pasted after a group was duplicated takes none of its members' names.
|
||||
#[test]
|
||||
fn inserting_after_duplicating_a_group_does_not_repeat_a_member_name() {
|
||||
let mut s = session();
|
||||
let mut ids = vec![];
|
||||
for x in [10, 70] {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap();
|
||||
ids.push(s.doc().unwrap().selection.shapes[0].0);
|
||||
}
|
||||
s.execute("arrange.group", &json!({"ids": ids})).unwrap();
|
||||
let group = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("edit.duplicate", &json!({"ids": [group]})).unwrap();
|
||||
// Fewer top-level shapes than shapes: counting only the top level reaches a member's number.
|
||||
for x in [130, 190, 250] {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap();
|
||||
}
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let mut all = vec![];
|
||||
deckcraft_model::walk(&doc.current_slide().unwrap().shapes, &mut |sh, _| all.push(sh.name.clone()));
|
||||
let mut unique = all.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), all.len(), "{all:?}");
|
||||
}
|
||||
|
||||
/// Group members are renamed like any other copied shape.
|
||||
#[test]
|
||||
fn duplicating_a_group_renames_its_automatic_children() {
|
||||
let mut s = session();
|
||||
let mut ids = vec![];
|
||||
for x in [10, 100] {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10, 50, 50]})).unwrap();
|
||||
ids.push(s.doc().unwrap().selection.shapes[0].0);
|
||||
}
|
||||
s.execute("arrange.group", &json!({"ids": ids})).unwrap();
|
||||
let group = s.doc().unwrap().selection.shapes[0].0;
|
||||
s.execute("edit.duplicate", &json!({"ids": [group]})).unwrap();
|
||||
let doc = s.doc().unwrap();
|
||||
let shapes = &doc.current_slide().unwrap().shapes;
|
||||
let (orig, copy) = (&shapes[shapes.len() - 2], &shapes[shapes.len() - 1]);
|
||||
assert!(orig.is_group() && copy.is_group());
|
||||
assert_ne!(orig.name, copy.name);
|
||||
assert!(copy.name.starts_with("Group "), "{}", copy.name);
|
||||
let all: Vec<_> = orig.children().iter().chain(copy.children()).map(|c| c.name.as_str()).collect();
|
||||
let mut unique = all.clone();
|
||||
unique.sort();
|
||||
unique.dedup();
|
||||
assert_eq!(unique.len(), all.len(), "{all:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasting_after_a_deleted_shape_does_not_repeat_a_name() {
|
||||
let mut s = session();
|
||||
@@ -1551,14 +1425,6 @@ fn opening_a_file_lists_the_fonts_it_uses_that_are_missing() {
|
||||
assert!(s.doc().unwrap().missing_fonts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_macos_hidden_system_font_is_not_reported_missing() {
|
||||
let mut s = Session::with_new();
|
||||
let r = s.execute("file.openBytes", &json!({"name": "a.pptx", "data": deck_in_font(".SF NS")})).unwrap();
|
||||
assert_eq!(r["missingFonts"], json!([]));
|
||||
assert!(s.doc().unwrap().missing_fonts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_embedded_face_is_used_when_the_machine_lacks_it() {
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
@@ -2454,15 +2320,6 @@ fn shape_effects_props_adjust_shadow_glow_and_reflection() {
|
||||
let f = fx(&s);
|
||||
assert!(f.outer_shadow.is_none());
|
||||
assert_eq!(f.inner_shadow.unwrap().blur, 3.0);
|
||||
// Size scales both axes (PowerPoint's 0–200%); out-of-range values clamp.
|
||||
s.execute("shape.effects", &json!({"shadow": "inner"})).unwrap();
|
||||
s.execute("shape.effects", &json!({"shadowProps": {"size": 1.5}})).unwrap();
|
||||
let sw = fx(&s).inner_shadow.unwrap();
|
||||
assert_eq!((sw.sx, sw.sy), (1.5, 1.5));
|
||||
s.execute("shape.effects", &json!({"shadowProps": {"size": 9}})).unwrap();
|
||||
assert_eq!(fx(&s).inner_shadow.unwrap().sx, 2.0);
|
||||
s.execute("shape.effects", &json!({"shadowProps": {"size": 0}})).unwrap();
|
||||
assert_eq!(fx(&s).inner_shadow.unwrap().sy, 0.01);
|
||||
// Glow: size, colour, transparency; size 0 removes it.
|
||||
s.execute("shape.effects", &json!({"glowProps": {"radius": 12, "transparency": 0.5}})).unwrap();
|
||||
let g = fx(&s).glow.unwrap();
|
||||
@@ -4564,16 +4421,6 @@ fn autoformat_starts_bulleted_and_numbered_lists_as_you_type() {
|
||||
assert_eq!(s.execute("text.autocorrect", &json!({})).unwrap()["lists"], true);
|
||||
s.execute("text.autocorrect", &json!({"lists": false})).unwrap();
|
||||
assert_eq!(typed(&mut s, "* x"), ("* x".to_string(), none));
|
||||
// List AutoFormat is independent of the Replace text as you type setting.
|
||||
s.execute("text.autocorrect", &json!({"on": false, "lists": true})).unwrap();
|
||||
assert_eq!(typed(&mut s, "*"), ("*".to_string(), Some(Bullet::None)));
|
||||
s.execute("text.insert", &json!({"text": " "})).unwrap();
|
||||
assert_eq!(para(&s), (String::new(), ch("•")));
|
||||
s.execute("text.delete", &json!({"dir": "backward"})).unwrap();
|
||||
assert_eq!(para(&s), ("* ".to_string(), Some(Bullet::None)));
|
||||
// Word replacements still run while list formatting is disabled.
|
||||
s.execute("text.autocorrect", &json!({"on": true, "lists": false})).unwrap();
|
||||
assert_eq!(typed(&mut s, "teh "), ("The ".to_string(), Some(Bullet::None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5799,160 +5646,3 @@ fn invalid_ids_array_never_falls_back_to_the_selection() {
|
||||
assert!(s.doc().unwrap().shape(deckcraft_model::ShapeId(a as u32)).is_some(), "shape deleted for {ids}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_cell_align_sets_the_horizontal_alignment() {
|
||||
use deckcraft_model::text::{Align, Anchor};
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
|
||||
s.execute("table.cellAlign", &json!({"id": id.0, "align": "center", "cells": [[0, 0], [1, 0]]})).unwrap();
|
||||
s.execute("table.cellAlign", &json!({"id": id.0, "align": "right", "anchor": "bottom", "cells": [[0, 1]]})).unwrap();
|
||||
let t = table_of(&s, id);
|
||||
let al = |r, c| t.cell(r, c).unwrap().text.paragraphs.iter().map(|p| p.props.align).collect::<Vec<_>>();
|
||||
assert_eq!(al(0, 0), vec![Some(Align::Center)]);
|
||||
assert_eq!(al(1, 0), vec![Some(Align::Center)]);
|
||||
assert_eq!(al(0, 1), vec![Some(Align::Right)]);
|
||||
assert_eq!(al(1, 1), vec![None], "other cells are untouched");
|
||||
assert_eq!(t.cell(0, 1).unwrap().anchor, Some(Anchor::Bottom));
|
||||
assert_eq!(t.cell(0, 0).unwrap().anchor, None, "align alone leaves the vertical anchor");
|
||||
assert!(s.execute("table.cellAlign", &json!({"id": id.0, "align": "diagonal"})).is_err());
|
||||
// A value that is not a name is refused, not read as absent (which would set the anchor to top).
|
||||
let rev = s.doc().unwrap().revision;
|
||||
assert!(s.execute("table.cellAlign", &json!({"id": id.0, "align": 1})).is_err());
|
||||
assert!(s.execute("table.cellAlign", &json!({"id": id.0, "align": "left", "anchor": true})).is_err());
|
||||
assert_eq!(s.doc().unwrap().revision, rev);
|
||||
}
|
||||
|
||||
/// In Slide Master / layout view the insert commands edit the master or layout; their answers say
|
||||
/// so with `editing`, and on slides carry no such key.
|
||||
#[test]
|
||||
fn insert_answers_carry_editing_in_master_and_layout_view() {
|
||||
let mut s = session();
|
||||
let ins = json!({"preset": "rect", "rect": [10.0, 10.0, 100.0, 100.0]});
|
||||
s.execute("view.slideMaster", &json!({"layout": 0})).unwrap();
|
||||
let r = s.execute("shape.insert", &ins).unwrap();
|
||||
assert_eq!(r["editing"]["kind"], "layout", "{r}");
|
||||
assert!(r["editing"]["name"].is_string(), "{r}");
|
||||
let r = s.execute("insert.textBox", &json!({"rect": [10.0, 10.0, 100.0, 50.0], "text": "x"})).unwrap();
|
||||
assert_eq!(r["editing"]["kind"], "layout", "{r}");
|
||||
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
|
||||
let r = s.execute("shape.insert", &ins).unwrap();
|
||||
assert_eq!(r["editing"]["kind"], "master", "{r}");
|
||||
s.execute("view.closeMaster", &json!({})).unwrap();
|
||||
let r = s.execute("shape.insert", &ins).unwrap();
|
||||
assert!(r.get("editing").is_none(), "{r}");
|
||||
}
|
||||
|
||||
/// MM4: moving a group's child through a command refits the group's box to its children, as
|
||||
/// PowerPoint does, and the children stay where they were drawn.
|
||||
#[test]
|
||||
fn moving_a_group_child_refits_the_group() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let ins = |s: &mut Session, p: serde_json::Value| s.execute("shape.insert", &p).unwrap()["id"].as_u64().unwrap();
|
||||
let a = ins(&mut s, json!({"preset": "rect", "rect": [100, 100, 100, 50]}));
|
||||
let b = ins(&mut s, json!({"preset": "rect", "rect": [300, 100, 100, 50]}));
|
||||
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
|
||||
let gb = |s: &Session| s.doc().unwrap().shape(ShapeId(g as u32)).unwrap().xfrm.unwrap();
|
||||
assert_eq!((gb(&s).x, gb(&s).w), (100.0, 300.0));
|
||||
s.execute("shape.move", &json!({"id": b, "dx": 100, "dy": 60})).unwrap();
|
||||
let x = gb(&s);
|
||||
assert_eq!((x.x, x.y, x.w, x.h), (100.0, 100.0, 400.0, 110.0), "group follows its children: {x:?}");
|
||||
// The child is where it was moved to on the slide.
|
||||
let r = s.execute("slide.inspect", &json!({})).unwrap();
|
||||
assert!(r.to_string().contains("400"), "{r}");
|
||||
s.execute("shape.setBounds", &json!({"ids": [a], "x": 20, "y": 20, "w": 100, "h": 50})).unwrap();
|
||||
let x = gb(&s);
|
||||
assert_eq!((x.x, x.y, x.w), (20.0, 20.0, 480.0), "{x:?}");
|
||||
}
|
||||
|
||||
/// HH9: Reset Picture discards the picture's formatting (adjustments, artistic effect, picture
|
||||
/// style effects and outline) but not the frame's shape (Change Shape / Crop to Shape).
|
||||
#[test]
|
||||
fn picture_reset_keeps_the_frame_shape() {
|
||||
let mut img = image::RgbaImage::new(40, 30);
|
||||
img.pixels_mut().for_each(|p| *p = image::Rgba([200, 30, 30, 255]));
|
||||
let mut png = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("insert.picture", &json!({"name": "a.png", "data": cmd::base64_encode(&png)})).unwrap();
|
||||
s.execute("shape.change", &json!({"preset": "roundRect"})).unwrap();
|
||||
s.execute("picture.adjust", &json!({"brightness": 0.3})).unwrap();
|
||||
let geom = s.doc().unwrap().selected_shapes()[0].geom.clone();
|
||||
assert_ne!(geom, deckcraft_model::Geom::preset("rect"));
|
||||
s.execute("picture.reset", &json!({})).unwrap();
|
||||
let sh = s.doc().unwrap().selected_shapes()[0].clone();
|
||||
assert_eq!(sh.geom, geom, "the frame shape stays");
|
||||
match &sh.kind {
|
||||
deckcraft_model::ShapeKind::Picture { fill } => assert_eq!(fill.adjust, Default::default()),
|
||||
_ => panic!("not a picture"),
|
||||
}
|
||||
let doc = command_specs().iter().find(|c| c.id == "picture.reset").unwrap().params;
|
||||
assert!(doc.contains("frame shape"), "{doc}");
|
||||
}
|
||||
|
||||
/// HH12: exporting the same slide twice to one PNG path after an edit overwrites the file with
|
||||
/// the new picture (not a bug: the second file differs).
|
||||
#[test]
|
||||
fn exporting_a_slide_again_overwrites_the_png_with_the_edit() {
|
||||
/// Removes the folder when the test ends, a failed assertion included.
|
||||
struct Cleanup(std::path::PathBuf);
|
||||
impl Drop for Cleanup {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
// A folder of this run's own: created exclusively (never one an earlier or parallel run left).
|
||||
let stamp = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
|
||||
let dir = (0..100)
|
||||
.map(|k| std::env::temp_dir().join(format!("deckcraft-hh12-{}-{stamp}-{k}", std::process::id())))
|
||||
.find(|d| std::fs::create_dir(d).is_ok())
|
||||
.expect("a fresh temporary folder");
|
||||
let _cleanup = Cleanup(dir.clone());
|
||||
let png = dir.join("slide.png");
|
||||
let mut s = session();
|
||||
let path = png.to_string_lossy().to_string();
|
||||
s.execute("file.export", &json!({"path": path, "format": "png", "slide": 0, "scale": 0.5})).unwrap();
|
||||
let first = std::fs::read(&png).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 400, 300]})).unwrap();
|
||||
s.execute("shape.fill", &json!({"color": "#FF0000"})).unwrap();
|
||||
s.execute("file.export", &json!({"path": path, "format": "png", "slide": 0, "scale": 0.5})).unwrap();
|
||||
let second = std::fs::read(&png).unwrap();
|
||||
assert_ne!(first, second, "the second export replaced the file with the edited slide");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_button_fill_follows_the_theme() {
|
||||
let mut s = session();
|
||||
let id = s.execute("insert.actionButton", &json!({"kind": "next"})).unwrap()["id"].clone();
|
||||
let fill = |s: &mut Session| {
|
||||
let r = s.execute("slide.inspect", &json!({})).unwrap();
|
||||
r["shapes"].as_array().unwrap().iter().find(|x| x["id"] == id).unwrap()["fill"].clone()
|
||||
};
|
||||
let before = fill(&mut s);
|
||||
assert!(before.as_str().is_some_and(|f| f.starts_with('#')), "{before}");
|
||||
s.execute("design.theme", &json!({"name": "Ember"})).unwrap();
|
||||
let after = fill(&mut s);
|
||||
assert_ne!(before, after, "the button takes the new theme's accent1");
|
||||
let st = s.doc().unwrap();
|
||||
let sh = st.doc.slides[0].shapes.iter().find(|x| x.fill.is_none() && x.style.is_some()).unwrap();
|
||||
assert!(sh.fill.is_none(), "no explicit fill: the style binds it to accent1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_slide_index_never_falls_back_to_the_selection_in_export_commands() {
|
||||
let mut s = session();
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-badslide-{}", std::process::id()));
|
||||
let path = dir.join("a.png");
|
||||
for slide in [json!(-1), json!(1.5), json!("two")] {
|
||||
let r = s.execute("file.export", &json!({"path": path.to_string_lossy(), "slide": slide, "scale": 0.1}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "export slide {slide} gave {r:?}");
|
||||
assert!(!path.exists(), "export wrote a file for {slide}");
|
||||
let r = s.execute("file.render", &json!({"slide": slide, "scale": 0.1}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "render slide {slide} gave {r:?}");
|
||||
let r = s.execute("slide.colorAt", &json!({"slide": slide, "x": 1, "y": 1, "scale": 0.1}));
|
||||
assert!(matches!(r, Err(crate::EngineError::BadParams { .. })), "colorAt slide {slide} gave {r:?}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::hit::{EditZone, edit_zone, edited_box, shape_in_front, text_member_at, text_pos};
|
||||
use super::{Drag, Hit, Point, Result, Session, ShapeId, ShapeKind, TextSel, ToolKind, Value, cmd, json, xfrm_of};
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
pub(super) fn double_click(&mut self, p: Point, tol: f64) -> Result<Value> {
|
||||
@@ -206,7 +206,7 @@ impl Session {
|
||||
let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) };
|
||||
let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0);
|
||||
let (start, frame) = (f.local(p), f.affine);
|
||||
if st.selection.text.as_ref().is_none_or(|t| t.shape != id) {
|
||||
if !st.selection.text.as_ref().is_some_and(|t| t.shape == id) {
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = None;
|
||||
|
||||
+148
-172
@@ -1,16 +1,5 @@
|
||||
use super::hit::{constrain_45, line_xfrm, resize_box};
|
||||
use super::{
|
||||
Affine, DRAW_TABLE, Drag, Mods, Point, Rect, Result, Session, ShapeId, ShapeKind, TextSel, ToolKind, Value, Vec2, Xfrm, cmd, json, set_warp_adj,
|
||||
};
|
||||
|
||||
struct MoveDrag {
|
||||
ids: Vec<ShapeId>,
|
||||
start: Point,
|
||||
origs: Vec<(ShapeId, Xfrm)>,
|
||||
moved: bool,
|
||||
duplicate: bool,
|
||||
enter_text: Option<(ShapeId, Point)>,
|
||||
}
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
@@ -24,7 +13,55 @@ impl Session {
|
||||
self.tool.guide_drag = Some((index, pos));
|
||||
}
|
||||
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
|
||||
return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol);
|
||||
let mut d = p - start;
|
||||
if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if !moved {
|
||||
self.begin(if duplicate { "Duplicate" } else { "Move" });
|
||||
if duplicate {
|
||||
// Alt-drag: drag a copy, leave the original.
|
||||
self.execute_dup_in_place(&ids)?;
|
||||
}
|
||||
}
|
||||
let ids_now = if duplicate { self.doc()?.selection.shapes.clone() } else { ids.clone() };
|
||||
if mods.shift {
|
||||
if d.x.abs() > d.y.abs() {
|
||||
d.y = 0.0;
|
||||
} else {
|
||||
d.x = 0.0;
|
||||
}
|
||||
}
|
||||
// Smart guides on the moved group's bounds.
|
||||
let bounds = origs.iter().map(|(_, x)| x.bounds()).reduce(|a, b| a.union(b)).unwrap_or_default();
|
||||
let moved_b = bounds + d;
|
||||
let (snap, guides) = self.snap_rect(moved_b, &ids_now, tol * 1.5, !mods.cmd);
|
||||
d += snap;
|
||||
self.tool.guides = guides;
|
||||
let origs2: Vec<(ShapeId, Xfrm)> =
|
||||
if duplicate { ids_now.iter().copied().zip(origs.iter().map(|o| o.1)).collect() } else { origs.clone() };
|
||||
self.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("move", "no slide"))?;
|
||||
for (id, o) in &origs2 {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(list, *id) {
|
||||
sh.xfrm = Some(Xfrm { x: o.x + d.x, y: o.y + d.y, ..*o });
|
||||
}
|
||||
}
|
||||
// A connector dragged away from shapes that stay put comes unglued at those ends.
|
||||
let moved: Vec<ShapeId> = origs2.iter().map(|o| o.0).collect();
|
||||
for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) {
|
||||
if let ShapeKind::Connector { start, end } = &mut sh.kind {
|
||||
for c in [start, end] {
|
||||
if c.is_some_and(|(to, _)| !moved.contains(&to)) {
|
||||
*c = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
self.tool.drag =
|
||||
Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) });
|
||||
}
|
||||
Drag::Resize { id, handle, orig, start } => {
|
||||
let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt);
|
||||
@@ -73,7 +110,39 @@ impl Session {
|
||||
self.set_xfrm(*id, nx)?;
|
||||
}
|
||||
}
|
||||
Drag::Adjust { id, index, orig } => return self.drag_adjust(id, index, orig, p),
|
||||
Drag::Adjust { id, index, orig } => {
|
||||
let st = self.doc()?;
|
||||
let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) };
|
||||
let local = orig.affine().inverse() * p;
|
||||
let geo = deckcraft_render::shape_geometry(&sh, orig.w, orig.h);
|
||||
let Some(h) = geo.handles.iter().find(|h| h.index == index) else { return Ok(Value::Null) };
|
||||
let free2d = matches!(sh.geom.preset_name(), Some(n) if n.contains("Callout") && !n.contains("Arrow"));
|
||||
let vals: Vec<(usize, f64)> = if free2d {
|
||||
let dx = (local.x - orig.w / 2.0) / orig.w.max(1e-6) * 1e5;
|
||||
let dy = (local.y - orig.h / 2.0) / orig.h.max(1e-6) * 1e5;
|
||||
vec![(0, dx.round()), (1, dy.round())]
|
||||
} else {
|
||||
vec![(index, h.value_at(local))]
|
||||
};
|
||||
self.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?;
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(list, id)
|
||||
&& let deckcraft_model::Geom::Preset { name, adj } = &mut sh.geom
|
||||
{
|
||||
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
|
||||
for (i, v) in &vals {
|
||||
while adj.len() <= *i {
|
||||
let k = adj.len();
|
||||
adj.push(defaults.get(k).copied().unwrap_or(0.0));
|
||||
}
|
||||
if let Some(slot) = adj.get_mut(*i) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
Drag::Vertex { id, path, index } => {
|
||||
self.tool.drag = Some(Drag::Vertex { id, path, index });
|
||||
self.edit_vertex(id, path, index, p)?;
|
||||
@@ -118,8 +187,71 @@ impl Session {
|
||||
Drag::Marquee { start, .. } => {
|
||||
self.tool.marquee = Some(Rect::from_points(start, p));
|
||||
}
|
||||
Drag::Create { start } => self.drag_create(start, p, mods, tol),
|
||||
Drag::TextSelect { shape, press, .. } => return self.drag_text_select(shape, press, p),
|
||||
Drag::Create { start } => {
|
||||
let line = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset));
|
||||
let x = if line {
|
||||
let mut q = if mods.shift { constrain_45(start, p) } else { p };
|
||||
if let Some((_, _, at)) = self.update_sites(q, tol, None) {
|
||||
q = at;
|
||||
}
|
||||
let s0 = self.glue_at(start, tol).map(|g| g.2).unwrap_or(start);
|
||||
line_xfrm(s0, q, 0.0)
|
||||
} else if matches!(&self.tool.kind, ToolKind::Shape { preset } if preset == DRAW_TABLE)
|
||||
&& crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some()
|
||||
{
|
||||
// The pencil inside a table draws a straight border, across or down.
|
||||
let q = if (p.x - start.x).abs() >= (p.y - start.y).abs() { Point::new(p.x, start.y) } else { Point::new(start.x, p.y) };
|
||||
let r = Rect::from_points(start, q);
|
||||
Xfrm::new(r.x0, r.y0, r.width(), r.height())
|
||||
} else {
|
||||
let mut r = Rect::from_points(start, p);
|
||||
if mods.shift {
|
||||
let s = r.width().max(r.height());
|
||||
let sx = if p.x < start.x { -s } else { s };
|
||||
let sy = if p.y < start.y { -s } else { s };
|
||||
r = Rect::from_points(start, start + Vec2::new(sx, sy));
|
||||
}
|
||||
if mods.alt {
|
||||
let d = p - start;
|
||||
r = Rect::new(start.x - d.x.abs(), start.y - d.y.abs(), start.x + d.x.abs(), start.y + d.y.abs());
|
||||
}
|
||||
Xfrm::new(r.x0, r.y0, r.width(), r.height())
|
||||
};
|
||||
self.tool.preview = Some(x);
|
||||
}
|
||||
Drag::TextSelect { shape, press, .. } => {
|
||||
// In a table, leaving the cell the drag started in selects whole cells, as in
|
||||
// PowerPoint; coming back to that cell selects text in it again.
|
||||
let st = self.doc()?;
|
||||
if let Some(start) = press.as_ref().and_then(|t| t.cell)
|
||||
&& let Some(f) = st.shape(shape).and_then(|sh| cmd::table::table_frame(st, sh))
|
||||
&& let Some(cell) = f.cell_at(f.local(p))
|
||||
{
|
||||
if cell != start {
|
||||
self.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.shapes = vec![shape];
|
||||
sel.cells = Some((shape, start, cell));
|
||||
})?;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if st.selection.text.is_none() {
|
||||
self.select(|_, sel| {
|
||||
sel.cells = None;
|
||||
sel.text = press;
|
||||
})?;
|
||||
}
|
||||
}
|
||||
let st = self.doc()?;
|
||||
if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) {
|
||||
let pos = cmd::text::text_pos_at(st, t, p);
|
||||
self.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.caret = pos;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Drag::TableBorder { id, column, index, start, size, frame, moved } => {
|
||||
let local = frame.inverse() * p;
|
||||
let d = if column { local.x - start.x } else { local.y - start.y };
|
||||
@@ -161,162 +293,6 @@ impl Session {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn drag_move(&mut self, drag: MoveDrag, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
let MoveDrag { ids, start, origs, moved, duplicate, enter_text } = drag;
|
||||
let mut d = p - start;
|
||||
if !moved && (d.hypot() < tol.max(1.0) || origs.is_empty()) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if !moved {
|
||||
self.begin(if duplicate { "Duplicate" } else { "Move" });
|
||||
if duplicate {
|
||||
// Alt-drag: drag a copy, leave the original.
|
||||
self.execute_dup_in_place(&ids)?;
|
||||
}
|
||||
}
|
||||
let ids_now = if duplicate { self.doc()?.selection.shapes.clone() } else { ids.clone() };
|
||||
if mods.shift {
|
||||
if d.x.abs() > d.y.abs() {
|
||||
d.y = 0.0;
|
||||
} else {
|
||||
d.x = 0.0;
|
||||
}
|
||||
}
|
||||
// Smart guides on the moved group's bounds.
|
||||
let bounds = origs.iter().map(|(_, x)| x.bounds()).reduce(|a, b| a.union(b)).unwrap_or_default();
|
||||
let moved_b = bounds + d;
|
||||
let (snap, guides) = self.snap_rect(moved_b, &ids_now, tol * 1.5, !mods.cmd);
|
||||
d += snap;
|
||||
self.tool.guides = guides;
|
||||
let origs2: Vec<(ShapeId, Xfrm)> = if duplicate { ids_now.iter().copied().zip(origs.iter().map(|o| o.1)).collect() } else { origs.clone() };
|
||||
self.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("move", "no slide"))?;
|
||||
for (id, o) in &origs2 {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(list, *id) {
|
||||
sh.xfrm = Some(Xfrm { x: o.x + d.x, y: o.y + d.y, ..*o });
|
||||
}
|
||||
}
|
||||
// A connector dragged away from shapes that stay put comes unglued at those ends.
|
||||
let moved: Vec<ShapeId> = origs2.iter().map(|o| o.0).collect();
|
||||
for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) {
|
||||
if let ShapeKind::Connector { start, end } = &mut sh.kind {
|
||||
for c in [start, end] {
|
||||
if c.is_some_and(|(to, _)| !moved.contains(&to)) {
|
||||
*c = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
self.tool.drag =
|
||||
Some(Drag::Move { ids: ids_now, start, origs: origs2, moved: true, duplicate: false, enter_text: enter_text.filter(|_| false) });
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn drag_adjust(&mut self, id: ShapeId, index: usize, orig: Xfrm, p: Point) -> Result<Value> {
|
||||
let st = self.doc()?;
|
||||
let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) };
|
||||
let local = orig.affine().inverse() * p;
|
||||
let geo = deckcraft_render::shape_geometry(&sh, orig.w, orig.h);
|
||||
let Some(h) = geo.handles.iter().find(|h| h.index == index) else { return Ok(Value::Null) };
|
||||
let free2d = matches!(sh.geom.preset_name(), Some(n) if n.contains("Callout") && !n.contains("Arrow"));
|
||||
let vals: Vec<(usize, f64)> = if free2d {
|
||||
let dx = (local.x - orig.w / 2.0) / orig.w.max(1e-6) * 1e5;
|
||||
let dy = (local.y - orig.h / 2.0) / orig.h.max(1e-6) * 1e5;
|
||||
vec![(0, dx.round()), (1, dy.round())]
|
||||
} else {
|
||||
vec![(index, h.value_at(local))]
|
||||
};
|
||||
self.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).ok_or_else(|| cmd::bad("adjust", "no slide"))?;
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(list, id)
|
||||
&& let deckcraft_model::Geom::Preset { name, adj } = &mut sh.geom
|
||||
{
|
||||
let defaults = deckcraft_geom::preset::info(name).map(|x| x.defaults).unwrap_or(&[]);
|
||||
for (i, v) in &vals {
|
||||
while adj.len() <= *i {
|
||||
let k = adj.len();
|
||||
adj.push(defaults.get(k).copied().unwrap_or(0.0));
|
||||
}
|
||||
if let Some(slot) = adj.get_mut(*i) {
|
||||
*slot = *v;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
fn drag_create(&mut self, start: Point, p: Point, mods: Mods, tol: f64) {
|
||||
let line = matches!(&self.tool.kind, ToolKind::Shape { preset } if deckcraft_geom::preset::is_line_like(preset));
|
||||
let x = if line {
|
||||
let mut q = if mods.shift { constrain_45(start, p) } else { p };
|
||||
if let Some((_, _, at)) = self.update_sites(q, tol, None) {
|
||||
q = at;
|
||||
}
|
||||
let s0 = self.glue_at(start, tol).map(|g| g.2).unwrap_or(start);
|
||||
line_xfrm(s0, q, 0.0)
|
||||
} else if matches!(&self.tool.kind, ToolKind::Shape { preset } if preset == DRAW_TABLE)
|
||||
&& crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some()
|
||||
{
|
||||
// The pencil inside a table draws a straight border, across or down.
|
||||
let q = if (p.x - start.x).abs() >= (p.y - start.y).abs() { Point::new(p.x, start.y) } else { Point::new(start.x, p.y) };
|
||||
let r = Rect::from_points(start, q);
|
||||
Xfrm::new(r.x0, r.y0, r.width(), r.height())
|
||||
} else {
|
||||
let mut r = Rect::from_points(start, p);
|
||||
if mods.shift {
|
||||
let s = r.width().max(r.height());
|
||||
let sx = if p.x < start.x { -s } else { s };
|
||||
let sy = if p.y < start.y { -s } else { s };
|
||||
r = Rect::from_points(start, start + Vec2::new(sx, sy));
|
||||
}
|
||||
if mods.alt {
|
||||
let d = p - start;
|
||||
r = Rect::new(start.x - d.x.abs(), start.y - d.y.abs(), start.x + d.x.abs(), start.y + d.y.abs());
|
||||
}
|
||||
Xfrm::new(r.x0, r.y0, r.width(), r.height())
|
||||
};
|
||||
self.tool.preview = Some(x);
|
||||
}
|
||||
|
||||
fn drag_text_select(&mut self, shape: ShapeId, press: Option<TextSel>, p: Point) -> Result<Value> {
|
||||
// In a table, leaving the cell the drag started in selects whole cells, as in
|
||||
// PowerPoint; coming back to that cell selects text in it again.
|
||||
let st = self.doc()?;
|
||||
if let Some(start) = press.as_ref().and_then(|t| t.cell)
|
||||
&& let Some(f) = st.shape(shape).and_then(|sh| cmd::table::table_frame(st, sh))
|
||||
&& let Some(cell) = f.cell_at(f.local(p))
|
||||
{
|
||||
if cell != start {
|
||||
self.select(|_, sel| {
|
||||
sel.text = None;
|
||||
sel.shapes = vec![shape];
|
||||
sel.cells = Some((shape, start, cell));
|
||||
})?;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if st.selection.text.is_none() {
|
||||
self.select(|_, sel| {
|
||||
sel.cells = None;
|
||||
sel.text = press;
|
||||
})?;
|
||||
}
|
||||
}
|
||||
let st = self.doc()?;
|
||||
if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) {
|
||||
let pos = cmd::text::text_pos_at(st, t, p);
|
||||
self.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.caret = pos;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Moves a vertex during a drag (one undo step for the whole drag).
|
||||
fn edit_vertex(&mut self, id: ShapeId, path: usize, index: usize, p: Point) -> Result<()> {
|
||||
let r = cmd::points::move_point_raw(self, id, path, index, p);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::hit::table_rows;
|
||||
use super::{Drag, Guide, HistoryEntry, Point, Rect, Result, Session, Shape, ShapeId, ShapeKind, ToolKind, Value, Vec2, Xfrm, cmd, json, xfrm_of};
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
/// End the freeform being drawn: create the shape (if it has at least two points).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Affine, Hit, Point, Rect, Shape, ShapeId, ShapeKind, TextSel, Vec2, Xfrm, cmd, xfrm_of};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn is_text_first(st: &crate::DocState, id: ShapeId) -> bool {
|
||||
st.shape(id).is_some_and(|s| s.ph.is_some() || s.text_box)
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
use super::hit::{EditZone, contains_rect, edit_zone, edited_box, hit_shape, in_table_cell, is_text_first, shape_in_front};
|
||||
use super::{
|
||||
Arc, DRAW_TABLE, Drag, HistoryEntry, Hit, Mods, Point, PointerEvent, PointerKind, Rect, Result, Session, ShapeId, ShapeKind, TABLE_ERASER,
|
||||
TextTarget, ToolKind, Value, Xfrm, box_handles, cmd, is_freeform_tool, json, wordart_handles, xfrm_of,
|
||||
};
|
||||
use super::*;
|
||||
|
||||
impl Session {
|
||||
pub fn set_tool(&mut self, kind: ToolKind) {
|
||||
@@ -455,11 +452,12 @@ impl Session {
|
||||
self.execute("guide.move", &json!({"index": index, "pos": pos}))?
|
||||
};
|
||||
}
|
||||
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. })
|
||||
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => {
|
||||
// Plain click on text: caret at the click (in a table, in the clicked cell).
|
||||
if self.edit_text_at(id, at)? => {
|
||||
if self.edit_text_at(id, at)? {
|
||||
out = json!({"editing": id});
|
||||
}
|
||||
}
|
||||
Some(Drag::Marquee { start, add }) => {
|
||||
let r = Rect::from_points(start, p);
|
||||
self.tool.marquee = None;
|
||||
|
||||
@@ -393,22 +393,6 @@ fn legacy_names(f: &skrifa::FontRef<'_>, family: &str) -> Option<(String, String
|
||||
Some((legacy, name(f, &[StringId::SUBFAMILY_NAME]).unwrap_or_else(|| "Regular".into())))
|
||||
}
|
||||
|
||||
/// The Windows family and style a variable font's named instance answers to. Windows lists each
|
||||
/// instance other than Regular, Italic, Bold and Bold Italic as a family of its own, as the
|
||||
/// family's static files name themselves: "Montserrat ExtraBold" / "Regular" for the ExtraBold
|
||||
/// instance, "Montserrat ExtraBold" / "Italic" for ExtraBold Italic.
|
||||
fn instance_legacy_names(family: &str, style: &str) -> Option<(String, String)> {
|
||||
if style.contains('{') {
|
||||
return None;
|
||||
}
|
||||
let weight: Vec<&str> = style.split_whitespace().filter(|w| !matches!(norm(w).as_str(), "italic" | "oblique")).collect();
|
||||
let weight = weight.join(" ");
|
||||
if matches!(norm(&weight).as_str(), "" | "regular" | "bold") {
|
||||
return None;
|
||||
}
|
||||
Some((format!("{family} {weight}"), if style_italic(style) { "Italic" } else { "Regular" }.into()))
|
||||
}
|
||||
|
||||
/// The tables skrifa reads glyph outlines from: `glyf` (TrueType), `CFF `, `CFF2` and `VARC`. For a
|
||||
/// face with none of them (bitmaps only, or outlines in Apple's `hvgl` table), [`FontDb::outline`]
|
||||
/// returns an empty path for every glyph.
|
||||
@@ -620,55 +604,21 @@ fn fontconfig_dirs(text: &str, data_home: Option<&str>, home: Option<&std::path:
|
||||
/// axis settings.
|
||||
type Found = (u32, String, String, Vec<([u8; 4], f32)>);
|
||||
|
||||
/// Maximum number of faces we enumerate from a single font collection.
|
||||
const MAX_COLLECTION_FACES: u32 = 256;
|
||||
/// Maximum collection entries inspected while looking for distinct table directories.
|
||||
const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384;
|
||||
/// Maximum named instances read from a single face.
|
||||
const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024;
|
||||
|
||||
fn distinct_face_indices(entries: impl IntoIterator<Item = (u32, u32)>) -> Vec<u32> {
|
||||
let mut offsets = std::collections::HashSet::new();
|
||||
let mut indices = Vec::with_capacity(MAX_COLLECTION_FACES as usize);
|
||||
for (index, offset) in entries.into_iter().take(MAX_COLLECTION_ENTRIES_SCANNED as usize) {
|
||||
if offsets.insert(offset) {
|
||||
indices.push(index);
|
||||
if indices.len() == MAX_COLLECTION_FACES as usize {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
indices
|
||||
}
|
||||
|
||||
fn capped_named_instance_count(count: usize) -> usize {
|
||||
count.min(MAX_NAMED_INSTANCES_PER_FACE)
|
||||
}
|
||||
|
||||
/// Parse every face in `data` (a font file or collection); a variable font yields one face per
|
||||
/// named instance (InDesign lists them as styles).
|
||||
fn enumerate_faces(data: &[u8]) -> Vec<Found> {
|
||||
let indices = match FileRef::new(data) {
|
||||
Ok(FileRef::Font(_)) => vec![0],
|
||||
Ok(FileRef::Collection(c)) => {
|
||||
let entries = (0..c.len().min(MAX_COLLECTION_ENTRIES_SCANNED)).filter_map(|i| {
|
||||
let start = 12usize.checked_add((i as usize).checked_mul(4)?)?;
|
||||
let end = start.checked_add(4)?;
|
||||
let offset = u32::from_be_bytes(data.get(start..end)?.try_into().ok()?);
|
||||
Some((i, offset))
|
||||
});
|
||||
distinct_face_indices(entries)
|
||||
}
|
||||
Err(_) => Vec::new(),
|
||||
let count = match FileRef::new(data) {
|
||||
Ok(FileRef::Font(_)) => 1,
|
||||
Ok(FileRef::Collection(c)) => c.len(),
|
||||
Err(_) => 0,
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for i in indices {
|
||||
for i in 0..count {
|
||||
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
|
||||
let Some((family, style)) = face_names(&f) else { continue };
|
||||
let axes = f.axes();
|
||||
let mut named = Vec::new();
|
||||
let instances = f.named_instances();
|
||||
for ni in instances.iter().take(capped_named_instance_count(instances.len())) {
|
||||
for ni in f.named_instances().iter() {
|
||||
let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue };
|
||||
if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
|
||||
continue;
|
||||
@@ -736,7 +686,7 @@ fn make_face(bytes: FontBytes, index: u32, family: String, style: String, coords
|
||||
.map(|v| v >= 0.5)
|
||||
.or(axis(b"slnt").map(|v| v.abs() > 0.1))
|
||||
.unwrap_or(!matches!(a.style, skrifa::attribute::Style::Normal) || style_italic(&style));
|
||||
let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { instance_legacy_names(&family, &style) };
|
||||
let legacy = if coords.is_empty() { legacy_names(&f, &family) } else { None };
|
||||
Some(FontFace {
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
@@ -1485,24 +1435,6 @@ mod font_dir_tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn duplicate_collection_offsets_do_not_consume_face_cap() {
|
||||
let mut entries = vec![(0, 7); MAX_COLLECTION_FACES as usize];
|
||||
entries.extend((0..MAX_COLLECTION_FACES).map(|i| (i + MAX_COLLECTION_FACES, i + 8)));
|
||||
let faces = distinct_face_indices(entries);
|
||||
assert_eq!(faces.len(), MAX_COLLECTION_FACES as usize);
|
||||
assert_eq!(faces.first(), Some(&0));
|
||||
assert_eq!(faces.get(1), Some(&MAX_COLLECTION_FACES));
|
||||
assert_eq!(faces.last(), Some(&(MAX_COLLECTION_FACES * 2 - 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn named_instance_limit_is_bounded_per_face() {
|
||||
assert_eq!(capped_named_instance_count(0), 0);
|
||||
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE), MAX_NAMED_INSTANCES_PER_FACE);
|
||||
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xdg_data_dirs_contribute_their_fonts_folders() {
|
||||
let home = Path::new("/home/ann");
|
||||
|
||||
@@ -147,36 +147,6 @@ fn windows_family_names_find_their_faces() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_font_instances_answer_to_their_windows_names() {
|
||||
// Windows lists a variable font's named instances other than Regular, Italic, Bold and
|
||||
// Bold Italic as families of their own, and documents name them that way ("Montserrat
|
||||
// ExtraBold"). The bundled Montserrat is variable, so these names found no face and drew
|
||||
// in Inter Regular, even with the family's static files installed (their faces are
|
||||
// skipped as already present).
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
if !db.is_loaded("Montserrat") {
|
||||
return; // built without craft-fonts
|
||||
}
|
||||
for (name, style, weight, italic) in [
|
||||
("Montserrat ExtraBold", "Regular", 800.0, false),
|
||||
("Montserrat SemiBold", "Regular", 600.0, false),
|
||||
("Montserrat Medium", "Regular", 500.0, false),
|
||||
("Montserrat Medium", "Italic", 500.0, true),
|
||||
("Montserrat Thin", "Bold", 100.0, false),
|
||||
] {
|
||||
assert!(db.has_family(name), "{name}");
|
||||
let f = db.face(name, style);
|
||||
assert_eq!((f.family.as_str(), f.weight, f.italic), ("Montserrat", weight, italic), "{name} {style}");
|
||||
assert_eq!(db.document_face(name, style).family, "Montserrat", "{name}: installed, so not Calibri");
|
||||
}
|
||||
// Regular and Bold are the family itself, not families of their own.
|
||||
assert!(!db.has_family("Montserrat Regular"));
|
||||
assert!(!db.has_family("Montserrat Bold"));
|
||||
assert_eq!(db.face("Montserrat", "Bold").weight, 700.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn faces_without_outlines_are_not_cataloged() {
|
||||
// Rename the Semibold's `glyf` table, as in fonts whose glyphs are bitmaps or are stored in
|
||||
|
||||
@@ -4,24 +4,7 @@ use super::builder::{
|
||||
};
|
||||
use super::catalog::{FillMode, Geometry, Handle, info};
|
||||
use super::shapes::{action_button, cloud, math, ribbon, scroll, wedge_rect};
|
||||
use super::{Arc, BezPath, Point, Rect, Vec2, extra};
|
||||
|
||||
/// Size and adjust values shared by every preset-shape builder.
|
||||
struct Ctx<'a> {
|
||||
name: &'a str,
|
||||
w: f64,
|
||||
h: f64,
|
||||
adj: &'a [f64],
|
||||
defaults: &'a [f64],
|
||||
}
|
||||
|
||||
impl Ctx<'_> {
|
||||
/// Adjust value `i`: the caller's value, else the preset default, else 0 (non-finite values become 0).
|
||||
fn a(&self, i: usize) -> f64 {
|
||||
let v = self.adj.get(i).copied().or_else(|| self.defaults.get(i).copied()).unwrap_or(0.0);
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
|
||||
/// Builds the geometry for preset `name` at size `w`×`h` with adjust values `adj` (missing
|
||||
/// values use the preset's defaults). Unknown names return `None`; the caller falls back to `rect`.
|
||||
@@ -29,25 +12,17 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||
if !(w.is_finite() && h.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let w = w.max(0.0);
|
||||
let h = h.max(0.0);
|
||||
let defaults = info(name).map(|i| i.defaults).unwrap_or(&[]);
|
||||
let c = Ctx { name, w: w.max(0.0), h: h.max(0.0), adj, defaults };
|
||||
rect_and_triangle_shapes(&c)
|
||||
.or_else(|| polygon_and_symbol_shapes(&c))
|
||||
.or_else(|| bracket_and_arrow_shapes(&c))
|
||||
.or_else(|| flowchart_and_star_shapes(&c))
|
||||
.or_else(|| callout_wave_and_connector_shapes(&c))
|
||||
.or_else(|| extra::build(name, c.w, c.h, &|i| c.a(i)))
|
||||
}
|
||||
|
||||
fn rect_and_triangle_shapes(c: &Ctx) -> Option<Geometry> {
|
||||
let name = c.name;
|
||||
let w = c.w;
|
||||
let h = c.h;
|
||||
let ss = c.w.min(c.h);
|
||||
let full = Rect::new(0.0, 0.0, c.w, c.h);
|
||||
let a = |i: usize| c.a(i);
|
||||
let a = |i: usize| -> f64 {
|
||||
let v = adj.get(i).copied().or_else(|| defaults.get(i).copied()).unwrap_or(0.0);
|
||||
if v.is_finite() { v } else { 0.0 }
|
||||
};
|
||||
let ss = w.min(h);
|
||||
let full = Rect::new(0.0, 0.0, w, h);
|
||||
let mut b = Pb::new();
|
||||
let g = match c.name {
|
||||
let g = match name {
|
||||
"rect" | "textBox" | "flowChartProcess" => simple(corner_rect(w, h, [Corner::Square; 4]), full, w, h),
|
||||
"actionButtonBlank"
|
||||
| "actionButtonBackPrevious"
|
||||
@@ -183,20 +158,6 @@ fn rect_and_triangle_shapes(c: &Ctx) -> Option<Geometry> {
|
||||
sites: mid_sites(w, h),
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(g)
|
||||
}
|
||||
|
||||
fn polygon_and_symbol_shapes(c: &Ctx) -> Option<Geometry> {
|
||||
let name = c.name;
|
||||
let w = c.w;
|
||||
let h = c.h;
|
||||
let ss = c.w.min(c.h);
|
||||
let full = Rect::new(0.0, 0.0, c.w, c.h);
|
||||
let a = |i: usize| c.a(i);
|
||||
let mut b = Pb::new();
|
||||
let g = match c.name {
|
||||
"pentagon" => simple(b.poly(&polygon_regular(5, w, h, -90.0)).take(), Rect::new(w * 0.2, h * 0.27, w * 0.8, h * 0.9), w, h),
|
||||
"heptagon" => simple(b.poly(&polygon_regular(7, w, h, -90.0)).take(), Rect::new(w * 0.16, h * 0.2, w * 0.84, h * 0.86), w, h),
|
||||
"decagon" => simple(b.poly(&polygon_regular(10, w, h, 0.0)).take(), Rect::new(w * 0.1, h * 0.17, w * 0.9, h * 0.83), w, h),
|
||||
@@ -587,20 +548,6 @@ fn polygon_and_symbol_shapes(c: &Ctx) -> Option<Geometry> {
|
||||
let dy4 = (ss * ss - g13 * g13).max(0.0).sqrt() * (h / 2.0) / ssf;
|
||||
simple(b.take(), Rect::new(g12 * w / ssf, h / 2.0 - dy4, g0w, h / 2.0 + dy4), w, h)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(g)
|
||||
}
|
||||
|
||||
fn bracket_and_arrow_shapes(c: &Ctx) -> Option<Geometry> {
|
||||
let name = c.name;
|
||||
let w = c.w;
|
||||
let h = c.h;
|
||||
let ss = c.w.min(c.h);
|
||||
let full = Rect::new(0.0, 0.0, c.w, c.h);
|
||||
let a = |i: usize| c.a(i);
|
||||
let mut b = Pb::new();
|
||||
let g = match c.name {
|
||||
"cloud" | "cloudCallout" => cloud(name, w, h, a(0), a(1)),
|
||||
"bracketPair" => {
|
||||
let r = ss * a(0).clamp(0.0, 50000.0) / 1e5;
|
||||
@@ -879,20 +826,6 @@ fn bracket_and_arrow_shapes(c: &Ctx) -> Option<Geometry> {
|
||||
simple(b.poly(&pts).take(), Rect::from_points(Point::new(tr[0].0, tr[0].1), Point::new(tr[1].0, tr[1].1)).abs(), w, h)
|
||||
}
|
||||
"mathPlus" | "mathMinus" | "mathEqual" | "mathDivide" | "mathMultiply" | "mathNotEqual" => math(name, w, h, a(0), a(1), a(2)),
|
||||
_ => return None,
|
||||
};
|
||||
Some(g)
|
||||
}
|
||||
|
||||
fn flowchart_and_star_shapes(c: &Ctx) -> Option<Geometry> {
|
||||
let name = c.name;
|
||||
let w = c.w;
|
||||
let h = c.h;
|
||||
let ss = c.w.min(c.h);
|
||||
let full = Rect::new(0.0, 0.0, c.w, c.h);
|
||||
let a = |i: usize| c.a(i);
|
||||
let mut b = Pb::new();
|
||||
let g = match c.name {
|
||||
"flowChartPredefinedProcess" => {
|
||||
let r = Pb::new().poly(&[(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)]).take();
|
||||
let mut l = Pb::new();
|
||||
@@ -1101,20 +1034,6 @@ fn flowchart_and_star_shapes(c: &Ctx) -> Option<Geometry> {
|
||||
);
|
||||
simple(b.poly(&pts).take(), Rect::new(w * 5372.0 / 21600.0, h * 6382.0 / 21600.0, w * 14640.0 / 21600.0, h * 15935.0 / 21600.0), w, h)
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(g)
|
||||
}
|
||||
|
||||
fn callout_wave_and_connector_shapes(c: &Ctx) -> Option<Geometry> {
|
||||
let name = c.name;
|
||||
let w = c.w;
|
||||
let h = c.h;
|
||||
let ss = c.w.min(c.h);
|
||||
let full = Rect::new(0.0, 0.0, c.w, c.h);
|
||||
let a = |i: usize| c.a(i);
|
||||
let mut b = Pb::new();
|
||||
let g = match c.name {
|
||||
"ribbon" | "ribbon2" => ribbon(name == "ribbon2", w, h, a(0), a(1)),
|
||||
"verticalScroll" | "horizontalScroll" => scroll(name == "verticalScroll", w, h, a(0)),
|
||||
"wave" | "doubleWave" => {
|
||||
@@ -1198,7 +1117,7 @@ fn callout_wave_and_connector_shapes(c: &Ctx) -> Option<Geometry> {
|
||||
let l = Pb::new().m(0.0, 0.0).c(x / 2.0, 0.0, x, h / 4.0, x, h / 2.0).c(x, h * 0.75, (x + w) / 2.0, h, w, h).take();
|
||||
Geometry { paths: vec![stroke_only(l)], text_rect: full, handles: vec![], sites: vec![Point::ZERO, Point::new(w, h)] }
|
||||
}
|
||||
_ => return None,
|
||||
_ => return extra::build(name, w, h, &a),
|
||||
};
|
||||
Some(g)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::catalog::{FillMode, Geometry, Handle, SubPath};
|
||||
use super::{Arc, BezPath, Point, Rect, Vec2};
|
||||
use super::*;
|
||||
|
||||
// ---------- path builder ----------
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::builder::vis;
|
||||
use super::{BezPath, Deserialize, Point, Rect, Serialize, Vec2};
|
||||
use super::*;
|
||||
|
||||
/// How a sub-path is filled. `Lighten`/`Darken` variants shade the shape's fill (3-D looking faces).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::builder::{Corner, Pb, corner_rect, fill, mid_sites, shade, simple, stroke_only, tip_handle};
|
||||
use super::catalog::{FillMode, Geometry};
|
||||
use super::{Point, Rect};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry {
|
||||
let tip = Point::new(w / 2.0 + w * a0 / 1e5, h / 2.0 + h * a1 / 1e5);
|
||||
|
||||
+63
-76
@@ -302,12 +302,71 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) {
|
||||
let top = usable(if my > 0 { Some(addr - mb_w) } else { None });
|
||||
let alpha_off = sq.alpha_offset;
|
||||
let beta_off = sq.beta_offset;
|
||||
let bs = boundary_strengths(pic, q, sq, [left, top]);
|
||||
let t8 = q.transform_8x8;
|
||||
// bS[dir][edge][segment]; dir 0 = vertical edges (x), 1 = horizontal edges (y)
|
||||
let mut bs = [[[0u8; 4]; 4]; 2];
|
||||
if q.kind.is_intra() {
|
||||
for dir in 0..2 {
|
||||
if (if dir == 0 { left } else { top }).is_some() {
|
||||
bs[dir][0] = [4; 4];
|
||||
}
|
||||
for e in 1..4 {
|
||||
if !(t8 && e % 2 == 1) {
|
||||
bs[dir][e] = [3; 4];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let qids = ref_ids8(q, sq);
|
||||
let q_uniform = (0..2).all(|l| {
|
||||
let r = q.ref_idx[l];
|
||||
r[1] == r[0] && r[2] == r[0] && r[3] == r[0] && q.mv[l].iter().all(|m| *m == q.mv[l][0])
|
||||
});
|
||||
let qm: [Motion; 16] = std::array::from_fn(|r| block_motion(q, &qids, r));
|
||||
for dir in 0..2 {
|
||||
let neighbor = if dir == 0 { left } else { top };
|
||||
if let Some(n) = neighbor {
|
||||
let p = &pic.mbs[n];
|
||||
if p.kind.is_intra() {
|
||||
bs[dir][0] = [4; 4];
|
||||
} else {
|
||||
let pids = ref_ids8(p, &pic.slices[p.slice_num as usize]);
|
||||
for k in 0..4 {
|
||||
let (rq, rp) = if dir == 0 { (k * 4, k * 4 + 3) } else { (k, 12 + k) };
|
||||
bs[dir][0][k] = if (p.nz_mask >> rp) & 1 != 0 || (q.nz_mask >> rq) & 1 != 0 {
|
||||
2
|
||||
} else {
|
||||
motion_bs(&block_motion(p, &pids, rp), &qm[rq])
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if q_uniform && q.nz_mask == 0 {
|
||||
// one motion for the whole MB and no coefficients: all internal edges have bS 0
|
||||
continue;
|
||||
}
|
||||
for e in 1..4 {
|
||||
if t8 && e % 2 == 1 {
|
||||
continue;
|
||||
}
|
||||
for k in 0..4 {
|
||||
let (rq, rp) = if dir == 0 { (k * 4 + e, k * 4 + e - 1) } else { (e * 4 + k, e * 4 + k - 4) };
|
||||
bs[dir][e][k] = if (q.nz_mask >> rp) & 1 != 0 || (q.nz_mask >> rq) & 1 != 0 { 2 } else { motion_bs(&qm[rp], &qm[rq]) };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let qp_of = |st: &MbState| if st.kind == MbKind::IPcm { 0 } else { st.qp as i32 };
|
||||
let q_qp = qp_of(q);
|
||||
let q_qpc = q.qpc;
|
||||
let p_qp = [left.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc)), top.map(|n| (qp_of(&pic.mbs[n]), pic.mbs[n].qpc))];
|
||||
let offsets = (alpha_off, beta_off);
|
||||
let params = |qpp: i32, qpq: i32, b: [u8; 4]| {
|
||||
let qpav = (qpp + qpq + 1) >> 1;
|
||||
let index_a = (qpav + alpha_off).clamp(0, 51) as usize;
|
||||
let index_b = (qpav + beta_off).clamp(0, 51) as usize;
|
||||
let tc = |b: u8| if (1..4).contains(&b) { TC0[index_a][b as usize - 1] as i32 } else { 0 };
|
||||
EdgeParams { bs: b, tc0: [tc(b[0]), tc(b[1]), tc(b[2]), tc(b[3])], alpha: ALPHA[index_a] as i32, beta: BETA[index_b] as i32 }
|
||||
};
|
||||
let width = pic.planes.width;
|
||||
let cwidth = pic.planes.cwidth;
|
||||
// luma
|
||||
@@ -317,7 +376,7 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) {
|
||||
continue;
|
||||
}
|
||||
let qpp = if e == 0 { p_qp[dir].map(|p| p.0).unwrap_or(q_qp) } else { q_qp };
|
||||
let ep = edge_params(offsets, qpp, q_qp, bs[dir][e]);
|
||||
let ep = params(qpp, q_qp, bs[dir][e]);
|
||||
if ep.alpha == 0 || ep.beta == 0 {
|
||||
// filterSamplesFlag needs |p0 - q0| < alpha and |p1 - p0| < beta (8.7.2.2): no
|
||||
// sample of the edge changes (low QPs)
|
||||
@@ -339,7 +398,7 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) {
|
||||
continue;
|
||||
}
|
||||
let qpp = if e == 0 { p_qp[dir].map(|p| p.1[c]).unwrap_or(q_qpc[c]) } else { q_qpc[c] } as i32;
|
||||
let ep = edge_params(offsets, qpp, q_qpc[c] as i32, bs[dir][e]);
|
||||
let ep = params(qpp, q_qpc[c] as i32, bs[dir][e]);
|
||||
if ep.alpha == 0 || ep.beta == 0 {
|
||||
continue;
|
||||
}
|
||||
@@ -354,78 +413,6 @@ pub fn deblock_mb(pic: &mut PicState, addr: usize, mb_w: usize) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filter parameters of one edge from the neighbouring QPs and the slice's alpha/beta offsets.
|
||||
fn edge_params((alpha_off, beta_off): (i32, i32), qpp: i32, qpq: i32, b: [u8; 4]) -> EdgeParams {
|
||||
let qpav = (qpp + qpq + 1) >> 1;
|
||||
let index_a = (qpav + alpha_off).clamp(0, 51) as usize;
|
||||
let index_b = (qpav + beta_off).clamp(0, 51) as usize;
|
||||
let tc = |b: u8| if (1..4).contains(&b) { TC0[index_a][b as usize - 1] as i32 } else { 0 };
|
||||
EdgeParams { bs: b, tc0: [tc(b[0]), tc(b[1]), tc(b[2]), tc(b[3])], alpha: ALPHA[index_a] as i32, beta: BETA[index_b] as i32 }
|
||||
}
|
||||
|
||||
/// bS[dir][edge][segment]; dir 0 = vertical edges (x), 1 = horizontal edges (y). `neighbors` is
|
||||
/// `[left, top]`.
|
||||
fn boundary_strengths(pic: &PicState, q: &MbState, sq: &SliceInfo, neighbors: [Option<usize>; 2]) -> [[[u8; 4]; 4]; 2] {
|
||||
if q.kind.is_intra() { intra_strengths(q.transform_8x8, neighbors) } else { inter_strengths(pic, q, sq, neighbors) }
|
||||
}
|
||||
|
||||
fn intra_strengths(t8: bool, neighbors: [Option<usize>; 2]) -> [[[u8; 4]; 4]; 2] {
|
||||
let mut bs = [[[0u8; 4]; 4]; 2];
|
||||
for dir in 0..2 {
|
||||
if neighbors[dir].is_some() {
|
||||
bs[dir][0] = [4; 4];
|
||||
}
|
||||
for e in 1..4 {
|
||||
if !(t8 && e % 2 == 1) {
|
||||
bs[dir][e] = [3; 4];
|
||||
}
|
||||
}
|
||||
}
|
||||
bs
|
||||
}
|
||||
|
||||
fn inter_strengths(pic: &PicState, q: &MbState, sq: &SliceInfo, neighbors: [Option<usize>; 2]) -> [[[u8; 4]; 4]; 2] {
|
||||
let mut bs = [[[0u8; 4]; 4]; 2];
|
||||
let t8 = q.transform_8x8;
|
||||
let qids = ref_ids8(q, sq);
|
||||
let q_uniform = (0..2).all(|l| {
|
||||
let r = q.ref_idx[l];
|
||||
r[1] == r[0] && r[2] == r[0] && r[3] == r[0] && q.mv[l].iter().all(|m| *m == q.mv[l][0])
|
||||
});
|
||||
let qm: [Motion; 16] = std::array::from_fn(|r| block_motion(q, &qids, r));
|
||||
for dir in 0..2 {
|
||||
if let Some(n) = neighbors[dir] {
|
||||
bs[dir][0] = edge_strengths_to_neighbor(&pic.mbs[n], &pic.slices[pic.mbs[n].slice_num as usize], q, &qm, dir);
|
||||
}
|
||||
if q_uniform && q.nz_mask == 0 {
|
||||
// one motion for the whole MB and no coefficients: all internal edges have bS 0
|
||||
continue;
|
||||
}
|
||||
for e in 1..4 {
|
||||
if t8 && e % 2 == 1 {
|
||||
continue;
|
||||
}
|
||||
for k in 0..4 {
|
||||
let (rq, rp) = if dir == 0 { (k * 4 + e, k * 4 + e - 1) } else { (e * 4 + k, e * 4 + k - 4) };
|
||||
bs[dir][e][k] = if (q.nz_mask >> rp) & 1 != 0 || (q.nz_mask >> rq) & 1 != 0 { 2 } else { motion_bs(&qm[rp], &qm[rq]) };
|
||||
}
|
||||
}
|
||||
}
|
||||
bs
|
||||
}
|
||||
|
||||
/// bS of the macroblock edge shared with neighbour `p` (left for `dir` 0, top for `dir` 1).
|
||||
fn edge_strengths_to_neighbor(p: &MbState, p_slice: &SliceInfo, q: &MbState, qm: &[Motion; 16], dir: usize) -> [u8; 4] {
|
||||
if p.kind.is_intra() {
|
||||
return [4; 4];
|
||||
}
|
||||
let pids = ref_ids8(p, p_slice);
|
||||
std::array::from_fn(|k| {
|
||||
let (rq, rp) = if dir == 0 { (k * 4, k * 4 + 3) } else { (k, 12 + k) };
|
||||
if (p.nz_mask >> rp) & 1 != 0 || (q.nz_mask >> rq) & 1 != 0 { 2 } else { motion_bs(&block_motion(p, &pids, rp), &qm[rq]) }
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+29
-30
@@ -557,16 +557,16 @@ impl Decoder {
|
||||
|
||||
/// Decode all slices of one picture and publish its rows.
|
||||
fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared, draft: bool) {
|
||||
let reuse = shared.pool.lock().ok().and_then(|mut p| p.pop());
|
||||
let mut pic = match reuse {
|
||||
Some(mut p) if p.mb_w == frame.mb_w && p.mb_h == frame.mb_h() => {
|
||||
p.reset(frame.clone());
|
||||
p
|
||||
}
|
||||
_ => PicState::new(frame.clone()),
|
||||
};
|
||||
pic.skip_deblock = draft;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let reuse = shared.pool.lock().ok().and_then(|mut p| p.pop());
|
||||
let mut pic = match reuse {
|
||||
Some(mut p) if p.mb_w == frame.mb_w && p.mb_h == frame.mb_h() => {
|
||||
p.reset(frame.clone());
|
||||
p
|
||||
}
|
||||
_ => PicState::new(frame.clone()),
|
||||
};
|
||||
pic.skip_deblock = draft;
|
||||
let mut first_err = None;
|
||||
for s in &slices {
|
||||
if let Err(e) = decode_slice(s, &mut pic) {
|
||||
@@ -574,10 +574,10 @@ fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared, draft: bool)
|
||||
}
|
||||
}
|
||||
pic.flush_rows(true);
|
||||
(pic, first_err)
|
||||
first_err
|
||||
}));
|
||||
let (pic, err) = match result {
|
||||
Ok((pic, err)) => (Some(pic), err),
|
||||
let err = match result {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
// Make sure readers never block on this frame.
|
||||
let fallback = Planes::gray(frame.width, frame.height);
|
||||
@@ -587,35 +587,34 @@ fn run_job(frame: FrameRef, slices: Vec<SliceJob>, shared: &Shared, draft: bool)
|
||||
frame.publish(r, Frame::make_row(&fallback, r, &blank, &|_, _, _| u32::MAX));
|
||||
}
|
||||
}
|
||||
(None, Some(Error::Invalid("internal error while decoding a picture".into())))
|
||||
pic = PicState::new(frame.clone());
|
||||
Some(Error::Invalid("internal error while decoding a picture".into()))
|
||||
}
|
||||
};
|
||||
if let Ok(mut s) = shared.stats.lock() {
|
||||
s.pictures += 1;
|
||||
s.draft_pictures += draft as u64;
|
||||
if let Some(pic) = &pic {
|
||||
for st in &pic.mbs {
|
||||
match st.kind {
|
||||
MbKind::I4x4 => s.mb_i4x4 += 1,
|
||||
MbKind::I8x8 => s.mb_i8x8 += 1,
|
||||
MbKind::I16x16 => s.mb_i16x16 += 1,
|
||||
MbKind::IPcm => s.mb_pcm += 1,
|
||||
MbKind::PSkip => s.mb_p_skip += 1,
|
||||
MbKind::BSkip => s.mb_b_skip += 1,
|
||||
MbKind::BDirect16x16 => s.mb_b_direct16x16 += 1,
|
||||
MbKind::Inter => s.mb_inter += 1,
|
||||
MbKind::None => {}
|
||||
}
|
||||
if st.transform_8x8 && !st.kind.is_intra() {
|
||||
s.mb_inter_8x8_transform += 1;
|
||||
}
|
||||
for st in &pic.mbs {
|
||||
match st.kind {
|
||||
MbKind::I4x4 => s.mb_i4x4 += 1,
|
||||
MbKind::I8x8 => s.mb_i8x8 += 1,
|
||||
MbKind::I16x16 => s.mb_i16x16 += 1,
|
||||
MbKind::IPcm => s.mb_pcm += 1,
|
||||
MbKind::PSkip => s.mb_p_skip += 1,
|
||||
MbKind::BSkip => s.mb_b_skip += 1,
|
||||
MbKind::BDirect16x16 => s.mb_b_direct16x16 += 1,
|
||||
MbKind::Inter => s.mb_inter += 1,
|
||||
MbKind::None => {}
|
||||
}
|
||||
if st.transform_8x8 && !st.kind.is_intra() {
|
||||
s.mb_inter_8x8_transform += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let (Some(e), Ok(mut slot)) = (err, shared.error.lock()) {
|
||||
slot.get_or_insert(e);
|
||||
}
|
||||
if let (Some(pic), Ok(mut p)) = (pic, shared.pool.lock())
|
||||
if let Ok(mut p) = shared.pool.lock()
|
||||
&& p.len() < 4
|
||||
{
|
||||
p.push(pic);
|
||||
|
||||
+116
-92
@@ -61,102 +61,126 @@ fn load_edge(plane: &[u8], stride: usize, x: usize, y: usize, n: usize, a: Avail
|
||||
|
||||
/// Directional / DC prediction shared by Intra_4x4 and Intra_8x8 (8.3.1.2.x, 8.3.2.2.x).
|
||||
fn pred_nxn(mode: u8, e: &Edge, n: i32, a: Avail, out: &mut [u8], stride: usize) {
|
||||
let mut fill = |value: &dyn Fn(i32, i32) -> i32| {
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
out[y as usize * stride + x as usize] = value(x, y) as u8;
|
||||
let mut put = |x: i32, y: i32, v: i32| out[y as usize * stride + x as usize] = v as u8;
|
||||
match mode {
|
||||
0 => {
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
put(x, y, e.t(x));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
match mode {
|
||||
0 => fill(&|x, _| e.t(x)),
|
||||
1 => fill(&|_, y| e.l(y)),
|
||||
1 => {
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
put(x, y, e.l(y));
|
||||
}
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
let dc = dc_value(e, n, a);
|
||||
fill(&|_, _| dc);
|
||||
let shift = if n == 4 { 2 } else { 3 };
|
||||
let st: i32 = (0..n).map(|x| e.t(x)).sum();
|
||||
let sl: i32 = (0..n).map(|y| e.l(y)).sum();
|
||||
let dc = match (a.top, a.left) {
|
||||
(true, true) => (st + sl + n) >> (shift + 1),
|
||||
(false, true) => (sl + (n >> 1)) >> shift,
|
||||
(true, false) => (st + (n >> 1)) >> shift,
|
||||
_ => 128,
|
||||
};
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
put(x, y, dc);
|
||||
}
|
||||
}
|
||||
}
|
||||
3 => {
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
let v = if x == n - 1 && y == n - 1 {
|
||||
(e.t(2 * n - 2) + 3 * e.t(2 * n - 1) + 2) >> 2
|
||||
} else {
|
||||
(e.t(x + y) + 2 * e.t(x + y + 1) + e.t(x + y + 2) + 2) >> 2
|
||||
};
|
||||
put(x, y, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
4 => {
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
let v = if x > y {
|
||||
(e.t(x - y - 2) + 2 * e.t(x - y - 1) + e.t(x - y) + 2) >> 2
|
||||
} else if x < y {
|
||||
(e.l(y - x - 2) + 2 * e.l(y - x - 1) + e.l(y - x) + 2) >> 2
|
||||
} else {
|
||||
(e.t(0) + 2 * e.tl + e.l(0) + 2) >> 2
|
||||
};
|
||||
put(x, y, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
5 => {
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
let z = 2 * x - y;
|
||||
let v = if z >= 0 && z % 2 == 0 {
|
||||
(e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 1) >> 1
|
||||
} else if z > 0 {
|
||||
(e.t(x - (y >> 1) - 2) + 2 * e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 2) >> 2
|
||||
} else if z == -1 {
|
||||
(e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2
|
||||
} else {
|
||||
(e.l(y - 2 * x - 1) + 2 * e.l(y - 2 * x - 2) + e.l(y - 2 * x - 3) + 2) >> 2
|
||||
};
|
||||
put(x, y, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
6 => {
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
let z = 2 * y - x;
|
||||
let v = if z >= 0 && z % 2 == 0 {
|
||||
(e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 1) >> 1
|
||||
} else if z > 0 {
|
||||
(e.l(y - (x >> 1) - 2) + 2 * e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 2) >> 2
|
||||
} else if z == -1 {
|
||||
(e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2
|
||||
} else {
|
||||
(e.t(x - 2 * y - 1) + 2 * e.t(x - 2 * y - 2) + e.t(x - 2 * y - 3) + 2) >> 2
|
||||
};
|
||||
put(x, y, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
7 => {
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
let i = x + (y >> 1);
|
||||
let v = if y % 2 == 0 { (e.t(i) + e.t(i + 1) + 1) >> 1 } else { (e.t(i) + 2 * e.t(i + 1) + e.t(i + 2) + 2) >> 2 };
|
||||
put(x, y, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let zmax = 2 * n - 3;
|
||||
for y in 0..n {
|
||||
for x in 0..n {
|
||||
let z = x + 2 * y;
|
||||
let i = y + (x >> 1);
|
||||
let v = if z < zmax && z % 2 == 0 {
|
||||
(e.l(i) + e.l(i + 1) + 1) >> 1
|
||||
} else if z < zmax {
|
||||
(e.l(i) + 2 * e.l(i + 1) + e.l(i + 2) + 2) >> 2
|
||||
} else if z == zmax {
|
||||
(e.l(n - 2) + 3 * e.l(n - 1) + 2) >> 2
|
||||
} else {
|
||||
e.l(n - 1)
|
||||
};
|
||||
put(x, y, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
3 => fill(&|x, y| diagonal_down_left(e, n, x, y)),
|
||||
4 => fill(&|x, y| diagonal_down_right(e, x, y)),
|
||||
5 => fill(&|x, y| vertical_right(e, x, y)),
|
||||
6 => fill(&|x, y| horizontal_down(e, x, y)),
|
||||
7 => fill(&|x, y| vertical_left(e, x, y)),
|
||||
_ => fill(&|x, y| horizontal_up(e, n, x, y)),
|
||||
}
|
||||
}
|
||||
|
||||
fn dc_value(e: &Edge, n: i32, a: Avail) -> i32 {
|
||||
let shift = if n == 4 { 2 } else { 3 };
|
||||
let st: i32 = (0..n).map(|x| e.t(x)).sum();
|
||||
let sl: i32 = (0..n).map(|y| e.l(y)).sum();
|
||||
match (a.top, a.left) {
|
||||
(true, true) => (st + sl + n) >> (shift + 1),
|
||||
(false, true) => (sl + (n >> 1)) >> shift,
|
||||
(true, false) => (st + (n >> 1)) >> shift,
|
||||
_ => 128,
|
||||
}
|
||||
}
|
||||
|
||||
fn diagonal_down_left(e: &Edge, n: i32, x: i32, y: i32) -> i32 {
|
||||
if x == n - 1 && y == n - 1 {
|
||||
(e.t(2 * n - 2) + 3 * e.t(2 * n - 1) + 2) >> 2
|
||||
} else {
|
||||
(e.t(x + y) + 2 * e.t(x + y + 1) + e.t(x + y + 2) + 2) >> 2
|
||||
}
|
||||
}
|
||||
|
||||
fn diagonal_down_right(e: &Edge, x: i32, y: i32) -> i32 {
|
||||
if x > y {
|
||||
(e.t(x - y - 2) + 2 * e.t(x - y - 1) + e.t(x - y) + 2) >> 2
|
||||
} else if x < y {
|
||||
(e.l(y - x - 2) + 2 * e.l(y - x - 1) + e.l(y - x) + 2) >> 2
|
||||
} else {
|
||||
(e.t(0) + 2 * e.tl + e.l(0) + 2) >> 2
|
||||
}
|
||||
}
|
||||
|
||||
fn vertical_right(e: &Edge, x: i32, y: i32) -> i32 {
|
||||
let z = 2 * x - y;
|
||||
if z >= 0 && z % 2 == 0 {
|
||||
(e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 1) >> 1
|
||||
} else if z > 0 {
|
||||
(e.t(x - (y >> 1) - 2) + 2 * e.t(x - (y >> 1) - 1) + e.t(x - (y >> 1)) + 2) >> 2
|
||||
} else if z == -1 {
|
||||
(e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2
|
||||
} else {
|
||||
(e.l(y - 2 * x - 1) + 2 * e.l(y - 2 * x - 2) + e.l(y - 2 * x - 3) + 2) >> 2
|
||||
}
|
||||
}
|
||||
|
||||
fn horizontal_down(e: &Edge, x: i32, y: i32) -> i32 {
|
||||
let z = 2 * y - x;
|
||||
if z >= 0 && z % 2 == 0 {
|
||||
(e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 1) >> 1
|
||||
} else if z > 0 {
|
||||
(e.l(y - (x >> 1) - 2) + 2 * e.l(y - (x >> 1) - 1) + e.l(y - (x >> 1)) + 2) >> 2
|
||||
} else if z == -1 {
|
||||
(e.l(0) + 2 * e.tl + e.t(0) + 2) >> 2
|
||||
} else {
|
||||
(e.t(x - 2 * y - 1) + 2 * e.t(x - 2 * y - 2) + e.t(x - 2 * y - 3) + 2) >> 2
|
||||
}
|
||||
}
|
||||
|
||||
fn vertical_left(e: &Edge, x: i32, y: i32) -> i32 {
|
||||
let i = x + (y >> 1);
|
||||
if y % 2 == 0 { (e.t(i) + e.t(i + 1) + 1) >> 1 } else { (e.t(i) + 2 * e.t(i + 1) + e.t(i + 2) + 2) >> 2 }
|
||||
}
|
||||
|
||||
fn horizontal_up(e: &Edge, n: i32, x: i32, y: i32) -> i32 {
|
||||
let zmax = 2 * n - 3;
|
||||
let z = x + 2 * y;
|
||||
let i = y + (x >> 1);
|
||||
if z < zmax && z % 2 == 0 {
|
||||
(e.l(i) + e.l(i + 1) + 1) >> 1
|
||||
} else if z < zmax {
|
||||
(e.l(i) + 2 * e.l(i + 1) + e.l(i + 2) + 2) >> 2
|
||||
} else if z == zmax {
|
||||
(e.l(n - 2) + 3 * e.l(n - 1) + 2) >> 2
|
||||
} else {
|
||||
e.l(n - 1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+96
-110
@@ -142,107 +142,6 @@ pub struct SliceHeader {
|
||||
pub header_bits: usize,
|
||||
}
|
||||
|
||||
/// ref_pic_list_modification() for list 0 and list 1.
|
||||
fn read_ref_pic_list_mods(r: &mut BitReader, slice_type: SliceType) -> Result<[Vec<RefPicListMod>; 2]> {
|
||||
let mut ref_pic_list_mod = [Vec::new(), Vec::new()];
|
||||
for (list, mods) in ref_pic_list_mod.iter_mut().enumerate() {
|
||||
let present = if list == 0 { !slice_type.is_intra() } else { slice_type.is_b() };
|
||||
if present && r.read_flag()? {
|
||||
loop {
|
||||
let idc = r.read_ue()?;
|
||||
ensure!(idc <= 5, "modification_of_pic_nums_idc out of range");
|
||||
if idc == 3 {
|
||||
break;
|
||||
}
|
||||
let value = r.read_ue()?;
|
||||
mods.push(RefPicListMod { idc, value });
|
||||
ensure!(mods.len() <= 66, "too many ref_pic_list_modification entries");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ref_pic_list_mod)
|
||||
}
|
||||
|
||||
/// pred_weight_table().
|
||||
fn read_pred_weight_table(r: &mut BitReader, sps: &Sps, slice_type: SliceType, num_ref_idx_active: [u32; 2]) -> Result<PredWeightTable> {
|
||||
let luma_log2_denom = r.read_ue()?;
|
||||
ensure!(luma_log2_denom <= 7, "luma_log2_weight_denom out of range");
|
||||
let has_chroma = sps.chroma_array_type() != 0;
|
||||
let mut chroma_log2_denom = 0;
|
||||
if has_chroma {
|
||||
chroma_log2_denom = r.read_ue()?;
|
||||
ensure!(chroma_log2_denom <= 7, "chroma_log2_weight_denom out of range");
|
||||
}
|
||||
let mut lists = [Vec::new(), Vec::new()];
|
||||
let nlists = if slice_type.is_b() { 2 } else { 1 };
|
||||
for (l, entries) in lists.iter_mut().enumerate().take(nlists) {
|
||||
for _ in 0..num_ref_idx_active[l] {
|
||||
entries.push(read_weight_entry(r, has_chroma, luma_log2_denom, chroma_log2_denom)?);
|
||||
}
|
||||
}
|
||||
let [l0, l1] = lists;
|
||||
Ok(PredWeightTable { luma_log2_denom, chroma_log2_denom, l0, l1 })
|
||||
}
|
||||
|
||||
fn read_weight_entry(r: &mut BitReader, has_chroma: bool, luma_log2_denom: u32, chroma_log2_denom: u32) -> Result<WeightEntry> {
|
||||
let mut e = WeightEntry { luma_weight: 1 << luma_log2_denom, chroma_weight: [1 << chroma_log2_denom; 2], ..Default::default() };
|
||||
if r.read_flag()? {
|
||||
e.luma_flag = true;
|
||||
e.luma_weight = r.read_se()?;
|
||||
e.luma_offset = r.read_se()?;
|
||||
ensure!((-128..=127).contains(&e.luma_weight) && (-128..=127).contains(&e.luma_offset), "luma weight out of range");
|
||||
}
|
||||
if has_chroma && r.read_flag()? {
|
||||
e.chroma_flag = true;
|
||||
for j in 0..2 {
|
||||
e.chroma_weight[j] = r.read_se()?;
|
||||
e.chroma_offset[j] = r.read_se()?;
|
||||
ensure!((-128..=127).contains(&e.chroma_weight[j]) && (-128..=127).contains(&e.chroma_offset[j]), "chroma weight out of range");
|
||||
}
|
||||
}
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
/// The memory_management_control_operation list of dec_ref_pic_marking().
|
||||
fn read_mmcos(r: &mut BitReader) -> Result<Vec<Mmco>> {
|
||||
let mut mmcos = Vec::new();
|
||||
loop {
|
||||
let op = r.read_ue()?;
|
||||
ensure!(op <= 6, "memory_management_control_operation out of range");
|
||||
if op == 0 {
|
||||
return Ok(mmcos);
|
||||
}
|
||||
let mut m = Mmco { op, difference_of_pic_nums_minus1: 0, long_term_pic_num: 0, long_term_frame_idx: 0, max_long_term_frame_idx_plus1: 0 };
|
||||
if op == 1 || op == 3 {
|
||||
m.difference_of_pic_nums_minus1 = r.read_ue()?;
|
||||
}
|
||||
if op == 2 {
|
||||
m.long_term_pic_num = r.read_ue()?;
|
||||
}
|
||||
if op == 3 || op == 6 {
|
||||
m.long_term_frame_idx = r.read_ue()?;
|
||||
}
|
||||
if op == 4 {
|
||||
m.max_long_term_frame_idx_plus1 = r.read_ue()?;
|
||||
}
|
||||
mmcos.push(m);
|
||||
ensure!(mmcos.len() <= 66, "too many MMCOs");
|
||||
}
|
||||
}
|
||||
|
||||
/// Deblocking filter control: (disable_deblocking_filter_idc, alpha_c0_offset_div2, beta_offset_div2).
|
||||
fn read_deblocking_params(r: &mut BitReader) -> Result<(u32, i32, i32)> {
|
||||
let disable_deblocking_filter_idc = r.read_ue()?;
|
||||
ensure!(disable_deblocking_filter_idc <= 2, "disable_deblocking_filter_idc out of range");
|
||||
if disable_deblocking_filter_idc == 1 {
|
||||
return Ok((disable_deblocking_filter_idc, 0, 0));
|
||||
}
|
||||
let alpha = r.read_se()?;
|
||||
let beta = r.read_se()?;
|
||||
ensure!((-6..=6).contains(&alpha) && (-6..=6).contains(&beta), "deblocking offsets out of range");
|
||||
Ok((disable_deblocking_filter_idc, alpha, beta))
|
||||
}
|
||||
|
||||
impl SliceHeader {
|
||||
pub fn qp(&self, pps: &Pps) -> i32 {
|
||||
pps.pic_init_qp.saturating_add(self.slice_qp_delta)
|
||||
@@ -315,12 +214,59 @@ impl SliceHeader {
|
||||
num_ref_idx_active[0] = 0;
|
||||
}
|
||||
ensure!(nal.nal_unit_type != 20 && nal.nal_unit_type != 21, "MVC slices are not supported");
|
||||
let ref_pic_list_mod = read_ref_pic_list_mods(&mut r, slice_type)?;
|
||||
let pred_weight_table = if (pps.weighted_pred && slice_type.is_p()) || (pps.weighted_bipred_idc == 1 && slice_type.is_b()) {
|
||||
Some(read_pred_weight_table(&mut r, sps, slice_type, num_ref_idx_active)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let mut ref_pic_list_mod = [Vec::new(), Vec::new()];
|
||||
for (list, mods) in ref_pic_list_mod.iter_mut().enumerate() {
|
||||
let present = if list == 0 { !slice_type.is_intra() } else { slice_type.is_b() };
|
||||
if present && r.read_flag()? {
|
||||
loop {
|
||||
let idc = r.read_ue()?;
|
||||
ensure!(idc <= 5, "modification_of_pic_nums_idc out of range");
|
||||
if idc == 3 {
|
||||
break;
|
||||
}
|
||||
let value = r.read_ue()?;
|
||||
mods.push(RefPicListMod { idc, value });
|
||||
ensure!(mods.len() <= 66, "too many ref_pic_list_modification entries");
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut pred_weight_table = None;
|
||||
if (pps.weighted_pred && slice_type.is_p()) || (pps.weighted_bipred_idc == 1 && slice_type.is_b()) {
|
||||
let luma_log2_denom = r.read_ue()?;
|
||||
ensure!(luma_log2_denom <= 7, "luma_log2_weight_denom out of range");
|
||||
let mut chroma_log2_denom = 0;
|
||||
if sps.chroma_array_type() != 0 {
|
||||
chroma_log2_denom = r.read_ue()?;
|
||||
ensure!(chroma_log2_denom <= 7, "chroma_log2_weight_denom out of range");
|
||||
}
|
||||
let mut lists = [Vec::new(), Vec::new()];
|
||||
let nlists = if slice_type.is_b() { 2 } else { 1 };
|
||||
for (l, entries) in lists.iter_mut().enumerate().take(nlists) {
|
||||
for _ in 0..num_ref_idx_active[l] {
|
||||
let mut e = WeightEntry { luma_weight: 1 << luma_log2_denom, chroma_weight: [1 << chroma_log2_denom; 2], ..Default::default() };
|
||||
if r.read_flag()? {
|
||||
e.luma_flag = true;
|
||||
e.luma_weight = r.read_se()?;
|
||||
e.luma_offset = r.read_se()?;
|
||||
ensure!((-128..=127).contains(&e.luma_weight) && (-128..=127).contains(&e.luma_offset), "luma weight out of range");
|
||||
}
|
||||
if sps.chroma_array_type() != 0 && r.read_flag()? {
|
||||
e.chroma_flag = true;
|
||||
for j in 0..2 {
|
||||
e.chroma_weight[j] = r.read_se()?;
|
||||
e.chroma_offset[j] = r.read_se()?;
|
||||
ensure!(
|
||||
(-128..=127).contains(&e.chroma_weight[j]) && (-128..=127).contains(&e.chroma_offset[j]),
|
||||
"chroma weight out of range"
|
||||
);
|
||||
}
|
||||
}
|
||||
entries.push(e);
|
||||
}
|
||||
}
|
||||
let [l0, l1] = lists;
|
||||
pred_weight_table = Some(PredWeightTable { luma_log2_denom, chroma_log2_denom, l0, l1 });
|
||||
}
|
||||
let mut no_output_of_prior_pics = false;
|
||||
let mut long_term_reference = false;
|
||||
let mut adaptive_ref_pic_marking = false;
|
||||
@@ -332,7 +278,34 @@ impl SliceHeader {
|
||||
} else {
|
||||
adaptive_ref_pic_marking = r.read_flag()?;
|
||||
if adaptive_ref_pic_marking {
|
||||
mmcos = read_mmcos(&mut r)?;
|
||||
loop {
|
||||
let op = r.read_ue()?;
|
||||
ensure!(op <= 6, "memory_management_control_operation out of range");
|
||||
if op == 0 {
|
||||
break;
|
||||
}
|
||||
let mut m = Mmco {
|
||||
op,
|
||||
difference_of_pic_nums_minus1: 0,
|
||||
long_term_pic_num: 0,
|
||||
long_term_frame_idx: 0,
|
||||
max_long_term_frame_idx_plus1: 0,
|
||||
};
|
||||
if op == 1 || op == 3 {
|
||||
m.difference_of_pic_nums_minus1 = r.read_ue()?;
|
||||
}
|
||||
if op == 2 {
|
||||
m.long_term_pic_num = r.read_ue()?;
|
||||
}
|
||||
if op == 3 || op == 6 {
|
||||
m.long_term_frame_idx = r.read_ue()?;
|
||||
}
|
||||
if op == 4 {
|
||||
m.max_long_term_frame_idx_plus1 = r.read_ue()?;
|
||||
}
|
||||
mmcos.push(m);
|
||||
ensure!(mmcos.len() <= 66, "too many MMCOs");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -352,8 +325,21 @@ impl SliceHeader {
|
||||
}
|
||||
slice_qs_delta = r.read_se()?;
|
||||
}
|
||||
let (disable_deblocking_filter_idc, slice_alpha_c0_offset_div2, slice_beta_offset_div2) =
|
||||
if pps.deblocking_filter_control_present { read_deblocking_params(&mut r)? } else { (0, 0, 0) };
|
||||
let mut disable_deblocking_filter_idc = 0;
|
||||
let mut slice_alpha_c0_offset_div2 = 0;
|
||||
let mut slice_beta_offset_div2 = 0;
|
||||
if pps.deblocking_filter_control_present {
|
||||
disable_deblocking_filter_idc = r.read_ue()?;
|
||||
ensure!(disable_deblocking_filter_idc <= 2, "disable_deblocking_filter_idc out of range");
|
||||
if disable_deblocking_filter_idc != 1 {
|
||||
slice_alpha_c0_offset_div2 = r.read_se()?;
|
||||
slice_beta_offset_div2 = r.read_se()?;
|
||||
ensure!(
|
||||
(-6..=6).contains(&slice_alpha_c0_offset_div2) && (-6..=6).contains(&slice_beta_offset_div2),
|
||||
"deblocking offsets out of range"
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut slice_group_change_cycle = 0;
|
||||
if pps.num_slice_groups > 1 && (3..=5).contains(&pps.slice_group_map_type) {
|
||||
// Ceil(Log2(PicSizeInMapUnits / SliceGroupChangeRate + 1)); rate is not retained, so read
|
||||
|
||||
+13
-15
@@ -561,9 +561,9 @@ fn run_job(
|
||||
draft: bool,
|
||||
shared: &Shared,
|
||||
) {
|
||||
let mut pic = PicState::new(frame.clone(), sps, pps, layout, scaling);
|
||||
pic.draft = draft;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut pic = PicState::new(frame.clone(), sps, pps, layout, scaling);
|
||||
pic.draft = draft;
|
||||
let mut first_err = None;
|
||||
for s in &slices {
|
||||
if let Err(e) = SliceDecoder::decode(&mut pic, s) {
|
||||
@@ -571,10 +571,10 @@ fn run_job(
|
||||
}
|
||||
}
|
||||
crate::filter::finish(&mut pic);
|
||||
(pic, first_err)
|
||||
first_err
|
||||
}));
|
||||
let (pic, err) = match result {
|
||||
Ok((pic, err)) => (Some(pic), err),
|
||||
let err = match result {
|
||||
Ok(e) => e,
|
||||
Err(_) => {
|
||||
// make sure readers never block on this frame
|
||||
let gray = Frame::gray(0, frame.poc, &frame);
|
||||
@@ -584,20 +584,18 @@ fn run_job(
|
||||
frame.publish(r, crate::picture::FrameRow { y: row.y.clone(), cb: row.cb.clone(), cr: row.cr.clone(), col: row.col.clone() });
|
||||
}
|
||||
}
|
||||
(None, Some(Error::Invalid("internal error while decoding a picture".into())))
|
||||
Some(Error::Invalid("internal error while decoding a picture".into()))
|
||||
}
|
||||
};
|
||||
if let Ok(mut s) = shared.stats.lock() {
|
||||
s.pictures += 1;
|
||||
if let Some(pic) = &pic {
|
||||
for b in &pic.blk {
|
||||
if b.flags & F_INTRA != 0 {
|
||||
s.intra_4x4_blocks += 1;
|
||||
} else if b.flags & F_SKIP != 0 {
|
||||
s.skip_4x4_blocks += 1;
|
||||
} else if b.flags != 0 {
|
||||
s.inter_4x4_blocks += 1;
|
||||
}
|
||||
for b in &pic.blk {
|
||||
if b.flags & F_INTRA != 0 {
|
||||
s.intra_4x4_blocks += 1;
|
||||
} else if b.flags & F_SKIP != 0 {
|
||||
s.skip_4x4_blocks += 1;
|
||||
} else if b.flags != 0 {
|
||||
s.inter_4x4_blocks += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+147
-190
@@ -154,194 +154,6 @@ impl SliceHeader {
|
||||
(st + lt) as u32
|
||||
}
|
||||
|
||||
/// Fields present only in independent slice segments.
|
||||
fn parse_independent_fields(&mut self, r: &mut BitReader, nal: NalHeader, pps: &Pps, sps: &Sps) -> Result<()> {
|
||||
r.skip(pps.num_extra_slice_header_bits as usize)?;
|
||||
let st = r.read_ue()?;
|
||||
self.slice_type = match st {
|
||||
0 => SliceType::B,
|
||||
1 => SliceType::P,
|
||||
2 => SliceType::I,
|
||||
_ => return invalid(format!("slice_type {st}")),
|
||||
};
|
||||
if nal.is_irap() {
|
||||
ensure!(self.slice_type == SliceType::I, "IRAP picture with inter slice");
|
||||
}
|
||||
if pps.output_flag_present {
|
||||
self.pic_output = r.read_flag()?;
|
||||
}
|
||||
if sps.separate_colour_plane {
|
||||
r.skip(2)?;
|
||||
}
|
||||
if !nal.is_idr() {
|
||||
self.parse_reference_picture_set(r, sps)?;
|
||||
}
|
||||
if sps.sao {
|
||||
self.sao_luma = r.read_flag()?;
|
||||
if sps.chroma_array_type() != 0 {
|
||||
self.sao_chroma = r.read_flag()?;
|
||||
}
|
||||
}
|
||||
if self.slice_type != SliceType::I {
|
||||
self.parse_inter_params(r, pps, sps)?;
|
||||
}
|
||||
self.parse_qp_and_deblocking(r, pps, sps)
|
||||
}
|
||||
|
||||
/// POC LSB, short-term and long-term reference picture sets and the temporal MVP flag.
|
||||
fn parse_reference_picture_set(&mut self, r: &mut BitReader, sps: &Sps) -> Result<()> {
|
||||
self.poc_lsb = r.read_bits(sps.log2_max_poc_lsb)?;
|
||||
if !r.read_flag()? {
|
||||
let start = r.position();
|
||||
self.st_rps = StRps::parse(r, sps.st_rps.len(), &sps.st_rps, sps.st_rps.len())?;
|
||||
self.st_rps_bits = r.position() - start;
|
||||
} else {
|
||||
ensure!(!sps.st_rps.is_empty(), "no short-term RPS in the SPS");
|
||||
let idx = if sps.st_rps.len() > 1 { r.read_bits(ceil_log2(sps.st_rps.len() as u32))? as usize } else { 0 };
|
||||
ensure!(idx < sps.st_rps.len(), "short_term_ref_pic_set_idx out of range");
|
||||
self.st_rps = sps.st_rps[idx].clone();
|
||||
}
|
||||
if sps.long_term_refs_present {
|
||||
self.parse_long_term_pics(r, sps)?;
|
||||
}
|
||||
if sps.temporal_mvp {
|
||||
self.temporal_mvp = r.read_flag()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_long_term_pics(&mut self, r: &mut BitReader, sps: &Sps) -> Result<()> {
|
||||
let num_lt_sps = if !sps.lt_ref_pics.is_empty() { r.read_ue()? } else { 0 };
|
||||
ensure!(num_lt_sps as usize <= sps.lt_ref_pics.len(), "num_long_term_sps out of range");
|
||||
let num_lt_pics = r.read_ue()?;
|
||||
ensure!(num_lt_sps + num_lt_pics <= 32, "too many long-term pictures");
|
||||
let mut msb_acc = 0u32;
|
||||
for i in 0..num_lt_sps + num_lt_pics {
|
||||
let (lsb, used) = if i < num_lt_sps {
|
||||
let idx = if sps.lt_ref_pics.len() > 1 { r.read_bits(ceil_log2(sps.lt_ref_pics.len() as u32))? } else { 0 };
|
||||
ensure!((idx as usize) < sps.lt_ref_pics.len(), "lt_idx_sps out of range");
|
||||
sps.lt_ref_pics[idx as usize]
|
||||
} else {
|
||||
(r.read_bits(sps.log2_max_poc_lsb)?, r.read_flag()?)
|
||||
};
|
||||
let msb_present = r.read_flag()?;
|
||||
let cycle = if msb_present { r.read_ue()? } else { 0 };
|
||||
// DeltaPocMsbCycleLt accumulates within each of the two groups (7-52)
|
||||
if i == 0 || i == num_lt_sps {
|
||||
msb_acc = cycle;
|
||||
} else {
|
||||
msb_acc = msb_acc.wrapping_add(cycle);
|
||||
}
|
||||
self.lt.push((lsb, used, msb_present, msb_acc));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reference index counts, list modification, collocated picture, prediction weights and merge candidates (P and B slices).
|
||||
fn parse_inter_params(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> {
|
||||
self.num_ref_idx = [pps.num_ref_idx_l0_default, if self.is_b() { pps.num_ref_idx_l1_default } else { 0 }];
|
||||
if r.read_flag()? {
|
||||
self.num_ref_idx[0] = r.read_ue_plus(1)?;
|
||||
if self.is_b() {
|
||||
self.num_ref_idx[1] = r.read_ue_plus(1)?;
|
||||
}
|
||||
}
|
||||
ensure!(self.num_ref_idx[0] <= 15 && self.num_ref_idx[1] <= 15, "num_ref_idx_active out of range");
|
||||
let total = self.num_pic_total_curr();
|
||||
ensure!(total > 0, "inter slice without reference pictures in the RPS");
|
||||
if pps.lists_modification_present && total > 1 {
|
||||
self.parse_list_entries(r, total)?;
|
||||
}
|
||||
if self.is_b() {
|
||||
self.mvd_l1_zero = r.read_flag()?;
|
||||
}
|
||||
if pps.cabac_init_present {
|
||||
self.cabac_init_flag = r.read_flag()?;
|
||||
}
|
||||
if self.temporal_mvp {
|
||||
self.parse_collocated(r)?;
|
||||
}
|
||||
if (pps.weighted_pred && self.slice_type == SliceType::P) || (pps.weighted_bipred && self.is_b()) {
|
||||
self.pwt = Some(parse_pwt(r, self, sps)?);
|
||||
}
|
||||
let five_minus = r.read_ue()?;
|
||||
ensure!(five_minus <= 4, "five_minus_max_num_merge_cand out of range");
|
||||
self.max_num_merge_cand = 5 - five_minus;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_list_entries(&mut self, r: &mut BitReader, total: u32) -> Result<()> {
|
||||
let bits = ceil_log2(total);
|
||||
for l in 0..if self.is_b() { 2 } else { 1 } {
|
||||
if r.read_flag()? {
|
||||
let mut e = Vec::new();
|
||||
for _ in 0..self.num_ref_idx[l] {
|
||||
let v = r.read_bits(bits)?;
|
||||
ensure!(v < total, "list_entry out of range");
|
||||
e.push(v);
|
||||
}
|
||||
self.list_entry[l] = Some(e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_collocated(&mut self, r: &mut BitReader) -> Result<()> {
|
||||
if self.is_b() {
|
||||
self.collocated_from_l0 = r.read_flag()?;
|
||||
}
|
||||
let l = if self.collocated_from_l0 { 0 } else { 1 };
|
||||
if self.num_ref_idx[l] > 1 {
|
||||
self.collocated_ref_idx = r.read_ue()?;
|
||||
ensure!(self.collocated_ref_idx < self.num_ref_idx[l], "collocated_ref_idx out of range");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Slice QP delta, chroma QP offsets and the deblocking / loop filter overrides.
|
||||
fn parse_qp_and_deblocking(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> {
|
||||
self.qp_delta = r.read_se()?;
|
||||
let qp = pps.init_qp.checked_add(self.qp_delta).ok_or_else(|| crate::Error::Invalid("SliceQpY out of range".into()))?;
|
||||
let qp_bd = 6 * (sps.bit_depth_luma as i32 - 8);
|
||||
ensure!((-qp_bd..=51).contains(&qp), "SliceQpY {qp} out of range");
|
||||
if pps.slice_chroma_qp_offsets_present {
|
||||
self.cb_qp_offset = r.read_se()?;
|
||||
self.cr_qp_offset = r.read_se()?;
|
||||
ensure!((-12..=12).contains(&self.cb_qp_offset) && (-12..=12).contains(&self.cr_qp_offset), "slice chroma QP offset out of range");
|
||||
}
|
||||
let override_flag = if pps.deblocking_override_enabled { r.read_flag()? } else { false };
|
||||
if override_flag {
|
||||
self.deblocking_disabled = r.read_flag()?;
|
||||
if !self.deblocking_disabled {
|
||||
self.beta_offset_div2 = r.read_se()?;
|
||||
self.tc_offset_div2 = r.read_se()?;
|
||||
ensure!((-6..=6).contains(&self.beta_offset_div2) && (-6..=6).contains(&self.tc_offset_div2), "deblocking offsets out of range");
|
||||
}
|
||||
}
|
||||
if pps.loop_filter_across_slices && (self.sao_luma || self.sao_chroma || !self.deblocking_disabled) {
|
||||
self.loop_filter_across_slices = r.read_flag()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// entry_point_offset list (tiles / wavefront substreams).
|
||||
fn parse_entry_points(&mut self, r: &mut BitReader, pps: &Pps, sps: &Sps) -> Result<()> {
|
||||
self.entry_points.clear();
|
||||
if pps.tiles_enabled || pps.entropy_coding_sync {
|
||||
let n = r.read_ue()?;
|
||||
ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range");
|
||||
if n > 0 {
|
||||
let len = r.read_ue_plus(1)?;
|
||||
ensure!(len <= 32, "offset_len_minus1 out of range");
|
||||
for _ in 0..n {
|
||||
let off = r.read_bits(len)?.checked_add(1).ok_or_else(|| crate::Error::Invalid("entry_point_offset out of range".into()))?;
|
||||
self.entry_points.push(off);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a slice segment header. `prev` is the header of the preceding independent slice segment
|
||||
/// of the same picture (needed for dependent slice segments).
|
||||
pub fn parse(
|
||||
@@ -413,9 +225,154 @@ impl SliceHeader {
|
||||
sh.dependent = dependent;
|
||||
sh.segment_address = segment_address;
|
||||
if !dependent {
|
||||
sh.parse_independent_fields(&mut r, nal, &pps, &sps)?;
|
||||
r.skip(pps.num_extra_slice_header_bits as usize)?;
|
||||
let st = r.read_ue()?;
|
||||
sh.slice_type = match st {
|
||||
0 => SliceType::B,
|
||||
1 => SliceType::P,
|
||||
2 => SliceType::I,
|
||||
_ => return invalid(format!("slice_type {st}")),
|
||||
};
|
||||
if nal.is_irap() {
|
||||
ensure!(sh.slice_type == SliceType::I, "IRAP picture with inter slice");
|
||||
}
|
||||
if pps.output_flag_present {
|
||||
sh.pic_output = r.read_flag()?;
|
||||
}
|
||||
if sps.separate_colour_plane {
|
||||
r.skip(2)?;
|
||||
}
|
||||
if !nal.is_idr() {
|
||||
sh.poc_lsb = r.read_bits(sps.log2_max_poc_lsb)?;
|
||||
let from_sps = r.read_flag()?;
|
||||
if !from_sps {
|
||||
let start = r.position();
|
||||
sh.st_rps = StRps::parse(&mut r, sps.st_rps.len(), &sps.st_rps, sps.st_rps.len())?;
|
||||
sh.st_rps_bits = r.position() - start;
|
||||
} else {
|
||||
ensure!(!sps.st_rps.is_empty(), "no short-term RPS in the SPS");
|
||||
let idx = if sps.st_rps.len() > 1 { r.read_bits(ceil_log2(sps.st_rps.len() as u32))? as usize } else { 0 };
|
||||
ensure!(idx < sps.st_rps.len(), "short_term_ref_pic_set_idx out of range");
|
||||
sh.st_rps = sps.st_rps[idx].clone();
|
||||
}
|
||||
if sps.long_term_refs_present {
|
||||
let num_lt_sps = if !sps.lt_ref_pics.is_empty() { r.read_ue()? } else { 0 };
|
||||
ensure!(num_lt_sps as usize <= sps.lt_ref_pics.len(), "num_long_term_sps out of range");
|
||||
let num_lt_pics = r.read_ue()?;
|
||||
ensure!(num_lt_sps + num_lt_pics <= 32, "too many long-term pictures");
|
||||
let mut msb_acc = 0u32;
|
||||
for i in 0..num_lt_sps + num_lt_pics {
|
||||
let (lsb, used) = if i < num_lt_sps {
|
||||
let idx = if sps.lt_ref_pics.len() > 1 { r.read_bits(ceil_log2(sps.lt_ref_pics.len() as u32))? } else { 0 };
|
||||
ensure!((idx as usize) < sps.lt_ref_pics.len(), "lt_idx_sps out of range");
|
||||
sps.lt_ref_pics[idx as usize]
|
||||
} else {
|
||||
(r.read_bits(sps.log2_max_poc_lsb)?, r.read_flag()?)
|
||||
};
|
||||
let msb_present = r.read_flag()?;
|
||||
let cycle = if msb_present { r.read_ue()? } else { 0 };
|
||||
// DeltaPocMsbCycleLt accumulates within each of the two groups (7-52)
|
||||
if i == 0 || i == num_lt_sps {
|
||||
msb_acc = cycle;
|
||||
} else {
|
||||
msb_acc = msb_acc.wrapping_add(cycle);
|
||||
}
|
||||
sh.lt.push((lsb, used, msb_present, msb_acc));
|
||||
}
|
||||
}
|
||||
if sps.temporal_mvp {
|
||||
sh.temporal_mvp = r.read_flag()?;
|
||||
}
|
||||
}
|
||||
if sps.sao {
|
||||
sh.sao_luma = r.read_flag()?;
|
||||
if sps.chroma_array_type() != 0 {
|
||||
sh.sao_chroma = r.read_flag()?;
|
||||
}
|
||||
}
|
||||
if sh.slice_type != SliceType::I {
|
||||
sh.num_ref_idx = [pps.num_ref_idx_l0_default, if sh.is_b() { pps.num_ref_idx_l1_default } else { 0 }];
|
||||
if r.read_flag()? {
|
||||
sh.num_ref_idx[0] = r.read_ue_plus(1)?;
|
||||
if sh.is_b() {
|
||||
sh.num_ref_idx[1] = r.read_ue_plus(1)?;
|
||||
}
|
||||
}
|
||||
ensure!(sh.num_ref_idx[0] <= 15 && sh.num_ref_idx[1] <= 15, "num_ref_idx_active out of range");
|
||||
let total = sh.num_pic_total_curr();
|
||||
ensure!(total > 0, "inter slice without reference pictures in the RPS");
|
||||
if pps.lists_modification_present && total > 1 {
|
||||
let bits = ceil_log2(total);
|
||||
for l in 0..if sh.is_b() { 2 } else { 1 } {
|
||||
if r.read_flag()? {
|
||||
let mut e = Vec::new();
|
||||
for _ in 0..sh.num_ref_idx[l] {
|
||||
let v = r.read_bits(bits)?;
|
||||
ensure!(v < total, "list_entry out of range");
|
||||
e.push(v);
|
||||
}
|
||||
sh.list_entry[l] = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if sh.is_b() {
|
||||
sh.mvd_l1_zero = r.read_flag()?;
|
||||
}
|
||||
if pps.cabac_init_present {
|
||||
sh.cabac_init_flag = r.read_flag()?;
|
||||
}
|
||||
if sh.temporal_mvp {
|
||||
if sh.is_b() {
|
||||
sh.collocated_from_l0 = r.read_flag()?;
|
||||
}
|
||||
let l = if sh.collocated_from_l0 { 0 } else { 1 };
|
||||
if sh.num_ref_idx[l] > 1 {
|
||||
sh.collocated_ref_idx = r.read_ue()?;
|
||||
ensure!(sh.collocated_ref_idx < sh.num_ref_idx[l], "collocated_ref_idx out of range");
|
||||
}
|
||||
}
|
||||
if (pps.weighted_pred && sh.slice_type == SliceType::P) || (pps.weighted_bipred && sh.is_b()) {
|
||||
sh.pwt = Some(parse_pwt(&mut r, &sh, &sps)?);
|
||||
}
|
||||
let five_minus = r.read_ue()?;
|
||||
ensure!(five_minus <= 4, "five_minus_max_num_merge_cand out of range");
|
||||
sh.max_num_merge_cand = 5 - five_minus;
|
||||
}
|
||||
sh.qp_delta = r.read_se()?;
|
||||
let qp = pps.init_qp.checked_add(sh.qp_delta).ok_or_else(|| crate::Error::Invalid("SliceQpY out of range".into()))?;
|
||||
let qp_bd = 6 * (sps.bit_depth_luma as i32 - 8);
|
||||
ensure!((-qp_bd..=51).contains(&qp), "SliceQpY {qp} out of range");
|
||||
if pps.slice_chroma_qp_offsets_present {
|
||||
sh.cb_qp_offset = r.read_se()?;
|
||||
sh.cr_qp_offset = r.read_se()?;
|
||||
ensure!((-12..=12).contains(&sh.cb_qp_offset) && (-12..=12).contains(&sh.cr_qp_offset), "slice chroma QP offset out of range");
|
||||
}
|
||||
let override_flag = if pps.deblocking_override_enabled { r.read_flag()? } else { false };
|
||||
if override_flag {
|
||||
sh.deblocking_disabled = r.read_flag()?;
|
||||
if !sh.deblocking_disabled {
|
||||
sh.beta_offset_div2 = r.read_se()?;
|
||||
sh.tc_offset_div2 = r.read_se()?;
|
||||
ensure!((-6..=6).contains(&sh.beta_offset_div2) && (-6..=6).contains(&sh.tc_offset_div2), "deblocking offsets out of range");
|
||||
}
|
||||
}
|
||||
if pps.loop_filter_across_slices && (sh.sao_luma || sh.sao_chroma || !sh.deblocking_disabled) {
|
||||
sh.loop_filter_across_slices = r.read_flag()?;
|
||||
}
|
||||
}
|
||||
sh.entry_points.clear();
|
||||
if pps.tiles_enabled || pps.entropy_coding_sync {
|
||||
let n = r.read_ue()?;
|
||||
ensure!(n <= sps.pic_width_in_ctbs() * sps.pic_height_in_ctbs(), "num_entry_point_offsets out of range");
|
||||
if n > 0 {
|
||||
let len = r.read_ue_plus(1)?;
|
||||
ensure!(len <= 32, "offset_len_minus1 out of range");
|
||||
for _ in 0..n {
|
||||
let off = r.read_bits(len)?.checked_add(1).ok_or_else(|| crate::Error::Invalid("entry_point_offset out of range".into()))?;
|
||||
sh.entry_points.push(off);
|
||||
}
|
||||
}
|
||||
}
|
||||
sh.parse_entry_points(&mut r, &pps, &sps)?;
|
||||
if pps.slice_header_extension_present {
|
||||
let len = r.read_ue()?;
|
||||
ensure!(len <= 256, "slice_segment_header_extension_length out of range");
|
||||
|
||||
+190
-238
@@ -1105,52 +1105,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
let n = 1usize << log2;
|
||||
let bypass = self.cu_transquant_bypass;
|
||||
let ts = if self.pps.transform_skip && !bypass && log2 == 2 { self.dd(TRANSFORM_SKIP + (c > 0) as usize) == 1 } else { false };
|
||||
let scan_idx = self.scan_idx(x0, y0, log2, c);
|
||||
let (mut lx, mut ly) = self.last_sig_coeff_pos(log2, c);
|
||||
if scan_idx == 2 {
|
||||
std::mem::swap(&mut lx, &mut ly);
|
||||
}
|
||||
ensure!((lx as usize) < n && (ly as usize) < n, "last significant coefficient outside the block");
|
||||
let t = TbCtx { c, log2, scan_idx, sdh: self.pps.sign_data_hiding && !bypass };
|
||||
let log2sb = log2 - 2;
|
||||
let sb_scan = scan_order(log2sb, scan_idx);
|
||||
let pos_scan = &SCAN_4[scan_idx];
|
||||
let sb_w = 1usize << log2sb;
|
||||
let last_sb = sb_scan.iter().position(|&(x, y)| x as u32 == lx >> 2 && y as u32 == ly >> 2).unwrap_or(0);
|
||||
let last_pos = pos_scan.iter().position(|&(x, y)| x as u32 == lx & 3 && y as u32 == ly & 3).unwrap_or(0);
|
||||
self.coeffs[..n * n].fill(0);
|
||||
let mut csbf = [0u8; 64];
|
||||
let mut prev_g1: Option<u32> = None;
|
||||
let (mut max_x, mut max_y) = (0usize, 0usize);
|
||||
for i in (0..=last_sb).rev() {
|
||||
let sb = SubBlock { xs: sb_scan[i].0 as usize, ys: sb_scan[i].1 as usize };
|
||||
let right = if sb.xs + 1 < sb_w { csbf[sb.ys * 8 + sb.xs + 1] } else { 0 };
|
||||
let below = if sb.ys + 1 < sb_w { csbf[(sb.ys + 1) * 8 + sb.xs] } else { 0 };
|
||||
let mut infer_dc = false;
|
||||
if i < last_sb && i > 0 {
|
||||
let inc = (right | below).min(1) as usize + if c > 0 { 2 } else { 0 };
|
||||
csbf[sb.ys * 8 + sb.xs] = self.c.decode_decision(CODED_SUB_BLOCK + inc) as u8;
|
||||
infer_dc = true;
|
||||
} else {
|
||||
csbf[sb.ys * 8 + sb.xs] = 1;
|
||||
}
|
||||
if csbf[sb.ys * 8 + sb.xs] == 0 {
|
||||
continue;
|
||||
}
|
||||
let (sig, nsig) = self.sig_coeff_flags(&t, &sb, right + (below << 1), infer_dc, (i == last_sb).then_some(last_pos));
|
||||
if nsig == 0 {
|
||||
continue;
|
||||
}
|
||||
let g = self.greater_flags(&t, i, nsig, &mut prev_g1);
|
||||
let (mx, my) = self.store_levels(&t, &sb, &sig[..nsig], &g)?;
|
||||
max_x = max_x.max(mx);
|
||||
max_y = max_y.max(my);
|
||||
}
|
||||
self.reconstruct((x0, y0), log2, c, qp, ts, (max_x, max_y))
|
||||
}
|
||||
|
||||
/// Decodes last_sig_coeff_{x,y}_prefix / suffix (before any scan-order swap).
|
||||
fn last_sig_coeff_pos(&mut self, log2: u32, c: usize) -> (u32, u32) {
|
||||
// last significant coefficient
|
||||
let cmax = (log2 << 1) - 1;
|
||||
let (off, shift) = if c == 0 { (3 * (log2 - 2) + ((log2 - 1) >> 2), (log2 + 1) >> 2) } else { (15, log2 - 2) };
|
||||
let mut px = 0;
|
||||
@@ -1161,144 +1116,209 @@ impl<'a> SliceDecoder<'a> {
|
||||
while py < cmax && self.dd(LAST_Y_PREFIX + (off + (py >> shift)) as usize) == 1 {
|
||||
py += 1;
|
||||
}
|
||||
let lx = self.last_pos_with_suffix(px);
|
||||
let ly = self.last_pos_with_suffix(py);
|
||||
(lx, ly)
|
||||
}
|
||||
|
||||
fn last_pos_with_suffix(&mut self, prefix: u32) -> u32 {
|
||||
if prefix > 3 {
|
||||
let nb = (prefix >> 1) - 1;
|
||||
(1 << nb) * (2 + (prefix & 1)) + self.bypass_bits(nb)
|
||||
} else {
|
||||
prefix
|
||||
let mut lx = px;
|
||||
if px > 3 {
|
||||
let nb = (px >> 1) - 1;
|
||||
lx = (1 << nb) * (2 + (px & 1)) + self.bypass_bits(nb);
|
||||
}
|
||||
}
|
||||
|
||||
/// scanIdx (7.4.9.11): 0 diagonal, 1 horizontal, 2 vertical.
|
||||
fn scan_idx(&self, x0: i32, y0: i32, log2: u32, c: usize) -> usize {
|
||||
if !(self.cu_intra && (log2 == 2 || (log2 == 3 && c == 0))) {
|
||||
return 0;
|
||||
let mut ly = py;
|
||||
if py > 3 {
|
||||
let nb = (py >> 1) - 1;
|
||||
ly = (1 << nb) * (2 + (py & 1)) + self.bypass_bits(nb);
|
||||
}
|
||||
let m = if c == 0 { self.pic.blk_at(x0, y0).ipm as u32 } else { self.intra_chroma_mode };
|
||||
if (6..=14).contains(&m) {
|
||||
2
|
||||
} else if (22..=30).contains(&m) {
|
||||
1
|
||||
// scanIdx (7.4.9.11)
|
||||
let scan_idx = if self.cu_intra && (log2 == 2 || (log2 == 3 && c == 0)) {
|
||||
let m = if c == 0 { self.pic.blk_at(x0, y0).ipm as u32 } else { self.intra_chroma_mode };
|
||||
if (6..=14).contains(&m) {
|
||||
2
|
||||
} else if (22..=30).contains(&m) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Significance map of one coded sub-block: scan positions of the significant coefficients, in
|
||||
/// descending order, and their count.
|
||||
fn sig_coeff_flags(&mut self, t: &TbCtx, sb: &SubBlock, prev_csbf: u8, infer_dc: bool, last_pos: Option<usize>) -> ([u8; 16], usize) {
|
||||
let pos_scan = &SCAN_4[t.scan_idx];
|
||||
let c_off_sig = if t.c == 0 { 0 } else { 27 };
|
||||
let mut infer_dc = infer_dc;
|
||||
let mut sig = [0u8; 16];
|
||||
let mut nsig = 0;
|
||||
let start: i32 = match last_pos {
|
||||
Some(lp) => {
|
||||
sig[0] = lp as u8;
|
||||
nsig = 1;
|
||||
lp as i32 - 1
|
||||
}
|
||||
None => 15,
|
||||
};
|
||||
for np in (0..=start).rev() {
|
||||
let (xp, yp) = (pos_scan[np as usize].0 as usize, pos_scan[np as usize].1 as usize);
|
||||
if np > 0 || !infer_dc {
|
||||
let ctx = sig_coeff_ctx(t, sb, prev_csbf, (xp, yp));
|
||||
if self.c.decode_decision(SIG_COEFF + c_off_sig + ctx) == 1 {
|
||||
sig[nsig] = np as u8;
|
||||
nsig += 1;
|
||||
infer_dc = false;
|
||||
}
|
||||
} else {
|
||||
// np == 0 with inferSbDcSigCoeffFlag
|
||||
sig[nsig] = 0;
|
||||
nsig += 1;
|
||||
}
|
||||
if scan_idx == 2 {
|
||||
std::mem::swap(&mut lx, &mut ly);
|
||||
}
|
||||
(sig, nsig)
|
||||
}
|
||||
|
||||
/// coeff_abs_level_greater1_flag / greater2_flag of one sub-block with `nsig` significant coefficients.
|
||||
fn greater_flags(&mut self, t: &TbCtx, sb_index: usize, nsig: usize, prev_g1: &mut Option<u32>) -> GreaterFlags {
|
||||
let mut ctx_set = if sb_index == 0 || t.c > 0 { 0 } else { 2 };
|
||||
if *prev_g1 == Some(0) {
|
||||
ctx_set += 1;
|
||||
}
|
||||
let mut g1ctx = 1u32;
|
||||
let mut g = GreaterFlags { gt1: [0u8; 16], first_g1: None, gt2: 0 };
|
||||
let c_off_g1 = if t.c > 0 { 16 } else { 0 };
|
||||
for k in 0..nsig.min(8) {
|
||||
let inc = ctx_set * 4 + g1ctx.min(3) as usize + c_off_g1;
|
||||
let f = self.c.decode_decision(GT1 + inc);
|
||||
g.gt1[k] = f as u8;
|
||||
if f == 1 {
|
||||
g1ctx = 0;
|
||||
if g.first_g1.is_none() {
|
||||
g.first_g1 = Some(k);
|
||||
}
|
||||
} else if g1ctx > 0 {
|
||||
g1ctx += 1;
|
||||
}
|
||||
}
|
||||
*prev_g1 = Some(g1ctx);
|
||||
if g.first_g1.is_some() {
|
||||
g.gt2 = self.c.decode_decision(GT2 + ctx_set + if t.c > 0 { 4 } else { 0 }) as u8;
|
||||
}
|
||||
g
|
||||
}
|
||||
|
||||
/// Reads signs and remaining levels of one sub-block and stores the coefficients. Returns the
|
||||
/// largest x and y coefficient position written.
|
||||
fn store_levels(&mut self, t: &TbCtx, sb: &SubBlock, sig: &[u8], g: &GreaterFlags) -> Result<(usize, usize)> {
|
||||
let nsig = sig.len();
|
||||
let n = 1usize << t.log2;
|
||||
let pos_scan = &SCAN_4[t.scan_idx];
|
||||
let sign_hidden = t.sdh && sig[0] as i32 - sig[nsig - 1] as i32 > 3;
|
||||
let nsigns = if sign_hidden { nsig - 1 } else { nsig };
|
||||
let mut signs = if nsigns > 0 { self.bypass_bits(nsigns as u32) << (32 - nsigns) } else { 0 };
|
||||
let mut rice = 0u32;
|
||||
let mut sum_abs = 0i32;
|
||||
ensure!((lx as usize) < n && (ly as usize) < n, "last significant coefficient outside the block");
|
||||
let log2sb = log2 - 2;
|
||||
let sb_scan = scan_order(log2sb, scan_idx);
|
||||
let pos_scan = &SCAN_4[scan_idx];
|
||||
let sb_w = 1usize << log2sb;
|
||||
let last_sb = sb_scan.iter().position(|&(x, y)| x as u32 == lx >> 2 && y as u32 == ly >> 2).unwrap_or(0);
|
||||
let last_pos = pos_scan.iter().position(|&(x, y)| x as u32 == lx & 3 && y as u32 == ly & 3).unwrap_or(0);
|
||||
self.coeffs[..n * n].fill(0);
|
||||
let mut csbf = [0u8; 64];
|
||||
let mut prev_g1: Option<u32> = None;
|
||||
let sdh = self.pps.sign_data_hiding && !bypass;
|
||||
let (mut max_x, mut max_y) = (0usize, 0usize);
|
||||
for k in 0..nsig {
|
||||
let mut base = 1 + g.gt1[k] as i32;
|
||||
if Some(k) == g.first_g1 {
|
||||
base += g.gt2 as i32;
|
||||
let c_off_sig = if c == 0 { 0 } else { 27 };
|
||||
for i in (0..=last_sb).rev() {
|
||||
let (xs, ys) = (sb_scan[i].0 as usize, sb_scan[i].1 as usize);
|
||||
let mut infer_dc = false;
|
||||
let right = if xs + 1 < sb_w { csbf[ys * 8 + xs + 1] } else { 0 };
|
||||
let below = if ys + 1 < sb_w { csbf[(ys + 1) * 8 + xs] } else { 0 };
|
||||
if i < last_sb && i > 0 {
|
||||
let inc = (right | below).min(1) as usize + if c > 0 { 2 } else { 0 };
|
||||
csbf[ys * 8 + xs] = self.c.decode_decision(CODED_SUB_BLOCK + inc) as u8;
|
||||
infer_dc = true;
|
||||
} else {
|
||||
csbf[ys * 8 + xs] = 1;
|
||||
}
|
||||
let thresh = if k < 8 { if Some(k) == g.first_g1 { 3 } else { 2 } } else { 1 };
|
||||
let mut level = base;
|
||||
if base == thresh {
|
||||
let rem = self.coeff_abs_level_remaining(rice)?;
|
||||
level = base + rem;
|
||||
if level > 3 * (1 << rice) {
|
||||
rice = (rice + 1).min(4);
|
||||
// significance map
|
||||
let mut sig = [0u8; 16]; // scan positions n of significant coefficients, descending
|
||||
let mut nsig = 0;
|
||||
let start: i32 = if i == last_sb {
|
||||
sig[0] = last_pos as u8;
|
||||
nsig = 1;
|
||||
last_pos as i32 - 1
|
||||
} else {
|
||||
15
|
||||
};
|
||||
if csbf[ys * 8 + xs] != 0 {
|
||||
let prev_csbf = right + (below << 1);
|
||||
let mut np = start;
|
||||
while np >= 0 {
|
||||
let (xp, yp) = (pos_scan[np as usize].0 as usize, pos_scan[np as usize].1 as usize);
|
||||
let (xc, yc) = (xs * 4 + xp, ys * 4 + yp);
|
||||
if np > 0 || !infer_dc {
|
||||
let sig_ctx = if log2 == 2 {
|
||||
CTX_IDX_MAP[(yc << 2) + xc] as usize
|
||||
} else if xc + yc == 0 {
|
||||
0
|
||||
} else {
|
||||
let mut s = match prev_csbf {
|
||||
0 => {
|
||||
if xp + yp == 0 {
|
||||
2
|
||||
} else if xp + yp < 3 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
if yp == 0 {
|
||||
2
|
||||
} else if yp == 1 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
2 => {
|
||||
if xp == 0 {
|
||||
2
|
||||
} else if xp == 1 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
_ => 2,
|
||||
};
|
||||
if c == 0 {
|
||||
if xs + ys > 0 {
|
||||
s += 3;
|
||||
}
|
||||
s += if log2 == 3 { if scan_idx == 0 { 9 } else { 15 } } else { 21 };
|
||||
} else {
|
||||
s += if log2 == 3 { 9 } else { 12 };
|
||||
}
|
||||
s
|
||||
};
|
||||
if self.c.decode_decision(SIG_COEFF + c_off_sig + sig_ctx) == 1 {
|
||||
sig[nsig] = np as u8;
|
||||
nsig += 1;
|
||||
infer_dc = false;
|
||||
}
|
||||
} else {
|
||||
// np == 0 with inferSbDcSigCoeffFlag
|
||||
sig[nsig] = 0;
|
||||
nsig += 1;
|
||||
}
|
||||
np -= 1;
|
||||
}
|
||||
}
|
||||
let np = sig[k] as usize;
|
||||
let (xc, yc) = (sb.xs * 4 + pos_scan[np].0 as usize, sb.ys * 4 + pos_scan[np].1 as usize);
|
||||
let mut v = level;
|
||||
if !(sign_hidden && k == nsig - 1) {
|
||||
if signs & 0x8000_0000 != 0 {
|
||||
v = -v;
|
||||
}
|
||||
signs <<= 1;
|
||||
if nsig == 0 {
|
||||
continue;
|
||||
}
|
||||
if sign_hidden {
|
||||
sum_abs += level;
|
||||
if k == nsig - 1 && sum_abs & 1 == 1 {
|
||||
v = -v;
|
||||
// greater1 / greater2 flags
|
||||
let mut ctx_set = if i == 0 || c > 0 { 0 } else { 2 };
|
||||
if let Some(g) = prev_g1
|
||||
&& g == 0
|
||||
{
|
||||
ctx_set += 1;
|
||||
}
|
||||
let mut g1ctx = 1u32;
|
||||
let mut gt1 = [0u8; 16];
|
||||
let mut first_g1: Option<usize> = None;
|
||||
let c_off_g1 = if c > 0 { 16 } else { 0 };
|
||||
for k in 0..nsig.min(8) {
|
||||
let inc = ctx_set * 4 + g1ctx.min(3) as usize + c_off_g1;
|
||||
let f = self.c.decode_decision(GT1 + inc);
|
||||
gt1[k] = f as u8;
|
||||
if f == 1 {
|
||||
g1ctx = 0;
|
||||
if first_g1.is_none() {
|
||||
first_g1 = Some(k);
|
||||
}
|
||||
} else if g1ctx > 0 {
|
||||
g1ctx += 1;
|
||||
}
|
||||
}
|
||||
self.coeffs[yc * n + xc] = v;
|
||||
max_x = max_x.max(xc);
|
||||
max_y = max_y.max(yc);
|
||||
prev_g1 = Some(g1ctx);
|
||||
let mut gt2 = 0u8;
|
||||
if let Some(k) = first_g1 {
|
||||
gt2 = self.c.decode_decision(GT2 + ctx_set + if c > 0 { 4 } else { 0 }) as u8;
|
||||
let _ = k;
|
||||
}
|
||||
// signs
|
||||
let last_sig_pos = sig[0] as i32;
|
||||
let first_sig_pos = sig[nsig - 1] as i32;
|
||||
let sign_hidden = sdh && last_sig_pos - first_sig_pos > 3;
|
||||
let nsigns = if sign_hidden { nsig - 1 } else { nsig };
|
||||
let mut signs = if nsigns > 0 { self.bypass_bits(nsigns as u32) << (32 - nsigns) } else { 0 };
|
||||
// remaining levels
|
||||
let mut rice = 0u32;
|
||||
let mut sum_abs = 0i32;
|
||||
for k in 0..nsig {
|
||||
let mut base = 1 + gt1[k] as i32;
|
||||
if Some(k) == first_g1 {
|
||||
base += gt2 as i32;
|
||||
}
|
||||
let thresh = if k < 8 { if Some(k) == first_g1 { 3 } else { 2 } } else { 1 };
|
||||
let mut level = base;
|
||||
if base == thresh {
|
||||
let rem = self.coeff_abs_level_remaining(rice)?;
|
||||
level = base + rem;
|
||||
if level > 3 * (1 << rice) {
|
||||
rice = (rice + 1).min(4);
|
||||
}
|
||||
}
|
||||
let np = sig[k] as usize;
|
||||
let (xc, yc) = (xs * 4 + pos_scan[np].0 as usize, ys * 4 + pos_scan[np].1 as usize);
|
||||
let mut v = level;
|
||||
if !(sign_hidden && k == nsig - 1) {
|
||||
if signs & 0x8000_0000 != 0 {
|
||||
v = -v;
|
||||
}
|
||||
signs <<= 1;
|
||||
}
|
||||
if sign_hidden {
|
||||
sum_abs += level;
|
||||
if k == nsig - 1 && sum_abs & 1 == 1 {
|
||||
v = -v;
|
||||
}
|
||||
}
|
||||
self.coeffs[yc * n + xc] = v;
|
||||
max_x = max_x.max(xc);
|
||||
max_y = max_y.max(yc);
|
||||
}
|
||||
}
|
||||
Ok((max_x, max_y))
|
||||
self.reconstruct((x0, y0), log2, c, qp, ts, (max_x, max_y))
|
||||
}
|
||||
|
||||
fn coeff_abs_level_remaining(&mut self, rice: u32) -> Result<i32> {
|
||||
@@ -1545,71 +1565,3 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-transform-block parameters of residual coding.
|
||||
struct TbCtx {
|
||||
c: usize,
|
||||
log2: u32,
|
||||
scan_idx: usize,
|
||||
sdh: bool,
|
||||
}
|
||||
|
||||
/// Position of a 4x4 sub-block in the sub-block grid.
|
||||
struct SubBlock {
|
||||
xs: usize,
|
||||
ys: usize,
|
||||
}
|
||||
|
||||
/// Greater-than-1 / greater-than-2 flags of one sub-block.
|
||||
struct GreaterFlags {
|
||||
gt1: [u8; 16],
|
||||
first_g1: Option<usize>,
|
||||
gt2: u8,
|
||||
}
|
||||
|
||||
/// sigCtx (9.3.4.2.5) for the coefficient at (`xp`, `yp`) inside the sub-block.
|
||||
fn sig_coeff_ctx(t: &TbCtx, sb: &SubBlock, prev_csbf: u8, (xp, yp): (usize, usize)) -> usize {
|
||||
let (xc, yc) = (sb.xs * 4 + xp, sb.ys * 4 + yp);
|
||||
if t.log2 == 2 {
|
||||
CTX_IDX_MAP[(yc << 2) + xc] as usize
|
||||
} else if xc + yc == 0 {
|
||||
0
|
||||
} else {
|
||||
sig_ctx_neighbourhood(prev_csbf, xp, yp) + sig_ctx_offset(t, sb.xs + sb.ys > 0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Base sigCtx from the coded-sub-block flags of the right and below neighbours.
|
||||
fn sig_ctx_neighbourhood(prev_csbf: u8, xp: usize, yp: usize) -> usize {
|
||||
let near = |v: usize| match v {
|
||||
0 => 2,
|
||||
1 => 1,
|
||||
_ => 0,
|
||||
};
|
||||
match prev_csbf {
|
||||
0 => {
|
||||
if xp + yp == 0 {
|
||||
2
|
||||
} else if xp + yp < 3 {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
1 => near(yp),
|
||||
2 => near(xp),
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
/// Offset added to sigCtx for the block size, colour component and sub-block position.
|
||||
fn sig_ctx_offset(t: &TbCtx, not_first_sb: bool) -> usize {
|
||||
if t.c == 0 {
|
||||
let size_off = if t.log2 == 3 { if t.scan_idx == 0 { 9 } else { 15 } } else { 21 };
|
||||
size_off + if not_first_sb { 3 } else { 0 }
|
||||
} else if t.log2 == 3 {
|
||||
9
|
||||
} else {
|
||||
12
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! JSON-RPC 2.0 framing and the MCP lifecycle / tools / resources methods.
|
||||
|
||||
use std::io::{self, BufRead, Write};
|
||||
use std::io::{BufRead, Write};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -17,8 +17,6 @@ const METHOD_NOT_FOUND: i64 = -32601;
|
||||
const INVALID_PARAMS: i64 = -32602;
|
||||
const INTERNAL_ERROR: i64 = -32603;
|
||||
const RESOURCE_NOT_FOUND: i64 = -32002;
|
||||
/// Keep request frames in step with the connected backend's reply limit.
|
||||
const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024;
|
||||
|
||||
const INSTRUCTIONS: &str = "DeckCraft is a presentation app (a PowerPoint clone). Coordinates are points on the \
|
||||
current slide (y down; the default 16:9 slide is 960×540). Every action is a command: find ids and parameters with \
|
||||
@@ -64,16 +62,9 @@ impl Server {
|
||||
/// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is
|
||||
/// the protocol stream).
|
||||
pub fn serve(&mut self, input: impl BufRead, mut output: impl Write) -> std::io::Result<()> {
|
||||
let mut input = input;
|
||||
while let Some(frame) = read_frame(&mut input)? {
|
||||
let reply = match frame {
|
||||
Frame::Complete(bytes) => match std::str::from_utf8(&bytes) {
|
||||
Ok(line) => self.handle_line(line),
|
||||
Err(_) => Some(error(Value::Null, PARSE_ERROR, "request frame is not valid UTF-8").to_string()),
|
||||
},
|
||||
Frame::TooLarge => Some(error(Value::Null, INVALID_REQUEST, "request frame exceeds 16 MiB").to_string()),
|
||||
};
|
||||
if let Some(reply) = reply {
|
||||
for line in input.lines() {
|
||||
let line = line?;
|
||||
if let Some(reply) = self.handle_line(&line) {
|
||||
output.write_all(reply.as_bytes())?;
|
||||
output.write_all(b"\n")?;
|
||||
output.flush()?;
|
||||
@@ -181,38 +172,3 @@ impl Server {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Frame {
|
||||
Complete(Vec<u8>),
|
||||
TooLarge,
|
||||
}
|
||||
|
||||
/// Read through one newline while retaining no more than the documented frame budget.
|
||||
fn read_frame(input: &mut impl BufRead) -> io::Result<Option<Frame>> {
|
||||
let mut bytes = Vec::new();
|
||||
let mut too_large = false;
|
||||
loop {
|
||||
let available = input.fill_buf()?;
|
||||
if available.is_empty() {
|
||||
if bytes.is_empty() && !too_large {
|
||||
return Ok(None);
|
||||
}
|
||||
return Ok(Some(if too_large { Frame::TooLarge } else { Frame::Complete(bytes) }));
|
||||
}
|
||||
let count = available.iter().position(|byte| *byte == b'\n').map_or(available.len(), |i| i + 1);
|
||||
if !too_large {
|
||||
let remaining = MAX_FRAME_BYTES.saturating_sub(bytes.len());
|
||||
if count > remaining {
|
||||
too_large = true;
|
||||
bytes.clear();
|
||||
} else {
|
||||
bytes.extend_from_slice(&available[..count]);
|
||||
}
|
||||
}
|
||||
let ended = available[count - 1] == b'\n';
|
||||
input.consume(count);
|
||||
if ended {
|
||||
return Ok(Some(if too_large { Frame::TooLarge } else { Frame::Complete(bytes) }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,34 +10,6 @@ fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
|
||||
serde_json::from_str(&out).expect("json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_stdio_frame_is_drained_before_the_next_request() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
let mut input = vec![b'x'; 16 * 1024 * 1024 + 1];
|
||||
input.extend_from_slice(b"\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n");
|
||||
let mut output = Vec::new();
|
||||
s.serve(std::io::Cursor::new(input), &mut output).unwrap();
|
||||
let replies: Vec<Value> = std::str::from_utf8(&output).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 2);
|
||||
assert_eq!(replies[0]["error"]["code"], -32600);
|
||||
assert!(replies[0]["error"]["message"].as_str().unwrap().contains("16 MiB"));
|
||||
assert_eq!(replies[1]["id"], 1);
|
||||
assert_eq!(replies[1]["result"]["serverInfo"]["name"], "deckcraft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_utf8_stdio_frame_gets_a_parse_error_and_does_not_stop_serving() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
let mut input = b"\xff\n{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n".to_vec();
|
||||
let mut output = Vec::new();
|
||||
s.serve(std::io::Cursor::new(&mut input), &mut output).unwrap();
|
||||
let replies: Vec<Value> = std::str::from_utf8(&output).unwrap().lines().map(|line| serde_json::from_str(line).unwrap()).collect();
|
||||
assert_eq!(replies.len(), 2);
|
||||
assert_eq!(replies[0]["error"]["code"], -32700);
|
||||
assert!(replies[0]["error"]["message"].as_str().unwrap().contains("UTF-8"));
|
||||
assert_eq!(replies[1]["result"]["serverInfo"]["name"], "deckcraft");
|
||||
}
|
||||
|
||||
fn tool(s: &mut Server, name: &str, args: Value) -> Value {
|
||||
let r = call(s, 9, "tools/call", json!({"name": name, "arguments": args}));
|
||||
let res = &r["result"];
|
||||
|
||||
@@ -31,9 +31,7 @@ fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) {
|
||||
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("in2/c.txt"), "Sibling").unwrap();
|
||||
let (in_dir, out_dir) = (base.join("in"), base.join("out"));
|
||||
let read = read.then(|| in_dir.to_path_buf()).into_iter().collect::<Vec<_>>();
|
||||
let write = write.then(|| out_dir.to_path_buf()).into_iter().collect::<Vec<_>>();
|
||||
let roots = AutomationRoots::new(&read, &write).unwrap();
|
||||
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
|
||||
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots)));
|
||||
ok(&mut server, "new_presentation", json!({}));
|
||||
(server, base)
|
||||
@@ -217,7 +215,7 @@ fn without_roots_nothing_is_confined() {
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
std::fs::write(base.join("a.txt"), "Anywhere").unwrap();
|
||||
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(&[], &[]).unwrap())));
|
||||
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap())));
|
||||
ok(&mut server, "open_presentation", json!({"path": s(&base.join("a.txt"))}));
|
||||
ok(&mut server, "save_presentation", json!({"path": s(&base.join("b.deckcraft"))}));
|
||||
assert!(base.join("b.deckcraft").exists());
|
||||
|
||||
@@ -50,8 +50,6 @@ const MAX_CHARS: usize = 32_768;
|
||||
const MAX_OBJECT_BYTES: usize = 64 * 1024 * 1024;
|
||||
/// Nesting depth of region nodes.
|
||||
const MAX_REGION_DEPTH: usize = 32;
|
||||
/// Region nodes and path points parsed together, including nodes in sibling branches.
|
||||
const MAX_REGION_WORK: usize = MAX_POINTS;
|
||||
|
||||
const TA_RIGHT: u32 = 2;
|
||||
const TA_CENTER: u32 = 6;
|
||||
@@ -95,7 +93,7 @@ impl Fill {
|
||||
if w == 0.0 || h == 0.0 {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
if *wrap == 4 && !((0.0..w).contains(&p.0) && (0.0..h).contains(&p.1)) {
|
||||
if *wrap == 4 && !(0.0..w).contains(&p.0) || *wrap == 4 && !(0.0..h).contains(&p.1) {
|
||||
return [0, 0, 0, 0];
|
||||
}
|
||||
let x = (p.0.rem_euclid(w) as usize).min(img.w - 1);
|
||||
@@ -288,20 +286,10 @@ impl Plus {
|
||||
if !self.active {
|
||||
return None;
|
||||
}
|
||||
match typ {
|
||||
0x4008 => self.object(flags, r),
|
||||
0x4009 | 0x4013 | 0x401A..=0x401C | 0x4036 => self.record_image_text_region(cv, typ, flags, r),
|
||||
0x4025..=0x4035 => self.record_state(cv, typ, flags, r),
|
||||
0x4010..=0x4012 | 0x4016..=0x4019 => self.record_curve(cv, typ, flags, r),
|
||||
_ => self.record_shape(cv, typ, flags, r),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rectangle, polygon, ellipse and path records.
|
||||
fn record_shape(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> {
|
||||
let compressed = flags & 0x4000 != 0;
|
||||
let id = usize::from(flags & 0xFF);
|
||||
match typ {
|
||||
0x4008 => self.object(flags, r),
|
||||
0x400A => {
|
||||
let fill = self.brush_of(flags, r.u32(0)?)?;
|
||||
let p = self.painter();
|
||||
@@ -360,15 +348,6 @@ impl Plus {
|
||||
self.painter().stroke(cv, &ops, pen);
|
||||
Some(())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Curve, bezier and arc records.
|
||||
fn record_curve(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> {
|
||||
let compressed = flags & 0x4000 != 0;
|
||||
let id = usize::from(flags & 0xFF);
|
||||
match typ {
|
||||
0x4019 => {
|
||||
let pen = self.pen(id)?;
|
||||
let pts = points(r, 4, r.u32(0)? as usize, compressed)?;
|
||||
@@ -401,29 +380,6 @@ impl Plus {
|
||||
self.painter().stroke(cv, &curve_ops(&pts, tension, false, offset, segments), pen);
|
||||
Some(())
|
||||
}
|
||||
0x4010 => {
|
||||
let fill = self.brush_of(flags, r.u32(0)?)?;
|
||||
let [start, sweep] = floats::<2>(r, 4)?;
|
||||
let rect = *rects(r, 12, 1, compressed)?.first()?;
|
||||
self.painter().fill(cv, &arc_ops(rect, start, sweep, true), &fill);
|
||||
Some(())
|
||||
}
|
||||
0x4011 | 0x4012 => {
|
||||
let pen = self.pen(id)?;
|
||||
let [start, sweep] = floats::<2>(r, 0)?;
|
||||
let rect = *rects(r, 8, 1, compressed)?.first()?;
|
||||
self.painter().stroke(cv, &arc_ops(rect, start, sweep, typ == 0x4011), pen);
|
||||
Some(())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear, region fill, image and text records.
|
||||
fn record_image_text_region(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> {
|
||||
let compressed = flags & 0x4000 != 0;
|
||||
let id = usize::from(flags & 0xFF);
|
||||
match typ {
|
||||
// Clear: the whole picture in one colour, whatever the clip (a transparent clear
|
||||
// leaves nothing to draw).
|
||||
0x4009 => {
|
||||
@@ -463,14 +419,6 @@ impl Plus {
|
||||
}
|
||||
0x401C => self.draw_string(cv, flags, r),
|
||||
0x4036 => self.draw_driver_string(cv, flags, r),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Save / restore, transform and clip records.
|
||||
fn record_state(&mut self, cv: &mut Canvas, typ: u16, flags: u16, r: Bytes) -> Option<()> {
|
||||
let id = usize::from(flags & 0xFF);
|
||||
match typ {
|
||||
0x4025 | 0x4028 => r.u32(0).map(|i| self.push(i)),
|
||||
0x4027 => r.u32(24).map(|i| self.push(i)),
|
||||
0x4026 | 0x4029 => r.u32(0).map(|i| self.pop(i)),
|
||||
@@ -522,6 +470,20 @@ impl Plus {
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
0x4010 => {
|
||||
let fill = self.brush_of(flags, r.u32(0)?)?;
|
||||
let [start, sweep] = floats::<2>(r, 4)?;
|
||||
let rect = *rects(r, 12, 1, compressed)?.first()?;
|
||||
self.painter().fill(cv, &arc_ops(rect, start, sweep, true), &fill);
|
||||
Some(())
|
||||
}
|
||||
0x4011 | 0x4012 => {
|
||||
let pen = self.pen(id)?;
|
||||
let [start, sweep] = floats::<2>(r, 0)?;
|
||||
let rect = *rects(r, 8, 1, compressed)?.first()?;
|
||||
self.painter().stroke(cv, &arc_ops(rect, start, sweep, typ == 0x4011), pen);
|
||||
Some(())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1108,14 +1070,12 @@ fn path(r: Bytes) -> Option<Vec<Op>> {
|
||||
/// A region object as a convex area in world units (non-convex parts become their bounding
|
||||
/// rectangle).
|
||||
fn region(r: Bytes) -> Option<Obj> {
|
||||
let mut work = 0;
|
||||
region_node(r, 8, 0, &mut work).map(|(a, _)| Obj::Region(a))
|
||||
region_node(r, 8, 0).map(|(a, _)| Obj::Region(a))
|
||||
}
|
||||
|
||||
/// One region node at `off`: its area and its length in bytes.
|
||||
fn region_node(r: Bytes, off: usize, depth: usize, work: &mut usize) -> Option<(Area, usize)> {
|
||||
*work = work.checked_add(1)?;
|
||||
if depth > MAX_REGION_DEPTH || *work > MAX_REGION_WORK {
|
||||
fn region_node(r: Bytes, off: usize, depth: usize) -> Option<(Area, usize)> {
|
||||
if depth > MAX_REGION_DEPTH {
|
||||
return None;
|
||||
}
|
||||
match r.u32(off)? {
|
||||
@@ -1125,23 +1085,14 @@ fn region_node(r: Bytes, off: usize, depth: usize, work: &mut usize) -> Option<(
|
||||
}
|
||||
0x1000_0001 => {
|
||||
let size = r.u32(off + 4)? as usize;
|
||||
if size < 12 {
|
||||
return None;
|
||||
}
|
||||
let data = Bytes(r.slice(off + 8, size)?);
|
||||
let points = (data.u32(4)? as usize).min(MAX_POINTS);
|
||||
*work = work.checked_add(points)?;
|
||||
if *work > MAX_REGION_WORK {
|
||||
return None;
|
||||
}
|
||||
let area = area_of_ops(&path(data)?);
|
||||
let area = path(r.tail(off + 8)?).map(|ops| area_of_ops(&ops)).unwrap_or_default();
|
||||
Some((Some(area), 8usize.checked_add(size)?))
|
||||
}
|
||||
0x1000_0002 => Some((Some(Vec::new()), 4)),
|
||||
0x1000_0003 => Some((None, 4)),
|
||||
mode @ 0..=5 => {
|
||||
let (a, la) = region_node(r, off + 4, depth + 1, work)?;
|
||||
let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1, work)?;
|
||||
let (a, la) = region_node(r, off + 4, depth + 1)?;
|
||||
let (b, lb) = region_node(r, off.checked_add(4 + la)?, depth + 1)?;
|
||||
let out = match mode {
|
||||
0 => b,
|
||||
1 => area_intersect(a, b),
|
||||
|
||||
@@ -1875,34 +1875,6 @@ fn emfplus_region_clip_intersects_rectangles() {
|
||||
assert_eq!((p.width, p.height), (20.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emfplus_region_path_cannot_read_into_its_sibling() {
|
||||
let rect = |r: [f32; 4]| cat(vec![h32(0x1000_0000), f32s(&r)]);
|
||||
// The path declares only its 12-byte header, but claims three points. The following
|
||||
// rectangle must not supply those points or be parsed twice as the next node.
|
||||
let path = cat(vec![h32(0x1000_0001), h32(12), h32(0), h32(3), h32(0)]);
|
||||
let region = cat(vec![h32(0xDBC0_1002), h32(2), h32(1), path, rect([10.0, 10.0, 20.0, 20.0])]);
|
||||
let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])]));
|
||||
let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, ®ion), prec(0x4034, 6, &[]), fill]);
|
||||
assert_eq!(p.items.len(), 1);
|
||||
assert_eq!((p.width, p.height), (100.0, 100.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emfplus_region_path_keeps_its_shape() {
|
||||
let mut path_data = cat(vec![h32(0), h32(4), h32(0x4000)]);
|
||||
for (x, y) in [(10i16, 10i16), (30, 10), (30, 30), (10, 30)] {
|
||||
path_data.extend(x.to_le_bytes());
|
||||
path_data.extend(y.to_le_bytes());
|
||||
}
|
||||
path_data.extend([0, 1, 1, 0x81]);
|
||||
let path_node = cat(vec![h32(0x1000_0001), h32(path_data.len() as u32), path_data]);
|
||||
let region = cat(vec![h32(0xDBC0_1002), h32(1), path_node]);
|
||||
let fill = prec(0x400A, 0x8000, &cat(vec![h32(0xFF00_0000), h32(1), f32s(&[0.0, 0.0, 100.0, 100.0])]));
|
||||
let p = plus_pic(vec![prec(0x4008, 0x0400 | 6, ®ion), prec(0x4034, 6, &[]), fill]);
|
||||
assert_eq!((p.width, p.height), (20.0, 20.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emfplus_pie_and_arc() {
|
||||
// A quarter pie from 0 to 90 degrees in a 0..100 circle: centre, then (100, 50) round to (50, 100).
|
||||
|
||||
+16
-16
@@ -367,37 +367,37 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[
|
||||
("shape", "Shape", AnimClass::Entrance, 6, 2000, &["circleIn", "circleOut", "boxIn", "boxOut", "diamondIn", "diamondOut", "plusIn", "plusOut"]),
|
||||
("wheel", "Wheel", AnimClass::Entrance, 21, 2000, &["1", "2", "3", "4", "8"]),
|
||||
("randomBars", "Random Bars", AnimClass::Entrance, 14, 500, &["horz", "vert"]),
|
||||
("grow", "Grow & Turn", AnimClass::Entrance, 31, 1000, &[]),
|
||||
("grow", "Grow & Turn", AnimClass::Entrance, 31, 500, &[]),
|
||||
("zoom", "Zoom", AnimClass::Entrance, 53, 500, &["in", "out"]),
|
||||
("swivel", "Swivel", AnimClass::Entrance, 45, 2000, &["horz", "vert"]),
|
||||
("bounce", "Bounce", AnimClass::Entrance, 26, 2000, &[]),
|
||||
("blinds", "Blinds", AnimClass::Entrance, 3, 500, &["horz", "vert"]),
|
||||
("box", "Box", AnimClass::Entrance, 4, 2000, &["in", "out"]),
|
||||
("box", "Box", AnimClass::Entrance, 4, 500, &["in", "out"]),
|
||||
("checkerboard", "Checkerboard", AnimClass::Entrance, 5, 500, &["across", "down"]),
|
||||
("circle", "Circle", AnimClass::Entrance, 6, 2000, &["in", "out"]),
|
||||
("diamond", "Diamond", AnimClass::Entrance, 8, 2000, &["in", "out"]),
|
||||
("circle", "Circle", AnimClass::Entrance, 6, 500, &["in", "out"]),
|
||||
("diamond", "Diamond", AnimClass::Entrance, 8, 500, &["in", "out"]),
|
||||
("dissolve", "Dissolve In", AnimClass::Entrance, 9, 500, &[]),
|
||||
("peek", "Peek In", AnimClass::Entrance, 12, 500, &["b", "l", "t", "r"]),
|
||||
("strips", "Strips", AnimClass::Entrance, 18, 500, &["lu", "ru", "ld", "rd"]),
|
||||
("expand", "Expand", AnimClass::Entrance, 55, 1000, &[]),
|
||||
("expand", "Expand", AnimClass::Entrance, 55, 500, &[]),
|
||||
("rise", "Rise Up", AnimClass::Entrance, 37, 1000, &[]),
|
||||
("spinner", "Spinner", AnimClass::Entrance, 49, 500, &[]),
|
||||
("spinner", "Spinner", AnimClass::Entrance, 49, 1000, &[]),
|
||||
("pulse", "Pulse", AnimClass::Emphasis, 26, 500, &[]),
|
||||
("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 250, &[]),
|
||||
("colorPulse", "Color Pulse", AnimClass::Emphasis, 27, 500, &[]),
|
||||
("teeter", "Teeter", AnimClass::Emphasis, 32, 1000, &[]),
|
||||
("spin", "Spin", AnimClass::Emphasis, 8, 2000, &["clockwise", "counterClockwise"]),
|
||||
("growShrink", "Grow/Shrink", AnimClass::Emphasis, 6, 2000, &["both", "horz", "vert"]),
|
||||
("desaturate", "Desaturate", AnimClass::Emphasis, 25, 500, &[]),
|
||||
("darken", "Darken", AnimClass::Emphasis, 24, 500, &[]),
|
||||
("desaturate", "Desaturate", AnimClass::Emphasis, 35, 500, &[]),
|
||||
("darken", "Darken", AnimClass::Emphasis, 25, 500, &[]),
|
||||
("lighten", "Lighten", AnimClass::Emphasis, 30, 500, &[]),
|
||||
("transparency", "Transparency", AnimClass::Emphasis, 9, 500, &[]),
|
||||
("objectColor", "Object Color", AnimClass::Emphasis, 19, 500, &[]),
|
||||
("objectColor", "Object Color", AnimClass::Emphasis, 19, 2000, &[]),
|
||||
("complementaryColor", "Complementary Color", AnimClass::Emphasis, 21, 500, &[]),
|
||||
("lineColor", "Line Color", AnimClass::Emphasis, 7, 2000, &[]),
|
||||
("fillColor", "Fill Color", AnimClass::Emphasis, 1, 2000, &[]),
|
||||
("fontColor", "Font Color", AnimClass::Emphasis, 3, 2000, &[]),
|
||||
("underline", "Underline", AnimClass::Emphasis, 18, 500, &[]),
|
||||
("boldFlash", "Bold Flash", AnimClass::Emphasis, 10, 2000, &[]),
|
||||
("boldFlash", "Bold Flash", AnimClass::Emphasis, 10, 500, &[]),
|
||||
("wave", "Wave", AnimClass::Emphasis, 34, 500, &[]),
|
||||
("disappear", "Disappear", AnimClass::Exit, 1, 0, &[]),
|
||||
("fadeOut", "Fade", AnimClass::Exit, 10, 500, &[]),
|
||||
@@ -408,20 +408,20 @@ pub static ANIMATIONS: &[(&str, &str, AnimClass, u32, u32, &[&str])] = &[
|
||||
("shapeOut", "Shape", AnimClass::Exit, 6, 2000, &["circleIn", "circleOut"]),
|
||||
("wheelOut", "Wheel", AnimClass::Exit, 21, 2000, &["1", "2", "3", "4", "8"]),
|
||||
("randomBarsOut", "Random Bars", AnimClass::Exit, 14, 500, &["horz", "vert"]),
|
||||
("shrinkTurn", "Shrink & Turn", AnimClass::Exit, 31, 1000, &[]),
|
||||
("shrinkTurn", "Shrink & Turn", AnimClass::Exit, 31, 500, &[]),
|
||||
("zoomOut", "Zoom", AnimClass::Exit, 53, 500, &["in", "out"]),
|
||||
("swivelOut", "Swivel", AnimClass::Exit, 45, 2000, &["horz", "vert"]),
|
||||
("bounceOut", "Bounce", AnimClass::Exit, 26, 2000, &[]),
|
||||
("dissolveOut", "Dissolve Out", AnimClass::Exit, 9, 500, &[]),
|
||||
("blindsOut", "Blinds", AnimClass::Exit, 3, 500, &["horz", "vert"]),
|
||||
("boxOut", "Box", AnimClass::Exit, 4, 2000, &["in", "out"]),
|
||||
("boxOut", "Box", AnimClass::Exit, 4, 500, &["in", "out"]),
|
||||
("checkerboardOut", "Checkerboard", AnimClass::Exit, 5, 500, &["across", "down"]),
|
||||
("diamondOut", "Diamond", AnimClass::Exit, 8, 2000, &["in", "out"]),
|
||||
("diamondOut", "Diamond", AnimClass::Exit, 8, 500, &["in", "out"]),
|
||||
("peekOut", "Peek Out", AnimClass::Exit, 12, 500, &["b", "l", "t", "r"]),
|
||||
("stripsOut", "Strips", AnimClass::Exit, 18, 500, &["lu", "ru", "ld", "rd"]),
|
||||
("sinkDown", "Sink Down", AnimClass::Exit, 37, 1000, &[]),
|
||||
("spinnerOut", "Spinner", AnimClass::Exit, 49, 500, &[]),
|
||||
("contract", "Contract", AnimClass::Exit, 55, 1000, &[]),
|
||||
("spinnerOut", "Spinner", AnimClass::Exit, 49, 1000, &[]),
|
||||
("contract", "Contract", AnimClass::Exit, 55, 500, &[]),
|
||||
("lines", "Lines", AnimClass::Path, 64, 2000, &["down", "up", "right", "left"]),
|
||||
("arcs", "Arcs", AnimClass::Path, 37, 2000, &["down", "up", "right", "left"]),
|
||||
("turns", "Turns", AnimClass::Path, 43, 2000, &["down", "up", "right", "left"]),
|
||||
|
||||
@@ -1270,14 +1270,7 @@ impl Chart {
|
||||
let mut bins: Vec<Bin> = (0..count).map(|i| Bin { lo: lo + width * i as f64, hi: lo + width * (i + 1) as f64, count: 0 }).collect();
|
||||
for x in v {
|
||||
// (lo, hi] bins, the first one also holding the minimum.
|
||||
let mut i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1);
|
||||
// The division can round across an edge: settle on the bin whose stored bounds hold x.
|
||||
while i > 0 && x <= bins[i - 1].hi {
|
||||
i -= 1;
|
||||
}
|
||||
while i + 1 < count && x > bins[i].hi {
|
||||
i += 1;
|
||||
}
|
||||
let i = (((x - lo) / width).ceil() as usize).saturating_sub(1).min(count - 1);
|
||||
if let Some(b) = bins.get_mut(i) {
|
||||
b.count += 1;
|
||||
}
|
||||
@@ -1542,16 +1535,6 @@ mod chartex_tests {
|
||||
assert_eq!(c.histogram_bins().len(), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn histogram_value_on_a_bin_edge_counts_in_that_bin() {
|
||||
let mut c = obs(&[0.0, 2.1, 3.0]);
|
||||
c.bin_size = Some(0.3);
|
||||
let bins = c.histogram_bins();
|
||||
let own = bins.iter().find(|b| b.lo < 2.1 && 2.1 <= b.hi).expect("a bin holds 2.1");
|
||||
assert_eq!(own.count, 1, "2.1 must be counted in ({}, {}]", own.lo, own.hi);
|
||||
assert_eq!(bins.iter().map(|b| b.count).sum::<usize>(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pareto_view_sorts_bins_and_adds_the_cumulative_line() {
|
||||
let mut c = obs(&[1.0, 5.0, 5.0, 5.0, 9.0, 9.0]);
|
||||
|
||||
@@ -49,7 +49,7 @@ struct B {
|
||||
}
|
||||
|
||||
impl B {
|
||||
fn shape(&mut self, name: &str, kind: PhType, idx: u32, [x, y, w, h]: [f64; 4]) -> Shape {
|
||||
fn shape(&mut self, name: &str, kind: PhType, idx: u32, x: f64, y: f64, w: f64, h: f64) -> Shape {
|
||||
self.next += 1;
|
||||
Shape {
|
||||
id: ShapeId(self.next),
|
||||
@@ -164,9 +164,9 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
let (x1, y1, w1, h1) = r(60.0, 500.0, 216.0, 28.0);
|
||||
let (x2, _, w2, _) = r(318.0, 500.0, 324.0, 28.0);
|
||||
let (x3, _, w3, _) = r(684.0, 500.0, 216.0, 28.0);
|
||||
let mut d = b.shape("Date Placeholder", PhType::Date, 10, [x1, y1, w1, h1]);
|
||||
let mut f = b.shape("Footer Placeholder", PhType::Footer, 11, [x2, y1, w2, h1]);
|
||||
let mut n = b.shape("Slide Number Placeholder", PhType::SlideNum, 12, [x3, y1, w3, h1]);
|
||||
let mut d = b.shape("Date Placeholder", PhType::Date, 10, x1, y1, w1, h1);
|
||||
let mut f = b.shape("Footer Placeholder", PhType::Footer, 11, x2, y1, w2, h1);
|
||||
let mut n = b.shape("Slide Number Placeholder", PhType::SlideNum, 12, x3, y1, w3, h1);
|
||||
let small = |align| {
|
||||
let mut ls = ListStyle::default();
|
||||
ls.set(
|
||||
@@ -202,13 +202,13 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
};
|
||||
let title_box = |b: &mut B| {
|
||||
let (x, y, ww, hh) = r(60.0, 29.0, 840.0, 104.0);
|
||||
let mut s = b.shape("Title 1", PhType::Title, 0, [x, y, ww, hh]);
|
||||
let mut s = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh);
|
||||
s.text = Some(body_with(Some(Anchor::Middle), Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 })));
|
||||
s
|
||||
};
|
||||
let body_box = |b: &mut B, name: &str, idx: u32, x: f64, y: f64, ww: f64, hh: f64| {
|
||||
let (x, y, ww, hh) = r(x, y, ww, hh);
|
||||
let mut s = b.shape(name, PhType::Body, idx, [x, y, ww, hh]);
|
||||
let mut s = b.shape(name, PhType::Body, idx, x, y, ww, hh);
|
||||
s.text = Some(body_with(Some(Anchor::Top), Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 })));
|
||||
s
|
||||
};
|
||||
@@ -251,7 +251,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
// Title Slide
|
||||
{
|
||||
let (x, y, ww, hh) = r(120.0, 88.0, 720.0, 188.0);
|
||||
let mut t = b.shape("Title 1", PhType::CtrTitle, 0, [x, y, ww, hh]);
|
||||
let mut t = b.shape("Title 1", PhType::CtrTitle, 0, x, y, ww, hh);
|
||||
let mut ls = ListStyle::default();
|
||||
ls.set(
|
||||
0,
|
||||
@@ -270,7 +270,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
paragraphs: vec![],
|
||||
});
|
||||
let (x, y, ww, hh) = r(120.0, 284.0, 720.0, 130.0);
|
||||
let mut st = b.shape("Subtitle 2", PhType::SubTitle, 1, [x, y, ww, hh]);
|
||||
let mut st = b.shape("Subtitle 2", PhType::SubTitle, 1, x, y, ww, hh);
|
||||
let mut ls = ListStyle::default();
|
||||
ls.set(
|
||||
0,
|
||||
@@ -309,7 +309,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
// Section Header
|
||||
{
|
||||
let (x, y, ww, hh) = r(65.0, 135.0, 830.0, 225.0);
|
||||
let mut t = b.shape("Title 1", PhType::Title, 0, [x, y, ww, hh]);
|
||||
let mut t = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh);
|
||||
let mut ls = ListStyle::default();
|
||||
ls.set(0, LevelStyle { run: RunProps { size: Some(60.0), ..Default::default() }, ..Default::default() });
|
||||
t.text = Some(TextBody { body: BodyProps { anchor: Some(Anchor::Bottom), ..Default::default() }, list_style: ls, paragraphs: vec![] });
|
||||
@@ -347,7 +347,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
let t = title_box(&mut b);
|
||||
let head = |b: &mut B, name: &str, idx: u32, x: f64| {
|
||||
let (x, y, ww, hh) = r(x, 133.0, 412.0, 62.0);
|
||||
let mut s = b.shape(name, PhType::Body, idx, [x, y, ww, hh]);
|
||||
let mut s = b.shape(name, PhType::Body, idx, x, y, ww, hh);
|
||||
let mut ls = ListStyle::default();
|
||||
ls.set(
|
||||
0,
|
||||
@@ -381,7 +381,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
// Content with Caption
|
||||
{
|
||||
let (x, y, ww, hh) = r(60.0, 36.0, 322.0, 126.0);
|
||||
let mut t = b.shape("Title 1", PhType::Title, 0, [x, y, ww, hh]);
|
||||
let mut t = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh);
|
||||
let mut ls = ListStyle::default();
|
||||
ls.set(0, LevelStyle { run: RunProps { size: Some(32.0), ..Default::default() }, ..Default::default() });
|
||||
t.text = Some(TextBody { body: BodyProps { anchor: Some(Anchor::Bottom), ..Default::default() }, list_style: ls, paragraphs: vec![] });
|
||||
@@ -405,12 +405,12 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
// Picture with Caption
|
||||
{
|
||||
let (x, y, ww, hh) = r(60.0, 36.0, 322.0, 126.0);
|
||||
let mut t = b.shape("Title 1", PhType::Title, 0, [x, y, ww, hh]);
|
||||
let mut t = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh);
|
||||
let mut ls = ListStyle::default();
|
||||
ls.set(0, LevelStyle { run: RunProps { size: Some(32.0), ..Default::default() }, ..Default::default() });
|
||||
t.text = Some(TextBody { body: BodyProps { anchor: Some(Anchor::Bottom), ..Default::default() }, list_style: ls, paragraphs: vec![] });
|
||||
let (x, y, ww, hh) = r(412.0, 78.0, 488.0, 384.0);
|
||||
let p = b.shape("Picture Placeholder 2", PhType::Picture, 1, [x, y, ww, hh]);
|
||||
let p = b.shape("Picture Placeholder 2", PhType::Picture, 1, x, y, ww, hh);
|
||||
let mut cap = body_box(&mut b, "Text Placeholder 3", 2, 60.0, 162.0, 322.0, 300.0);
|
||||
let mut ls = ListStyle::default();
|
||||
ls.set(
|
||||
@@ -444,7 +444,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
// Vertical Title and Text
|
||||
{
|
||||
let (x, y, ww, hh) = r(687.0, 29.0, 213.0, 458.0);
|
||||
let mut t = b.shape("Vertical Title 1", PhType::Title, 0, [x, y, ww, hh]);
|
||||
let mut t = b.shape("Vertical Title 1", PhType::Title, 0, x, y, ww, hh);
|
||||
t.text = Some(TextBody { body: BodyProps { vert: Some(TextDir::EaVertical), ..Default::default() }, ..Default::default() });
|
||||
if let Some(p) = t.ph.as_mut() {
|
||||
p.vertical = true;
|
||||
|
||||
@@ -418,8 +418,6 @@ fn pos_of_char(body: &TextBody, mut n: usize) -> Pos {
|
||||
/// keeps its run and paragraph formatting and new text takes the formatting where it lands. Text
|
||||
/// that shares nothing with the old text replaces it as plain text.
|
||||
pub fn replace_text(body: &mut TextBody, text: &str) {
|
||||
// Normalise line endings first so the common prefix/suffix cannot split a `\r\n` pair.
|
||||
let text = &text.replace("\r\n", "\n").replace('\r', "\n");
|
||||
let old: Vec<char> = body.text().chars().collect();
|
||||
let new: Vec<char> = text.chars().collect();
|
||||
if old == new {
|
||||
@@ -725,22 +723,6 @@ mod tests {
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_text_crlf_matches_from_text() {
|
||||
// The kept suffix must not start inside a `\r\n` pair.
|
||||
let mut b = TextBody::from_text("x\ny");
|
||||
replace_text(&mut b, "z\r\ny");
|
||||
assert_eq!(b.text(), "z\ny");
|
||||
// CRLF in the kept prefix, and the same text with CRLF changes nothing.
|
||||
let before = notes();
|
||||
let mut b = before.clone();
|
||||
replace_text(&mut b, "Overview\r\nFirst point\r\nSecond point");
|
||||
assert_eq!(b, before);
|
||||
replace_text(&mut b, "Overview\r\nFirst point\r\nSecond\rpoint");
|
||||
assert_eq!(b.text(), "Overview\nFirst point\nSecond\npoint");
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_text_keeps_formatting() {
|
||||
let before = notes();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user