mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-25 11:07:01 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b61a5d20e |
+37
-43
@@ -1,61 +1,55 @@
|
||||
name: CI
|
||||
name: ci
|
||||
|
||||
# Quality gates for every push to main and every pull request (`cargo xtask ci`), through the
|
||||
# suite's shared gate workflow in craft-common-actions.
|
||||
# Quality gates for every push to main and every pull request: rustfmt, clippy (-D warnings),
|
||||
# tests, asset attribution, crate layering and the wasm32 check — `cargo xtask ci`.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# A newer push to a PR branch cancels its older run; every push to main runs to the end.
|
||||
concurrency:
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
# Same craft-fonts commit as release.yml (Latin presentation fonts).
|
||||
CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
|
||||
jobs:
|
||||
# Keep the early manifest preflight separate from the reusable workflow. The shared gate
|
||||
# also runs with require-craft-fonts: true, so the build script enforces actual required faces.
|
||||
# Keep this pin identical to gate, freebsd.yml and release.yml.
|
||||
font-pin:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
gates:
|
||||
timeout-minutes: 60
|
||||
name: gates (${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# No macOS on PRs or main: the org's 5 macOS slots are kept for release builds, and
|
||||
# release.yml builds macOS (a failing release is fixed and rebuilt).
|
||||
os: [ubuntu-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- name: Check out craft-fonts
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: ${{ env.CRAFT_FONTS_REF }}
|
||||
path: craft-fonts
|
||||
- name: Font pin gate
|
||||
env:
|
||||
# The only pin allowed to predate the reviewed Chinese manifest. It is a sentinel, not
|
||||
# a proposed future SHA; update it together with all three workflow pins.
|
||||
PENDING_CRAFT_FONTS_PIN: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
- name: Linux build dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
manifest="$GITHUB_WORKSPACE/craft-fonts/fonts/simplified-chinese-manifest.txt"
|
||||
if [ -f "$manifest" ]; then
|
||||
echo "craft-fonts pin carries the reviewed simplified-Chinese manifest."
|
||||
elif [ "$CRAFT_FONTS_REF" = "$PENDING_CRAFT_FONTS_PIN" ]; then
|
||||
echo "::warning title=craft-fonts pin pending::The pinned craft-fonts commit has no fonts/simplified-chinese-manifest.txt, so CJK checks may skip. Advance ci.yml, freebsd.yml and release.yml together to the reviewed immutable SHA before claiming shipped Chinese fonts."
|
||||
else
|
||||
echo "::error title=craft-fonts pin missing Chinese manifest::CRAFT_FONTS_REF advanced past the known pending pin ($PENDING_CRAFT_FONTS_PIN) but still has no fonts/simplified-chinese-manifest.txt. Update the pin to the reviewed commit that carries it."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gate:
|
||||
needs: font-pin
|
||||
# Tracks @main so suite-wide CI fixes reach every app without a PR in each repository.
|
||||
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
|
||||
with:
|
||||
# Same craft-fonts commit as release.yml.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
require-craft-fonts: true
|
||||
packages: >-
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev
|
||||
libgl1-mesa-dev
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev
|
||||
- uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
targets: wasm32-unknown-unknown
|
||||
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
with:
|
||||
key: ci-${{ matrix.os }}
|
||||
- name: cargo xtask ci
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
run: cargo xtask ci
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
# The VM build is slow, so it runs nightly on main and on pushes to `release`, not on every pull
|
||||
# request: a drifted dependency still shows up within a day, and always before a release ships.
|
||||
# PRs that change the FreeBSD workflow itself still run it.
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- "crates/fonts/**"
|
||||
- .github/workflows/freebsd.yml
|
||||
schedule:
|
||||
- cron: "27 2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -27,8 +26,4 @@ jobs:
|
||||
# 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/freebsd.yml@main
|
||||
with:
|
||||
# Keep identical to ci.yml and release.yml. The shared FreeBSD workflow passes
|
||||
# CRAFT_FONTS_DIR to its tests but has no CRAFT_FONTS_REQUIRED input yet, so it does not
|
||||
# qualify the required Chinese input after the pin advances; that gate stays explicitly
|
||||
# pending in docs/egui-037-migration-plan.md section 5.1.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
|
||||
@@ -24,10 +24,7 @@ jobs:
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-title: DeckCraft
|
||||
# Keep identical to ci.yml and freebsd.yml. The shared release workflow sets
|
||||
# CRAFT_FONTS_REQUIRED=1 in its packaging steps, so a pin without the reviewed Chinese
|
||||
# manifest fails the release instead of silently skipping it.
|
||||
craft-fonts-ref: 37827adc41fd165f9a9234ac40f94dbad353aa69
|
||||
craft-fonts-ref: 43913056ce82a3fdad0ef69f6b88d1dc922a8104
|
||||
version: ${{ inputs.version }}
|
||||
secrets:
|
||||
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: SonarCloud
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sonarcloud-${{ github.ref }}
|
||||
# A push to main is the run that publishes coverage; superseded runs there would never finish.
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
sonarcloud:
|
||||
# Tracked at @main so org-wide CI improvements propagate without PR churn.
|
||||
uses: storytold/craft-common-actions/.github/workflows/sonarcloud.yml@main
|
||||
secrets: inherit
|
||||
@@ -10,9 +10,8 @@ on:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
# Nightly, so dependency and runner-image drift shows up within a day.
|
||||
schedule:
|
||||
- cron: "3 5 * * *"
|
||||
- cron: "41 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -131,12 +131,11 @@ Standard: craftrules `standards/never-crash.md`.
|
||||
CLI, control channel and MCP do exactly the same thing.
|
||||
- **The UI is thin**: panels read engine state and act through commands. Colours come from theme
|
||||
tokens. **Never use Tauri**; egui only.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Never break wasm** (`cargo xtask wasm`).
|
||||
|
||||
## 5. Quality gates (before every commit)
|
||||
|
||||
`cargo xtask ci` = fmt check, clippy `-D warnings`, tests (nextest when installed) and doctests, layering check, asset check, wasm
|
||||
`cargo xtask ci` = fmt check, clippy `-D warnings`, tests, layering check, asset check, wasm
|
||||
check. Commit after each landed arc with a task id in the message (`M1.2: smart guides`) and push
|
||||
to `main`.
|
||||
|
||||
|
||||
+4
-20
@@ -41,9 +41,8 @@ artwork. The ribbon and menu icons stay original drawings.
|
||||
|
||||
| Asset | Author | Source | Licence | Used for |
|
||||
|---|---|---|---|---|
|
||||
| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.37) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback |
|
||||
| Hack Regular, Noto Emoji (via `epaint_default_fonts` / egui) | Source Foundry; Google | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1 | egui UI fallback glyphs |
|
||||
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
|
||||
| Ubuntu Light (`Ubuntu-Light.ttf`, via the `epaint_default_fonts` crate 0.36) | Dalton Maag Ltd for Canonical | https://crates.io/crates/epaint_default_fonts | Ubuntu Font Licence 1.0 | Last-resort text face when no other font is available; UI fallback |
|
||||
| Hack Regular, Noto Emoji, emoji-icon-font (via `epaint_default_fonts` / egui) | Source Foundry; Google; Jakub Steiner et al. | https://crates.io/crates/epaint_default_fonts | MIT / Bitstream-Vera, OFL-1.1, MIT | egui UI fallback glyphs |
|
||||
|
||||
## Embedded at build time from craft-fonts (`CRAFT_FONTS_DIR`)
|
||||
|
||||
@@ -59,14 +58,9 @@ families excluded by `crates/fonts/build.rs`:
|
||||
| Carlito, Caladea | OFL-1.1 | Metric-compatible stand-ins for documents that ask for Calibri / Cambria |
|
||||
| Liberation Sans, Serif, Mono | OFL-1.1 | Metric-compatible stand-ins for Arial, Times New Roman, Courier New |
|
||||
| Montserrat, Lato, Open Sans, Roboto, Merriweather, Playfair Display, Poppins, Nunito Sans | OFL-1.1 | Theme fonts |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho | OFL-1.1 | Japanese text |
|
||||
| Noto Sans Arabic, Noto Sans Hebrew | OFL-1.1 | Arabic and Hebrew text |
|
||||
| Noto Sans Thai, Noto Sans Georgian | OFL-1.1 | Thai and Georgian text |
|
||||
| NanumGothic | OFL-1.1 | Korean text and interface fallback |
|
||||
| Noto Sans Symbols, Noto Sans Symbols 2 | OFL-1.1 | Interface symbol fallback |
|
||||
| BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans Arabic | OFL-1.1 | Japanese and Arabic text |
|
||||
|
||||
Web (wasm32) builds embed only Inter Regular/Bold (and a Variable face where the Latin manifest
|
||||
provides one) and BIZ UDPGothic Regular.
|
||||
Web (wasm32) builds embed only Inter Regular/Bold and BIZ UDPGothic Regular.
|
||||
|
||||
## PDF writer colour profiles
|
||||
|
||||
@@ -81,13 +75,3 @@ is copied into this repository. The licence text is in `licenses/pdf-writer/LICE
|
||||
| `krilla 0.8.2 / icc/sGrey-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v4.icc | CC0-1.0 | `00c0f94e09127520a17dc0e1d9264b5702081d96dbdb1549ee88e3631ce42a9d` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sRGB-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v2-magic.icc | CC0-1.0 | `af4efe28f6d311799865f325ba39184a2e978b113ca74124d60af7de22b105f4` | PDF export colour profiles |
|
||||
| `krilla 0.8.2 / icc/sRGB-v4.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sRGB-v4.icc | CC0-1.0 | `c56e1685d888f5edb92fe07f2750f387f8fe8e91b32ff8fb0b56bfbbb9458353` | PDF export colour profiles |
|
||||
|
||||
### Optional native Chinese fallback
|
||||
|
||||
Native builds also read craft-fonts' `fonts/simplified-chinese-manifest.txt` when present.
|
||||
This opt-in does not enter the wasm font allowlist. Font bytes and the full licence remain
|
||||
in craft-fonts, including the LXGW Reserved Font Names and original Klee notice.
|
||||
|
||||
| Family | Author | Source | Licence | Use |
|
||||
|---|---|---|---|---|
|
||||
| LXGW WenKai Lite Regular 1.530 | LXGW; original Klee Project Authors / Fontworks | https://github.com/lxgw/LxgwWenKai-Lite/tree/b712c83d98acf977bacfb7fb010207802808bda9 | OFL-1.1, unmodified upstream font | Native Chinese glyph fallback after existing UI faces; font SHA256 `f799a538a3355877e597a0ae04050e897932c9ae7689eb9227c2953c5dd549ea`, OFL SHA256 `0333a85fe077b6849f8e417acc44e8bd879442ceb681e271acf93e2d57fd4e2e` |
|
||||
|
||||
Generated
+388
-239
File diff suppressed because it is too large
Load Diff
+8
-32
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -49,9 +49,7 @@ rayon = "1"
|
||||
unicode-bidi = "0.3"
|
||||
unicode-segmentation = "1"
|
||||
unicode-linebreak = "0.1"
|
||||
# Base features are wasm-safe (`std` only). The non-wasm `clock` feature is requested
|
||||
# per-crate for targets that read the local clock (see crates/text).
|
||||
chrono = { version = "0.4", default-features = false, features = ["std"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock"] }
|
||||
|
||||
# text / rendering / formats
|
||||
skrifa = "0.47"
|
||||
@@ -59,18 +57,16 @@ 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 = "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-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.1.0", default-features = false, features = ["write"] }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
|
||||
# ui
|
||||
egui = { version = "0.37", default-features = false }
|
||||
egui_extras = { version = "0.37", default-features = false }
|
||||
eframe = { version = "0.37", default-features = false }
|
||||
egui_kittest = "0.37"
|
||||
web-time = "1.1"
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
egui_kittest = "0.36"
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
@@ -86,16 +82,11 @@ self_named_constructors = "allow"
|
||||
large_enum_variant = "allow"
|
||||
result_large_err = "allow"
|
||||
|
||||
# Line tables keep file and line in panics and backtraces; full debug info
|
||||
# for every test binary and dependency would multiply the target dir size.
|
||||
# The test profile inherits both settings.
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
debug = false
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
debug = "line-tables-only"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
@@ -104,19 +95,4 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
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" }
|
||||
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
|
||||
|
||||
@@ -18,13 +18,7 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
- Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) embed the fonts listed in
|
||||
ATTRIBUTION.md (Inter, Carlito, Caladea, Liberation, Montserrat, Lato, Open Sans, Roboto,
|
||||
Merriweather, Playfair Display, Poppins, Nunito Sans, BIZ UD, Shippori Mincho, Noto Sans
|
||||
Arabic, Noto Sans Thai, Noto Sans Georgian, NanumGothic, Noto Sans Symbols, Noto Sans Symbols 2,
|
||||
Noto Sans Hebrew) under the SIL Open Font License 1.1; their licence texts ship with release
|
||||
packages (the exact font files and OFL texts are retained in craft-fonts releases). Native
|
||||
builds may additionally read craft-fonts' separate Simplified Chinese manifest and embed LXGW
|
||||
WenKai Lite 1.530 under OFL-1.1; its OFL text ships with release packages, and the exact font
|
||||
and licence files are retained in craft-fonts with the LXGW Reserved Font Names. That face is
|
||||
not in the web payload.
|
||||
Arabic) under the SIL Open Font License 1.1; their licence texts ship with release packages.
|
||||
- Lucide icon paths embedded in crates/engine/src/icon_data.rs (Insert › Icons), release 1.56.0,
|
||||
commit 440a1e1dfb1e712505308da46bead3c28b1f1398, https://github.com/lucide-icons/lucide.
|
||||
ISC License, Copyright (c) 2026 Lucide Icons and Contributors. Icons Lucide derives from Feather
|
||||
@@ -86,20 +80,3 @@ Code dependencies
|
||||
(https://github.com/saucecontrol/Compact-ICC-Profiles) are CC0-1.0; the profile
|
||||
licence is retained in licenses/pdf-writer/LICENSE-ICC.txt and exact files, sources
|
||||
and hashes are in ATTRIBUTION.md.
|
||||
|
||||
|
||||
egui icon font subset (dependency asset)
|
||||
----------------------------------------
|
||||
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
|
||||
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
|
||||
uses the same MIT license. Upstream license text follows unchanged.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2014 John Slegers
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
@@ -7,10 +7,6 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png" width="128" height="128" alt="DeckCraft icon: a presentation screen with a bar chart on an orange tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">DeckCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -224,41 +220,6 @@ DirectX 12 shaders are compiled with FXC, which is part of Windows, so no `dxcom
|
||||
|
||||
RISC-V 64 (riscv64): `deckcraft-<ver>-linux-riscv64.tar.gz`, a tarball only (cross-compiled; needs glibc 2.39+, e.g. Ubuntu 24.04+).
|
||||
|
||||
**Runtime libraries.** The window-system, GPU and file-dialog libraries DeckCraft needs are opened
|
||||
when the app starts (`dlopen` through `x11-dl`, `xkbcommon-dl`, `ash`, `glutin` and `rfd`) instead
|
||||
of being linked in, so `ldd` shows none of them: an install that satisfies the linked dependencies
|
||||
can still fail at start-up with a loader error and no hint of what to install. Only
|
||||
`libasound.so.2` (slide-show sound) is a hard link-time dependency, and only of the `deckcraft`
|
||||
GUI — `deckcraft-cli` uses neither sound nor a display. The `.deb` and `.rpm` declare the set in
|
||||
`packaging/linux/nfpm.yaml`; a tarball install or a musl system has to provide it itself:
|
||||
|
||||
```sh
|
||||
apk add libx11 libxcursor libxrandr libxi libxkbcommon xkeyboard-config \
|
||||
wayland-libs-client alsa-lib \
|
||||
vulkan-loader mesa-vulkan-swrast \
|
||||
mesa-gl mesa-egl mesa-dri-gallium \
|
||||
xdg-desktop-portal
|
||||
```
|
||||
|
||||
Alpine Linux `v3.22` names above; Void's musl variant uses the same `/lib/ld-musl-x86_64.so.1`
|
||||
loader and the same libraries as `libX11 libXcursor libXrandr libXi libxkbcommon xkeyboard-config
|
||||
wayland alsa-lib vulkan-loader mesa xdg-desktop-portal`. There is no musl build among the release
|
||||
artifacts yet — this is what a build from source on musl needs at run time, and what a future musl
|
||||
artifact will need (#954).
|
||||
|
||||
- `mesa-vulkan-swrast` is the software Vulkan driver: it runs anywhere, VMs and containers
|
||||
included. On a real GPU install your vendor's driver instead (`mesa-vulkan-intel`,
|
||||
`mesa-vulkan-ati`, `mesa-vulkan-nouveau`, …).
|
||||
- Without Vulkan the app falls back to CPU rendering through Mesa's GL drivers. It works, but
|
||||
slowly — a driver limit, not a DeckCraft bug.
|
||||
- `xdg-desktop-portal` (with `xdg-desktop-portal-gtk` or `-kde`; `-wlr` has no file chooser, so
|
||||
on wlroots compositors install `zenity`) is what the Open and Save dialogs talk to; `xkeyboard-config` holds the XKB keyboard data `libxkbcommon` reads.
|
||||
- fontconfig and freetype are not needed: DeckCraft reads `/usr/share/fonts` itself and embeds its
|
||||
presentation fonts.
|
||||
|
||||
Building from source on musl also needs `build-base pkgconf alsa-lib-dev` (`alsa-lib-dev` is the
|
||||
ALSA headers).
|
||||
|
||||
### FreeBSD
|
||||
|
||||
| Build | File |
|
||||
|
||||
+3
-65
@@ -2,10 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **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-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)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (macOS Finder document opening; native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
@@ -134,61 +131,19 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) |
|
||||
| 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) |
|
||||
| 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 |
|
||||
| 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) |
|
||||
| 2026-10-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-22 | Slide Show: while animations play, each frame draws only the slide's shapes over the slide's backdrop (background, master and layout graphics), rendered once and ahead of time. A full-slide picture with a glow in the layout re-rendered at every frame held animations to 2–3 frames a second (~390 ms a frame at 1080p); now ~28 ms |
|
||||
| 2026-10-22 | Animation Pane ▸ Play From: with an effect selected, the pane's play button starts the canvas preview at that effect, with the effects before it already played. With nothing selected it is still Play All |
|
||||
| 2026-10-21 | Opening a .pptx puts the text of shapes without an anchor at the top even when the theme's shape defaults (`spDef`) centre new shapes, as PowerPoint for Mac draws them; checked with a synthetic probe and on a private 166-deck corpus (51 slides closer to PowerPoint's PDF, none further) |
|
||||
| 2026-10-23 | A shape with no fill and no line (a text box) no longer draws its shape-level glow as a halo round its box; the text's own glow is all PowerPoint shows |
|
||||
| 2026-10-23 | Normal view: a slide zoomed past the window gets a horizontal and a vertical scroll bar on the axes that overflow; drag the thumb or click the track to reach every edge (#1157) |
|
||||
| 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) |
|
||||
| 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
|
||||
| 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again |
|
||||
| 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-21 | Reuse Slides: pick which slides of the other presentation to insert from a list (`slide.reuseList`, #1103) |
|
||||
| 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members |
|
||||
| 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) |
|
||||
| 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |
|
||||
| 2026-10-18 | Slide show: a video or audio clip is looked up on the slide it belongs to, so slides whose shape ids repeat (the PPTX import numbers each slide afresh) no longer play each other's clips (#994) |
|
||||
| 2026-10-18 | About: Contributors and Models get their own fixed-size block below the tab row, so the tab row is no longer squeezed into the credits and the tabs keep answering (#843) |
|
||||
| 2026-10-19 | README: the Linux runtime libraries a tarball or musl install has to provide — the `dlopen`ed X11/Vulkan/GL/dialog libraries with their Alpine and Void package names, the software-Vulkan fallback, and why fontconfig and freetype are not among them (#955) |
|
||||
| 2026-10-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
|
||||
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
|
||||
| 2026-10-24 | Edit Points: hold Shift, Ctrl or Alt while dragging a point's handle to make it a smooth, straight or corner point, as in PowerPoint (Cmd and Option work as Ctrl and Alt on the Mac); the point keeps that type for the next drag |
|
||||
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
|
||||
| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) |
|
||||
| 2026-10-16 | Symbol-font characters: a run's U+F0xx symbol code (from `<a:sym typeface="Symbol"/>`) now draws from the installed Symbol face even when it has no private-use mapping — Apple's Symbol on macOS maps the glyphs to real Unicode, so the code is remapped to that character (the Adobe Symbol encoding) instead of falling to a look-alike. Bullets and metafile text take the same remap |
|
||||
| 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection |
|
||||
| 2026-10-18 | Charts: a series' own data-label setting is honoured — numbers PowerPoint shows on a graph no longer disappear, and a bare `c:showVal` means on (#868) |
|
||||
| 2026-10-15 | Render: quick styles render on empty placeholders — the fill gate now checks for style and line overrides too (#832) |
|
||||
| 2026-10-15 | Canvas: the resize cursor follows the handle's rotated diagonal — rotated shapes showed the wrong diagonal cursor (#745) |
|
||||
| 2026-10-20 | Tests: the Options dialog's Cancel, Esc and close button put the settings back the way they were (#878) |
|
||||
| 2026-10-20 | Tests: Ctrl+A in the slide sorter selects every slide, as in PowerPoint (#936) |
|
||||
| 2026-10-18 | Thumbnails: navigating slides with the arrow keys scrolls the thumbnail pane to keep the current slide visible (#917) |
|
||||
| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
|
||||
| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
|
||||
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
|
||||
| 2026-10-16 | Fitted canvas: discrete mouse-wheel steps navigate immediately, including rapid and batched input, while trackpad gestures retain their cooldown and zoomed slides still pan. |
|
||||
| 2026-10-16 | PPTX import: a paragraph build edited in PowerPoint (paragraphs with their own start, other shapes' effects in between, builds by 2nd-level paragraphs) takes PowerPoint's clicks in the show; it used to be rebuilt as one build, 34 clicks instead of 10 on the reporter's slide. Paragraph builds with sub-points save as PowerPoint writes them (#844) |
|
||||
| 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
|
||||
| 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) |
|
||||
| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off |
|
||||
| 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) |
|
||||
| 2026-10-15 | Canvas: the Format and Animation Painter override the cursor to show the tool is armed (#830) |
|
||||
| 2026-10-24 | Edit Points: Esc pressed before the mouse button is released cancels a point or handle drag, as in PowerPoint, and leaves the shape as it was with no undo step. Edit Points stays on after the cancel |
|
||||
| 2026-10-15 | Edit Points: right-click a segment for Straight/Curved Segment, a point for Open/Close Path (`shape.segmentType`, `shape.openPath`, `shape.closePath`); Delete Segment and Add Point in the menu still to come |
|
||||
| 2026-10-24 | Edit Points: right-click a segment for Add Point (at the pointer), Delete Segment (a closed outline opens there; grayed out on an open one) and Open/Close Path, as in PowerPoint (`shape.deleteSegment`) |
|
||||
| 2026-10-15 | Design › Browse for Themes… with a `.pptx` or `.potx` brings its slide master and layouts, not just the theme: the master the current slide uses is replaced (a preserved one stays beside it), the template stretches to the deck's slide size, and each slide moves to the layout with the same name or kind and keeps its content. One undo step (#807) |
|
||||
| 2026-10-15 | Clicking the frame of a text box or placeholder selects the box whole, so it can be moved, duplicated or deleted; only a click inside puts the caret in the text (#787) |
|
||||
| 2026-10-15 | Review › Compare: Reject and Reject All drop revisions without applying them (`review.reject`); Accept All skips what was rejected |
|
||||
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
|
||||
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
|
||||
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
|
||||
@@ -252,7 +207,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | Draw: pen pressure scales ink width when the pointer reports it (a mouse or a missing reading stays at the pen width; a stroke saved as a flat 0.5 still draws at the pen width). Tilt is not read |
|
||||
| 2026-10-13 | Saving a presentation keeps the embedded workbook behind an unchanged chart, and the chart part's relationships (#335). Editing the chart drops that workbook instead of writing numbers that no longer match it |
|
||||
| 2026-10-13 | A presentation whose object ids are used up refuses to grow instead of handing out duplicate ids (#407) |
|
||||
| 2026-10-13 | macOS: a Finder double-click, Open With, a drop on the Dock icon and `open -a DeckCraft deck.pptx` open the file (#138, #347). `Info.plist` now declares the documents DeckCraft opens (`.deckcraft`, `.pptx`, `.potx`, `.ppsx`, the same set as the Windows installer and the Linux desktop file, kept equal by a test), and the `kAEOpenDocuments` Apple event is handled through the pinned `fmv-macos-events` crate as in GridCraft; the `-psn_` argument older macOS adds on a Finder launch is ignored. PowerPoint stays the default until the user picks DeckCraft |
|
||||
| 2026-10-21 | macOS: a Finder double-click, Open With, a drop on the Dock icon and `open -a DeckCraft deck.pptx` open the file (#138, #347). `Info.plist` now declares the documents DeckCraft opens (`.deckcraft`, `.pptx`, `.potx`, `.ppsx`, the same set as the Windows installer and the Linux desktop file, kept equal by a test), and the `kAEOpenDocuments` Apple event is handled through the pinned `fmv-macos-events` crate as in GridCraft; the `-psn_` argument older macOS adds on a Finder launch is ignored. The current default app is preserved until the user chooses DeckCraft; system-opened `.ppsx` files start their slide show |
|
||||
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
|
||||
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
|
||||
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
|
||||
@@ -285,28 +240,11 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-24 | trivial | Progress log: selecting a range of table cells by dragging. Scores and milestone names unchanged |
|
||||
| 2026-10-22 | trivial | Progress log: Animation Pane ▸ Play From. Scores unchanged |
|
||||
| 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: the Columns dialog. Scores and milestone names unchanged |
|
||||
| 2026-10-24 | trivial | Progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option. Scores and milestone names unchanged |
|
||||
| 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
|
||||
| 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged |
|
||||
| 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
|
||||
| 2026-10-16 | minor | Symbol-font U+F0xx codes remap to the installed Symbol face's real Unicode (Apple Symbol on macOS) |
|
||||
| 2026-10-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
|
||||
| 2026-10-18 | trivial | Progress log: thumbnails of huge decks render only the slides in view (#969) |
|
||||
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
|
||||
| 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) |
|
||||
| 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) |
|
||||
| 2026-10-22 | trivial | Progress log: table text takes the master's other-text style |
|
||||
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
|
||||
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
|
||||
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
|
||||
| 2026-10-22 | trivial | Progress log: chart series pattern and no fills |
|
||||
| 2026-10-15 | trivial | Progress log: Browse for Themes applies a template's master and layouts (#807) |
|
||||
| 2026-10-15 | trivial | Progress log: Compare Reject and Reject All |
|
||||
| 2026-10-16 | trivial | Progress log: PPTX paragraph builds edited in PowerPoint keep their clicks (#844) |
|
||||
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
|
||||
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
|
||||
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
|
||||
@@ -360,7 +298,7 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-13 | trivial | Progress log: Grid Options dialog |
|
||||
| 2026-10-14 | trivial | Progress log: pen pressure scales ink width |
|
||||
| 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) |
|
||||
| 2026-10-13 | trivial | Progress log: Finder opens presentations on macOS (#138) |
|
||||
| 2026-10-21 | minor | Progress log: Finder opens presentations on macOS (#138) |
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
|
||||
| 2026-10-12 | minor | Progress log: table cell editing and border resizing |
|
||||
|
||||
@@ -407,7 +407,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
|
||||
return Ok(vec![out.to_string()]);
|
||||
}
|
||||
let p = std::path::Path::new(out);
|
||||
let stem = p.file_stem().and_then(std::ffi::OsStr::to_str).unwrap_or("frame");
|
||||
let stem = p.file_stem().and_then(|x| x.to_str()).unwrap_or("frame");
|
||||
let mut paths: Vec<String> = Vec::with_capacity(ts.len());
|
||||
for t in ts {
|
||||
let path = p.with_file_name(format!("{stem}-t{t:.3}.png")).to_string_lossy().into_owned();
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
mod cli;
|
||||
mod convert;
|
||||
mod damaged;
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
mod mcp_roots;
|
||||
@@ -1,4 +1,6 @@
|
||||
//! `deckcraft-cli mcp` with the automation root flags, run as the real binary.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
|
||||
<title>DeckCraft</title>
|
||||
<link data-trunk rel="rust" data-bin="deckcraft-web" data-wasm-opt="z" data-weak-refs />
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/deckcraft.ico" />
|
||||
<link data-trunk rel="icon" href="../../assets/app-icon/hicolor/32x32/apps/ai.storyteller.deckcraft.png" />
|
||||
<style>
|
||||
html, body { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background: #262626; }
|
||||
#deckcraft_canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
|
||||
|
||||
@@ -11,8 +11,6 @@ const VIDEO_EXTS: &[&str] = &["mp4", "m4v", "mov", "webm"];
|
||||
const CANVAS_ID: &str = "deckcraft_canvas";
|
||||
const LOADING_ID: &str = "deckcraft_loading";
|
||||
|
||||
/// Start the browser shell: install logging, locate the canvas, launch the eframe runner,
|
||||
/// build the sample or blank deck, and replace the loading element when launch finishes.
|
||||
pub fn start() {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
wasm_bindgen_futures::spawn_local(async {
|
||||
@@ -38,7 +36,6 @@ pub fn start() {
|
||||
if let Some(rs) = &cc.wgpu_render_state {
|
||||
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
|
||||
}
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
let inbox: Inbox = Inbox::default();
|
||||
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
|
||||
let q = query();
|
||||
@@ -77,17 +74,11 @@ struct WebShell {
|
||||
}
|
||||
|
||||
impl eframe::App for WebShell {
|
||||
/// Delegate one eframe logic pass to the wrapped application.
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
|
||||
/// and deliver the id, name, and bytes through the inbox.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
// Browsers cannot read dropped files synchronously. Consume this pass's drops here, with
|
||||
// fresh input, and capture the document/slide before the asynchronous read finishes.
|
||||
for f in std::mem::take(&mut raw.dropped_files) {
|
||||
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
|
||||
for f in dropped {
|
||||
// Capture the document and slide now. The bytes arrive later, after the user may
|
||||
// have switched slides.
|
||||
let id = self.app.begin_external_file("drop");
|
||||
let inbox = self.inbox.clone();
|
||||
let ctx = ctx.clone();
|
||||
@@ -106,7 +97,11 @@ impl eframe::App for WebShell {
|
||||
}
|
||||
});
|
||||
}
|
||||
self.app.raw_input_hook(ctx, raw);
|
||||
self.app.logic(ctx);
|
||||
}
|
||||
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.app.raw_input_hook(raw);
|
||||
}
|
||||
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
|
||||
@@ -18,7 +18,6 @@ eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
# Icon + VERSIONINFO for deckcraft.exe; build.rs only uses it when the target is Windows.
|
||||
|
||||
@@ -36,18 +36,78 @@ impl AppleEvents {
|
||||
|
||||
/// Open the documents and act on the quit requests that arrived since the last frame.
|
||||
pub fn poll(inbox: &Inbox, app: &mut SlideApp, ctx: &egui::Context) {
|
||||
for e in inbox.drain() {
|
||||
match e {
|
||||
Event::Open(paths) => {
|
||||
for p in paths.iter().filter_map(|p| p.to_str()) {
|
||||
if let Err(e) = app.open_path(p) {
|
||||
let safe_p = p.replace(['\r', '\n'], " ");
|
||||
log::error!("{safe_p}: {e}");
|
||||
app.set_status(format!("Couldn't open {safe_p}: {e}"));
|
||||
}
|
||||
}
|
||||
for event in inbox.drain() {
|
||||
handle(event, app, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(event: Event, app: &mut SlideApp, ctx: &egui::Context) {
|
||||
match event {
|
||||
Event::Open(paths) => {
|
||||
let paths: Vec<String> = paths.iter().filter_map(|p| p.to_str().map(str::to_string)).collect();
|
||||
for (path, error) in app.open_from_system(&paths) {
|
||||
log::error!("{path}: {error}");
|
||||
app.set_status(format!("Couldn't open {path}: {error}"));
|
||||
}
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
Event::Quit => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_ui_egui::Services;
|
||||
use serde_json::json;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn presentation(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-apple-events-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(name);
|
||||
let mut session = Session::with_new();
|
||||
session.execute("file.saveAs", &json!({"path": path})).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_events_append_presentations_at_launch_and_while_running() {
|
||||
let first = presentation("Finder launch ü.pptx");
|
||||
let second = presentation("Finder second.deckcraft");
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
handle(Event::Open(vec![first.clone()]), &mut app, &ctx);
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
handle(Event::Open(vec![second.clone()]), &mut app, &ctx);
|
||||
assert_eq!(app.session.documents().len(), 2);
|
||||
assert_eq!(app.ui.recent.first(), second.to_str().map(str::to_string).as_ref());
|
||||
std::fs::remove_file(first).unwrap();
|
||||
std::fs::remove_file(second).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_event_starts_only_the_last_successful_powerpoint_show() {
|
||||
let show = presentation("Finder show.PPSX");
|
||||
let edit = presentation("Finder edit.pptx");
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
handle(Event::Open(vec![show.clone(), edit.clone()]), &mut app, &ctx);
|
||||
assert!(app.show.is_none(), "the last file opens for editing");
|
||||
handle(Event::Open(vec![show.clone()]), &mut app, &ctx);
|
||||
assert!(app.show.is_some(), "a system-opened PPSX starts its show");
|
||||
std::fs::remove_file(show).unwrap();
|
||||
std::fs::remove_file(edit).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_open_event_reports_the_error_and_keeps_the_document() {
|
||||
let mut app = SlideApp::new(Session::with_new(), Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
handle(Event::Open(vec![PathBuf::from("/nonexistent/deckcraft-finder-missing.pptx")]), &mut app, &ctx);
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
// Status is visible through the public UI control inspection.
|
||||
let result = deckcraft_ui_egui::control::inspect(&app, &ctx);
|
||||
assert!(result.to_string().contains("Couldn't open"), "{result}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
//! socket may sit idle or blocked on a write.
|
||||
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
|
||||
use std::net::{Shutdown, TcpListener, TcpStream};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::mpsc::{Receiver, SyncSender, TrySendError, sync_channel};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::Duration;
|
||||
|
||||
use deckcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
@@ -61,35 +61,16 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
|
||||
/// example while the process has no file descriptors left, and retrying at once would keep a
|
||||
/// core busy until it recovers.
|
||||
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The longest a connection refused as busy is drained before it is closed.
|
||||
const REFUSED_GRACE: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Refused connections drained at once, each on a thread of its own. Past this many, a refused
|
||||
/// connection gets the busy reply and is closed without a drain.
|
||||
const MAX_REFUSING: usize = 16;
|
||||
|
||||
/// The connections `incoming` accepts, calling `wait` after each failed accept.
|
||||
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
|
||||
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
|
||||
}
|
||||
|
||||
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let refusing = Arc::new(AtomicUsize::new(0));
|
||||
std::thread::spawn(move || {
|
||||
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
|
||||
for mut stream in listener.incoming().flatten() {
|
||||
let _ = stream.set_write_timeout(Some(limits.write_timeout));
|
||||
if active.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
|
||||
active.fetch_sub(1, Ordering::SeqCst);
|
||||
let _ = writeln!(stream, "{}", json!({"ok": false, "error": "busy: too many control connections"}));
|
||||
refuse(stream, &refusing, limits.write_timeout.min(REFUSED_GRACE));
|
||||
continue;
|
||||
}
|
||||
let slot = Slot(Arc::clone(&active));
|
||||
@@ -107,27 +88,6 @@ fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) ->
|
||||
rx
|
||||
}
|
||||
|
||||
/// Close a connection that was told it is refused. A peer that wrote a line before it read the
|
||||
/// reply would see only a reset, so the connection is drained for up to `grace` first, on a
|
||||
/// thread of its own: the accepting thread goes straight back to accepting. Past
|
||||
/// [`MAX_REFUSING`] drains at once, the connection is closed without one.
|
||||
fn refuse(stream: TcpStream, refusing: &Arc<AtomicUsize>, grace: Duration) {
|
||||
if refusing.fetch_add(1, Ordering::SeqCst) >= MAX_REFUSING {
|
||||
refusing.fetch_sub(1, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
let slot = Slot(Arc::clone(refusing));
|
||||
let spawned = std::thread::Builder::new().name("control-refused".into()).spawn(move || {
|
||||
let _slot = slot;
|
||||
if let Ok(read) = stream.try_clone() {
|
||||
close_politely(&stream, &mut BufReader::new(read), grace);
|
||||
}
|
||||
});
|
||||
if spawned.is_err() {
|
||||
log::warn!("control server could not start a thread to close a refused connection");
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds one connection slot; gives it back when the client thread ends, however it ends.
|
||||
struct Slot(Arc<AtomicUsize>);
|
||||
|
||||
@@ -210,37 +170,6 @@ fn is_http_request_line(text: &str) -> bool {
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
/// Close after the last reply so the peer can still read it. A peer that had sent another line
|
||||
/// before the reply arrived -- two lines in one write, which any script does -- leaves input
|
||||
/// unread in the socket, and closing a socket with unread input resets the connection: the reply
|
||||
/// already queued for the peer is discarded and it sees only the reset. Sending the FIN first and
|
||||
/// then draining, briefly and boundedly, makes the close orderly and the reason survive.
|
||||
fn close_politely(out: &TcpStream, reader: &mut BufReader<TcpStream>, grace: Duration) {
|
||||
if out.shutdown(Shutdown::Write).is_err() {
|
||||
return;
|
||||
}
|
||||
// Enough for a pipelined line or two; a peer that keeps talking is dropped at the deadline.
|
||||
// The timeout is what is left of the grace before each read, not the whole of it: a peer that
|
||||
// sends a byte just before each one expires would otherwise hold this thread, and its slot,
|
||||
// for close to twice as long as the deadline says. The timeout is set on the handle that
|
||||
// reads: on Windows, a read timeout set on one handle of a `try_clone` pair can fail to apply
|
||||
// to reads through the other.
|
||||
const MAX_DRAIN: usize = 64 << 10;
|
||||
let until = Instant::now() + grace;
|
||||
let mut sink = [0u8; 4 << 10];
|
||||
let mut drained = 0;
|
||||
while drained < MAX_DRAIN {
|
||||
let left = until.saturating_duration_since(Instant::now());
|
||||
if left.is_zero() || reader.get_ref().set_read_timeout(Some(left)).is_err() {
|
||||
break;
|
||||
}
|
||||
match reader.read(&mut sink) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => drained += n,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits) {
|
||||
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
|
||||
return;
|
||||
@@ -253,7 +182,6 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
Line::Text(t) => t,
|
||||
Line::TooLong => {
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": format!("request line longer than {} bytes", limits.max_line)}));
|
||||
close_politely(&out, &mut reader, limits.write_timeout);
|
||||
break;
|
||||
}
|
||||
Line::Closed => break,
|
||||
@@ -261,11 +189,8 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
let reply = match parse(&line) {
|
||||
Parsed::Blank => continue,
|
||||
Parsed::Reject(e) => {
|
||||
// The connection closes either way, but the peer is told why first: see
|
||||
// `close_politely`, without which a peer that had pipelined another line had this
|
||||
// reply reset away and saw nothing.
|
||||
// The connection closes either way; a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
|
||||
close_politely(&out, &mut reader, limits.write_timeout);
|
||||
break;
|
||||
}
|
||||
Parsed::Request { id, method, params } => {
|
||||
@@ -309,18 +234,6 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A failed accept is followed by a wait before the next one, so a listener that keeps
|
||||
/// failing does not spin, and the connections around the failures still come through.
|
||||
#[test]
|
||||
fn a_failed_accept_waits_before_the_next() {
|
||||
let failed = || Err(std::io::Error::other("no file descriptors left"));
|
||||
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
|
||||
let mut waits = 0;
|
||||
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
|
||||
assert_eq!(connections, [1, 2]);
|
||||
assert_eq!(waits, 3, "one wait per failed accept");
|
||||
}
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -414,93 +327,6 @@ mod tests {
|
||||
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
|
||||
}
|
||||
|
||||
/// A peer that pipelines -- two lines in one write, as a script does -- still reads why its
|
||||
/// line was rejected. Closing with its second line unread resets the connection and discards
|
||||
/// the reply already on its way, so the peer saw nothing at all; repeated here because it
|
||||
/// depended on the timing of the two.
|
||||
#[test]
|
||||
fn a_pipelined_peer_still_reads_why_its_line_was_rejected() {
|
||||
// A slot comes back only when the thread serving a peer ends, which can be after the next
|
||||
// peer connects, so every peer has a slot of its own.
|
||||
const PEERS: usize = 12;
|
||||
let (addr, rx) = server(Limits { max_clients: PEERS, ..small() });
|
||||
for _ in 0..PEERS {
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"hello\n").unwrap();
|
||||
// A second line, written after the first as a script writes it: it races the close.
|
||||
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
assert!(replies[0]["error"].as_str().unwrap_or_default().contains("closing the connection"), "{replies:?}");
|
||||
}
|
||||
// The line after the rejected one is never dispatched, pipelined or not.
|
||||
assert!(rx.try_recv().is_err(), "a line after the rejection was dispatched");
|
||||
}
|
||||
|
||||
/// A peer refused because every connection is taken still reads why when it had already
|
||||
/// written a line: closing with that line unread resets the connection and discards the
|
||||
/// reply. Repeated because it depends on the timing of the two.
|
||||
#[test]
|
||||
fn a_pipelined_peer_refused_as_busy_still_reads_why() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
// Take the only slot, and make sure it is being served.
|
||||
let (mut hr, mut hw) = client(addr);
|
||||
writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut hr).is_some());
|
||||
for _ in 0..50 {
|
||||
let (mut r, mut w) = client(addr);
|
||||
let _ = w.write_all(b"{\"id\":2,\"method\":\"m\"}\n");
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap_or_default().starts_with("busy"), "{replies:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Peers refused as busy that stay connected without sending anything do not hold up the
|
||||
/// connections after them: each one's drain runs to its deadline on a thread of its own, and
|
||||
/// past `MAX_REFUSING` drains at once a refused connection is closed without one.
|
||||
#[test]
|
||||
fn silent_refused_peers_do_not_delay_the_next_refusal() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let (mut hr, mut hw) = client(addr);
|
||||
writeln!(hw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
assert!(reply(&mut hr).is_some());
|
||||
// Twenty drains of 250 ms each, one after another, would take five seconds.
|
||||
let silent: Vec<_> = (0..MAX_REFUSING + 4).map(|_| client(addr)).collect();
|
||||
let start = Instant::now();
|
||||
let (mut r, _w) = client(addr);
|
||||
let refused = reply(&mut r).unwrap();
|
||||
assert!(refused["error"].as_str().unwrap_or_default().starts_with("busy"), "{refused}");
|
||||
assert!(start.elapsed() < Duration::from_secs(1), "refused after {:?}", start.elapsed());
|
||||
drop(silent);
|
||||
}
|
||||
|
||||
/// A peer that keeps its connection open after its line was rejected gives its slot back at
|
||||
/// the drain deadline (the write timeout), not at the idle timeout.
|
||||
#[test]
|
||||
fn a_rejected_peer_that_stays_connected_frees_its_slot_at_the_deadline() {
|
||||
let (addr, rx) = server(Limits { max_clients: 1, idle_timeout: Duration::from_secs(30), ..small() });
|
||||
ui(rx);
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(b"hello\n").unwrap();
|
||||
assert_eq!(reply(&mut r).unwrap()["ok"], false);
|
||||
// `w` stays open, sending nothing.
|
||||
let deadline = Instant::now() + Duration::from_secs(4);
|
||||
loop {
|
||||
let (mut dr, mut dw) = client(addr);
|
||||
writeln!(dw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
if reply(&mut dr).is_some_and(|v| v["ok"] == true) {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "slot never freed");
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
drop(w);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_line_closes_connection() {
|
||||
let (addr, rx) = server(small());
|
||||
@@ -525,9 +351,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn connection_limit_refuses_then_frees_slot() {
|
||||
// `a` sends nothing. Under a 300 ms idle timeout, a slow run could close it before the
|
||||
// third peer connected, and that peer then took its slot instead of reading `busy`.
|
||||
let (addr, rx) = server(Limits { idle_timeout: Duration::from_secs(30), ..small() });
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
let a = client(addr);
|
||||
let (mut br, mut bw) = client(addr);
|
||||
@@ -554,18 +378,17 @@ mod tests {
|
||||
#[test]
|
||||
fn blocked_ui_times_out_and_full_queue_is_busy() {
|
||||
let (addr, rx) = server(small());
|
||||
// No UI: the request that reaches the one-slot queue first fills it and times out; the
|
||||
// other finds it full. Each connection has its own thread, so either request can be first.
|
||||
// No UI: the first request fills the one-slot queue and times out; the second finds it full.
|
||||
let (mut ar, mut aw) = client(addr);
|
||||
let (mut br, mut bw) = client(addr);
|
||||
writeln!(aw, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
writeln!(bw, r#"{{"id":2,"method":"m"}}"#).unwrap();
|
||||
let a = reply(&mut ar).unwrap();
|
||||
let b = reply(&mut br).unwrap();
|
||||
assert_eq!((&a["id"], &b["id"]), (&json!(1), &json!(2)));
|
||||
let mut errors = [a["error"].as_str().unwrap_or_default(), b["error"].as_str().unwrap_or_default()];
|
||||
errors.sort_unstable();
|
||||
assert_eq!(errors, ["busy: too many queued requests", "timeout"], "{a} {b}");
|
||||
assert!(b["error"].as_str().unwrap().starts_with("busy"));
|
||||
assert_eq!(b["id"], 2);
|
||||
let a = reply(&mut ar).unwrap();
|
||||
assert_eq!(a["error"], "timeout");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
|
||||
@@ -95,35 +95,29 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch; a clock
|
||||
/// set absurdly far ahead keeps its real year (years far outside chrono's own range are preserved)
|
||||
/// instead of panicking.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let (y, dt) = utc_parts(since.as_secs(), since.subsec_nanos());
|
||||
// `{:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{}", dt.format("%m-%dT%H:%M:%S%.3fZ"))
|
||||
let secs = since.as_secs();
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days);
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Unix seconds (+ nanos) → (proleptic Gregorian year, the same wall-clock instant moved into
|
||||
/// chrono's range by whole 400-year cycles). The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so month, day and time of day come from chrono and only the year is carried,
|
||||
/// which keeps every input exact instead of collapsing out-of-range years to the epoch. `secs` is
|
||||
/// `u64` (the clock is clamped at the epoch before 1970), so `days` always fits an `i64`.
|
||||
fn utc_parts(secs: u64, nanos: u32) -> (i64, chrono::NaiveDateTime) {
|
||||
use chrono::Datelike;
|
||||
let (days, sod) = ((secs / 86_400) as i64, (secs % 86_400) as i64);
|
||||
let (cycles, rest) = (days.div_euclid(146_097), days.rem_euclid(146_097));
|
||||
// `rest + 719_163` (1970-01-01 is CE day 719_163) is always a valid CE day, and `sod` < 86_400.
|
||||
let date = i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt);
|
||||
let time = u32::try_from(sod).ok().and_then(|s| chrono::NaiveTime::from_num_seconds_from_midnight_opt(s, nanos.min(999_999_999)));
|
||||
match (date, time) {
|
||||
(Some(d), Some(t)) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.and_time(t))
|
||||
}
|
||||
_ => (1970, chrono::NaiveDateTime::default()), // unreachable
|
||||
}
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -395,17 +389,6 @@ mod tests {
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// A clock set absurdly far ahead (past chrono's own range) keeps its real year rather than
|
||||
// collapsing to the epoch. Windows' SystemTime can't hold such an instant, so it is built
|
||||
// with a checked add and only asserted where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -42,9 +42,8 @@ impl eframe::App for App {
|
||||
apple_events::poll(&self.1, &mut self.0, ctx);
|
||||
self.0.logic(ctx);
|
||||
}
|
||||
/// Forward native raw input to the wrapped application once per pass.
|
||||
fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(ctx, raw);
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
@@ -181,8 +180,6 @@ fn app_icon() -> Option<egui::IconData> {
|
||||
eframe::icon_data::from_png_bytes(png).map_err(|e| log::warn!("app icon: {e}")).ok()
|
||||
}
|
||||
|
||||
/// Install logging, parse startup arguments and environment, launch the native app, and
|
||||
/// propagate an eframe startup failure.
|
||||
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`.
|
||||
@@ -258,7 +255,6 @@ fn main() -> eframe::Result {
|
||||
"DeckCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
let mut app = SlideApp::new(Session::new(), services());
|
||||
load_prefs(&mut app);
|
||||
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
|
||||
|
||||
@@ -59,6 +59,3 @@ real_name = "Ryan C. Shoemaker"
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
[people.GhostMitra]
|
||||
sync_github_name = true
|
||||
|
||||
@@ -49,7 +49,7 @@ fn tokenize(s: &str) -> Vec<Tok> {
|
||||
n1.is_ascii_digit() || ((n1 == '-' || n1 == '+') && n2.is_ascii_digit())
|
||||
} {
|
||||
i += 2;
|
||||
while cs.get(i).is_some_and(char::is_ascii_digit) {
|
||||
while cs.get(i).is_some_and(|d| d.is_ascii_digit()) {
|
||||
i += 1;
|
||||
}
|
||||
break;
|
||||
|
||||
+9
-19
@@ -1,10 +1,10 @@
|
||||
//! Slide show navigation: click steps, slide order (hidden slides, custom shows, ranges), loop
|
||||
//! and automatic advance.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
use deckcraft_model::{Presentation, SlideId};
|
||||
|
||||
use crate::Timeline;
|
||||
use crate::interact::SlideRun;
|
||||
@@ -83,25 +83,12 @@ pub struct ShowState {
|
||||
pub zoom: Option<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// Index of each slide by id (the first slide wins a duplicate id), so resolving a custom show
|
||||
/// costs the sum of its two lists, not their product.
|
||||
fn slide_indices(pres: &Presentation) -> HashMap<SlideId, usize> {
|
||||
let mut map = HashMap::with_capacity(pres.slides.len());
|
||||
for (i, s) in pres.slides.iter().enumerate() {
|
||||
map.entry(s.id).or_insert(i);
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Slide indices the show visits in order: the custom show or range from Set Up Slide Show, minus
|
||||
/// hidden slides.
|
||||
pub fn show_order(pres: &Presentation) -> Vec<usize> {
|
||||
let n = pres.slides.len();
|
||||
let base: Vec<usize> = match pres.show.custom_show.as_ref().and_then(|name| pres.custom_shows.iter().find(|c| &c.name == name)) {
|
||||
Some(cs) => {
|
||||
let index = slide_indices(pres);
|
||||
cs.slides.iter().filter_map(|id| index.get(id).copied()).collect()
|
||||
}
|
||||
Some(cs) => cs.slides.iter().filter_map(|id| pres.slides.iter().position(|s| s.id == *id)).collect(),
|
||||
None => match pres.show.range {
|
||||
Some((a, b)) => {
|
||||
let a = (a.max(1) as usize).saturating_sub(1);
|
||||
@@ -117,9 +104,12 @@ pub fn show_order(pres: &Presentation) -> Vec<usize> {
|
||||
/// Visible slides of custom show `name`, in that show's order.
|
||||
fn custom_indices(pres: &Presentation, name: &str) -> Option<Vec<usize>> {
|
||||
let cs = pres.custom_shows.iter().find(|c| c.name == name)?;
|
||||
let index = slide_indices(pres);
|
||||
let order: Vec<usize> =
|
||||
cs.slides.iter().filter_map(|id| index.get(id).copied()).filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden)).collect();
|
||||
let order: Vec<usize> = cs
|
||||
.slides
|
||||
.iter()
|
||||
.filter_map(|id| pres.slides.iter().position(|s| s.id == *id))
|
||||
.filter(|&i| pres.slides.get(i).is_some_and(|s| !s.hidden))
|
||||
.collect();
|
||||
if order.is_empty() { None } else { Some(order) }
|
||||
}
|
||||
|
||||
|
||||
@@ -344,36 +344,6 @@ fn empty_slide() {
|
||||
assert_eq!(tl.locate(5.0), (0, 0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_of_seeks_the_play_all_clock_to_an_effect() {
|
||||
let mut late = anim(4, AnimClass::Entrance, "fade", AnimStart::WithPrevious);
|
||||
late.delay_ms = 250;
|
||||
let mut triggered = anim(4, AnimClass::Emphasis, "pulse", AnimStart::OnClick);
|
||||
triggered.trigger = Some(ShapeId(1));
|
||||
let tl = Timeline::new(
|
||||
&slide_with(vec![
|
||||
anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick),
|
||||
anim(2, AnimClass::Exit, "fadeOut", AnimStart::OnClick),
|
||||
anim(3, AnimClass::Entrance, "fade", AnimStart::OnClick),
|
||||
late,
|
||||
triggered,
|
||||
]),
|
||||
W,
|
||||
H,
|
||||
);
|
||||
assert_eq!(tl.start_of(0), Some(0.0));
|
||||
assert_eq!(tl.start_of(2), Some(2.0));
|
||||
assert_eq!(tl.start_of(3), Some(2.25));
|
||||
assert_eq!(tl.start_of(4), None, "a triggered effect is not on the play-all clock");
|
||||
assert_eq!(tl.start_of(9), None);
|
||||
// Playing from the third effect: what came before has played, the rest has not.
|
||||
let at = tl.start_of(2).unwrap();
|
||||
assert!(tl.state_at(ShapeId(1), None, at).visible, "entered before");
|
||||
assert!(!tl.state_at(ShapeId(2), None, at).visible, "exited before");
|
||||
assert!(!tl.state_at(ShapeId(3), None, at).visible, "not entered yet");
|
||||
assert!(!tl.state_at(ShapeId(4), None, at).visible, "not entered yet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locate_play_all_clock() {
|
||||
let tl = Timeline::new(
|
||||
@@ -1082,53 +1052,3 @@ fn show_returns_to_the_zoom_slide() {
|
||||
s.goto(&p, 4);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_show_resolves_duplicate_ids_to_the_first_slide() {
|
||||
let mut p = pres(3);
|
||||
Arc::make_mut(&mut p.slides[2]).id = p.slides[0].id;
|
||||
p.custom_shows = vec![CustomShow { name: "Dup".into(), slides: vec![p.slides[0].id, SlideId(1001), SlideId(424_242)] }];
|
||||
p.show.custom_show = Some("Dup".into());
|
||||
assert_eq!(show_order(&p), vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_show_with_many_entries_resolves_quickly() {
|
||||
let mut p = pres(20_000);
|
||||
let ids: Vec<SlideId> = p.slides.iter().map(|s| s.id).collect();
|
||||
p.custom_shows = vec![CustomShow { name: "All".into(), slides: ids.iter().rev().copied().collect() }];
|
||||
p.show.custom_show = Some("All".into());
|
||||
let t = std::time::Instant::now();
|
||||
let order = show_order(&p);
|
||||
assert_eq!(order.len(), 20_000);
|
||||
assert_eq!(order[0], 19_999);
|
||||
assert!(t.elapsed().as_secs() < 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_targets_the_first_shape_with_a_duplicate_id() {
|
||||
let mut s = slide_with(vec![anim(7, AnimClass::Entrance, "fade", AnimStart::OnClick)]);
|
||||
s.shapes[0] = shape(7, "first");
|
||||
s.shapes[1] = shape(7, "second");
|
||||
s.shapes[0].xfrm = Some(Xfrm::new(10.0, 10.0, 20.0, 20.0));
|
||||
let tl = Timeline::new(&s, W, H);
|
||||
assert_eq!(tl.effects().len(), 1);
|
||||
assert_eq!(tl.steps(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
|
||||
let mut s = slide_with(vec![]);
|
||||
s.shapes[0].text = Some(TextBody::from_text("one\ntwo\nthree"));
|
||||
s.animations = (0..2_000)
|
||||
.map(|_| {
|
||||
let mut a = anim(1, AnimClass::Path, "customPath", AnimStart::OnClick);
|
||||
a.text_build = TextBuild::ByParagraph;
|
||||
a.path = Some("M 0 0 L 0.5 0.5 E".into());
|
||||
a
|
||||
})
|
||||
.collect();
|
||||
let tl = Timeline::new(&s, W, H);
|
||||
assert_eq!(tl.effects().len(), 6_000);
|
||||
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
+27
-72
@@ -1,11 +1,10 @@
|
||||
//! A slide's animation sequence: click steps, effect start times and per-shape state over time.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::anim::{BuildRole, TextBuild, paragraph_build_roles};
|
||||
use deckcraft_model::anim::TextBuild;
|
||||
use deckcraft_model::style::Fill;
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, Shape, ShapeId, Slide, Xfrm};
|
||||
use deckcraft_model::{AnimClass, AnimStart, Animation, ColorScheme, Rgba, SchemeSlot, ShapeId, Slide, Xfrm};
|
||||
|
||||
use crate::AnimState;
|
||||
use crate::effects::{EffectSpec, exit_base, motion_path};
|
||||
@@ -13,6 +12,7 @@ use crate::effects::{EffectSpec, exit_base, motion_path};
|
||||
/// Delay between paragraphs of a by-paragraph build that isn't on click (seconds).
|
||||
pub const PARAGRAPH_STAGGER: f64 = 0.5;
|
||||
const MAX_EFFECTS: usize = 100_000;
|
||||
const MAX_PARAGRAPHS: usize = 1_000;
|
||||
const MAX_ITERATIONS: f64 = 1.0e6;
|
||||
|
||||
/// Which sequence an effect belongs to.
|
||||
@@ -86,9 +86,7 @@ enum After {
|
||||
#[derive(Clone, Debug)]
|
||||
struct Effect {
|
||||
timing: EffectTiming,
|
||||
/// Shared by every paragraph effect of one animation, so a large motion path is not copied
|
||||
/// per paragraph.
|
||||
spec: Arc<EffectSpec>,
|
||||
spec: EffectSpec,
|
||||
after: After,
|
||||
}
|
||||
|
||||
@@ -165,14 +163,6 @@ impl Timeline {
|
||||
let accent = scheme.map(|s| s.get(SchemeSlot::Accent1)).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
|
||||
|
||||
let mut tl = Timeline::default();
|
||||
if slide.animations.is_empty() {
|
||||
return tl;
|
||||
}
|
||||
// Every shape by id, built once: resolving each animation's target by tree search would
|
||||
// cost shapes x animations.
|
||||
let shapes = shape_index(&slide.shapes);
|
||||
// Paragraph targets of each shape, worked out for its first by-paragraph animation only.
|
||||
let mut para_cache: HashMap<ShapeId, Arc<Vec<Option<usize>>>> = HashMap::new();
|
||||
// Group the animations by sequence in one pass, sequences in order of first appearance
|
||||
// (main first), so many distinct triggers cost linear work, not a scan per trigger.
|
||||
let mut seqs: Vec<(Sequence, Vec<usize>)> = vec![(Sequence::Main, Vec::new())];
|
||||
@@ -201,38 +191,34 @@ impl Timeline {
|
||||
if tl.effects.len() >= MAX_EFFECTS {
|
||||
break;
|
||||
}
|
||||
let shape = shapes.get(&a.shape).copied();
|
||||
let shape = slide.shape(a.shape);
|
||||
// Paragraph targets of this animation.
|
||||
let paras: Arc<Vec<Option<usize>>> = match (a.paragraph, a.text_build) {
|
||||
(Some(p), _) => Arc::new(vec![Some(p as usize)]),
|
||||
let paras: Vec<Option<usize>> = match (a.paragraph, a.text_build) {
|
||||
(Some(p), _) => vec![Some(p as usize)],
|
||||
(None, TextBuild::ByParagraph) => {
|
||||
let cached = para_cache.entry(a.shape).or_insert_with(|| {
|
||||
let mut v: Vec<Option<usize>> = vec![];
|
||||
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
|
||||
for (i, role) in paragraph_build_roles(t).into_iter().enumerate() {
|
||||
match role {
|
||||
BuildRole::Top => v.push(Some(i)),
|
||||
BuildRole::Member(o) => {
|
||||
if grouped.insert((a.shape, i, o)) {
|
||||
tl.groups.push((a.shape, i, o));
|
||||
}
|
||||
}
|
||||
BuildRole::Unowned => {}
|
||||
}
|
||||
let mut v: Vec<Option<usize>> = vec![];
|
||||
if let Some(t) = shape.and_then(|s| s.text.as_ref()) {
|
||||
let filled = |p: &deckcraft_model::text::Paragraph| p.runs.iter().any(|r| !r.text.trim().is_empty());
|
||||
let top = t.paragraphs.iter().filter(|p| filled(p)).map(|p| p.level).min().unwrap_or(0);
|
||||
let mut owner: Option<usize> = None;
|
||||
for (i, p) in t.paragraphs.iter().enumerate() {
|
||||
if p.level <= top && filled(p) && v.len() < MAX_PARAGRAPHS {
|
||||
owner = Some(i);
|
||||
v.push(Some(i));
|
||||
} else if let Some(o) = owner
|
||||
&& grouped.insert((a.shape, i, o))
|
||||
{
|
||||
tl.groups.push((a.shape, i, o));
|
||||
}
|
||||
}
|
||||
if v.is_empty() {
|
||||
v.push(None)
|
||||
}
|
||||
Arc::new(v)
|
||||
});
|
||||
Arc::clone(cached)
|
||||
}
|
||||
if v.is_empty() { vec![None] } else { v }
|
||||
}
|
||||
_ => Arc::new(vec![None]),
|
||||
_ => vec![None],
|
||||
};
|
||||
let xf = xfrm_of(a.shape).or_else(|| shape.and_then(|s| s.xfrm)).unwrap_or(Xfrm::new(sw / 4.0, sh / 4.0, sw / 2.0, sh / 2.0));
|
||||
let xf = sanitize_xfrm(xf, sw, sh);
|
||||
let spec = Arc::new(build_spec(a, xf, sw, sh, &resolve));
|
||||
let spec = build_spec(a, xf, sw, sh, &resolve);
|
||||
let dur = secs(a.duration_ms);
|
||||
let delay = secs(a.delay_ms);
|
||||
let until_next_click = a.repeat == u32::MAX;
|
||||
@@ -301,7 +287,7 @@ impl Timeline {
|
||||
span,
|
||||
class: a.class,
|
||||
},
|
||||
spec: Arc::clone(&spec),
|
||||
spec: spec.clone(),
|
||||
after: after.clone(),
|
||||
});
|
||||
}
|
||||
@@ -312,13 +298,12 @@ impl Timeline {
|
||||
}
|
||||
}
|
||||
// Base colours of animated shapes.
|
||||
let mut colored: HashSet<ShapeId> = HashSet::new();
|
||||
for e in &tl.effects {
|
||||
let id = e.timing.shape;
|
||||
if !colored.insert(id) {
|
||||
if tl.colors.iter().any(|(s, _)| *s == id) {
|
||||
continue;
|
||||
}
|
||||
let c = match shapes.get(&id).copied().and_then(|s| s.fill.as_ref()) {
|
||||
let c = match slide.shape(id).and_then(|s| s.fill.as_ref()) {
|
||||
Some(Fill::Solid { color }) => resolve(color),
|
||||
_ => accent,
|
||||
};
|
||||
@@ -374,19 +359,6 @@ impl Timeline {
|
||||
(self.main.len(), 0.0)
|
||||
}
|
||||
|
||||
/// Where effect `anim` (index into `slide.animations`) starts on the play-everything clock:
|
||||
/// Animation Pane ▸ Play From. `None` for a triggered effect or one that isn't on the slide.
|
||||
pub fn start_of(&self, anim: usize) -> Option<f64> {
|
||||
let e = self
|
||||
.effects
|
||||
.iter()
|
||||
.map(|e| &e.timing)
|
||||
.filter(|e| e.anim == anim && e.sequence == Sequence::Main)
|
||||
.min_by(|a, b| a.step.cmp(&b.step).then(a.start.total_cmp(&b.start)))?;
|
||||
let before: f64 = self.main.iter().take(e.step).map(|s| s.duration).sum();
|
||||
Some(before + e.start)
|
||||
}
|
||||
|
||||
/// Shapes that start an interactive sequence when clicked.
|
||||
pub fn trigger_shapes(&self) -> Vec<ShapeId> {
|
||||
self.triggers.iter().map(|(s, _)| *s).collect()
|
||||
@@ -551,23 +523,6 @@ fn status(e: &EffectTiming, step: usize, t: f64) -> Status {
|
||||
Status::Active(p.clamp(0.0, 1.0))
|
||||
}
|
||||
|
||||
/// Shapes by id in the order `Slide::shape` searches (a shape before its children, groups at most
|
||||
/// 64 deep); the first shape with an id wins a duplicate.
|
||||
fn shape_index(shapes: &[Shape]) -> HashMap<ShapeId, &Shape> {
|
||||
fn walk<'a>(shapes: &'a [Shape], depth: usize, out: &mut HashMap<ShapeId, &'a Shape>) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for s in shapes {
|
||||
out.entry(s.id).or_insert(s);
|
||||
walk(s.children(), depth + 1, out);
|
||||
}
|
||||
}
|
||||
let mut out = HashMap::new();
|
||||
walk(shapes, 0, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn build_spec(a: &Animation, xf: Xfrm, sw: f64, sh: f64, resolve: &dyn Fn(&deckcraft_model::ColorRef) -> Rgba) -> EffectSpec {
|
||||
let effect = if a.class == AnimClass::Exit { exit_base(&a.effect).to_string() } else { a.effect.clone() };
|
||||
let path = if a.class == AnimClass::Path { Some(motion_path(&a.effect, &a.option, a.path.as_deref())) } else { None };
|
||||
|
||||
@@ -9,10 +9,7 @@ use std::f64::consts::PI;
|
||||
|
||||
use crate::easing::ease;
|
||||
use crate::transition::{FULL, Layer, R, Source};
|
||||
use crate::transition_geom::{
|
||||
Cam, V3, boundary, card, center, clip, dir, edge, ein, eout, facing_shade, fan, field, hash64, moved, offset, poly_layers, pow_inout, q2,
|
||||
rect_poly, rnd, rot, rot_quad, scale_rect, scaled, soft_rect, ss, v3, win,
|
||||
};
|
||||
use crate::transition_geom::*;
|
||||
|
||||
use Source::{Black, New, NewBack, NewContent, Old, OldBack, OldContent, White};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! CDEF (7.15).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::{CDEF_DIRECTIONS, CDEF_PRI_TAPS, CDEF_SEC_TAPS, CDEF_UV_DIR, DIV_TABLE};
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Run CDEF on `fs.cur`; returns the CdefFrame, or None when no block is filtered (CdefFrame
|
||||
@@ -242,14 +242,14 @@ fn filter_rows<const W: usize>(
|
||||
}
|
||||
for i in 0..h {
|
||||
let ci = ((i + 2) * PAD_STRIDE + 2) as isize;
|
||||
let Some(&x) = b16.get(ci as usize..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
let Some(&x) = b16.get(ci as usize..).and_then(|r| r.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
let (mut sum, mut max, mut min) = ([0i16; W], x, x);
|
||||
for &(off, weight, thr, adj) in taps {
|
||||
let (weight, thr) = (weight as i16, thr as i16);
|
||||
let start = (ci + off) as usize;
|
||||
let Some(p) = b16.get(start..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
let Some(p) = b16.get(start..).and_then(|r| r.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
|
||||
+1
-21
@@ -1,26 +1,6 @@
|
||||
//! The adaptive CDF arrays (spec 6.8.2 init_non_coeff_cdfs / init_coeff_cdfs, load_cdfs).
|
||||
|
||||
use crate::spec_tables::{
|
||||
DEFAULT_ANGLE_DELTA_CDF, DEFAULT_CFL_ALPHA_CDF, DEFAULT_CFL_SIGN_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF,
|
||||
DEFAULT_COMP_BWD_REF_CDF, DEFAULT_COMP_GROUP_IDX_CDF, DEFAULT_COMP_MODE_CDF, DEFAULT_COMP_REF_CDF, DEFAULT_COMP_REF_TYPE_CDF,
|
||||
DEFAULT_COMPOUND_IDX_CDF, DEFAULT_COMPOUND_MODE_CDF, DEFAULT_COMPOUND_TYPE_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DELTA_LF_CDF, DEFAULT_DELTA_Q_CDF,
|
||||
DEFAULT_DRL_MODE_CDF, DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF,
|
||||
DEFAULT_EOB_PT_256_CDF, DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_FILTER_INTRA_CDF, DEFAULT_FILTER_INTRA_MODE_CDF,
|
||||
DEFAULT_INTER_INTRA_CDF, DEFAULT_INTER_INTRA_MODE_CDF, DEFAULT_INTER_TX_TYPE_SET1_CDF, DEFAULT_INTER_TX_TYPE_SET2_CDF,
|
||||
DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTERP_FILTER_CDF, DEFAULT_INTRA_TX_TYPE_SET1_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_INTRABC_CDF,
|
||||
DEFAULT_IS_INTER_CDF, DEFAULT_MOTION_MODE_CDF, DEFAULT_MV_BIT_CDF, DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF,
|
||||
DEFAULT_MV_CLASS0_HP_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_HP_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, DEFAULT_NEW_MV_CDF,
|
||||
DEFAULT_PALETTE_SIZE_2_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_2_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_3_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_4_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_4_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_5_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_6_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_6_Y_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_7_Y_COLOR_CDF,
|
||||
DEFAULT_PALETTE_SIZE_8_UV_COLOR_CDF, DEFAULT_PALETTE_SIZE_8_Y_COLOR_CDF, DEFAULT_PALETTE_UV_MODE_CDF, DEFAULT_PALETTE_UV_SIZE_CDF,
|
||||
DEFAULT_PALETTE_Y_MODE_CDF, DEFAULT_PALETTE_Y_SIZE_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF,
|
||||
DEFAULT_PARTITION_W64_CDF, DEFAULT_PARTITION_W128_CDF, DEFAULT_REF_MV_CDF, DEFAULT_RESTORATION_TYPE_CDF, DEFAULT_SEGMENT_ID_CDF,
|
||||
DEFAULT_SEGMENT_ID_PREDICTED_CDF, DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_SKIP_MODE_CDF, DEFAULT_TX_8X8_CDF, DEFAULT_TX_16X16_CDF,
|
||||
DEFAULT_TX_32X32_CDF, DEFAULT_TX_64X64_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_TXFM_SPLIT_CDF, DEFAULT_UNI_COMP_REF_CDF, DEFAULT_USE_OBMC_CDF,
|
||||
DEFAULT_USE_SGRPROJ_CDF, DEFAULT_USE_WIENER_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_WEDGE_INDEX_CDF,
|
||||
DEFAULT_WEDGE_INTER_INTRA_CDF, DEFAULT_Y_MODE_CDF, DEFAULT_ZERO_MV_CDF, FRAME_LF_COUNT, MV_CONTEXTS,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
|
||||
/// Every CDF array listed by init_non_coeff_cdfs and init_coeff_cdfs. Each innermost array holds
|
||||
/// N cumulative values (the last is 32768) plus the adaptation counter.
|
||||
|
||||
@@ -17,11 +17,7 @@ use crate::cdf::Cdfs;
|
||||
use crate::frame::FrameBuf;
|
||||
use crate::header::{FilmGrainParams, FrameHeader, HeaderState, LoopFilterDeltas, RefInfo, SegmentationFeatures, SequenceHeader, default_gm_params};
|
||||
use crate::par::Pool;
|
||||
use crate::spec_tables::{
|
||||
ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, DEFAULT_INTRA_FRAME_Y_MODE_CDF, DIV_MULT, GOLDEN_FRAME, INTRA_FRAME, INTRA_ONLY_FRAME, KEY_FRAME,
|
||||
LAST_FRAME, LAST2_FRAME, MAX_FRAME_DISTANCE, MAX_OFFSET_HEIGHT, MAX_OFFSET_WIDTH, MFMV_STACK_SIZE, NUM_REF_FRAMES, PRIMARY_REF_NONE,
|
||||
REFMVS_LIMIT, REFS_PER_FRAME,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::{FrameShared, FrameState, TileState};
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
use crate::tile::TileDecoder;
|
||||
@@ -65,7 +61,7 @@ impl<T: Clone> Slot<T> {
|
||||
}
|
||||
|
||||
fn set(&self, v: Result<T>) {
|
||||
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
|
||||
*g = Some(v);
|
||||
self.cv.notify_all();
|
||||
}
|
||||
@@ -75,12 +71,12 @@ impl<T: Clone> Slot<T> {
|
||||
}
|
||||
|
||||
fn wait(&self) -> Result<T> {
|
||||
let mut g = self.value.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut g = self.value.lock().unwrap_or_else(|e| e.into_inner());
|
||||
loop {
|
||||
if let Some(v) = g.as_ref() {
|
||||
return v.clone();
|
||||
}
|
||||
g = self.cv.wait(g).unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
g = self.cv.wait(g).unwrap_or_else(|e| e.into_inner());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const POOL_MAX_SAMPLES: usize = 96 << 20;
|
||||
fn zeroed(len: usize) -> Vec<u16> {
|
||||
if len >= POOL_MIN_LEN {
|
||||
let found = {
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
pool.iter().position(|v| v.capacity() >= len && v.capacity() <= 2 * len).map(|i| pool.swap_remove(i))
|
||||
};
|
||||
if let Some(mut v) = found {
|
||||
@@ -52,8 +52,8 @@ impl Drop for Plane {
|
||||
fn drop(&mut self) {
|
||||
if self.data.capacity() >= POOL_MIN_LEN {
|
||||
let v = std::mem::take(&mut self.data);
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let kept: usize = pool.iter().map(Vec::capacity).sum();
|
||||
let mut pool = PLANE_POOL.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let kept: usize = pool.iter().map(|b| b.capacity()).sum();
|
||||
if kept + v.capacity() <= POOL_MAX_SAMPLES {
|
||||
pool.push(v);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
//! Sequence header (5.5) and uncompressed frame header (5.9) parsing.
|
||||
|
||||
use crate::bits::BitReader;
|
||||
use crate::spec_tables::{
|
||||
AFFINE, ALTREF_FRAME, ALTREF2_FRAME, BWDREF_FRAME, GM_ABS_ALPHA_BITS, GM_ABS_TRANS_BITS, GM_ABS_TRANS_ONLY_BITS, GM_ALPHA_PREC_BITS,
|
||||
GM_TRANS_ONLY_PREC_BITS, GM_TRANS_PREC_BITS, GOLDEN_FRAME, IDENTITY, INTER_FRAME, INTRA_ONLY_FRAME, KEY_FRAME, LAST_FRAME, MAX_SEGMENTS,
|
||||
MAX_TILE_AREA, MAX_TILE_COLS, MAX_TILE_ROWS, MAX_TILE_WIDTH, NUM_REF_FRAMES, ONLY_4X4, PRIMARY_REF_NONE, REF_FRAME_LIST, REFS_PER_FRAME,
|
||||
RESTORATION_TILESIZE_MAX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_SWITCHABLE, RESTORE_WIENER, ROTZOOM, SEG_LVL_ALT_Q, SEG_LVL_MAX,
|
||||
SEG_LVL_REF_FRAME, SELECT_INTEGER_MV, SELECT_SCREEN_CONTENT_TOOLS, SUPERRES_DENOM_BITS, SUPERRES_DENOM_MIN, SUPERRES_NUM, SWITCH_FRAME,
|
||||
TOTAL_REFS_PER_FRAME, TRANSLATION, TX_MODE_LARGEST, TX_MODE_SELECT, WARPEDMODEL_PREC_BITS,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::{Error, Result};
|
||||
|
||||
pub const EIGHTTAP: u8 = 0;
|
||||
|
||||
+3
-12
@@ -5,16 +5,7 @@ use std::sync::OnceLock;
|
||||
|
||||
use crate::frame::Plane;
|
||||
use crate::mvpred::round2_signed64;
|
||||
use crate::spec_tables::{
|
||||
BLOCK_8X8, BLOCK_SIZES, COMPOUND_AVERAGE, COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_WEDGE, DIV_LUT, DIV_LUT_BITS,
|
||||
DIV_LUT_PREC_BITS, EIGHTTAP, EIGHTTAP_SHARP, EIGHTTAP_SMOOTH, FILTER_BITS, GLOBAL_GLOBALMV, GLOBALMV, II_H_PRED, II_SMOOTH_PRED, II_V_PRED,
|
||||
II_WEIGHTS_1D, INTRA_FRAME, LAST_FRAME, LOCALWARP, LS_MV_MAX, MASK_MASTER_SIZE, MAX_FRAME_DISTANCE, MAX_SB_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2,
|
||||
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, OBMC_MASK_2, OBMC_MASK_4, OBMC_MASK_8, OBMC_MASK_16, OBMC_MASK_32, QUANT_DIST_LOOKUP,
|
||||
QUANT_DIST_WEIGHT, REF_SCALE_SHIFT, SCALE_SUBPEL_BITS, SUBPEL_BITS, SUBPEL_FILTERS, SUBPEL_MASK, TRANSLATION, WARP_PARAM_REDUCE_BITS,
|
||||
WARPED_FILTERS, WARPEDDIFF_PREC_BITS, WARPEDMODEL_NONDIAGAFFINE_CLAMP, WARPEDMODEL_PREC_BITS, WARPEDMODEL_TRANS_CLAMP, WARPEDPIXEL_PREC_SHIFTS,
|
||||
WEDGE_BITS, WEDGE_CODEBOOK, WEDGE_HORIZONTAL, WEDGE_MASTER_OBLIQUE_EVEN, WEDGE_MASTER_OBLIQUE_ODD, WEDGE_MASTER_VERTICAL, WEDGE_OBLIQUE27,
|
||||
WEDGE_OBLIQUE63, WEDGE_OBLIQUE117, WEDGE_OBLIQUE153, WEDGE_TYPES, WEDGE_VERTICAL,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
const PRED_STRIDE: usize = 128;
|
||||
@@ -687,7 +678,7 @@ fn hpass<const W: usize>(src: &[u16], hf: &[i16; 8], out: &mut [i32], half: i32,
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = hf[t] as i32;
|
||||
let Some(p) = src.get(t..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
let Some(p) = src.get(t..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
@@ -708,7 +699,7 @@ fn vpass<const W: usize>(rows: &[i32], vf: &[i16; 8], out: &mut [i32], half: i32
|
||||
let mut acc = [half; W];
|
||||
for t in 0..8 {
|
||||
let f = vf[t] as i32;
|
||||
let Some(p) = rows.get(t * W..).and_then(<[_]>::first_chunk::<W>) else {
|
||||
let Some(p) = rows.get(t * W..).and_then(|s| s.first_chunk::<W>()) else {
|
||||
return;
|
||||
};
|
||||
for j in 0..W {
|
||||
|
||||
@@ -3,14 +3,7 @@
|
||||
use crate::Result;
|
||||
use crate::header::{EIGHTTAP, SWITCHABLE};
|
||||
use crate::mvpred::MvP;
|
||||
use crate::spec_tables::{
|
||||
ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_32X32, BLOCK_64X64, BLOCK_128X128, BWDREF_FRAME, CLASS0_SIZE, COMP_NEWMV_CTXS, COMPOUND_AVERAGE,
|
||||
COMPOUND_DIFFWTD, COMPOUND_DISTANCE, COMPOUND_INTRA, COMPOUND_MODE_CTX_MAP, COMPOUND_REFERENCE, COMPOUND_WEDGE, GLOBAL_GLOBALMV, GLOBALMV,
|
||||
GOLDEN_FRAME, INTRA_FRAME, INTRABC_DELAY_PIXELS, LAST_FRAME, LAST2_FRAME, LAST3_FRAME, LOCALWARP, MV_CLASS_0, MV_INTRABC_CONTEXT,
|
||||
MV_JOINT_HNZVNZ, MV_JOINT_HNZVZ, MV_JOINT_HZVNZ, NEAR_NEARMV, NEAR_NEWMV, NEAREST_NEARESTMV, NEAREST_NEWMV, NEARESTMV, NEARMV, NEW_NEARESTMV,
|
||||
NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, OBMC, REF_SCALE_SHIFT, SEG_LVL_GLOBALMV, SEG_LVL_REF_FRAME, SEG_LVL_SKIP,
|
||||
SIMPLE, SINGLE_REFERENCE, SIZE_GROUP, TRANSLATION, UNIDIR_COMP_REFERENCE, UV_CFL_PRED, WEDGE_BITS,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
fn block_width(bs: usize) -> usize {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
//! Intra prediction (spec 7.11.2), palette prediction (7.11.4) and chroma from luma (7.11.5).
|
||||
|
||||
use crate::frame::Plane;
|
||||
use crate::spec_tables::{
|
||||
ANGLE_STEP, D67_PRED, DC_PRED, DR_INTRA_DERIVATIVE, INTRA_EDGE_KERNEL, INTRA_EDGE_TAPS, INTRA_FILTER_SCALE_BITS, INTRA_FILTER_TAPS,
|
||||
MODE_TO_ANGLE, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SM_WEIGHTS_TX_64X64, SMOOTH_H_PRED, SMOOTH_PRED,
|
||||
SMOOTH_V_PRED, V_PRED,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::transform::round2;
|
||||
|
||||
/// Inputs of the intra prediction process that come from the block.
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
//! Motion vector prediction processes (spec 7.10): find_mv_stack, has_overlappable_candidates
|
||||
//! and find_warp_samples.
|
||||
|
||||
use crate::spec_tables::{
|
||||
GLOBAL_GLOBALMV, GLOBALMV, IDENTITY, INTRA_FRAME, LEAST_SQUARES_SAMPLES_MAX, MAX_REF_MV_STACK_SIZE, MV_BORDER, NEAR_NEWMV, NEAREST_NEWMV,
|
||||
NEW_NEARESTMV, NEW_NEARMV, NEW_NEWMV, NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, REF_CAT_LEVEL, TRANSLATION, WARPEDMODEL_PREC_BITS,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::tile::TileDecoder;
|
||||
|
||||
pub(crate) type MvP = [i32; 2];
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
//! In-loop post filters run by decode_frame_wrapup: loop filter (7.14), CDEF (7.15),
|
||||
//! super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::spec_tables::{
|
||||
GLOBAL_GLOBALMV, GLOBALMV, INTRA_FRAME, MAX_LOOP_FILTER, NEARESTMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SEG_LVL_ALT_LF_Y_V, TX_HEIGHT,
|
||||
TX_WIDTH,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::FrameState;
|
||||
use crate::stats::{DecodeStats, Stage, Timer};
|
||||
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
//! Super-resolution upscaling (7.16) and loop restoration (7.17).
|
||||
|
||||
use crate::frame::{FrameBuf, Plane};
|
||||
use crate::spec_tables::{
|
||||
FILTER_BITS, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER, SGR_PARAMS, SGRPROJ_MTABLE_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_RECIP_BITS,
|
||||
SGRPROJ_RST_BITS, SGRPROJ_SGR_BITS, SUPERRES_EXTRA_BITS, SUPERRES_FILTER_OFFSET, SUPERRES_FILTER_TAPS, SUPERRES_SCALE_BITS, SUPERRES_SCALE_MASK,
|
||||
UPSCALE_FILTER,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::FrameState;
|
||||
|
||||
/// Upscale a frame horizontally from FrameWidth to UpscaledWidth (7.16).
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
use crate::decoder::RefFrame;
|
||||
use crate::frame::{FrameBuf, MiInfo};
|
||||
use crate::header::{FrameHeader, RefInfo, SequenceHeader};
|
||||
use crate::spec_tables::{DEFAULT_INTRA_FRAME_Y_MODE_CDF, NUM_REF_FRAMES, RESTORE_NONE, SUBSAMPLED_SIZE};
|
||||
use crate::spec_tables::*;
|
||||
use std::ops::{Deref, DerefMut};
|
||||
use std::sync::Arc;
|
||||
|
||||
|
||||
+5
-23
@@ -6,27 +6,7 @@ use crate::cdf::Cdfs;
|
||||
use crate::frame::{MiInfo, Mv};
|
||||
use crate::header::{FrameHeader, NONE, SequenceHeader};
|
||||
use crate::intra::{IntraParams, is_directional_mode, predict_intra, round2_signed};
|
||||
use crate::spec_tables::{
|
||||
AC_QLOOKUP, ADJUSTED_TX_SIZE, ADST_FLIPADST, BLOCK_4X4, BLOCK_8X8, BLOCK_64X64, BLOCK_128X128, BR_CDF_SIZE, CFL_SIGN_NEG, CFL_SIGN_ZERO,
|
||||
COEFF_BASE_CTX_OFFSET, COEFF_BASE_POS_CTX_OFFSET, COEFF_BASE_RANGE, COMPOUND_AVERAGE, DC_PRED, DC_QLOOKUP, DCT_DCT, DCT_FLIPADST,
|
||||
DEFAULT_SCAN_4X4, DEFAULT_SCAN_4X8, DEFAULT_SCAN_4X16, DEFAULT_SCAN_8X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_8X16, DEFAULT_SCAN_8X32,
|
||||
DEFAULT_SCAN_16X4, DEFAULT_SCAN_16X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_16X32, DEFAULT_SCAN_32X8, DEFAULT_SCAN_32X16, DEFAULT_SCAN_32X32,
|
||||
DELTA_LF_SMALL, DELTA_Q_SMALL, FILTER_INTRA_MODE_TO_INTRA_DIR, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, FRAME_LF_COUNT, H_ADST, H_DCT,
|
||||
H_FLIPADST, H_PRED, IDTX, II_DC_PRED, II_H_PRED, II_V_PRED, INTRA_FRAME, INTRA_MODE_CONTEXT, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA,
|
||||
MAX_LOOP_FILTER, MAX_TX_DEPTH_TABLE, MAX_TX_SIZE_RECT, MAX_VARTX_DEPTH, MCOL_SCAN_4X4, MCOL_SCAN_4X8, MCOL_SCAN_4X16, MCOL_SCAN_8X4,
|
||||
MCOL_SCAN_8X8, MCOL_SCAN_8X16, MCOL_SCAN_16X4, MCOL_SCAN_16X8, MCOL_SCAN_16X16, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MODE_TO_TXFM, MROW_SCAN_4X4,
|
||||
MROW_SCAN_4X8, MROW_SCAN_4X16, MROW_SCAN_8X4, MROW_SCAN_8X8, MROW_SCAN_8X16, MROW_SCAN_16X4, MROW_SCAN_16X8, MROW_SCAN_16X16,
|
||||
NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PALETTE_COLOR_CONTEXT, PALETTE_COLOR_HASH_MULTIPLIERS, PALETTE_COLORS,
|
||||
PALETTE_NUM_NEIGHBORS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE, PARTITION_SPLIT, PARTITION_SUBSIZE,
|
||||
PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, QM_OFFSET, QUANTIZER_MATRIX, RESTORE_NONE, RESTORE_SGRPROJ, RESTORE_WIENER,
|
||||
SEG_LVL_SKIP, SGR_PARAMS, SGRPROJ_PARAMS_BITS, SGRPROJ_PRJ_BITS, SGRPROJ_PRJ_SUBEXP_K, SIG_REF_DIFF_OFFSET, SIG_REF_DIFF_OFFSET_NUM, SIMPLE,
|
||||
SMOOTH_H_PRED, SMOOTH_PRED, SMOOTH_V_PRED, SPLIT_TX_SIZE, SUPERRES_NUM, TX_4X4, TX_4X8, TX_4X16, TX_8X4, TX_8X8, TX_8X16, TX_8X32, TX_16X4,
|
||||
TX_16X8, TX_16X16, TX_16X32, TX_16X64, TX_32X8, TX_32X16, TX_32X32, TX_32X64, TX_64X16, TX_64X32, TX_64X64, TX_CLASS_2D, TX_CLASS_HORIZ,
|
||||
TX_CLASS_VERT, TX_HEIGHT, TX_HEIGHT_LOG2, TX_MODE_SELECT, TX_SET_DCTONLY, TX_SET_INTER_1, TX_SET_INTER_2, TX_SET_INTER_3, TX_SET_INTRA_1,
|
||||
TX_SET_INTRA_2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_SIZES, TX_SIZES_ALL, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA, TX_TYPE_INTER_INV_SET1,
|
||||
TX_TYPE_INTER_INV_SET2, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET1, TX_TYPE_INTRA_INV_SET2, TX_WIDTH, TX_WIDTH_LOG2, UV_CFL_PRED, V_ADST,
|
||||
V_DCT, V_FLIPADST, V_PRED,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
use crate::state::{FrameShared, TileState};
|
||||
use crate::symbol::SymbolDecoder;
|
||||
use crate::transform::inverse_transform_2d;
|
||||
@@ -2242,16 +2222,18 @@ fn neg_deinterleave(diff: i32, r: i32, max: i32) -> i32 {
|
||||
if diff <= 2 * r {
|
||||
if diff & 1 == 1 {
|
||||
return r + ((diff + 1) >> 1);
|
||||
} else {
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
diff
|
||||
} else {
|
||||
if diff <= 2 * (max - r - 1) {
|
||||
if diff & 1 == 1 {
|
||||
return r + ((diff + 1) >> 1);
|
||||
} else {
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
return r - (diff >> 1);
|
||||
}
|
||||
max - (diff + 1)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
//! Inverse transforms (spec 7.13), implemented step by step as specified.
|
||||
|
||||
use crate::spec_tables::{
|
||||
ADST_ADST, ADST_DCT, ADST_FLIPADST, COS128_LOOKUP, DCT_ADST, DCT_DCT, DCT_FLIPADST, FLIPADST_ADST, FLIPADST_DCT, FLIPADST_FLIPADST, H_ADST,
|
||||
H_DCT, H_FLIPADST, TRANSFORM_ROW_SHIFT, TX_HEIGHT_LOG2, TX_WIDTH_LOG2, V_ADST, V_DCT, V_FLIPADST,
|
||||
};
|
||||
use crate::spec_tables::*;
|
||||
|
||||
#[inline(always)]
|
||||
fn round2_64(x: i64, n: u32) -> i32 {
|
||||
|
||||
@@ -9,4 +9,6 @@ rust-version.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
craft-media = { workspace = true, features = ["bitstream"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = { workspace = true }
|
||||
|
||||
+485
-4
@@ -1,14 +1,495 @@
|
||||
//! Bit-level reading and writing for DeckCraft's codec crates: a re-export of
|
||||
//! [`craft_media::bitstream`] from craft-libs, plus [`budget`].
|
||||
//! Bit-level reading and writing shared by DeckCraft's codec crates (ported from FilmCraft).
|
||||
//!
|
||||
//! - [`BitReader`]: MSB-first reader with Exp-Golomb (`ue(v)`, `se(v)`), as used by H.264/HEVC/AV1 headers.
|
||||
//! - [`BitWriter`]: the matching writer (for encoders and header rewriting).
|
||||
//! - [`unescape_rbsp`] / [`escape_rbsp`]: remove/insert H.264/HEVC emulation-prevention bytes.
|
||||
//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
|
||||
//! - [`budget`]: the memory budget video decoders check their picture store against.
|
||||
//! - [`annexb_nals`] / [`length_prefixed_nals`]: split Annex-B byte streams and AVCC/HVCC samples into NAL units.
|
||||
|
||||
#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))]
|
||||
|
||||
pub use craft_media::bitstream::*;
|
||||
use std::fmt;
|
||||
|
||||
pub mod budget;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BitError {
|
||||
/// Tried to read past the end of the buffer.
|
||||
Eof,
|
||||
/// An Exp-Golomb code longer than 32 leading zeros.
|
||||
InvalidExpGolomb,
|
||||
/// A NAL length prefix width outside 1..=4 bytes.
|
||||
InvalidLengthSize,
|
||||
}
|
||||
|
||||
impl fmt::Display for BitError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
BitError::Eof => f.write_str("unexpected end of bitstream"),
|
||||
BitError::InvalidExpGolomb => f.write_str("invalid Exp-Golomb code"),
|
||||
BitError::InvalidLengthSize => f.write_str("unsupported NAL length prefix size"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for BitError {}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, BitError>;
|
||||
|
||||
/// MSB-first bit reader over a byte slice.
|
||||
#[derive(Clone)]
|
||||
pub struct BitReader<'a> {
|
||||
data: &'a [u8],
|
||||
/// Position in bits.
|
||||
pos: usize,
|
||||
}
|
||||
|
||||
impl<'a> BitReader<'a> {
|
||||
pub fn new(data: &'a [u8]) -> Self {
|
||||
Self { data, pos: 0 }
|
||||
}
|
||||
pub fn data(&self) -> &'a [u8] {
|
||||
self.data
|
||||
}
|
||||
/// Bit position from the start.
|
||||
pub fn position(&self) -> usize {
|
||||
self.pos
|
||||
}
|
||||
pub fn bits_left(&self) -> usize {
|
||||
(self.data.len() * 8).saturating_sub(self.pos)
|
||||
}
|
||||
pub fn is_byte_aligned(&self) -> bool {
|
||||
self.pos.is_multiple_of(8)
|
||||
}
|
||||
pub fn byte_align(&mut self) {
|
||||
self.pos = self.pos.div_ceil(8) * 8;
|
||||
}
|
||||
/// Byte offset of the current position (rounded down).
|
||||
pub fn byte_pos(&self) -> usize {
|
||||
self.pos / 8
|
||||
}
|
||||
pub fn seek_bits(&mut self, pos: usize) {
|
||||
self.pos = pos;
|
||||
}
|
||||
|
||||
/// Peek up to 32 bits without consuming (zero-padded past the end).
|
||||
#[inline]
|
||||
pub fn peek(&self, n: u32) -> u32 {
|
||||
debug_assert!(n <= 32);
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
let byte = self.pos / 8;
|
||||
let shift = self.pos % 8;
|
||||
let mut v: u64 = 0;
|
||||
for i in 0..5 {
|
||||
v = (v << 8) | *self.data.get(byte + i).unwrap_or(&0) as u64;
|
||||
}
|
||||
// v holds 40 bits starting at `byte`
|
||||
((v << (24 + shift)) >> (64 - n)) as u32
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn read_bits(&mut self, n: u32) -> Result<u32> {
|
||||
if n as usize > self.bits_left() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
let v = self.peek(n);
|
||||
self.pos += n as usize;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub fn read_bits_u64(&mut self, n: u32) -> Result<u64> {
|
||||
if n <= 32 {
|
||||
return self.read_bits(n).map(u64::from);
|
||||
}
|
||||
let hi = self.read_bits(n - 32)? as u64;
|
||||
let lo = self.read_bits(32)? as u64;
|
||||
Ok((hi << 32) | lo)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn read_bit(&mut self) -> Result<bool> {
|
||||
self.read_bits(1).map(|b| b == 1)
|
||||
}
|
||||
pub fn read_flag(&mut self) -> Result<bool> {
|
||||
self.read_bit()
|
||||
}
|
||||
|
||||
pub fn skip(&mut self, n: usize) -> Result<()> {
|
||||
if n > self.bits_left() {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
self.pos += n;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unsigned Exp-Golomb `ue(v)`.
|
||||
pub fn read_ue(&mut self) -> Result<u32> {
|
||||
let mut zeros = 0u32;
|
||||
while !self.read_bit()? {
|
||||
zeros += 1;
|
||||
if zeros > 32 {
|
||||
return Err(BitError::InvalidExpGolomb);
|
||||
}
|
||||
}
|
||||
if zeros == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let v = self.read_bits_u64(zeros)?;
|
||||
let r = (1u64 << zeros) - 1 + v;
|
||||
u32::try_from(r).map_err(|_| BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// `ue(v) + add` (the `..._minus1` / `..._minus8` fields), failing instead of overflowing on a
|
||||
/// hostile value near `u32::MAX`.
|
||||
pub fn read_ue_plus(&mut self, add: u32) -> Result<u32> {
|
||||
self.read_ue()?.checked_add(add).ok_or(BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// Signed Exp-Golomb `se(v)`. Codes whose value does not fit an `i32` (the code for `2^31`) are rejected.
|
||||
pub fn read_se(&mut self) -> Result<i32> {
|
||||
let k = self.read_ue()? as i64;
|
||||
let v = if k & 1 == 1 { (k + 1) / 2 } else { -(k / 2) };
|
||||
i32::try_from(v).map_err(|_| BitError::InvalidExpGolomb)
|
||||
}
|
||||
|
||||
/// Truncated Exp-Golomb `te(v)` with range `max`.
|
||||
pub fn read_te(&mut self, max: u32) -> Result<u32> {
|
||||
if max > 1 { self.read_ue() } else { Ok(!self.read_bit()? as u32) }
|
||||
}
|
||||
|
||||
/// H.264/HEVC `more_rbsp_data()`: true if there is data before the rbsp trailing bits.
|
||||
pub fn more_rbsp_data(&self) -> bool {
|
||||
let total = self.data.len() * 8;
|
||||
if self.pos >= total {
|
||||
return false;
|
||||
}
|
||||
// find last set bit (the rbsp_stop_one_bit)
|
||||
let mut last = None;
|
||||
for (i, &b) in self.data.iter().enumerate().rev() {
|
||||
if b != 0 {
|
||||
last = Some(i * 8 + 7 - b.trailing_zeros() as usize);
|
||||
break;
|
||||
}
|
||||
}
|
||||
match last {
|
||||
Some(stop) => self.pos < stop,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// MSB-first bit writer.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct BitWriter {
|
||||
buf: Vec<u8>,
|
||||
acc: u64,
|
||||
nbits: u32,
|
||||
}
|
||||
|
||||
impl BitWriter {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
pub fn write_bits(&mut self, value: u32, n: u32) {
|
||||
debug_assert!(n <= 32);
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
let v = (value as u64) & ((1u64 << n) - 1);
|
||||
self.acc = (self.acc << n) | v;
|
||||
self.nbits += n;
|
||||
while self.nbits >= 8 {
|
||||
self.nbits -= 8;
|
||||
self.buf.push((self.acc >> self.nbits) as u8);
|
||||
}
|
||||
self.acc &= (1u64 << self.nbits) - 1;
|
||||
}
|
||||
pub fn write_bit(&mut self, b: bool) {
|
||||
self.write_bits(b as u32, 1);
|
||||
}
|
||||
pub fn write_ue(&mut self, v: u32) {
|
||||
self.write_ue_u64(v as u64);
|
||||
}
|
||||
fn write_ue_u64(&mut self, v: u64) {
|
||||
let x = v + 1;
|
||||
let len = 64 - x.leading_zeros();
|
||||
let zeros = len - 1;
|
||||
for _ in 0..zeros {
|
||||
self.write_bit(false);
|
||||
}
|
||||
if len > 32 {
|
||||
self.write_bits((x >> 32) as u32, len - 32);
|
||||
self.write_bits(x as u32, 32);
|
||||
} else {
|
||||
self.write_bits(x as u32, len);
|
||||
}
|
||||
}
|
||||
/// Signed Exp-Golomb `se(v)`. `i32::MIN` needs a code above `u32::MAX`, which `read_se` rejects.
|
||||
pub fn write_se(&mut self, v: i32) {
|
||||
let v = v as i64;
|
||||
let k = if v > 0 { (v as u64) * 2 - 1 } else { ((-v) as u64) * 2 };
|
||||
self.write_ue_u64(k);
|
||||
}
|
||||
pub fn bit_len(&self) -> usize {
|
||||
self.buf.len() * 8 + self.nbits as usize
|
||||
}
|
||||
pub fn is_byte_aligned(&self) -> bool {
|
||||
self.nbits == 0
|
||||
}
|
||||
/// Pad with zero bits to a byte boundary.
|
||||
pub fn align_zero(&mut self) {
|
||||
if self.nbits > 0 {
|
||||
self.write_bits(0, 8 - self.nbits);
|
||||
}
|
||||
}
|
||||
/// `rbsp_trailing_bits()`: a one bit then zeros to alignment.
|
||||
pub fn rbsp_trailing(&mut self) {
|
||||
self.write_bit(true);
|
||||
self.align_zero();
|
||||
}
|
||||
pub fn write_bytes(&mut self, bytes: &[u8]) {
|
||||
if self.nbits == 0 {
|
||||
self.buf.extend_from_slice(bytes);
|
||||
} else {
|
||||
for &b in bytes {
|
||||
self.write_bits(b as u32, 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn finish(mut self) -> Vec<u8> {
|
||||
self.align_zero();
|
||||
self.buf
|
||||
}
|
||||
pub fn bytes(&self) -> &[u8] {
|
||||
&self.buf
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove emulation-prevention bytes (`00 00 03` → `00 00`) from a NAL payload.
|
||||
pub fn unescape_rbsp(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len());
|
||||
let mut zeros = 0;
|
||||
for &b in data {
|
||||
if zeros >= 2 && b == 3 {
|
||||
zeros = 0;
|
||||
continue;
|
||||
}
|
||||
out.push(b);
|
||||
zeros = if b == 0 { zeros + 1 } else { 0 };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Insert emulation-prevention bytes so no `00 00 0x` (x ≤ 3) sequence appears.
|
||||
pub fn escape_rbsp(data: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(data.len() + data.len() / 64);
|
||||
let mut zeros = 0;
|
||||
for &b in data {
|
||||
if zeros >= 2 && b <= 3 {
|
||||
out.push(3);
|
||||
zeros = 0;
|
||||
}
|
||||
out.push(b);
|
||||
zeros = if b == 0 { zeros + 1 } else { 0 };
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split an Annex-B byte stream (start codes `00 00 01` / `00 00 00 01`) into NAL units (escaped payloads).
|
||||
pub fn annexb_nals(data: &[u8]) -> Vec<&[u8]> {
|
||||
let mut starts = Vec::new();
|
||||
let mut i = 0;
|
||||
while i + 3 <= data.len() {
|
||||
if data[i] == 0 && data[i + 1] == 0 && data[i + 2] == 1 {
|
||||
starts.push(i + 3);
|
||||
i += 3;
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(starts.len());
|
||||
for (k, &s) in starts.iter().enumerate() {
|
||||
let mut e = if k + 1 < starts.len() { starts[k + 1] - 3 } else { data.len() };
|
||||
// trim trailing zero bytes (part of a 4-byte start code or trailing_zero_8bits)
|
||||
while e > s && data[e - 1] == 0 {
|
||||
e -= 1;
|
||||
}
|
||||
if e > s {
|
||||
out.push(&data[s..e]);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Split a length-prefixed sample (AVCC/HVCC, `length_size` 1..=4 bytes, as the 2-bit `lengthSizeMinusOne` allows) into NAL units.
|
||||
pub fn length_prefixed_nals(data: &[u8], length_size: usize) -> Result<Vec<&[u8]>> {
|
||||
if !(1..=4).contains(&length_size) {
|
||||
return Err(BitError::InvalidLengthSize);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
if length_size > data.len() - i {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
let mut len = 0usize;
|
||||
for k in 0..length_size {
|
||||
len = (len << 8) | data[i + k] as usize;
|
||||
}
|
||||
i += length_size;
|
||||
if len > data.len() - i {
|
||||
return Err(BitError::Eof);
|
||||
}
|
||||
out.push(&data[i..i + len]);
|
||||
i += len;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// CRC-32 (IEEE, reflected) — used by containers (e.g. Matroska CRC-32 elements, PNG).
|
||||
pub fn crc32(data: &[u8]) -> u32 {
|
||||
let mut crc = 0xFFFF_FFFFu32;
|
||||
for &b in data {
|
||||
crc ^= b as u32;
|
||||
for _ in 0..8 {
|
||||
crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 };
|
||||
}
|
||||
}
|
||||
!crc
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn read_ue_plus_rejects_overflow() {
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(7);
|
||||
w.write_ue(u32::MAX);
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
assert_eq!(r.read_ue_plus(1).unwrap(), 8);
|
||||
assert!(r.read_ue_plus(1).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_exp_golomb_extremes() {
|
||||
let mut w = BitWriter::new();
|
||||
for v in [i32::MAX, i32::MIN + 1, i32::MIN] {
|
||||
w.write_se(v);
|
||||
}
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
assert_eq!(r.read_se().unwrap(), i32::MAX);
|
||||
assert_eq!(r.read_se().unwrap(), i32::MIN + 1);
|
||||
assert_eq!(r.read_se(), Err(BitError::InvalidExpGolomb));
|
||||
// ue code 0xFFFF_FFFF maps to +2^31, which must not wrap to a negative i32.
|
||||
let mut w = BitWriter::new();
|
||||
w.write_ue(u32::MAX);
|
||||
w.rbsp_trailing();
|
||||
let b = w.finish();
|
||||
assert_eq!(BitReader::new(&b).read_se(), Err(BitError::InvalidExpGolomb));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn length_prefixed_rejects_bad_width_and_oversized_length() {
|
||||
assert_eq!(length_prefixed_nals(&[1, 2, 3, 4, 5, 6], 5), Err(BitError::InvalidLengthSize));
|
||||
assert_eq!(length_prefixed_nals(&[1, 2, 3], 0), Err(BitError::InvalidLengthSize));
|
||||
assert_eq!(length_prefixed_nals(&[0, 0, 1, 7], 3).unwrap(), vec![&[7u8][..]]);
|
||||
assert_eq!(length_prefixed_nals(&[0xFF, 0xFF, 0xFF, 0xFF, 1], 4), Err(BitError::Eof));
|
||||
assert_eq!(length_prefixed_nals(&[0, 0, 0, 1, 9], 4).unwrap(), vec![&[9u8][..]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_bits() {
|
||||
let d = [0b1010_1100, 0b0101_0011];
|
||||
let mut r = BitReader::new(&d);
|
||||
assert_eq!(r.read_bits(1).unwrap(), 1);
|
||||
assert_eq!(r.read_bits(3).unwrap(), 0b010);
|
||||
assert_eq!(r.read_bits(8).unwrap(), 0b1100_0101);
|
||||
assert_eq!(r.bits_left(), 4);
|
||||
assert_eq!(r.read_bits(5), Err(BitError::Eof));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exp_golomb_known() {
|
||||
// 1 | 010 | 011 | 00100 | 00101 -> 0,1,2,3,4
|
||||
let mut w = BitWriter::new();
|
||||
for v in 0..5 {
|
||||
w.write_ue(v);
|
||||
}
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for v in 0..5 {
|
||||
assert_eq!(r.read_ue().unwrap(), v);
|
||||
}
|
||||
let mut w = BitWriter::new();
|
||||
for v in [0, 1, -1, 2, -2, 1000, -1000] {
|
||||
w.write_se(v);
|
||||
}
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for v in [0, 1, -1, 2, -2, 1000, -1000] {
|
||||
assert_eq!(r.read_se().unwrap(), v);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emulation_prevention() {
|
||||
let raw = [0, 0, 1, 0, 0, 0, 5, 0, 0, 3];
|
||||
let esc = escape_rbsp(&raw);
|
||||
assert_eq!(esc, vec![0, 0, 3, 1, 0, 0, 3, 0, 5, 0, 0, 3, 3]);
|
||||
assert_eq!(unescape_rbsp(&esc), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annexb_split() {
|
||||
let s = [0, 0, 0, 1, 0x67, 1, 2, 0, 0, 1, 0x68, 3, 0, 0, 0, 1, 0x65, 9, 9];
|
||||
let n = annexb_nals(&s);
|
||||
assert_eq!(n, vec![&[0x67, 1, 2][..], &[0x68, 3][..], &[0x65, 9, 9][..]]);
|
||||
let l = [0, 0, 0, 2, 0x67, 1, 0, 0, 0, 1, 0x68];
|
||||
assert_eq!(length_prefixed_nals(&l, 4).unwrap(), vec![&[0x67, 1][..], &[0x68][..]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn more_rbsp() {
|
||||
let d = [0b1100_0000];
|
||||
let mut r = BitReader::new(&d);
|
||||
assert!(r.more_rbsp_data());
|
||||
r.read_bit().unwrap();
|
||||
assert!(!r.more_rbsp_data());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crc() {
|
||||
assert_eq!(crc32(b"123456789"), 0xCBF4_3926);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn roundtrip(vals in proptest::collection::vec((0u32..u32::MAX, 1u32..=32), 0..200), ues in proptest::collection::vec(0u32..u32::MAX - 1, 0..50)) {
|
||||
let mut w = BitWriter::new();
|
||||
for &(v, n) in &vals { w.write_bits(v, n); }
|
||||
for &u in &ues { w.write_ue(u); }
|
||||
let b = w.finish();
|
||||
let mut r = BitReader::new(&b);
|
||||
for &(v, n) in &vals {
|
||||
let mask = if n == 32 { u32::MAX } else { (1 << n) - 1 };
|
||||
prop_assert_eq!(r.read_bits(n).unwrap(), v & mask);
|
||||
}
|
||||
for &u in &ues { prop_assert_eq!(r.read_ue().unwrap(), u); }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_roundtrip(data in proptest::collection::vec(0u8..4, 0..300)) {
|
||||
let e = escape_rbsp(&data);
|
||||
prop_assert_eq!(unescape_rbsp(&e), data);
|
||||
for w in e.windows(3) { prop_assert!(!(w[0] == 0 && w[1] == 0 && w[2] < 3)); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ impl Rgba {
|
||||
/// `RRGGBB` or `#RRGGBB` (also `RRGGBBAA`).
|
||||
pub fn from_hex(s: &str) -> Option<Self> {
|
||||
let s = s.trim().trim_start_matches('#');
|
||||
if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
if !s.is_ascii() {
|
||||
return None;
|
||||
}
|
||||
let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
@@ -519,14 +519,6 @@ pub fn preset(name: &str) -> Option<Rgba> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn from_hex_rejects_signs() {
|
||||
assert_eq!(Rgba::from_hex("+12345"), None);
|
||||
assert_eq!(Rgba::from_hex("#+1+2+3"), None);
|
||||
assert_eq!(Rgba::from_hex("+1+2+3+4"), None);
|
||||
assert_eq!(Rgba::from_hex("+12"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_roundtrip() {
|
||||
assert_eq!(Rgba::from_hex("#156082"), Some(Rgba::rgb(0x15, 0x60, 0x82)));
|
||||
|
||||
@@ -25,7 +25,6 @@ i_overlay = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
image = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
deckcraft-anim = { workspace = true }
|
||||
|
||||
@@ -525,46 +525,4 @@ mod tests {
|
||||
s.execute("animation.painterApply", &json!({"ids": [id3]})).unwrap();
|
||||
assert!(s.anim_painter.is_some());
|
||||
}
|
||||
|
||||
fn setup_triggers() -> (Session, [u64; 4]) {
|
||||
let mut s = Session::with_new();
|
||||
let mut ids = [0u64; 4];
|
||||
for (slot, x) in ids.iter_mut().zip([10.0, 150.0, 300.0, 450.0]) {
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 10.0, 100.0, 100.0]})).unwrap();
|
||||
*slot = r.get("id").unwrap().as_u64().unwrap();
|
||||
}
|
||||
(s, ids)
|
||||
}
|
||||
|
||||
fn triggers(s: &Session) -> Vec<Option<deckcraft_model::ShapeId>> {
|
||||
s.doc().unwrap().current_slide().unwrap().animations.iter().map(|x| x.trigger).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_trigger_shape_clears_the_trigger() {
|
||||
let (mut s, [a, b, c, d]) = setup_triggers();
|
||||
let id = |v: u64| Some(deckcraft_model::ShapeId(v as u32));
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [d]})).unwrap();
|
||||
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
|
||||
s.execute("animation.timing", &json!({"index": 1, "trigger": c})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![id(b), id(c)]);
|
||||
|
||||
s.execute("edit.delete", &json!({"ids": [b]})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![None, id(c)], "only the deleted trigger is cleared");
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 2, "the animated shapes keep their effects");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_group_clears_triggers_of_its_children() {
|
||||
let (mut s, [a, b, c, _]) = setup_triggers();
|
||||
let g = s.execute("arrange.group", &json!({"ids": [b, c]})).unwrap().get("id").unwrap().as_u64().unwrap();
|
||||
s.execute("animation.add", &json!({"effect": "fade", "ids": [a]})).unwrap();
|
||||
s.execute("animation.timing", &json!({"index": 0, "trigger": b})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![Some(deckcraft_model::ShapeId(b as u32))]);
|
||||
|
||||
s.execute("edit.delete", &json!({"ids": [g]})).unwrap();
|
||||
assert_eq!(triggers(&s), vec![None], "the trigger was inside the deleted group");
|
||||
assert_eq!(s.doc().unwrap().current_slide().unwrap().animations.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,22 +171,6 @@ pub(crate) fn union_bounds(xs: &[Xfrm]) -> Option<Rect> {
|
||||
Some(it.fold(first, |a, b| a.union(b)))
|
||||
}
|
||||
|
||||
/// How deep groups may nest: as deep as the PPTX reader reads and the renderer draws. A saved
|
||||
/// .deckcraft file stops opening at about 39 (the JSON reader's recursion limit), and walks over
|
||||
/// the shape tree stop at 64.
|
||||
pub const MAX_GROUP_DEPTH: usize = 32;
|
||||
|
||||
/// How many groups deep `shapes` nest (0 without groups).
|
||||
fn group_depth(shapes: &[Shape]) -> usize {
|
||||
let mut d = 0;
|
||||
deckcraft_model::walk(shapes, &mut |sh, depth| {
|
||||
if sh.is_group() {
|
||||
d = d.max(depth + 1);
|
||||
}
|
||||
});
|
||||
d
|
||||
}
|
||||
|
||||
fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
s.edit(|doc, sel| {
|
||||
@@ -195,9 +179,6 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if members.len() < 2 {
|
||||
return Err(bad("arrange.group", "select two or more objects on the slide"));
|
||||
}
|
||||
if group_depth(&members) + 1 > MAX_GROUP_DEPTH {
|
||||
return Err(bad("arrange.group", format!("groups can be nested at most {MAX_GROUP_DEPTH} deep")));
|
||||
}
|
||||
for m in &mut members {
|
||||
if m.xfrm.is_none() {
|
||||
m.xfrm = Some(xfrm_of(doc, sel, m));
|
||||
@@ -227,7 +208,7 @@ fn group(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
/// A child's xfrm in slide space, given its group.
|
||||
pub fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
|
||||
pub(crate) fn child_to_parent(group: &Xfrm, ch: &Xfrm, c: &Xfrm) -> Xfrm {
|
||||
let sx = if ch.w.abs() > 1e-9 { group.w / ch.w } else { 1.0 };
|
||||
let sy = if ch.h.abs() > 1e-9 { group.h / ch.h } else { 1.0 };
|
||||
let a = deckcraft_geom::group_child_affine(group, Rect::new(ch.x, ch.y, ch.x + ch.w, ch.y + ch.h));
|
||||
@@ -341,9 +322,9 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (start, end) = if to_slide {
|
||||
if horiz { (0.0, size.width) } else { (0.0, size.height) }
|
||||
} else {
|
||||
let lo = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::INFINITY, f64::min);
|
||||
let hi = |f: fn(&Rect) -> f64| boxes.iter().map(|b| f(&b.1)).fold(f64::NEG_INFINITY, f64::max);
|
||||
if horiz { (lo(|r| r.x0), hi(|r| r.x1)) } else { (lo(|r| r.y0), hi(|r| r.y1)) }
|
||||
let f = boxes.first().map(|b| b.1).unwrap_or_default();
|
||||
let l = boxes.last().map(|b| b.1).unwrap_or_default();
|
||||
if horiz { (f.x0, l.x1) } else { (f.y0, l.y1) }
|
||||
};
|
||||
let n = boxes.len();
|
||||
let gap = if n > 1 { ((end - start) - total) / (n - 1) as f64 } else { 0.0 };
|
||||
|
||||
+22
-527
@@ -1,7 +1,6 @@
|
||||
//! Design tab and masters: themes, colours, fonts, backgrounds, slide size, header & footer,
|
||||
//! Slide Master view.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_geom::Size;
|
||||
@@ -21,7 +20,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Browse for Themes…",
|
||||
["Design", "Themes"],
|
||||
None,
|
||||
"{path} | {name, data: base64} — apply a .pptx or .potx's slide master and layouts (slides move to the matching layouts), or a .thmx's theme",
|
||||
"{path} | {name, data: base64} — apply the theme of a .thmx, .pptx or .potx file",
|
||||
has_doc,
|
||||
browse_theme
|
||||
),
|
||||
@@ -161,13 +160,6 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !deckcraft_pptx::sniff(&bytes) {
|
||||
return Err(bad("design.browseTheme", "not a theme or PowerPoint file"));
|
||||
}
|
||||
// A presentation or template brings its slide master and layouts, as in PowerPoint; a .thmx
|
||||
// (its main part is a theme, not a presentation) brings the theme alone.
|
||||
if let Ok(src) = super::file::open_presentation("theme.pptx", &bytes)
|
||||
&& src.masters.first().is_some_and(|m| !m.layouts.is_empty())
|
||||
{
|
||||
return apply_master(s, &src);
|
||||
}
|
||||
let (theme, media) = deckcraft_pptx::import_theme(&bytes).map_err(|e| bad("design.browseTheme", e.to_string()))?;
|
||||
let name = theme.name.clone();
|
||||
s.edit(|doc, sel| {
|
||||
@@ -187,58 +179,6 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"theme": name}))
|
||||
}
|
||||
|
||||
/// Design › Browse for Themes… with a `.pptx` / `.potx`: its first master and layouts replace the
|
||||
/// master the current slide uses (a preserved master stays beside it), stretched to this deck's
|
||||
/// slide size. Each slide on it moves to the new layout with the same name, else the same kind,
|
||||
/// keeping its content as Layout does.
|
||||
fn apply_master(s: &mut Session, src: &Presentation) -> Result<Value> {
|
||||
let from = src.masters.first().ok_or_else(|| bad("design.browseTheme", "no master"))?;
|
||||
let theme = from.theme.name.clone();
|
||||
s.edit(|doc, sel| {
|
||||
let mi = master_index(doc, sel);
|
||||
let old = doc.masters.get(mi).cloned().ok_or_else(|| bad("design.browseTheme", "no master"))?;
|
||||
let (mut media, mut layouts) = Default::default();
|
||||
let mut m = super::reuse::import_master("design.browseTheme", doc, src, from, &mut media, &mut layouts)?;
|
||||
let (sx, sy) = (doc.slide_size.width / src.slide_size.width.max(1.0), doc.slide_size.height / src.slide_size.height.max(1.0));
|
||||
if (sx - 1.0).abs() > 1e-9 || (sy - 1.0).abs() > 1e-9 {
|
||||
scale_shapes(&mut m.shapes, sx, sy, 1.0, 0.0, 0.0);
|
||||
for l in &mut m.layouts {
|
||||
scale_shapes(&mut l.shapes, sx, sy, 1.0, 0.0, 0.0);
|
||||
}
|
||||
}
|
||||
let moves: Vec<(usize, deckcraft_model::LayoutId)> =
|
||||
doc.slides.iter().enumerate().filter_map(|(i, sl)| old.layout(sl.layout).and_then(|l| matching_layout(&m, l)).map(|l| (i, l))).collect();
|
||||
let layouts = m.layouts.len();
|
||||
if old.preserve {
|
||||
doc.masters.insert(mi, Arc::new(m));
|
||||
} else if let Some(slot) = doc.masters.get_mut(mi) {
|
||||
*slot = Arc::new(m);
|
||||
}
|
||||
for (i, l) in &moves {
|
||||
super::slide::apply_layout(doc, *i, *l)?;
|
||||
}
|
||||
if let Target::Layout { master, .. } = sel.target {
|
||||
sel.target = Target::Master { master };
|
||||
}
|
||||
sel.shapes.clear();
|
||||
sel.text = None;
|
||||
Ok(json!({"theme": theme, "layouts": layouts, "slides": moves.len()}))
|
||||
})
|
||||
}
|
||||
|
||||
/// The layout of `m` a slide on `from` moves to: same name, else same kind (not for custom
|
||||
/// layouts), else Title and Content, else the first.
|
||||
fn matching_layout(m: &deckcraft_model::Master, from: &deckcraft_model::Layout) -> Option<deckcraft_model::LayoutId> {
|
||||
use deckcraft_model::LayoutType;
|
||||
let ls = &m.layouts;
|
||||
ls.iter()
|
||||
.find(|l| !from.name.is_empty() && l.name.trim().eq_ignore_ascii_case(from.name.trim()))
|
||||
.or_else(|| ls.iter().find(|l| from.kind != LayoutType::Custom && l.kind == from.kind))
|
||||
.or_else(|| ls.iter().find(|l| l.kind == LayoutType::TitleAndContent))
|
||||
.or_else(|| ls.first())
|
||||
.map(|l| l.id)
|
||||
}
|
||||
|
||||
/// `theme` with every `media` id field rewritten through `map` (format-scheme picture fills).
|
||||
fn remap_media_ids(theme: &deckcraft_model::theme::Theme, map: &std::collections::HashMap<u32, u32>) -> Result<deckcraft_model::theme::Theme> {
|
||||
fn rec(v: &mut Value, map: &std::collections::HashMap<u32, u32>, depth: usize) {
|
||||
@@ -652,11 +592,6 @@ fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option<usize
|
||||
}
|
||||
}
|
||||
|
||||
fn used_master_indices(doc: &Presentation) -> BTreeSet<usize> {
|
||||
let fallback = (!doc.masters.is_empty()).then_some(0);
|
||||
doc.slides.iter().filter_map(|sl| doc.masters.iter().position(|m| m.layout(sl.layout).is_some()).or(fallback)).collect()
|
||||
}
|
||||
|
||||
fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: &Option<Background>) -> bool {
|
||||
match sel.target {
|
||||
Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg),
|
||||
@@ -664,16 +599,7 @@ fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option
|
||||
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg),
|
||||
Target::Slides => {
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
let slides_match = !list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg));
|
||||
if all && bg.is_some() {
|
||||
let masters = used_master_indices(doc);
|
||||
let masters_match = !masters.is_empty() && masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.background == *bg));
|
||||
let layouts_match = !masters.is_empty()
|
||||
&& masters.iter().all(|&idx| doc.masters.get(idx).is_some_and(|m| m.layouts.iter().all(|l| l.background.is_none())));
|
||||
slides_match && masters_match && layouts_match
|
||||
} else {
|
||||
slides_match
|
||||
}
|
||||
!list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -696,17 +622,6 @@ fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Optio
|
||||
}
|
||||
}
|
||||
Target::Slides => {
|
||||
if all && bg.is_some() {
|
||||
for idx in used_master_indices(doc) {
|
||||
if let Some(m) = doc.masters.get_mut(idx) {
|
||||
let m = Arc::make_mut(m);
|
||||
m.background = bg.clone();
|
||||
for l in &mut m.layouts {
|
||||
l.background = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
@@ -724,14 +639,7 @@ fn copy_slide_background(s: &mut Session) -> Result<Value> {
|
||||
return Err(bad("design.background", "copy applies to slides"));
|
||||
}
|
||||
let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone());
|
||||
let slides_match = st.doc.slides.iter().all(|sl| sl.background == bg);
|
||||
let masters_match = if bg.is_some() {
|
||||
let masters = used_master_indices(&st.doc);
|
||||
!masters.is_empty() && masters.iter().all(|&idx| st.doc.masters.get(idx).is_some_and(|m| m.background == bg))
|
||||
} else {
|
||||
true
|
||||
};
|
||||
if slides_match && masters_match {
|
||||
if st.doc.slides.iter().all(|sl| sl.background == bg) {
|
||||
return ok();
|
||||
}
|
||||
bg
|
||||
@@ -913,14 +821,6 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(v) = str_param(p, "dateText") {
|
||||
hf.date_text = v.into();
|
||||
}
|
||||
if let Some(v) = str_param(p, "dateFormat") {
|
||||
// Refused rather than ignored: a format that renders nothing would leave the
|
||||
// placeholder showing its saved text for ever.
|
||||
if !deckcraft_text::datetime::DATE_KINDS.contains(&v) {
|
||||
return Err(bad("design.headerFooter", "dateFormat: one of datetime1…datetime13"));
|
||||
}
|
||||
hf.date_format = v.into();
|
||||
}
|
||||
if let Some(v) = bool_param(p, "slideNumber") {
|
||||
hf.slide_number = v;
|
||||
}
|
||||
@@ -936,7 +836,6 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let hf = hf.clone();
|
||||
let date_given = str_param(p, "dateText").is_some();
|
||||
let format_given = str_param(p, "dateFormat").is_some();
|
||||
let all = bool_or(p, "all", true);
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![sel.slide] };
|
||||
for i in list {
|
||||
@@ -981,11 +880,7 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
},
|
||||
PhType::Date if hf.date_text.is_empty() => TextBody {
|
||||
paragraphs: vec![deckcraft_model::Paragraph {
|
||||
runs: vec![Run {
|
||||
text: date_now(&hf.date_format),
|
||||
props: Default::default(),
|
||||
kind: RunKind::Field { field: date_kind(&hf.date_format).into() },
|
||||
}],
|
||||
runs: vec![Run { text: today(), props: Default::default(), kind: RunKind::Field { field: "datetime1".into() } }],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
@@ -1006,9 +901,9 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sh.text = Some(TextBody::from_text(&hf.footer_text));
|
||||
}
|
||||
}
|
||||
(Some(pos), true) if k == PhType::Date && (date_given || format_given) => {
|
||||
(Some(pos), true) if k == PhType::Date && date_given => {
|
||||
if let Some(sh) = sl.shapes.get_mut(pos) {
|
||||
set_date(sh, &hf.date_text, &hf.date_format);
|
||||
set_date(sh, &hf.date_text);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -1020,33 +915,15 @@ fn header_footer(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The date field type `format` names, `datetime1` when it is empty or unknown.
|
||||
fn date_kind(format: &str) -> &str {
|
||||
if deckcraft_text::datetime::DATE_KINDS.contains(&format) { format } else { "datetime1" }
|
||||
}
|
||||
|
||||
/// The saved text of a date field of `format`, as it shows now.
|
||||
fn date_now(format: &str) -> String {
|
||||
let kind = date_kind(format);
|
||||
deckcraft_text::datetime::DateTime::now_local().and_then(|now| deckcraft_text::datetime::field_text(kind, None, now)).unwrap_or_else(today)
|
||||
}
|
||||
|
||||
/// Point a slide's date placeholder at a fixed date (its own plain text) or, for an empty
|
||||
/// `fixed`, at an automatic date field (one it already has keeps its format).
|
||||
fn set_date(sh: &mut Shape, fixed: &str, format: &str) {
|
||||
fn set_date(sh: &mut Shape, fixed: &str) {
|
||||
let runs = || sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs);
|
||||
let field_now = runs().find_map(|r| match &r.kind {
|
||||
RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field) => Some(field.clone()),
|
||||
_ => None,
|
||||
});
|
||||
let has_field = field_now.is_some();
|
||||
let has_field = runs().any(|r| matches!(&r.kind, RunKind::Field { field } if deckcraft_text::datetime::is_date_field(field)));
|
||||
let props = runs().next().map(|r| r.props.clone()).unwrap_or_default();
|
||||
let want = date_kind(format);
|
||||
let (text, kind) = match fixed {
|
||||
// Automatic: keep the field, but follow a format that has changed.
|
||||
"" if field_now.as_deref() == Some(want) => return,
|
||||
"" if has_field && format.is_empty() => return,
|
||||
"" => (date_now(format), RunKind::Field { field: want.into() }),
|
||||
"" if has_field => return,
|
||||
"" => (today(), RunKind::Field { field: "datetime1".into() }),
|
||||
_ if !has_field && sh.text.as_ref().is_some_and(|t| t.text() == fixed) => return,
|
||||
_ => (fixed.to_string(), RunKind::Text),
|
||||
};
|
||||
@@ -1066,21 +943,18 @@ pub fn date_field_text(kind: &str) -> String {
|
||||
DateTime::now_local().and_then(|now| field_text(kind, None, now)).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → (year, month, day), exact for every `i64`.
|
||||
/// Days since 1970-01-01 → (year, month, day) (Howard Hinnant's algorithm).
|
||||
pub fn civil(z: i64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
// `NaiveDate` covers only about ±262143 years, and the Gregorian calendar repeats every 400
|
||||
// years (146_097 days). chrono converts the day within its cycle and whole cycles go back
|
||||
// into the year (an i128 for i64::MAX/MIN). 1970-01-01 is `num_days_from_ce` 719_163, and
|
||||
// 0..146_097 days from it always fits.
|
||||
let (cycles, rest) = (z.div_euclid(146_097), z.rem_euclid(146_097));
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(if y < 0 { i64::MIN } else { i64::MAX }), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1),
|
||||
}
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
fn master_view(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1582,8 +1456,6 @@ fn slide_number_paragraph() -> deckcraft_model::Paragraph {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_color::SchemeSlot;
|
||||
use deckcraft_model::style::PictureMode;
|
||||
use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes};
|
||||
@@ -1623,11 +1495,6 @@ mod tests {
|
||||
fn civil_dates() {
|
||||
assert_eq!(super::civil(0), (1970, 1, 1));
|
||||
assert_eq!(super::civil(20_734), (2026, 10, 8));
|
||||
// Past `NaiveDate`'s range the 400-year cycles carry the year, exactly, up to the extremes.
|
||||
assert_eq!(super::civil(95_026_602), (262_144, 1, 1));
|
||||
assert_eq!(super::civil(-96_465_964), (-262_145, 3, 1));
|
||||
assert_eq!(super::civil(i64::MAX), (25_252_734_927_768_524, 7, 27));
|
||||
assert_eq!(super::civil(i64::MIN), (-25_252_734_927_764_585, 6, 7));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1860,195 +1727,12 @@ mod tests {
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_all_applies_to_master_and_new_slides_inherit() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
let s0 = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s0["background"], "#121826");
|
||||
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
// Idempotency: re-applying the same background to all is a no-op (no extra undo step)
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "re-applying already applied background is not an undo step");
|
||||
|
||||
// Slide 0 has new background, master has new background, layouts cleared
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(matches!(doc.slides[0].background, Some(Background::Fill { .. })));
|
||||
assert_eq!(doc.masters[0].background, doc.slides[0].background);
|
||||
for l in &doc.masters[0].layouts {
|
||||
assert!(l.background.is_none());
|
||||
}
|
||||
|
||||
// Newly added slides inherit the master background (fixes #891)
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "new"})).unwrap();
|
||||
let s0_after = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
|
||||
let s1_after = s.execute("slide.inspect", &json!({"index": 1})).unwrap();
|
||||
assert_eq!(s0_after["background"], "#0D0D0D");
|
||||
assert_eq!(s1_after["background"], "#0D0D0D");
|
||||
|
||||
// Undo restores previous slide and master backgrounds
|
||||
s.execute("edit.undo", &json!({})).unwrap(); // undos slide.new
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap(); // undos design.background
|
||||
let s0_reverted = s.execute("slide.inspect", &json!({"index": 0})).unwrap();
|
||||
assert_eq!(s0_reverted["background"], "#121826");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_copy_from_slide_following_master_preserves_master_and_layout_backgrounds() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
|
||||
|
||||
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x22, 0x22, 0x22))) };
|
||||
s.edit(|doc, _| {
|
||||
let m = Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts[0].background = Some(custom_layout_bg.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Slide 0 follows master (background: None)
|
||||
assert!(s.doc().unwrap().doc.slides[0].background.is_none());
|
||||
// Give Slide 1 an explicit override
|
||||
s.execute("design.background", &json!({"color": "#FF0000", "index": 1})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
|
||||
|
||||
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
|
||||
assert!(orig_master_bg.is_some());
|
||||
|
||||
// Select slide 0 (which follows master) and apply copy to all
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
// Slide 1 has been reset to None (follows master), slide 0 remains None
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.slides[0].background.is_none());
|
||||
assert!(doc.slides[1].background.is_none());
|
||||
|
||||
// Master and layout backgrounds survived!
|
||||
assert_eq!(doc.masters[0].background, orig_master_bg);
|
||||
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
|
||||
|
||||
// Idempotency: re-applying copy is a no-op
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "re-copying when already matching is a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_reset_all_preserves_master_and_layout_backgrounds() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
|
||||
|
||||
let custom_layout_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x33, 0x33, 0x33))) };
|
||||
s.edit(|doc, _| {
|
||||
let m = Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts[0].background = Some(custom_layout_bg.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let orig_master_bg = s.doc().unwrap().doc.masters[0].background.clone();
|
||||
assert!(orig_master_bg.is_some());
|
||||
|
||||
// Set explicit backgrounds on both slides individually (master remains untouched)
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 0})).unwrap();
|
||||
s.execute("design.background", &json!({"color": "#0D0D0D", "index": 1})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides[0].background.is_some());
|
||||
assert!(s.doc().unwrap().doc.slides[1].background.is_some());
|
||||
|
||||
// Now perform reset with all
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
|
||||
// Slides reset to None to follow master
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.slides[0].background.is_none());
|
||||
assert!(doc.slides[1].background.is_none());
|
||||
|
||||
// Master and layout backgrounds survived!
|
||||
assert_eq!(doc.masters[0].background, orig_master_bg);
|
||||
assert_eq!(doc.masters[0].layouts[0].background, Some(custom_layout_bg));
|
||||
|
||||
// Idempotency: re-running reset all is a no-op
|
||||
s.execute("design.background", &json!({"reset": true, "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "re-resetting when already reset is a no-op");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_all_only_touches_used_masters() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
|
||||
// Add a second master to presentation
|
||||
let second_master_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x55, 0x55, 0x55))) };
|
||||
s.edit(|doc, _| {
|
||||
let mut m2 = (*doc.masters[0]).clone();
|
||||
m2.id = deckcraft_model::MasterId(999);
|
||||
m2.background = Some(second_master_bg.clone());
|
||||
doc.masters.push(Arc::new(m2));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(s.doc().unwrap().doc.masters.len(), 2);
|
||||
// Slides only use master 0 (default layout)
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
// Master 0 updated
|
||||
assert_eq!(doc.masters[0].background, doc.slides[0].background);
|
||||
// Master 1 preserved!
|
||||
assert_eq!(doc.masters[1].background, Some(second_master_bg));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("file.new", &json!({"theme": "Nocturne"})).unwrap();
|
||||
|
||||
// Set all slides and master to #AA0000
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.masters[0].background, s.doc().unwrap().doc.slides[0].background);
|
||||
|
||||
// Add a layout override on layout 0
|
||||
let custom_bg = Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x12, 0x34, 0x56))) };
|
||||
s.edit(|doc, _| {
|
||||
let m = Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts[0].background = Some(custom_bg);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
// Re-run Apply to All with #AA0000. Must not be skipped as no-op; must clear layout override.
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "should execute and clear layout override, not treat as no-op");
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.masters[0].layouts[0].background.is_none());
|
||||
|
||||
// Now that layout override is cleared, repeating is an actual no-op
|
||||
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod browse_theme_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::PhType;
|
||||
use deckcraft_model::text::RunKind;
|
||||
use deckcraft_model::{Background, Fill, PhType};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Session, Target};
|
||||
@@ -2074,108 +1758,6 @@ mod browse_theme_tests {
|
||||
assert!(s.execute("design.browseTheme", &json!({})).is_err());
|
||||
}
|
||||
|
||||
/// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides
|
||||
/// can only find them by kind) and its content layout's title moved.
|
||||
fn brand_template() -> (String, Vec<u8>, deckcraft_model::Xfrm) {
|
||||
let mut img = image::RgbaImage::new(2, 2);
|
||||
img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255]));
|
||||
let mut logo = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap();
|
||||
let mut t = Session::with_new();
|
||||
t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap();
|
||||
let title = t
|
||||
.edit(|doc, _| {
|
||||
let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap();
|
||||
let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap());
|
||||
let m = Arc::make_mut(doc.masters.get_mut(0).unwrap());
|
||||
m.shapes.push(deckcraft_model::Shape {
|
||||
id,
|
||||
name: "Logo".into(),
|
||||
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
|
||||
xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }),
|
||||
..Default::default()
|
||||
});
|
||||
m.background =
|
||||
Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) });
|
||||
for l in &mut m.layouts {
|
||||
l.name = format!("Brand {}", l.name);
|
||||
}
|
||||
let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap();
|
||||
let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() };
|
||||
title.xfrm = Some(x);
|
||||
Ok(x)
|
||||
})
|
||||
.unwrap();
|
||||
(crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_brings_its_master_and_layouts_and_slides_follow() {
|
||||
let (data, logo, title) = brand_template();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap();
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
let n = undo_len(&s);
|
||||
let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap();
|
||||
assert_eq!(r["slides"], json!(2), "{r}");
|
||||
assert_eq!(undo_len(&s), n + 1, "one undo step");
|
||||
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
|
||||
assert_eq!(doc.masters.len(), 1, "the old master is replaced");
|
||||
let m = &doc.masters[0];
|
||||
assert_ne!(m.id, before.masters[0].id);
|
||||
assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand ")));
|
||||
assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } })));
|
||||
let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along");
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") };
|
||||
assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo));
|
||||
// The 4:3 master stretches to this deck's 16:9.
|
||||
let k = doc.slide_size.width / 720.0;
|
||||
let px = pic.xfrm.unwrap();
|
||||
assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}");
|
||||
|
||||
// Each slide moved to the brand layout of its kind, and kept its text.
|
||||
for (old, new) in before.slides.iter().zip(&doc.slides) {
|
||||
let (_, was) = before.layout(old.layout).unwrap();
|
||||
let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master");
|
||||
assert_eq!(owner.id, m.id);
|
||||
assert_eq!(now.kind, was.kind);
|
||||
}
|
||||
let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap();
|
||||
assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one")));
|
||||
let (_, lay) = doc.layout(agenda.layout).unwrap();
|
||||
let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap();
|
||||
assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}");
|
||||
let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
|
||||
assert!(st.xfrm.is_none(), "the slide title follows the new layout");
|
||||
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
assert_eq!(doc.masters, before.masters);
|
||||
assert_eq!(doc.slides, before.slides);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_preserved_master_stays_beside_the_template() {
|
||||
let (data, _, _) = brand_template();
|
||||
let mut s = Session::with_new();
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
|
||||
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
|
||||
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
|
||||
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
|
||||
let old = s.doc().unwrap().doc.masters[0].id;
|
||||
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
|
||||
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.masters.len(), 2);
|
||||
assert_eq!(st.doc.masters[1].id, old);
|
||||
assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master");
|
||||
let new = &st.doc.masters[0];
|
||||
assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some()));
|
||||
}
|
||||
|
||||
fn undo_len(s: &crate::Session) -> usize {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
@@ -2326,90 +1908,3 @@ mod browse_theme_tests {
|
||||
assert!((box_.w - 840.0).abs() < 0.01 && (box_.h - 104.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod header_footer_date_tests {
|
||||
use deckcraft_model::PhType;
|
||||
use deckcraft_model::text::RunKind;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// The date field of the Date placeholders of every slide: (saved text, field type).
|
||||
fn dates(s: &Session) -> Vec<(String, Option<String>)> {
|
||||
let d = &s.doc().unwrap().doc;
|
||||
d.slides
|
||||
.iter()
|
||||
.flat_map(|sl| sl.shapes.iter())
|
||||
.filter(|sh| sh.ph_type() == Some(PhType::Date))
|
||||
.flat_map(|sh| sh.text.iter())
|
||||
.flat_map(|t| t.paragraphs.iter())
|
||||
.flat_map(|p| p.runs.iter())
|
||||
.map(|r| {
|
||||
let kind = match &r.kind {
|
||||
RunKind::Field { field } => Some(field.clone()),
|
||||
_ => None,
|
||||
};
|
||||
(r.text.clone(), kind)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sample(kind: &str) -> String {
|
||||
let now = deckcraft_text::datetime::DateTime::now_local().expect("a local date");
|
||||
deckcraft_text::datetime::field_text(kind, None, now).expect("a sample")
|
||||
}
|
||||
|
||||
/// Runs `design.headerFooter` and checks the slide's date run is an automatic `kind` field.
|
||||
/// The clock is read before and after the command, so a second ticking over between the
|
||||
/// command and the read cannot fail the comparison. Returns the run text.
|
||||
fn run_automatic(s: &mut Session, params: serde_json::Value, kind: &str) -> String {
|
||||
let before = sample(kind);
|
||||
s.execute("design.headerFooter", ¶ms).unwrap();
|
||||
let after = sample(kind);
|
||||
let got = dates(s);
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_eq!(got[0].1.as_deref(), Some(kind));
|
||||
assert!(got[0].0 == before || got[0].0 == after, "{got:?} is neither {before:?} nor {after:?}");
|
||||
got[0].0.clone()
|
||||
}
|
||||
|
||||
/// Header & Footer's automatic date takes the format it is given. `HeaderFooter::date_format`
|
||||
/// existed for this and was never read: the placeholder was always `datetime1`, and a
|
||||
/// `dateFormat` parameter was accepted and silently dropped.
|
||||
#[test]
|
||||
fn the_automatic_date_takes_the_format_it_is_given() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
// Default: the short date, as before.
|
||||
run_automatic(&mut s, json!({"date": true, "dateText": "", "all": true}), "datetime1");
|
||||
|
||||
// A format: the field type changes and the saved text is that format's.
|
||||
run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime5", "all": true}), "datetime5");
|
||||
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime5");
|
||||
|
||||
// A time format works the same way, and these two differ, so the format is really read.
|
||||
let time = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
assert_ne!(time, sample("datetime5"));
|
||||
|
||||
// Asking again changes nothing (and does not rewrite the run).
|
||||
let again = run_automatic(&mut s, json!({"date": true, "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
assert_eq!(again, time);
|
||||
|
||||
// A fixed date still wins: plain text, no field.
|
||||
s.execute("design.headerFooter", &json!({"date": true, "dateText": "4 July 1776", "all": true})).unwrap();
|
||||
assert_eq!(dates(&s), vec![("4 July 1776".to_string(), None)]);
|
||||
|
||||
// Back to automatic: the remembered format comes back, not datetime1.
|
||||
run_automatic(&mut s, json!({"date": true, "dateText": "", "dateFormat": "datetime11", "all": true}), "datetime11");
|
||||
|
||||
// An unknown format is refused rather than ignored, and changes nothing.
|
||||
for bad in ["datetime0", "datetime14", "nonsense", ""] {
|
||||
assert!(s.execute("design.headerFooter", &json!({"date": true, "dateFormat": bad})).is_err(), "{bad}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().doc.header_footer.date_format, "datetime11");
|
||||
let got = dates(&s);
|
||||
assert_eq!(got.len(), 1, "{got:?}");
|
||||
assert_eq!(got[0].1.as_deref(), Some("datetime11"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,19 +44,14 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
}
|
||||
|
||||
fn restore(s: &mut Session, redo: bool) -> Result<Value> {
|
||||
let vertex_kind = s.tool.vertex_kind.clone();
|
||||
let st = s.doc_mut()?;
|
||||
let (from, to) = if redo { (&mut st.history.redo, &mut st.history.undo) } else { (&mut st.history.undo, &mut st.history.redo) };
|
||||
let Some(e) = from.pop() else { return ok() };
|
||||
to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind });
|
||||
to.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() });
|
||||
st.doc = e.doc;
|
||||
st.selection = e.selection;
|
||||
st.interaction = None;
|
||||
// A later shape can get an undone copy's id: Duplicate starts over with the usual offset.
|
||||
st.last_duplicate = None;
|
||||
st.revision += 1;
|
||||
// The point type Edit Points remembered with the restored document: handle drags follow it.
|
||||
s.tool.vertex_kind = e.vertex_kind;
|
||||
Ok(json!({"label": e.label}))
|
||||
}
|
||||
|
||||
@@ -72,6 +67,30 @@ fn slides_scope(s: &Session, p: &Value) -> bool {
|
||||
|| s.active().is_some_and(|d| d.selection.shapes.is_empty() && d.selection.text.is_none() && !d.selection.slides.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn media_of_shapes(shapes: &[Shape], out: &mut Vec<MediaId>) {
|
||||
deckcraft_model::walk(shapes, &mut |s, _| {
|
||||
match &s.kind {
|
||||
deckcraft_model::ShapeKind::Picture { fill } => out.push(fill.media),
|
||||
deckcraft_model::ShapeKind::Media(m) => {
|
||||
out.push(m.media);
|
||||
if let Some(p) = m.poster {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
deckcraft_model::ShapeKind::Model3d { media, poster } => {
|
||||
out.push(*media);
|
||||
if let Some(p) = poster {
|
||||
out.push(*p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(deckcraft_model::Fill::Picture(pf)) = &s.fill {
|
||||
out.push(pf.media);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
let st = s.doc()?;
|
||||
// Text selection.
|
||||
@@ -92,18 +111,16 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
let slides: Vec<Slide> = ids.iter().filter_map(|id| st.doc.slide(*id).cloned()).collect();
|
||||
let mut mids = vec![];
|
||||
for sl in &slides {
|
||||
mids.extend(crate::media_refs::slide_media(sl));
|
||||
media_of_shapes(&sl.shapes, &mut mids);
|
||||
if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &sl.background {
|
||||
mids.push(pf.media);
|
||||
}
|
||||
}
|
||||
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
|
||||
let plain = slides.iter().map(|x| x.title()).collect::<Vec<_>>().join("\n");
|
||||
s.clipboard = crate::Clipboard { slides, media, plain: plain.clone(), ..Default::default() };
|
||||
return Ok(plain);
|
||||
}
|
||||
// Selected table cells copy as their text: a tab between cells and a paragraph per row.
|
||||
if let Some(plain) = super::table::selected_cells_text(st).filter(|_| p.get("id").is_none() && p.get("ids").is_none()) {
|
||||
s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() };
|
||||
return Ok(plain);
|
||||
}
|
||||
let shapes: Vec<Shape> = st
|
||||
.selection
|
||||
.shapes
|
||||
@@ -117,7 +134,8 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mids = crate::media_refs::shapes_media(&shapes);
|
||||
let mut mids = vec![];
|
||||
media_of_shapes(&shapes, &mut mids);
|
||||
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
|
||||
let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");
|
||||
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
|
||||
@@ -230,13 +248,32 @@ fn walk_mut(shapes: &mut [Shape], f: &mut dyn FnMut(&mut Shape)) {
|
||||
rec(shapes, f, 0);
|
||||
}
|
||||
|
||||
/// Rewrite a media id the clipboard used (`map`: clipboard id to destination id).
|
||||
fn remap_media(map: &HashMap<MediaId, MediaId>) -> impl FnMut(&mut MediaId) + '_ {
|
||||
move |id: &mut MediaId| {
|
||||
/// Rewrite the media ids shapes use (`map`: clipboard id to destination id).
|
||||
fn remap_media(shapes: &mut [Shape], map: &HashMap<MediaId, MediaId>) {
|
||||
if map.is_empty() {
|
||||
return;
|
||||
}
|
||||
let m = |id: &mut MediaId| {
|
||||
if let Some(n) = map.get(id) {
|
||||
*id = *n;
|
||||
}
|
||||
}
|
||||
};
|
||||
walk_mut(shapes, &mut |s| {
|
||||
match &mut s.kind {
|
||||
deckcraft_model::ShapeKind::Picture { fill } => m(&mut fill.media),
|
||||
deckcraft_model::ShapeKind::Media(c) => {
|
||||
m(&mut c.media);
|
||||
if let Some(p) = c.poster.as_mut() {
|
||||
m(p);
|
||||
}
|
||||
}
|
||||
deckcraft_model::ShapeKind::Opaque { preview: Some(p), .. } => m(p),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(deckcraft_model::Fill::Picture(pf)) = &mut s.fill {
|
||||
m(&mut pf.media);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Add the clipboard's media to `doc` and return the ids that changed: an id already used in
|
||||
@@ -289,7 +326,12 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("edit.paste", NO_ID_SPACE))?);
|
||||
let map = reid("edit.paste", doc, &mut sl.shapes)?;
|
||||
remap_animations(&mut sl.animations, &map);
|
||||
crate::media_refs::visit_mut::slide(&mut sl, &mut remap_media(&media));
|
||||
remap_media(&mut sl.shapes, &media);
|
||||
if let Some(deckcraft_model::Background::Fill { fill: deckcraft_model::Fill::Picture(pf) }) = &mut sl.background
|
||||
&& let Some(n) = media.get(&pf.media)
|
||||
{
|
||||
pf.media = *n;
|
||||
}
|
||||
if doc.layout(sl.layout).is_none() {
|
||||
sl.layout = doc.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
|
||||
}
|
||||
@@ -313,7 +355,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let media = bring_media("edit.paste", doc, &clip.media)?;
|
||||
let mut shapes = clip.shapes.clone();
|
||||
reid("edit.paste", doc, &mut shapes)?;
|
||||
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
|
||||
remap_media(&mut shapes, &media);
|
||||
for sh in &mut shapes {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x += off;
|
||||
@@ -460,8 +502,7 @@ fn paste_picture(s: &mut Session) -> Result<Value> {
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let mut doc = (*st.doc).clone();
|
||||
let media = bring_media("edit.pasteSpecial", &mut doc, &clip.media)?;
|
||||
crate::media_refs::visit_mut::shapes(&mut clip.shapes, &mut remap_media(&media));
|
||||
bring_media("edit.pasteSpecial", &mut doc, &clip.media)?;
|
||||
let mut slide = st.current_slide().cloned().ok_or_else(|| bad("edit.pasteSpecial", "no slide"))?;
|
||||
let xs: Vec<deckcraft_geom::Xfrm> = clip.shapes.iter().filter_map(|x| x.xfrm).collect();
|
||||
let b = super::arrange::union_bounds(&xs).ok_or_else(|| bad("edit.pasteSpecial", "nothing to draw"))?;
|
||||
@@ -493,31 +534,15 @@ fn paste_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let off = s.prefs.duplicate_offset;
|
||||
let last = s.doc()?.last_duplicate.clone();
|
||||
let (new_ids, made) = s.edit(|doc, sel| {
|
||||
s.edit(|doc, sel| {
|
||||
let list = super::current_shapes(doc, sel).cloned().unwrap_or_default();
|
||||
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
|
||||
let sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
|
||||
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
|
||||
// Duplicating the copies of the last Duplicate again repeats how far they were moved
|
||||
// since (#966); anything else gets the usual offset.
|
||||
let (dx, dy) = last
|
||||
.filter(|(slide, target, pairs)| {
|
||||
*slide == sel.slide && *target == sel.target && pairs.len() == sources.len() && pairs.iter().all(|(_, c)| sources.contains(c))
|
||||
})
|
||||
.and_then(|(_, _, pairs)| {
|
||||
let (src, copy) = pairs.first()?;
|
||||
let a = xfrm_of(doc, sel, list.iter().find(|x| x.id == *src)?);
|
||||
let b = xfrm_of(doc, sel, list.iter().find(|x| x.id == *copy)?);
|
||||
let d = (b.x - a.x, b.y - a.y);
|
||||
(d.0.is_finite() && d.1.is_finite()).then_some(d)
|
||||
})
|
||||
.unwrap_or((off, off));
|
||||
reid("edit.duplicate", doc, &mut copies)?;
|
||||
for (c, b) in copies.iter_mut().zip(boxes) {
|
||||
let mut x = b;
|
||||
x.x += dx;
|
||||
x.y += dy;
|
||||
x.x += off;
|
||||
x.y += off;
|
||||
c.xfrm = Some(x);
|
||||
if sel.target == crate::Target::Slides {
|
||||
c.ph = None;
|
||||
@@ -530,11 +555,8 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.duplicate", "no slide"))?;
|
||||
shapes.extend(copies);
|
||||
sel.shapes = new_ids.clone();
|
||||
let made = (sel.slide, sel.target, sources.into_iter().zip(new_ids.iter().copied()).collect());
|
||||
Ok((new_ids, made))
|
||||
})?;
|
||||
s.doc_mut()?.last_duplicate = Some(made);
|
||||
Ok(json!({"ids": new_ids}))
|
||||
Ok(json!({"ids": new_ids}))
|
||||
})
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -544,35 +566,18 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if slides_scope(s, p) && ids_param(p, "ids").is_none() {
|
||||
return s.execute("slide.delete", &json!({}));
|
||||
}
|
||||
// With table cells selected, Delete clears their text and keeps the table, as in PowerPoint.
|
||||
let sel = &s.doc()?.selection;
|
||||
if ids_param(p, "ids").is_none() && p.get("id").is_none() && sel.cells.is_some_and(|c| sel.shapes == [c.0]) {
|
||||
return super::table::clear_selected_cells(s);
|
||||
}
|
||||
let ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad("edit.delete", "no slide"))?;
|
||||
let mut before = Vec::new();
|
||||
deckcraft_model::walk(shapes, &mut |s, _| before.push(s.id));
|
||||
let n = remove_shapes(shapes, &ids);
|
||||
// Everything that is gone: the given ids plus group descendants and emptied groups.
|
||||
let mut left = Vec::new();
|
||||
deckcraft_model::walk(shapes, &mut |s, _| left.push(s.id));
|
||||
let mut gone = ids.clone();
|
||||
gone.extend(before.into_iter().filter(|i| !left.contains(i)));
|
||||
// Animations of deleted shapes go too.
|
||||
if sel.target == crate::Target::Slides
|
||||
&& let Some(sl) = doc.slides.get_mut(sel.slide)
|
||||
{
|
||||
let sl = Arc::make_mut(sl);
|
||||
sl.animations.retain(|a| !gone.contains(&a.shape));
|
||||
// Effects triggered by a deleted shape fall back to the main sequence.
|
||||
for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| gone.contains(&t))) {
|
||||
a.trigger = None;
|
||||
}
|
||||
Arc::make_mut(sl).animations.retain(|a| !ids.contains(&a.shape));
|
||||
}
|
||||
sel.shapes.retain(|i| !ids.contains(i));
|
||||
sel.text = None;
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
//! File menu: new, open, save, export, close, properties.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_media::MediaInfo;
|
||||
use deckcraft_model::{MediaId, MediaItem, Presentation, Shape, ShapeKind, defaults};
|
||||
use deckcraft_model::{Presentation, defaults};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -37,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
|
||||
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|rtf|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
@@ -160,17 +156,14 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
if deckcraft_pptx::sniff(bytes) {
|
||||
let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
deckcraft_format::repair(&mut p);
|
||||
probe_clips(&mut p);
|
||||
return Ok((p, lost_slides));
|
||||
}
|
||||
if deckcraft_pptx::is_encrypted(bytes) {
|
||||
return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet.")));
|
||||
}
|
||||
let lower = name.to_ascii_lowercase();
|
||||
let rtf = deckcraft_format::rtf::sniff_rtf(bytes);
|
||||
if rtf || lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
if lower.ends_with(".txt") || lower.ends_with(".md") {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
let text = if rtf { std::borrow::Cow::Owned(deckcraft_format::rtf::rtf_to_outline(&text)) } else { text };
|
||||
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
|
||||
if lower.ends_with(".md") {
|
||||
deckcraft_format::markdown_to_slides(&mut p, &text);
|
||||
@@ -185,48 +178,6 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read")))
|
||||
}
|
||||
|
||||
/// Fill in each audio and video clip's length and picture size from its bytes: a `.pptx` doesn't
|
||||
/// store them. Headers only, so opening never decodes a whole audio file.
|
||||
fn probe_clips(p: &mut Presentation) {
|
||||
fn rec(shapes: &mut [Shape], media: &[MediaItem], probed: &mut HashMap<MediaId, Option<MediaInfo>>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for s in shapes {
|
||||
if let ShapeKind::Media(m) = &mut s.kind
|
||||
&& (m.duration_ms == 0 || m.width == 0 && m.height == 0)
|
||||
&& let Some(info) = probed
|
||||
.entry(m.media)
|
||||
.or_insert_with(|| media.iter().find(|i| i.id == m.media).and_then(|i| deckcraft_media::probe_headers(&i.data).ok()))
|
||||
{
|
||||
if m.duration_ms == 0 {
|
||||
m.duration_ms = info.duration_ms.min(u32::MAX as u64) as u32;
|
||||
}
|
||||
if m.width == 0
|
||||
&& m.height == 0
|
||||
&& let Some(v) = &info.video
|
||||
{
|
||||
(m.width, m.height) = (v.width, v.height);
|
||||
}
|
||||
}
|
||||
if let Some(children) = s.children_mut() {
|
||||
rec(children, media, probed, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut probed = HashMap::new();
|
||||
// Just read, so no slide is shared yet and `make_mut` doesn't copy.
|
||||
for slide in &mut p.slides {
|
||||
rec(&mut Arc::make_mut(slide).shapes, &p.media, &mut probed, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// An outline file's text: RTF is read into the tab-indented outline, anything else as UTF-8.
|
||||
pub fn outline_text(bytes: &[u8]) -> String {
|
||||
let text = String::from_utf8_lossy(bytes);
|
||||
if deckcraft_format::rtf::sniff_rtf(bytes) { deckcraft_format::rtf::rtf_to_outline(&text) } else { text.into_owned() }
|
||||
}
|
||||
|
||||
pub fn add_opened(s: &mut Session, name: &str, path: Option<String>, p: Presentation) -> usize {
|
||||
let title = std::path::Path::new(name).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| name.to_string());
|
||||
crate::fonts::install_embedded_fonts(&p);
|
||||
@@ -342,7 +293,6 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
match format {
|
||||
f @ ("pptx" | "potx" | "ppsx") => deckcraft_pptx::export_as(doc, f).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()),
|
||||
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()),
|
||||
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"gif" => gif_bytes(doc, &Value::Null),
|
||||
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
|
||||
@@ -418,7 +368,7 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// The output extensions [`format_for_path`] maps to a writer of that format.
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md", "rtf"];
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md"];
|
||||
|
||||
/// The format a file named `path` is written in, or `None` when its extension names no format
|
||||
/// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save
|
||||
@@ -451,8 +401,6 @@ pub fn format_for_path(path: &str) -> &'static str {
|
||||
"gif"
|
||||
} else if l.ends_with(".txt") || l.ends_with(".md") {
|
||||
"outline"
|
||||
} else if l.ends_with(".rtf") {
|
||||
"rtf"
|
||||
} else {
|
||||
"deckcraft"
|
||||
}
|
||||
@@ -633,7 +581,7 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
|
||||
}))
|
||||
}
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" | "rtf" => {
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
|
||||
let bytes = save_bytes(&st.doc, &format)?;
|
||||
write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
|
||||
@@ -105,7 +105,7 @@ impl Opts {
|
||||
}
|
||||
|
||||
fn para_chars(p: &Paragraph) -> Vec<char> {
|
||||
p.runs.iter().flat_map(|r| r.content().chars()).collect()
|
||||
p.runs.iter().flat_map(|r| r.text.chars()).collect()
|
||||
}
|
||||
|
||||
/// Matches that lie wholly in plain text runs (never inside a field, equation or break).
|
||||
@@ -113,7 +113,7 @@ fn para_matches(p: &Paragraph, o: &Opts) -> Vec<(usize, usize)> {
|
||||
let mut spans = vec![];
|
||||
let mut off = 0;
|
||||
for r in &p.runs {
|
||||
let n = r.char_len();
|
||||
let n = r.text.chars().count();
|
||||
spans.push((off, off + n, matches!(r.kind, RunKind::Text)));
|
||||
off += n;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ fn splice(p: &mut Paragraph, a: usize, b: usize, with: &str) {
|
||||
let mut off = 0;
|
||||
let mut placed = false;
|
||||
for r in &mut p.runs {
|
||||
let chars: Vec<char> = r.content().chars().collect();
|
||||
let chars: Vec<char> = r.text.chars().collect();
|
||||
let (s, e) = (off, off + chars.len());
|
||||
off = e;
|
||||
if e <= a || s >= b {
|
||||
@@ -222,34 +222,6 @@ mod tests {
|
||||
assert_eq!(opts("cat", false, true).matches(&t), vec![(0, 3), (4, 7)]);
|
||||
}
|
||||
|
||||
fn text_run(t: &str) -> Run {
|
||||
Run { text: t.into(), props: RunProps::default(), kind: RunKind::Text }
|
||||
}
|
||||
|
||||
fn br() -> Run {
|
||||
Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_match_never_spans_a_line_break() {
|
||||
let p = Paragraph { runs: vec![text_run("a"), br(), text_run("b")], ..Default::default() };
|
||||
assert!(para_matches(&p, &opts("ab", true, false)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offsets_count_a_line_break_as_one_char() {
|
||||
let p = Paragraph { runs: vec![text_run("ab"), br(), text_run("cd")], ..Default::default() };
|
||||
assert_eq!(para_matches(&p, &opts("cd", true, false)), vec![(3, 5)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splice_after_a_line_break_keeps_the_break() {
|
||||
let mut p = Paragraph { runs: vec![text_run("ab"), br(), text_run("cd")], ..Default::default() };
|
||||
splice(&mut p, 3, 5, "X");
|
||||
assert_eq!(p.text(), "ab\u{b}X");
|
||||
assert_eq!(p.runs[1].kind, RunKind::Break);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splice_across_runs_keeps_the_first_runs_format() {
|
||||
let bold = RunProps { bold: Some(true), ..Default::default() };
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use deckcraft_model::edit as ed;
|
||||
use deckcraft_model::resolve;
|
||||
use deckcraft_model::text::{Align, Anchor, AutoFit, Bullet, Caps, ParaProps, RunProps, Spacing, Strike, TabStop, TextDir};
|
||||
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape, ShapeKind};
|
||||
use deckcraft_model::{ColorRef, ColorTransform, Effects, Fill, Line, SchemeSlot, Shape};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::text::{format_body, format_para, format_run};
|
||||
@@ -198,15 +198,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text_or_shapes,
|
||||
direction
|
||||
),
|
||||
cmd!(
|
||||
"format.columns",
|
||||
"Columns",
|
||||
["Home", "Paragraph"],
|
||||
None,
|
||||
"{count?: 1-16, spacing?: pt between columns}; a key left out keeps the shape's value, and with neither the Columns dialog opens",
|
||||
has_text_or_shapes,
|
||||
columns
|
||||
),
|
||||
cmd!("format.columns", "Columns", ["Home", "Paragraph"], None, "{count, spacing?: pt}", has_text_or_shapes, columns),
|
||||
cmd!("format.autofit", "Autofit", ["Format Shape", "Text Box"], None, "{mode: none|shrink|resize}", has_text_or_shapes, autofit),
|
||||
cmd!("format.wrap", "Wrap Text in Shape", ["Format Shape", "Text Box"], None, "{on: bool}", has_text_or_shapes, wrap),
|
||||
cmd!(
|
||||
@@ -348,27 +340,6 @@ fn shrink(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let c = color_param(p, "color").ok_or_else(|| bad("format.color", "missing or invalid `color`"))?;
|
||||
// A selected chart has no runs: Font Color colours its text (title, axes, legend, labels),
|
||||
// which used to answer and change nothing.
|
||||
let st = s.doc()?;
|
||||
if st.selection.text.is_none() {
|
||||
let charts: Vec<ShapeId> =
|
||||
st.selection.shapes.iter().copied().filter(|id| matches!(st.shape(*id).map(|sh| &sh.kind), Some(ShapeKind::Chart(_)))).collect();
|
||||
let only_charts = charts.len() == st.selection.shapes.len();
|
||||
if !charts.is_empty() {
|
||||
let tc = c.clone();
|
||||
edit_shapes(s, &json!({"ids": charts}), "format.color", |sh| {
|
||||
if let ShapeKind::Chart(ch) = &mut sh.kind {
|
||||
ch.text_color = Some(tc.clone());
|
||||
ch.raw = None;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
if only_charts {
|
||||
return ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
format_run(s, &move |r| r.fill = Some(Fill::solid(c.clone())))
|
||||
}
|
||||
/// `a:alpha` for `transparency` (0–1) on `c`, replacing any alpha it had; fully opaque drops it.
|
||||
@@ -721,8 +692,7 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let m = if mode == "cycle" { ed::next_case(&ed::text_range(&body, a, b)).to_string() } else { mode.clone() };
|
||||
super::text::edit_text(s, move |body, t| {
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in a.0..=b.0.min(last) {
|
||||
for pi in a.0..=b.0 {
|
||||
let Some(para) = body.paragraphs.get_mut(pi) else { continue };
|
||||
let n = para.char_len();
|
||||
let from = if pi == a.0 { a.1 } else { 0 };
|
||||
@@ -1119,45 +1089,12 @@ fn direction(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
format_body(s, &move |b| b.vert = Some(d))
|
||||
}
|
||||
/// The edited or first selected shape's columns as drawn: the count and the spacing in points.
|
||||
fn current_columns(s: &Session) -> (u32, f64) {
|
||||
let Ok(st) = s.doc() else { return (1, 0.0) };
|
||||
let id = match &st.selection.text {
|
||||
Some(t) if !t.notes => Some(t.shape),
|
||||
_ => st.selection.shapes.first().copied(),
|
||||
};
|
||||
let Some(sh) = id.and_then(|id| st.shape(id)) else { return (1, 0.0) };
|
||||
let body = match super::text::ctx_for(&st.doc, &st.selection) {
|
||||
Some(c) => resolve::body(&c, sh),
|
||||
None => sh.text.as_ref().map(|b| b.body.clone()).unwrap_or_default(),
|
||||
};
|
||||
(body.columns.unwrap_or(1).clamp(1, 16), body.col_spacing.unwrap_or(0.0).max(0.0))
|
||||
}
|
||||
fn columns(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "format.columns";
|
||||
let count = match p.get("count") {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let n = v
|
||||
.as_f64()
|
||||
.filter(|n| n.fract() == 0.0 && (1.0..=16.0).contains(n))
|
||||
.ok_or_else(|| bad(CMD, "`count` must be a whole number from 1 to 16"))?;
|
||||
Some(n as u32)
|
||||
}
|
||||
};
|
||||
let spacing = finite_key(p, CMD, "spacing")?.map(clamp_pt);
|
||||
if count.is_none() && spacing.is_none() {
|
||||
let (count, spacing) = current_columns(s);
|
||||
s.ui_requests.push(crate::UiRequest::Dialog { id: "columns".into(), params: json!({"count": count, "spacing": spacing}) });
|
||||
return ok();
|
||||
}
|
||||
let n = p.get("count").and_then(Value::as_u64).unwrap_or(1).clamp(1, 16) as u32;
|
||||
let sp = f64_or(p, "spacing", 0.0).max(0.0);
|
||||
format_body(s, &move |b| {
|
||||
if let Some(n) = count {
|
||||
b.columns = Some(n);
|
||||
}
|
||||
if let Some(sp) = spacing {
|
||||
b.col_spacing = Some(sp);
|
||||
}
|
||||
b.columns = Some(n);
|
||||
b.col_spacing = Some(sp);
|
||||
})
|
||||
}
|
||||
fn autofit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1233,31 +1170,6 @@ pub(crate) fn apply_painted(sh: &mut Shape, p: &Painted) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The paragraphs the text selection covers whole: it starts at or before their start and
|
||||
/// reaches their end. A paragraph the selection only touches the start of (it ends at offset
|
||||
/// 0, as after a triple-click) is not covered.
|
||||
fn covered_paragraphs(s: &Session) -> Vec<usize> {
|
||||
let Ok(st) = s.doc() else { return vec![] };
|
||||
let Some(t) = &st.selection.text else { return vec![] };
|
||||
let Some(body) = super::text::body_of(st, t) else { return vec![] };
|
||||
let (a, b) = t.ordered();
|
||||
// A hostile selection can point past the body: walk only the paragraphs it has.
|
||||
(a.0..=b.0.min(body.paragraphs.len().saturating_sub(1)))
|
||||
.filter(|&pi| {
|
||||
let Some(para) = body.paragraphs.get(pi) else { return false };
|
||||
let starts = pi > a.0 || a.1 == 0;
|
||||
let ends = if pi < b.0 {
|
||||
true
|
||||
} else if b.0 > a.0 && b.1 == 0 {
|
||||
false
|
||||
} else {
|
||||
b.1 >= para.char_len()
|
||||
};
|
||||
starts && ends
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let Some((painted, sticky)) = s.painter.clone() else { return Err(bad("format.painterApply", "pick formatting with the Format Painter first")) };
|
||||
if !sticky {
|
||||
@@ -1267,17 +1179,8 @@ fn painter_apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(r) = painted.run.clone() {
|
||||
format_run(s, &move |rp| *rp = r.clone())?;
|
||||
}
|
||||
// Paragraph formatting (bullets, alignment, spacing) goes only onto paragraphs the
|
||||
// selection covers whole; painting part of a bulleted line changes just its text.
|
||||
let covered = covered_paragraphs(s);
|
||||
if let (Some(pp), false) = (painted.para.clone(), covered.is_empty()) {
|
||||
super::text::edit_text(s, |body, _| {
|
||||
for &pi in &covered {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
para.props = pp.clone();
|
||||
}
|
||||
}
|
||||
})?;
|
||||
if let Some(pp) = painted.para.clone() {
|
||||
format_para(s, &move |para| para.props = pp.clone())?;
|
||||
}
|
||||
return ok();
|
||||
}
|
||||
@@ -1656,51 +1559,6 @@ mod tests {
|
||||
assert!(para(&s).indent.is_none());
|
||||
}
|
||||
|
||||
fn body(s: &Session) -> deckcraft_model::text::BodyProps {
|
||||
s.doc().unwrap().selected_shapes().first().and_then(|sh| sh.text.as_ref()).map(|t| t.body.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_set_the_number_and_spacing_and_a_left_out_key_keeps_its_value() {
|
||||
let mut s = shape();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.execute("format.columns", &json!({"count": 4, "spacing": 36.0})).unwrap();
|
||||
assert_eq!((body(&s).columns, body(&s).col_spacing), (Some(4), Some(36.0)));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n + 1, "one undo step");
|
||||
s.execute("format.columns", &json!({"count": 2})).unwrap();
|
||||
assert_eq!((body(&s).columns, body(&s).col_spacing), (Some(2), Some(36.0)), "the spacing stays");
|
||||
s.execute("format.columns", &json!({"spacing": 9.0})).unwrap();
|
||||
assert_eq!((body(&s).columns, body(&s).col_spacing), (Some(2), Some(9.0)), "the number stays");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_with_no_fields_open_the_dialog_with_the_shapes_values() {
|
||||
let mut s = shape();
|
||||
s.execute("format.columns", &json!({"count": 3, "spacing": 27.0})).unwrap();
|
||||
let rev = s.doc().unwrap().revision;
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
s.ui_requests.clear();
|
||||
s.execute("format.columns", &json!({})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().revision, rev);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
let Some(UiRequest::Dialog { id, params }) = s.ui_requests.last() else { panic!("dialog request") };
|
||||
assert_eq!(id, "columns");
|
||||
assert_eq!(params["count"], 3);
|
||||
assert_eq!(params["spacing"], 27.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn columns_reject_a_bad_number_or_spacing_without_editing() {
|
||||
let mut s = shape();
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
for p in [json!({"count": 0}), json!({"count": 17}), json!({"count": 2.5}), json!({"count": "two"}), json!({"spacing": "wide"})] {
|
||||
let err = s.execute("format.columns", &p).unwrap_err().to_string();
|
||||
assert!(err.contains("format.columns"), "{p}: {err}");
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), n);
|
||||
assert_eq!(body(&s).columns, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tabs_dialog_sets_stops_in_one_undo_and_opens_with_no_fields() {
|
||||
let mut s = shape();
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! slide. Everything is computed here, offline.
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::{Fill, Presentation, Rgba, Shape, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -58,44 +57,9 @@ fn roles(shapes: &[Shape]) -> Roles {
|
||||
r
|
||||
}
|
||||
|
||||
/// Whether `s` paints a fill or an outline once inherited and style-referenced values are
|
||||
/// resolved, as the renderer does (`fill: None` alone means inherited, not invisible).
|
||||
fn shows(ctx: Option<&Ctx>, s: &Shape) -> bool {
|
||||
let visible = |f: &Option<Fill>| f.as_ref().is_some_and(|f| !f.is_none());
|
||||
match ctx {
|
||||
Some(ctx) => visible(&resolve::fill(ctx, s).0) || visible(&resolve::line(ctx, s).0.fill),
|
||||
None => visible(&s.fill) || visible(&s.line.as_ref().and_then(|l| l.fill.clone())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an idea may move this shape: a picture, a placeholder or a plain text box. Diagram
|
||||
/// boxes, connectors, groups, tables, charts and media are laid out by their own geometry, which
|
||||
/// the ideas do not know, so a slide holding one gets no ideas.
|
||||
fn arrangeable(ctx: Option<&Ctx>, s: &Shape) -> bool {
|
||||
match s.kind {
|
||||
ShapeKind::Picture { .. } => true,
|
||||
ShapeKind::Shape => {
|
||||
let is_title = s.ph.as_ref().is_some_and(|p| p.kind.is_title());
|
||||
let has_text = s.text.as_ref().is_some_and(|t| !t.text().trim().is_empty());
|
||||
// Only titles and shapes with text are moved (see `roles`): an empty one that shows
|
||||
// (filled or outlined) would stay where it is, over the rearranged slide.
|
||||
(s.ph.is_some() || s.text_box) && (is_title || has_text || !shows(ctx, s))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The ideas for slide `index` (none when it has nothing to arrange, or other content in the way).
|
||||
/// The ideas for slide `index` (none when it has nothing to arrange).
|
||||
pub fn ideas_for(doc: &Presentation, index: usize) -> Vec<Idea> {
|
||||
let Some(slide) = doc.slides.get(index) else { return vec![] };
|
||||
let ctx = Ctx::for_slide(doc, slide);
|
||||
if slide.shapes.iter().any(|s| !s.hidden && !arrangeable(ctx.as_ref(), s)) {
|
||||
return vec![];
|
||||
}
|
||||
// The ideas move one picture; a second would stay put, over the rearranged text.
|
||||
if slide.shapes.iter().filter(|s| !s.hidden && matches!(s.kind, ShapeKind::Picture { .. })).count() > 1 {
|
||||
return vec![];
|
||||
}
|
||||
let r = roles(&slide.shapes);
|
||||
if r.picture.is_some() {
|
||||
PICTURE_IDEAS.to_vec()
|
||||
@@ -120,22 +84,6 @@ fn cover_crop(src: f64, dst: f64) -> [f64; 4] {
|
||||
}
|
||||
}
|
||||
|
||||
/// The light fill behind the title in the full-bleed idea. Its opacity is an `alpha` transform
|
||||
/// (about 220/255), as PPTX stores it, so the fill compares equal after a save and reopen.
|
||||
fn band_fill() -> Fill {
|
||||
Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgb(255, 255, 255)).with(deckcraft_model::ColorTransform::Alpha(86274)))
|
||||
}
|
||||
|
||||
/// The band as released versions up to 0.10 stored it (opacity in the colour's own alpha).
|
||||
fn legacy_band_fill() -> Fill {
|
||||
Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)))
|
||||
}
|
||||
|
||||
/// Whether `f` is a Full-bleed band, in the current or the legacy form.
|
||||
fn is_band(f: &Fill) -> bool {
|
||||
*f == band_fill() || *f == legacy_band_fill()
|
||||
}
|
||||
|
||||
/// Apply idea `id` to slide `index` of `doc` (`sel` resolves inherited placeholder boxes).
|
||||
pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &str) -> Result<()> {
|
||||
if !ideas_for(doc, index).iter().any(|i| i.0 == id) {
|
||||
@@ -232,13 +180,8 @@ pub fn apply_idea(doc: &mut Presentation, sel: &Selection, index: usize, id: &st
|
||||
let pic = slide.shapes.remove(p);
|
||||
slide.shapes.insert(0, pic);
|
||||
}
|
||||
if let Some(t) = slide.shapes.iter_mut().find(|s| !s.hidden && s.ph.as_ref().is_some_and(|p| p.kind.is_title())) {
|
||||
if band {
|
||||
t.fill = Some(band_fill());
|
||||
} else if t.fill.as_ref().is_some_and(is_band) {
|
||||
// The band belongs to full-bleed only: another idea takes it away again.
|
||||
t.fill = None;
|
||||
}
|
||||
if band && let Some(t) = slide.shapes.iter_mut().find(|s| s.ph.as_ref().is_some_and(|p| p.kind.is_title())) {
|
||||
t.fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220))));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -285,7 +228,6 @@ mod tests {
|
||||
};
|
||||
let body = Shape {
|
||||
id: ShapeId(501),
|
||||
text_box: true,
|
||||
xfrm: Some(Xfrm::new(50.0, 140.0, 860.0, 350.0)),
|
||||
text: Some(deckcraft_model::text::TextBody::from_text("one\ntwo")),
|
||||
..Default::default()
|
||||
@@ -372,170 +314,6 @@ mod tests {
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "nope"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_from_full_bleed_removes_the_title_band() {
|
||||
let mut s = deck(true);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert!(shape(&s, 500).fill.is_none(), "the band belongs to full-bleed only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_band_goes_on_the_visible_title_not_a_hidden_one_before_it() {
|
||||
let mut s = deck(true);
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut st.doc).slides[0]);
|
||||
let hidden = Shape {
|
||||
id: ShapeId(503),
|
||||
hidden: true,
|
||||
ph: Some(Placeholder { kind: PhType::Title, ..Default::default() }),
|
||||
xfrm: Some(Xfrm::new(50.0, 30.0, 860.0, 90.0)),
|
||||
text: Some(deckcraft_model::text::TextBody::from_text("Old title")),
|
||||
..Default::default()
|
||||
};
|
||||
slide.shapes.insert(0, hidden);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()), "visible title gets the band");
|
||||
assert!(shape(&s, 503).fill.is_none(), "hidden title is untouched");
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert!(shape(&s, 500).fill.is_none(), "switching clears the visible title's band");
|
||||
assert!(shape(&s, 503).fill.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_ideas_removes_the_band_after_a_pptx_round_trip() {
|
||||
let mut s = deck(true);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let back = deckcraft_pptx::import(&bytes).unwrap();
|
||||
s.doc_mut().unwrap().doc = Arc::new(back);
|
||||
let title = s.doc().unwrap().doc.slides[0].shapes.iter().position(|x| x.ph.as_ref().is_some_and(|p| p.kind.is_title())).unwrap();
|
||||
let id = s.doc().unwrap().doc.slides[0].shapes[title].id.0;
|
||||
assert_eq!(shape(&s, id).fill, Some(band_fill()), "band survives the save");
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert!(shape(&s, id).fill.is_none(), "the band is removed after reopening");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_ideas_removes_a_band_saved_by_earlier_versions() {
|
||||
let mut s = deck(true);
|
||||
let legacy = Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(255, 255, 255, 220)));
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut st.doc).slides[0]);
|
||||
slide.shapes.iter_mut().find(|x| x.id == ShapeId(500)).unwrap().fill = Some(legacy);
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert!(shape(&s, 500).fill.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_title_fill_set_by_the_user_survives_other_ideas() {
|
||||
// Set through the command, and set to exactly the band's own fill.
|
||||
let mut s = deck(true);
|
||||
s.execute("shape.fill", &json!({"id": 500, "color": "#ffffff", "transparency": 0.14})).unwrap();
|
||||
let user = shape(&s, 500).fill;
|
||||
assert!(user.is_some());
|
||||
s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, user);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_bleed_twice_keeps_the_band_and_undo_after_switching_brings_it_back() {
|
||||
let mut s = deck(true);
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
s.execute("design.applyIdea", &json!({"id": "fullBleed"})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
|
||||
s.execute("design.applyIdea", &json!({"id": "framed"})).unwrap();
|
||||
assert!(shape(&s, 500).fill.is_none());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(shape(&s, 500).fill, Some(band_fill()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_with_diagrams_or_other_graphics_get_no_ideas() {
|
||||
// A filled autoshape with text (a diagram box) is not text to restack.
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(510),
|
||||
xfrm: Some(Xfrm::new(300.0, 200.0, 200.0, 80.0)),
|
||||
fill: Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255)))),
|
||||
text: Some(deckcraft_model::text::TextBody::from_text("Solar wind")),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "titleColumn"})).is_err());
|
||||
// A connector too, and a hidden one does not count.
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(511),
|
||||
kind: ShapeKind::Connector { start: None, end: None },
|
||||
xfrm: Some(Xfrm::new(300.0, 200.0, 100.0, 0.0)),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[2].hidden = true;
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_visible_pictures_get_no_ideas_but_a_hidden_second_one_does_not_count() {
|
||||
let mut s = deck(true);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(503),
|
||||
kind: ShapeKind::Picture { fill: PictureFill::default() },
|
||||
xfrm: Some(Xfrm::new(600.0, 300.0, 200.0, 100.0)),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty());
|
||||
assert!(s.execute("design.applyIdea", &json!({"id": "pictureLeft"})).is_err());
|
||||
Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]).shapes[3].hidden = true;
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_text_box_blocks_ideas_only_when_it_shows() {
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape { id: ShapeId(512), text_box: true, xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)), ..Default::default() });
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "invisible and empty: nothing in the way");
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes[2].fill = Some(Fill::solid(deckcraft_model::ColorRef::rgb(Rgba::rgba(200, 80, 20, 255))));
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "a filled empty box would stay on top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_box_with_an_explicit_no_fill_does_not_block_ideas() {
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(512),
|
||||
text_box: true,
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)),
|
||||
fill: Some(Fill::None),
|
||||
..Default::default()
|
||||
});
|
||||
assert_eq!(ideas_for(&s.doc().unwrap().doc, 0).len(), 3, "an explicit no fill shows nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_box_filled_through_its_style_blocks_ideas() {
|
||||
let mut s = deck(false);
|
||||
let slide = Arc::make_mut(&mut Arc::make_mut(&mut s.doc_mut().unwrap().doc).slides[0]);
|
||||
let mut style = deckcraft_model::style::ShapeStyle::accent(deckcraft_model::SchemeSlot::Accent1);
|
||||
style.line_ref.0 = 0;
|
||||
slide.shapes.push(Shape {
|
||||
id: ShapeId(512),
|
||||
text_box: true,
|
||||
xfrm: Some(Xfrm::new(10.0, 10.0, 100.0, 40.0)),
|
||||
style: Some(style),
|
||||
..Default::default()
|
||||
});
|
||||
assert!(ideas_for(&s.doc().unwrap().doc, 0).is_empty(), "the theme fill shows, so the box would stay on top");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cover_crop_matches_the_box_aspect() {
|
||||
let c = cover_crop(2.0, 1.0);
|
||||
|
||||
@@ -118,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Chart Elements",
|
||||
["Chart Design", "Chart Layouts"],
|
||||
None,
|
||||
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, textColor?: #RRGGBB|accent1..6|tx1…|null (title, axes, legend and labels; null for PowerPoint's default: the theme's text colour at 65 % plus 35 %, 595959 on a light theme), id?}",
|
||||
"{title?: string|null, legend?: b|t|l|r|null, dataLabels?: bool, gridlines?: bool, palette?, binCount?: n|null, binSize?: n|null, subtotals?: [point index], quartiles?: exclusive|inclusive, upDownBars?: bool, id?}",
|
||||
has_selection,
|
||||
chart_options
|
||||
),
|
||||
@@ -577,7 +577,7 @@ fn commit_album(s: &mut Session, items: Vec<AlbumItem>, opts: AlbumOpts, replace
|
||||
}
|
||||
|
||||
fn album_norm(s: &str) -> String {
|
||||
s.chars().filter(char::is_ascii_alphanumeric).collect::<String>().to_ascii_lowercase()
|
||||
s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn album_layout(p: &Value) -> Result<AlbumLayout> {
|
||||
@@ -770,7 +770,7 @@ fn image_px(bytes: &[u8]) -> (f64, f64) {
|
||||
}
|
||||
|
||||
fn file_stem(name: &str) -> String {
|
||||
let base = std::path::Path::new(name).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or(name);
|
||||
let base = std::path::Path::new(name).file_name().and_then(|n| n.to_str()).unwrap_or(name);
|
||||
let stem = match base.rsplit_once('.') {
|
||||
Some((s, ext)) if !s.is_empty() && !ext.is_empty() => s,
|
||||
_ => base,
|
||||
@@ -1761,39 +1761,6 @@ fn chart_data(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_bin_options(c: &mut deckcraft_model::chart::Chart, bin_count: Option<Option<u32>>, bin_size: Option<Option<f64>>) {
|
||||
if let Some(n) = bin_count {
|
||||
c.bin_count = n;
|
||||
if n.is_some() {
|
||||
c.bin_size = None;
|
||||
}
|
||||
}
|
||||
if let Some(w) = bin_size {
|
||||
c.bin_size = w;
|
||||
if w.is_some() {
|
||||
c.bin_count = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_subtotals_and_bars(c: &mut deckcraft_model::chart::Chart, p: &Value, subtotals: Option<&[usize]>) {
|
||||
if let Some(t) = subtotals {
|
||||
let n = c.series.first().map_or(0, |s| s.values.len());
|
||||
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
|
||||
t.sort_unstable();
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Histogram bins: a count or a width (setting one clears the other; null for automatic).
|
||||
let bin_count = p.get("binCount").map(|v| v.as_u64().map(|n| n.clamp(1, deckcraft_model::chart::MAX_BINS as u64) as u32));
|
||||
@@ -1804,21 +1771,34 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Waterfall total points.
|
||||
let subtotals: Option<Vec<usize>> =
|
||||
p.get("subtotals").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|i| i as usize).collect());
|
||||
// Colour of the chart's text (title, axes, legend, data labels); null goes back to PowerPoint's
|
||||
// default (tx1 at lumMod 65 % / lumOff 35 %).
|
||||
let text_color = match p.get("textColor") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(v) => {
|
||||
Some(Some(color_value(v).ok_or_else(|| bad("chart.options", "`textColor` is a colour: #RRGGBB, accent1…accent6, tx1…, or null"))?))
|
||||
}
|
||||
};
|
||||
with_chart(s, p, "chart.options", |c| {
|
||||
if let Some(t) = &text_color {
|
||||
c.text_color = t.clone();
|
||||
if let Some(n) = bin_count {
|
||||
c.bin_count = n;
|
||||
if n.is_some() {
|
||||
c.bin_size = None;
|
||||
}
|
||||
}
|
||||
if let Some(w) = bin_size {
|
||||
c.bin_size = w;
|
||||
if w.is_some() {
|
||||
c.bin_count = None;
|
||||
}
|
||||
}
|
||||
if let Some(t) = &subtotals {
|
||||
let n = c.series.first().map_or(0, |s| s.values.len());
|
||||
let mut t: Vec<usize> = t.iter().copied().filter(|i| *i < n).collect();
|
||||
t.sort_unstable();
|
||||
t.dedup();
|
||||
c.subtotals = t;
|
||||
}
|
||||
if let Some(b) = p.get("upDownBars").and_then(Value::as_bool) {
|
||||
c.up_down_bars = b;
|
||||
c.raw = None;
|
||||
}
|
||||
if let Some(q) = p.get("quartiles").and_then(Value::as_str) {
|
||||
c.inclusive_quartiles = q.eq_ignore_ascii_case("inclusive");
|
||||
c.raw = None;
|
||||
}
|
||||
apply_bin_options(c, bin_count, bin_size);
|
||||
apply_subtotals_and_bars(c, p, subtotals.as_deref());
|
||||
if bin_count.is_some() || bin_size.is_some() || subtotals.is_some() {
|
||||
c.raw = None;
|
||||
}
|
||||
@@ -1830,10 +1810,6 @@ fn chart_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
if let Some(v) = bool_param(p, "dataLabels") {
|
||||
c.data_labels = v;
|
||||
// A series' own setting would otherwise keep overriding the chart-wide switch.
|
||||
for series in &mut c.series {
|
||||
series.data_labels = None;
|
||||
}
|
||||
}
|
||||
if let Some(v) = bool_param(p, "gridlines") {
|
||||
c.gridlines = v;
|
||||
|
||||
@@ -228,21 +228,6 @@ mod tests {
|
||||
assert!(s.execute("media.posterFrame", &json!({})).is_err(), "audio has no poster frame");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_video_reopened_from_pptx_keeps_its_length_and_size() {
|
||||
let mut s = Session::with_new();
|
||||
let clip = include_bytes!("../../../media/tests/fixtures/clip-h264.mp4");
|
||||
s.execute("insert.video", &json!({"name": "clip.mp4", "data": base64_encode(clip)})).expect("insert");
|
||||
let before = s.execute("media.info", &json!({})).expect("info");
|
||||
assert!(before["durationMs"].as_u64().is_some_and(|ms| ms > 0));
|
||||
let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).expect("save");
|
||||
s.execute("file.openBytes", &json!({"name": "deck.pptx", "data": saved["data"]})).expect("open");
|
||||
let after = s.execute("media.info", &json!({})).expect("info");
|
||||
for key in ["durationMs", "width", "height"] {
|
||||
assert_eq!(after[key], before[key], "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_commands_need_media() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -318,14 +318,6 @@ pub(crate) fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {
|
||||
pub(crate) fn usize_param(p: &Value, key: &str) -> Option<usize> {
|
||||
p.get(key).and_then(Value::as_u64).and_then(|v| usize::try_from(v).ok())
|
||||
}
|
||||
/// An index param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
||||
/// present but not a valid index (negative, non-integer), so a bad index never acts on the selection.
|
||||
pub(crate) fn opt_usize_param(p: &Value, key: &str, cmd: &str) -> Result<Option<usize>> {
|
||||
match p.get(key) {
|
||||
None | Some(Value::Null) => Ok(None),
|
||||
Some(v) => v.as_u64().and_then(|v| usize::try_from(v).ok()).map(Some).ok_or_else(|| bad(cmd, format!("`{key}` is not a valid index: {v}"))),
|
||||
}
|
||||
}
|
||||
/// A shape id param: `Ok(None)` when absent (callers may fall back to the selection), `Err` when
|
||||
/// present but not a valid id (negative, non-integer, beyond `u32`), so a bad id never edits the selection.
|
||||
pub(crate) fn id_param(p: &Value, key: &str, cmd: &str) -> Result<Option<ShapeId>> {
|
||||
@@ -371,8 +363,6 @@ pub(crate) fn ok() -> Result<Value> {
|
||||
}
|
||||
|
||||
/// Parse a colour param: `"#RRGGBB"`, `"accent1"`…, or `{"scheme":"accent2","lumMod":75000}`.
|
||||
/// A preset or system colour keeps its base: `{"preset":"red"}`,
|
||||
/// `{"system":"windowText","last":"#000000"}`.
|
||||
pub(crate) fn color_param(p: &Value, key: &str) -> Option<deckcraft_model::ColorRef> {
|
||||
let v = p.get(key)?;
|
||||
color_value(v)
|
||||
@@ -393,11 +383,6 @@ pub(crate) fn color_value(v: &Value) -> Option<deckcraft_model::ColorRef> {
|
||||
Value::Object(o) => {
|
||||
let mut c = if let Some(s) = o.get("scheme").and_then(Value::as_str) {
|
||||
ColorRef::scheme(deckcraft_color::SchemeSlot::from_xml(s)?)
|
||||
} else if let Some(name) = o.get("preset").and_then(Value::as_str) {
|
||||
ColorRef { base: deckcraft_model::ColorBase::Preset { name: name.to_string() }, mods: vec![] }
|
||||
} else if let Some(name) = o.get("system").and_then(Value::as_str) {
|
||||
let last = deckcraft_color::Rgba::from_hex(o.get("last").and_then(Value::as_str)?)?;
|
||||
ColorRef { base: deckcraft_model::ColorBase::System { name: name.to_string(), last }, mods: vec![] }
|
||||
} else {
|
||||
ColorRef::rgb(deckcraft_color::Rgba::from_hex(o.get("rgb").and_then(Value::as_str)?)?)
|
||||
};
|
||||
@@ -646,16 +631,4 @@ mod tests {
|
||||
assert!(!needs_arguments(save.params));
|
||||
assert!(needs_arguments(audio.params));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_and_system_colors_keep_their_base() {
|
||||
use deckcraft_color::{ColorTransform, Rgba};
|
||||
use deckcraft_model::{ColorBase, ColorRef};
|
||||
use serde_json::json;
|
||||
let preset = color_value(&json!({"preset": "red", "lumMod": 50_000}));
|
||||
assert_eq!(preset, Some(ColorRef { base: ColorBase::Preset { name: "red".into() }, mods: vec![ColorTransform::LumMod(50_000)] }));
|
||||
let system = color_value(&json!({"system": "windowText", "last": "#123456"}));
|
||||
assert_eq!(system, Some(ColorRef { base: ColorBase::System { name: "windowText".into(), last: Rgba::rgb(0x12, 0x34, 0x56) }, mods: vec![] }));
|
||||
assert_eq!(color_value(&json!({"system": "windowText"})), None, "a system color needs its last value");
|
||||
}
|
||||
}
|
||||
|
||||
+24
-261
@@ -24,7 +24,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
edit_points
|
||||
),
|
||||
cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]], segments: [[straight|curved|null]]} vertices of a freeform and the segment ending at each", has_selection, points),
|
||||
cmd!(query "shape.points", "Points", [], None, "{id?} → {id, paths: [[[x, y] slide pt]], types: [[smooth|straight|corner]]} vertices of a freeform", has_selection, points),
|
||||
cmd!("shape.movePoint", "Move Point", [], None, "{path?: 0, index, x, y: slide pt, id?}", has_selection, move_point),
|
||||
cmd!(
|
||||
"shape.addPoint",
|
||||
@@ -54,42 +54,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
move_handle
|
||||
),
|
||||
cmd!("shape.deletePoint", "Delete Point", [], None, "{path?: 0, index, id?}", has_selection, delete_point),
|
||||
cmd!(
|
||||
"shape.deleteSegment",
|
||||
"Delete Segment",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, segment: index of the vertex ending it, id?}: removes a segment of a closed outline, which opens there; it starts at the segment's end and ends at its start",
|
||||
has_selection,
|
||||
delete_segment
|
||||
),
|
||||
cmd!(
|
||||
"shape.segmentType",
|
||||
"Segment Type",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, segment: index of the vertex ending it, type: straight|curved, id?} — straight drops the segment's handles; curved gives it handles at its thirds",
|
||||
has_selection,
|
||||
segment_type
|
||||
),
|
||||
cmd!(
|
||||
"shape.openPath",
|
||||
"Open Path",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, index, id?} — break a closed outline at a point, which becomes its two ends",
|
||||
has_selection,
|
||||
open_path
|
||||
),
|
||||
cmd!(
|
||||
"shape.closePath",
|
||||
"Close Path",
|
||||
[],
|
||||
None,
|
||||
"{path?: 0, index: any point of the open outline, id?} — join its ends with a straight segment (one point when they meet)",
|
||||
has_selection,
|
||||
close_path
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -317,92 +281,6 @@ fn remove_vertex(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// The sub-path holding segment `si`: from its move up to the next move (or the end).
|
||||
fn subpath(segs: &[Seg], si: usize) -> (usize, usize) {
|
||||
let from = segs.get(..=si).and_then(|s| s.iter().rposition(|x| matches!(x, Seg::M(_)))).unwrap_or(0);
|
||||
let to = (si + 1..segs.len()).find(|j| matches!(segs.get(*j), Some(Seg::M(_)))).unwrap_or(segs.len());
|
||||
(from, to)
|
||||
}
|
||||
|
||||
/// The segment that ends at the vertex ending segment `si`: the closing edge for a closed
|
||||
/// sub-path's start, none for an open one's.
|
||||
fn ending_segment(segs: &[Seg], si: usize) -> Option<usize> {
|
||||
if matches!(segs.get(si), Some(Seg::M(_))) {
|
||||
(si + 1..segs.len()).take_while(|j| !matches!(segs.get(*j), Some(Seg::M(_)))).find(|j| closes_onto_start(segs, *j))
|
||||
} else {
|
||||
Some(si)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether segment `j` is a straight line or a curve.
|
||||
fn segment_kind(segs: &[Seg], j: usize) -> Option<&'static str> {
|
||||
match segs.get(j)? {
|
||||
Seg::L(_) => Some("straight"),
|
||||
Seg::C(..) | Seg::Q(..) => Some("curved"),
|
||||
Seg::M(_) | Seg::Z => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes segment `j` a straight line (dropping its handles) or a curve with handles at its
|
||||
/// thirds, so it keeps its shape until a handle is dragged.
|
||||
fn set_segment(segs: &mut [Seg], j: usize, curved: bool) {
|
||||
if curved {
|
||||
to_cubic(segs, j);
|
||||
} else if let Some(s) = segs.get_mut(j)
|
||||
&& let Seg::C(_, _, p) | Seg::Q(_, p) = *s
|
||||
{
|
||||
*s = Seg::L(p);
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the closed sub-path through the vertex ending segment `si` there: it now starts and ends
|
||||
/// on that vertex, as two points. Returns false for an open sub-path.
|
||||
fn open_at(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
let (from, to) = subpath(segs, si);
|
||||
let Some(z) = to.checked_sub(1).filter(|z| *z > si && matches!(segs.get(*z), Some(Seg::Z))) else { return false };
|
||||
let Some(v) = segs.get(si).and_then(Seg::end) else { return false };
|
||||
let (Some(after), Some(before)) = (segs.get(si + 1..z), segs.get(from + 1..=si)) else { return false };
|
||||
let mut out = vec![Seg::M(v)];
|
||||
out.extend_from_slice(after);
|
||||
out.extend_from_slice(before);
|
||||
segs.splice(from..to, out);
|
||||
true
|
||||
}
|
||||
|
||||
/// Deletes the segment ending at the vertex ending segment `si` from its closed sub-path, which
|
||||
/// opens there: it now starts at the segment's end and ends at its start. Returns false for an
|
||||
/// open sub-path.
|
||||
fn delete_segment_at(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
let (from, _) = subpath(segs, si);
|
||||
if !open_at(segs, si) {
|
||||
return false;
|
||||
}
|
||||
// The deleted segment now ends the sub-path.
|
||||
let (_, to) = subpath(segs, from);
|
||||
if to > from + 1 {
|
||||
segs.remove(to - 1);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Closes the open sub-path through the vertex ending segment `si`: a straight segment joins its
|
||||
/// ends, or they become one point when they meet. Returns false for a closed sub-path.
|
||||
fn close_at(segs: &mut Vec<Seg>, si: usize) -> bool {
|
||||
let (from, to) = subpath(segs, si);
|
||||
let last = to.saturating_sub(1);
|
||||
if last <= from || matches!(segs.get(last), Some(Seg::Z)) {
|
||||
return false;
|
||||
}
|
||||
let (Some(Seg::M(start)), Some(end)) = (segs.get(from).copied(), segs.get(last).and_then(Seg::end)) else { return false };
|
||||
let mut add = Vec::new();
|
||||
if (start - end).hypot() >= 1e-6 {
|
||||
add.push(Seg::L(start));
|
||||
}
|
||||
add.push(Seg::Z);
|
||||
segs.splice(to..to, add);
|
||||
true
|
||||
}
|
||||
|
||||
/// Custom paths of a preset shape, in shape-local points.
|
||||
fn to_custom(sh: &Shape, w: f64, h: f64) -> Vec<CustomPath> {
|
||||
let geo = deckcraft_render::shape_geometry(sh, w, h);
|
||||
@@ -524,7 +402,6 @@ pub(crate) fn prepare(s: &mut Session, id: ShapeId) -> Result<()> {
|
||||
fn edit_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let on = p.get("on").and_then(Value::as_bool).unwrap_or(s.tool.edit_points.is_none());
|
||||
s.tool.edit_vertex = None;
|
||||
s.tool.edit_segment = None;
|
||||
if !on {
|
||||
s.tool.edit_points = None;
|
||||
return Ok(json!({"editing": Value::Null}));
|
||||
@@ -559,16 +436,17 @@ fn points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?;
|
||||
let paths: Vec<Vec<[f64; 2]>> =
|
||||
slide_vertices(&st.doc, &st.selection, sh).into_iter().map(|v| v.into_iter().map(|q| [q.x, q.y]).collect()).collect();
|
||||
let (mut types, mut kinds): (Vec<Vec<&str>>, Vec<Vec<Option<&str>>>) = (vec![], vec![]);
|
||||
if let Geom::Custom { paths } = &sh.geom {
|
||||
for cp in paths {
|
||||
let segs = parse(&cp.d);
|
||||
let vs = vertices(&segs);
|
||||
types.push(vs.iter().map(|si| point_kind(&segs, *si)).collect());
|
||||
kinds.push(vs.iter().map(|si| ending_segment(&segs, *si).and_then(|j| segment_kind(&segs, j))).collect());
|
||||
}
|
||||
}
|
||||
Ok(json!({"id": id.0, "paths": paths, "types": types, "segments": kinds}))
|
||||
let types: Vec<Vec<&str>> = match &sh.geom {
|
||||
Geom::Custom { paths } => paths
|
||||
.iter()
|
||||
.map(|cp| {
|
||||
let segs = parse(&cp.d);
|
||||
vertices(&segs).iter().map(|si| point_kind(&segs, *si)).collect()
|
||||
})
|
||||
.collect(),
|
||||
Geom::Preset { .. } => vec![],
|
||||
};
|
||||
Ok(json!({"id": id.0, "paths": paths, "types": types}))
|
||||
}
|
||||
|
||||
/// Runs `f` on the segments of path `path` of `id` (in shape-local points), then refits the box.
|
||||
@@ -624,26 +502,26 @@ fn add_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let seg = index_param(p, "segment", "shape.addPoint")?;
|
||||
let t = f64_param(p, "t").filter(|t| t.is_finite()).unwrap_or(0.5).clamp(0.01, 0.99);
|
||||
let r = edit_path(s, id, path, "shape.addPoint", move |segs, vs, _| {
|
||||
edit_path(s, id, path, "shape.addPoint", move |segs, vs, _| {
|
||||
let si = *vs.get(seg).ok_or_else(|| bad("shape.addPoint", "no such segment"))?;
|
||||
// The closing edge of a closed outline ends at the start: split it, not the start.
|
||||
let si = ending_segment(segs, si).ok_or_else(|| bad("shape.addPoint", "the first point ends no segment"))?;
|
||||
let si = if matches!(segs.get(si), Some(Seg::M(_))) {
|
||||
(si + 1..segs.len()).find(|j| closes_onto_start(segs, *j)).ok_or_else(|| bad("shape.addPoint", "the first point ends no segment"))?
|
||||
} else {
|
||||
si
|
||||
};
|
||||
if split(segs, si, t) { Ok(true) } else { Err(bad("shape.addPoint", "no segment ends at that point")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
})
|
||||
}
|
||||
|
||||
fn delete_point(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.deletePoint")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.deletePoint")?;
|
||||
let r = edit_path(s, id, path, "shape.deletePoint", move |segs, vs, _| {
|
||||
edit_path(s, id, path, "shape.deletePoint", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.deletePoint", "no such point"))?;
|
||||
if remove_vertex(segs, si) { Ok(true) } else { Err(bad("shape.deletePoint", "an outline keeps at least two points")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
})
|
||||
}
|
||||
|
||||
/// The vertex of `sh` near slide point `p`: (path, index).
|
||||
@@ -690,35 +568,15 @@ pub fn segment_at(doc: &deckcraft_model::Presentation, sel: &crate::Selection, s
|
||||
vs.iter().position(|v| *v == si)
|
||||
};
|
||||
let Some(ev) = end_vertex else { continue };
|
||||
let mut near: Option<(f64, f64)> = None;
|
||||
for k in 1..32 {
|
||||
let t = k as f64 / 32.0;
|
||||
if let Some(q) = at(t) {
|
||||
let d = (q - local).hypot();
|
||||
if d <= tol && near.is_none_or(|n| d < n.0) {
|
||||
near = Some((d, t));
|
||||
if d <= tol && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, pi, ev, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((mut d, mut t)) = near else { continue };
|
||||
// Narrow `t` down to the nearest place on the segment, where Add Point puts the point.
|
||||
let mut step = 1.0 / 32.0;
|
||||
for _ in 0..4 {
|
||||
step /= 8.0;
|
||||
let c = t;
|
||||
for k in -8..=8 {
|
||||
let u = (c + f64::from(k) * step).clamp(0.0, 1.0);
|
||||
if let Some(q) = at(u) {
|
||||
let e = (q - local).hypot();
|
||||
if e < d {
|
||||
(d, t) = (e, u);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, pi, ev, t));
|
||||
}
|
||||
}
|
||||
}
|
||||
best.map(|(_, pi, ev, t)| (pi, ev, t))
|
||||
@@ -882,7 +740,7 @@ fn point_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
_ => return Err(bad("shape.pointType", "`type` is smooth, straight or corner")),
|
||||
};
|
||||
// A straight point's handles can be as long as each other; remember the choice for drags.
|
||||
s.tool.vertex_kind = Some((id, path, index, kind.clone()));
|
||||
s.tool.vertex_kind = Some((path, index, kind.clone()));
|
||||
edit_path(s, id, path, "shape.pointType", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.pointType", "no such point"))?;
|
||||
set_type(segs, si, &kind);
|
||||
@@ -926,98 +784,3 @@ pub fn kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
|
||||
pub(crate) fn move_handle_raw(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
move_handle(s, p)
|
||||
}
|
||||
|
||||
fn segment_type(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.segmentType")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let seg = index_param(p, "segment", "shape.segmentType")?;
|
||||
let curved = match str_param(p, "type") {
|
||||
Some("straight") => false,
|
||||
Some("curved") => true,
|
||||
_ => return Err(bad("shape.segmentType", "`type` is straight or curved")),
|
||||
};
|
||||
// Already that type: nothing to change, so no undo step.
|
||||
let want = if curved { "curved" } else { "straight" };
|
||||
if s.doc()?.shape(id).is_some_and(|sh| segment_kind_of(sh, path, seg) == Some(want)) {
|
||||
return ok();
|
||||
}
|
||||
edit_path(s, id, path, "shape.segmentType", move |segs, vs, _| {
|
||||
let si = *vs.get(seg).ok_or_else(|| bad("shape.segmentType", "no such segment"))?;
|
||||
let j = ending_segment(segs, si).ok_or_else(|| bad("shape.segmentType", "the first point of an open outline ends no segment"))?;
|
||||
set_segment(segs, j, curved);
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn open_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.openPath")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.openPath")?;
|
||||
let r = edit_path(s, id, path, "shape.openPath", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.openPath", "no such point"))?;
|
||||
if open_at(segs, si) { Ok(true) } else { Err(bad("shape.openPath", "the outline is open already")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn close_path(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.closePath")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let index = index_param(p, "index", "shape.closePath")?;
|
||||
let r = edit_path(s, id, path, "shape.closePath", move |segs, vs, _| {
|
||||
let si = *vs.get(index).ok_or_else(|| bad("shape.closePath", "no such point"))?;
|
||||
if close_at(segs, si) { Ok(true) } else { Err(bad("shape.closePath", "the outline is closed already")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
fn delete_segment(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = target(s, p, "shape.deleteSegment")?;
|
||||
let path = p.get("path").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let seg = index_param(p, "segment", "shape.deleteSegment")?;
|
||||
let r = edit_path(s, id, path, "shape.deleteSegment", move |segs, vs, _| {
|
||||
let si = *vs.get(seg).ok_or_else(|| bad("shape.deleteSegment", "no such segment"))?;
|
||||
if ending_segment(segs, si).is_none() {
|
||||
return Err(bad("shape.deleteSegment", "the first point of an open outline ends no segment"));
|
||||
}
|
||||
if delete_segment_at(segs, si) { Ok(true) } else { Err(bad("shape.deleteSegment", "only a segment of a closed outline can be deleted")) }
|
||||
})?;
|
||||
forget_kinds(s, path);
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// The point where the segment ending at vertex `index` of path `path` of `sh` starts.
|
||||
pub fn segment_start_of(sh: &Shape, path: usize, index: usize) -> Option<usize> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let segs = parse(&paths.get(path)?.d);
|
||||
let vs = vertices(&segs);
|
||||
let j = ending_segment(&segs, *vs.get(index)?)?;
|
||||
vs.iter().position(|v| Some(*v) == j.checked_sub(1))
|
||||
}
|
||||
|
||||
/// Adding, deleting, opening or closing renumbers the points of `path`: a point type remembered
|
||||
/// for one of them would follow the wrong point.
|
||||
fn forget_kinds(s: &mut Session, path: usize) {
|
||||
if s.tool.vertex_kind.as_ref().is_some_and(|k| k.1 == path) {
|
||||
s.tool.vertex_kind = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the segment ending at vertex `index` of path `path` of `sh` is straight or curved.
|
||||
pub fn segment_kind_of(sh: &Shape, path: usize, index: usize) -> Option<&'static str> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let segs = parse(&paths.get(path)?.d);
|
||||
let si = *vertices(&segs).get(index)?;
|
||||
segment_kind(&segs, ending_segment(&segs, si)?)
|
||||
}
|
||||
|
||||
/// Whether the outline through vertex `index` of path `path` of `sh` is closed.
|
||||
pub fn closed_at(sh: &Shape, path: usize, index: usize) -> Option<bool> {
|
||||
let Geom::Custom { paths } = &sh.geom else { return None };
|
||||
let segs = parse(&paths.get(path)?.d);
|
||||
let si = *vertices(&segs).get(index)?;
|
||||
let (_, to) = subpath(&segs, si);
|
||||
Some(matches!(segs.get(to.checked_sub(1)?), Some(Seg::Z)))
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
//! Picture Format ▸ Color: Recolor presets and Set Transparent Color.
|
||||
|
||||
use deckcraft_color::{Rgba, SchemeSlot};
|
||||
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
|
||||
use deckcraft_model::style::{PictureAdjust, PictureMode};
|
||||
use deckcraft_model::{ColorRef, Shape, ShapeId, ShapeKind};
|
||||
use deckcraft_model::style::PictureAdjust;
|
||||
use deckcraft_model::{ColorRef, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -28,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Set Transparent Color",
|
||||
["Picture Format", "Adjust", "Color"],
|
||||
None,
|
||||
"{color: #RRGGBB | at: [x, y] | none: true, ids?} (pixels of that colour become transparent; `at` takes the colour of the picture's own pixel under that slide point, as a click does in PowerPoint)",
|
||||
"{color: #RRGGBB | none: true, ids?} (pixels of that colour become transparent)",
|
||||
has_selection,
|
||||
transparent_color
|
||||
),
|
||||
@@ -122,15 +121,6 @@ fn recolor(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(at) = p.get("at") {
|
||||
let (id, c) = picture_pixel_at(s, p, at)?;
|
||||
return edit_shapes(s, &json!({"ids": [id]}), "picture.transparentColor", |sh| {
|
||||
if let ShapeKind::Picture { fill } = &mut sh.kind {
|
||||
fill.adjust.clear_color = Some(c);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
let c = if bool_or(p, "none", false) {
|
||||
None
|
||||
} else {
|
||||
@@ -144,97 +134,3 @@ fn transparent_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// The topmost target picture under slide point `at` and the colour of its own pixel there: the
|
||||
/// picture as stored, before corrections and recolouring, so the colour matches the pixels that
|
||||
/// `clear_color` is compared with.
|
||||
fn picture_pixel_at(s: &Session, p: &Value, at: &Value) -> Result<(ShapeId, Rgba)> {
|
||||
const CMD: &str = "picture.transparentColor";
|
||||
let xy = |i: usize| at.get(i).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
let (Some(x), Some(y)) = (xy(0), xy(1)) else {
|
||||
return Err(bad(CMD, "`at` is [x, y] in slide units"));
|
||||
};
|
||||
let mut ids = targets(s, p)?;
|
||||
let st = s.doc()?;
|
||||
let shapes = current_shapes(&st.doc, &st.selection).ok_or_else(|| bad(CMD, "no slide"))?;
|
||||
// Same order as the hit-test: later in the shape list is in front, and a group's children sit
|
||||
// where the group is. The selection order says nothing about what is on top.
|
||||
ids.sort_by_cached_key(|id| std::cmp::Reverse(stack_path(shapes, *id)));
|
||||
for id in ids {
|
||||
let Some((sh, xf, to_slide)) = placed(&st.doc, &st.selection, shapes, id, Affine::IDENTITY, 0) else { continue };
|
||||
let ShapeKind::Picture { fill } = &sh.kind else { continue };
|
||||
// The camera turns the picture where it is drawn, so the click is mapped back through it too.
|
||||
let turn = text::ctx_for(&st.doc, &st.selection).and_then(|c| deckcraft_render::camera_turn(&c, sh, xf.w, xf.h)).unwrap_or(Affine::IDENTITY);
|
||||
let local = (to_slide * turn).inverse() * Point::new(x, y);
|
||||
if !(local.is_finite() && (0.0..=xf.w).contains(&local.x) && (0.0..=xf.h).contains(&local.y)) {
|
||||
continue;
|
||||
}
|
||||
let PictureMode::Stretch { fill_rect: [l, t, r, b] } = fill.mode else {
|
||||
return Err(bad(CMD, "a tiled picture has no single pixel under a point; give `color`"));
|
||||
};
|
||||
let inner = Rect::new(l * xf.w, t * xf.h, xf.w - r * xf.w, xf.h - b * xf.h);
|
||||
let dest = deckcraft_render::crop_dest(inner, fill.crop);
|
||||
let img =
|
||||
st.doc.media(fill.media).and_then(|m| deckcraft_render::decode_image(&m.data)).ok_or_else(|| bad(CMD, "the picture can't be read"))?;
|
||||
let (u, v) = ((local.x - dest.x0) / dest.width(), (local.y - dest.y0) / dest.height());
|
||||
if !((0.0..=1.0).contains(&u) && (0.0..=1.0).contains(&v)) {
|
||||
continue;
|
||||
}
|
||||
let (w, h) = (img.width(), img.height());
|
||||
let px = ((u * f64::from(w)) as u32).min(w.saturating_sub(1));
|
||||
let py = ((v * f64::from(h)) as u32).min(h.saturating_sub(1));
|
||||
let Some(c) = img.get_pixel_checked(px, py) else { continue };
|
||||
// A transparent pixel keeps a colour nobody sees (often black): clearing it would clear
|
||||
// that colour where it is drawn.
|
||||
if c[3] == 0 {
|
||||
return Err(bad(CMD, "that spot of the picture is already transparent"));
|
||||
}
|
||||
return Ok((id, Rgba::rgb(c[0], c[1], c[2])));
|
||||
}
|
||||
Err(bad(CMD, "the point is not on a selected picture"))
|
||||
}
|
||||
|
||||
/// Where `id` sits in the paint order: its index in each list from the slide down to it. A larger
|
||||
/// path is drawn later, so in front.
|
||||
fn stack_path(shapes: &[Shape], id: ShapeId) -> Option<Vec<usize>> {
|
||||
for (i, sh) in shapes.iter().enumerate() {
|
||||
if sh.id == id {
|
||||
return Some(vec![i]);
|
||||
}
|
||||
if let ShapeKind::Group { children, .. } = &sh.kind
|
||||
&& let Some(mut rest) = stack_path(children, id)
|
||||
{
|
||||
rest.insert(0, i);
|
||||
return Some(rest);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Shape `id` among `shapes` (searched into groups), its box, and the map from that box to slide
|
||||
/// space.
|
||||
fn placed<'a>(
|
||||
doc: &deckcraft_model::Presentation,
|
||||
sel: &crate::Selection,
|
||||
shapes: &'a [Shape],
|
||||
id: ShapeId,
|
||||
to_slide: Affine,
|
||||
depth: usize,
|
||||
) -> Option<(&'a Shape, Xfrm, Affine)> {
|
||||
if depth > 64 {
|
||||
return None;
|
||||
}
|
||||
for sh in shapes {
|
||||
let x = xfrm_of(doc, sel, sh);
|
||||
if sh.id == id {
|
||||
return Some((sh, x, to_slide * x.affine()));
|
||||
}
|
||||
if let ShapeKind::Group { children, child } = &sh.kind {
|
||||
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
|
||||
if let Some(found) = placed(doc, sel, children, id, to_slide * deckcraft_geom::group_child_affine(&x, ch), depth + 1) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -10,41 +10,22 @@ use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session, media_refs};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "slide.reuseList",
|
||||
"List Slides to Reuse",
|
||||
[],
|
||||
None,
|
||||
"{path | data: base64, name?} → {name, slides: [{index, title, hidden}]}: the slides of another presentation, for choosing which ones `slide.reuse` brings in",
|
||||
has_doc,
|
||||
reuse_list
|
||||
),
|
||||
cmd!(
|
||||
"slide.reuse",
|
||||
"Reuse Slides…",
|
||||
["Home", "Slides", "New Slide"],
|
||||
None,
|
||||
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
|
||||
has_doc,
|
||||
reuse
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn reuse_list(_s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (name, bytes) = super::insert::media_bytes(p, "slide.reuseList")?;
|
||||
let src = super::file::open_presentation(&name, &bytes)?;
|
||||
let slides: Vec<Value> =
|
||||
src.slides.iter().enumerate().map(|(index, sl)| json!({"index": index, "title": sl.title(), "hidden": sl.hidden})).collect();
|
||||
Ok(json!({"name": name, "slides": slides}))
|
||||
vec![cmd!(
|
||||
"slide.reuse",
|
||||
"Reuse Slides…",
|
||||
["Home", "Slides", "New Slide"],
|
||||
None,
|
||||
"{path? | data: base64, name?, slides?: [index] (0-based, default all), keepFormatting?: bool (default false: use the destination theme), at?: index (default after the current slide)}",
|
||||
has_doc,
|
||||
reuse
|
||||
)]
|
||||
}
|
||||
|
||||
/// Point every media id under `x` at its copy in the destination (`map`: source → destination).
|
||||
@@ -133,9 +114,9 @@ fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap<SlideId, S
|
||||
}
|
||||
|
||||
/// Fresh shape ids for `shapes`, and the old → new map.
|
||||
fn renumber(cmd: &str, doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
let old = shape_ids(shapes);
|
||||
super::edit::reid(cmd, doc, shapes)?;
|
||||
super::edit::reid("slide.reuse", doc, shapes)?;
|
||||
let map: HashMap<ShapeId, ShapeId> = old.into_iter().zip(shape_ids(shapes)).collect();
|
||||
remap_connectors(shapes, &map, 0);
|
||||
Ok(map)
|
||||
@@ -190,7 +171,19 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
masters_done.push(m.id);
|
||||
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
|
||||
let mut used = vec![];
|
||||
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
|
||||
copy_media(doc, &src, &used, &mut media_map)?;
|
||||
let mut m = m.clone();
|
||||
media_refs::visit_mut::master(&mut m, &mut remap(&media_map));
|
||||
m.id = MasterId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
renumber(doc, &mut m.shapes)?;
|
||||
for l in &mut m.layouts {
|
||||
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
layout_map.insert(l.id, new);
|
||||
l.id = new;
|
||||
renumber(doc, &mut l.shapes)?;
|
||||
}
|
||||
doc.masters.push(Arc::new(m));
|
||||
}
|
||||
}
|
||||
@@ -199,11 +192,11 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut slide_map: HashMap<SlideId, SlideId> = HashMap::new();
|
||||
for &i in &picked {
|
||||
let Some(from) = src.slides.get(i) else { continue };
|
||||
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
let mut sl: Slide = from.as_ref().clone();
|
||||
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
|
||||
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
|
||||
let map = renumber(doc, &mut sl.shapes)?;
|
||||
for a in &mut sl.animations {
|
||||
a.shape = map.get(&a.shape).copied().unwrap_or(a.shape);
|
||||
a.trigger = a.trigger.map(|t| map.get(&t).copied().unwrap_or(t));
|
||||
@@ -234,35 +227,9 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A copy of `src`'s master `m` ready to join `doc`: its media copied in, fresh master, layout and
|
||||
/// shape ids. `layouts` gains each source layout id → its copy's.
|
||||
pub(crate) fn import_master(
|
||||
cmd: &str,
|
||||
doc: &mut Presentation,
|
||||
src: &Presentation,
|
||||
m: &Master,
|
||||
media_map: &mut HashMap<MediaId, MediaId>,
|
||||
layouts: &mut HashMap<LayoutId, LayoutId>,
|
||||
) -> Result<Master> {
|
||||
let mut used = vec![];
|
||||
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
|
||||
copy_media(cmd, doc, src, &used, media_map)?;
|
||||
let mut m = m.clone();
|
||||
media_refs::visit_mut::master(&mut m, &mut remap(media_map));
|
||||
m.id = MasterId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
renumber(cmd, doc, &mut m.shapes)?;
|
||||
for l in &mut m.layouts {
|
||||
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
layouts.insert(l.id, new);
|
||||
l.id = new;
|
||||
renumber(cmd, doc, &mut l.shapes)?;
|
||||
}
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Copy the source media `used` into `doc` under fresh ids, once each (identical bytes already in
|
||||
/// `doc` are shared; a linked item keeps its link).
|
||||
fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
|
||||
fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
|
||||
for &id in used {
|
||||
if map.contains_key(&id) {
|
||||
continue;
|
||||
@@ -270,12 +237,12 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med
|
||||
let Some(m) = src.media(id) else { continue };
|
||||
let new = if m.link.is_some() {
|
||||
let mut item = m.clone();
|
||||
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
|
||||
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
|
||||
let new = item.id;
|
||||
doc.media.push(item);
|
||||
new
|
||||
} else {
|
||||
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))?
|
||||
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?
|
||||
};
|
||||
map.insert(id, new);
|
||||
}
|
||||
@@ -430,34 +397,4 @@ mod tests {
|
||||
assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link");
|
||||
assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too");
|
||||
}
|
||||
|
||||
/// The list that lets a dialog offer the slides of the other file; picking from it brings in
|
||||
/// exactly those.
|
||||
#[test]
|
||||
fn reuse_list_names_the_slides_and_the_picked_ones_come_in() {
|
||||
let mut src = Session::with_new();
|
||||
for t in ["Alpha", "Beta", "Gamma"] {
|
||||
src.execute("slide.new", &json!({"layout": "titleAndContent", "title": t})).expect("new");
|
||||
}
|
||||
let last_slide = src.doc().expect("doc").doc.slides.len() - 1;
|
||||
src.execute("slide.hide", &json!({"index": last_slide})).expect("hide");
|
||||
let bytes = crate::cmd::file::save_bytes(&src.doc().expect("doc").doc, "deckcraft").expect("save");
|
||||
let data = super::super::base64_encode(&bytes);
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let listed = s.execute("slide.reuseList", &json!({"data": data, "name": "src.deckcraft"})).expect("list");
|
||||
let slides = listed["slides"].as_array().expect("slides");
|
||||
let title = |i: usize| slides.iter().find(|x| x["index"] == i).and_then(|x| x["title"].as_str()).unwrap_or_default().to_string();
|
||||
assert!(slides.len() >= 3);
|
||||
let (a, b, g) = (title(slides.len() - 3), title(slides.len() - 2), title(slides.len() - 1));
|
||||
assert_eq!((a.as_str(), b.as_str(), g.as_str()), ("Alpha", "Beta", "Gamma"));
|
||||
assert_eq!(slides.last().map(|x| x["hidden"].clone()), Some(json!(true)));
|
||||
assert_eq!(s.doc().expect("doc").doc.slides.len(), 1, "listing changes nothing");
|
||||
|
||||
let last = slides.len() - 1;
|
||||
s.execute("slide.reuse", &json!({"data": data, "name": "src.deckcraft", "slides": [last - 2, last]})).expect("reuse");
|
||||
let titles: Vec<String> = s.doc().expect("doc").doc.slides.iter().map(|x| x.title()).collect();
|
||||
assert!(titles.iter().any(|t| t == "Alpha") && titles.iter().any(|t| t == "Gamma"));
|
||||
assert!(!titles.iter().any(|t| t == "Beta"), "a slide that was not picked stays out");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,15 +59,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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",
|
||||
["Review", "Compare"],
|
||||
None,
|
||||
"{index} | {all: true} → {rejected} (drop revisions without applying them: the open deck stays as it is; not an undo step)",
|
||||
comparing,
|
||||
reject
|
||||
),
|
||||
cmd!(noundo "review.endCompare", "End Review", ["Review", "Compare"], None, "{}", comparing, end_compare),
|
||||
cmd!(noundo "show.fromStart", "Play from Start", ["Slide Show", "Start Slide Show"], Some("Cmd+Shift+Enter"), "{}", has_slide, |s, _| start(s, 0)),
|
||||
cmd!(noundo "show.fromCurrent", "Play from Current Slide", ["Slide Show", "Start Slide Show"], Some("Cmd+Enter"), "{}", has_slide, |s, _| {
|
||||
@@ -664,7 +655,7 @@ fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
|
||||
fn revisions_json(st: &crate::DocState) -> Result<Value> {
|
||||
let c = st.compare.as_ref().ok_or_else(|| bad("review.revisions", "not comparing"))?;
|
||||
let name = std::path::Path::new(&c.name).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| c.name.clone());
|
||||
Ok(json!({"name": name, "changes": crate::compare::pending(&st.doc, c), "byPosition": c.by_position}))
|
||||
Ok(json!({"name": name, "changes": crate::compare::diff(&st.doc, &c.revised, c.by_position), "byPosition": c.by_position}))
|
||||
}
|
||||
|
||||
fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -672,7 +663,7 @@ fn compare(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let revised = super::file::open_presentation(&name, &bytes)?;
|
||||
let st = s.doc_mut()?;
|
||||
let by_position = crate::compare::unrelated(&st.doc, &revised);
|
||||
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position, rejected: Default::default() });
|
||||
st.compare = Some(crate::compare::Compare { name, revised: Arc::new(revised), by_position });
|
||||
s.ui_requests.push(UiRequest::Pane { id: "revisions".into() });
|
||||
revisions_json(s.doc()?)
|
||||
}
|
||||
@@ -682,8 +673,7 @@ fn revisions(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmp = s.doc()?.compare.clone().ok_or_else(|| bad("review.accept", "not comparing"))?;
|
||||
let rev = cmp.revised.clone();
|
||||
let (rev, by_pos) = s.doc()?.compare.as_ref().map(|c| (c.revised.clone(), c.by_position)).ok_or_else(|| bad("review.accept", "not comparing"))?;
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let index = usize_param(p, "index");
|
||||
if !all && index.is_none() {
|
||||
@@ -694,7 +684,7 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Each accept shifts indices, so the list is recomputed; bounded by the slide counts.
|
||||
let limit = doc.slides.len().saturating_add(rev.slides.len()).saturating_add(1);
|
||||
for _ in 0..limit {
|
||||
let changes = crate::compare::pending(doc, &cmp);
|
||||
let changes = crate::compare::diff(doc, &rev, by_pos);
|
||||
let c = if all {
|
||||
changes.into_iter().find(|c| !(c.kind == crate::compare::ChangeKind::Deleted && doc.slides.len() <= 1))
|
||||
} else {
|
||||
@@ -717,24 +707,6 @@ fn accept(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
fn reject(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let all = bool_param(p, "all").unwrap_or(false);
|
||||
let index = usize_param(p, "index");
|
||||
if !all && index.is_none() {
|
||||
return Err(bad("review.reject", "give `index` or `all`"));
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
let doc = st.doc.clone();
|
||||
let c = st.compare.as_mut().ok_or_else(|| bad("review.reject", "not comparing"))?;
|
||||
let pending = crate::compare::pending(&doc, c);
|
||||
let picked: Vec<_> = if all { pending } else { pending.into_iter().nth(index.unwrap_or(0)).into_iter().collect() };
|
||||
if picked.is_empty() {
|
||||
return Err(bad("review.reject", "no such revision"));
|
||||
}
|
||||
c.rejected.extend(picked.iter().map(|ch| crate::compare::ChangeKey::of(&doc, ch)));
|
||||
Ok(json!({"rejected": picked.len()}))
|
||||
}
|
||||
|
||||
fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
s.doc_mut()?.compare = None;
|
||||
ok()
|
||||
|
||||
@@ -107,16 +107,14 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
}
|
||||
if closing {
|
||||
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 !kind.is_empty()
|
||||
&& let Some(pos) = stack.iter().rposition(|e| e.0 == kind)
|
||||
{
|
||||
let (_, db, di, du) = stack[pos];
|
||||
bold -= db;
|
||||
italic -= di;
|
||||
underline -= du;
|
||||
stack.truncate(pos);
|
||||
}
|
||||
if is_block(&name) {
|
||||
new_para(&mut paragraphs);
|
||||
@@ -132,7 +130,7 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
bold += db;
|
||||
italic += di;
|
||||
underline += du;
|
||||
if !token.ends_with('/') && stack.len() < 128 {
|
||||
if !token.ends_with('/') {
|
||||
stack.push((tag_kind(&name), db, di, du));
|
||||
} else {
|
||||
bold -= db;
|
||||
@@ -147,13 +145,8 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
continue;
|
||||
}
|
||||
let end = bytes[i..].iter().position(|c| *c == b'<').map(|n| i + n).unwrap_or(bytes.len());
|
||||
let mut text = decode_text(&html[i..end]);
|
||||
let text = decode_text(&html[i..end]);
|
||||
i = end;
|
||||
// A space at the start of a paragraph, or after a space, does not render in HTML.
|
||||
let after_space = paragraphs.last().and_then(|p| p.runs.last()).is_none_or(|r| r.text.ends_with(' '));
|
||||
if after_space {
|
||||
text = text.trim_start_matches(' ').to_string();
|
||||
}
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -162,7 +155,6 @@ pub fn html_to_body(html: &str) -> Option<TextBody> {
|
||||
while paragraphs.last().is_some_and(Paragraph::is_empty) && paragraphs.len() > 1 {
|
||||
paragraphs.pop();
|
||||
}
|
||||
trim_end_space(&mut paragraphs);
|
||||
if paragraphs.iter().all(Paragraph::is_empty) {
|
||||
return None;
|
||||
}
|
||||
@@ -248,19 +240,10 @@ fn new_para(paragraphs: &mut Vec<Paragraph>) {
|
||||
return;
|
||||
}
|
||||
if paragraphs.last().is_some_and(|p| !p.is_empty()) {
|
||||
trim_end_space(paragraphs);
|
||||
paragraphs.push(Paragraph::default());
|
||||
}
|
||||
}
|
||||
|
||||
/// Whitespace at the end of a paragraph does not render in HTML.
|
||||
fn trim_end_space(paragraphs: &mut [Paragraph]) {
|
||||
if let Some(run) = paragraphs.last_mut().and_then(|p| p.runs.last_mut()) {
|
||||
let len = run.text.trim_end_matches(' ').len();
|
||||
run.text.truncate(len);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold: bool, italic: bool, underline: bool) {
|
||||
if *runs >= MAX_RUNS {
|
||||
return;
|
||||
@@ -280,35 +263,15 @@ fn push_run(paragraphs: &mut [Paragraph], runs: &mut usize, text: String, bold:
|
||||
*runs += 1;
|
||||
}
|
||||
|
||||
/// Text with each run of HTML whitespace (spaces, tabs, line breaks) collapsed to one space.
|
||||
fn collapse_space(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut space = false;
|
||||
for c in s.chars() {
|
||||
if matches!(c, ' ' | '\t' | '\n' | '\r') {
|
||||
if !space {
|
||||
out.push(' ');
|
||||
}
|
||||
space = true;
|
||||
} else {
|
||||
out.push(c);
|
||||
space = false;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_text(raw: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = raw;
|
||||
while let Some(at) = rest.find('&') {
|
||||
out.push_str(&collapse_space(&rest[..at]));
|
||||
out.push_str(&rest[..at].replace(['\n', '\r'], ""));
|
||||
rest = &rest[at..];
|
||||
let search_window = rest.len().min(32);
|
||||
let Some(end) = rest[..search_window].find(';') else {
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
continue;
|
||||
let Some(end) = rest.find(';') else {
|
||||
out.push_str(&rest.replace(['\n', '\r'], ""));
|
||||
break;
|
||||
};
|
||||
let entity = &rest[1..end];
|
||||
let decoded = match entity {
|
||||
@@ -317,7 +280,7 @@ fn decode_text(raw: &str) -> String {
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"nbsp" => Some('\u{a0}'),
|
||||
"nbsp" => Some(' '),
|
||||
_ => entity.strip_prefix('#').and_then(numeric_char),
|
||||
};
|
||||
if let Some(c) = decoded {
|
||||
@@ -330,19 +293,14 @@ fn decode_text(raw: &str) -> String {
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() && !rest.starts_with('&') {
|
||||
out.push_str(&collapse_space(rest));
|
||||
out.push_str(&rest.replace(['\n', '\r'], ""));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn numeric_char(digits: &str) -> Option<char> {
|
||||
let hex = digits.strip_prefix('x').or_else(|| digits.strip_prefix('X'));
|
||||
// `parse` and `from_str_radix` accept a leading '+', which is not a valid reference.
|
||||
let (body, radix) = hex.map_or((digits, 10), |h| (h, 16));
|
||||
if body.is_empty() || !body.chars().all(|c| c.is_digit(radix)) {
|
||||
return None;
|
||||
}
|
||||
let n = u32::from_str_radix(body, radix).ok()?;
|
||||
let n = if let Some(hex) = hex { u32::from_str_radix(hex, 16).ok()? } else { digits.parse().ok()? };
|
||||
char::from_u32(n).filter(|c| !c.is_control() || *c == '\n')
|
||||
}
|
||||
|
||||
@@ -361,13 +319,6 @@ fn fold(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn signed_numeric_entities_are_kept_as_typed() {
|
||||
assert_eq!(decode_text("a&#+65;b"), "a&#+65;b");
|
||||
assert_eq!(decode_text("a&#x+41;b"), "a&#x+41;b");
|
||||
assert_eq!(decode_text("aAbA"), "aAbA");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn google_docs_normal_weight_wrapper_is_not_bold() {
|
||||
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
|
||||
@@ -407,10 +358,6 @@ mod tests {
|
||||
assert_eq!(text("<p>R&D costs; up</p>"), "R&D costs; up");
|
||||
assert_eq!(text("<p>AT&T; Q&A</p>"), "AT&T; Q&A");
|
||||
assert_eq!(text("<p>A & B & C & D</p>"), "A & B & C & D");
|
||||
|
||||
// Verify many bare ampersands do not cause quadratic scan freeze
|
||||
let hostile = format!("<p>{}</p>", "&".repeat(2000));
|
||||
assert_eq!(text(&hostile), "&".repeat(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -424,18 +371,6 @@ mod tests {
|
||||
assert!(!same_words(&body.text(), "other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newline_in_html_text_is_a_space() {
|
||||
assert_eq!(decode_text("Hello\nworld"), "Hello world");
|
||||
let body = html_to_body("<p>Hello\r\n world</p>").unwrap();
|
||||
assert_eq!(body.text(), "Hello world");
|
||||
let body = html_to_body("<p>\n<b>Hello</b>\n<i>world</i>\n</p>\n<p>next</p>").unwrap();
|
||||
assert_eq!(body.text(), "Hello world\nnext");
|
||||
assert!(same_words(&body.text(), "Hello world\nnext"));
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(body.paragraphs[0].runs.last().unwrap().props.italic, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_huge_fragment_is_refused() {
|
||||
let html = "x".repeat(MAX_HTML + 1);
|
||||
|
||||
+24
-132
@@ -1,8 +1,6 @@
|
||||
//! Shape Format: position and size, rotation, flips, geometry, fill, outline, effects, styles,
|
||||
//! names and alt text.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
|
||||
use deckcraft_model::style::{Dash, Glow, Gradient, GradientShape, GradientStop, LineEnd, PatternFill, PictureFill, PictureMode, Reflection, Shadow};
|
||||
use deckcraft_model::{ColorRef, Effects, Fill, Geom, Line, ShapeStyle};
|
||||
@@ -87,7 +85,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Fill",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{color? | none?: true | gradient?: {stops: [[pos, color]], angle?, kind?: linear|radial} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; 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",
|
||||
has_selection,
|
||||
fill
|
||||
),
|
||||
@@ -96,7 +94,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Shape Outline",
|
||||
["Shape Format", "Shape Styles"],
|
||||
None,
|
||||
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}; a group also formats its members",
|
||||
"{color?, none?, width?: pt, dash?: solid|dot|dash|lgDash|dashDot|sysDot|sysDash, cap?: flat|round|square, join?: round|bevel|miter, compound?: single|double|thickThin|thinThick|triple, head?: none|triangle|stealth|diamond|oval|arrow, tail?, headSize?: sm|med|lg, tailSize?, reset?, ids?}",
|
||||
has_selection,
|
||||
line
|
||||
),
|
||||
@@ -535,24 +533,6 @@ pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
|
||||
Some(Gradient { tile: [0.0; 4], stops, shape, rotate_with_shape: v.get("rotateWithShape").and_then(Value::as_bool).unwrap_or(true) })
|
||||
}
|
||||
|
||||
/// Fill and outline apply to a group's visible members as well as its container. Other edits
|
||||
/// (notably geometry) still target only the selected shape. The whole traversal stays in one edit.
|
||||
fn edit_group_appearance(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
fn apply(sh: &mut deckcraft_model::Shape, depth: usize, f: &dyn Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<()> {
|
||||
if depth > 64 {
|
||||
return Ok(());
|
||||
}
|
||||
f(sh)?;
|
||||
if let Some(children) = sh.children_mut() {
|
||||
for child in children {
|
||||
apply(child, depth + 1, f)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
edit_shapes(s, p, cmd, |sh| apply(sh, 0, &f))
|
||||
}
|
||||
|
||||
fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let new_fill: Option<Option<Fill>> = if bool_or(p, "reset", false) {
|
||||
Some(None)
|
||||
@@ -585,26 +565,27 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Transparency on an inherited (style) fill needs the effective colour.
|
||||
let st = s.doc()?;
|
||||
let ctx_fill = {
|
||||
let ctx_fill: Vec<(deckcraft_model::ShapeId, Option<Fill>)> = {
|
||||
let ctx = super::text::ctx_for(&st.doc, &st.selection);
|
||||
let mut fills = HashMap::new();
|
||||
for id in targets(s, p)? {
|
||||
if let Some(sh) = st.shape(id) {
|
||||
deckcraft_model::walk(std::slice::from_ref(sh), &mut |sh, _| {
|
||||
let fill = ctx.as_ref().and_then(|c| {
|
||||
let (f, ph) = deckcraft_model::resolve::fill(c, sh);
|
||||
f.map(|f| match f {
|
||||
Fill::Solid { color } => Fill::solid(ColorRef::rgb(c.color(&color, ph))),
|
||||
other => other,
|
||||
})
|
||||
});
|
||||
fills.insert(sh.id, fill);
|
||||
});
|
||||
}
|
||||
}
|
||||
fills
|
||||
targets(s, p)?
|
||||
.into_iter()
|
||||
.filter_map(|id| {
|
||||
st.shape(id).map(|sh| {
|
||||
(
|
||||
id,
|
||||
ctx.as_ref().and_then(|c| {
|
||||
let (f, ph) = deckcraft_model::resolve::fill(c, sh);
|
||||
f.map(|f| match f {
|
||||
Fill::Solid { color } => Fill::solid(ColorRef::rgb(c.color(&color, ph))),
|
||||
other => other,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
edit_group_appearance(s, p, "shape.fill", |sh| {
|
||||
edit_shapes(s, p, "shape.fill", |sh| {
|
||||
if let Some(f) = &new_fill {
|
||||
sh.fill.clone_from(f);
|
||||
}
|
||||
@@ -617,7 +598,7 @@ fn fill(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(t) = transparency {
|
||||
let base = sh.fill.clone().or_else(|| ctx_fill.get(&sh.id).cloned().flatten());
|
||||
let base = sh.fill.clone().or_else(|| ctx_fill.iter().find(|(i, _)| *i == sh.id).and_then(|(_, f)| f.clone()));
|
||||
if let Some(Fill::Solid { mut color }) = base {
|
||||
color.mods.retain(|m| !matches!(m, ColorTransform::Alpha(_)));
|
||||
if t > 0.0 {
|
||||
@@ -709,7 +690,7 @@ fn line(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"miter" => deckcraft_model::style::LineJoin::Miter,
|
||||
_ => deckcraft_model::style::LineJoin::Round,
|
||||
});
|
||||
edit_group_appearance(s, p, "shape.line", |sh| {
|
||||
edit_shapes(s, p, "shape.line", |sh| {
|
||||
if reset {
|
||||
sh.line = None;
|
||||
return Ok(());
|
||||
@@ -1522,95 +1503,6 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn nested_group() -> (Session, Vec<ShapeId>, ShapeId, ShapeId) {
|
||||
let mut s = Session::with_new();
|
||||
let mut ids = vec![];
|
||||
for i in 0..4 {
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [40 + i * 100, 100, 80, 60]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("shape.line", &json!({"width": i + 1, "dash": "dash"})).unwrap();
|
||||
ids.push(ShapeId(id as u32));
|
||||
}
|
||||
let group = |s: &mut Session, ids: &[ShapeId]| {
|
||||
s.execute("edit.select", &json!({"ids": ids})).unwrap();
|
||||
ShapeId(s.execute("arrange.group", &json!({})).unwrap()["id"].as_u64().unwrap() as u32)
|
||||
};
|
||||
let inner = group(&mut s, &ids[..2]);
|
||||
let outer = group(&mut s, &[inner, ids[2]]);
|
||||
(s, ids[..3].to_vec(), outer, ids[3])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_fill_reaches_nested_members_in_one_undo_step() {
|
||||
let (mut s, members, group, outside) = nested_group();
|
||||
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||||
let untouched = serde_json::to_value(s.doc().unwrap().shape(outside)).unwrap();
|
||||
s.execute("shape.fill", &json!({"color": "#ff0000"})).unwrap();
|
||||
for id in &members {
|
||||
assert_eq!(s.doc().unwrap().shape(*id).unwrap().fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(255, 0, 0)))));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, vec![group]);
|
||||
assert_eq!(serde_json::to_value(s.doc().unwrap().shape(outside)).unwrap(), untouched);
|
||||
let filled = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), filled);
|
||||
|
||||
s.execute("shape.fill", &json!({"none": true})).unwrap();
|
||||
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().fill == Some(Fill::None)));
|
||||
s.execute("shape.fill", &json!({"reset": true})).unwrap();
|
||||
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().fill.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_outline_reaches_members_without_resetting_their_other_properties() {
|
||||
let (mut s, members, group, outside) = nested_group();
|
||||
let before = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||||
// Explicit ids work even when another object is selected.
|
||||
s.execute("edit.select", &json!({"ids": [outside]})).unwrap();
|
||||
s.execute("shape.line", &json!({"ids": [group], "color": "#00ff00"})).unwrap();
|
||||
for (i, id) in members.iter().enumerate() {
|
||||
let line = s.doc().unwrap().shape(*id).unwrap().line.as_ref().unwrap();
|
||||
assert_eq!(line.fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(0, 255, 0)))));
|
||||
assert_eq!(line.width, Some((i + 1) as f64));
|
||||
assert_eq!(line.dash, Some(Dash::Preset { name: "dash".into() }));
|
||||
}
|
||||
assert_eq!(s.doc().unwrap().selection.shapes, vec![outside]);
|
||||
let outlined = serde_json::to_value(&s.doc().unwrap().doc).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&s.doc().unwrap().doc).unwrap(), outlined);
|
||||
s.execute("shape.line", &json!({"ids": [group], "none": true})).unwrap();
|
||||
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.as_ref().unwrap().fill == Some(Fill::None)));
|
||||
s.execute("shape.line", &json!({"ids": [group], "reset": true})).unwrap();
|
||||
assert!(members.iter().all(|id| s.doc().unwrap().shape(*id).unwrap().line.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_fill_transparency_keeps_each_members_inherited_colour() {
|
||||
let (mut s, members, group, _) = nested_group();
|
||||
s.execute("shape.fill", &json!({"ids": [members[1]], "color": "#00ff00"})).unwrap();
|
||||
let colours: Vec<_> = {
|
||||
let st = s.doc().unwrap();
|
||||
let ctx = super::super::text::ctx_for(&st.doc, &st.selection).unwrap();
|
||||
members
|
||||
.iter()
|
||||
.map(|id| {
|
||||
let (fill, ph) = deckcraft_model::resolve::fill(&ctx, st.shape(*id).unwrap());
|
||||
let Some(Fill::Solid { color }) = fill else { panic!("solid theme fill") };
|
||||
ctx.color(&color, ph)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
s.execute("shape.fill", &json!({"ids": [group], "transparency": 0.5})).unwrap();
|
||||
for (id, colour) in members.iter().zip(colours) {
|
||||
let mut expected = ColorRef::rgb(colour);
|
||||
expected.mods.push(ColorTransform::Alpha(50000));
|
||||
assert_eq!(s.doc().unwrap().shape(*id).unwrap().fill, Some(Fill::solid(expected)));
|
||||
}
|
||||
}
|
||||
|
||||
fn rotation(s: &Session) -> f64 {
|
||||
let st = s.active().unwrap();
|
||||
let id = st.selection.shapes[0];
|
||||
@@ -1646,7 +1538,7 @@ mod tests {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [10.0, 10.0, 30.0, 30.0]})).unwrap();
|
||||
let st = s.active_mut().unwrap();
|
||||
st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind: None });
|
||||
st.interaction = Some(crate::Interaction { label: "Move".into(), doc: st.doc.clone(), selection: st.selection.clone() });
|
||||
s.execute("shape.rotate", &json!({"gesture": "end", "ids": []})).unwrap();
|
||||
assert_eq!(s.active().unwrap().interaction.as_ref().unwrap().label, "Move");
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcr
|
||||
|
||||
fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
||||
let st = s.doc()?;
|
||||
let i = opt_usize_param(p, "index", "slide")?.unwrap_or(st.selection.slide);
|
||||
let i = usize_param(p, "index").unwrap_or(st.selection.slide);
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("slide", format!("no slide {i}")));
|
||||
}
|
||||
@@ -187,7 +187,7 @@ fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
||||
}
|
||||
|
||||
fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let indices: Vec<usize> = match opt_usize_param(p, "index", "slide.duplicate")? {
|
||||
let indices: Vec<usize> = match usize_param(p, "index") {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
@@ -240,7 +240,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
if let Some(a) = p.get("ids").and_then(Value::as_array) {
|
||||
a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).map(SlideId).collect()
|
||||
} else if let Some(i) = opt_usize_param(p, "index", "slide.delete")? {
|
||||
} else if let Some(i) = usize_param(p, "index") {
|
||||
st.doc.slides.get(i).map(|x| vec![x.id]).unwrap_or_default()
|
||||
} else if !st.selection.slides.is_empty() {
|
||||
st.selection.slides.clone()
|
||||
@@ -266,7 +266,7 @@ fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn move_slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = opt_usize_param(p, "from", "slide.move")?.unwrap_or(s.doc()?.selection.slide);
|
||||
let from = usize_param(p, "from").unwrap_or(s.doc()?.selection.slide);
|
||||
let to = usize_param(p, "to").ok_or_else(|| bad("slide.move", "missing `to`"))?;
|
||||
s.edit(|doc, sel| {
|
||||
if from >= doc.slides.len() {
|
||||
@@ -373,7 +373,7 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn hide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let targets: Vec<usize> = match opt_usize_param(p, "index", "slide.hide")? {
|
||||
let targets: Vec<usize> = match usize_param(p, "index") {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
@@ -442,7 +442,7 @@ pub fn apply_layout(doc: &mut Presentation, index: usize, layout: LayoutId) -> R
|
||||
|
||||
fn set_layout(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let key = p.get("layout").cloned().ok_or_else(|| bad("slide.layout", "missing `layout`"))?;
|
||||
let targets: Vec<usize> = match opt_usize_param(p, "index", "slide.layout")? {
|
||||
let targets: Vec<usize> = match usize_param(p, "index") {
|
||||
Some(i) => vec![i],
|
||||
None => {
|
||||
let st = s.doc()?;
|
||||
@@ -531,7 +531,7 @@ fn rename(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let at = opt_usize_param(p, "at", "section.add")?.unwrap_or(s.doc()?.selection.slide);
|
||||
let at = usize_param(p, "at").unwrap_or(s.doc()?.selection.slide);
|
||||
let name = str_param(p, "name").unwrap_or("Untitled Section").to_string();
|
||||
s.edit(|doc, _| {
|
||||
let at = at.min(doc.slides.len().saturating_sub(1));
|
||||
@@ -539,9 +539,9 @@ fn section_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if doc.sections.is_empty() {
|
||||
// The slides before `at` go into a "Default Section".
|
||||
if at > 0 {
|
||||
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(<[_]>::to_vec).unwrap_or_default() });
|
||||
doc.sections.push(Section { name: "Default Section".into(), slides: ids.get(..at).map(|x| x.to_vec()).unwrap_or_default() });
|
||||
}
|
||||
doc.sections.push(Section { name, slides: ids.get(at..).map(<[_]>::to_vec).unwrap_or_default() });
|
||||
doc.sections.push(Section { name, slides: ids.get(at..).map(|x| x.to_vec()).unwrap_or_default() });
|
||||
} else {
|
||||
let Some(start) = ids.get(at).copied() else { return Err(bad("section.add", "no slide")) };
|
||||
let si = doc.section_of(start).unwrap_or(0);
|
||||
@@ -648,25 +648,6 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn invalid_index_params_are_rejected_not_defaulted_to_selection() {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < 3 {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
for bad in [json!(-1), json!(1.5)] {
|
||||
for (cmd, p) in [
|
||||
("slide.delete", json!({"index": bad})),
|
||||
("slide.duplicate", json!({"index": bad})),
|
||||
("slide.move", json!({"from": bad, "to": 0})),
|
||||
("section.add", json!({"at": bad})),
|
||||
] {
|
||||
assert!(s.execute(cmd, &p).is_err(), "{cmd} must reject {p}");
|
||||
assert_eq!(s.doc().expect("doc").doc.slides.len(), 3, "{cmd} {p}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_keeps_section_order_in_step_with_slides() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -363,12 +363,10 @@ fn target_cells(s: &Session, p: &Value) -> Result<impl Fn(&Table) -> Vec<(usize,
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
|
||||
let st = s.doc()?;
|
||||
// A selected range grows over the merged cells it touches; with only a caret, the caret's cell is used.
|
||||
let sel = st.selection.cells.map(|(_, a, b)| (a, b, true)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c, false)));
|
||||
let sel = st.selection.cells.map(|(_, a, b)| (a, b)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c)));
|
||||
Ok(move |t: &Table| match (&cells, sel) {
|
||||
(Some(c), _) => c.clone(),
|
||||
(None, Some((a, b, range))) => {
|
||||
let (a, b) = if range { cell_block(t, a, b) } else { (a, b) };
|
||||
(None, Some((a, b))) => {
|
||||
// The selection can name cells outside the table; only walk the ones that exist.
|
||||
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
|
||||
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
|
||||
@@ -587,110 +585,6 @@ fn select_cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// The cell range from `a` to `b` in `t`, grown until every merged cell it touches lies inside
|
||||
/// it, as (the corner on `a`'s side, the corner on `b`'s side). Cells past the table's edge are
|
||||
/// clamped to it.
|
||||
pub fn cell_block(t: &Table, a: (usize, usize), b: (usize, usize)) -> ((usize, usize), (usize, usize)) {
|
||||
let (nr, nc) = (t.n_rows().max(1), t.n_cols().max(1));
|
||||
let (a, b) = ((a.0.min(nr - 1), a.1.min(nc - 1)), (b.0.min(nr - 1), b.1.min(nc - 1)));
|
||||
let (mut r0, mut r1, mut c0, mut c1) = (a.0.min(b.0), a.0.max(b.0), a.1.min(b.1), a.1.max(b.1));
|
||||
// Each pass takes in the merged cells that overlap the block; stop when one adds nothing.
|
||||
for _ in 0..nr + nc {
|
||||
let mut grown = false;
|
||||
for (r, row) in t.rows.iter().enumerate() {
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
if cell.is_covered() {
|
||||
continue;
|
||||
}
|
||||
// Spans come from the file; the additions saturate so that they cannot overflow on 32-bit targets.
|
||||
let end = |at: usize, span: u32, n: usize| at.saturating_add((span.max(1) - 1) as usize).min(n - 1);
|
||||
let (er, ec) = (end(r, cell.row_span, nr), end(c, cell.grid_span, nc));
|
||||
let overlaps = r <= r1 && er >= r0 && c <= c1 && ec >= c0;
|
||||
if overlaps && (r < r0 || er > r1 || c < c0 || ec > c1) {
|
||||
(r0, r1, c0, c1) = (r0.min(r), r1.max(er), c0.min(c), c1.max(ec));
|
||||
grown = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !grown {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let pick = |lo: usize, hi: usize, first: bool| if first { lo } else { hi };
|
||||
let from = (pick(r0, r1, a.0 <= b.0), pick(c0, c1, a.1 <= b.1));
|
||||
let to = (pick(r0, r1, a.0 > b.0), pick(c0, c1, a.1 > b.1));
|
||||
(from, to)
|
||||
}
|
||||
|
||||
/// The selected cell range of table `id` (`t`), grown over merged cells, as its top-left and
|
||||
/// bottom-right cells. `None` when no range of that table is selected.
|
||||
pub fn selected_range(
|
||||
cells: Option<(deckcraft_model::ShapeId, (usize, usize), (usize, usize))>,
|
||||
id: deckcraft_model::ShapeId,
|
||||
t: &Table,
|
||||
) -> Option<((usize, usize), (usize, usize))> {
|
||||
let (_, a, b) = cells.filter(|c| c.0 == id)?;
|
||||
let (x, y) = cell_block(t, a, b);
|
||||
Some(((x.0.min(y.0), x.1.min(y.1)), (x.0.max(y.0), x.1.max(y.1))))
|
||||
}
|
||||
|
||||
/// Whether cell (`r`, `c`) lies in `range` (from [`selected_range`]); without a range every cell does.
|
||||
pub fn in_range(range: Option<((usize, usize), (usize, usize))>, r: usize, c: usize) -> bool {
|
||||
range.is_none_or(|((r0, c0), (r1, c1))| (r0..=r1).contains(&r) && (c0..=c1).contains(&c))
|
||||
}
|
||||
|
||||
/// Shift+arrow with a cell range selected: the cell the range was extended to moves by (`dr`,
|
||||
/// `dc`), on past a merged cell until the range changes. The cell it started from stays. Returns
|
||||
/// false when no cell range of the selected table is selected.
|
||||
pub(crate) fn extend_selected_cells(s: &mut Session, dr: isize, dc: isize) -> Result<bool> {
|
||||
let st = s.doc()?;
|
||||
let Some((id, from, to)) = st.selection.cells.filter(|c| st.selection.shapes == [c.0]) else { return Ok(false) };
|
||||
let Some(ShapeKind::Table(t)) = st.shape(id).map(|sh| &sh.kind) else { return Ok(false) };
|
||||
let (nr, nc) = (t.n_rows(), t.n_cols());
|
||||
let old = cell_block(t, from, to);
|
||||
let mut end = to;
|
||||
while cell_block(t, from, end) == old {
|
||||
let r = end.0.checked_add_signed(dr).filter(|r| *r < nr);
|
||||
let c = end.1.checked_add_signed(dc).filter(|c| *c < nc);
|
||||
let (Some(r), Some(c)) = (r, c) else { break };
|
||||
end = (r, c);
|
||||
}
|
||||
s.select(|_, sel| sel.cells = Some((id, from, end)))?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// The text of the selected range of cells, for Copy and Cut: a tab between the cells of a row
|
||||
/// and a line per row (a merged cell once, where it starts). `None` without a cell range of the
|
||||
/// selected table.
|
||||
pub(crate) fn selected_cells_text(st: &DocState) -> Option<String> {
|
||||
let id = st.selection.cells.filter(|c| st.selection.shapes == [c.0])?.0;
|
||||
let ShapeKind::Table(t) = &st.shape(id)?.kind else { return None };
|
||||
let ((r0, c0), (r1, c1)) = selected_range(st.selection.cells, id, t)?;
|
||||
let rows = (r0..=r1).map(|r| {
|
||||
let cells = (c0..=c1).filter_map(|c| t.cell(r, c).filter(|x| !x.is_covered()).map(|x| x.text.text()));
|
||||
cells.collect::<Vec<_>>().join("\t")
|
||||
});
|
||||
Some(rows.collect::<Vec<_>>().join("\n"))
|
||||
}
|
||||
|
||||
/// Delete with a cell range selected: the text in those cells goes and the cells stay, each
|
||||
/// keeping its first paragraph's formatting.
|
||||
pub(crate) fn clear_selected_cells(s: &mut Session) -> Result<Value> {
|
||||
let Some((id, ..)) = s.doc()?.selection.cells else { return ok() };
|
||||
let cells = s.doc()?.selection.cells;
|
||||
with_table(s, &json!({"id": id}), "edit.delete", move |t| {
|
||||
let range = selected_range(cells, id, t);
|
||||
for (r, row) in t.rows.iter_mut().enumerate() {
|
||||
for (c, cell) in row.cells.iter_mut().enumerate() {
|
||||
if in_range(range, r, c) {
|
||||
deckcraft_model::edit::delete(&mut cell.text, (0, 0), (usize::MAX, usize::MAX));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn point_param(p: &Value, key: &str) -> Option<(f64, f64)> {
|
||||
let a = p.get(key)?;
|
||||
let (x, y) = (a.get(0)?.as_f64()?, a.get(1)?.as_f64()?);
|
||||
|
||||
+17
-698
@@ -10,10 +10,8 @@ use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{DocState, HistoryEntry, Result, Selection, Session, Target, TextSel, push_undo};
|
||||
use crate::{DocState, Result, Selection, Session, Target, TextSel};
|
||||
|
||||
/// Catalogue text-editing, selection, autocorrect, font, IME, and surrounding-deletion commands
|
||||
/// with their parameter docs and enabled predicates.
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "font.used", "Fonts in Presentation", [], None, "{} → [family] (theme fonts resolved)", has_doc, fonts_used),
|
||||
@@ -33,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AutoCorrect Options",
|
||||
["Tools"],
|
||||
None,
|
||||
"{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?, lists?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
|
||||
"{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
|
||||
always,
|
||||
autocorrect_options
|
||||
),
|
||||
@@ -68,30 +66,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_text,
|
||||
delete
|
||||
),
|
||||
// An input method asks for text around the caret to be removed (macOS press-and-hold,
|
||||
// dictation, Wayland text-input). Internal: the keyboard and control protocol keep the
|
||||
// existing text.delete spelling. `bytes` says the counts are UTF-8 bytes (winit's
|
||||
// `Ime::DeleteSurrounding`), otherwise characters, both in the logical text
|
||||
// `ime_surrounding` publishes: a paragraph mark or soft line break is one `\n` byte, so a
|
||||
// range may cross paragraphs. The selection and pre-edit are kept; byte ranges round
|
||||
// inward to whole scalars. A deletion while composing stays in the composition's single
|
||||
// undo step.
|
||||
//
|
||||
// `undoable: false` is deliberate even though a non-composing deletion is undoable: the
|
||||
// handler pushes its own history entry from a snapshot taken before the composition is
|
||||
// replaced, so the generic post-run undo hook must not add a duplicate. `journal` stays
|
||||
// off for the same reason an input-method edit is not a replayable document command.
|
||||
CommandSpec {
|
||||
id: "text.deleteSurrounding",
|
||||
label: "Delete Surrounding Text",
|
||||
menu: &[],
|
||||
shortcut: None,
|
||||
params: "{before, after, bytes?: bool}",
|
||||
enabled: has_text,
|
||||
run: delete_surrounding,
|
||||
journal: false,
|
||||
undoable: false,
|
||||
},
|
||||
cmd!(noundo "text.move", "Move Insertion Point", [], None, "{to: left|right|up|down|wordLeft|wordRight|lineStart|lineEnd|paraStart|paraEnd|paraUp|paraDown|start|end, extend?: bool} — left/right are on screen: in a right-to-left paragraph left goes forward", has_text, move_caret),
|
||||
cmd!(noundo "text.select", "Select Text", [], None, "{anchor: [p, c], caret: [p, c]}", has_text, select),
|
||||
cmd!(noundo "text.selectAll", "Select All Text", [], None, "{}", has_text, select_all),
|
||||
@@ -137,240 +111,6 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti
|
||||
Some(sh.text.get_or_insert_with(TextBody::default))
|
||||
}
|
||||
|
||||
/// Maximum number of UTF-8 bytes in an outgoing IME surrounding-text query.
|
||||
///
|
||||
/// This matches `egui::output::MAX_IME_SURROUNDING_TEXT_BYTES`; keeping the bound here lets the
|
||||
/// engine answer without copying a whole body or document.
|
||||
pub const IME_SURROUNDING_MAX_BYTES: usize = 4000;
|
||||
|
||||
/// Bounded committed text around an IME selection, in logical (not visual) order and with UTF-8
|
||||
/// byte offsets. The current input-method composition (pre-edit) is removed from both the text
|
||||
/// and the offsets.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ImeSurrounding {
|
||||
pub text: String,
|
||||
pub cursor: usize,
|
||||
pub anchor: usize,
|
||||
}
|
||||
|
||||
/// How a soft line break reads in the IME view: a newline, like a paragraph break.
|
||||
///
|
||||
/// The model stores it as `\u{b}` ([`RunKind::Break`]); publishing that control character would
|
||||
/// give the platform a text its own `ime_text` filter never produces, so its idea of the lines
|
||||
/// and our offsets would disagree. Both are one byte, so offsets do not otherwise change.
|
||||
fn ime_char(ch: char) -> char {
|
||||
if ch == '\u{b}' { '\n' } else { ch }
|
||||
}
|
||||
|
||||
/// Committed characters of one paragraph (the pre-edit skipped), as `(char index, character)`.
|
||||
fn committed_chars(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator<Item = (usize, char)> + '_ {
|
||||
p.runs
|
||||
.iter()
|
||||
.flat_map(|run| run.content().chars())
|
||||
.enumerate()
|
||||
.filter_map(move |(i, ch)| (!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch))))
|
||||
}
|
||||
|
||||
/// The same characters, newest first, without walking the paragraph from its start.
|
||||
fn committed_chars_rev(p: &Paragraph, comp: Option<(usize, usize)>) -> impl Iterator<Item = (usize, char)> + '_ {
|
||||
let mut starts = Vec::with_capacity(p.runs.len());
|
||||
let mut at = 0usize;
|
||||
for r in &p.runs {
|
||||
let n = r.char_len();
|
||||
starts.push((at, n));
|
||||
at += n;
|
||||
}
|
||||
p.runs.iter().zip(starts).rev().flat_map(move |(r, (start, n))| {
|
||||
let content = r.content();
|
||||
content.chars().rev().enumerate().filter_map(move |(k, ch)| {
|
||||
let i = start + n - 1 - k;
|
||||
(!comp.is_some_and(|(s, e)| i >= s && i < e)).then_some((i, ime_char(ch)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The composition's `(paragraph, start, end)` character range, when it is inside the body.
|
||||
fn composition_range(body: &TextBody, c: Option<&crate::Composition>) -> Option<(usize, usize, usize)> {
|
||||
let c = c?;
|
||||
let para = body.paragraphs.get(c.start.0)?;
|
||||
let start = c.start.1.min(para.char_len());
|
||||
let end = start.saturating_add(c.len).min(para.char_len());
|
||||
(start < end).then_some((c.start.0, start, end))
|
||||
}
|
||||
|
||||
/// Return the composition character range only when its paragraph index equals `pi`.
|
||||
fn comp_in(comp: Option<(usize, usize, usize)>, pi: usize) -> Option<(usize, usize)> {
|
||||
comp.filter(|(p, _, _)| *p == pi).map(|(_, s, e)| (s, e))
|
||||
}
|
||||
|
||||
/// Clip a model position to the body. An empty body only has position `(0, 0)`.
|
||||
fn model_pos(body: &TextBody, pos: (usize, usize)) -> Option<(usize, usize)> {
|
||||
if body.paragraphs.is_empty() {
|
||||
return (pos == (0, 0)).then_some((0, 0));
|
||||
}
|
||||
body.paragraphs.get(pos.0).map(|p| (pos.0, pos.1.min(p.char_len())))
|
||||
}
|
||||
|
||||
/// Walk forward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole
|
||||
/// characters), stopping at the end of the body. Returns the position reached and the characters
|
||||
/// and bytes consumed.
|
||||
///
|
||||
/// The returned window is bounded by `limit`, but locating `from` inside a paragraph still walks
|
||||
/// that paragraph's runs from their start: runs do not cache character lengths, and a position is
|
||||
/// a character index. A megabyte paragraph therefore costs time proportional to the caret's
|
||||
/// character offset, even though the copied surrounding text stays within `limit`.
|
||||
fn walk_forward(
|
||||
body: &TextBody,
|
||||
comp: Option<(usize, usize, usize)>,
|
||||
from: (usize, usize),
|
||||
limit: usize,
|
||||
bytes: bool,
|
||||
) -> ((usize, usize), usize, usize) {
|
||||
let (mut pi, mut at) = from;
|
||||
let (mut chars, mut used) = (0usize, 0usize);
|
||||
// `limit` comes from a (possibly hostile) request: every count is checked, never added
|
||||
// blindly, so an enormous limit cannot overflow.
|
||||
let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit };
|
||||
while let Some(para) = body.paragraphs.get(pi) {
|
||||
for (i, ch) in committed_chars(para, comp_in(comp, pi)) {
|
||||
if i < at {
|
||||
continue;
|
||||
}
|
||||
let n = ch.len_utf8();
|
||||
if !fits(used, chars, n) {
|
||||
return ((pi, i), chars, used);
|
||||
}
|
||||
used += n;
|
||||
chars += 1;
|
||||
at = i + 1;
|
||||
}
|
||||
if pi + 1 >= body.paragraphs.len() {
|
||||
break;
|
||||
}
|
||||
// The paragraph separator between this paragraph and the next.
|
||||
if !fits(used, chars, 1) {
|
||||
return ((pi, at), chars, used);
|
||||
}
|
||||
used += 1;
|
||||
chars += 1;
|
||||
pi += 1;
|
||||
at = 0;
|
||||
}
|
||||
((pi, at), chars, used)
|
||||
}
|
||||
|
||||
/// Walk backward from `from` over at most `limit` committed characters (or UTF-8 bytes of whole
|
||||
/// characters). Returns the position reached and the characters and bytes consumed.
|
||||
fn walk_backward(
|
||||
body: &TextBody,
|
||||
comp: Option<(usize, usize, usize)>,
|
||||
from: (usize, usize),
|
||||
limit: usize,
|
||||
bytes: bool,
|
||||
) -> ((usize, usize), usize, usize) {
|
||||
let (mut pi, mut at) = from;
|
||||
let (mut chars, mut used) = (0usize, 0usize);
|
||||
let fits = |used: usize, chars: usize, n: usize| if bytes { used.saturating_add(n) <= limit } else { chars < limit };
|
||||
loop {
|
||||
let Some(para) = body.paragraphs.get(pi) else { return ((pi, at), chars, used) };
|
||||
let mut boundary = at.min(para.char_len());
|
||||
for (i, ch) in committed_chars_rev(para, comp_in(comp, pi)) {
|
||||
if i >= boundary {
|
||||
continue;
|
||||
}
|
||||
let n = ch.len_utf8();
|
||||
if !fits(used, chars, n) {
|
||||
return ((pi, boundary), chars, used);
|
||||
}
|
||||
used += n;
|
||||
chars += 1;
|
||||
boundary = i;
|
||||
}
|
||||
// This paragraph is consumed down to its start.
|
||||
if pi == 0 {
|
||||
return ((0, 0), chars, used);
|
||||
}
|
||||
if !fits(used, chars, 1) {
|
||||
return ((pi, 0), chars, used);
|
||||
}
|
||||
used += 1;
|
||||
chars += 1;
|
||||
pi -= 1;
|
||||
at = usize::MAX; // all of the previous paragraph is before us
|
||||
}
|
||||
}
|
||||
|
||||
/// The committed logical text between two model positions (excluding `end`). `None` when it is
|
||||
/// longer than `cap` bytes: every caller only needs a bounded answer.
|
||||
fn logical_between(body: &TextBody, comp: Option<(usize, usize, usize)>, start: (usize, usize), end: (usize, usize), cap: usize) -> Option<String> {
|
||||
let (a, b) = if start <= end { (start, end) } else { (end, start) };
|
||||
let mut out = String::new();
|
||||
for pi in a.0..=b.0 {
|
||||
let Some(para) = body.paragraphs.get(pi) else { return Some(out) };
|
||||
let from = if pi == a.0 { a.1 } else { 0 };
|
||||
let to = if pi == b.0 { b.1 } else { usize::MAX };
|
||||
for (i, ch) in committed_chars(para, comp_in(comp, pi)) {
|
||||
if i < from || i >= to {
|
||||
continue;
|
||||
}
|
||||
out.push(ch);
|
||||
if out.len() > cap {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
if pi < b.0 {
|
||||
out.push('\n');
|
||||
if out.len() > cap {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Committed surrounding text for the text being edited.
|
||||
///
|
||||
/// Returns `None` when the body or selection is invalid, or when the selection alone is wider
|
||||
/// than [`IME_SURROUNDING_MAX_BYTES`]. An empty body is `Some` with empty text, which is distinct
|
||||
/// from an unavailable context. `cursor > anchor` is preserved for a reversed selection.
|
||||
///
|
||||
/// Only a bounded window is measured and copied: the query walks at most the budget out from the
|
||||
/// selection on each side and does not copy the rest of the body. Finding the selection's
|
||||
/// character offsets inside one paragraph still scans that paragraph's prefix, so the time is not
|
||||
/// bounded independently of the paragraph length.
|
||||
pub fn ime_surrounding(st: &DocState, t: &TextSel) -> Option<ImeSurrounding> {
|
||||
let body = body_of(st, t)?;
|
||||
let comp = composition_range(body, st.composition.as_ref());
|
||||
let caret = model_pos(body, t.caret)?;
|
||||
let anchor = model_pos(body, t.anchor)?;
|
||||
let (start, end) = if caret <= anchor { (caret, anchor) } else { (anchor, caret) };
|
||||
let selected = logical_between(body, comp, start, end, IME_SURROUNDING_MAX_BYTES)?;
|
||||
let selection_len = selected.len();
|
||||
let budget = IME_SURROUNDING_MAX_BYTES.saturating_sub(selection_len);
|
||||
let (_, _, before_available) = walk_backward(body, comp, start, budget, true);
|
||||
let (_, _, after_available) = walk_forward(body, comp, end, budget, true);
|
||||
let mut before = (budget / 2).min(before_available);
|
||||
let mut after = (budget - before).min(after_available);
|
||||
before = (budget - after).min(before_available);
|
||||
after = (budget - before).min(after_available);
|
||||
let (window_start, _, _) = walk_backward(body, comp, start, before, true);
|
||||
let (window_end, _, _) = walk_forward(body, comp, end, after, true);
|
||||
let before_text = logical_between(body, comp, window_start, start, budget)?;
|
||||
let after_text = logical_between(body, comp, end, window_end, budget)?;
|
||||
let before_len = before_text.len();
|
||||
let mut text = before_text;
|
||||
text.push_str(&selected);
|
||||
text.push_str(&after_text);
|
||||
// The budget arithmetic above already bounds this; refuse rather than publish more.
|
||||
if text.len() > IME_SURROUNDING_MAX_BYTES {
|
||||
return None;
|
||||
}
|
||||
// The caret and anchor are the selection's own endpoints, so their offsets inside the
|
||||
// window are the window's start plus either end of the selected text.
|
||||
let (cursor, anchor) = if caret >= anchor { (before_len + selection_len, before_len) } else { (before_len, before_len + selection_len) };
|
||||
Some(ImeSurrounding { text, cursor, anchor })
|
||||
}
|
||||
|
||||
/// Edit the active text body and selection; refits auto-sizing text boxes afterwards.
|
||||
pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result<T> {
|
||||
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?;
|
||||
@@ -599,7 +339,6 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.select(|_, sel| {
|
||||
sel.text = Some(TextSel { notes: true, anchor: end, caret: end, ..Default::default() });
|
||||
sel.shapes.clear();
|
||||
sel.cells = None;
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
@@ -630,7 +369,6 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
s.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = Some(TextSel { shape: id, anchor: at, caret: at, cell, notes: false, line_end: None });
|
||||
sel.cells = None;
|
||||
})?;
|
||||
Ok(json!({"id": id, "at": [at.0, at.1]}))
|
||||
}
|
||||
@@ -679,8 +417,7 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
|
||||
let c = ed::insert(body, at, &text, props);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in at.0..=c.0.min(last) {
|
||||
for pi in at.0..=c.0 {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
}
|
||||
@@ -688,8 +425,6 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
|
||||
c
|
||||
})?;
|
||||
autocorrect(s, text.chars().count() == 1)?;
|
||||
// AutoCorrect can move the caret: a replaced word, or a list lead it removed.
|
||||
let pos = s.doc()?.selection.text.as_ref().map_or(pos, |t| t.caret);
|
||||
Ok(json!({"caret": [pos.0, pos.1]}))
|
||||
}
|
||||
|
||||
@@ -896,13 +631,6 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
if end == 0 || !line.get(end - 1).is_some_and(|c| c.is_whitespace() || ".,;:!?".contains(*c)) {
|
||||
return Ok(());
|
||||
}
|
||||
if typing && s.prefs.ac_lists {
|
||||
// A lead such as `i.` or `a.` is kept as typed, so the space after it starts its list.
|
||||
let lead: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default();
|
||||
if auto_list_style(&lead).is_some() || start_auto_list(s, &t, &line, end)? {
|
||||
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()) {
|
||||
@@ -951,83 +679,6 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A list AutoFormat started: the text being edited right after it, the typed lead with its
|
||||
/// space, the paragraph's own formatting before, and the revision the change left.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AutoList {
|
||||
sel: TextSel,
|
||||
typed: String,
|
||||
props: deckcraft_model::text::ParaProps,
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
/// The list a paragraph that starts with `lead` and a space or tab becomes, as the command and
|
||||
/// parameters that make it: `*` •, `-` or `–` –, `--` or `—` ■, `>` ➢, `<>` ◆, and the numbering
|
||||
/// styles `1.`, `1)`, `I.`, `i.`, `A.`, `a.` and `a)`.
|
||||
fn auto_list_style(lead: &str) -> Option<(&'static str, Value)> {
|
||||
let bullet = |c: &str| Some(("format.bullets", json!({"on": true, "char": c})));
|
||||
let number = |scheme: &str| Some(("format.numbering", json!({"on": true, "scheme": scheme})));
|
||||
match lead {
|
||||
"*" => bullet("•"),
|
||||
"-" | "–" => bullet("–"),
|
||||
"--" | "—" => bullet("■"),
|
||||
">" => bullet("➢"),
|
||||
"<>" => bullet("◆"),
|
||||
"1." => number("arabicPeriod"),
|
||||
"1)" => number("arabicParenR"),
|
||||
"I." => number("romanUcPeriod"),
|
||||
"i." => number("romanLcPeriod"),
|
||||
"A." => number("alphaUcPeriod"),
|
||||
"a." => number("alphaLcPeriod"),
|
||||
"a)" => number("alphaLcParenR"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// AutoFormat's automatic bulleted and numbered lists: when the caret's paragraph holds only a
|
||||
/// list lead (`*`, `1.`…) before the space or tab just typed, the lead and the space are removed
|
||||
/// and the paragraph becomes a list paragraph in that style. `false` when nothing changed.
|
||||
fn start_auto_list(s: &mut Session, t: &TextSel, line: &[char], end: usize) -> Result<bool> {
|
||||
if t.is_range() || !line.get(end - 1).is_some_and(|c| *c == ' ' || *c == '\t') {
|
||||
return Ok(false);
|
||||
}
|
||||
let lead: String = line.get(..end - 1).map(|l| l.iter().collect()).unwrap_or_default();
|
||||
let Some((id, params)) = auto_list_style(&lead) else { return Ok(false) };
|
||||
let typed: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default();
|
||||
let para = t.caret.0;
|
||||
let props = body_of(s.doc()?, t).and_then(|b| b.paragraphs.get(para)).map(|p| p.props.clone()).unwrap_or_default();
|
||||
edit_text(s, |body, tt| {
|
||||
ed::delete(body, (para, 0), (para, end));
|
||||
tt.anchor = (para, 0);
|
||||
tt.caret = (para, 0);
|
||||
})?;
|
||||
s.execute(id, ¶ms)?;
|
||||
let st = s.doc_mut()?;
|
||||
if let Some(sel) = st.selection.text.clone() {
|
||||
st.auto_list = Some(AutoList { sel, typed, props, revision: st.revision });
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Backspace right after AutoFormat started a list: the paragraph gets back the typed lead and
|
||||
/// space and the formatting it had. `false` when the last change was anything else.
|
||||
fn undo_auto_list(s: &mut Session) -> Result<bool> {
|
||||
let st = s.doc_mut()?;
|
||||
let Some(a) = st.auto_list.take() else { return Ok(false) };
|
||||
if a.revision != st.revision || st.selection.text.as_ref() != Some(&a.sel) {
|
||||
return Ok(false);
|
||||
}
|
||||
edit_text(s, |body, t| {
|
||||
if let Some(p) = body.paragraphs.get_mut(a.sel.caret.0) {
|
||||
p.props = a.props.clone();
|
||||
}
|
||||
let c = ed::insert(body, a.sel.caret, &a.typed, None);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
})?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat
|
||||
/// superscripts it.
|
||||
fn is_ordinal(word: &str) -> bool {
|
||||
@@ -1075,7 +726,6 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
("capsLock", &mut pr.ac_caps_lock),
|
||||
("fractions", &mut pr.ac_fractions),
|
||||
("ordinals", &mut pr.ac_ordinals),
|
||||
("lists", &mut pr.ac_lists),
|
||||
] {
|
||||
if let Some(v) = p.get(key).and_then(Value::as_bool) {
|
||||
*slot = v;
|
||||
@@ -1122,7 +772,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({
|
||||
"on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps,
|
||||
"days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions,
|
||||
"ordinals": pr.ac_ordinals, "lists": pr.ac_lists, "entries": list,
|
||||
"ordinals": pr.ac_ordinals, "entries": list,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1134,8 +784,7 @@ pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result<Value> {
|
||||
let c = ed::insert_body(body, at, src);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in at.0..=c.0.min(last) {
|
||||
for pi in at.0..=c.0 {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
}
|
||||
@@ -1192,9 +841,7 @@ fn compose(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// A composition whose text was changed under it is kept as it is; a new one starts.
|
||||
let current = match current {
|
||||
Some(c) if !composition_fits(s.doc()?, &c) => {
|
||||
// The text changed under the pre-edit: the app settled it, so the platform's copy
|
||||
// must be cancelled too.
|
||||
s.settle_composition();
|
||||
s.end_composition();
|
||||
None
|
||||
}
|
||||
c => c,
|
||||
@@ -1287,7 +934,7 @@ pub fn cell_range_underlines(
|
||||
return vec![];
|
||||
};
|
||||
let body = deckcraft_render::table_cell_body(&ctx, tb, row, col, cell);
|
||||
let tmp = deckcraft_model::Shape::table_cell(body.clone());
|
||||
let tmp = deckcraft_model::Shape { text: Some(body.clone()), ..Default::default() };
|
||||
let l = deckcraft_text::layout(
|
||||
&ctx,
|
||||
&tmp,
|
||||
@@ -1356,7 +1003,7 @@ fn cell_on_slide(st: &DocState, id: ShapeId, row: usize, col: usize) -> Option<C
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(deckcraft_model::text::Paragraph::default());
|
||||
}
|
||||
let tmp = deckcraft_model::Shape::table_cell(body.clone());
|
||||
let tmp = deckcraft_model::Shape { text: Some(body.clone()), ..Default::default() };
|
||||
let layout = deckcraft_text::layout(
|
||||
&ctx,
|
||||
&tmp,
|
||||
@@ -1379,9 +1026,6 @@ fn insert(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
||||
if dir == "backward" && undo_auto_list(s)? {
|
||||
return ok();
|
||||
}
|
||||
let dir = dir.to_string();
|
||||
edit_text(s, move |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
@@ -1416,93 +1060,6 @@ pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result<Value> {
|
||||
ok()
|
||||
}
|
||||
|
||||
/// A `before`/`after` count from a control or input-method request.
|
||||
///
|
||||
/// Input-derived numbers are hostile: a value that does not fit `usize` (on 32-bit wasm, say) is
|
||||
/// clamped to `usize::MAX` and then bounded by the walk, never truncated to a smaller count that
|
||||
/// would delete different text than asked.
|
||||
fn count_param(p: &Value, name: &str) -> usize {
|
||||
p.get(name).and_then(Value::as_u64).map_or(0, |v| usize::try_from(v).unwrap_or(usize::MAX))
|
||||
}
|
||||
|
||||
/// Delete at most the requested character or byte counts around the selection or pre-edit,
|
||||
/// preserving selection direction and composition boundaries and recording one undo step
|
||||
/// outside composition.
|
||||
fn delete_surrounding(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let before = count_param(p, "before");
|
||||
let after = count_param(p, "after");
|
||||
let bytes = bool_or(p, "bytes", false);
|
||||
let st = s.doc()?;
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.deleteSurrounding", "not editing text"))?;
|
||||
let body = body_of(st, &t).ok_or_else(|| bad("text.deleteSurrounding", "the text being edited is gone"))?;
|
||||
let comp = composition_range(body, st.composition.as_ref());
|
||||
// The ranges are in the same logical coordinates `ime_surrounding` publishes: one byte for a
|
||||
// paragraph separator and for a soft line break. A range may therefore cross paragraphs.
|
||||
let ordered = {
|
||||
let a = model_pos(body, t.anchor).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
|
||||
let b = model_pos(body, t.caret).ok_or_else(|| bad("text.deleteSurrounding", "the selection is not in the text"))?;
|
||||
if a <= b { (a, b) } else { (b, a) }
|
||||
};
|
||||
// The selection (or the input method's pre-edit) stays exactly where it is. Only whole
|
||||
// scalars fully inside the requested before/after ranges around it are removed.
|
||||
let (start, end) = match comp {
|
||||
Some((pi, cs, ce)) => ((pi, cs), (pi, ce)),
|
||||
None => ordered,
|
||||
};
|
||||
let (from, before_chars, _) = walk_backward(body, comp, start, before, bytes);
|
||||
let (to, after_chars, _) = walk_forward(body, comp, end, after, bytes);
|
||||
let deleted = before_chars + after_chars;
|
||||
if deleted == 0 {
|
||||
return Ok(json!({"deleted": 0}));
|
||||
}
|
||||
let before_doc = st.doc.clone();
|
||||
let before_sel = st.selection.clone();
|
||||
let composing = st.composition.is_some();
|
||||
let vertex_kind = s.tool.vertex_kind.clone();
|
||||
edit_text(s, |body, t| {
|
||||
// Delete the after-range first, so the positions of the earlier range stay valid; then
|
||||
// the text before the selection.
|
||||
if after_chars > 0 {
|
||||
ed::delete(body, end, to);
|
||||
}
|
||||
if before_chars > 0 {
|
||||
ed::delete(body, from, start);
|
||||
}
|
||||
t.anchor = remap_after_delete(t.anchor, start, from, before_chars);
|
||||
t.caret = remap_after_delete(t.caret, start, from, before_chars);
|
||||
t.line_end = None;
|
||||
})?;
|
||||
if composing {
|
||||
if let Some(c) = s.doc_mut()?.composition.as_mut() {
|
||||
c.start = if before_chars == 0 {
|
||||
start
|
||||
} else if from.0 == start.0 {
|
||||
(start.0, start.1 - before_chars)
|
||||
} else {
|
||||
from
|
||||
};
|
||||
}
|
||||
} else {
|
||||
push_undo(s.doc_mut()?, HistoryEntry { label: "Delete Text".into(), doc: before_doc, selection: before_sel, vertex_kind });
|
||||
}
|
||||
Ok(json!({"deleted": deleted}))
|
||||
}
|
||||
|
||||
/// Map a text position across the deletion of `before_chars` characters that begins at `start`
|
||||
/// and reaches back to `from`. Within the start paragraph its characters shift; from an earlier
|
||||
/// paragraph those paragraphs merge, so later paragraph numbers shift too.
|
||||
fn remap_after_delete(pos: (usize, usize), start: (usize, usize), from: (usize, usize), before_chars: usize) -> (usize, usize) {
|
||||
if before_chars == 0 || pos < start {
|
||||
return pos;
|
||||
}
|
||||
if from.0 == start.0 {
|
||||
// Only endpoints in the start paragraph move; later paragraphs keep their text.
|
||||
if pos.0 == start.0 { (pos.0, pos.1 - before_chars) } else { pos }
|
||||
} else {
|
||||
(from.0 + (pos.0 - start.0), if pos.0 == start.0 { from.1 + (pos.1 - start.1) } else { pos.1 })
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
delete_dir(s, str_param(p, "dir").unwrap_or("backward"))
|
||||
}
|
||||
@@ -1567,7 +1124,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
None => match to.as_str() {
|
||||
"up" => (caret.0.saturating_sub(1), caret.1),
|
||||
"down" => (caret.0.saturating_add(1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
|
||||
"down" => ((caret.0 + 1).min(body.paragraphs.len().saturating_sub(1)), caret.1),
|
||||
"lineStart" => (caret.0, 0),
|
||||
_ => (caret.0, body.paragraphs.get(caret.0).map(|x| x.char_len()).unwrap_or(0)),
|
||||
},
|
||||
@@ -1632,11 +1189,9 @@ fn select_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let t = st.selection.text.clone().ok_or_else(|| bad("text.selectParagraph", "not editing text"))?;
|
||||
let at = pos_param(p, "at").unwrap_or(t.caret);
|
||||
let body = body_of(st, &t);
|
||||
// Clamp to the last paragraph so a huge index can neither overflow nor select past the body.
|
||||
let at = (at.0.min(body.map_or(0, |b| b.paragraphs.len().saturating_sub(1))), at.1);
|
||||
let n = body.and_then(|b| b.paragraphs.get(at.0)).map(|x| x.char_len()).unwrap_or(0);
|
||||
// The paragraph mark is part of the paragraph (PowerPoint's triple-click selects it too).
|
||||
let end = if body.is_some_and(|b| at.0.saturating_add(1) < b.paragraphs.len()) { (at.0.saturating_add(1), 0) } else { (at.0, n) };
|
||||
let end = if body.is_some_and(|b| at.0 + 1 < b.paragraphs.len()) { (at.0 + 1, 0) } else { (at.0, n) };
|
||||
s.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.anchor = (at.0, 0);
|
||||
@@ -1738,18 +1293,12 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
if ids.is_empty() {
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
let cells = st.selection.cells;
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
let id = sh.id;
|
||||
match &mut sh.kind {
|
||||
ShapeKind::Table(t) => {
|
||||
// Only the selected cells, when a range of them is selected.
|
||||
let range = super::table::selected_range(cells, id, t);
|
||||
for (r, row) in t.rows.iter_mut().enumerate() {
|
||||
for (c, cell) in row.cells.iter_mut().enumerate() {
|
||||
if super::table::in_range(range, r, c) {
|
||||
ed::format_all(&mut cell.text, f);
|
||||
}
|
||||
for row in &mut t.rows {
|
||||
for c in &mut row.cells {
|
||||
ed::format_all(&mut c.text, f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1822,8 +1371,7 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
if body.paragraphs.is_empty() {
|
||||
body.paragraphs.push(Default::default());
|
||||
}
|
||||
let last = body.paragraphs.len().saturating_sub(1);
|
||||
for pi in a.0..=b.0.min(last) {
|
||||
for pi in a.0..=b.0 {
|
||||
if let Some(p) = body.paragraphs.get_mut(pi) {
|
||||
f(p);
|
||||
}
|
||||
@@ -1835,17 +1383,12 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
if ids.is_empty() {
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
let cells = st.selection.cells;
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
let id = sh.id;
|
||||
match &mut sh.kind {
|
||||
ShapeKind::Table(t) => {
|
||||
let range = super::table::selected_range(cells, id, t);
|
||||
for (r, row) in t.rows.iter_mut().enumerate() {
|
||||
for (c, cell) in row.cells.iter_mut().enumerate() {
|
||||
if super::table::in_range(range, r, c) {
|
||||
cell.text.paragraphs.iter_mut().for_each(f);
|
||||
}
|
||||
for row in &mut t.rows {
|
||||
for c in &mut row.cells {
|
||||
c.text.paragraphs.iter_mut().for_each(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1932,210 +1475,6 @@ mod rtl_promote_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ime_surrounding_tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
/// Test helper: insert and enter a rectangle containing `aé😀b`, returning its shape id.
|
||||
fn edit(s: &mut Session) -> u64 {
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 240, 60], "text": "aé😀b"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
s.execute("text.edit", &json!({"id": id})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// Test helper: return the identified shape's text, or an empty string when it is gone.
|
||||
fn text(s: &Session, id: u64) -> String {
|
||||
let id = deckcraft_model::ShapeId(u32::try_from(id).unwrap());
|
||||
s.doc().unwrap().shape(id).and_then(|sh| sh.text.as_ref()).map(deckcraft_model::text::TextBody::text).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Test helper: set an in-paragraph text selection through the engine command path.
|
||||
fn select(s: &mut Session, anchor: usize, caret: usize) {
|
||||
s.execute("text.select", &json!({"anchor": [0, anchor], "caret": [0, caret]})).unwrap();
|
||||
}
|
||||
|
||||
/// DeleteSurrounding removes only the requested text around the selection. The selection
|
||||
/// itself and its direction stay, so the following commit replaces it.
|
||||
#[test]
|
||||
fn selected_text_survives_the_requested_characters_or_bytes_around_it() {
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
select(&mut s, 3, 1); // reversed: caret before anchor, selecting é😀
|
||||
s.delete_surrounding(1, 1, false).unwrap();
|
||||
assert_eq!(text(&s, id), "é😀", "a and b go; the selected run stays");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert_eq!((t.anchor, t.caret), ((0, 2), (0, 0)), "both endpoints shift and stay reversed");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b");
|
||||
|
||||
select(&mut s, 1, 3);
|
||||
s.delete_surrounding(1, 1, true).unwrap();
|
||||
assert_eq!(text(&s, id), "é😀", "byte counts around the selection delete a and b too");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b");
|
||||
}
|
||||
|
||||
/// A byte range removes only whole scalars fully contained in it. A count that ends inside
|
||||
/// a character leaves that character whole (and undeleted), never splitting it.
|
||||
#[test]
|
||||
fn byte_counts_round_inward_to_whole_characters() {
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
select(&mut s, 2, 2); // after é: bytes 0..3
|
||||
s.delete_surrounding(1, 0, true).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b", "one byte inside é is not the whole scalar");
|
||||
s.delete_surrounding(2, 0, true).unwrap();
|
||||
assert_eq!(text(&s, id), "a😀b", "two bytes reach é's start, so the whole é goes");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b");
|
||||
|
||||
select(&mut s, 2, 2);
|
||||
s.delete_surrounding(0, 1, true).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b", "one byte into the emoji is not the whole scalar");
|
||||
s.delete_surrounding(0, 4, true).unwrap();
|
||||
assert_eq!(text(&s, id), "aéb", "four bytes contain the whole emoji");
|
||||
}
|
||||
|
||||
/// The committed query is logical text with UTF-8 byte offsets; it keeps a reversed
|
||||
/// selection, removes the model-stored pre-edit, and treats an empty body as available.
|
||||
#[test]
|
||||
fn surrounding_text_is_committed_logical_and_bounded() {
|
||||
use super::{IME_SURROUNDING_MAX_BYTES, ime_surrounding};
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("aé😀b", 8, 8));
|
||||
|
||||
s.execute("text.select", &json!({"anchor": [0, 3], "caret": [0, 1]})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert_eq!((q.cursor, q.anchor), (1, 7), "reversed selection keeps caret before anchor");
|
||||
assert_eq!(q.text, "aé😀b");
|
||||
|
||||
s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||
s.compose_text("日本", None).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab", 2, 2), "pre-edit is not committed text");
|
||||
|
||||
s.execute("text.set", &json!({"id": id, "text": ""})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("", 0, 0), "empty body is available");
|
||||
|
||||
let long = "a".repeat(IME_SURROUNDING_MAX_BYTES + 1500);
|
||||
s.execute("text.set", &json!({"id": id, "text": long})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.clone().unwrap();
|
||||
let q = ime_surrounding(st, &t).unwrap();
|
||||
assert!(q.text.len() <= IME_SURROUNDING_MAX_BYTES, "{}", q.text.len());
|
||||
assert_eq!(q.cursor, q.text.len(), "caret stays at the end of the bounded window");
|
||||
}
|
||||
|
||||
/// Paragraph marks are part of the logical text the platform sees (`\n`), so a deletion may
|
||||
/// remove one and merge the paragraphs, exactly as the published offsets say.
|
||||
#[test]
|
||||
fn paragraph_marks_are_deletable_logical_text() {
|
||||
use super::ime_surrounding;
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
s.execute("text.set", &json!({"id": id, "text": "ab\ncd"})).unwrap();
|
||||
// The caret at the start of the second paragraph: after "ab" and its paragraph mark.
|
||||
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("ab\ncd", 3, 3));
|
||||
|
||||
// One byte before the caret is that paragraph mark.
|
||||
s.delete_surrounding(1, 0, true).unwrap();
|
||||
assert_eq!(text(&s, id), "abcd", "the mark goes and the paragraphs merge");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert_eq!(t.caret, (0, 2), "the caret stays where the merged text continues");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "ab\ncd");
|
||||
|
||||
// Characters count the same mark.
|
||||
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 0]})).unwrap();
|
||||
s.delete_surrounding(1, 0, false).unwrap();
|
||||
assert_eq!(text(&s, id), "abcd");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
|
||||
// And from the other side: one character after the end of the first paragraph.
|
||||
s.execute("text.select", &json!({"anchor": [0, 2], "caret": [0, 2]})).unwrap();
|
||||
s.delete_surrounding(0, 1, false).unwrap();
|
||||
assert_eq!(text(&s, id), "abcd");
|
||||
}
|
||||
|
||||
/// A soft line break (Shift+Enter) is published as a newline, like a paragraph break: the
|
||||
/// platform must never see the model's `\u{b}`, which its own text filter would reject.
|
||||
#[test]
|
||||
fn soft_line_breaks_read_as_newlines() {
|
||||
use super::ime_surrounding;
|
||||
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
s.execute("text.autocorrect", &json!({"on": false})).unwrap(); // isolate logical line-break coordinates
|
||||
|
||||
s.execute("text.set", &json!({"id": id, "text": "ab"})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "\u{b}"})).unwrap();
|
||||
assert_eq!(text(&s, id), "a\u{b}b", "a line break inside one paragraph");
|
||||
let st = s.doc().unwrap();
|
||||
let q = ime_surrounding(st, &st.selection.text.clone().unwrap()).unwrap();
|
||||
assert_eq!((q.text.as_str(), q.cursor, q.anchor), ("a\nb", 2, 2));
|
||||
|
||||
// The break is one logical byte, so removing it removes one character.
|
||||
s.delete_surrounding(1, 0, true).unwrap();
|
||||
assert_eq!(text(&s, id), "ab");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "a\u{b}b");
|
||||
}
|
||||
|
||||
/// A count that does not fit a `usize` (32-bit wasm) is clamped, never truncated to a
|
||||
/// different number that would delete other text than the platform asked for.
|
||||
#[test]
|
||||
fn out_of_range_counts_clamp_instead_of_truncating() {
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
select(&mut s, 1, 1);
|
||||
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::count_param(&json!({"before": 4_294_967_296u64}), "before"), expected);
|
||||
assert!(expected > 0, "the count is clamped, not wrapped to nothing");
|
||||
assert_eq!(super::count_param(&json!({}), "before"), 0);
|
||||
}
|
||||
|
||||
/// A non-composing input-method deletion is exactly one undo step; the manual history entry
|
||||
/// must not be duplicated by the generic command wrapper.
|
||||
#[test]
|
||||
fn a_non_composing_ime_deletion_is_one_undo_step() {
|
||||
let mut s = Session::with_new();
|
||||
let id = edit(&mut s);
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
select(&mut s, 3, 3);
|
||||
s.delete_surrounding(1, 0, false).unwrap();
|
||||
assert_eq!(text(&s, id), "aéb");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one history entry, not two");
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text(&s, id), "aé😀b");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod replace_font_tests {
|
||||
use serde_json::json;
|
||||
@@ -2180,23 +1519,3 @@ mod replace_font_tests {
|
||||
assert!(s.execute("font.replace", &json!({"from": "Inter"})).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod select_paragraph_tests {
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn select_paragraph_huge_at() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "B"})).unwrap();
|
||||
s.execute("insert.textBox", &json!({"rect": [10, 10, 200, 50], "text": "hello"})).unwrap();
|
||||
s.execute("text.edit", &json!({})).unwrap();
|
||||
let r = s.execute("text.selectParagraph", &json!({"at": [u64::MAX, 0]}));
|
||||
assert!(r.is_ok(), "{r:?}");
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
// Clamped to the last (only) paragraph, never past the end of the body.
|
||||
assert_eq!((t.anchor, t.caret), ((0, 0), (0, 5)));
|
||||
}
|
||||
}
|
||||
|
||||
+27
-210
@@ -1,5 +1,5 @@
|
||||
//! Review › Compare: the differences between the open presentation and a revised copy of it, slide
|
||||
//! by slide, and accepting a revised slide into the open one or rejecting it.
|
||||
//! by slide, and accepting a revised slide into the open one.
|
||||
//!
|
||||
//! Slides match by id (a revised copy of the same deck keeps them); when the two decks share no
|
||||
//! slide id at all, by position. Shapes match by id, then by name.
|
||||
@@ -7,7 +7,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::{MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use deckcraft_model::{Fill, MediaId, Presentation, Shape, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde::Serialize;
|
||||
|
||||
/// The revised presentation a document is being compared with.
|
||||
@@ -18,54 +18,6 @@ pub struct Compare {
|
||||
/// Slides match by position (the decks shared no slide id when the compare started); fixed
|
||||
/// for the session so accepting slides doesn't switch the matching half way.
|
||||
pub by_position: bool,
|
||||
/// Revisions rejected: no longer listed and never accepted, the open deck left as it is.
|
||||
pub rejected: HashSet<ChangeKey>,
|
||||
}
|
||||
|
||||
/// What a revision is about, stable while slides move: the open slide (for changed and deleted)
|
||||
/// and the revised slide (for changed and inserted).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ChangeKey {
|
||||
kind: ChangeKind,
|
||||
open: Option<SlideId>,
|
||||
revised: Option<usize>,
|
||||
}
|
||||
|
||||
impl ChangeKey {
|
||||
pub fn of(doc: &Presentation, change: &Change) -> ChangeKey {
|
||||
let open = match change.kind {
|
||||
ChangeKind::Inserted => None,
|
||||
ChangeKind::Changed | ChangeKind::Deleted => doc.slides.get(change.slide).map(|s| s.id),
|
||||
};
|
||||
ChangeKey { kind: change.kind, open, revised: change.revised }
|
||||
}
|
||||
}
|
||||
|
||||
/// The revisions still to review: [`diff`] without the rejected ones.
|
||||
pub fn pending(doc: &Presentation, c: &Compare) -> Vec<Change> {
|
||||
let mut out = diff(doc, &c.revised, c.by_position);
|
||||
out.retain(|ch| !is_rejected(doc, c, ch));
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `ch` was rejected. Positional matching re-pairs slides by index when the open deck gains
|
||||
/// or loses a slide, which changes the open half of the key; the revised index stays put, so any
|
||||
/// rejected key for the same revised slide counts. An open slide pushed past the end of the revised
|
||||
/// deck turns into a deletion; it stays kept when a rejected key names it.
|
||||
fn is_rejected(doc: &Presentation, c: &Compare, ch: &Change) -> bool {
|
||||
if c.rejected.contains(&ChangeKey::of(doc, ch)) {
|
||||
return true;
|
||||
}
|
||||
if !c.by_position {
|
||||
return false;
|
||||
}
|
||||
match ch.kind {
|
||||
ChangeKind::Deleted => {
|
||||
let open = doc.slides.get(ch.slide).map(|s| s.id);
|
||||
open.is_some() && c.rejected.iter().any(|k| k.open == open)
|
||||
}
|
||||
_ => ch.revised.is_some() && c.rejected.iter().any(|k| k.revised == ch.revised),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `cur` and `rev` share no slide id (then slides match by position).
|
||||
@@ -74,7 +26,7 @@ pub fn unrelated(cur: &Presentation, rev: &Presentation) -> bool {
|
||||
!cur.slides.iter().any(|s| ids.contains(&s.id))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ChangeKind {
|
||||
/// Only in the revised deck.
|
||||
@@ -281,8 +233,10 @@ fn max_shape_id(shapes: &[Shape], m: &mut u32) {
|
||||
pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacing: Option<SlideId>) -> crate::Result<Slide> {
|
||||
let mut sl = slide.clone();
|
||||
// Media.
|
||||
let mut mids = vec![];
|
||||
crate::cmd::edit::media_of_shapes(&sl.shapes, &mut mids);
|
||||
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
|
||||
for id in crate::media_refs::slide_media(&sl) {
|
||||
for id in mids {
|
||||
let Some(m) = rev.media(id) else { continue };
|
||||
match doc.media(id) {
|
||||
Some(x) if x.data == m.data => {}
|
||||
@@ -308,6 +262,7 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
|
||||
shapes: &mut [Shape],
|
||||
taken: &HashSet<u32>,
|
||||
ids: &mut HashMap<ShapeId, ShapeId>,
|
||||
media: &HashMap<MediaId, MediaId>,
|
||||
depth: usize,
|
||||
) -> crate::Result<()> {
|
||||
if depth > 64 {
|
||||
@@ -319,18 +274,31 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
|
||||
ids.insert(s.id, new);
|
||||
s.id = new;
|
||||
}
|
||||
let remap = |m: &mut MediaId| {
|
||||
if let Some(n) = media.get(m) {
|
||||
*m = *n;
|
||||
}
|
||||
};
|
||||
match &mut s.kind {
|
||||
ShapeKind::Picture { fill } => remap(&mut fill.media),
|
||||
ShapeKind::Media(m) => {
|
||||
remap(&mut m.media);
|
||||
if let Some(p) = m.poster.as_mut() {
|
||||
remap(p);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if let Some(Fill::Picture(pf)) = &mut s.fill {
|
||||
remap(&mut pf.media);
|
||||
}
|
||||
if let Some(ch) = s.children_mut() {
|
||||
rec(doc, ch, taken, ids, depth + 1)?;
|
||||
rec(doc, ch, taken, ids, media, depth + 1)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
rec(doc, &mut sl.shapes, &taken, &mut id_map, 0)?;
|
||||
crate::media_refs::visit_mut::slide(&mut sl, &mut |m: &mut MediaId| {
|
||||
if let Some(n) = media_map.get(m) {
|
||||
*m = *n;
|
||||
}
|
||||
});
|
||||
rec(doc, &mut sl.shapes, &taken, &mut id_map, &media_map, 0)?;
|
||||
for a in &mut sl.animations {
|
||||
if let Some(n) = id_map.get(&a.shape) {
|
||||
a.shape = *n;
|
||||
@@ -444,52 +412,6 @@ mod tests {
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
|
||||
}
|
||||
|
||||
/// A deck with one slide: a table whose cell has a picture fill, a picture background and a
|
||||
/// transition sound. `tag` starts the bytes of each.
|
||||
fn media_deck(tag: &str) -> deckcraft_model::Presentation {
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::style::PictureFill;
|
||||
use deckcraft_model::{Background, Fill, Shape, ShapeId, ShapeKind, Table, Transition, defaults};
|
||||
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let cell = p.add_media("cell.png", "image/png", format!("{tag} cell").into_bytes()).expect("media");
|
||||
let back = p.add_media("back.png", "image/png", format!("{tag} background").into_bytes()).expect("media");
|
||||
let sound = p.add_media("sound.wav", "audio/wav", format!("{tag} sound").into_bytes()).expect("media");
|
||||
let mut t = Table::new(1, 1, 100.0, 40.0);
|
||||
t.rows[0].cells[0].fill = Some(Fill::Picture(PictureFill { media: cell, ..Default::default() }));
|
||||
let id = ShapeId(p.alloc_id().expect("id"));
|
||||
let mut s = (*p.slides.first().cloned().unwrap_or_default()).clone();
|
||||
s.shapes.push(Shape { id, kind: ShapeKind::Table(t), ..Default::default() });
|
||||
s.background = Some(Background::Fill { fill: Fill::Picture(PictureFill { media: back, ..Default::default() }) });
|
||||
s.transition = Some(Transition { kind: "fade".into(), sound: Some(sound), ..Default::default() });
|
||||
p.slides = vec![Arc::new(s)];
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_accepted_slide_keeps_its_cell_picture_background_and_sound() {
|
||||
use deckcraft_model::{Background, Fill, MediaId, ShapeKind};
|
||||
|
||||
// Both decks hold the same media ids with different bytes.
|
||||
let mut doc = media_deck("open");
|
||||
let rev = media_deck("revised");
|
||||
let sl = super::adopt(&mut doc, &rev, &rev.slides[0], None).unwrap();
|
||||
let bytes = |id: MediaId| doc.media(id).unwrap().data.as_ref().clone();
|
||||
let table = sl.shapes.iter().find_map(|x| if let ShapeKind::Table(t) = &x.kind { Some(t) } else { None }).unwrap();
|
||||
let Some(Fill::Picture(cell)) = &table.rows[0].cells[0].fill else { panic!("no cell picture") };
|
||||
let Some(Background::Fill { fill: Fill::Picture(back) }) = &sl.background else { panic!("no background picture") };
|
||||
let sound = sl.transition.as_ref().and_then(|t| t.sound).unwrap();
|
||||
assert_eq!(
|
||||
[bytes(cell.media), bytes(back.media), bytes(sound)],
|
||||
["revised cell", "revised background", "revised sound"].map(|x| x.as_bytes().to_vec())
|
||||
);
|
||||
// The open deck's own slide is untouched.
|
||||
let own = &doc.slides[0];
|
||||
let Some(Background::Fill { fill: Fill::Picture(back) }) = &own.background else { panic!("no background picture") };
|
||||
assert_eq!(bytes(back.media), b"open background");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_one_then_all_makes_the_decks_equal_and_undoes() {
|
||||
let (mut s, rev, n) = decks();
|
||||
@@ -512,50 +434,6 @@ mod tests {
|
||||
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_keeps_the_open_deck_and_accept_all_skips_rejected_revisions() {
|
||||
let (mut s, rev, n) = decks();
|
||||
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
|
||||
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
|
||||
let ins = before.iter().position(|c| c["kind"] == "inserted").unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let undo = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(s.execute("review.reject", &json!({"index": ins})).unwrap()["rejected"], 1);
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc), "rejecting leaves the open deck as it is");
|
||||
let left = s.execute("review.revisions", &json!({})).unwrap();
|
||||
assert_eq!(kinds(&left).len(), before.len() - 1);
|
||||
assert!(!kinds(&left).contains(&"inserted".into()), "{left}");
|
||||
// Accept All takes the rest, never the rejected slide.
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.doc.slides.len(), n - 1, "the deleted slide goes, the rejected insertion never comes");
|
||||
assert!(!st.doc.slides.iter().any(|x| super::slide_title(x) == "Brand new"));
|
||||
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc));
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "rejecting is not an undo step");
|
||||
// Reject All: nothing left to review, the deck as it was; Accept All then has nothing to take.
|
||||
assert_eq!(s.execute("review.reject", &json!({"all": true})).unwrap()["rejected"], before.len() - 1);
|
||||
assert!(s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().is_empty());
|
||||
assert!(s.execute("review.accept", &json!({"all": true})).is_err());
|
||||
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc().unwrap().doc));
|
||||
assert!(s.execute("review.reject", &json!({"all": true})).is_err(), "nothing left to reject");
|
||||
assert!(s.execute("review.reject", &json!({})).is_err());
|
||||
s.execute("review.endCompare", &json!({})).unwrap();
|
||||
assert!(s.execute("review.reject", &json!({"index": 0})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejecting_one_revision_keeps_the_others_in_order() {
|
||||
let (mut s, rev, _) = decks();
|
||||
s.execute("review.compare", &json!({"name": "rev.deckcraft", "data": rev})).unwrap();
|
||||
let before = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
|
||||
assert!(s.execute("review.reject", &json!({"index": before.len()})).is_err());
|
||||
s.execute("review.reject", &json!({"index": 0})).unwrap();
|
||||
let left = s.execute("review.revisions", &json!({})).unwrap()["changes"].as_array().unwrap().clone();
|
||||
assert_eq!(left.as_slice(), &before[1..], "the rejected one goes, the others stay in order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_decks_match_by_position_and_bad_input_fails() {
|
||||
let mut s = Session::with_new();
|
||||
@@ -574,65 +452,4 @@ mod tests {
|
||||
assert!(s.execute("review.compare", &json!({})).is_err());
|
||||
assert!(s.execute("review.accept", &json!({"index": 999})).is_err());
|
||||
}
|
||||
|
||||
fn slide_texts(s: &Session) -> Vec<String> {
|
||||
s.doc().unwrap().doc.slides.iter().map(|sl| sl.shapes.iter().filter_map(|x| x.text.as_ref()).map(|t| t.text()).collect::<String>()).collect()
|
||||
}
|
||||
|
||||
/// By position, a rejected revision stays rejected when a slide is inserted into the open deck
|
||||
/// and the same revised slide now pairs with a different open slide.
|
||||
#[test]
|
||||
fn a_rejected_positional_revision_stays_rejected_after_a_slide_is_inserted() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "title", "title": "A"})).unwrap();
|
||||
let mut other = Session::with_new();
|
||||
other.execute("slide.new", &json!({"layout": "title", "title": "B"})).unwrap();
|
||||
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap();
|
||||
assert_eq!(v["byPosition"], true, "{v}");
|
||||
let changed = |v: &Value| -> Vec<u64> {
|
||||
v["changes"].as_array().unwrap().iter().filter(|c| c["kind"] == "changed").map(|c| c["revised"].as_u64().unwrap()).collect()
|
||||
};
|
||||
let revised = changed(&v);
|
||||
let last = *revised.last().expect("a changed slide");
|
||||
let at = v["changes"].as_array().unwrap().iter().position(|c| c["revised"] == last).unwrap();
|
||||
s.execute("review.reject", &json!({"index": at})).unwrap();
|
||||
// A new slide at the front shifts every open slide down by one.
|
||||
s.execute("slide.new", &json!({"layout": "title", "title": "N"})).unwrap();
|
||||
let n_at = slide_texts(&s).iter().position(|t| t.contains('N')).unwrap();
|
||||
s.execute("slide.move", &json!({"from": n_at, "to": 0})).unwrap();
|
||||
assert_eq!(slide_texts(&s).iter().position(|t| t.contains('N')), Some(0));
|
||||
let now = s.execute("review.revisions", &json!({})).unwrap();
|
||||
assert!(!changed(&now).contains(&last), "{now}");
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
let texts = slide_texts(&s);
|
||||
assert!(!texts.iter().any(|t| t.contains('B')), "{texts:?}");
|
||||
}
|
||||
|
||||
/// By position, inserting a slide before a rejected pair pushes the pair's open slide past the
|
||||
/// end of the revised deck; it must not turn into a deletion that Accept All carries out.
|
||||
#[test]
|
||||
fn a_rejected_positional_revision_does_not_become_a_deletion_when_a_slide_is_inserted() {
|
||||
let mut s = Session::with_new();
|
||||
let mut other = Session::with_new();
|
||||
for t in ["A", "X"] {
|
||||
s.execute("slide.new", &json!({"layout": "title", "title": t})).unwrap();
|
||||
}
|
||||
for t in ["B", "Y"] {
|
||||
other.execute("slide.new", &json!({"layout": "title", "title": t})).unwrap();
|
||||
}
|
||||
let data = other.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let v = s.execute("review.compare", &json!({"name": "o.pptx", "data": data})).unwrap();
|
||||
assert_eq!(v["byPosition"], true, "{v}");
|
||||
let changes = v["changes"].as_array().unwrap();
|
||||
let last = changes.iter().filter(|c| c["kind"] == "changed").map(|c| c["revised"].as_u64().unwrap()).max().expect("a changed slide");
|
||||
let at = changes.iter().position(|c| c["kind"] == "changed" && c["revised"] == last).unwrap();
|
||||
let open_id = s.doc().unwrap().doc.slides[last as usize].id;
|
||||
s.execute("review.reject", &json!({"index": at})).unwrap();
|
||||
s.execute("slide.new", &json!({"layout": "title", "title": "N"})).unwrap();
|
||||
let n_at = slide_texts(&s).iter().position(|t| t.contains('N')).unwrap();
|
||||
s.execute("slide.move", &json!({"from": n_at, "to": 0})).unwrap();
|
||||
s.execute("review.accept", &json!({"all": true})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides.iter().any(|sl| sl.id == open_id), "the open slide whose revision was rejected was deleted");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ use crate::{EngineError, Result, Session};
|
||||
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
|
||||
payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(ToString::to_string)
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "unknown panic".into())
|
||||
}
|
||||
@@ -36,15 +36,11 @@ impl Session {
|
||||
/// Run command `id` (`f`) inside the guard.
|
||||
pub(crate) fn guarded(&mut self, id: &str, f: impl FnOnce(&mut Session) -> Result<Value>) -> Result<Value> {
|
||||
let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone(), d.revision));
|
||||
let vertex_kind = self.tool.vertex_kind.clone();
|
||||
let depth = self.depth;
|
||||
match catch_unwind(AssertUnwindSafe(|| f(self))) {
|
||||
Ok(r) => r,
|
||||
Err(payload) => {
|
||||
self.depth = depth;
|
||||
// The remembered point type names a shape of the document it was chosen in.
|
||||
let same = before.as_ref().is_some_and(|b| self.active().is_some_and(|st| st.uid == b.0));
|
||||
self.tool.vertex_kind = if same { vertex_kind } else { None };
|
||||
let msg = panic_message(payload.as_ref());
|
||||
// Keep the document as it was before the command.
|
||||
if let (Some((uid, doc, selection, revision)), Some(st)) = (before, self.active_mut())
|
||||
@@ -95,29 +91,6 @@ mod tests {
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [36, 236, 200, 400]})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_panicking_command_keeps_the_remembered_point_type() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
let kept = Some((deckcraft_model::ShapeId(1), 0, 0, "straight".to_string()));
|
||||
s.tool.vertex_kind = kept.clone();
|
||||
let r = s.guarded("test.panic", |s| {
|
||||
s.tool.vertex_kind = Some((deckcraft_model::ShapeId(1), 0, 0, "corner".to_string()));
|
||||
panic!("boom");
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(s.tool.vertex_kind, kept);
|
||||
// A command that opens another document and then panics leaves no point type behind: the
|
||||
// one from before names a shape of the other document.
|
||||
let r = s.guarded("test.panic", |s| {
|
||||
s.execute("file.new", &json!({}))?;
|
||||
s.tool.vertex_kind = Some((deckcraft_model::ShapeId(1), 0, 0, "corner".to_string()));
|
||||
panic!("boom");
|
||||
});
|
||||
assert!(r.is_err());
|
||||
assert_eq!(s.tool.vertex_kind, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_that_fails_after_editing_leaves_the_document_alone() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -60,7 +60,7 @@ pub fn stroke_width(icon: &Icon, w: f64, h: f64) -> f64 {
|
||||
}
|
||||
|
||||
fn parsed() -> &'static HashMap<&'static str, BezPath> {
|
||||
static CACHE: OnceLock<HashMap<&str, BezPath>> = OnceLock::new();
|
||||
static CACHE: OnceLock<HashMap<&'static str, BezPath>> = OnceLock::new();
|
||||
CACHE.get_or_init(|| {
|
||||
let mut m = HashMap::new();
|
||||
for icon in ICONS {
|
||||
|
||||
+5
-59
@@ -8,14 +8,6 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
crate::file_access::check_read(path)?;
|
||||
let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?;
|
||||
if !meta.is_file() {
|
||||
return Err(format!("{path}: not a regular file"));
|
||||
}
|
||||
const MAX_FILE_SIZE: u64 = 2 << 30; // 2 GiB
|
||||
if meta.len() > MAX_FILE_SIZE {
|
||||
return Err(format!("{path}: file size {} exceeds 2 GiB limit", meta.len()));
|
||||
}
|
||||
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
@@ -36,29 +28,21 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
crate::file_access::check_write(path)?;
|
||||
let err = |e: std::io::Error| format!("{path}: {e}");
|
||||
|
||||
let target_path = match std::fs::canonicalize(path) {
|
||||
Ok(canonical) => canonical,
|
||||
Err(_) => std::path::PathBuf::from(path),
|
||||
};
|
||||
let target_str = target_path.to_str().unwrap_or(path);
|
||||
let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions());
|
||||
|
||||
// A uniquely named temporary sibling, created exclusively (never following or reusing an
|
||||
// existing file or symlink), is renamed over the destination: concurrent saves never share
|
||||
// temporary bytes.
|
||||
let mut attempts = 0;
|
||||
let (tmp, mut file) = loop {
|
||||
let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
let tmp = format!("{path}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
|
||||
Ok(f) => break (tmp, f),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
|
||||
Err(e) => return Err(err(e)),
|
||||
}
|
||||
};
|
||||
if let Some(perms) = existing_perms {
|
||||
let _ = file.set_permissions(perms);
|
||||
}
|
||||
let written = file.write_all(bytes).and_then(|()| file.sync_all());
|
||||
drop(file);
|
||||
let result = written.and_then(|()| std::fs::rename(&tmp, &target_path));
|
||||
let result = written.and_then(|()| std::fs::rename(&tmp, path));
|
||||
if let Err(e) = result {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(err(e));
|
||||
@@ -83,41 +67,3 @@ pub fn file_len(path: &str) -> Option<u64> {
|
||||
pub fn file_len(_path: &str) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn read_file_rejects_directory() {
|
||||
let dir = std::env::temp_dir();
|
||||
let res = read_file(dir.to_str().unwrap());
|
||||
assert!(res.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_preserves_permissions_and_writes_content() {
|
||||
let tmp_dir = std::env::temp_dir().join("deckcraft_io_test");
|
||||
let _ = std::fs::create_dir_all(&tmp_dir);
|
||||
let test_file = tmp_dir.join("test_write.txt");
|
||||
let path_str = test_file.to_str().unwrap();
|
||||
|
||||
assert!(write_file(path_str, b"hello world").is_ok());
|
||||
let read = read_file(path_str).unwrap();
|
||||
assert_eq!(read, b"hello world");
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o640);
|
||||
std::fs::set_permissions(&test_file, perms).unwrap();
|
||||
|
||||
assert!(write_file(path_str, b"updated world").is_ok());
|
||||
let new_meta = std::fs::metadata(&test_file).unwrap();
|
||||
assert_eq!(new_meta.permissions().mode() & 0o777, 0o640);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&test_file);
|
||||
let _ = std::fs::remove_dir(&tmp_dir);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,21 +49,17 @@ impl Session {
|
||||
/// `ArrowLeft`…, `Home`, `End`, `PageUp`, `PageDown`, `F5`) or a character (`b`, `]`).
|
||||
/// Returns `{handled: bool, …}`.
|
||||
pub fn key(&mut self, key: &str, mods: Mods) -> Result<Value> {
|
||||
self.settle_composition();
|
||||
self.end_composition();
|
||||
let editing = self.active().is_some_and(|d| d.selection.text.is_some());
|
||||
let has_shapes = self.active().is_some_and(|d| !d.selection.shapes.is_empty());
|
||||
let k = key_token(key);
|
||||
let k = k.as_str();
|
||||
if matches!(k, "escape" | "esc") && self.cancel_point_drag() {
|
||||
return Ok(json!({"handled": true}));
|
||||
}
|
||||
if self.tool.drawing_freeform() && matches!(k, "enter" | "return" | "escape") {
|
||||
return self.finish_freeform(false);
|
||||
}
|
||||
if self.tool.edit_points.is_some() && matches!(k, "enter" | "return" | "escape") {
|
||||
self.tool.edit_points = None;
|
||||
self.tool.edit_vertex = None;
|
||||
self.tool.edit_segment = None;
|
||||
return Ok(json!({"handled": true, "command": "shape.editPoints"}));
|
||||
}
|
||||
if editing && let Some((id, params)) = self.text_key(k, mods) {
|
||||
@@ -93,21 +89,6 @@ impl Session {
|
||||
return Ok(json!({"handled": true}));
|
||||
}
|
||||
}
|
||||
if !editing && has_shapes && !mods.cmd && mods.shift && !mods.alt {
|
||||
// Shift+arrow grows or shrinks a selected range of table cells.
|
||||
let d = match k {
|
||||
"left" => Some((0, -1)),
|
||||
"right" => Some((0, 1)),
|
||||
"up" => Some((-1, 0)),
|
||||
"down" => Some((1, 0)),
|
||||
_ => None,
|
||||
};
|
||||
if let Some((dr, dc)) = d
|
||||
&& crate::cmd::table::extend_selected_cells(self, dr, dc)?
|
||||
{
|
||||
return Ok(json!({"handled": true}));
|
||||
}
|
||||
}
|
||||
if !editing && has_shapes && !mods.cmd {
|
||||
let step = if mods.alt { 1.0 } else { 6.0 };
|
||||
let d = match k {
|
||||
@@ -266,16 +247,6 @@ impl Session {
|
||||
self.execute("text.commit", &json!({"text": text}))
|
||||
}
|
||||
|
||||
/// Remove text around the composition caret or selection, as an input method asks
|
||||
/// (`ImeEvent::DeleteSurrounding`/`DeleteSurroundingBytes`). Counts are characters or UTF-8
|
||||
/// bytes of the logical text the surrounding query publishes, so a range may cross a
|
||||
/// paragraph mark (read as one `\n` byte) and whole scalars only are removed; out-of-range
|
||||
/// counts are clamped. A deletion while composing stays inside the composition's single undo
|
||||
/// step.
|
||||
pub fn delete_surrounding(&mut self, before: usize, after: usize, bytes: bool) -> Result<Value> {
|
||||
self.execute("text.deleteSurrounding", &json!({"before": before, "after": after, "bytes": bytes}))
|
||||
}
|
||||
|
||||
/// Ready to take typed `text`: editing text already, or one selected shape whose text editing
|
||||
/// starts with everything selected. False when there is nowhere to type (or nothing to type
|
||||
/// and nothing being edited).
|
||||
|
||||
@@ -163,8 +163,6 @@ pub struct HistoryEntry {
|
||||
pub label: String,
|
||||
pub doc: Arc<Presentation>,
|
||||
pub selection: Selection,
|
||||
/// The point type Edit Points remembered with `doc` (`ToolState::vertex_kind`).
|
||||
pub vertex_kind: Option<(ShapeId, usize, usize, String)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -186,8 +184,6 @@ pub struct Interaction {
|
||||
pub label: String,
|
||||
pub doc: Arc<Presentation>,
|
||||
pub selection: Selection,
|
||||
/// The point type Edit Points remembered when it began.
|
||||
pub vertex_kind: Option<(ShapeId, usize, usize, String)>,
|
||||
}
|
||||
|
||||
/// Text an input method is composing (#101): its preedit sits in the edited text body, at `start`
|
||||
@@ -226,12 +222,6 @@ pub struct DocState {
|
||||
pub composition: Option<Composition>,
|
||||
/// Review › Compare in progress: the revised copy.
|
||||
pub compare: Option<compare::Compare>,
|
||||
/// The last Duplicate: where it happened and each original with its copy, so duplicating the
|
||||
/// copies again repeats how far they were moved, as in PowerPoint (#966).
|
||||
pub last_duplicate: Option<(usize, Target, Vec<(ShapeId, ShapeId)>)>,
|
||||
/// The list AutoFormat just started from a typed `* ` or `1. `, so Backspace right after
|
||||
/// can put the typed text back.
|
||||
pub auto_list: Option<cmd::text::AutoList>,
|
||||
}
|
||||
|
||||
static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
@@ -259,8 +249,6 @@ impl DocState {
|
||||
missing_fonts: Vec::new(),
|
||||
composition: None,
|
||||
compare: None,
|
||||
last_duplicate: None,
|
||||
auto_list: None,
|
||||
}
|
||||
}
|
||||
pub fn is_dirty(&self) -> bool {
|
||||
@@ -336,15 +324,13 @@ pub struct Prefs {
|
||||
pub smart_quotes: bool,
|
||||
/// AutoCorrect options (Tools ▸ AutoCorrect): fix TWo INitial CApitals, capitalize the first
|
||||
/// letter of sentences and the names of days, fix accidental cAPS lOCK, and replace 1/2, 1/4
|
||||
/// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) and starts
|
||||
/// bulleted and numbered lists (`* `, `1. `) as you type.
|
||||
/// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) as you type.
|
||||
pub ac_two_initials: bool,
|
||||
pub ac_sentence_caps: bool,
|
||||
pub ac_days: bool,
|
||||
pub ac_caps_lock: bool,
|
||||
pub ac_fractions: bool,
|
||||
pub ac_ordinals: bool,
|
||||
pub ac_lists: bool,
|
||||
/// Your own "replace … with …" entries; they win over the built-in list.
|
||||
pub ac_entries: Vec<(String, String)>,
|
||||
/// Built-in entries you deleted.
|
||||
@@ -370,7 +356,6 @@ impl Default for Prefs {
|
||||
ac_caps_lock: true,
|
||||
ac_fractions: true,
|
||||
ac_ordinals: true,
|
||||
ac_lists: true,
|
||||
ac_entries: Vec::new(),
|
||||
ac_removed: Vec::new(),
|
||||
author: "Presenter".into(),
|
||||
@@ -493,8 +478,6 @@ pub struct Session {
|
||||
/// Presentations a host read off the UI thread, waiting for `file.adopt` (by token).
|
||||
loaded: Vec<(u64, cmd::file::LoadedFile)>,
|
||||
next_loaded: u64,
|
||||
/// Compositions the application settled itself (see [`Session::composition_settles`]).
|
||||
composition_settles: u64,
|
||||
}
|
||||
|
||||
impl Default for Session {
|
||||
@@ -504,8 +487,6 @@ impl Default for Session {
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Install the process panic hook and construct an empty session with default preferences,
|
||||
/// clipboard, tool state, and no active document.
|
||||
pub fn new() -> Self {
|
||||
guard::install_panic_hook();
|
||||
Session {
|
||||
@@ -528,7 +509,6 @@ impl Session {
|
||||
panics_caught: 0,
|
||||
loaded: vec![],
|
||||
next_loaded: 0,
|
||||
composition_settles: 0,
|
||||
}
|
||||
}
|
||||
/// Hand over a presentation read on another thread ([`cmd::file::read_presentation_file`]);
|
||||
@@ -573,21 +553,16 @@ impl Session {
|
||||
self.active_mut().ok_or(EngineError::NoDocument)
|
||||
}
|
||||
pub fn set_active(&mut self, i: usize) {
|
||||
let was = self.active().map(|d| d.uid);
|
||||
if i < self.docs.len() {
|
||||
self.active = Some(i);
|
||||
}
|
||||
self.forget_vertex_kind_unless(was);
|
||||
}
|
||||
pub fn add_document(&mut self, d: DocState) -> usize {
|
||||
let was = self.active().map(|d| d.uid);
|
||||
self.docs.push(d);
|
||||
self.active = Some(self.docs.len() - 1);
|
||||
self.forget_vertex_kind_unless(was);
|
||||
self.docs.len() - 1
|
||||
}
|
||||
pub fn close_document(&mut self, i: usize) {
|
||||
let was = self.active().map(|d| d.uid);
|
||||
if i < self.docs.len() {
|
||||
let d = self.docs.remove(i);
|
||||
if let Some(dir) = &self.recovery_dir {
|
||||
@@ -595,14 +570,6 @@ impl Session {
|
||||
}
|
||||
self.active = if self.docs.is_empty() { None } else { Some(i.min(self.docs.len() - 1)) };
|
||||
}
|
||||
self.forget_vertex_kind_unless(was);
|
||||
}
|
||||
/// The point type Edit Points remembers names a shape of the active document, and another
|
||||
/// document can have a shape with the same id: a change of active document forgets it.
|
||||
fn forget_vertex_kind_unless(&mut self, was: Option<u64>) {
|
||||
if self.active().map(|d| d.uid) != was {
|
||||
self.tool.vertex_kind = None;
|
||||
}
|
||||
}
|
||||
pub fn next_untitled(&mut self) -> String {
|
||||
self.untitled += 1;
|
||||
@@ -615,13 +582,11 @@ impl Session {
|
||||
self.guarded(id, |s| s.execute_unguarded(id, params))
|
||||
}
|
||||
|
||||
/// Resolve and run a command, settling composition first, checking eligibility, rolling back a
|
||||
/// failed outer command, and committing the resulting undo entry or journal record.
|
||||
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?;
|
||||
// Any other edit (or a click, a key, undo) settles an input method's composition first.
|
||||
if self.depth == 0 && (spec.journal || spec.undoable) && !matches!(id, "text.compose" | "text.commit") {
|
||||
self.settle_composition();
|
||||
self.end_composition();
|
||||
}
|
||||
if let Err(e) = (spec.enabled)(self) {
|
||||
let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some());
|
||||
@@ -630,7 +595,6 @@ impl Session {
|
||||
}
|
||||
}
|
||||
let before = self.active().map(|d| (d.uid, d.doc.clone(), d.selection.clone()));
|
||||
let vertex_kind = self.tool.vertex_kind.clone();
|
||||
let outer = self.depth == 0;
|
||||
self.depth += 1;
|
||||
let r = (spec.run)(self, params);
|
||||
@@ -649,9 +613,6 @@ impl Session {
|
||||
st.selection = sel;
|
||||
st.revision += 1;
|
||||
}
|
||||
if outer {
|
||||
self.tool.vertex_kind = vertex_kind;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
@@ -665,7 +626,7 @@ impl Session {
|
||||
&& spec.undoable
|
||||
&& outer
|
||||
{
|
||||
push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel, vertex_kind });
|
||||
push_undo(st, HistoryEntry { label: spec.label.to_string(), doc: old, selection: sel });
|
||||
}
|
||||
if spec.journal && outer {
|
||||
self.journal.push((id.to_string(), params.clone()));
|
||||
@@ -695,39 +656,14 @@ impl Session {
|
||||
|
||||
/// Keep an input method's composition as it stands (#101): its text stays and the whole
|
||||
/// composition becomes one undo step. Nothing happens without one.
|
||||
///
|
||||
/// This is also how the input method's own commit ends a composition: the platform has
|
||||
/// already finished that one, so it must not ask for an interrupt.
|
||||
pub fn end_composition(&mut self) {
|
||||
let vertex_kind = self.tool.vertex_kind.clone();
|
||||
let Some(st) = self.active_mut() else { return };
|
||||
let Some(c) = st.composition.take() else { return };
|
||||
if !Arc::ptr_eq(&c.before, &st.doc) {
|
||||
push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection, vertex_kind });
|
||||
push_undo(st, HistoryEntry { label: "Type Text".into(), doc: c.before, selection: c.before_selection });
|
||||
}
|
||||
}
|
||||
|
||||
/// Settle a composition because the application did something else (a click, a key, a
|
||||
/// command, a target change) rather than because the input method committed it.
|
||||
///
|
||||
/// The platform still holds its own copy of that composition, so a host must publish one
|
||||
/// `should_interrupt_composition` for each increment of [`Self::composition_settles`]: the
|
||||
/// backend then cancels it instead of committing its text into whatever is selected next.
|
||||
pub fn settle_composition(&mut self) {
|
||||
if self.active().is_some_and(|d| d.composition.is_some()) {
|
||||
self.composition_settles = self.composition_settles.wrapping_add(1);
|
||||
}
|
||||
self.end_composition();
|
||||
}
|
||||
|
||||
/// How many compositions the application settled itself (clicks, keys, other commands).
|
||||
///
|
||||
/// A host compares this with the value it last published; every increment is one platform
|
||||
/// composition that must be interrupted. The input method's own commits do not count.
|
||||
pub fn composition_settles(&self) -> u64 {
|
||||
self.composition_settles
|
||||
}
|
||||
|
||||
/// Change only the selection (no undo step).
|
||||
pub fn select(&mut self, f: impl FnOnce(&Presentation, &mut Selection)) -> Result<()> {
|
||||
let st = self.doc_mut()?;
|
||||
@@ -743,7 +679,7 @@ impl Session {
|
||||
st.revision += 1;
|
||||
// A composition belongs to the text body it began in: leaving that body settles it.
|
||||
if st.composition.is_some() && owner(&st.selection) != before {
|
||||
self.settle_composition();
|
||||
self.end_composition();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -44,11 +44,6 @@ pub fn instance_id() -> u64 {
|
||||
*ID.get_or_init(|| {
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let mut h = std::collections::hash_map::RandomState::new().build_hasher();
|
||||
// No PIDs and no std::time on wasm (`std::process::id()` and
|
||||
// `SystemTime::now()` panic there): the browser clock stands in.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
h.write_u64(js_sys::Date::now() as u64);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
h.write_u32(std::process::id());
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Ok(d) = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
|
||||
@@ -85,20 +80,8 @@ fn owner_alive(dir: &Path, owner: u64) -> bool {
|
||||
|
||||
/// Write `bytes` to `path` through a temporary file and a rename, so a crash never leaves it torn.
|
||||
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
let tmp = path.with_extension(format!("{}.tmp", path.extension().and_then(|e| e.to_str()).unwrap_or("")));
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
opts.mode(0o600);
|
||||
}
|
||||
let mut file = opts.open(&tmp).map_err(|e| EngineError::Other(format!("{}: {e}", tmp.display())))?;
|
||||
file.write_all(bytes).and_then(|()| file.sync_all()).and_then(|()| std::fs::rename(&tmp, path)).map_err(|e| {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
EngineError::Other(format!("{}: {e}", path.display()))
|
||||
})
|
||||
std::fs::write(&tmp, bytes).and_then(|_| std::fs::rename(&tmp, path)).map_err(|e| EngineError::Other(format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
/// Presentations reopened per start; the rest stay on disk for the next one, so a folder
|
||||
@@ -164,13 +147,13 @@ pub fn list(dir: &Path) -> Vec<(u64, Value)> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return out };
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension().and_then(std::ffi::OsStr::to_str) != Some("deckcraft") {
|
||||
if p.extension().and_then(|x| x.to_str()) != Some("deckcraft") {
|
||||
continue;
|
||||
}
|
||||
if !std::fs::metadata(&p).is_ok_and(|m| m.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let Some(uid) = p.file_stem().and_then(std::ffi::OsStr::to_str).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
let Some(uid) = p.file_stem().and_then(|s| s.to_str()).and_then(|s| s.parse::<u64>().ok()) else { continue };
|
||||
// Metadata is a file on disk: anything but an object (corrupt, hand-edited) counts as none.
|
||||
let meta = read_regular(&p.with_extension("json"), MAX_META_BYTES)
|
||||
.and_then(|t| serde_json::from_slice::<Value>(&t).ok())
|
||||
|
||||
+2
-1302
File diff suppressed because it is too large
Load Diff
@@ -437,147 +437,3 @@ fn select_into_another_table_cell_settles_the_composition() {
|
||||
let cell = |c| t.cell(0, c).map(|x| x.text.text()).unwrap_or_default();
|
||||
assert_eq!((cell(0), cell(1)), ("abhello".to_string(), "X0123456789".to_string()));
|
||||
}
|
||||
|
||||
/// Bold the first paragraph, select part of it and arm the Format Painter.
|
||||
fn painter_armed(sticky: bool) -> (Session, u32) {
|
||||
let (mut s, id) = editing("Hello\nworld", (0, 0));
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();
|
||||
s.execute("format.bold", &json!({"on": true})).unwrap();
|
||||
s.execute("format.painter", &json!({"sticky": sticky})).unwrap();
|
||||
(s, id)
|
||||
}
|
||||
|
||||
fn bold_at(s: &mut Session, at: Pos) -> bool {
|
||||
s.execute("text.select", &json!({"anchor": [at.0, at.1], "caret": [at.0, at.1 + 1]})).unwrap();
|
||||
s.execute("format.state", &json!({})).unwrap()["bold"] == true
|
||||
}
|
||||
|
||||
fn pointer(s: &mut Session, kind: crate::tools::PointerKind, x: f64, y: f64) {
|
||||
s.pointer(crate::tools::PointerEvent { kind, x, y, tol: 4.0, ..Default::default() }).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_applies_to_a_dragged_text_selection() {
|
||||
use crate::tools::PointerKind::{Down, Drag, Up};
|
||||
let (mut s, _) = painter_armed(false);
|
||||
assert!(!bold_at(&mut s, (1, 0)));
|
||||
s.execute("text.move", &json!({"to": "start"})).unwrap();
|
||||
// Drag across the second line's text.
|
||||
pointer(&mut s, Down, 68.0, 90.0);
|
||||
pointer(&mut s, Drag, 110.0, 90.0);
|
||||
pointer(&mut s, Up, 110.0, 90.0);
|
||||
let t = s.doc().unwrap().selection.text.clone().unwrap();
|
||||
assert!(t.is_range(), "the drag should select text");
|
||||
assert!(s.painter.is_none(), "the painter is used up");
|
||||
assert!(bold_at(&mut s, (1, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_applies_to_a_triple_clicked_paragraph() {
|
||||
use crate::tools::PointerKind::TripleClick;
|
||||
let (mut s, _) = painter_armed(true);
|
||||
s.execute("text.select", &json!({"anchor": [1, 2], "caret": [1, 2]})).unwrap();
|
||||
pointer(&mut s, TripleClick, 70.0, 90.0);
|
||||
assert!(s.painter.is_some(), "a sticky painter stays armed");
|
||||
assert!(bold_at(&mut s, (1, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_waits_for_a_range_on_a_plain_click() {
|
||||
use crate::tools::PointerKind::{Down, Up};
|
||||
let (mut s, _) = painter_armed(false);
|
||||
pointer(&mut s, Down, 68.0, 90.0);
|
||||
pointer(&mut s, Up, 68.0, 90.0);
|
||||
assert!(s.painter.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_on_part_of_a_bulleted_line_keeps_its_bullet() {
|
||||
use crate::tools::PointerKind::{Down, Drag, Up};
|
||||
let (mut s, _) = painter_armed(false);
|
||||
s.execute("text.select", &json!({"anchor": [1, 0], "caret": [1, 5]})).unwrap();
|
||||
s.execute("format.bullets", &json!({"on": true})).unwrap();
|
||||
let bulleted = |s: &mut Session| s.execute("format.state", &json!({})).unwrap()["bullets"] == true;
|
||||
assert!(bulleted(&mut s));
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 5]})).unwrap();
|
||||
s.execute("format.painter", &json!({})).unwrap();
|
||||
pointer(&mut s, Down, 68.0, 90.0);
|
||||
pointer(&mut s, Drag, 100.0, 90.0);
|
||||
pointer(&mut s, Up, 100.0, 90.0);
|
||||
assert!(s.painter.is_none());
|
||||
assert!(bold_at(&mut s, (1, 0)));
|
||||
assert!(bulleted(&mut s), "the bullet stays");
|
||||
}
|
||||
|
||||
/// Three bulleted paragraphs "one", "two", "three" and the painter armed from bold plain text.
|
||||
fn painter_over_bullets() -> Session {
|
||||
let (mut s, _) = editing("one\ntwo\nthree\nplain", (0, 0));
|
||||
s.execute("text.select", &json!({"anchor": [3, 0], "caret": [3, 5]})).unwrap();
|
||||
s.execute("format.bold", &json!({"on": true})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [2, 5]})).unwrap();
|
||||
s.execute("format.bullets", &json!({"on": true})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [3, 0], "caret": [3, 5]})).unwrap();
|
||||
s.execute("format.painter", &json!({})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn bulleted(s: &mut Session, para: usize) -> bool {
|
||||
s.execute("text.select", &json!({"anchor": [para, 0], "caret": [para, 0]})).unwrap();
|
||||
s.execute("format.state", &json!({})).unwrap()["bullets"] == true
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_paints_only_the_paragraphs_a_selection_covers() {
|
||||
// Mid paragraph 0 to mid paragraph 2: only paragraph 1 is covered, so only it loses its bullet.
|
||||
let mut s = painter_over_bullets();
|
||||
s.execute("text.select", &json!({"anchor": [0, 1], "caret": [2, 2]})).unwrap();
|
||||
s.execute("format.painterApply", &json!({})).unwrap();
|
||||
assert_eq!([bulleted(&mut s, 0), bulleted(&mut s, 1), bulleted(&mut s, 2)], [true, false, true]);
|
||||
// Start of paragraph 0 to mid paragraph 2: the partly covered last paragraph keeps its bullet.
|
||||
let mut s = painter_over_bullets();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [2, 2]})).unwrap();
|
||||
s.execute("format.painterApply", &json!({})).unwrap();
|
||||
assert_eq!([bulleted(&mut s, 0), bulleted(&mut s, 1), bulleted(&mut s, 2)], [false, false, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_triple_click_leaves_the_next_paragraph_alone() {
|
||||
use crate::tools::PointerKind::TripleClick;
|
||||
let mut s = painter_over_bullets();
|
||||
s.execute("text.select", &json!({"anchor": [0, 1], "caret": [0, 1]})).unwrap();
|
||||
pointer(&mut s, TripleClick, 70.0, 70.0);
|
||||
assert!(s.painter.is_none());
|
||||
assert_eq!([bulleted(&mut s, 0), bulleted(&mut s, 1), bulleted(&mut s, 2)], [false, true, true]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_ignores_a_double_click_that_selects_nothing() {
|
||||
use crate::tools::PointerKind::DoubleClick;
|
||||
let mut s = painter_over_bullets();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [1, 2]})).unwrap();
|
||||
let before = sel(&s);
|
||||
pointer(&mut s, DoubleClick, 900.0, 520.0);
|
||||
assert_eq!(sel(&s), before);
|
||||
assert!(s.painter.is_some(), "a click that left the selection alone keeps the painter");
|
||||
assert!(bulleted(&mut s, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_painter_ignores_a_shift_press_that_leaves_the_selection() {
|
||||
use crate::tools::PointerKind::{Down, Up};
|
||||
let (mut s, _) = painter_armed(false);
|
||||
// Find the caret position under the point, then select from the line start to it.
|
||||
pointer(&mut s, Down, 110.0, 90.0);
|
||||
pointer(&mut s, Up, 110.0, 90.0);
|
||||
let k = caret(&s);
|
||||
assert!(k.1 > 0);
|
||||
s.execute("text.select", &json!({"anchor": [k.0, 0], "caret": [k.0, k.1]})).unwrap();
|
||||
// Shift-pressing at the caret keeps the range, so there is nothing new to paint.
|
||||
let shift = Mods { shift: true, ..NONE };
|
||||
for kind in [Down, Up] {
|
||||
s.pointer(crate::tools::PointerEvent { kind, x: 110.0, y: 90.0, mods: shift, tol: 4.0, ..Default::default() }).unwrap();
|
||||
}
|
||||
assert_eq!(sel(&s), ((k.0, 0), k));
|
||||
assert!(s.painter.is_some(), "the painter stays armed");
|
||||
assert!(!bold_at(&mut s, (1, 0)));
|
||||
}
|
||||
|
||||
+28
-196
@@ -34,9 +34,6 @@ pub struct Mods {
|
||||
pub cmd: bool,
|
||||
}
|
||||
|
||||
/// A text selection's shape, table cell, notes flag, anchor and caret.
|
||||
type TextTarget = (ShapeId, Option<(usize, usize)>, bool, (usize, usize), (usize, usize));
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct PointerEvent {
|
||||
@@ -194,11 +191,6 @@ enum Drag {
|
||||
},
|
||||
TextSelect {
|
||||
shape: ShapeId,
|
||||
/// The text selection before the press, to tell whether the drag changed it.
|
||||
before: Option<TextTarget>,
|
||||
/// The text selection the press made. In a table, a drag that leaves its cell selects
|
||||
/// whole cells, and coming back to the cell restores this.
|
||||
press: Option<TextSel>,
|
||||
},
|
||||
/// Dragging a table border: the right edge of column `index`, or the bottom edge of row
|
||||
/// `index`. `start` is the table-local press point, `size` the column's width or the row's
|
||||
@@ -269,13 +261,8 @@ pub struct ToolState {
|
||||
pub edit_points: Option<ShapeId>,
|
||||
/// The vertex (path, index) whose control handles show in Edit Points.
|
||||
pub edit_vertex: Option<(usize, usize)>,
|
||||
/// The segment (path, index of the vertex ending it) last right-clicked in Edit Points, for
|
||||
/// the segment menu.
|
||||
pub edit_segment: Option<(usize, usize)>,
|
||||
/// Where on that segment the right click was (0..1), where Add Point puts the new point.
|
||||
pub edit_segment_t: Option<f64>,
|
||||
/// The point type last chosen for a vertex (shape, path, index, type): handle drags follow it.
|
||||
pub vertex_kind: Option<(ShapeId, usize, usize, String)>,
|
||||
/// The point type last chosen for a vertex (path, index, type): handle drags follow it.
|
||||
pub vertex_kind: Option<(usize, usize, String)>,
|
||||
/// Drawing guides are shown (View › Guides): they can be dragged and shapes snap to them.
|
||||
pub guides_visible: bool,
|
||||
/// The guide being dragged and where it is now (slide points); `None` once dropped.
|
||||
@@ -459,7 +446,7 @@ impl Session {
|
||||
}
|
||||
// A press (not a hover) settles an input method's composition, as clicking does in PowerPoint.
|
||||
if !matches!(ev.kind, PointerKind::Move) {
|
||||
self.settle_composition();
|
||||
self.end_composition();
|
||||
}
|
||||
match ev.kind {
|
||||
PointerKind::Move => {
|
||||
@@ -487,17 +474,11 @@ impl Session {
|
||||
PointerKind::Down => self.down(p, ev.mods, tol),
|
||||
PointerKind::Drag => self.drag(p, ev.mods, tol),
|
||||
PointerKind::Up => self.up(p, ev.mods),
|
||||
PointerKind::DoubleClick => {
|
||||
let before = self.text_selection();
|
||||
let r = self.double_click(p, tol)?;
|
||||
self.paint_if_selection_changed(before, r)
|
||||
}
|
||||
PointerKind::DoubleClick => self.double_click(p, tol),
|
||||
PointerKind::ContextClick => self.context_click(p, tol),
|
||||
PointerKind::TripleClick => {
|
||||
if self.active().is_some_and(|d| d.selection.text.is_some()) {
|
||||
let before = self.text_selection();
|
||||
let r = self.execute("text.selectParagraph", &json!({}))?;
|
||||
self.paint_if_selection_changed(before, r)
|
||||
self.execute("text.selectParagraph", &json!({}))
|
||||
} else {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
@@ -505,33 +486,11 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Format Painter applies once a text range is selected (drag, word or paragraph click).
|
||||
fn paint_text_selection(&mut self) -> Result<Option<Value>> {
|
||||
if self.painter.is_some() && self.doc()?.selection.text.as_ref().is_some_and(|t| t.is_range()) {
|
||||
return self.execute("format.painterApply", &json!({})).map(Some);
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Where the text selection is: its shape, cell or notes, anchor and caret.
|
||||
fn text_selection(&self) -> Option<TextTarget> {
|
||||
self.active().and_then(|d| d.selection.text.as_ref()).map(|t| (t.shape, t.cell, t.notes, t.anchor, t.caret))
|
||||
}
|
||||
|
||||
/// A double or triple click paints only the range it selected, not an older one it left alone.
|
||||
fn paint_if_selection_changed(&mut self, before: Option<TextTarget>, r: Value) -> Result<Value> {
|
||||
if self.text_selection() == before {
|
||||
return Ok(r);
|
||||
}
|
||||
self.paint_text_selection().map(|a| a.unwrap_or(r))
|
||||
}
|
||||
|
||||
pub(crate) fn begin(&mut self, label: &str) {
|
||||
let vertex_kind = self.tool.vertex_kind.clone();
|
||||
if let Some(st) = self.active_mut()
|
||||
&& st.interaction.is_none()
|
||||
{
|
||||
st.interaction = Some(crate::Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), vertex_kind });
|
||||
st.interaction = Some(crate::Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -540,33 +499,10 @@ impl Session {
|
||||
&& let Some(i) = st.interaction.take()
|
||||
&& !Arc::ptr_eq(&i.doc, &st.doc)
|
||||
{
|
||||
crate::push_undo(st, HistoryEntry { label: i.label, doc: i.doc, selection: i.selection, vertex_kind: i.vertex_kind });
|
||||
crate::push_undo(st, HistoryEntry { label: i.label, doc: i.doc, selection: i.selection });
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels a point or handle drag in Edit Points when Esc is pressed: ends the drag, puts back the
|
||||
/// document and selection saved at the press, and adds no undo step. Returns whether a drag was cancelled.
|
||||
pub(crate) fn cancel_point_drag(&mut self) -> bool {
|
||||
if !matches!(self.tool.drag, Some(Drag::Vertex { .. } | Drag::Handle { .. })) {
|
||||
return false;
|
||||
}
|
||||
self.tool.drag = None;
|
||||
if let Some(st) = self.active_mut()
|
||||
&& st.interaction.as_ref().is_some_and(|i| i.label == "Edit Points")
|
||||
&& let Some(i) = st.interaction.take()
|
||||
{
|
||||
// A modifier held during the drag may have set a point type; the drag is undone, so is that.
|
||||
let kind = i.vertex_kind;
|
||||
if !Arc::ptr_eq(&i.doc, &st.doc) {
|
||||
st.doc = i.doc;
|
||||
st.selection = i.selection;
|
||||
st.revision += 1;
|
||||
}
|
||||
self.tool.vertex_kind = kind;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// End the interaction only when it is the one `label` started. A twist must not commit a drag.
|
||||
pub(crate) fn commit_named(&mut self, label: &str) {
|
||||
let ours = self.active().and_then(|st| st.interaction.as_ref()).is_some_and(|i| i.label == label);
|
||||
@@ -616,10 +552,8 @@ impl Session {
|
||||
ToolKind::Select => {}
|
||||
}
|
||||
// Clicking inside the text being edited moves the caret / starts a text selection.
|
||||
let before = self.text_selection();
|
||||
if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? {
|
||||
let press = self.doc()?.selection.text.clone();
|
||||
self.tool.drag = Some(Drag::TextSelect { shape, before, press });
|
||||
self.tool.drag = Some(Drag::TextSelect { shape });
|
||||
return Ok(json!({"caret": [pos.0, pos.1]}));
|
||||
}
|
||||
let hit = self.hit_test(p, tol);
|
||||
@@ -704,18 +638,6 @@ impl Session {
|
||||
self.tool.drag = Some(Drag::Warp { id, index, name, base, orig });
|
||||
}
|
||||
}
|
||||
Some(Hit::Shape { id, .. })
|
||||
if !(mods.shift || mods.cmd) && st.selection.cells.is_some_and(|c| c.0 == id && st.selection.shapes == [id]) =>
|
||||
{
|
||||
// A press in a table whose cells are selected starts again in the cell under the
|
||||
// pointer: a click puts the caret there and a drag selects another range.
|
||||
let before = self.text_selection();
|
||||
if self.edit_text_at(id, p)? {
|
||||
self.select(|_, sel| sel.cells = None)?;
|
||||
let press = self.doc()?.selection.text.clone();
|
||||
self.tool.drag = Some(Drag::TextSelect { shape: id, before, press });
|
||||
}
|
||||
}
|
||||
Some(Hit::Shape { id, text }) => {
|
||||
let selected = st.selection.shapes.contains(&id);
|
||||
let toggle = mods.shift || mods.cmd;
|
||||
@@ -739,12 +661,7 @@ impl Session {
|
||||
// placeholders take the caret straight away like PowerPoint. A click in a selected
|
||||
// table puts the caret in the clicked cell.
|
||||
let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_)));
|
||||
// A click on the frame band of a text box selects the box whole, as PowerPoint
|
||||
// does, so it can be moved, duplicated or deleted; only the inside takes the caret.
|
||||
let on_frame = text
|
||||
&& is_text_first(st, id)
|
||||
&& st.shape(id).is_some_and(|s| edit_zone(&xfrm_of(&st.doc, &st.selection, s), p, tol) == EditZone::Frame);
|
||||
let enter_text = (!toggle && !on_frame && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
|
||||
let enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
|
||||
let ids2 = ids.clone();
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = ids2;
|
||||
@@ -935,18 +852,7 @@ impl Session {
|
||||
Drag::Handle { id, path, index, out } => {
|
||||
self.tool.drag = Some(Drag::Handle { id, path, index, out });
|
||||
let side = if out { "out" } else { "in" };
|
||||
// Shift, Ctrl or Alt held while dragging a handle makes the point smooth, straight or
|
||||
// corner, as in PowerPoint; Cmd and Option count as Ctrl and Alt.
|
||||
let held = if mods.shift {
|
||||
Some("smooth")
|
||||
} else if mods.cmd {
|
||||
Some("straight")
|
||||
} else if mods.alt {
|
||||
Some("corner")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Err(e) = self.execute_inner_move_handle(id, path, index, side, p, held) {
|
||||
if let Err(e) = self.execute_inner_move_handle(id, path, index, side, p) {
|
||||
log::debug!("edit handle: {e}");
|
||||
}
|
||||
return Ok(Value::Null);
|
||||
@@ -1003,29 +909,8 @@ impl Session {
|
||||
};
|
||||
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;
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Drag::TextSelect { shape } => {
|
||||
// The selection stays in the shape or table cell where it started.
|
||||
let st = self.doc()?;
|
||||
if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) {
|
||||
let pos = cmd::text::text_pos_at(st, t, p);
|
||||
@@ -1086,19 +971,9 @@ impl Session {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves a control handle during a drag. `held` is the point type set by the modifier held
|
||||
/// during the drag; the point keeps that type for later drags, as after `shape.pointType`.
|
||||
fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point, held: Option<&str>) -> Result<Value> {
|
||||
let kind = match held {
|
||||
Some(k) => Some(k.to_string()),
|
||||
None => self.tool.vertex_kind.clone().filter(|(s, pp, i, _)| (*s, *pp, *i) == (id, path, index)).map(|k| k.3),
|
||||
};
|
||||
let r =
|
||||
cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind}))?;
|
||||
if let Some(k) = held {
|
||||
self.tool.vertex_kind = Some((id, path, index, k.to_string()));
|
||||
}
|
||||
Ok(r)
|
||||
fn execute_inner_move_handle(&mut self, id: ShapeId, path: usize, index: usize, side: &str, p: Point) -> Result<Value> {
|
||||
let kind = self.tool.vertex_kind.clone().filter(|(pp, i, _)| (*pp, *i) == (path, index)).map(|k| k.2);
|
||||
cmd::points::move_handle_raw(self, &json!({"id": id.0, "path": path, "index": index, "side": side, "x": p.x, "y": p.y, "type": kind}))
|
||||
}
|
||||
|
||||
fn up(&mut self, p: Point, mods: Mods) -> Result<Value> {
|
||||
@@ -1229,10 +1104,6 @@ impl Session {
|
||||
let _ = cmd::text::fit_text_box(self, id);
|
||||
self.refit_after_resize(id)?;
|
||||
}
|
||||
Some(Drag::TextSelect { before, .. }) => {
|
||||
out = self.paint_if_selection_changed(before, out)?;
|
||||
self.commit();
|
||||
}
|
||||
Some(_) => self.commit(),
|
||||
None => {}
|
||||
}
|
||||
@@ -1335,12 +1206,6 @@ impl Session {
|
||||
) => Some(*id),
|
||||
None => None,
|
||||
};
|
||||
// Edit Points: the point or else the segment under the pointer picks the menu.
|
||||
let picked = self.tool.editing_points(st).and_then(|eid| st.shape(eid)).map(|sh| match &hit {
|
||||
Some(Hit::Vertex { path, index, .. }) => (Some((*path, *index)), None),
|
||||
Some(Hit::Handle { .. }) => (None, None),
|
||||
_ => (None, cmd::points::segment_at(&st.doc, &st.selection, sh, p, tol * 1.6)),
|
||||
});
|
||||
if !id.is_some_and(|id| st.selection.shapes.contains(&id)) {
|
||||
self.select(|_, sel| {
|
||||
sel.text = None;
|
||||
@@ -1348,14 +1213,6 @@ impl Session {
|
||||
sel.shapes = id.into_iter().collect();
|
||||
})?;
|
||||
}
|
||||
if let Some((vertex, segment)) = picked {
|
||||
// A control handle belongs to the picked point: its menu stays.
|
||||
if !matches!(hit, Some(Hit::Handle { .. })) {
|
||||
self.tool.edit_vertex = vertex;
|
||||
}
|
||||
self.tool.edit_segment = segment.map(|(path, seg, _)| (path, seg));
|
||||
self.tool.edit_segment_t = segment.map(|(_, _, t)| t);
|
||||
}
|
||||
let st = self.doc()?;
|
||||
Ok(json!({
|
||||
"hit": serde_json::to_value(&hit).unwrap_or_default(),
|
||||
@@ -1592,13 +1449,6 @@ impl Session {
|
||||
/// Smart guides: snap `r` edges/centres to the slide and other shapes. Returns the offset to
|
||||
/// apply and the guides to draw.
|
||||
fn snap_rect(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool) -> (Vec2, Vec<Guide>) {
|
||||
self.snap_edges(r, exclude, tol, enabled, [true; 3], [true; 3])
|
||||
}
|
||||
|
||||
/// `snap_rect` looking only at some of `r`'s edges: `xs_on` is left, centre, right and `ys_on`
|
||||
/// top, centre, bottom. Resizing passes just the edges being dragged, so a fixed edge that
|
||||
/// already sits on a line (distance 0) doesn't win and leave the dragged edge unsnapped.
|
||||
fn snap_edges(&self, r: Rect, exclude: &[ShapeId], tol: f64, enabled: bool, xs_on: [bool; 3], ys_on: [bool; 3]) -> (Vec2, Vec<Guide>) {
|
||||
let Some(st) = self.active() else { return (Vec2::ZERO, vec![]) };
|
||||
if !enabled || !self.prefs.smart_guides {
|
||||
return (Vec2::ZERO, vec![]);
|
||||
@@ -1636,7 +1486,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
let mut best_x: Option<(f64, f64, Rect)> = None;
|
||||
for edge in [r.x0, r.center().x, r.x1].into_iter().zip(xs_on).filter_map(|(e, on)| on.then_some(e)) {
|
||||
for edge in [r.x0, r.center().x, r.x1] {
|
||||
for (x, b) in &xs {
|
||||
let d = x - edge;
|
||||
if d.abs() <= tol && best_x.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) {
|
||||
@@ -1645,7 +1495,7 @@ impl Session {
|
||||
}
|
||||
}
|
||||
let mut best_y: Option<(f64, f64, Rect)> = None;
|
||||
for edge in [r.y0, r.center().y, r.y1].into_iter().zip(ys_on).filter_map(|(e, on)| on.then_some(e)) {
|
||||
for edge in [r.y0, r.center().y, r.y1] {
|
||||
for (y, b) in &ys {
|
||||
let d = y - edge;
|
||||
if d.abs() <= tol && best_y.is_none_or(|(bd, _, _)| d.abs() < bd.abs()) {
|
||||
@@ -1659,49 +1509,32 @@ impl Session {
|
||||
off.x = d;
|
||||
let rr = r + Vec2::new(d, 0.0);
|
||||
guides.push(Guide { vertical: true, pos: x, from: b.y0.min(rr.y0), to: b.y1.max(rr.y1) });
|
||||
} else if self.prefs.snap_to_grid && xs_on.contains(&true) {
|
||||
let at = if xs_on[0] {
|
||||
r.x0
|
||||
} else if xs_on[2] {
|
||||
r.x1
|
||||
} else {
|
||||
r.center().x
|
||||
};
|
||||
off.x = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at;
|
||||
} else if self.prefs.snap_to_grid {
|
||||
off.x = deckcraft_geom::snap(r.x0, self.prefs.grid_spacing) - r.x0;
|
||||
}
|
||||
if let Some((d, y, b)) = best_y {
|
||||
off.y = d;
|
||||
let rr = r + Vec2::new(0.0, d);
|
||||
guides.push(Guide { vertical: false, pos: y, from: b.x0.min(rr.x0), to: b.x1.max(rr.x1) });
|
||||
} else if self.prefs.snap_to_grid && ys_on.contains(&true) {
|
||||
let at = if ys_on[0] {
|
||||
r.y0
|
||||
} else if ys_on[2] {
|
||||
r.y1
|
||||
} else {
|
||||
r.center().y
|
||||
};
|
||||
off.y = deckcraft_geom::snap(at, self.prefs.grid_spacing) - at;
|
||||
} else if self.prefs.snap_to_grid {
|
||||
off.y = deckcraft_geom::snap(r.y0, self.prefs.grid_spacing) - r.y0;
|
||||
}
|
||||
(off, guides)
|
||||
}
|
||||
|
||||
/// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.
|
||||
fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
|
||||
if x.rot != 0.0 {
|
||||
self.tool.guides.clear();
|
||||
return x;
|
||||
}
|
||||
let r = x.rect();
|
||||
// Only the dragged edges move, so only they look for a line to snap to. The handles sit in
|
||||
// the box's unflipped frame: in a flipped box, also one that a drag past the opposite edge
|
||||
// just flipped, the dragged handle is on the other side.
|
||||
let (left, right) = (matches!(handle, 0 | 6 | 7), matches!(handle, 2..=4));
|
||||
let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6));
|
||||
let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) };
|
||||
let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) };
|
||||
let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
|
||||
let (off, guides) = self.snap_rect(r, &[id], tol * 1.5, true);
|
||||
let mut nx = x;
|
||||
// Only the dragged edges move.
|
||||
let moves_left = matches!(handle, 0 | 6 | 7);
|
||||
let moves_right = matches!(handle, 2..=4);
|
||||
let moves_top = matches!(handle, 0..=2);
|
||||
let moves_bottom = matches!(handle, 4..=6);
|
||||
if moves_left {
|
||||
nx.x += off.x;
|
||||
nx.w -= off.x;
|
||||
@@ -1775,9 +1608,8 @@ impl Session {
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let vertex_kind = self.tool.vertex_kind.clone();
|
||||
if let Some(st) = self.active_mut() {
|
||||
crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before, vertex_kind });
|
||||
crate::push_undo(st, HistoryEntry { label: "Erase".into(), doc: before, selection: sel_before });
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
mod chart_native;
|
||||
mod show_clicks;
|
||||
@@ -1,262 +0,0 @@
|
||||
//! #844: a paragraph-by-paragraph build read from a PPTX file takes the clicks PowerPoint gives
|
||||
//! it. The file lists one effect per paragraph, each with its own start (On Click or With
|
||||
//! Previous); the show must not ask for more clicks, or fewer, than those effects.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_anim::{ShowAction, ShowState};
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::anim::{AnimStart, Animation, TextBuild};
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{LayoutType, Presentation, Shape, ShapeId, defaults};
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
fn shape(p: &mut Presentation, name: &str, text: &str, y: f64) -> Shape {
|
||||
Shape {
|
||||
id: ShapeId(p.alloc_id().expect("id")),
|
||||
name: name.into(),
|
||||
xfrm: Some(Xfrm::new(100.0, y, 600.0, 150.0)),
|
||||
text: Some(TextBody::from_text(text)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Two slides: "Body" (paragraphs `body`, at `levels`) and "Picture" on the first, nothing on
|
||||
/// the second, saved as PPTX.
|
||||
fn saved(body: &str, levels: &[u8]) -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
p.slides.clear();
|
||||
let mut b = shape(&mut p, "Body", body, 50.0);
|
||||
if let Some(t) = b.text.as_mut() {
|
||||
for (para, lvl) in t.paragraphs.iter_mut().zip(levels) {
|
||||
para.level = *lvl;
|
||||
}
|
||||
}
|
||||
let pic = shape(&mut p, "Picture", "Picture", 300.0);
|
||||
let anims = vec![
|
||||
Animation { shape: b.id, text_build: TextBuild::ByParagraph, start: AnimStart::OnClick, duration_ms: 500, ..Default::default() },
|
||||
Animation { shape: pic.id, start: AnimStart::WithPrevious, duration_ms: 500, ..Default::default() },
|
||||
];
|
||||
for (shapes, animations) in [(vec![b, pic], anims), (vec![], vec![])] {
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::Blank).unwrap_or_default();
|
||||
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
s.shapes = shapes;
|
||||
s.animations = animations;
|
||||
p.slides.push(Arc::new(s));
|
||||
}
|
||||
deckcraft_pptx::export(&p).expect("export")
|
||||
}
|
||||
|
||||
fn spid(xml: &str, name: &str) -> String {
|
||||
let at = xml.find(&format!(r#"name="{name}""#)).expect("shape");
|
||||
let head = &xml[..at];
|
||||
let id = head.rfind(r#"id=""#).expect("id") + 4;
|
||||
head[id..].split('"').next().expect("spid").to_string()
|
||||
}
|
||||
|
||||
/// One effect node as PowerPoint writes it: Fade in on `spid` (paragraph `para`).
|
||||
fn effect(ids: &mut u32, spid: &str, para: Option<u32>, node: &str) -> String {
|
||||
let mut id = || {
|
||||
*ids += 1;
|
||||
*ids
|
||||
};
|
||||
let tgt = match para {
|
||||
Some(p) => format!(r#"<p:spTgt spid="{spid}"><p:txEl><p:pRg st="{p}" end="{p}"/></p:txEl></p:spTgt>"#),
|
||||
None => format!(r#"<p:spTgt spid="{spid}"/>"#),
|
||||
};
|
||||
format!(
|
||||
r#"<p:par><p:cTn id="{}" presetID="10" presetClass="entr" presetSubtype="0" fill="hold" grpId="0" nodeType="{node}"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst><p:set><p:cBhvr><p:cTn id="{}" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn><p:tgtEl>{tgt}</p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="visible"/></p:to></p:set><p:animEffect transition="in" filter="fade"><p:cBhvr><p:cTn id="{}" dur="500"/><p:tgtEl>{tgt}</p:tgtEl></p:cBhvr></p:animEffect></p:childTnLst></p:cTn></p:par>"#,
|
||||
id(),
|
||||
id(),
|
||||
id()
|
||||
)
|
||||
}
|
||||
|
||||
/// A click group holding `effects`.
|
||||
fn click(ids: &mut u32, effects: &[String]) -> String {
|
||||
*ids += 2;
|
||||
format!(
|
||||
r#"<p:par><p:cTn id="{}" fill="hold"><p:stCondLst><p:cond delay="indefinite"/></p:stCondLst><p:childTnLst><p:par><p:cTn id="{}" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst><p:childTnLst>{}</p:childTnLst></p:cTn></p:par></p:childTnLst></p:cTn></p:par>"#,
|
||||
*ids - 1,
|
||||
*ids,
|
||||
effects.concat()
|
||||
)
|
||||
}
|
||||
|
||||
/// `bytes` with the first slide's timing replaced by `clicks` (built with `effect` and `click`
|
||||
/// from the spids of Body and Picture), Body's build "By paragraph".
|
||||
fn with_timing(bytes: &[u8], clicks: impl Fn(&mut u32, &str, &str) -> Vec<String>) -> Vec<u8> {
|
||||
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..src.len() {
|
||||
let mut f = src.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut data = Vec::new();
|
||||
f.read_to_end(&mut data).expect("read");
|
||||
if name == "ppt/slides/slide1.xml" {
|
||||
let xml = String::from_utf8(data).expect("utf8");
|
||||
let (body, pic) = (spid(&xml, "Body"), spid(&xml, "Picture"));
|
||||
let mut ids = 2;
|
||||
let main = clicks(&mut ids, &body, &pic).concat();
|
||||
let timing = format!(
|
||||
r#"<p:timing><p:tnLst><p:par><p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot"><p:childTnLst><p:seq concurrent="1" nextAc="seek"><p:cTn id="2" dur="indefinite" nodeType="mainSeq"><p:childTnLst>{main}</p:childTnLst></p:cTn><p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst><p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst></p:seq></p:childTnLst></p:cTn></p:par></p:tnLst><p:bldLst><p:bldP spid="{body}" grpId="0" build="p"/><p:bldP spid="{pic}" grpId="0" animBg="1"/></p:bldLst></p:timing>"#
|
||||
);
|
||||
let a = xml.find("<p:timing>").expect("timing");
|
||||
let b = xml.find("</p:timing>").expect("timing end") + "</p:timing>".len();
|
||||
data = format!("{}{timing}{}", &xml[..a], &xml[b..]).into_bytes();
|
||||
}
|
||||
out.start_file(name, SimpleFileOptions::default()).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
/// Clicks (Next with nothing playing) from the start of the first slide to the second one.
|
||||
fn clicks_to_second_slide(pres: &Presentation) -> usize {
|
||||
let mut s = ShowState::new(pres);
|
||||
for n in 1..=50 {
|
||||
match s.next(pres) {
|
||||
ShowAction::PlayStep(_) => {
|
||||
// The step clock runs out before the next click.
|
||||
s.step_done(pres);
|
||||
}
|
||||
ShowAction::Slide(1) => return n,
|
||||
a => panic!("unexpected {a:?}"),
|
||||
}
|
||||
}
|
||||
usize::MAX
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_build_split_by_another_shapes_effect_takes_powerpoints_clicks() {
|
||||
// As PowerPoint writes "Body: By paragraph, Picture: With Previous after the first
|
||||
// paragraph": click 1 = Body ¶0 + Picture, click 2 = Body ¶1, click 3 = Body ¶2.
|
||||
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
|
||||
let e0 = effect(ids, body, Some(0), "clickEffect");
|
||||
let ep = effect(ids, pic, None, "withEffect");
|
||||
let e1 = effect(ids, body, Some(1), "clickEffect");
|
||||
let e2 = effect(ids, body, Some(2), "clickEffect");
|
||||
vec![click(ids, &[e0, ep]), click(ids, &[e1]), click(ids, &[e2])]
|
||||
});
|
||||
let pres = deckcraft_pptx::import(&bytes).expect("import");
|
||||
assert_eq!(ShowState::steps(&pres, 0), 3, "three click steps, as in the file");
|
||||
assert_eq!(clicks_to_second_slide(&pres), 4, "three builds and one click to move on");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_build_by_second_level_paragraphs_takes_a_click_per_paragraph() {
|
||||
// "By 2nd level paragraphs": the heading and each sub-point have their own click.
|
||||
// Picture comes in with the last one, so nothing splits Body's build.
|
||||
let bytes = with_timing(&saved("Question\nFirst\nSecond", &[0, 1, 1]), |ids, body, pic| {
|
||||
let e0 = effect(ids, body, Some(0), "clickEffect");
|
||||
let e1 = effect(ids, body, Some(1), "clickEffect");
|
||||
let e2 = effect(ids, body, Some(2), "clickEffect");
|
||||
let ep = effect(ids, pic, None, "withEffect");
|
||||
vec![click(ids, &[e0]), click(ids, &[e1]), click(ids, &[e2, ep])]
|
||||
});
|
||||
let pres = deckcraft_pptx::import(&bytes).expect("import");
|
||||
assert_eq!(ShowState::steps(&pres, 0), 3, "three click steps, as in the file");
|
||||
}
|
||||
|
||||
/// The first slide's XML in a saved PPTX.
|
||||
fn slide_xml(bytes: &[u8]) -> String {
|
||||
let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
zip.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("read");
|
||||
xml
|
||||
}
|
||||
|
||||
/// Paragraph and start of each animation on the first slide.
|
||||
fn effects(pres: &Presentation) -> Vec<(Option<u32>, AnimStart, TextBuild)> {
|
||||
pres.slides[0].animations.iter().map(|a| (a.paragraph, a.start, a.text_build)).collect()
|
||||
}
|
||||
|
||||
/// "By 1st level paragraphs" as PowerPoint writes it: a sub-point comes in with its parent.
|
||||
fn first_level_build() -> Vec<u8> {
|
||||
with_timing(&saved("One\nSub\nTwo", &[0, 1, 0]), |ids, body, pic| {
|
||||
let e0 = effect(ids, body, Some(0), "clickEffect");
|
||||
let e1 = effect(ids, body, Some(1), "withEffect");
|
||||
let e2 = effect(ids, body, Some(2), "clickEffect");
|
||||
let ep = effect(ids, pic, None, "withEffect");
|
||||
vec![click(ids, &[e0, e1]), click(ids, &[e2, ep])]
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_first_level_build_still_reads_as_one_by_paragraph_animation() {
|
||||
let pres = deckcraft_pptx::import(&first_level_build()).expect("import");
|
||||
assert_eq!(effects(&pres)[0], (None, AnimStart::OnClick, TextBuild::ByParagraph));
|
||||
assert_eq!(pres.slides[0].animations.len(), 2, "Body's build and Picture");
|
||||
assert_eq!(ShowState::steps(&pres, 0), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_by_paragraph_build_with_sub_points_survives_saving() {
|
||||
let pres = deckcraft_pptx::import(&first_level_build()).expect("import");
|
||||
let again = deckcraft_pptx::import(&deckcraft_pptx::export(&pres).expect("export")).expect("reimport");
|
||||
assert_eq!(effects(&again), effects(&pres));
|
||||
assert_eq!(ShowState::steps(&again, 0), 2, "the sub-point still comes in with its parent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paragraph_effects_save_as_powerpoint_writes_them_and_read_back_unchanged() {
|
||||
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
|
||||
let e0 = effect(ids, body, Some(0), "clickEffect");
|
||||
let ep = effect(ids, pic, None, "withEffect");
|
||||
let e1 = effect(ids, body, Some(1), "clickEffect");
|
||||
let e2 = effect(ids, body, Some(2), "clickEffect");
|
||||
vec![click(ids, &[e0, ep]), click(ids, &[e1]), click(ids, &[e2])]
|
||||
});
|
||||
let pres = deckcraft_pptx::import(&bytes).expect("import");
|
||||
let saved = deckcraft_pptx::export(&pres).expect("export");
|
||||
let xml = slide_xml(&saved);
|
||||
let body = spid(&xml, "Body");
|
||||
let blds: Vec<&str> = xml.split("<p:bldP ").skip(1).filter(|b| b.starts_with(&format!(r#"spid="{body}""#))).collect();
|
||||
// ¶0 is its own build (Picture sits between), ¶1 and ¶2 share one.
|
||||
assert_eq!(blds.len(), 2, "{blds:?}");
|
||||
assert!(blds.iter().all(|b| b.contains(r#"build="p""#) && !b.contains("animBg")), "{blds:?}");
|
||||
let again = deckcraft_pptx::import(&saved).expect("reimport");
|
||||
assert_eq!(effects(&again), effects(&pres));
|
||||
assert_eq!(ShowState::steps(&again, 0), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_by_paragraph_build_on_blank_text_saves_as_a_whole_shape_effect() {
|
||||
let xml = slide_xml(&saved(" ", &[0]));
|
||||
assert!(!xml.contains(r#"build="p""#), "no paragraph build without a paragraph to animate");
|
||||
assert!(!xml.contains("<p:txEl>"), "the effect targets the whole shape");
|
||||
}
|
||||
|
||||
/// Body's three paragraphs under `build="p"`, effects in the order `effects` gives them, then
|
||||
/// Picture with the last one.
|
||||
fn three_paragraphs(effects: impl Fn(&mut u32, &str) -> Vec<String>) -> Presentation {
|
||||
let bytes = with_timing(&saved("One\nTwo\nThree", &[0, 0, 0]), |ids, body, pic| {
|
||||
let mut clicks: Vec<String> = effects(ids, body).iter().map(|e| click(ids, std::slice::from_ref(e))).collect();
|
||||
let ep = effect(ids, pic, None, "clickEffect");
|
||||
clicks.push(click(ids, &[ep]));
|
||||
clicks
|
||||
});
|
||||
deckcraft_pptx::import(&bytes).expect("import")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reversed_build_keeps_its_order() {
|
||||
let pres = three_paragraphs(|ids, body| (0..3).rev().map(|p| effect(ids, body, Some(p), "clickEffect")).collect());
|
||||
let paras: Vec<Option<u32>> = pres.slides[0].animations.iter().map(|a| a.paragraph).collect();
|
||||
assert_eq!(paras, vec![Some(2), Some(1), Some(0), None], "last paragraph first, as in the file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_paragraph_with_its_own_duration_keeps_its_own_effect() {
|
||||
let pres = three_paragraphs(|ids, body| {
|
||||
(0..3)
|
||||
.map(|p| {
|
||||
let e = effect(ids, body, Some(p), "clickEffect");
|
||||
if p == 1 { e.replace(r#"dur="500""#, r#"dur="900""#) } else { e }
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
let durs: Vec<(Option<u32>, u32)> = pres.slides[0].animations.iter().map(|a| (a.paragraph, a.duration_ms)).collect();
|
||||
assert_eq!(durs, vec![(Some(0), 500), (Some(1), 900), (Some(2), 500), (None, 500)]);
|
||||
}
|
||||
@@ -15,4 +15,4 @@ harfrust = { workspace = true }
|
||||
unicode-bidi = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Last-resort face only (Ubuntu Light); no UI code.
|
||||
epaint_default_fonts = "0.37"
|
||||
epaint_default_fonts = "0.36"
|
||||
|
||||
+25
-462
@@ -1,61 +1,27 @@
|
||||
//! Optional craft-fonts build input (https://github.com/storytold/craft-fonts, recipe from its
|
||||
//! `docs/integration.md`). With `CRAFT_FONTS_DIR=<checkout>` the fonts in its
|
||||
//! `fonts/latin-manifest.txt` (presentation fonts) and `fonts/manifest.txt` are embedded as
|
||||
//! `CRAFT_FONTS`, Latin first. Native builds additionally read
|
||||
//! `fonts/simplified-chinese-manifest.txt`; it is optional for ordinary local builds but a required
|
||||
//! native input (`CRAFT_FONTS_REQUIRED`) fails without it. Unset, `CRAFT_FONTS` is empty. Web
|
||||
//! (wasm32) builds embed only Inter Regular/Bold (or a supported Variable face) and BIZ UDPGothic
|
||||
//! Regular, to stay within the web size budget; native builds also carry BIZ UDPGothic Bold.
|
||||
//! Required builds verify the faces they promise survive exclusion (and, on wasm, the web filter),
|
||||
//! so a pin without the Latin manifest or the native bold face fails instead of silently dropping
|
||||
//! them. It only reads the local checkout: no network.
|
||||
//! `CRAFT_FONTS`, Latin first; unset, `CRAFT_FONTS` is empty. Web (wasm32) builds embed only Inter
|
||||
//! Regular/Bold and BIZ UDPGothic Regular, to stay within the web size budget. It only reads the
|
||||
//! local checkout: no network.
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Families DeckCraft never embeds (AGENTS.md §1: no Adobe-authored visual design, and no Noto
|
||||
/// CJK rebrand of Source Han on any platform).
|
||||
const EXCLUDED_FAMILIES: &[&str] = &["Source Sans 3", "Source Serif 4", "Source Han Sans", "Source Han Serif"];
|
||||
/// Families DeckCraft never embeds (AGENTS.md §1: no Adobe-authored visual design).
|
||||
const EXCLUDED_FAMILIES: &[&str] = &["Source Sans 3", "Source Serif 4", "Source Han Sans", "Source Han Serif", "Noto Sans CJK SC"];
|
||||
|
||||
/// Reject source families prohibited by the asset policy, including Source Han and every
|
||||
/// Noto CJK/Serif/Mono CJK variant.
|
||||
fn is_excluded_family(family: &str) -> bool {
|
||||
EXCLUDED_FAMILIES.contains(&family)
|
||||
|| family.starts_with("Source Han Sans")
|
||||
|| family.starts_with("Source Han Serif")
|
||||
|| family.starts_with("Noto Sans CJK")
|
||||
|| family.starts_with("Noto Serif CJK")
|
||||
|| family.starts_with("Noto Sans Mono CJK")
|
||||
}
|
||||
|
||||
/// The generated initialisers plus the faces that were actually embedded after exclusion and web
|
||||
/// filtering. Validation must use the latter, not the raw manifest text.
|
||||
#[derive(Default)]
|
||||
struct FontInput {
|
||||
source: String,
|
||||
faces: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl FontInput {
|
||||
/// Concatenate another input's generated source and embedded-face list into this one.
|
||||
fn append(&mut self, other: Self) {
|
||||
self.source.push_str(&other.source);
|
||||
self.faces.extend(other.faces);
|
||||
}
|
||||
}
|
||||
|
||||
/// Read font-input environment and target architecture, write `craft_fonts.rs`, report font-input
|
||||
/// failures as warnings unless required, and always fail when the generated file cannot be written.
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
let dir = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from);
|
||||
let required = std::env::var_os("CRAFT_FONTS_REQUIRED").is_some();
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
match input_fonts(dir.as_deref(), wasm, required) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if required => println!("cargo::error={e}"),
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: {e}"),
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
|
||||
match craft_fonts(&dir) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
src.push_str("];\n");
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
@@ -65,88 +31,30 @@ fn main() {
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
fn input_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Result<String, String> {
|
||||
load_fonts(dir, wasm, required).map(|input| input.source)
|
||||
}
|
||||
|
||||
/// Use no fonts when the directory is absent unless required; otherwise load the manifests.
|
||||
fn load_fonts(dir: Option<&std::path::Path>, wasm: bool, required: bool) -> Result<FontInput, String> {
|
||||
match dir {
|
||||
Some(dir) => craft_fonts(dir, wasm, required),
|
||||
None if required => Err("CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing".into()),
|
||||
None => Ok(FontInput::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load optional Latin input, required main input, and native-only Chinese input, then validate
|
||||
/// the promised required faces after filtering.
|
||||
fn craft_fonts(dir: &std::path::Path, wasm: bool, required: bool) -> Result<FontInput, String> {
|
||||
let mut input = FontInput::default();
|
||||
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
let mut out = String::new();
|
||||
// The Latin presentation fonts are optional within craft-fonts (older checkouts lack them).
|
||||
// A malformed or incomplete optional manifest is skipped as a unit: the main Japanese/symbol/RTL
|
||||
// faces and the native Chinese addition stay usable, and required builds still fail validation.
|
||||
let latin = dir.join("fonts/latin-manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", latin.display());
|
||||
let latin_exists = match latin.try_exists() {
|
||||
Ok(exists) => exists,
|
||||
Err(e) if required => return Err(format!("{}: {e}", latin.display())),
|
||||
Err(e) => {
|
||||
println!("cargo::warning=skipping optional Latin fonts: {}: {e}", latin.display());
|
||||
false
|
||||
}
|
||||
};
|
||||
if latin_exists {
|
||||
match manifest(dir, &latin, wasm) {
|
||||
Ok(entries) => input.append(entries),
|
||||
Err(e) if required => return Err(e),
|
||||
Err(e) => println!("cargo::warning=skipping optional Latin fonts: {e}"),
|
||||
}
|
||||
if latin.exists() {
|
||||
out.push_str(&manifest(dir, &latin)?);
|
||||
}
|
||||
input.append(manifest(dir, &dir.join("fonts/manifest.txt"), wasm)?);
|
||||
// Native consumers opt in; older craft-fonts checkouts need not carry this manifest.
|
||||
// An invalid optional addition must never discard the main Japanese/symbol/RTL faces.
|
||||
if !wasm {
|
||||
let chinese = dir.join("fonts/simplified-chinese-manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", chinese.display());
|
||||
let exists = match chinese.try_exists() {
|
||||
Ok(exists) => exists,
|
||||
Err(e) if required => return Err(format!("{}: {e}", chinese.display())),
|
||||
Err(e) => {
|
||||
println!("cargo::warning=skipping optional Chinese fonts: {}: {e}", chinese.display());
|
||||
false
|
||||
}
|
||||
};
|
||||
if exists {
|
||||
match manifest(dir, &chinese, false) {
|
||||
Ok(entries) => input.append(entries),
|
||||
Err(e) if required => return Err(e),
|
||||
Err(e) => println!("cargo::warning=skipping optional Chinese fonts: {e}"),
|
||||
}
|
||||
} else if required {
|
||||
// A required native font input that silently lacks the reviewed Chinese manifest
|
||||
// would make the Chinese UI/canvas test pass only by skipping.
|
||||
return Err(format!("{}: CRAFT_FONTS_REQUIRED is set but the simplified-Chinese font manifest is missing", chinese.display()));
|
||||
}
|
||||
}
|
||||
if required {
|
||||
validate_required_faces(&input.faces, wasm)?;
|
||||
}
|
||||
Ok(input)
|
||||
out.push_str(&manifest(dir, &dir.join("fonts/manifest.txt"))?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Parse one manifest, reject malformed rows, exclude banned families, apply the wasm filter,
|
||||
/// canonicalise assets, and emit initialisers.
|
||||
fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool) -> Result<FontInput, String> {
|
||||
fn manifest(dir: &std::path::Path, manifest: &std::path::Path) -> Result<String, String> {
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let mut input = FontInput::default();
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
|
||||
let mut out = String::new();
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
// DeckCraft's asset policy: no Adobe-authored typefaces, even openly licensed ones.
|
||||
if is_excluded_family(family) {
|
||||
if EXCLUDED_FAMILIES.contains(family) {
|
||||
continue;
|
||||
}
|
||||
if wasm && !matches!((*family, *style), ("BIZ UDPGothic", "Regular") | ("Inter", "Regular" | "Bold" | "Variable")) {
|
||||
@@ -155,357 +63,12 @@ fn manifest(dir: &std::path::Path, manifest: &std::path::Path, wasm: bool) -> Re
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
|
||||
input.faces.push(((*family).to_owned(), (*style).to_owned()));
|
||||
let _ = writeln!(
|
||||
input.source,
|
||||
out,
|
||||
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},",
|
||||
scripts.join(", "),
|
||||
path.display().to_string(),
|
||||
);
|
||||
}
|
||||
Ok(input)
|
||||
}
|
||||
|
||||
/// Required builds must actually carry the faces the platform promises: Inter Regular and Bold
|
||||
/// (or one Variable face) and BIZ UDPGothic Regular everywhere, BIZ UDPGothic Bold natively (the
|
||||
/// weight-pair metric test relies on it), plus LXGW WenKai Lite Regular for native builds. This
|
||||
/// runs after exclusion and, on wasm, the web filter, so an absent manifest, empty/all-excluded
|
||||
/// rows, a wrong-family row or a partial catalog cannot satisfy the gate. Wasm deliberately
|
||||
/// embeds only BIZ Regular to stay within the web size budget.
|
||||
fn validate_required_faces(faces: &[(String, String)], wasm: bool) -> Result<(), String> {
|
||||
let has = |family: &str, style: &str| faces.iter().any(|(f, s)| f == family && s == style);
|
||||
let inter = has("Inter", "Variable") || (has("Inter", "Regular") && has("Inter", "Bold"));
|
||||
let biz_regular = has("BIZ UDPGothic", "Regular");
|
||||
let biz_bold = wasm || has("BIZ UDPGothic", "Bold");
|
||||
let chinese = wasm || has("LXGW WenKai Lite", "Regular");
|
||||
let mut missing = Vec::new();
|
||||
if !inter {
|
||||
missing.push("Inter Regular/Bold (or Inter Variable)");
|
||||
}
|
||||
if !biz_regular {
|
||||
missing.push("BIZ UDPGothic Regular");
|
||||
}
|
||||
if !biz_bold {
|
||||
missing.push("BIZ UDPGothic Bold");
|
||||
}
|
||||
if !chinese {
|
||||
missing.push("LXGW WenKai Lite Regular");
|
||||
}
|
||||
if missing.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("CRAFT_FONTS_REQUIRED is set but the embedded font set after filtering lacks {}", missing.join(" and ")))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0);
|
||||
const CHINESE_ROW: &str = "LXGW WenKai Lite | Regular | fonts/fixture.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
|
||||
|
||||
struct InputFixture(PathBuf);
|
||||
|
||||
impl InputFixture {
|
||||
/// Test helper: create a unique temporary fixture with a main manifest and asset file.
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!("deck-font-input-{}-{}", std::process::id(), NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir(&path).unwrap();
|
||||
std::fs::create_dir(path.join("fonts")).unwrap();
|
||||
std::fs::write(path.join("fonts/fixture.ttf"), b"build-input path fixture").unwrap();
|
||||
std::fs::write(
|
||||
path.join("fonts/manifest.txt"),
|
||||
"BIZ UDPGothic | Regular | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
|
||||
BIZ UDPGothic | Bold | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
)
|
||||
.unwrap();
|
||||
Self(path)
|
||||
}
|
||||
|
||||
/// Test helper: replace the fixture's main manifest contents.
|
||||
fn main(&self, text: &str) {
|
||||
std::fs::write(self.0.join("fonts/manifest.txt"), text).unwrap();
|
||||
}
|
||||
|
||||
/// Test helper: write the fixture's optional Latin manifest.
|
||||
fn latin(&self, text: &str) {
|
||||
std::fs::write(self.0.join("fonts/latin-manifest.txt"), text).unwrap();
|
||||
}
|
||||
|
||||
/// Test helper: remove the optional Latin manifest.
|
||||
fn no_latin(&self) {
|
||||
let _ = std::fs::remove_file(self.0.join("fonts/latin-manifest.txt"));
|
||||
}
|
||||
|
||||
/// Test helper: install a valid Inter Regular/Bold Latin manifest.
|
||||
fn valid_latin(&self) {
|
||||
self.latin(
|
||||
"Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
|
||||
Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
);
|
||||
}
|
||||
|
||||
/// Test helper: write the optional native Chinese manifest with the supplied rows.
|
||||
fn optional(&self, text: &str) {
|
||||
std::fs::write(self.0.join("fonts/simplified-chinese-manifest.txt"), text).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputFixture {
|
||||
/// Test helper: best-effort removal of the temporary fixture directory.
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// A required build without a font directory fails; an optional build embeds no fonts.
|
||||
#[test]
|
||||
fn required_input_cannot_silently_disappear() {
|
||||
assert!(input_fonts(None, false, true).unwrap_err().contains("CRAFT_FONTS_DIR"));
|
||||
assert_eq!(input_fonts(None, false, false).unwrap(), "");
|
||||
}
|
||||
|
||||
/// A malformed optional Chinese manifest, or one naming a missing asset, leaves the main catalog
|
||||
/// unchanged in optional builds, while a required build fails.
|
||||
#[test]
|
||||
fn broken_optional_manifest_preserves_the_main_fonts() {
|
||||
let fixture = InputFixture::new();
|
||||
let main = input_fonts(Some(&fixture.0), false, false).unwrap();
|
||||
fixture.optional("malformed row");
|
||||
assert_eq!(input_fonts(Some(&fixture.0), false, false).unwrap(), main);
|
||||
assert!(input_fonts(Some(&fixture.0), false, true).is_err());
|
||||
fixture.optional("LXGW WenKai Lite | Regular | missing.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid");
|
||||
assert_eq!(input_fonts(Some(&fixture.0), false, false).unwrap(), main);
|
||||
assert!(input_fonts(Some(&fixture.0), false, true).is_err());
|
||||
}
|
||||
|
||||
/// Adding or breaking native Chinese input must not change the wasm font initialiser bytes.
|
||||
#[test]
|
||||
fn native_opt_in_does_not_change_wasm_embedded_bytes() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
let before = input_fonts(Some(&fixture.0), true, true).unwrap();
|
||||
fixture.optional(CHINESE_ROW);
|
||||
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
|
||||
assert!(native.contains("LXGW WenKai Lite"));
|
||||
assert_eq!(input_fonts(Some(&fixture.0), true, true).unwrap(), before);
|
||||
fixture.optional("malformed row");
|
||||
assert_eq!(input_fonts(Some(&fixture.0), true, true).unwrap(), before);
|
||||
}
|
||||
|
||||
/// Required native builds fail without the Chinese manifest; optional native and wasm builds
|
||||
/// stay valid.
|
||||
#[test]
|
||||
fn required_native_input_requires_the_chinese_manifest() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
assert!(input_fonts(Some(&fixture.0), false, true).unwrap_err().contains("simplified-Chinese"));
|
||||
// Optional native builds and wasm keep working without it.
|
||||
assert!(input_fonts(Some(&fixture.0), false, false).is_ok());
|
||||
assert!(input_fonts(Some(&fixture.0), true, true).is_ok());
|
||||
}
|
||||
|
||||
/// Every prohibited Source Han/Noto CJK family is excluded while permitted CJK, Latin, and
|
||||
/// Hebrew faces survive.
|
||||
#[test]
|
||||
fn all_noto_cjk_families_are_excluded_from_every_platform() {
|
||||
for family in [
|
||||
"Noto Sans CJK SC",
|
||||
"Noto Sans CJK TC",
|
||||
"Noto Sans CJK JP",
|
||||
"Noto Sans CJK KR",
|
||||
"Noto Sans CJK HK",
|
||||
"Noto Serif CJK SC",
|
||||
"Noto Serif CJK JP",
|
||||
"Noto Sans Mono CJK SC",
|
||||
"Source Han Sans SC",
|
||||
"Source Han Serif JP",
|
||||
] {
|
||||
assert!(is_excluded_family(family), "{family} would be embedded");
|
||||
}
|
||||
for family in ["LXGW WenKai Lite", "Noto Sans Hebrew", "Inter", "Noto Sans Symbols 2"] {
|
||||
assert!(!is_excluded_family(family), "{family} is an allowed input");
|
||||
}
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
let mut rows = String::from(CHINESE_ROW);
|
||||
for family in ["Noto Sans CJK SC", "Noto Serif CJK JP", "Noto Sans Mono CJK KR"] {
|
||||
rows.push_str(&format!("{family} | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n"));
|
||||
}
|
||||
fixture.optional(&rows);
|
||||
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
|
||||
assert!(!native.contains("Noto Sans CJK") && !native.contains("Noto Serif CJK") && !native.contains("Noto Sans Mono CJK"));
|
||||
assert!(native.contains("BIZ UDPGothic"), "the allowed main manifest is retained");
|
||||
assert!(native.contains("LXGW WenKai Lite"), "the valid Chinese row is retained");
|
||||
}
|
||||
|
||||
/// A required native Chinese manifest must embed LXGW WenKai Lite Regular, not merely list a
|
||||
/// nonempty manifest.
|
||||
#[test]
|
||||
fn required_native_chinese_manifest_must_emit_the_reviewed_face() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
let excluded = "Noto Sans CJK SC | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
|
||||
Noto Serif CJK JP | Regular | fonts/fixture.ttf | Hani | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
|
||||
let wrong_family = "LXGW WenKai | Regular | fonts/fixture.ttf | Hani,Hans | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
|
||||
for rows in ["", excluded, wrong_family] {
|
||||
fixture.optional(rows);
|
||||
let e = input_fonts(Some(&fixture.0), false, true).unwrap_err();
|
||||
assert!(e.contains("LXGW WenKai Lite Regular"), "rows={rows:?}: {e}");
|
||||
assert!(input_fonts(Some(&fixture.0), false, false).is_ok(), "optional builds still preserve the valid core");
|
||||
}
|
||||
fixture.optional(CHINESE_ROW);
|
||||
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
|
||||
assert!(native.contains("CraftFont { family: \"LXGW WenKai Lite\", style: \"Regular\""), "{native}");
|
||||
assert!(native.contains("BIZ UDPGothic") && native.contains("Inter"), "the rest of the required catalog is retained");
|
||||
}
|
||||
|
||||
/// A broken optional Latin manifest is skipped as a unit, preserving main and valid Chinese
|
||||
/// faces; required builds still fail.
|
||||
#[test]
|
||||
fn optional_broken_latin_manifest_is_skipped_without_losing_core_or_chinese() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.optional(CHINESE_ROW);
|
||||
let missing_asset = "Inter | Regular | fonts/missing.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n";
|
||||
for latin in [None, Some("malformed row"), Some(missing_asset)] {
|
||||
match latin {
|
||||
Some(text) => fixture.latin(text),
|
||||
None => fixture.no_latin(),
|
||||
}
|
||||
for wasm in [false, true] {
|
||||
let catalog = input_fonts(Some(&fixture.0), wasm, false).unwrap();
|
||||
assert!(catalog.contains("BIZ UDPGothic"), "wasm={wasm}: the main manifest is retained");
|
||||
assert!(!catalog.contains("Inter"), "wasm={wasm}: the broken Latin manifest is skipped as a unit");
|
||||
if !wasm {
|
||||
assert!(catalog.contains("LXGW WenKai Lite"), "the valid Chinese addition is retained");
|
||||
}
|
||||
}
|
||||
assert!(input_fonts(Some(&fixture.0), false, true).is_err(), "required builds must not skip the missing required faces");
|
||||
}
|
||||
}
|
||||
|
||||
/// The reviewed Latin catalog keeps 43 permitted faces plus BIZ and LXGW natively, while wasm
|
||||
/// remains a three-face subset.
|
||||
#[test]
|
||||
fn full_prior_latin_catalog_keeps_permitted_native_families() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.latin(include_str!("tests/fixtures/latin-families.txt"));
|
||||
fixture.optional(CHINESE_ROW);
|
||||
let native = input_fonts(Some(&fixture.0), false, true).unwrap();
|
||||
assert_eq!(native.matches("CraftFont {").count(), 46, "43 permitted Latin faces plus BIZ Regular/Bold and LXGW");
|
||||
for family in [
|
||||
"Carlito",
|
||||
"Caladea",
|
||||
"Liberation Serif",
|
||||
"Montserrat",
|
||||
"Lato",
|
||||
"Open Sans",
|
||||
"Roboto",
|
||||
"Merriweather",
|
||||
"Playfair Display",
|
||||
"Poppins",
|
||||
"Nunito Sans",
|
||||
] {
|
||||
assert!(native.contains(family), "missing prior presentation family {family}");
|
||||
}
|
||||
let web = input_fonts(Some(&fixture.0), true, true).unwrap();
|
||||
assert_eq!(web.matches("CraftFont {").count(), 3, "Inter Regular/Bold and BIZ only");
|
||||
for catalog in [&native, &web] {
|
||||
assert!(!catalog.contains("Source Sans 3"));
|
||||
assert!(!catalog.contains("Source Serif 4"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Required builds without the Latin catalog fail because Inter Regular/Bold (or Variable) is
|
||||
/// mandatory.
|
||||
#[test]
|
||||
fn required_builds_need_the_latin_catalog_for_inter() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.optional(CHINESE_ROW);
|
||||
for wasm in [true, false] {
|
||||
let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err();
|
||||
assert!(e.contains("Inter Regular/Bold"), "wasm={wasm}: {e}");
|
||||
assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work");
|
||||
}
|
||||
}
|
||||
|
||||
/// A single Inter weight, non-Inter substitutes, or excluded faces cannot satisfy the required
|
||||
/// Inter contract.
|
||||
#[test]
|
||||
fn required_builds_reject_partial_or_unrelated_faces() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.optional(CHINESE_ROW);
|
||||
for latin in [
|
||||
"Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
"Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
"Inter | Italic | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
|
||||
Inter | SemiBold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
"Carlito | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
"Source Sans 3 | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
|
||||
Source Sans 3 | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
] {
|
||||
fixture.latin(latin);
|
||||
for wasm in [true, false] {
|
||||
let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err();
|
||||
assert!(e.contains("Inter Regular/Bold"), "wasm={wasm}: {e}");
|
||||
assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// BIZ UDPGothic Regular is required on every platform; required native builds additionally
|
||||
/// need Bold.
|
||||
#[test]
|
||||
fn required_builds_need_biz_udpgothic_regular() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
fixture.main(
|
||||
"Inter | Regular | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n\
|
||||
Inter | Bold | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n",
|
||||
);
|
||||
fixture.optional(CHINESE_ROW);
|
||||
for wasm in [true, false] {
|
||||
let e = input_fonts(Some(&fixture.0), wasm, true).unwrap_err();
|
||||
assert!(e.contains("BIZ UDPGothic Regular"), "wasm={wasm}: {e}");
|
||||
if !wasm {
|
||||
assert!(e.contains("BIZ UDPGothic Bold"), "native builds need the weight pair: {e}");
|
||||
}
|
||||
assert!(input_fonts(Some(&fixture.0), wasm, false).is_ok(), "optional builds still work");
|
||||
}
|
||||
}
|
||||
|
||||
/// Native required builds need the real BIZ Bold face for weight metrics; wasm deliberately
|
||||
/// embeds only Regular to stay within its size budget. This pins the contract the engine's
|
||||
/// required-build metric pair assertion relies on.
|
||||
#[test]
|
||||
fn required_native_builds_need_biz_bold_but_wasm_does_not() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
fixture.optional(CHINESE_ROW);
|
||||
fixture.main("BIZ UDPGothic | Regular | fonts/fixture.ttf | Jpan,Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n");
|
||||
let e = input_fonts(Some(&fixture.0), false, true).unwrap_err();
|
||||
assert!(e.contains("BIZ UDPGothic Bold"), "required native build accepted a Regular-only catalog: {e}");
|
||||
let web = input_fonts(Some(&fixture.0), true, true).unwrap();
|
||||
assert_eq!(web.matches("CraftFont {").count(), 3, "wasm keeps Inter Regular/Bold and BIZ Regular only");
|
||||
}
|
||||
|
||||
/// Required checks accept either static Inter Regular+Bold or a single Variable face, together
|
||||
/// with BIZ.
|
||||
#[test]
|
||||
fn required_builds_accept_static_or_variable_inter_with_biz() {
|
||||
let fixture = InputFixture::new();
|
||||
fixture.valid_latin();
|
||||
fixture.optional(CHINESE_ROW);
|
||||
for wasm in [true, false] {
|
||||
let static_catalog = input_fonts(Some(&fixture.0), wasm, true).unwrap();
|
||||
assert!(static_catalog.contains("Inter") && static_catalog.contains("BIZ UDPGothic"));
|
||||
}
|
||||
fixture.latin("Inter | Variable | fonts/fixture.ttf | Latn | OFL-1.1 | OFL.txt | fixture | https://example.invalid\n");
|
||||
for wasm in [true, false] {
|
||||
let variable_catalog = input_fonts(Some(&fixture.0), wasm, true).unwrap();
|
||||
assert!(variable_catalog.contains("Inter") && variable_catalog.contains("BIZ UDPGothic"));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
+27
-28
@@ -161,12 +161,12 @@ pub struct FaceRef(&'static FontFace);
|
||||
impl FaceRef {
|
||||
/// The handle for a loaded face (memoised per face).
|
||||
pub fn of(face: &Arc<FontFace>) -> FaceRef {
|
||||
static LEAKED: RwLock<Vec<Option<&FontFace>>> = RwLock::new(Vec::new());
|
||||
static LEAKED: RwLock<Vec<Option<&'static FontFace>>> = RwLock::new(Vec::new());
|
||||
let id = face.id as usize;
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(std::sync::PoisonError::into_inner).get(id) {
|
||||
if let Some(Some(f)) = LEAKED.read().unwrap_or_else(|e| e.into_inner()).get(id) {
|
||||
return FaceRef(f);
|
||||
}
|
||||
let mut w = LEAKED.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut w = LEAKED.write().unwrap_or_else(|e| e.into_inner());
|
||||
if w.len() <= id {
|
||||
w.resize(id + 1, None);
|
||||
}
|
||||
@@ -743,7 +743,7 @@ pub fn base_style(style: &str) -> &str {
|
||||
}
|
||||
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
s.chars().filter(|c| c.is_alphanumeric()).flat_map(|c| c.to_lowercase()).collect()
|
||||
}
|
||||
|
||||
/// Weight implied by a style name.
|
||||
@@ -807,7 +807,7 @@ impl FontDb {
|
||||
pub fn set_system_fallback(&self, on: bool) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled = on;
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled = on;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = on;
|
||||
@@ -822,14 +822,14 @@ impl FontDb {
|
||||
}
|
||||
|
||||
pub(crate) fn read_faces(&self) -> std::sync::RwLockReadGuard<'_, Vec<Arc<FontFace>>> {
|
||||
self.faces.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
self.faces.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// The system font catalog, scanned first if it hasn't been yet.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn read_catalog(&self) -> std::sync::RwLockReadGuard<'_, Vec<CatalogEntry>> {
|
||||
self.ensure_catalog();
|
||||
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
self.catalog.read().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Family names available (loaded plus cataloged system fonts), sorted and deduplicated.
|
||||
@@ -885,7 +885,7 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut faces = self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut faces = self.faces.write().unwrap_or_else(|e| e.into_inner());
|
||||
let mut added = 0;
|
||||
for f in new {
|
||||
// Checked again under the lock: another thread may have added the same face.
|
||||
@@ -923,7 +923,7 @@ impl FontDb {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_system_fonts(&self) -> usize {
|
||||
// Families load again from the new catalog when next looked up.
|
||||
self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
|
||||
self.loaded_families.lock().unwrap_or_else(|e| e.into_inner()).clear();
|
||||
// The first scan, or a rescan once it is done (never both at once).
|
||||
let mut first = None;
|
||||
self.cataloged.get_or_init(|| first = Some(self.scan_font_dirs()));
|
||||
@@ -959,7 +959,7 @@ impl FontDb {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let ext = p.extension().and_then(std::ffi::OsStr::to_str).map(str::to_ascii_lowercase);
|
||||
let ext = p.extension().and_then(|e| e.to_str()).map(|e| e.to_ascii_lowercase());
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
@@ -975,7 +975,7 @@ impl FontDb {
|
||||
}
|
||||
let n = found.len();
|
||||
log::debug!("cataloged {n} system font faces");
|
||||
*self.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner) = found;
|
||||
*self.catalog.write().unwrap_or_else(|e| e.into_inner()) = found;
|
||||
n
|
||||
}
|
||||
|
||||
@@ -983,7 +983,7 @@ impl FontDb {
|
||||
/// it is loaded.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn load_cataloged(&self, family: &str) -> bool {
|
||||
let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
||||
self.load_family(family, &loading)
|
||||
}
|
||||
|
||||
@@ -1047,7 +1047,7 @@ impl FontDb {
|
||||
// them rather than finding none and falling back to another family.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let mut loaded = self.loaded_families.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut loaded = self.loaded_families.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let key = family.to_lowercase();
|
||||
if !loaded.contains(&key) {
|
||||
self.load_cataloged(family);
|
||||
@@ -1112,7 +1112,7 @@ impl FontDb {
|
||||
}
|
||||
let f = make_face(base.bytes.clone(), base.index, base.family.clone(), style.to_string(), coords)?;
|
||||
let f = Arc::new(f);
|
||||
self.faces.write().unwrap_or_else(std::sync::PoisonError::into_inner).push(f.clone());
|
||||
self.faces.write().unwrap_or_else(|e| e.into_inner()).push(f.clone());
|
||||
Some(f)
|
||||
}
|
||||
|
||||
@@ -1214,20 +1214,20 @@ impl FontDb {
|
||||
}
|
||||
let seen = self.read_faces().len();
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !sys.enabled || sys.misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f));
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
let loading = self.loading.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Another thread may have loaded a face for `c` (faces are only ever added), or found
|
||||
// there is none, while this one waited.
|
||||
if self.read_faces().len() != seen && covered(self) {
|
||||
return true;
|
||||
}
|
||||
if self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.contains(&c) {
|
||||
if self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.contains(&c) {
|
||||
return false;
|
||||
}
|
||||
for fam in SYSTEM_FALLBACKS {
|
||||
@@ -1238,8 +1238,7 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> =
|
||||
self.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner).iter().map(|e| e.path.clone()).collect();
|
||||
let mut paths: Vec<std::path::PathBuf> = self.catalog.read().unwrap_or_else(|e| e.into_inner()).iter().map(|e| e.path.clone()).collect();
|
||||
paths.sort();
|
||||
paths.dedup();
|
||||
for p in paths {
|
||||
@@ -1254,7 +1253,7 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
if remember_miss {
|
||||
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).misses.insert(c);
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -1327,10 +1326,10 @@ impl FontDb {
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
if !self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled {
|
||||
if !self.sys.lock().unwrap_or_else(|e| e.into_inner()).enabled {
|
||||
return false;
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) {
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
@@ -1348,7 +1347,7 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c);
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1360,12 +1359,12 @@ impl FontDb {
|
||||
return false;
|
||||
}
|
||||
{
|
||||
let sys = self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let sys = self.sys.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if !sys.enabled {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).contains(&c) {
|
||||
if MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).contains(&c) {
|
||||
return false;
|
||||
}
|
||||
let mut paths: Vec<std::path::PathBuf> = self.read_catalog().iter().map(|e| e.path.clone()).collect();
|
||||
@@ -1383,14 +1382,14 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
MISSES.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get_or_insert_with(Default::default).insert(c);
|
||||
MISSES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(Default::default).insert(c);
|
||||
false
|
||||
}
|
||||
|
||||
/// Glyph outline in font units, y-down (flipped), cached per (face, glyph).
|
||||
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
|
||||
let key = (face.id, gid);
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
|
||||
if let Some(p) = self.outlines.lock().unwrap_or_else(|e| e.into_inner()).get(&key) {
|
||||
return p.clone();
|
||||
}
|
||||
let mut pen = FlipPen(BezPath::new());
|
||||
@@ -1400,7 +1399,7 @@ impl FontDb {
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
|
||||
}
|
||||
let p = Arc::new(pen.0);
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut cache = self.outlines.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if cache.len() >= OUTLINE_CACHE_MAX {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
@@ -409,38 +409,16 @@ mod tests {
|
||||
assert!(feature("liga").is_some());
|
||||
}
|
||||
|
||||
/// When the Latin build input exists, the runtime catalogue carries the expected Latin faces;
|
||||
/// required builds also assert their presence.
|
||||
#[test]
|
||||
fn craft_fonts_latin_when_present() {
|
||||
let required = option_env!("CRAFT_FONTS_REQUIRED").is_some();
|
||||
if !CRAFT_FONTS.iter().any(|f| f.family == "Inter") {
|
||||
assert!(!required, "CRAFT_FONTS_REQUIRED must embed Inter rather than skip the optional Latin manifest");
|
||||
eprintln!("skipped: built without craft-fonts' Latin manifest (set CRAFT_FONTS_DIR)");
|
||||
return;
|
||||
}
|
||||
let inter = |style: &str| CRAFT_FONTS.iter().find(|f| f.family == "Inter" && f.style == style);
|
||||
let (regular, bold, variable) = (inter("Regular"), inter("Bold"), inter("Variable"));
|
||||
if required {
|
||||
assert!(
|
||||
variable.is_some() || (regular.is_some() && bold.is_some()),
|
||||
"CRAFT_FONTS_REQUIRED must embed Inter Regular and Bold (or a Variable face)"
|
||||
);
|
||||
}
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.face("Inter", "Regular").family, "Inter");
|
||||
assert_eq!(db.face("Aptos", "Regular").family, "Inter");
|
||||
if let (Some(regular), Some(bold)) = (regular, bold) {
|
||||
// The build input carries one entry per face; real static Bold must not be the Regular
|
||||
// file relabelled in the manifest.
|
||||
assert_ne!(regular.bytes, bold.bytes, "Inter Bold must be a distinct face file from Regular");
|
||||
let (r, b) = (db.face("Inter", "Regular"), db.face("Inter", "Bold"));
|
||||
assert_eq!((r.family.as_str(), r.style.as_str()), ("Inter", "Regular"));
|
||||
assert_eq!((b.family.as_str(), b.style.as_str()), ("Inter", "Bold"));
|
||||
assert_ne!(r.id(), b.id(), "Inter Bold must load as its own face");
|
||||
assert!(b.weight > r.weight, "Inter Bold weight {} is not above Regular {}", b.weight, r.weight);
|
||||
}
|
||||
if CRAFT_FONTS.iter().any(|f| f.family == "Carlito") {
|
||||
assert_eq!(db.face("Calibri", "Bold").family, "Carlito");
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
//! Exercise the build-input parser without changing process environment variables.
|
||||
#[allow(dead_code)]
|
||||
#[path = "../build.rs"]
|
||||
mod build_script;
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
# Synthetic paths; family/style inventory from craft-fonts 76ba5a0496503c0e2841abd626295038cbe07cde.
|
||||
Inter | Regular | fonts/fixture.ttf | Latn
|
||||
Inter | Italic | fonts/fixture.ttf | Latn
|
||||
Inter | SemiBold | fonts/fixture.ttf | Latn
|
||||
Inter | Bold | fonts/fixture.ttf | Latn
|
||||
Carlito | Regular | fonts/fixture.ttf | Latn
|
||||
Carlito | Bold | fonts/fixture.ttf | Latn
|
||||
Carlito | Italic | fonts/fixture.ttf | Latn
|
||||
Carlito | Bold Italic | fonts/fixture.ttf | Latn
|
||||
Caladea | Regular | fonts/fixture.ttf | Latn
|
||||
Caladea | Bold | fonts/fixture.ttf | Latn
|
||||
Caladea | Italic | fonts/fixture.ttf | Latn
|
||||
Caladea | Bold Italic | fonts/fixture.ttf | Latn
|
||||
Liberation Sans | Regular | fonts/fixture.ttf | Latn
|
||||
Liberation Sans | Bold | fonts/fixture.ttf | Latn
|
||||
Liberation Sans | Italic | fonts/fixture.ttf | Latn
|
||||
Liberation Sans | Bold Italic | fonts/fixture.ttf | Latn
|
||||
Liberation Serif | Regular | fonts/fixture.ttf | Latn
|
||||
Liberation Serif | Bold | fonts/fixture.ttf | Latn
|
||||
Liberation Serif | Italic | fonts/fixture.ttf | Latn
|
||||
Liberation Serif | Bold Italic | fonts/fixture.ttf | Latn
|
||||
Liberation Mono | Regular | fonts/fixture.ttf | Latn
|
||||
Liberation Mono | Bold | fonts/fixture.ttf | Latn
|
||||
Liberation Mono | Italic | fonts/fixture.ttf | Latn
|
||||
Liberation Mono | Bold Italic | fonts/fixture.ttf | Latn
|
||||
Source Sans 3 | Regular | fonts/fixture.ttf | Latn
|
||||
Source Sans 3 | Italic | fonts/fixture.ttf | Latn
|
||||
Source Sans 3 | Semibold | fonts/fixture.ttf | Latn
|
||||
Source Sans 3 | Bold | fonts/fixture.ttf | Latn
|
||||
Source Serif 4 | Regular | fonts/fixture.ttf | Latn
|
||||
Source Serif 4 | Italic | fonts/fixture.ttf | Latn
|
||||
Source Serif 4 | Semibold | fonts/fixture.ttf | Latn
|
||||
Source Serif 4 | Bold | fonts/fixture.ttf | Latn
|
||||
Montserrat | Variable | fonts/fixture.ttf | Latn
|
||||
Montserrat | Italic Variable | fonts/fixture.ttf | Latn
|
||||
Lato | Regular | fonts/fixture.ttf | Latn
|
||||
Lato | Bold | fonts/fixture.ttf | Latn
|
||||
Lato | Italic | fonts/fixture.ttf | Latn
|
||||
Open Sans | Variable | fonts/fixture.ttf | Latn
|
||||
Open Sans | Italic Variable | fonts/fixture.ttf | Latn
|
||||
Roboto | Variable | fonts/fixture.ttf | Latn
|
||||
Roboto | Italic Variable | fonts/fixture.ttf | Latn
|
||||
Merriweather | Regular | fonts/fixture.ttf | Latn
|
||||
Merriweather | Bold | fonts/fixture.ttf | Latn
|
||||
Merriweather | Italic | fonts/fixture.ttf | Latn
|
||||
Playfair Display | Variable | fonts/fixture.ttf | Latn
|
||||
Playfair Display | Italic Variable | fonts/fixture.ttf | Latn
|
||||
Poppins | Regular | fonts/fixture.ttf | Latn
|
||||
Poppins | Bold | fonts/fixture.ttf | Latn
|
||||
Poppins | Italic | fonts/fixture.ttf | Latn
|
||||
Nunito Sans | Variable | fonts/fixture.ttf | Latn
|
||||
Nunito Sans | Italic Variable | fonts/fixture.ttf | Latn
|
||||
+13
-58
@@ -6,7 +6,6 @@
|
||||
|
||||
mod chart_parts;
|
||||
mod finite;
|
||||
pub mod rtf;
|
||||
|
||||
#[cfg(test)]
|
||||
mod chart_parts_tests;
|
||||
@@ -499,9 +498,8 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
|
||||
}
|
||||
}
|
||||
match &mut sh.kind {
|
||||
deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop, .. } }
|
||||
| deckcraft_model::ShapeKind::Media(deckcraft_model::MediaClip { crop, .. }) => {
|
||||
for c in crop {
|
||||
deckcraft_model::ShapeKind::Picture { fill } => {
|
||||
for c in &mut fill.crop {
|
||||
*c = if c.is_finite() { c.clamp(-10.0, 1.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
@@ -662,7 +660,6 @@ pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
if let ShapeKind::Table(t) = &sh.kind {
|
||||
for row in &t.rows {
|
||||
let cells: Vec<String> = row.cells.iter().map(|c| c.text.text().replace(['\u{b}', '\n'], " ")).collect();
|
||||
out.push('\t');
|
||||
out.push_str(&cells.join("\t"));
|
||||
out.push('\n');
|
||||
}
|
||||
@@ -830,18 +827,6 @@ mod tests {
|
||||
assert!(load(&b).is_err());
|
||||
}
|
||||
|
||||
/// A video's crop is brought into the same range as a picture's (#972).
|
||||
#[test]
|
||||
fn a_video_crop_is_repaired_like_a_picture_crop() {
|
||||
use deckcraft_model::{MediaClip, Shape};
|
||||
let mut p = Presentation::default();
|
||||
let clip = MediaClip { video: true, crop: [f64::NAN, 0.25, -1e9, 0.0], ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(Shape { kind: ShapeKind::Media(clip), ..Default::default() });
|
||||
let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}"));
|
||||
let ShapeKind::Media(m) = &back.slides[0].shapes.last().unwrap().kind else { panic!("media") };
|
||||
assert_eq!(m.crop, [0.0, 0.25, -10.0, 0.0]);
|
||||
}
|
||||
|
||||
/// Non-finite numbers are written as `null` by serde_json; the file still opens, with each
|
||||
/// such number repaired (it failed as "invalid type: null, expected f64").
|
||||
#[test]
|
||||
@@ -1072,49 +1057,23 @@ mod tests {
|
||||
assert_eq!(md_list_item(not), None, "{not:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #814: slides_to_outline includes table cell text, which it used to skip.
|
||||
#[test]
|
||||
fn outline_includes_table_text() {
|
||||
use deckcraft_model::table::{Cell, Row, Table};
|
||||
use deckcraft_model::{Shape, ShapeKind, Xfrm};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
|
||||
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title()));
|
||||
let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() };
|
||||
body.text = Some(TextBody::from_text("Text box body"));
|
||||
s.shapes.push(body);
|
||||
|
||||
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
|
||||
for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] {
|
||||
let cells: Vec<Cell> = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect();
|
||||
table.rows.push(Row { height: 30.0, cells });
|
||||
}
|
||||
let mut tbl = Shape { xfrm: Some(Xfrm::new(0.0, 100.0, 400.0, 60.0)), ..Default::default() };
|
||||
tbl.kind = ShapeKind::Table(table);
|
||||
s.shapes.push(tbl);
|
||||
p.slides = vec![std::sync::Arc::new(s)];
|
||||
|
||||
let out = slides_to_outline(&p);
|
||||
assert!(out.contains("Text box body"), "{out}");
|
||||
assert!(out.contains("Cell A1\tCell B1"), "{out}");
|
||||
assert!(out.contains("Cell A2\tCell B2"), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #814: slides_to_outline includes table cell text, which it used to skip.
|
||||
#[test]
|
||||
fn outline_table_rows_are_indented_and_do_not_become_slides() {
|
||||
fn outline_includes_table_text() {
|
||||
use deckcraft_model::table::{Cell, Row, Table};
|
||||
use deckcraft_model::{Shape, ShapeKind, Xfrm};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).unwrap();
|
||||
let mut s = defaults::new_slide(&mut p, layout).expect("slide");
|
||||
for sh in s.shapes.iter_mut().filter(|sh| sh.ph_type().is_some_and(|k| k.is_title())) {
|
||||
sh.text = Some(TextBody::from_text("Slide title"));
|
||||
}
|
||||
s.shapes.retain(|sh| !sh.ph_type().is_some_and(|k| k.is_title()));
|
||||
let mut body = Shape { xfrm: Some(Xfrm::new(0.0, 0.0, 600.0, 100.0)), ..Default::default() };
|
||||
body.text = Some(TextBody::from_text("Text box body"));
|
||||
s.shapes.push(body);
|
||||
|
||||
let mut table = Table { cols: vec![200.0, 200.0], ..Default::default() };
|
||||
for texts in [["A1", "B1"], ["A2", "B2"]] {
|
||||
for texts in [["Cell A1", "Cell B1"], ["Cell A2", "Cell B2"]] {
|
||||
let cells: Vec<Cell> = texts.iter().map(|t| Cell { text: TextBody::from_text(t), ..Default::default() }).collect();
|
||||
table.rows.push(Row { height: 30.0, cells });
|
||||
}
|
||||
@@ -1124,11 +1083,7 @@ fn outline_table_rows_are_indented_and_do_not_become_slides() {
|
||||
p.slides = vec![std::sync::Arc::new(s)];
|
||||
|
||||
let out = slides_to_outline(&p);
|
||||
let mut q = defaults::new_presentation(None);
|
||||
q.slides.clear();
|
||||
assert_eq!(outline_to_slides(&mut q, &out), 1, "table rows became slides: {out:?}");
|
||||
assert_eq!(q.slides.len(), 1, "{out:?}");
|
||||
let body = q.slides[0].shapes.iter().filter_map(|sh| sh.text.as_ref()).map(|t| t.text()).collect::<Vec<_>>().join("\n");
|
||||
assert!(body.contains("A1\tB1"), "{body:?}");
|
||||
assert!(body.contains("A2\tB2"), "{body:?}");
|
||||
assert!(out.contains("Text box body"), "{out}");
|
||||
assert!(out.contains("Cell A1\tCell B1"), "{out}");
|
||||
assert!(out.contains("Cell A2\tCell B2"), "{out}");
|
||||
}
|
||||
|
||||
@@ -1,363 +0,0 @@
|
||||
//! Outline / RTF: PowerPoint's "Outline/RTF" save type, and reading such a file back as slides.
|
||||
//!
|
||||
//! The export writes one paragraph per slide title (outline level 0, bold, larger) and one per
|
||||
//! body paragraph (outline level = indent + 1, indented), keeping bold, italic and underline.
|
||||
//! The import reads paragraphs and their `\outlinelevel` (or, failing that, `\li` indent) into
|
||||
//! the tab-indented outline [`crate::outline_to_slides`] understands.
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::{Paragraph, RunKind};
|
||||
|
||||
/// RTF-escape `s` (non-ASCII as `\uN?`, UTF-16, signed as RTF wants).
|
||||
fn escape(out: &mut String, s: &str) {
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'\\' | '{' | '}' => {
|
||||
out.push('\\');
|
||||
out.push(c);
|
||||
}
|
||||
'\t' => out.push_str("\\tab "),
|
||||
'\u{b}' | '\n' => out.push_str("\\line "),
|
||||
c if c.is_ascii() && !c.is_ascii_control() => out.push(c),
|
||||
c if c.is_ascii() => {}
|
||||
c => {
|
||||
let mut buf = [0u16; 2];
|
||||
for u in c.encode_utf16(&mut buf) {
|
||||
out.push_str(&format!("\\u{}?", *u as i16));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn runs(out: &mut String, p: &Paragraph) {
|
||||
for r in &p.runs {
|
||||
if matches!(r.kind, RunKind::Break) {
|
||||
out.push_str("\\line ");
|
||||
continue;
|
||||
}
|
||||
let (b, i, u) = (r.props.bold == Some(true), r.props.italic == Some(true), r.props.underline.as_deref().is_some_and(|u| u != "none"));
|
||||
if !(b || i || u) {
|
||||
escape(out, &r.text);
|
||||
continue;
|
||||
}
|
||||
out.push('{');
|
||||
out.push_str(if b { "\\b" } else { "" });
|
||||
out.push_str(if i { "\\i" } else { "" });
|
||||
out.push_str(if u { "\\ul" } else { "" });
|
||||
out.push(' ');
|
||||
escape(out, &r.text);
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
|
||||
/// The presentation's outline (slide titles and body text) as RTF.
|
||||
pub fn slides_to_rtf(p: &Presentation) -> String {
|
||||
let mut out = String::from("{\\rtf1\\ansi\\ansicpg1252\\deff0\\uc1{\\fonttbl{\\f0\\fswiss Liberation Sans;}}\n");
|
||||
for s in &p.slides {
|
||||
let title = s.shapes.iter().find(|sh| sh.ph_type().is_some_and(|k| k.is_title())).and_then(|sh| sh.text.as_ref());
|
||||
out.push_str("\\pard\\outlinelevel0\\sb240\\b\\fs32 ");
|
||||
match title.and_then(|t| t.paragraphs.iter().find(|p| !p.is_empty())) {
|
||||
Some(tp) => runs(&mut out, tp),
|
||||
None => escape(&mut out, &s.title()),
|
||||
}
|
||||
out.push_str("\\b0\\par\n");
|
||||
for sh in &s.shapes {
|
||||
if sh.ph_type().is_some_and(|k| k.is_title()) {
|
||||
continue;
|
||||
}
|
||||
let Some(t) = &sh.text else { continue };
|
||||
for para in t.paragraphs.iter().filter(|p| !p.is_empty()) {
|
||||
let lvl = para.level.min(8) as u32;
|
||||
out.push_str(&format!("\\pard\\outlinelevel{}\\li{}\\fs24 ", lvl + 1, 360 * (lvl + 1)));
|
||||
runs(&mut out, para);
|
||||
out.push_str("\\par\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `bytes` look like RTF.
|
||||
pub fn sniff_rtf(bytes: &[u8]) -> bool {
|
||||
bytes.trim_ascii_start().starts_with(b"{\\rtf")
|
||||
}
|
||||
|
||||
/// Destinations whose text is not document text.
|
||||
const SKIP: &[&str] = &[
|
||||
"fonttbl",
|
||||
"colortbl",
|
||||
"stylesheet",
|
||||
"info",
|
||||
"pict",
|
||||
"header",
|
||||
"footer",
|
||||
"footnote",
|
||||
"listtable",
|
||||
"listoverridetable",
|
||||
"generator",
|
||||
"xmlnstbl",
|
||||
"themedata",
|
||||
"datastore",
|
||||
"latentstyles",
|
||||
"rsidtbl",
|
||||
"object",
|
||||
"fldinst",
|
||||
];
|
||||
|
||||
/// `i + n`, or one character on when that would land inside a multi-byte character (a fallback
|
||||
/// run after `\uN` or `\'hh` may be followed by non-ASCII text).
|
||||
fn advance(rtf: &str, i: usize, n: usize) -> usize {
|
||||
if rtf.is_char_boundary(i + n) { i + n } else { i + rtf.get(i..).and_then(|r| r.chars().next()).map_or(1, char::len_utf8) }
|
||||
}
|
||||
|
||||
/// Read an RTF outline into tab-indented outline text: a paragraph's outline level (or its left
|
||||
/// indent in 0.25" steps) becomes its number of tabs.
|
||||
pub fn rtf_to_outline(rtf: &str) -> String {
|
||||
#[derive(Clone, Copy)]
|
||||
struct St {
|
||||
skip: bool,
|
||||
uc: usize,
|
||||
}
|
||||
let b = rtf.as_bytes();
|
||||
let mut stack: Vec<St> = vec![];
|
||||
let mut st = St { skip: false, uc: 1 };
|
||||
let (mut level, mut indent): (Option<u32>, i64) = (None, 0);
|
||||
let mut line = String::new();
|
||||
let mut out = String::new();
|
||||
let mut pending_skip = 0usize;
|
||||
let mut i = 0;
|
||||
let flush = |line: &mut String, level: Option<u32>, indent: i64, out: &mut String| {
|
||||
let text = line.trim();
|
||||
if !text.is_empty() {
|
||||
let n = level.map(|l| l as i64).unwrap_or(indent.saturating_add(180) / 360).clamp(0, 9) as usize;
|
||||
out.push_str(&"\t".repeat(n));
|
||||
out.push_str(text);
|
||||
out.push('\n');
|
||||
}
|
||||
line.clear();
|
||||
};
|
||||
while i < b.len() {
|
||||
let c = b[i];
|
||||
match c {
|
||||
b'{' => {
|
||||
stack.push(st);
|
||||
i += 1;
|
||||
// `{\*\dest …}`: an ignorable destination.
|
||||
if b[i..].starts_with(b"\\*") {
|
||||
st.skip = true;
|
||||
}
|
||||
}
|
||||
b'}' => {
|
||||
st = stack.pop().unwrap_or(St { skip: false, uc: 1 });
|
||||
i += 1;
|
||||
}
|
||||
b'\\' => {
|
||||
i += 1;
|
||||
let Some(&n) = b.get(i) else { break };
|
||||
if n.is_ascii_alphabetic() {
|
||||
let s = i;
|
||||
while i < b.len() && b[i].is_ascii_alphabetic() {
|
||||
i += 1;
|
||||
}
|
||||
let word = &rtf[s..i];
|
||||
let ns = i;
|
||||
if i < b.len() && (b[i] == b'-' || b[i].is_ascii_digit()) {
|
||||
i += 1;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
let arg: Option<i64> = rtf[ns..i].parse().ok();
|
||||
if i < b.len() && b[i] == b' ' {
|
||||
i += 1;
|
||||
}
|
||||
if pending_skip > 0 {
|
||||
pending_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if SKIP.contains(&word) {
|
||||
st.skip = true;
|
||||
}
|
||||
if st.skip {
|
||||
continue;
|
||||
}
|
||||
match word {
|
||||
"par" => {
|
||||
flush(&mut line, level, indent, &mut out);
|
||||
}
|
||||
"pard" => {
|
||||
level = None;
|
||||
indent = 0;
|
||||
}
|
||||
"outlinelevel" => level = arg.map(|a| a.clamp(0, 9) as u32),
|
||||
"li" => indent = arg.unwrap_or(0),
|
||||
"tab" | "line" => line.push(' '),
|
||||
"uc" => st.uc = arg.unwrap_or(1).clamp(0, 8) as usize,
|
||||
"u" => {
|
||||
let v = arg.unwrap_or(0);
|
||||
// RTF writes UTF-16 units as signed 16-bit values.
|
||||
push_utf16(&mut line, (v & 0xffff) as u32);
|
||||
pending_skip = 0;
|
||||
// Skip the fallback characters.
|
||||
let mut k = st.uc;
|
||||
while k > 0 && i < b.len() {
|
||||
if b[i] == b'\\' && b.get(i + 1) == Some(&b'\'') {
|
||||
i = advance(rtf, i, 4);
|
||||
} else if b[i] == b'{' || b[i] == b'}' || b[i] == b'\\' {
|
||||
break;
|
||||
} else {
|
||||
i = advance(rtf, i, 1);
|
||||
}
|
||||
k -= 1;
|
||||
}
|
||||
}
|
||||
"emdash" => line.push('—'),
|
||||
"endash" => line.push('–'),
|
||||
"bullet" => line.push('•'),
|
||||
"lquote" => line.push('‘'),
|
||||
"rquote" => line.push('’'),
|
||||
"ldblquote" => line.push('“'),
|
||||
"rdblquote" => line.push('”'),
|
||||
_ => {}
|
||||
}
|
||||
} else if n == b'\'' {
|
||||
let hex = rtf.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok());
|
||||
i = advance(rtf, i, 3);
|
||||
if !st.skip
|
||||
&& let Some(h) = hex
|
||||
&& h != 0
|
||||
{
|
||||
line.push(cp1252(h));
|
||||
}
|
||||
} else {
|
||||
i += 1;
|
||||
if st.skip {
|
||||
continue;
|
||||
}
|
||||
match n {
|
||||
b'\\' | b'{' | b'}' => line.push(n as char),
|
||||
b'~' => line.push('\u{a0}'),
|
||||
b'\n' | b'\r' => flush(&mut line, level, indent, &mut out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
b'\r' | b'\n' => i += 1,
|
||||
_ => {
|
||||
let s = i;
|
||||
while i < b.len() && !matches!(b[i], b'{' | b'}' | b'\\' | b'\r' | b'\n') {
|
||||
i += 1;
|
||||
}
|
||||
if !st.skip
|
||||
&& let Some(t) = rtf.get(s..i)
|
||||
{
|
||||
line.push_str(&t.replace('\u{0}', ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(&mut line, level, indent, &mut out);
|
||||
finish_surrogates(out)
|
||||
}
|
||||
|
||||
/// Push a UTF-16 unit; surrogate halves are parked as private markers (NUL plus four hex digits)
|
||||
/// and joined afterwards. NUL itself is the marker, so a NUL unit is dropped.
|
||||
fn push_utf16(line: &mut String, v: u32) {
|
||||
if v == 0 {
|
||||
return;
|
||||
}
|
||||
match char::from_u32(v) {
|
||||
Some(c) => line.push(c),
|
||||
None => line.push_str(&format!("\u{0}{v:04x}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_surrogates(s: String) -> String {
|
||||
if !s.contains('\u{0}') {
|
||||
return s;
|
||||
}
|
||||
let mut out = String::new();
|
||||
let mut rest = s.as_str();
|
||||
while let Some(p) = rest.find('\u{0}') {
|
||||
out.push_str(&rest[..p]);
|
||||
let hi = rest.get(p + 1..p + 5).and_then(|h| u32::from_str_radix(h, 16).ok());
|
||||
let lo = rest.get(p + 6..p + 10).filter(|_| rest.get(p + 5..p + 6) == Some("\u{0}")).and_then(|h| u32::from_str_radix(h, 16).ok());
|
||||
match (hi, lo) {
|
||||
(Some(h), Some(l)) if (0xD800..0xDC00).contains(&h) && (0xDC00..0xE000).contains(&l) => {
|
||||
out.extend(char::from_u32(0x10000 + ((h - 0xD800) << 10) + (l - 0xDC00)));
|
||||
rest = &rest[p + 10..];
|
||||
}
|
||||
_ => {
|
||||
out.push('\u{fffd}');
|
||||
// Skip the NUL and, when four hex digits follow, the digits; otherwise
|
||||
// resume right after the NUL so no following character is lost.
|
||||
let skip = if hi.is_some() { p + 5 } else { p + 1 };
|
||||
rest = rest.get(skip..).unwrap_or("");
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
fn cp1252(b: u8) -> char {
|
||||
const HIGH: [char; 32] = [
|
||||
'€', '\u{81}', '‚', 'ƒ', '„', '…', '†', '‡', 'ˆ', '‰', 'Š', '‹', 'Œ', '\u{8d}', 'Ž', '\u{8f}', '\u{90}', '‘', '’', '“', '”', '•', '–', '—',
|
||||
'˜', '™', 'š', '›', 'œ', '\u{9d}', 'ž', 'Ÿ',
|
||||
];
|
||||
match b {
|
||||
0x80..=0x9f => HIGH[(b - 0x80) as usize],
|
||||
_ => b as char,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
/// Non-ASCII text after a `\\uN` or `\\'hh` escape used to split a character and panic.
|
||||
#[test]
|
||||
fn non_ascii_text_after_escapes_does_not_panic() {
|
||||
let out = rtf_to_outline(r"{\rtf1\pard \u233é x \'€y\par}");
|
||||
assert!(out.contains('x'), "{out:?}");
|
||||
for rtf in [r"{\rtf1 \u233é€ z\par}", r"{\rtf1 \'é\par}", r"{\rtf1 \'4", r"{\rtf1 \u233"] {
|
||||
let _ = rtf_to_outline(rtf);
|
||||
}
|
||||
}
|
||||
|
||||
/// NUL is the surrogate marker, so NULs from `\u0`, `\'00` or raw bytes must not reach `finish_surrogates`.
|
||||
#[test]
|
||||
fn nul_escapes_do_not_drop_text_or_add_a_slide() {
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 \u0?abcé\par}"), "abcé\n");
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 a\'00bé\par}"), "abé\n");
|
||||
assert_eq!(rtf_to_outline("{\\rtf1 a\0b\\par}\0\0"), "ab\n");
|
||||
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
|
||||
}
|
||||
|
||||
/// A hostile `\li` near `i64::MAX` overflowed the rounding addend in debug builds.
|
||||
#[test]
|
||||
fn huge_left_indent_gives_the_deepest_level() {
|
||||
let out = rtf_to_outline(r"{\rtf1\pard\li9223372036854775807 deep\par\pard\li-9223372036854775808 flat\par}");
|
||||
assert_eq!(out, format!("{}deep\nflat\n", "\t".repeat(9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_reads_back_as_the_same_outline() {
|
||||
let mut p = deckcraft_model::defaults::blank_presentation(deckcraft_model::defaults::WIDE, Default::default(), false);
|
||||
let outline = "Plan {Q3}\n\tGrow — fast 😀\n\t\tEurope\\Asia\nCafé résumé\n\tDone\n";
|
||||
crate::outline_to_slides(&mut p, outline);
|
||||
let rtf = slides_to_rtf(&p);
|
||||
assert!(sniff_rtf(rtf.as_bytes()));
|
||||
assert!(rtf.contains("\\outlinelevel2") && rtf.contains("Plan \\{Q3\\}"), "{rtf}");
|
||||
assert_eq!(rtf_to_outline(&rtf), crate::slides_to_outline(&p));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_word_style_rtf_by_indent_and_hex_escapes() {
|
||||
let rtf = r"{\rtf1\ansi{\fonttbl{\f0 Arial;}}{\*\generator X;}\pard\b Title one\b0\par\pard\li720 Point \'93a\'94\par\pard\li1440 Sub\par}";
|
||||
assert_eq!(rtf_to_outline(rtf), "Title one\n\t\tPoint “a”\n\t\t\t\tSub\n");
|
||||
}
|
||||
}
|
||||
+6
-163
@@ -33,8 +33,7 @@ pub struct SubPath {
|
||||
}
|
||||
|
||||
/// A draggable adjust handle (the yellow dot). `pos = origin + dir * value`; dragging projects
|
||||
/// the pointer onto `dir`. An `angle` handle instead measures the pointer's angle about `origin`
|
||||
/// (1/60 000 degree, y down, 0° at +x).
|
||||
/// the pointer onto `dir`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Handle {
|
||||
pub index: usize,
|
||||
@@ -43,29 +42,14 @@ pub struct Handle {
|
||||
pub dir: Vec2,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
pub angle: bool,
|
||||
}
|
||||
|
||||
impl Handle {
|
||||
fn new(index: usize, origin: Point, dir: Vec2, value: f64, min: f64, max: f64) -> Self {
|
||||
Handle { index, pos: origin + dir * value, origin, dir, min, max, angle: false }
|
||||
}
|
||||
/// An angle handle on the `rx`×`ry` ellipse around `center` at visual angle `value`.
|
||||
fn angle(index: usize, center: Point, rx: f64, ry: f64, value: f64) -> Self {
|
||||
let t = vis(value / 60000.0, rx, ry).to_radians();
|
||||
let pos = Point::new(center.x + rx * t.cos(), center.y + ry * t.sin());
|
||||
Handle { index, pos, origin: center, dir: Vec2::ZERO, min: 0.0, max: 21600000.0, angle: true }
|
||||
Handle { index, pos: origin + dir * value, origin, dir, min, max }
|
||||
}
|
||||
/// The adjust value for a pointer at `p` (shape-local space).
|
||||
pub fn value_at(&self, p: Point) -> f64 {
|
||||
if self.angle {
|
||||
let d = p - self.origin;
|
||||
if !(d.x.is_finite() && d.y.is_finite()) || d.hypot() < 1e-9 {
|
||||
return self.min;
|
||||
}
|
||||
let v = (d.y.atan2(d.x).to_degrees().rem_euclid(360.0) * 60000.0).round();
|
||||
return if v >= self.max { self.min } else { v };
|
||||
}
|
||||
let l2 = self.dir.hypot2();
|
||||
if l2 < 1e-18 {
|
||||
return self.min;
|
||||
@@ -1098,15 +1082,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||
b.m(p0.x, p0.y).arc(rx, ry, s0, sweep);
|
||||
let pi = Point::new(w / 2.0 + irx * si.to_radians().cos(), h / 2.0 + iry * si.to_radians().sin());
|
||||
b.l(pi.x, pi.y).arc(irx, iry, si, isweep).z();
|
||||
let mut g = simple(b.take(), full, w, h);
|
||||
let c = Point::new(rx, ry);
|
||||
g.handles.push(Handle::angle(0, c, rx - t / 2.0, ry - t / 2.0, a(0)));
|
||||
// Thickness: slides inward from the outer edge along the end angle's ray.
|
||||
let pe = Point::new(rx + rx * e0.to_radians().cos(), ry + ry * e0.to_radians().sin());
|
||||
let u = (c - pe).normalize();
|
||||
let u = if u.x.is_finite() && u.y.is_finite() { u } else { Vec2::ZERO };
|
||||
g.handles.push(Handle::new(2, pe, u * (ss / 100000.0), a(2).clamp(0.0, 50000.0), 0.0, 50000.0));
|
||||
g
|
||||
simple(b.take(), full, w, h)
|
||||
}
|
||||
"pie" | "chord" | "arc" => {
|
||||
let (rx, ry) = (w / 2.0, h / 2.0);
|
||||
@@ -1119,7 +1095,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||
let p0 = Point::new(rx + rx * s.to_radians().cos(), ry + ry * s.to_radians().sin());
|
||||
b.m(p0.x, p0.y).arc(rx, ry, s, sw);
|
||||
let text = Rect::new(w * 0.14645, h * 0.14645, w * 0.85355, h * 0.85355);
|
||||
let mut g = match name {
|
||||
match name {
|
||||
"pie" => {
|
||||
b.l(rx, ry).z();
|
||||
simple(b.take(), text, w, h)
|
||||
@@ -1135,11 +1111,7 @@ pub fn build(name: &str, w: f64, h: f64, adj: &[f64]) -> Option<Geometry> {
|
||||
f.close_path();
|
||||
Geometry { paths: vec![fill_only(f), stroke_only(open)], text_rect: text, handles: vec![], sites: mid_sites(w, h) }
|
||||
}
|
||||
};
|
||||
let c = Point::new(rx, ry);
|
||||
g.handles.push(Handle::angle(0, c, rx, ry, a(0)));
|
||||
g.handles.push(Handle::angle(1, c, rx, ry, a(1)));
|
||||
g
|
||||
}
|
||||
}
|
||||
"teardrop" => {
|
||||
let k = a(0).clamp(0.0, 200000.0) / 100000.0;
|
||||
@@ -1849,7 +1821,7 @@ fn ellipse_sites(w: f64, h: f64) -> Vec<Point> {
|
||||
|
||||
fn tip_handle(w: f64, h: f64, tip: Point) -> Handle {
|
||||
// A free 2-D handle is represented as index 0 with zero direction; callers move both values.
|
||||
Handle { index: 0, pos: tip, origin: Point::new(w / 2.0, h / 2.0), dir: Vec2::new(w / 1e5, h / 1e5), min: -1e6, max: 1e6, angle: false }
|
||||
Handle { index: 0, pos: tip, origin: Point::new(w / 2.0, h / 2.0), dir: Vec2::new(w / 1e5, h / 1e5), min: -1e6, max: 1e6 }
|
||||
}
|
||||
|
||||
fn wedge_rect(w: f64, h: f64, a0: f64, a1: f64, r: f64) -> Geometry {
|
||||
@@ -2280,135 +2252,6 @@ mod tests {
|
||||
assert_eq!(h.value_at(Point::new(500.0, 0.0)), 50000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_circle_angle_handles_map_back() {
|
||||
// Start 0° sits at the right of the ellipse, end 270° at its top.
|
||||
for name in ["pie", "chord", "arc"] {
|
||||
let g = build(name, 200.0, 100.0, &[0.0, 16200000.0]).unwrap();
|
||||
assert_eq!(g.handles.iter().map(|h| h.index).collect::<Vec<_>>(), vec![0, 1], "{name}");
|
||||
let (s, e) = (&g.handles[0], &g.handles[1]);
|
||||
assert!((s.pos - Point::new(200.0, 50.0)).hypot() < 1e-6, "{name} {:?}", s.pos);
|
||||
assert!((e.pos - Point::new(100.0, 0.0)).hypot() < 1e-6, "{name} {:?}", e.pos);
|
||||
assert_eq!(s.value_at(s.pos), 0.0, "{name}");
|
||||
assert_eq!(e.value_at(e.pos), 16200000.0, "{name}");
|
||||
// Dragging to the bottom of the ellipse gives 90°.
|
||||
assert_eq!(s.value_at(Point::new(100.0, 100.0)), 5400000.0, "{name}");
|
||||
// Off-axis angles on a non-square ellipse: the visual 45° ray meets the 200×100
|
||||
// ellipse at offset (√2000, √2000) from the centre.
|
||||
let k = 2000f64.sqrt();
|
||||
let g = build(name, 200.0, 100.0, &[2700000.0, 8100000.0]).unwrap();
|
||||
let (s, e) = (&g.handles[0], &g.handles[1]);
|
||||
assert!((s.pos - Point::new(100.0 + k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", s.pos);
|
||||
assert!((e.pos - Point::new(100.0 - k, 50.0 + k)).hypot() < 1e-6, "{name} {:?}", e.pos);
|
||||
for (deg, h) in [(45.0, s), (135.0, e)] {
|
||||
let on = ((h.pos.x - 100.0) / 100.0).powi(2) + ((h.pos.y - 50.0) / 50.0).powi(2);
|
||||
assert!((on - 1.0).abs() < 1e-9, "{name} {deg}° off ellipse: {:?}", h.pos);
|
||||
assert!((h.value_at(h.pos) - deg * 60000.0).abs() <= 1.0, "{name} {deg}°: {}", h.value_at(h.pos));
|
||||
}
|
||||
}
|
||||
for deg in [45.0, 135.0, 200.0, 315.0] {
|
||||
let g = build("blockArc", 200.0, 100.0, &[deg * 60000.0, 0.0, 25000.0]).unwrap();
|
||||
let start = g.handles.iter().find(|h| h.index == 0).unwrap();
|
||||
// Thickness 25 → the handle runs on the 87.5×37.5 mid-ring ellipse.
|
||||
let on = ((start.pos.x - 100.0) / 87.5).powi(2) + ((start.pos.y - 50.0) / 37.5).powi(2);
|
||||
assert!((on - 1.0).abs() < 1e-9, "blockArc {deg}° off ring: {:?}", start.pos);
|
||||
let d = start.pos - Point::new(100.0, 50.0);
|
||||
assert!((d.y.atan2(d.x).to_degrees().rem_euclid(360.0) - deg).abs() < 1e-6, "blockArc {deg}°: {:?}", start.pos);
|
||||
assert!((start.value_at(start.pos) - deg * 60000.0).abs() <= 1.0, "blockArc {deg}°");
|
||||
}
|
||||
let g = build("blockArc", 200.0, 100.0, &[10800000.0, 0.0, 25000.0]).unwrap();
|
||||
let start = g.handles.iter().find(|h| h.index == 0).unwrap();
|
||||
assert_eq!(start.value_at(start.pos), 10800000.0);
|
||||
let thick = g.handles.iter().find(|h| h.index == 2).unwrap();
|
||||
assert!((thick.value_at(thick.pos) - 25000.0).abs() <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_with_adjust_values_expose_handles() {
|
||||
// Presets whose adjust values have no handle of their own yet.
|
||||
const NO_HANDLES: &[&str] = &[
|
||||
"curvedConnector3",
|
||||
"snip2SameRect",
|
||||
"snip2DiagRect",
|
||||
"snipRoundRect",
|
||||
"round1Rect",
|
||||
"round2SameRect",
|
||||
"round2DiagRect",
|
||||
"teardrop",
|
||||
"halfFrame",
|
||||
"corner",
|
||||
"diagStripe",
|
||||
"foldedCorner",
|
||||
"smileyFace",
|
||||
"moon",
|
||||
"gear6",
|
||||
"gear9",
|
||||
"bracketPair",
|
||||
"bracePair",
|
||||
"leftBracket",
|
||||
"rightBracket",
|
||||
"leftBrace",
|
||||
"rightBrace",
|
||||
"leftRightArrow",
|
||||
"upDownArrow",
|
||||
"quadArrow",
|
||||
"leftRightUpArrow",
|
||||
"bentArrow",
|
||||
"uturnArrow",
|
||||
"curvedRightArrow",
|
||||
"curvedLeftArrow",
|
||||
"curvedUpArrow",
|
||||
"curvedDownArrow",
|
||||
"leftUpArrow",
|
||||
"bentUpArrow",
|
||||
"circularArrow",
|
||||
"leftCircularArrow",
|
||||
"leftRightCircularArrow",
|
||||
"swooshArrow",
|
||||
"rightArrowCallout",
|
||||
"downArrowCallout",
|
||||
"leftArrowCallout",
|
||||
"upArrowCallout",
|
||||
"leftRightArrowCallout",
|
||||
"upDownArrowCallout",
|
||||
"quadArrowCallout",
|
||||
"mathPlus",
|
||||
"mathMinus",
|
||||
"mathMultiply",
|
||||
"mathDivide",
|
||||
"mathEqual",
|
||||
"mathNotEqual",
|
||||
"ribbon2",
|
||||
"ribbon",
|
||||
"ellipseRibbon2",
|
||||
"ellipseRibbon",
|
||||
"leftRightRibbon",
|
||||
"verticalScroll",
|
||||
"horizontalScroll",
|
||||
"wave",
|
||||
"doubleWave",
|
||||
"borderCallout1",
|
||||
"borderCallout2",
|
||||
"borderCallout3",
|
||||
"accentCallout1",
|
||||
"accentCallout2",
|
||||
"accentCallout3",
|
||||
"callout1",
|
||||
"callout2",
|
||||
"callout3",
|
||||
"accentBorderCallout1",
|
||||
"accentBorderCallout2",
|
||||
"accentBorderCallout3",
|
||||
];
|
||||
for p in CATALOG {
|
||||
if p.defaults.is_empty() || NO_HANDLES.contains(&p.name) {
|
||||
continue;
|
||||
}
|
||||
let g = build(p.name, 200.0, 100.0, &[]).unwrap();
|
||||
assert!(!g.handles.is_empty(), "{} has adjust values but no handles", p.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rect_area_and_ellipse_area() {
|
||||
let g = build("rect", 100.0, 50.0, &[]).unwrap();
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use crate::cabac::Cabac;
|
||||
use crate::error::{Result, ensure, invalid};
|
||||
use crate::mbtypes::{MbTypeInfo, Part, SubMbInfo, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type};
|
||||
use crate::mbtypes::*;
|
||||
use crate::picture::MbKind;
|
||||
use crate::slice::SliceType;
|
||||
use crate::slicedec::{MbCur, SliceDecoder};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! CAVLC residual block parsing (9.2).
|
||||
|
||||
use crate::cavlc_tables::{COEFF_TOKEN, RUN_BEFORE, TOTAL_ZEROS_2X2, TOTAL_ZEROS_2X4, TOTAL_ZEROS_4X4};
|
||||
use crate::cavlc_tables::*;
|
||||
use crate::error::{Result, ensure};
|
||||
use deckcraft_bitstream::BitReader;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -232,7 +232,7 @@ fn filter_vertical<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y: u
|
||||
let mut w = [0u64; N];
|
||||
for (i, w) in w.iter_mut().enumerate() {
|
||||
let o = (y + i) * stride + x - 4;
|
||||
let Some(&b) = pix.get(o..).and_then(<[_]>::first_chunk::<8>) else {
|
||||
let Some(&b) = pix.get(o..).and_then(|p| p.first_chunk::<8>()) else {
|
||||
return;
|
||||
};
|
||||
*w = u64::from_le_bytes(b);
|
||||
@@ -259,7 +259,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
|
||||
let base = (y - 4) * stride + x;
|
||||
let mut s = [[0i16; N]; 8];
|
||||
for (k, row) in s.iter_mut().enumerate() {
|
||||
let Some(src) = pix.get(base + k * stride..).and_then(<[_]>::first_chunk::<N>) else {
|
||||
let Some(src) = pix.get(base + k * stride..).and_then(|p| p.first_chunk::<N>()) else {
|
||||
return;
|
||||
};
|
||||
for i in 0..N {
|
||||
@@ -269,7 +269,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
|
||||
filter_lanes(&mut s, ep, chroma);
|
||||
let (k0, k1) = if chroma { (3, 5) } else { (1, 7) };
|
||||
for (k, row) in s.iter().enumerate().take(k1).skip(k0) {
|
||||
let Some(dst) = pix.get_mut(base + k * stride..).and_then(<[_]>::first_chunk_mut::<N>) else {
|
||||
let Some(dst) = pix.get_mut(base + k * stride..).and_then(|p| p.first_chunk_mut::<N>()) else {
|
||||
return;
|
||||
};
|
||||
for i in 0..N {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Sequence and picture parameter sets (7.3.2.1, 7.3.2.2) including VUI and scaling lists.
|
||||
|
||||
use crate::error::{Result, ensure, unsupported};
|
||||
use crate::tables::{DEFAULT_4X4_INTER, DEFAULT_4X4_INTRA, DEFAULT_8X8_INTER, DEFAULT_8X8_INTRA, ZIGZAG4, ZIGZAG8};
|
||||
use crate::tables::*;
|
||||
use deckcraft_bitstream::BitReader;
|
||||
|
||||
/// Scaling matrices after applying fall-back rules, stored in raster order (weightScale).
|
||||
|
||||
@@ -301,7 +301,7 @@ fn plane_allocator() -> fn(usize) -> Vec<u8> {
|
||||
fn copy_line<const N: usize>(line: &[u8], x0: i32, out: &mut [u8], w: usize) {
|
||||
if w <= N
|
||||
&& x0 >= 0
|
||||
&& let Some(src) = line.get(x0 as usize..).and_then(<[_]>::first_chunk::<N>)
|
||||
&& let Some(src) = line.get(x0 as usize..).and_then(|l| l.first_chunk::<N>())
|
||||
&& let Some(dst) = out.first_chunk_mut::<N>()
|
||||
{
|
||||
*dst = *src;
|
||||
|
||||
@@ -5,11 +5,11 @@ use crate::deblock;
|
||||
use crate::error::{Result, ensure, invalid};
|
||||
use crate::inter::{self, Weight};
|
||||
use crate::intra::{self, Avail};
|
||||
use crate::mbtypes::{CBP_ME, MbTypeInfo, Part, SubMbInfo, SubPart, b_mb_type, b_sub_mb_type, intra_mb_type, p_mb_type, p_sub_mb_type};
|
||||
use crate::mbtypes::*;
|
||||
use crate::params::{Pps, Sps};
|
||||
use crate::picture::{Frame, FrameRef, MbKind, MbState, Planes, RefPic};
|
||||
use crate::picture::*;
|
||||
use crate::slice::{SliceHeader, SliceType};
|
||||
use crate::tables::{BLK4_XY, QPC_TABLE, RASTER_TO_BLK4, ZIGZAG4, ZIGZAG8};
|
||||
use crate::tables::*;
|
||||
use crate::transform::{self, LevelScale};
|
||||
use deckcraft_bitstream::BitReader;
|
||||
|
||||
@@ -907,7 +907,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
let stride = self.pic.planes.width;
|
||||
let px = self.mb_x * 16 + (raster & 3) * 4;
|
||||
let py = self.mb_y * 16 + (raster >> 2) * 4;
|
||||
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(<[_]>::first_chunk_mut::<16>) else {
|
||||
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
|
||||
return;
|
||||
};
|
||||
transform::idct4_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
|
||||
@@ -921,7 +921,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
let stride = self.pic.planes.width;
|
||||
let px = self.mb_x * 16 + (b8 & 1) * 8;
|
||||
let py = self.mb_y * 16 + (b8 >> 1) * 8;
|
||||
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(<[_]>::first_chunk_mut::<64>) else {
|
||||
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(|c| c.first_chunk_mut::<64>()) else {
|
||||
return;
|
||||
};
|
||||
transform::idct8_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
|
||||
@@ -940,7 +940,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
let px = self.mb_x * 8 + (b & 1) * 4;
|
||||
let py = self.mb_y * 8 + (b >> 1) * 4;
|
||||
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(<[_]>::first_chunk_mut::<16>) else {
|
||||
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
|
||||
continue;
|
||||
};
|
||||
let plane = if c == 0 { &mut self.pic.planes.cb } else { &mut self.pic.planes.cr };
|
||||
|
||||
@@ -107,7 +107,7 @@ struct Pic {
|
||||
|
||||
impl Pic {
|
||||
fn is_p(&self) -> bool {
|
||||
!self.idr && !self.b && self.mbs.iter().any(Option::is_none)
|
||||
!self.idr && !self.b && self.mbs.iter().any(|m| m.is_none())
|
||||
}
|
||||
fn inter(&self) -> bool {
|
||||
self.b || self.is_p()
|
||||
|
||||
@@ -117,7 +117,7 @@ pub fn idct4_add(d: &[i32; 16], dst: &mut [u8], stride: usize) {
|
||||
o[3][j] = e - h;
|
||||
}
|
||||
for (i, row) in o.iter().enumerate() {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(<[_]>::first_chunk_mut::<4>) else {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<4>()) else {
|
||||
return;
|
||||
};
|
||||
for (p, &v) in line.iter_mut().zip(row) {
|
||||
@@ -173,7 +173,7 @@ pub fn idct8_add(d: &[i32; 64], dst: &mut [u8], stride: usize) {
|
||||
}
|
||||
}
|
||||
for (i, row) in o.iter().enumerate() {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(<[_]>::first_chunk_mut::<8>) else {
|
||||
let Some(line) = dst.get_mut(i * stride..).and_then(|d| d.first_chunk_mut::<8>()) else {
|
||||
return;
|
||||
};
|
||||
for (p, &v) in line.iter_mut().zip(row) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user