mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-29 04:56:58 -06:00
Compare commits
111
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe510bdac6 | ||
|
|
f34f4121c8 | ||
|
|
010941ec60 | ||
|
|
cb78440a16 | ||
|
|
868016ee2f | ||
|
|
0981e6e675 | ||
|
|
006bbe1136 | ||
|
|
dfe40a17bf | ||
|
|
0b6705c12c | ||
|
|
ef6e8cdc2f | ||
|
|
9430673350 | ||
|
|
efc279d6f2 | ||
|
|
c1184d64fd | ||
|
|
8b01ed3e88 | ||
|
|
9e1430e296 | ||
|
|
fab77f45e5 | ||
|
|
11bc157477 | ||
|
|
389c2c2921 | ||
|
|
4fcf593bac | ||
|
|
26371b0629 | ||
|
|
dfa91441e6 | ||
|
|
9eadf35c97 | ||
|
|
f5a895ef75 | ||
|
|
0883d89e77 | ||
|
|
eb417f0470 | ||
|
|
19fadde1cc | ||
|
|
45eeb7c819 | ||
|
|
6dca3e860b | ||
|
|
3e729bbf08 | ||
|
|
3f973b0d87 | ||
|
|
e5a3c9ca65 | ||
|
|
8ee5094c60 | ||
|
|
368e07493c | ||
|
|
051d3b4fd3 | ||
|
|
daae74b57e | ||
|
|
5e8c1bcc51 | ||
|
|
f4afe6d2df | ||
|
|
a773fcfce9 | ||
|
|
121882e177 | ||
|
|
22db9261f9 | ||
|
|
19724866a6 | ||
|
|
83f21e7cb9 | ||
|
|
bdcbee8d0b | ||
|
|
23144e36fe | ||
|
|
a178b9955b | ||
|
|
2cf4535c5d | ||
|
|
887e868a46 | ||
|
|
2a4adf3024 | ||
|
|
70244c899d | ||
|
|
d3fb412167 | ||
|
|
7f1979a1cc | ||
|
|
392734a135 | ||
|
|
39e7fb1d4f | ||
|
|
c7423aa986 | ||
|
|
8c41140abe | ||
|
|
b6af19ae35 | ||
|
|
1dc69720db | ||
|
|
1b64de3499 | ||
|
|
ad6be08603 | ||
|
|
e5620b538a | ||
|
|
1bb408462c | ||
|
|
e755d3eeb1 | ||
|
|
f4731822c9 | ||
|
|
f9af68a06f | ||
|
|
be2f10d8cb | ||
|
|
acea4e3f14 | ||
|
|
367c3488c5 | ||
|
|
c8dc372519 | ||
|
|
d7509e7ad6 | ||
|
|
595d8f17c1 | ||
|
|
59d5893793 | ||
|
|
0954289c62 | ||
|
|
062adfe405 | ||
|
|
aa681eafad | ||
|
|
c39d71a51a | ||
|
|
9859da3925 | ||
|
|
09c7f60567 | ||
|
|
155c1cfce2 | ||
|
|
a15d93acdd | ||
|
|
561902d0b3 | ||
|
|
a74191dba4 | ||
|
|
593fd215ff | ||
|
|
1cba8f0f60 | ||
|
|
404d303c2e | ||
|
|
e4fd7a29cc | ||
|
|
0b870df463 | ||
|
|
816f256c5f | ||
|
|
60fa10f99f | ||
|
|
82ef9642a2 | ||
|
|
672d86c0e4 | ||
|
|
54d3f19b5b | ||
|
|
2c57163549 | ||
|
|
0cbe475f13 | ||
|
|
0222a90e5d | ||
|
|
51de1ad2cf | ||
|
|
8b08718b9a | ||
|
|
7cda7eda1d | ||
|
|
57c03d36fc | ||
|
|
40e05abd45 | ||
|
|
b69613bf88 | ||
|
|
a82f691993 | ||
|
|
82ee5dec25 | ||
|
|
2568c55afa | ||
|
|
f4ce764d1c | ||
|
|
16bef64ea1 | ||
|
|
8c3404fc87 | ||
|
|
50d9ad00e6 | ||
|
|
42fb6728fa | ||
|
|
aeaa3684a8 | ||
|
|
c536c6e905 | ||
|
|
7d5dd85209 |
@@ -0,0 +1,30 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Maintain Rust / Cargo dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
# Consolidate updates into a single PR rather than one PR per crate
|
||||
groups:
|
||||
cargo-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# Maintain GitHub Actions workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
time: "06:00"
|
||||
timezone: "America/New_York"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci(deps)"
|
||||
@@ -0,0 +1,33 @@
|
||||
# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation
|
||||
# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice.
|
||||
name: Nix
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
pull_request:
|
||||
paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: nix-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
eval:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1
|
||||
- name: nix flake check --no-build
|
||||
run: |
|
||||
if [ -f flake.nix ]; then
|
||||
nix flake check --no-build --all-systems
|
||||
else
|
||||
echo "No flake.nix present in this repository; skipping check."
|
||||
fi
|
||||
@@ -0,0 +1,30 @@
|
||||
# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64
|
||||
# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed
|
||||
# binaries are ARM64, run the CLI natively, and uninstall.
|
||||
name: Windows ARM64
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [release]
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/windows-arm64.yml
|
||||
- packaging/windows/**
|
||||
schedule:
|
||||
- cron: "41 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: windows-arm64-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
windows-arm64:
|
||||
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
|
||||
uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main
|
||||
with:
|
||||
app-name: deckcraft
|
||||
app-dir: DeckCraft
|
||||
@@ -61,3 +61,17 @@ families excluded by `crates/fonts/build.rs`:
|
||||
| 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 BIZ UDPGothic Regular.
|
||||
|
||||
## PDF writer colour profiles
|
||||
|
||||
`craft-pdf` is used with only its `write` feature and default features disabled. Krilla
|
||||
0.8.2 embeds the following profiles; these were already reachable through the direct Krilla
|
||||
exporter. No interpreter fallback fonts or CMaps are enabled, and no profile or font binary
|
||||
is copied into this repository. The licence text is in `licenses/pdf-writer/LICENSE-ICC.txt`.
|
||||
|
||||
| Asset | Author | Source | Licence | SHA-256 | Used for |
|
||||
|---|---|---|---|---|---|
|
||||
| `krilla 0.8.2 / icc/sGrey-v2-magic.icc` | Clinton Ingram (Compact ICC Profiles) | https://github.com/LaurenzV/krilla/blob/3ffdf0588cf98050aad6edba51ca70162e1fb5b5/crates/krilla/icc/sGrey-v2-magic.icc | CC0-1.0 | `4110169287e5f1b5149ca9f1da1814d32943905bba8030e849d1a3b729a19481` | PDF export colour profiles |
|
||||
| `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 |
|
||||
|
||||
Generated
+114
-56
@@ -605,6 +605,12 @@ dependencies = [
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.6.0"
|
||||
@@ -814,6 +820,15 @@ dependencies = [
|
||||
"windows 0.54.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.2"
|
||||
@@ -877,7 +892,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"cpal",
|
||||
@@ -896,7 +911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -905,7 +920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -914,30 +929,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
"serde_json",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -962,7 +978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -974,7 +990,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -985,7 +1001,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -993,7 +1009,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1002,7 +1018,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1011,7 +1027,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1019,11 +1035,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1033,7 +1049,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1051,7 +1067,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1060,7 +1076,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1072,30 +1088,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
"deckcraft-model",
|
||||
"deckcraft-render",
|
||||
"deckcraft-text",
|
||||
"hayro-syntax",
|
||||
"krilla",
|
||||
"kurbo",
|
||||
"log",
|
||||
"serde",
|
||||
"thiserror 2.0.21",
|
||||
"wasm-bindgen-test",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1111,7 +1130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1130,7 +1149,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1144,7 +1163,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1166,7 +1185,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1175,7 +1194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1586,12 +1605,6 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "fearless_simd"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3296eb97d869113eaf148a6bdf01884e90b3828ad7ae2340b0f93362fc158611"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.14"
|
||||
@@ -2005,7 +2018,6 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "000e025850f4592ddcaee85640db70704151f2bafd43b6f431aa5b6a2c6a35a9"
|
||||
dependencies = [
|
||||
"fearless_simd 1.1.0",
|
||||
"hayro-ccitt",
|
||||
]
|
||||
|
||||
@@ -2014,9 +2026,6 @@ name = "hayro-jpeg2000"
|
||||
version = "0.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c75ab947623ef4ccaa7acf0579edf7cbb5a73838e3839a7be73335e522f433a1"
|
||||
dependencies = [
|
||||
"fearless_simd 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-syntax"
|
||||
@@ -2024,26 +2033,13 @@ version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0edeafd70aa2db743de8ede8637d07ec87db05efe69cde371d03f1b185fcef27"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-jbig2",
|
||||
"hayro-jpeg2000",
|
||||
"memchr",
|
||||
"rustc-hash 2.1.3",
|
||||
"smallvec",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hayro-write"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a0b3db92c4917aad24ff30d61413a9a7509ae88b27aee1ae34eff881e1d2723a"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hayro-syntax",
|
||||
"pdf-writer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.3"
|
||||
@@ -2430,15 +2426,13 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27da593198b20eeba65caeb73c2bbeec3e53ab08fa549898312ce81c4fce5e33"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=c3487320b2d1de657b860b3c8abb44e1f2b32bd8#c3487320b2d1de657b860b3c8abb44e1f2b32bd8"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bumpalo",
|
||||
"flate2",
|
||||
"float-cmp",
|
||||
"gif",
|
||||
"hayro-write",
|
||||
"image-webp",
|
||||
"imagesize",
|
||||
"indexmap",
|
||||
@@ -2588,6 +2582,16 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minicov"
|
||||
version = "0.3.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2698,6 +2702,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-derive"
|
||||
version = "0.4.2"
|
||||
@@ -3095,6 +3108,12 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "oorandom"
|
||||
version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.4.4"
|
||||
@@ -4615,7 +4634,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd 0.4.1",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -4630,7 +4649,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"fearless_simd 0.4.1",
|
||||
"fearless_simd",
|
||||
"guillotiere",
|
||||
"log",
|
||||
"peniko",
|
||||
@@ -4758,6 +4777,45 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae7499dfd45780a0a91d7ee6bb9ac51970a4479a41a89da443fdda5a39547d42"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"cast",
|
||||
"js-sys",
|
||||
"libm",
|
||||
"minicov",
|
||||
"nu-ansi-term",
|
||||
"num-traits",
|
||||
"oorandom",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-bindgen-test-macro",
|
||||
"wasm-bindgen-test-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-macro"
|
||||
version = "0.3.79"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b84b5ac638bfb168196a1a461fcc8f46a294a18b1b6be52133b4e0db122cc9f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-test-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f692aa943ccd88363733b77063f32cfed5bc6cbea8e6e8b251b302f881606fe"
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.17"
|
||||
@@ -5663,7 +5721,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
+6
-3
@@ -4,10 +4,10 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.0"
|
||||
version = "0.9.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
rust-version = "1.95"
|
||||
repository = "https://github.com/storytold/deckcraft"
|
||||
|
||||
[workspace.dependencies]
|
||||
@@ -57,7 +57,7 @@ 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"] }
|
||||
krilla = { version = "0.8", features = ["pdf"] }
|
||||
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"] }
|
||||
@@ -93,3 +93,6 @@ opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[patch.crates-io]
|
||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8", version = "=0.8.2" }
|
||||
|
||||
@@ -72,3 +72,11 @@ Code dependencies
|
||||
https://github.com/pdeljanov/Symphonia.
|
||||
- The H.264, HEVC, VP9, AV1, Opus, MP4, Matroska and Ogg crates under crates/ are ported from
|
||||
FilmCraft (same owner, MIT OR Apache-2.0).
|
||||
|
||||
- Krilla 0.8.2 (https://github.com/LaurenzV/krilla) is used through craft-pdf under
|
||||
MIT OR Apache-2.0. craft-libs vendors that version with page/surface unwind recovery
|
||||
and rejection of serialization after failed cleanup. Its upstream licence files are
|
||||
retained in craft-libs/licenses/pdf-writer. Its bundled Compact ICC Profiles by Clinton Ingram
|
||||
(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.
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
<h1 align="center">DeckCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
<b>Presentations and slide shows; an open-source, clean-room reimplementation of Microsoft PowerPoint, rebuilt in pure Rust.</b>
|
||||
<b>Presentations and slide shows; an open-source, modern presentation and slide designer, built in pure Rust.</b>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
Build decks with themes, layouts, shapes, pictures, tables, charts, transitions and animations,
|
||||
open and save PowerPoint files, and present them full screen. Native on macOS, Windows, Linux
|
||||
open and save .pptx presentation files, and present them full screen. Native on macOS, Windows, Linux
|
||||
and FreeBSD, and in the browser. Every button is also a command that the CLI and AI agents
|
||||
(through MCP) can drive.
|
||||
</p>
|
||||
@@ -74,7 +74,7 @@ Today it can:
|
||||
audio and video (MP3, AAC, FLAC, ALAC, Ogg, Opus, WAV, AIFF; H.264, HEVC, VP9, AV1).
|
||||
- **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations,
|
||||
by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback.
|
||||
- **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts
|
||||
- **Exchange files**: open and save .pptx presentations, export PDF (slides, notes pages, handouts
|
||||
with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover.
|
||||
- **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an
|
||||
MCP server for AI agents.
|
||||
@@ -97,6 +97,9 @@ Today it can:
|
||||
|
||||
## Getting started
|
||||
|
||||
PDF export uses the shared `craft-pdf` writer with import features disabled. See
|
||||
[`crates/pdf/README.md`](crates/pdf/README.md) for the dependency and validation requirements.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/deckcraft
|
||||
cd deckcraft
|
||||
@@ -129,7 +132,7 @@ settings directory, beside `ui.json` and `prefs.json` (Linux `~/.config/deckcraf
|
||||
`$XDG_CONFIG_HOME/deckcraft/logs/`; macOS `~/Library/Application Support/DeckCraft/logs/`; Windows
|
||||
`%APPDATA%\DeckCraft\logs\`). A start from a desktop menu or the Dock has no terminal, so this file
|
||||
is what to attach to a bug report: the crash guard's panic report, a failed crash-recovery save,
|
||||
PowerPoint parts skipped on import and audio output problems land there. Each launch moves the
|
||||
Parts skipped on import and audio output problems land there. Each launch moves the
|
||||
previous log to `deckcraft.1.log` (and that one to `deckcraft.2.log`), so the log of a run that
|
||||
crashed survives the next start. The file stops growing at 16 MiB. `--version` writes no file.
|
||||
|
||||
@@ -155,8 +158,8 @@ deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # con
|
||||
|
||||
## Roadmap
|
||||
|
||||
DeckCraft is in **alpha**. It covers **84% of PowerPoint's features by breadth** (92% of the core
|
||||
ones, measured) and is about **55% of the way to replacing PowerPoint for real work** (estimated).
|
||||
DeckCraft is in **alpha**. It covers **84% of the features tracked in [`docs/target-app-parity.md`](docs/target-app-parity.md) by breadth** (92% of the core
|
||||
ones, measured) and is about **55% ready for real work across all tracked capabilities** (estimated).
|
||||
See [ROADMAP.md](ROADMAP.md) for the stage, numbers by dimension and what's next,
|
||||
[docs/target-app-parity.md](docs/target-app-parity.md) for the full assessment,
|
||||
[docs/gaps.md](docs/gaps.md) for every known shortfall and
|
||||
@@ -276,15 +279,13 @@ Bundled fonts, icons, images and other assets keep their own open licenses; each
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
DeckCraft's themes, layouts, shape geometry, icons and sample decks are original work, drawn or
|
||||
generated in code; no Microsoft artwork, fonts, themes or templates are used.
|
||||
generated in code; no proprietary artwork, fonts, themes or templates are used.
|
||||
|
||||
The ArtCraft name, wordmark and logos in [`docs/brand/`](docs/brand/) are trademarks of the
|
||||
ArtCraft Team and are not covered by this license. They may be used only unmodified, and only as
|
||||
part of this repository and DeckCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt).
|
||||
Forks and modified versions must remove them.
|
||||
|
||||
<sub>Microsoft and PowerPoint are trademarks of the Microsoft group of companies. Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. DeckCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Microsoft Corporation or Adobe Inc.; these names are used only to describe the workflows it is compatible with.</sub>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://getartcraft.com/"><img alt="ArtCraft" src="docs/brand/artcraft-mark.svg" width="28"></a><br>
|
||||
<sub>Made by the <a href="https://getartcraft.com/">ArtCraft</a> team and community.</sub>
|
||||
|
||||
+26
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
|
||||
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
|
||||
@@ -131,6 +131,24 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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-17 | Opening a file caps absurd effect sizes (glow, soft edges, shadows, reflections, on shapes, text and table text) at what Format Shape allows: a glow of 10^18 pt in a .pptx made rendering ask for 34 GB and abort |
|
||||
| 2026-10-17 | Opening a .deckcraft file whose shapes repeat an id gives the repeats fresh ids (groups, layouts and masters too), so the deck validates and edits by id find one shape |
|
||||
| 2026-10-17 | Edit Points: turning a shape into a freeform is one undo step, so Undo brings the original shape back (it used to stay a freeform) |
|
||||
| 2026-10-17 | While a placeholder's text is being edited, a click on a shape in front of it selects that shape instead of moving the caret, as in PowerPoint (#931) |
|
||||
| 2026-10-15 | Native saves keep an imported chart's embedded workbook, drawings and relationships; headless PPTX → edit → native save/reopen → PPTX tests verify the payloads and stale-workbook rejection |
|
||||
| 2026-10-15 | PPTX trigger sequences keep separate On Click steps, including paragraph builds; synthetic import/edit/save/reopen and headless show regressions |
|
||||
| 2026-10-16 | Export: PNG with a transparent background — the slide background is left out, only the slide's content is drawn (Export… dialog, File › Export, web download, `file.export`/`file.render` `transparentBackground`, `deckcraft-cli render --transparent`) (#784) |
|
||||
| 2026-10-16 | Arrange ▸ Align: Align to Slide and Align Selected Objects, as in PowerPoint. One object aligns to the slide and several to each other until the other choice is ticked; the choice holds for that selection and also drives Distribute |
|
||||
| 2026-10-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
|
||||
| 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) |
|
||||
| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off |
|
||||
| 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) |
|
||||
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
|
||||
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
|
||||
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
|
||||
| 2026-10-13 | Interface font fallback continues to eligible installed faces when the first covering face is colour or exceeds the 48 MiB limit; document fallback order and the CJK/IME rendering assertions are retained (#101) |
|
||||
| 2026-10-13 | PDFW2: adopt the shared writer with native unwind recovery and explicit font-embedding failures; preserve app-owned export behavior and keep reader dependencies out of production |
|
||||
| 2026-10-13 | Eyedropper: Shape Fill, Shape Outline, Font Color and every other colour menu end with an Eyedropper that takes the colour drawn at a point of the slide (hover shows it, a click applies it, Esc cancels); `slide.colorAt` gives the same from scripts and MCP (#367) |
|
||||
| 2026-10-13 | Slide show: Honeycomb's hexagons cover the whole slide again — the bottom quarter stayed black until the endpoint (#420) |
|
||||
| 2026-10-13 | Copy and Cut from the ribbon, the slide's right-click menu and the menus put the text on the system clipboard, as Ctrl+C and Ctrl+X do (#431) |
|
||||
@@ -221,6 +239,13 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-15 | trivial | Progress log: chart package parts survive a native save. Scores and milestone names unchanged |
|
||||
| 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-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 |
|
||||
| 2026-10-13 | trivial | Progress log: Eyedropper in the colour menus, slide.colorAt (#367) |
|
||||
| 2026-10-13 | trivial | Progress log: video seek frame-loss race and regression coverage |
|
||||
|
||||
@@ -17,3 +17,6 @@ path = "src/main.rs"
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-mcp = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
zip = { workspace = true }
|
||||
|
||||
@@ -19,15 +19,16 @@ The shapes of one slide, with their ids, boxes and text, come from the slide.ins
|
||||
},
|
||||
Subcommand {
|
||||
name: "render",
|
||||
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT",
|
||||
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] [--transparent] OUT",
|
||||
summary: "draw slides as PNG images",
|
||||
details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first;
|
||||
OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per
|
||||
slide. Prints each image's path and size.
|
||||
|
||||
--slide N the slide at index N; indices start at 0, and 0 is the default
|
||||
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
|
||||
--edit draw what the editor shows: placeholder prompts and their dashed outlines
|
||||
--slide N the slide at index N; indices start at 0, and 0 is the default
|
||||
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
|
||||
--edit draw what the editor shows: placeholder prompts and their dashed outlines
|
||||
--transparent leave the slide background out, so only what is on the slide is drawn
|
||||
|
||||
deckcraft-cli render deck.deckcraft --slide 2 slide-3.png",
|
||||
},
|
||||
|
||||
@@ -117,11 +117,27 @@ fn open(s: &mut Session, input: Option<&str>, sample: bool) -> Result<(), String
|
||||
return deckcraft_engine::sample::open_sample(s).map_err(|e| e.to_string());
|
||||
}
|
||||
match input {
|
||||
Some(p) => s.execute("file.open", &json!({"path": p})).map(|_| ()).map_err(|e| e.to_string()),
|
||||
Some(p) => {
|
||||
let opened = s.execute("file.open", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
warn_if_damaged(p, &opened);
|
||||
Ok(())
|
||||
}
|
||||
None => s.execute("file.new", &json!({})).map(|_| ()).map_err(|e| e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Say on stderr what a file lost on opening, so a saved copy doesn't silently miss it (#203).
|
||||
fn warn_if_damaged(path: &str, opened: &serde_json::Value) {
|
||||
let count = |key: &str| opened.get(key).and_then(|v| v.as_array()).map_or(0, |a| a.len());
|
||||
let (slides, media) = (count("lostSlides"), count("damagedMedia"));
|
||||
if slides > 0 {
|
||||
eprintln!("deckcraft-cli: warning: {path}: {slides} slide{} couldn't be read and left out", if slides == 1 { "" } else { "s" });
|
||||
}
|
||||
if media > 0 {
|
||||
eprintln!("deckcraft-cli: warning: {path}: {media} picture or media file{} couldn't be read", if media == 1 { "" } else { "s" });
|
||||
}
|
||||
}
|
||||
|
||||
/// Median and slowest of `runs` timings of `f`, in milliseconds.
|
||||
fn time_ms(runs: usize, mut f: impl FnMut() -> Result<(), String>) -> Result<(f64, f64), String> {
|
||||
let mut ms = Vec::with_capacity(runs);
|
||||
@@ -244,11 +260,13 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
let mut all = false;
|
||||
let mut scale = 2.0;
|
||||
let mut edit = false;
|
||||
let mut transparent = false;
|
||||
let mut out = None;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--sample" => sample = true,
|
||||
"--transparent" => transparent = true,
|
||||
"--slide" => slide = Some(it.next().and_then(|v| v.parse().ok()).ok_or("--slide needs a number")?),
|
||||
"--all" => all = true,
|
||||
"--edit" => edit = true,
|
||||
@@ -281,7 +299,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
std::fs::create_dir_all(&out).map_err(|e| format!("{out}: {e}"))?;
|
||||
}
|
||||
for i in list {
|
||||
let (png, w, h) = deckcraft_engine::cmd::file::render_png(&st.doc, i, scale, edit);
|
||||
let (png, w, h) = deckcraft_engine::cmd::file::render_png_with(&st.doc, i, scale, edit, transparent);
|
||||
let path = if all { format!("{out}/slide-{:02}.png", i + 1) } else { out.clone() };
|
||||
std::fs::write(&path, png).map_err(|e| format!("{path}: {e}"))?;
|
||||
outln!("{path} ({w}×{h})");
|
||||
@@ -680,6 +698,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// `render --transparent` leaves the slide background out (#784): the corner is transparent.
|
||||
#[test]
|
||||
fn render_transparent_leaves_the_background_out() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-transparent-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let alpha = |path: &std::path::Path| deckcraft_engine::render::decode_image(&std::fs::read(path).unwrap()).unwrap().get_pixel(1, 1).0[3];
|
||||
let opaque = dir.join("opaque.png");
|
||||
render(&args(&["--sample", "--scale", "0.25", &opaque.to_string_lossy()])).unwrap();
|
||||
assert_eq!(alpha(&opaque), 255);
|
||||
let clear = dir.join("clear.png");
|
||||
render(&args(&["--sample", "--transparent", "--scale", "0.25", &clear.to_string_lossy()])).unwrap();
|
||||
assert_eq!(alpha(&clear), 0);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs.
|
||||
#[test]
|
||||
fn exports_an_animated_gif() {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Opening a `.pptx` that lost slides says so on stderr instead of saving a shorter deck in silence (#203).
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn temp_dir(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// `deck` with `ppt/slides/slide2.xml` cut off.
|
||||
fn without_slide2(deck: &[u8]) -> Vec<u8> {
|
||||
let mut zin = zip::ZipArchive::new(Cursor::new(deck)).unwrap();
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let options = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..zin.len() {
|
||||
let mut f = zin.by_index(i).unwrap();
|
||||
let mut data = vec![];
|
||||
f.read_to_end(&mut data).unwrap();
|
||||
if f.name() == "ppt/slides/slide2.xml" {
|
||||
data = b"<p:sld".to_vec();
|
||||
}
|
||||
out.start_file(f.name(), options).unwrap();
|
||||
out.write_all(&data).unwrap();
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pptx_with_an_unreadable_slide_is_reported_on_stderr() {
|
||||
let dir = temp_dir("lost-slide");
|
||||
let good = dir.join("good.pptx");
|
||||
let made = cli(&["run", "--sample", "--save", good.to_str().unwrap()]);
|
||||
assert!(made.status.success(), "{}", String::from_utf8_lossy(&made.stderr));
|
||||
let broken = dir.join("broken.pptx");
|
||||
std::fs::write(&broken, without_slide2(&std::fs::read(&good).unwrap())).unwrap();
|
||||
|
||||
let ok = cli(&["info", good.to_str().unwrap()]);
|
||||
assert!(ok.status.success() && !String::from_utf8_lossy(&ok.stderr).contains("warning"), "{}", String::from_utf8_lossy(&ok.stderr));
|
||||
|
||||
let bad = cli(&["info", broken.to_str().unwrap()]);
|
||||
assert!(bad.status.success(), "{}", String::from_utf8_lossy(&bad.stderr));
|
||||
let err = String::from_utf8_lossy(&bad.stderr);
|
||||
assert!(err.contains("warning") && err.contains("1 slide couldn't be read and left out"), "{err}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`deckcraft-cli mcp --connect`) wraps.
|
||||
//!
|
||||
//! Every resource a client can hold is bounded ([`Limits`]): simultaneous connections, request
|
||||
//! line length, queued requests, and how long a socket may sit idle or blocked on a write.
|
||||
//! The server only reads requests: a line that is not a JSON object with a string `method` (an
|
||||
//! HTTP request line, a stray `nc`, binary data) gets one error reply and the connection is
|
||||
//! closed, so nothing sent after it runs. Every resource a client can hold is bounded
|
||||
//! ([`Limits`]): simultaneous connections, request line length, queued requests, and how long a
|
||||
//! socket may sit idle or blocked on a write.
|
||||
|
||||
use std::io::{BufRead, BufReader, ErrorKind, Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
@@ -120,6 +123,53 @@ fn read_line(r: &mut impl BufRead, max: usize) -> Line {
|
||||
}
|
||||
}
|
||||
|
||||
/// One request line, parsed.
|
||||
enum Parsed {
|
||||
Request {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
Reject(String),
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> Parsed {
|
||||
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||
return Parsed::Reject("not a control request (invalid UTF-8); closing the connection".into());
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Parsed::Blank;
|
||||
}
|
||||
if is_http_request_line(text) {
|
||||
return Parsed::Reject("HTTP is not served here: this port takes JSON lines; closing the connection".into());
|
||||
}
|
||||
let msg = match serde_json::from_str::<Value>(text) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Parsed::Reject(format!("bad JSON ({e}); expected one JSON request object per line; closing the connection")),
|
||||
};
|
||||
let Some(obj) = msg.as_object() else {
|
||||
return Parsed::Reject("not a control request (expected a JSON object); closing the connection".into());
|
||||
};
|
||||
let Some(method) = obj.get("method").and_then(Value::as_str) else {
|
||||
return Parsed::Reject("not a control request (missing string `method`); closing the connection".into());
|
||||
};
|
||||
Parsed::Request {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP/1 request line (`POST / HTTP/1.1`) or the HTTP/2 preface (`PRI * HTTP/2.0`).
|
||||
fn is_http_request_line(text: &str) -> bool {
|
||||
const METHODS: [&str; 10] = ["GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", "PRI"];
|
||||
text.split_once(' ').is_some_and(|(method, _)| METHODS.contains(&method))
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -136,25 +186,32 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
}
|
||||
Line::Closed => break,
|
||||
};
|
||||
let Ok(line) = String::from_utf8(line) else {
|
||||
if writeln!(out, "{}", json!({"ok": false, "error": "bad JSON: not UTF-8"})).is_err() {
|
||||
let reply = match parse(&line) {
|
||||
Parsed::Blank => continue,
|
||||
Parsed::Reject(e) => {
|
||||
// The connection closes either way; a peer that can't read the reason loses nothing.
|
||||
let _ = writeln!(out, "{}", json!({"ok": false, "error": e}));
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
Parsed::Request { id, method, params } => {
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
let state = Arc::clone(&req.state);
|
||||
let mut r = match tx.try_send(req) {
|
||||
Ok(()) => {
|
||||
ctx.request_repaint();
|
||||
rrx.recv_timeout(limits.reply_timeout).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}))
|
||||
match rrx.recv_timeout(limits.reply_timeout) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
// Give up on the request. While it was still queued this makes
|
||||
// sure it never runs late (#719); if the app had already taken
|
||||
// it, its outcome is unknown rather than a definite failure.
|
||||
if ControlRequest::abandon(&state) {
|
||||
json!({"ok": false, "error": "timeout"})
|
||||
} else {
|
||||
json!({"ok": false, "error": "timeout: the app had already started the request, so it may still run; its outcome is unknown"})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(TrySendError::Full(_)) => json!({"ok": false, "error": "busy: too many queued requests"}),
|
||||
Err(TrySendError::Disconnected(_)) => break,
|
||||
@@ -164,7 +221,6 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
if let Err(e) = writeln!(out, "{reply}") {
|
||||
if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) {
|
||||
@@ -232,6 +288,45 @@ mod tests {
|
||||
assert_eq!(v["result"], "ui.inspect");
|
||||
}
|
||||
|
||||
/// Every reply line until the server closes the connection.
|
||||
fn replies_until_closed(r: &mut BufReader<TcpStream>) -> Vec<Value> {
|
||||
std::iter::from_fn(|| reply(r)).collect()
|
||||
}
|
||||
|
||||
/// A browser can POST to the port: its request line closes the connection before the JSON
|
||||
/// line in its body is read, so nothing is dispatched.
|
||||
#[test]
|
||||
fn an_http_request_closes_the_connection_before_its_body_is_dispatched() {
|
||||
let (addr, rx) = server(Limits { max_line: 512, ..small() });
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(
|
||||
b"POST / HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: text/plain\r\n\r\n\
|
||||
{\"id\":1,\"method\":\"engine.execute\",\"params\":{\"command\":\"slide.new\"}}\n",
|
||||
)
|
||||
.unwrap();
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("closing the connection"), "{replies:?}");
|
||||
assert!(rx.try_recv().is_err(), "the body was dispatched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_that_is_not_a_request_closes_the_connection() {
|
||||
let (addr, rx) = server(small());
|
||||
ui(rx);
|
||||
for junk in [&b"hello\n"[..], b"[1,2]\n", b"{\"id\":1}\n", b"{\"method\":5}\n", b"\xff\xfe\n", b"GET /x HTTP/1.1\n"] {
|
||||
let (mut r, mut w) = client(addr);
|
||||
w.write_all(junk).unwrap();
|
||||
w.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
|
||||
let replies = replies_until_closed(&mut r);
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
}
|
||||
assert!(is_http_request_line("OPTIONS * HTTP/1.1"));
|
||||
assert!(is_http_request_line("PRI * HTTP/2.0"));
|
||||
assert!(!is_http_request_line("{\"method\":\"GET /\"}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_line_closes_connection() {
|
||||
let (addr, rx) = server(small());
|
||||
@@ -296,4 +391,43 @@ mod tests {
|
||||
assert_eq!(a["error"], "timeout");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// A request that timed out while it was still queued must not run when the window resumes (#719).
|
||||
#[test]
|
||||
fn timed_out_request_does_not_run_when_the_window_resumes() {
|
||||
let (addr, rx) = server(Limits { max_line: 512, ..small() });
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":1,"method":"engine.execute","params":{{"command":"slide.new"}}}}"#).unwrap();
|
||||
let t = reply(&mut r).unwrap();
|
||||
assert_eq!(t["error"], "timeout");
|
||||
// The window resumes and drains the queue, exactly as `SlideApp::drain_control` does.
|
||||
let mut app = deckcraft_ui_egui::SlideApp::new(deckcraft_engine::Session::with_new(), deckcraft_ui_egui::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
let before = app.session.active().unwrap().doc.slides.len();
|
||||
let req = rx.try_recv().expect("the timed-out request is still queued");
|
||||
if req.take() {
|
||||
let _ = deckcraft_ui_egui::control::handle(&mut app, &ctx, &req);
|
||||
}
|
||||
assert_eq!(app.session.active().unwrap().doc.slides.len(), before, "a request that timed out must not run when the window resumes");
|
||||
drop(rx);
|
||||
}
|
||||
|
||||
/// A request the app had already started when the reply timed out reports an unknown outcome,
|
||||
/// not a definite failure (#719).
|
||||
#[test]
|
||||
fn a_request_already_started_when_the_reply_times_out_reports_an_unknown_outcome() {
|
||||
let (addr, rx) = server(small());
|
||||
let ui = std::thread::spawn(move || {
|
||||
let req = rx.recv().expect("the request is queued");
|
||||
assert!(req.take(), "the app claims the request");
|
||||
// Still running it when the sender's wait expires.
|
||||
std::thread::sleep(Duration::from_millis(450));
|
||||
let _ = req.reply.send(json!({"ok": true, "result": "ran"}));
|
||||
});
|
||||
let (mut r, mut w) = client(addr);
|
||||
writeln!(w, r#"{{"id":1,"method":"m"}}"#).unwrap();
|
||||
let v = reply(&mut r).unwrap();
|
||||
assert!(v["error"].as_str().unwrap().contains("outcome is unknown"), "{v}");
|
||||
ui.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! DeckCraft desktop app.
|
||||
//!
|
||||
//! Usage: `deckcraft [--control <port>] [--sample] [--show] [files…]`
|
||||
//! A PowerPoint Show (`.ppsx`) given last starts its slide show, as a double-click does in PowerPoint.
|
||||
//! `deckcraft --help` prints the flags and exits. `deckcraft --version` prints the version and exits.
|
||||
//!
|
||||
//! `--control <port>` (or `DECKCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
@@ -134,6 +135,8 @@ Usage: deckcraft [--control PORT] [--sample] [--show] [FILE...]
|
||||
--show start the slide show
|
||||
--help, -h print this help and exit
|
||||
--version, -V print the version and exit
|
||||
|
||||
A PowerPoint Show (.ppsx) given last starts its slide show.
|
||||
";
|
||||
|
||||
enum Startup {
|
||||
@@ -254,10 +257,9 @@ fn main() -> eframe::Result {
|
||||
if sample && let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
|
||||
log::error!("sample: {e}");
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.open_path(&f) {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
// A .ppsx opened last starts its slide show (#862).
|
||||
for (f, e) in app.open_from_system(&files) {
|
||||
log::error!("{f}: {e}");
|
||||
}
|
||||
if show {
|
||||
app.start_show(0, false);
|
||||
|
||||
@@ -54,3 +54,8 @@ real_name = "Nikita Rakitin"
|
||||
|
||||
[people.RCShoemaker]
|
||||
real_name = "Ryan C. Shoemaker"
|
||||
|
||||
[people.marcoarnulfo]
|
||||
real_name = "Marco Arnulfo"
|
||||
display_name = "Marco Arnulfo"
|
||||
sync_github_name = false
|
||||
|
||||
+11
-7
@@ -299,7 +299,8 @@ impl ShowState {
|
||||
ShowAction::FinishStep(n)
|
||||
}
|
||||
|
||||
/// ← / Backspace: undo the last step, else go to the previous slide fully built.
|
||||
/// ← / Backspace: undo the last step, else go to the previous slide fully built. An automatic
|
||||
/// first step (With/After Previous) is part of the slide appearing, so it is never undone.
|
||||
pub fn prev(&mut self, pres: &Presentation) -> ShowAction {
|
||||
if self.ended {
|
||||
self.ended = false;
|
||||
@@ -310,13 +311,16 @@ impl ShowState {
|
||||
self.playing = false;
|
||||
return ShowAction::Slide(self.slide);
|
||||
}
|
||||
if self.playing {
|
||||
// #844: undoing the automatic first step left the slide as before it appeared, and the show
|
||||
// played the step again at once, so Back never got past the slide.
|
||||
let undo = if self.playing { Some(self.step) } else { self.step.checked_sub(1) };
|
||||
let auto_first = undo == Some(0) && !pres.show.without_animation && ShowState::timeline(pres, self.slide).step_is_auto(0);
|
||||
if let Some(k) = undo
|
||||
&& !auto_first
|
||||
{
|
||||
self.playing = false;
|
||||
return ShowAction::StepBack(self.step);
|
||||
}
|
||||
if self.step > 0 {
|
||||
self.step -= 1;
|
||||
return ShowAction::StepBack(self.step);
|
||||
self.step = k;
|
||||
return ShowAction::StepBack(k);
|
||||
}
|
||||
let target = match self.prev_index(pres) {
|
||||
Some(i) => Some(i),
|
||||
|
||||
@@ -808,6 +808,30 @@ fn show_steps_through_builds() {
|
||||
assert_eq!(s.step, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_back_leaves_a_slide_whose_first_step_is_automatic() {
|
||||
// #844: undoing the automatic first step left the slide as before it appeared, and the show
|
||||
// played that step again at once, so Back never reached the slide before.
|
||||
for start in [AnimStart::WithPrevious, AnimStart::AfterPrevious] {
|
||||
let mut p = pres(2);
|
||||
Arc::make_mut(&mut p.slides[1]).shapes = vec![shape(1, "A"), shape(2, "B")];
|
||||
Arc::make_mut(&mut p.slides[1]).animations =
|
||||
vec![anim(1, AnimClass::Entrance, "fade", start), anim(2, AnimClass::Entrance, "fade", AnimStart::OnClick)];
|
||||
let mut s = ShowState::new(&p);
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
assert!(s.playing, "{start:?}");
|
||||
// While the automatic step plays.
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}");
|
||||
assert_eq!(s.next(&p), ShowAction::Slide(1));
|
||||
// Once it has played, the click step is undone first, then Back leaves the slide.
|
||||
assert_eq!(s.next(&p), ShowAction::PlayStep(1));
|
||||
assert_eq!(s.step_done(&p), ShowAction::FinishStep(1));
|
||||
assert_eq!(s.prev(&p), ShowAction::StepBack(1), "{start:?}");
|
||||
assert_eq!(s.prev(&p), ShowAction::Slide(0), "{start:?}");
|
||||
assert_eq!((s.step, s.playing), (0, false));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_auto_advance() {
|
||||
let mut p = pres(2);
|
||||
|
||||
@@ -175,7 +175,11 @@ fn copied_html(s: &Session) -> String {
|
||||
fn cut(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let caret = copying_caret(s);
|
||||
let t = copy_inner(s, p)?;
|
||||
let html = if caret { String::new() } else { copied_html(s) };
|
||||
if caret {
|
||||
// Nothing is selected, so there is nothing to cut; `delete` would remove the next character.
|
||||
return Ok(json!({"text": t, "html": ""}));
|
||||
}
|
||||
let html = copied_html(s);
|
||||
delete(s, p)?;
|
||||
Ok(json!({"text": t, "html": html}))
|
||||
}
|
||||
|
||||
@@ -33,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|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, transparentBackground?: bool (png: leave the slide background out), width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool} → {png: base64, width, height}", has_doc, render),
|
||||
cmd!(query "file.render", "Render Slide", [], None, "{slide?: index, scale?, edit?: bool, transparentBackground?: bool} → {png: base64, width, height}", has_doc, render),
|
||||
cmd!(
|
||||
query "slide.colorAt",
|
||||
"Color At",
|
||||
@@ -128,19 +128,35 @@ pub fn open_presentation(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
open_checked(name, bytes).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// [`open_presentation`], plus the embedded media a `.deckcraft` file had lost or damaged.
|
||||
pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
if deckcraft_format::sniff(bytes) {
|
||||
return deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()));
|
||||
}
|
||||
open_other(name, bytes).map(|p| (p, vec![]))
|
||||
/// What a file lost on opening.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Damage {
|
||||
/// Pictures and media a `.deckcraft` file had lost or damaged.
|
||||
pub media: Vec<String>,
|
||||
/// Slides a `.pptx` lists but that couldn't be read, so they are not in the presentation.
|
||||
pub slides: Vec<String>,
|
||||
}
|
||||
|
||||
fn open_other(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
impl Damage {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.media.is_empty() && self.slides.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`open_presentation`], plus what the file had lost or damaged.
|
||||
pub fn open_checked(name: &str, bytes: &[u8]) -> Result<(Presentation, Damage)> {
|
||||
if deckcraft_format::sniff(bytes) {
|
||||
let (p, media) = deckcraft_format::load_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
return Ok((p, Damage { media, slides: vec![] }));
|
||||
}
|
||||
open_other(name, bytes).map(|(p, slides)| (p, Damage { media: vec![], slides }))
|
||||
}
|
||||
|
||||
fn open_other(name: &str, bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
if deckcraft_pptx::sniff(bytes) {
|
||||
let mut p = deckcraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
let (mut p, lost_slides) = deckcraft_pptx::import_checked(bytes).map_err(|e| EngineError::Other(e.to_string()))?;
|
||||
deckcraft_format::repair(&mut p);
|
||||
return Ok(p);
|
||||
return Ok((p, lost_slides));
|
||||
}
|
||||
if deckcraft_pptx::is_encrypted(bytes) {
|
||||
return Err(EngineError::Other(format!("{name} is password-protected. DeckCraft can't open password-protected presentations yet.")));
|
||||
@@ -157,7 +173,7 @@ fn open_other(name: &str, bytes: &[u8]) -> Result<Presentation> {
|
||||
if p.slides.is_empty() {
|
||||
p = defaults::new_presentation(None);
|
||||
}
|
||||
return Ok(p);
|
||||
return Ok((p, vec![]));
|
||||
}
|
||||
Err(EngineError::Other(format!("{name}: not a presentation DeckCraft can read")))
|
||||
}
|
||||
@@ -180,8 +196,8 @@ pub struct LoadedFile {
|
||||
pub doc: Presentation,
|
||||
/// A native or PPTX file (saving goes back to it); an outline import has no file to save to.
|
||||
pub native: bool,
|
||||
/// Pictures and media a `.deckcraft` file had lost or damaged.
|
||||
pub damaged: Vec<String>,
|
||||
/// What the file had lost or damaged.
|
||||
pub damaged: Damage,
|
||||
}
|
||||
|
||||
/// Read and parse the presentation at `path`.
|
||||
@@ -202,7 +218,10 @@ fn adopt_loaded(s: &mut Session, f: LoadedFile) -> Result<Value> {
|
||||
let i = add_opened(s, &path, keep_path.then(|| path.clone()), doc);
|
||||
report_damage(s, &damaged)?;
|
||||
let st = s.doc()?;
|
||||
Ok(json!({"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts, "damagedMedia": damaged}))
|
||||
Ok(json!({
|
||||
"document": i, "slides": st.doc.slides.len(), "missingFonts": st.missing_fonts,
|
||||
"damagedMedia": damaged.media, "lostSlides": damaged.slides,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -236,23 +255,35 @@ fn open_bytes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (doc, damaged) = open_checked(name, &data)?;
|
||||
let i = add_opened(s, name, None, doc);
|
||||
report_damage(s, &damaged)?;
|
||||
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged}))
|
||||
Ok(json!({"document": i, "missingFonts": s.doc()?.missing_fonts, "damagedMedia": damaged.media, "lostSlides": damaged.slides}))
|
||||
}
|
||||
|
||||
/// Tell the user which pictures and media a damaged file lost, and mark the copy repaired.
|
||||
fn report_damage(s: &mut Session, damaged: &[String]) -> Result<()> {
|
||||
/// Tell the user which pictures, media and slides a damaged file lost, and mark the copy repaired.
|
||||
fn report_damage(s: &mut Session, damaged: &Damage) -> Result<()> {
|
||||
if damaged.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let st = s.doc_mut()?;
|
||||
st.title = format!("{} (Repaired)", st.title);
|
||||
let n = damaged.len();
|
||||
let text = format!(
|
||||
"{n} picture{} or media file{} in this presentation couldn't be read and {} empty. It was opened as a repaired copy; save it under a new name to keep the original file.",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "is" } else { "are" },
|
||||
);
|
||||
let mut text = String::new();
|
||||
let n = damaged.media.len();
|
||||
if n > 0 {
|
||||
text = format!(
|
||||
"{n} picture{} or media file{} in this presentation couldn't be read and {} empty. ",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "is" } else { "are" },
|
||||
);
|
||||
}
|
||||
let n = damaged.slides.len();
|
||||
if n > 0 {
|
||||
text.push_str(&format!(
|
||||
"{n} slide{} in this file couldn't be read and {} left out. ",
|
||||
if n == 1 { "" } else { "s" },
|
||||
if n == 1 { "was" } else { "were" },
|
||||
));
|
||||
}
|
||||
text.push_str("It was opened as a repaired copy; save it under a new name to keep the original file.");
|
||||
s.ui_requests.push(crate::UiRequest::Message { text });
|
||||
Ok(())
|
||||
}
|
||||
@@ -431,8 +462,14 @@ pub fn image_scale(doc: &Presentation, scale: f64) -> f64 {
|
||||
|
||||
/// Render slide `index` (current when absent) to PNG bytes.
|
||||
pub fn render_png(doc: &Presentation, index: usize, scale: f64, edit: bool) -> (Vec<u8>, u32, u32) {
|
||||
render_png_with(doc, index, scale, edit, false)
|
||||
}
|
||||
|
||||
/// [`render_png`]; `transparent` leaves the slide background out (#784).
|
||||
pub fn render_png_with(doc: &Presentation, index: usize, scale: f64, edit: bool, transparent: bool) -> (Vec<u8>, u32, u32) {
|
||||
let scale = image_scale(doc, scale);
|
||||
let img = deckcraft_render::render_slide(doc, index, &deckcraft_render::RenderOpts { scale, edit, ..Default::default() });
|
||||
let opts = deckcraft_render::RenderOpts { scale, edit, transparent_background: transparent, ..Default::default() };
|
||||
let img = deckcraft_render::render_slide(doc, index, &opts);
|
||||
(img.to_png_at(deckcraft_render::dpi_for_scale(scale)), img.width, img.height)
|
||||
}
|
||||
|
||||
@@ -451,7 +488,7 @@ fn render(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if i >= st.doc.slides.len() {
|
||||
return Err(bad("file.render", format!("no slide {i}")));
|
||||
}
|
||||
let (png, w, h) = render_png(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false));
|
||||
let (png, w, h) = render_png_with(&st.doc, i, f64_or(p, "scale", 1.0), bool_or(p, "edit", false), bool_or(p, "transparentBackground", false));
|
||||
Ok(json!({"png": base64_encode(&png), "width": w, "height": h}))
|
||||
}
|
||||
|
||||
@@ -483,6 +520,10 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let format = str_param(p, "format").unwrap_or(format_for_path(&path)).to_string();
|
||||
let st = s.doc()?;
|
||||
let scale = finite_scale(f64_or(p, "scale", 2.0));
|
||||
let transparent = bool_or(p, "transparentBackground", false);
|
||||
if transparent && format != "png" {
|
||||
return Err(bad("file.export", format!("a transparent background is for PNG export, not {format}")));
|
||||
}
|
||||
match format.as_str() {
|
||||
"png" | "jpeg" | "jpg" => {
|
||||
let slides: Vec<usize> = match usize_param(p, "slide") {
|
||||
@@ -497,7 +538,11 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let scale = image_scale(&st.doc, scale);
|
||||
let mut written = vec![];
|
||||
for (k, i) in slides.iter().enumerate() {
|
||||
let img = deckcraft_render::render_slide(&st.doc, *i, &deckcraft_render::RenderOpts { scale, ..Default::default() });
|
||||
let img = deckcraft_render::render_slide(
|
||||
&st.doc,
|
||||
*i,
|
||||
&deckcraft_render::RenderOpts { scale, transparent_background: transparent, ..Default::default() },
|
||||
);
|
||||
let bytes = picture_bytes(&img, &format, scale);
|
||||
let out = if slides.len() == 1 {
|
||||
path.clone()
|
||||
|
||||
@@ -698,21 +698,28 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let from = if pi == a.0 { a.1 } else { 0 };
|
||||
let to = if pi == b.0 { b.1 } else { n };
|
||||
let mut pos = 0;
|
||||
for r in &mut para.runs {
|
||||
let mut pieces = Vec::new();
|
||||
for (ri, r) in para.runs.iter().enumerate() {
|
||||
// Breaks and fields are read for context (a break ends a word) but not rewritten.
|
||||
let len = r.char_len();
|
||||
if r.kind == deckcraft_model::text::RunKind::Text {
|
||||
let rs = from.max(pos).saturating_sub(pos);
|
||||
let re = to.min(pos + len).saturating_sub(pos);
|
||||
if re > rs {
|
||||
let chars: Vec<char> = r.text.chars().collect();
|
||||
let mid: String = chars.get(rs..re).map(|c| c.iter().collect()).unwrap_or_default();
|
||||
let pre: String = chars.get(..rs).map(|c| c.iter().collect()).unwrap_or_default();
|
||||
let post: String = chars.get(re..).map(|c| c.iter().collect()).unwrap_or_default();
|
||||
r.text = format!("{pre}{}{post}", ed::change_case(&mid, &m));
|
||||
}
|
||||
let rs = from.max(pos).saturating_sub(pos);
|
||||
let re = to.min(pos + len).saturating_sub(pos);
|
||||
if re > rs {
|
||||
pieces.push((ri, rs, re));
|
||||
}
|
||||
pos += len;
|
||||
}
|
||||
let mids: Vec<String> = pieces
|
||||
.iter()
|
||||
.map(|&(ri, rs, re)| para.runs.get(ri).map(|r| r.content().chars().skip(rs).take(re - rs).collect()).unwrap_or_default())
|
||||
.collect();
|
||||
let changed = ed::change_case_parts(&mids.iter().map(String::as_str).collect::<Vec<_>>(), &m);
|
||||
for (&(ri, rs, re), new) in pieces.iter().zip(changed) {
|
||||
let Some(r) = para.runs.get_mut(ri).filter(|r| r.kind == deckcraft_model::text::RunKind::Text) else { continue };
|
||||
let pre: String = r.text.chars().take(rs).collect();
|
||||
let post: String = r.text.chars().skip(re).collect();
|
||||
r.text = format!("{pre}{new}{post}");
|
||||
}
|
||||
}
|
||||
if collapsed {
|
||||
t.anchor = a;
|
||||
@@ -734,9 +741,12 @@ fn case(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_shapes(s, &json!({}), "format.case", |sh| {
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
for para in &mut t.paragraphs {
|
||||
for r in &mut para.runs {
|
||||
// Breaks and fields are read for context (a break ends a word) but not rewritten.
|
||||
let texts: Vec<&str> = para.runs.iter().map(|r| r.content()).collect();
|
||||
let changed = ed::change_case_parts(&texts, &mode);
|
||||
for (r, new) in para.runs.iter_mut().zip(changed) {
|
||||
if r.kind == deckcraft_model::text::RunKind::Text {
|
||||
r.text = ed::change_case(&r.text, &mode);
|
||||
r.text = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1089,7 +1099,7 @@ fn columns(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn autofit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let m = match str_param(p, "mode").unwrap_or("none") {
|
||||
"shrink" => AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 },
|
||||
"shrink" => AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 },
|
||||
"resize" | "shape" => AutoFit::Shape,
|
||||
_ => AutoFit::None,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@ use crate::{Result, Session};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
noundo "shape.editPoints",
|
||||
"shape.editPoints",
|
||||
"Edit Points",
|
||||
["Shape Format", "Insert Shapes", "Edit Shape"],
|
||||
None,
|
||||
@@ -355,8 +355,8 @@ fn refit(sh: &mut Shape) {
|
||||
/// The one shape whose points are edited: `id`, or the single selected shape.
|
||||
fn target(s: &Session, p: &Value, cmd: &str) -> Result<ShapeId> {
|
||||
let st = s.doc()?;
|
||||
let id = match p.get("id").and_then(Value::as_u64) {
|
||||
Some(i) => ShapeId(i as u32),
|
||||
let id = match id_param(p, "id", cmd)? {
|
||||
Some(i) => i,
|
||||
None => match st.selection.shapes.as_slice() {
|
||||
[one] => *one,
|
||||
_ => return Err(bad(cmd, "select one shape")),
|
||||
@@ -429,8 +429,8 @@ pub fn slide_vertices(doc: &deckcraft_model::Presentation, sel: &crate::Selectio
|
||||
|
||||
fn points(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let id = match p.get("id").and_then(Value::as_u64) {
|
||||
Some(i) => ShapeId(i as u32),
|
||||
let id = match id_param(p, "id", "shape.points")? {
|
||||
Some(i) => i,
|
||||
None => *st.selection.shapes.first().ok_or_else(|| bad("shape.points", "select a shape"))?,
|
||||
};
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.points", "no such shape"))?;
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
//! The slides take the destination's look by default (each one moves to the destination layout
|
||||
//! with the same name, else the same kind); with `keepFormatting` the source master and its
|
||||
//! layouts come along. Pictures, media and sounds are copied under fresh ids, and shape ids are
|
||||
//! renumbered with animations, triggers and connector ends following them.
|
||||
//! renumbered with animations, triggers and connector ends following them. Links to another
|
||||
//! reused slide follow its copy.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
|
||||
use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -60,6 +62,57 @@ fn remap_connectors(shapes: &mut [deckcraft_model::Shape], map: &HashMap<ShapeId
|
||||
}
|
||||
}
|
||||
|
||||
/// Point a link to a reused slide at its copy (`map`: source → destination).
|
||||
fn relink(h: &mut Option<Hyperlink>, map: &HashMap<SlideId, SlideId>) {
|
||||
let Some(h) = h else { return };
|
||||
if let Action::Slide { slide } = &mut h.action {
|
||||
*slide = map.get(slide).copied().unwrap_or(*slide);
|
||||
}
|
||||
if let Some(z) = &mut h.zoom_return {
|
||||
*z = map.get(z).copied().unwrap_or(*z);
|
||||
}
|
||||
}
|
||||
|
||||
fn relink_run(r: &mut RunProps, map: &HashMap<SlideId, SlideId>) {
|
||||
relink(&mut r.link, map);
|
||||
relink(&mut r.hover, map);
|
||||
}
|
||||
|
||||
fn relink_text(t: &mut TextBody, map: &HashMap<SlideId, SlideId>) {
|
||||
for lv in t.list_style.levels.iter_mut().flatten() {
|
||||
relink_run(&mut lv.run, map);
|
||||
}
|
||||
for p in &mut t.paragraphs {
|
||||
relink_run(&mut p.def_props, map);
|
||||
relink_run(&mut p.end_props, map);
|
||||
for r in &mut p.runs {
|
||||
relink_run(&mut r.props, map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the slide links under `shapes` (click and hover actions, text links) at the copies.
|
||||
fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap<SlideId, SlideId>, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
relink(&mut sh.click, map);
|
||||
relink(&mut sh.hover, map);
|
||||
if let Some(t) = &mut sh.text {
|
||||
relink_text(t, map);
|
||||
}
|
||||
if let ShapeKind::Table(t) = &mut sh.kind {
|
||||
for c in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
relink_text(&mut c.text, map);
|
||||
}
|
||||
}
|
||||
if let Some(ch) = sh.children_mut() {
|
||||
relink_shapes(ch, map, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh shape ids for `shapes`, and the old → new map.
|
||||
fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
|
||||
let old = shape_ids(shapes);
|
||||
@@ -134,8 +187,10 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
doc.masters.push(Arc::new(m));
|
||||
}
|
||||
}
|
||||
let mut new_ids = vec![];
|
||||
for (k, &i) in picked.iter().enumerate() {
|
||||
let mut copies: Vec<Slide> = vec![];
|
||||
// Source slide → its copy, so links between reused slides follow (forward ones too).
|
||||
let mut slide_map: HashMap<SlideId, SlideId> = HashMap::new();
|
||||
for &i in &picked {
|
||||
let Some(from) = src.slides.get(i) else { continue };
|
||||
copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?;
|
||||
let mut sl: Slide = from.as_ref().clone();
|
||||
@@ -154,6 +209,13 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
matching_layout(doc, &lname, kind)
|
||||
}
|
||||
};
|
||||
slide_map.insert(from.id, sl.id);
|
||||
copies.push(sl);
|
||||
}
|
||||
let mut new_ids = vec![];
|
||||
for (k, mut sl) in copies.into_iter().enumerate() {
|
||||
relink_shapes(&mut sl.shapes, &slide_map, 0);
|
||||
relink_text(&mut sl.notes, &slide_map);
|
||||
new_ids.push(sl.id);
|
||||
doc.slides.insert((at + k).min(doc.slides.len()), Arc::new(sl));
|
||||
}
|
||||
@@ -263,4 +325,76 @@ mod tests {
|
||||
want.sort();
|
||||
assert_eq!(got, want, "every reference resolves to its own copied bytes");
|
||||
}
|
||||
|
||||
fn link_to(slide: SlideId) -> Hyperlink {
|
||||
Hyperlink {
|
||||
action: Action::Slide { slide },
|
||||
tooltip: String::new(),
|
||||
highlight_click: false,
|
||||
sound: None,
|
||||
stop_sound: false,
|
||||
zoom_return: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn linked_text(slide: SlideId) -> TextBody {
|
||||
let mut text = TextBody::from_text("go");
|
||||
if let Some(r) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
|
||||
r.props.link = Some(link_to(slide));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
/// Links to a slide that is reused too follow the copy; links to a slide left behind stay.
|
||||
#[test]
|
||||
fn reused_slides_link_to_each_others_copies() {
|
||||
let mut src = defaults::new_presentation(None);
|
||||
let template = src.slides.first().map(|s| s.as_ref().clone()).expect("slide");
|
||||
for _ in 0..2 {
|
||||
let mut sl = template.clone();
|
||||
sl.id = SlideId(src.alloc_id().expect("id"));
|
||||
src.slides.push(Arc::new(sl));
|
||||
}
|
||||
let ids: Vec<SlideId> = src.slides.iter().map(|s| s.id).collect();
|
||||
let id = |i: usize| ids.get(i).copied().unwrap_or_default();
|
||||
if let Some(sl) = src.slides.first_mut().map(Arc::make_mut) {
|
||||
sl.shapes = vec![
|
||||
Shape { id: ShapeId(9001), click: Some(link_to(id(1))), ..Default::default() },
|
||||
Shape { id: ShapeId(9002), text: Some(linked_text(id(1))), ..Default::default() },
|
||||
Shape { id: ShapeId(9003), click: Some(link_to(id(2))), ..Default::default() },
|
||||
];
|
||||
}
|
||||
if let Some(sl) = src.slides.get_mut(1).map(Arc::make_mut) {
|
||||
sl.shapes = vec![Shape { id: ShapeId(9004), click: Some(link_to(id(0))), ..Default::default() }];
|
||||
}
|
||||
let bytes = deckcraft_format::save(&src).expect("save");
|
||||
let reopened = crate::cmd::file::open_presentation("src.deckcraft", &bytes).expect("open");
|
||||
let left_behind = reopened.slides.get(2).map(|s| s.id).expect("third slide");
|
||||
|
||||
let mut s = Session::with_new();
|
||||
for _ in 0..3 {
|
||||
s.execute("slide.new", &json!({})).expect("new");
|
||||
}
|
||||
let out = s
|
||||
.execute("slide.reuse", &json!({"data": super::super::base64_encode(&bytes), "name": "src.deckcraft", "slides": [0, 1]}))
|
||||
.expect("reuse");
|
||||
let new: Vec<SlideId> = serde_json::from_value(out.get("ids").cloned().unwrap_or_default()).expect("ids");
|
||||
let doc = &s.doc().expect("doc").doc;
|
||||
let copy = |i: usize| new.get(i).and_then(|id| doc.slide(*id)).expect("copied slide");
|
||||
let click = |sh: Option<&Shape>| sh.and_then(|sh| sh.click.as_ref()).map(|h| h.action.clone());
|
||||
let first = copy(0);
|
||||
let to = |i: usize| Some(Action::Slide { slide: new.get(i).copied().unwrap_or_default() });
|
||||
assert_eq!(click(first.shapes.first()), to(1), "shape click follows the copy");
|
||||
let run_link = first
|
||||
.shapes
|
||||
.get(1)
|
||||
.and_then(|sh| sh.text.as_ref())
|
||||
.and_then(|t| t.paragraphs.first())
|
||||
.and_then(|p| p.runs.first())
|
||||
.and_then(|r| r.props.link.as_ref())
|
||||
.map(|h| h.action.clone());
|
||||
assert_eq!(run_link, to(1), "text link follows the copy");
|
||||
assert_eq!(click(first.shapes.get(2)), Some(Action::Slide { slide: left_behind }), "a slide not reused keeps its link");
|
||||
assert_eq!(click(copy(1).shapes.first()), to(0), "a link back to an earlier copy follows too");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,14 @@ fn reply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let all = bool_or(p, "all", false);
|
||||
let i = usize_param(p, "index");
|
||||
let i = match p.get("index") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(v) => Some(
|
||||
v.as_u64()
|
||||
.and_then(|v| usize::try_from(v).ok())
|
||||
.ok_or_else(|| bad("comment.delete", format!("`index` is not a valid comment index: {v}")))?,
|
||||
),
|
||||
};
|
||||
s.edit(|doc, sel| {
|
||||
if all {
|
||||
for sl in &mut doc.slides {
|
||||
@@ -793,4 +800,18 @@ mod tests {
|
||||
s.execute("show.setup", &json!({"custom": ""})).unwrap();
|
||||
assert!(s.active().unwrap().doc.show.custom_show.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_rejects_an_invalid_index_and_keeps_the_comments() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("comment.add", &json!({"text": "a"})).unwrap();
|
||||
s.execute("comment.add", &json!({"text": "b"})).unwrap();
|
||||
for bad_index in [json!(-1), json!("2"), json!(1.5)] {
|
||||
let r = s.execute("comment.delete", &json!({"index": bad_index}));
|
||||
assert!(r.is_err(), "index {bad_index} gave {r:?}");
|
||||
assert_eq!(s.active().unwrap().doc.slides[0].comments.len(), 2, "index {bad_index}");
|
||||
}
|
||||
s.execute("comment.delete", &json!({})).unwrap();
|
||||
assert!(s.active().unwrap().doc.slides[0].comments.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,8 +189,8 @@ fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
|
||||
let mut underline = i32::from(name == "u");
|
||||
if let Some(style) = attr(src, "style") {
|
||||
let s = style.to_ascii_lowercase();
|
||||
if s.contains("font-weight:bold") || s.contains("font-weight: bold") || weight_bold(&s) {
|
||||
bold = 1;
|
||||
if let Some(on) = weight_bold(&s) {
|
||||
bold = i32::from(on);
|
||||
}
|
||||
if s.contains("font-style:italic") || s.contains("font-style: italic") {
|
||||
italic = 1;
|
||||
@@ -206,10 +206,19 @@ fn tag_style(name: &str, src: &str) -> (i32, i32, i32) {
|
||||
(bold, italic, underline)
|
||||
}
|
||||
|
||||
fn weight_bold(style: &str) -> bool {
|
||||
let Some(at) = style.find("font-weight") else { return false };
|
||||
/// Whether a `font-weight` in `style` turns bold on (`Some(true)`) or off (`Some(false)`).
|
||||
fn weight_bold(style: &str) -> Option<bool> {
|
||||
let at = style.find("font-weight")?;
|
||||
let rest = style[at + "font-weight".len()..].trim_start_matches(|c: char| c == ':' || c.is_whitespace());
|
||||
rest.starts_with('6') || rest.starts_with('7') || rest.starts_with('8') || rest.starts_with('9')
|
||||
if rest.starts_with("bold") {
|
||||
return Some(true);
|
||||
}
|
||||
if rest.starts_with("normal") || rest.starts_with("lighter") {
|
||||
return Some(false);
|
||||
}
|
||||
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
let weight: u32 = digits.parse().ok()?;
|
||||
Some(weight >= 600)
|
||||
}
|
||||
|
||||
fn attr<'a>(tag: &'a str, key: &str) -> Option<&'a str> {
|
||||
@@ -265,20 +274,22 @@ fn decode_text(raw: &str) -> String {
|
||||
break;
|
||||
};
|
||||
let entity = &rest[1..end];
|
||||
rest = &rest[end + 1..];
|
||||
match entity {
|
||||
"amp" => out.push('&'),
|
||||
"lt" => out.push('<'),
|
||||
"gt" => out.push('>'),
|
||||
"quot" => out.push('"'),
|
||||
"apos" => out.push('\''),
|
||||
"nbsp" => out.push(' '),
|
||||
_ => {
|
||||
let ch = entity.strip_prefix('#').and_then(numeric_char);
|
||||
if let Some(c) = ch {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
let decoded = match entity {
|
||||
"amp" => Some('&'),
|
||||
"lt" => Some('<'),
|
||||
"gt" => Some('>'),
|
||||
"quot" => Some('"'),
|
||||
"apos" => Some('\''),
|
||||
"nbsp" => Some(' '),
|
||||
_ => entity.strip_prefix('#').and_then(numeric_char),
|
||||
};
|
||||
if let Some(c) = decoded {
|
||||
out.push(c);
|
||||
rest = &rest[end + 1..];
|
||||
} else {
|
||||
// Not a well-formed entity (e.g. a bare "R&D"): keep the '&' as typed.
|
||||
out.push('&');
|
||||
rest = &rest[1..];
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() && !rest.starts_with('&') {
|
||||
@@ -308,6 +319,20 @@ fn fold(s: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn google_docs_normal_weight_wrapper_is_not_bold() {
|
||||
let html = "<meta charset=\"utf-8\"><b style=\"font-weight:normal;\" id=\"docs-internal-guid-1\"><span style=\"font-weight:400\">plain words</span></b>";
|
||||
let body = html_to_body(html).unwrap();
|
||||
assert_eq!(body.text(), "plain words");
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, None);
|
||||
let light = html_to_body("<b style=\"font-weight:300\">thin</b>").unwrap();
|
||||
assert_eq!(light.paragraphs[0].runs[0].props.bold, None);
|
||||
let plain = html_to_body("<b>strong</b>").unwrap();
|
||||
assert_eq!(plain.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
let heavy = html_to_body("<span style=\"font-weight:700\">heavy</span>").unwrap();
|
||||
assert_eq!(heavy.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_italic_and_underline_round_trip() {
|
||||
let mut body = TextBody::from_text("Hi there");
|
||||
@@ -327,6 +352,14 @@ mod tests {
|
||||
assert!(last.props.underline.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_ampersand_keeps_text_up_to_next_semicolon() {
|
||||
let text = |html: &str| html_to_body(html).unwrap().text();
|
||||
assert_eq!(text("<p>R&D costs; up</p>"), "R&D costs; up");
|
||||
assert_eq!(text("<p>AT&T; Q&A</p>"), "AT&T; Q&A");
|
||||
assert_eq!(text("<p>A & B & C & D</p>"), "A & B & C & D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages_style_and_escapes() {
|
||||
let html = "<html><head><style>p{}</style></head><body><p><span style=\"font-weight: bold\">A & B</span></p><p><span style=\"font-style: italic\">C</span></p></body></html>";
|
||||
|
||||
@@ -169,13 +169,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Keeps a side length in `0..=MAX_PT` so a huge value cannot exhaust memory when rendering.
|
||||
/// An overflow to infinity lands on the limit; NaN becomes 0.
|
||||
fn clamp_side(v: f64) -> f64 {
|
||||
if v.is_nan() { 0.0 } else { v.clamp(0.0, MAX_PT) }
|
||||
}
|
||||
|
||||
/// Keeps a coordinate within `±POS_LIMIT`.
|
||||
fn clamp_pos(v: f64) -> f64 {
|
||||
if v.is_nan() { 0.0 } else { v.clamp(-POS_LIMIT, POS_LIMIT) }
|
||||
}
|
||||
|
||||
fn move_by(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (dx, dy) = (f64_param(p, "dx"), f64_param(p, "dy"));
|
||||
let (ax, ay) = (f64_param(p, "x"), f64_param(p, "y"));
|
||||
edit_geometry(s, p, "shape.move", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = ax.unwrap_or(x.x) + dx.unwrap_or(0.0);
|
||||
x.y = ay.unwrap_or(x.y) + dy.unwrap_or(0.0);
|
||||
x.x = clamp_pos(ax.unwrap_or(x.x) + dx.unwrap_or(0.0));
|
||||
x.y = clamp_pos(ay.unwrap_or(x.y) + dy.unwrap_or(0.0));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -202,10 +213,12 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
x.h = h.unwrap_or(x.h);
|
||||
}
|
||||
}
|
||||
x.w = clamp_side(x.w);
|
||||
x.h = clamp_side(x.h);
|
||||
// Rotated shapes resize about their centre; others keep the top-left.
|
||||
if x.rot != 0.0 {
|
||||
x.x = cx - x.w / 2.0;
|
||||
x.y = cy - x.h / 2.0;
|
||||
x.x = clamp_pos(cx - x.w / 2.0);
|
||||
x.y = clamp_pos(cy - x.h / 2.0);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -215,10 +228,10 @@ fn resize(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_geometry(s, p, "shape.setBounds", |sh| {
|
||||
if let Some(x) = sh.xfrm.as_mut() {
|
||||
x.x = f64_param(p, "x").unwrap_or(x.x);
|
||||
x.y = f64_param(p, "y").unwrap_or(x.y);
|
||||
x.w = f64_param(p, "w").unwrap_or(x.w).max(0.0);
|
||||
x.h = f64_param(p, "h").unwrap_or(x.h).max(0.0);
|
||||
x.x = clamp_pos(f64_param(p, "x").unwrap_or(x.x));
|
||||
x.y = clamp_pos(f64_param(p, "y").unwrap_or(x.y));
|
||||
x.w = clamp_side(f64_param(p, "w").unwrap_or(x.w));
|
||||
x.h = clamp_side(f64_param(p, "h").unwrap_or(x.h));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -406,6 +419,8 @@ fn flip(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
} else {
|
||||
x.flip_h = !x.flip_h;
|
||||
}
|
||||
// `Xfrm::affine` flips first and rotates second; a true mirror also negates the rotation.
|
||||
x.rot = (360.0 - x.rot_norm()) % 360.0;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
@@ -1150,19 +1165,17 @@ fn media_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({}))
|
||||
}
|
||||
|
||||
fn shape_id(p: &Value, key: &str) -> Option<deckcraft_model::ShapeId> {
|
||||
p.get(key).and_then(Value::as_u64).map(|v| deckcraft_model::ShapeId(v as u32))
|
||||
}
|
||||
|
||||
fn sites(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let id = shape_id(p, "id").ok_or_else(|| bad("shape.sites", "missing `id`"))?;
|
||||
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
|
||||
let st = s.doc()?;
|
||||
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
|
||||
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
|
||||
}
|
||||
|
||||
fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (Some(from), Some(to)) = (shape_id(p, "from"), shape_id(p, "to")) else { return Err(bad("shape.connect", "missing `from` / `to`")) };
|
||||
let (Some(from), Some(to)) = (id_param(p, "from", "shape.connect")?, id_param(p, "to", "shape.connect")?) else {
|
||||
return Err(bad("shape.connect", "missing `from` / `to`"));
|
||||
};
|
||||
let st = s.doc()?;
|
||||
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
|
||||
let (a, b) = (site_list(from), site_list(to));
|
||||
@@ -1186,7 +1199,7 @@ fn connect(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
};
|
||||
let (p0, p1) = (a[ia], b[ib]);
|
||||
let id = match shape_id(p, "id") {
|
||||
let id = match id_param(p, "id", "shape.connect")? {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
|
||||
|
||||
@@ -122,7 +122,7 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
sh.text = Some(TextBody::from_text(t));
|
||||
}
|
||||
if let Some(b) = str_param(p, "body")
|
||||
&& let Some(sh) = slide.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| !k.is_title()))
|
||||
&& let Some(sh) = body_placeholder(&mut slide.shapes)
|
||||
{
|
||||
let mut body = TextBody::from_text(b);
|
||||
// Leading tabs set the outline level, as in text.set.
|
||||
@@ -158,6 +158,25 @@ fn new(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The placeholder a new slide's `body` goes into: the layout's main content placeholder.
|
||||
/// Prefers `idx="1"` (the content slot PowerPoint fills), then a full-size text placeholder over
|
||||
/// a `half`/`quarter` one, then a text placeholder over a picture/chart/table one; ties keep
|
||||
/// shape order, so a layout with a single body placeholder behaves as before.
|
||||
fn body_placeholder(shapes: &mut [deckcraft_model::Shape]) -> Option<&mut deckcraft_model::Shape> {
|
||||
use deckcraft_model::PhType;
|
||||
let rank = |sh: &deckcraft_model::Shape| {
|
||||
let ph = sh.ph.as_ref()?;
|
||||
if ph.kind.is_title() {
|
||||
return None;
|
||||
}
|
||||
let text = matches!(ph.kind, PhType::Body | PhType::Obj | PhType::SubTitle);
|
||||
let small = matches!(ph.size.as_deref(), Some("half" | "quarter"));
|
||||
Some((ph.idx != 1, small, !text))
|
||||
};
|
||||
let best = shapes.iter().enumerate().filter_map(|(i, sh)| rank(sh).map(|r| (r, i))).min()?.1;
|
||||
shapes.get_mut(best)
|
||||
}
|
||||
|
||||
fn index_of(s: &Session, p: &Value) -> Result<usize> {
|
||||
let st = s.doc()?;
|
||||
let i = usize_param(p, "index").unwrap_or(st.selection.slide);
|
||||
@@ -199,6 +218,9 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
&& let Some(sec) = doc.sections.get_mut(si)
|
||||
{
|
||||
sec.slides.push(nid);
|
||||
// Keep the section list in document order so the copy sits next to its source.
|
||||
let order: Vec<SlideId> = doc.slides.iter().map(|x| x.id).collect();
|
||||
sec.slides.sort_by_key(|x| order.iter().position(|o| o == x).unwrap_or(usize::MAX));
|
||||
}
|
||||
}
|
||||
sel.slide = (last + new_ids.len()).min(doc.slides.len().saturating_sub(1));
|
||||
@@ -605,6 +627,17 @@ fn from_outline(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
for (k, sl) in new.into_iter().enumerate() {
|
||||
doc.slides.insert(at + k, sl);
|
||||
}
|
||||
// Join the section of the slide before, as slide.new does.
|
||||
let ids: Vec<SlideId> = doc.slides.iter().skip(at).take(n).map(|x| x.id).collect();
|
||||
let prev = at.checked_sub(1).and_then(|i| doc.slides.get(i)).map(|x| x.id);
|
||||
let section = prev.and_then(|p| doc.section_of(p));
|
||||
if let Some(sec) = section.and_then(|si| doc.sections.get_mut(si)) {
|
||||
let pos = prev.and_then(|p| sec.slides.iter().position(|x| *x == p)).map(|k| k + 1).unwrap_or(sec.slides.len());
|
||||
let pos = pos.min(sec.slides.len());
|
||||
sec.slides.splice(pos..pos, ids);
|
||||
} else if let Some(first) = doc.sections.first_mut() {
|
||||
first.slides.splice(0..0, ids);
|
||||
}
|
||||
Ok(json!({"slides": n}))
|
||||
})
|
||||
}
|
||||
@@ -615,6 +648,23 @@ mod tests {
|
||||
|
||||
use crate::Session;
|
||||
|
||||
#[test]
|
||||
fn duplicate_keeps_section_order_in_step_with_slides() {
|
||||
let mut s = Session::with_new();
|
||||
while s.doc().expect("doc").doc.slides.len() < 3 {
|
||||
s.execute("slide.new", &json!({})).expect("new slide");
|
||||
}
|
||||
s.execute("section.add", &json!({"at": 0})).expect("section");
|
||||
let orig = s.doc().expect("doc").doc.slides.first().expect("slide").id;
|
||||
s.execute("slide.duplicate", &json!({"index": 0})).expect("dup");
|
||||
let st = s.doc().expect("doc");
|
||||
let all: Vec<_> = st.doc.slides.iter().map(|x| x.id).collect();
|
||||
let sec = &st.doc.sections.first().expect("section").slides;
|
||||
assert_eq!(all.len(), 4);
|
||||
assert_eq!(sec, &all, "section order must match slide order");
|
||||
assert_eq!(sec.first(), Some(&orig));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_slide_body_leading_tabs_become_levels() {
|
||||
let mut s = Session::with_new();
|
||||
|
||||
@@ -541,7 +541,7 @@ fn shadow(blur: f64, dist: f64) -> Shadow {
|
||||
/// `title` false every line is a bullet (the bullet boxes of the bullet lists).
|
||||
fn text_shape(preset: &str, x: Xfrm, lines: &[&DiagramNode], title: bool) -> Shape {
|
||||
let mut body = TextBody::default();
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
|
||||
let top = lines.first().map(|n| if title { n.level } else { n.level.saturating_sub(1) }).unwrap_or(0);
|
||||
for (k, n) in lines.iter().enumerate() {
|
||||
let mut para = Paragraph::new(&n.text);
|
||||
@@ -1913,7 +1913,7 @@ fn to_text(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
body.paragraphs.push(Paragraph::new(""));
|
||||
}
|
||||
body.body.wrap = Some(true);
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
|
||||
let id = ShapeId(doc.alloc_id().ok_or_else(|| bad(CMD, NO_ID_SPACE))?);
|
||||
let tb =
|
||||
Shape { id, name: "Text".into(), xfrm: Some(area), text_box: true, text: Some(body), geom: Geom::preset("rect"), ..Default::default() };
|
||||
|
||||
@@ -288,9 +288,9 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str) -> Result<Value> {
|
||||
with_table(s, p, "table", move |t| {
|
||||
match op.as_str() {
|
||||
"rowAbove" => t.insert_row(r),
|
||||
"rowBelow" => t.insert_row(r + 1),
|
||||
"rowBelow" => t.insert_row(r.saturating_add(1)),
|
||||
"colLeft" => t.insert_col(c),
|
||||
"colRight" => t.insert_col(c + 1),
|
||||
"colRight" => t.insert_col(c.saturating_add(1)),
|
||||
"delRow" => {
|
||||
if !t.delete_row(r) {
|
||||
return Err(bad("table.deleteRow", "can't delete the last row"));
|
||||
@@ -537,7 +537,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
with_table(s, p, "table.columnWidth", move |t| {
|
||||
// Keeping the table's width, the column to the right gives or takes the difference (dragging
|
||||
// an inner border). The last column has none: the table gets wider or narrower.
|
||||
let pair = t.cols.get(c).zip(t.cols.get(c + 1)).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
|
||||
let pair = t.cols.get(c).zip(t.cols.get(c.saturating_add(1))).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
|
||||
match pair {
|
||||
Some(pair) if keep_total => {
|
||||
let min = MIN_COLUMN_WIDTH.min(pair / 2.0);
|
||||
@@ -545,7 +545,7 @@ fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(x) = t.cols.get_mut(c) {
|
||||
*x = w;
|
||||
}
|
||||
if let Some(x) = t.cols.get_mut(c + 1) {
|
||||
if let Some(x) = t.cols.get_mut(c.saturating_add(1)) {
|
||||
*x = pair - w;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"AutoCorrect Options",
|
||||
["Tools"],
|
||||
None,
|
||||
"{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
|
||||
"{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?: bool, add?: [from, to], remove?: from} → the options and the replacement list",
|
||||
always,
|
||||
autocorrect_options
|
||||
),
|
||||
@@ -119,9 +119,14 @@ pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut T
|
||||
let mut t = t0.clone();
|
||||
let body = body_mut(doc, sel, &t).ok_or_else(|| bad("text", "the text being edited is gone"))?;
|
||||
let r = f(body, &mut t);
|
||||
// The saved shrink-on-overflow scale was for the old text: fit the new text afresh.
|
||||
// The saved shrink-on-overflow scale was for the old text: fit the new text afresh. A
|
||||
// body whose file carried no `fontScale` keeps none (100 %) here; `fit_shrink_scale`
|
||||
// below fills in the computed scale when the new text overflows (#724).
|
||||
if let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut body.body.autofit {
|
||||
(*font_scale, *line_reduction) = (1.0, 0.0);
|
||||
if font_scale.is_some() {
|
||||
*font_scale = Some(1.0);
|
||||
}
|
||||
*line_reduction = 0.0;
|
||||
}
|
||||
sel.text = Some(t);
|
||||
Ok(r)
|
||||
@@ -132,8 +137,11 @@ pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut T
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// Grow/shrink an auto-sizing text box (Resize shape to fit text) to its text.
|
||||
/// Refit a shape after its text changed: remember the shrink-on-overflow scale the layout
|
||||
/// computes (#724), then grow/shrink an auto-sizing text box (Resize shape to fit text) to its
|
||||
/// text.
|
||||
pub fn fit_text_box(s: &mut Session, id: ShapeId) -> Result<()> {
|
||||
fit_shrink_scale(s, id)?;
|
||||
let st = s.doc()?;
|
||||
let Some(sh) = st.shape(id).cloned() else { return Ok(()) };
|
||||
let Some(body) = sh.text.clone() else { return Ok(()) };
|
||||
@@ -171,6 +179,53 @@ pub fn fit_text_box(s: &mut Session, id: ShapeId) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Store the shrink-on-overflow scale the layout computes for `id`, so a save keeps
|
||||
/// `fontScale`/`lnSpcReduction` in step with what DeckCraft draws (#724). Runs after a text
|
||||
/// change: a body with no meaningful saved scale — a file's bare `<a:normAutofit/>` (`None`) or
|
||||
/// the reset an edit leaves (`1.0`, no reduction) — is fitted afresh; a scale the file saved is
|
||||
/// left as it is, as PowerPoint draws it.
|
||||
fn fit_shrink_scale(s: &mut Session, id: ShapeId) -> Result<()> {
|
||||
let st = s.doc()?;
|
||||
let Some(sh) = st.shape(id).cloned() else { return Ok(()) };
|
||||
let Some(body) = sh.text.clone() else { return Ok(()) };
|
||||
let Some(AutoFit::Shrink { font_scale, line_reduction }) = body.body.autofit else { return Ok(()) };
|
||||
if !(font_scale.is_none() || (font_scale == Some(1.0) && line_reduction == 0.0)) {
|
||||
return Ok(());
|
||||
}
|
||||
let doc = st.doc.clone();
|
||||
let sel = st.selection.clone();
|
||||
let Some(ctx) = ctx_for(&doc, &sel) else { return Ok(()) };
|
||||
let x = xfrm_of(&doc, &sel, &sh);
|
||||
let tr = deckcraft_render::shape_geometry(&sh, x.w, x.h).text_rect;
|
||||
// Lay out as if the scale had just been reset, so the fit is computed even for `None` (which
|
||||
// is drawn at 100 % and only fitted again when its text changes).
|
||||
let mut probe = body.clone();
|
||||
probe.body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
|
||||
let opts = deckcraft_text::Opts { rect: tr, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false };
|
||||
let l = deckcraft_text::layout(&ctx, &sh, &probe, &opts);
|
||||
if l.font_scale >= 1.0 && l.line_reduction <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
// Change the body in place without an extra undo step (it is part of the edit).
|
||||
let st = s.doc_mut()?;
|
||||
let mut d = (*st.doc).clone();
|
||||
let stored = if let Some(list) = crate::shapes_mut(&mut d, &st.selection)
|
||||
&& let Some(target) = deckcraft_model::find_shape_mut(list, id)
|
||||
&& let Some(t) = target.text.as_mut()
|
||||
&& let Some(AutoFit::Shrink { font_scale, line_reduction }) = &mut t.body.autofit
|
||||
{
|
||||
(*font_scale, *line_reduction) = (Some(l.font_scale), l.line_reduction);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
if stored {
|
||||
st.doc = Arc::new(d);
|
||||
st.revision += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option<Ctx<'a>> {
|
||||
match sel.target {
|
||||
Target::Slides => doc.slides.get(sel.slide).and_then(|s| Ctx::for_slide(doc, s)),
|
||||
@@ -600,7 +655,12 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
} else {
|
||||
correct_word(&s.prefs, &word, sentence_start, typing)
|
||||
};
|
||||
let Some(rep) = rep else { return Ok(()) };
|
||||
let Some(rep) = rep else {
|
||||
if typing && s.prefs.ac_ordinals && is_ordinal(&word) {
|
||||
return superscript_suffix(s, &t, word_end);
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
let delta = rep.chars().count() as isize - word.chars().count() as isize;
|
||||
let st = s.doc_mut()?;
|
||||
let mut d = (*st.doc).clone();
|
||||
@@ -619,6 +679,43 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat
|
||||
/// superscripts it.
|
||||
fn is_ordinal(word: &str) -> bool {
|
||||
let digits = word.trim_end_matches(|c: char| c.is_ascii_lowercase());
|
||||
let suffix = word.get(digits.len()..).unwrap_or_default();
|
||||
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) || suffix.len() != 2 {
|
||||
return false;
|
||||
}
|
||||
let d = digits.as_bytes();
|
||||
let teen = d.len() >= 2 && d.get(d.len() - 2) == Some(&b'1');
|
||||
let want = match d.last() {
|
||||
_ if teen => "th",
|
||||
Some(b'1') => "st",
|
||||
Some(b'2') => "nd",
|
||||
Some(b'3') => "rd",
|
||||
_ => "th",
|
||||
};
|
||||
suffix == want
|
||||
}
|
||||
|
||||
/// Superscript the two letters before `end` in the caret's paragraph (an ordinal's suffix).
|
||||
fn superscript_suffix(s: &mut Session, t: &crate::TextSel, end: usize) -> Result<()> {
|
||||
let (a, b) = ((t.caret.0, end.saturating_sub(2)), (t.caret.0, end));
|
||||
let st = s.doc_mut()?;
|
||||
if body_of(st, t).is_none_or(|body| ed::props_at(body, b).baseline.unwrap_or(0.0) > 0.0) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut d = (*st.doc).clone();
|
||||
let sel = st.selection.clone();
|
||||
if let Some(body) = body_mut(&mut d, &sel, t) {
|
||||
ed::format(body, a, b, &|r| r.baseline = Some(0.3));
|
||||
}
|
||||
st.doc = Arc::new(d);
|
||||
st.revision += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let pr = &mut s.prefs;
|
||||
for (key, slot) in [
|
||||
@@ -628,6 +725,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
("days", &mut pr.ac_days),
|
||||
("capsLock", &mut pr.ac_caps_lock),
|
||||
("fractions", &mut pr.ac_fractions),
|
||||
("ordinals", &mut pr.ac_ordinals),
|
||||
] {
|
||||
if let Some(v) = p.get(key).and_then(Value::as_bool) {
|
||||
*slot = v;
|
||||
@@ -674,7 +772,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({
|
||||
"on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps,
|
||||
"days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions,
|
||||
"entries": list,
|
||||
"ordinals": pr.ac_ordinals, "entries": list,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -324,12 +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.
|
||||
/// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) as you type.
|
||||
pub ac_two_initials: bool,
|
||||
pub ac_sentence_caps: bool,
|
||||
pub ac_days: bool,
|
||||
pub ac_caps_lock: bool,
|
||||
pub ac_fractions: bool,
|
||||
pub ac_ordinals: bool,
|
||||
/// Your own "replace … with …" entries; they win over the built-in list.
|
||||
pub ac_entries: Vec<(String, String)>,
|
||||
/// Built-in entries you deleted.
|
||||
@@ -354,6 +355,7 @@ impl Default for Prefs {
|
||||
ac_days: true,
|
||||
ac_caps_lock: true,
|
||||
ac_fractions: true,
|
||||
ac_ordinals: true,
|
||||
ac_entries: Vec::new(),
|
||||
ac_removed: Vec::new(),
|
||||
author: "Presenter".into(),
|
||||
|
||||
@@ -311,7 +311,10 @@ mod tests {
|
||||
let opened = open(&mut s2, &dir).unwrap();
|
||||
assert!(s2.documents().iter().all(|d| d.uid != uid), "{opened:?}");
|
||||
assert!(lock_path(&dir, other).exists(), "a live owner's lock stays");
|
||||
// Once the owner is gone, its entry is recovered and its lock file cleaned up.
|
||||
// Once the owner is gone, its entry is recovered and its lock file cleaned up. Unlock
|
||||
// rather than only drop: a child spawned meanwhile by a parallel test (mkfifo) shares the
|
||||
// open file and would keep the lock held until it execs.
|
||||
lock.unlock().unwrap();
|
||||
drop(lock);
|
||||
let mut s3 = Session::new();
|
||||
open(&mut s3, &dir).unwrap();
|
||||
|
||||
+585
-3
@@ -94,6 +94,26 @@ fn text_editing_flow() {
|
||||
assert_eq!(s.doc().unwrap().selection.shapes.len(), 1);
|
||||
}
|
||||
|
||||
/// Cut with only a caret copies nothing, so it must not delete the character after the caret.
|
||||
#[test]
|
||||
fn cut_with_only_a_caret_changes_nothing() {
|
||||
let mut s = session();
|
||||
let r = s.execute("insert.textBox", &json!({"text": "Hello"})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
s.execute("text.edit", &json!({"id": id})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "start"})).unwrap();
|
||||
let out = s.execute("edit.cut", &json!({})).unwrap();
|
||||
assert_eq!(out["text"], "");
|
||||
let t = s.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(t["text"], "Hello", "cut with only a caret changed the text");
|
||||
// A real selection is still cut.
|
||||
s.execute("text.move", &json!({"to": "end", "extend": true})).unwrap();
|
||||
let out = s.execute("edit.cut", &json!({})).unwrap();
|
||||
assert_eq!(out["text"], "Hello");
|
||||
let t = s.execute("text.get", &json!({"id": id})).unwrap();
|
||||
assert_eq!(t["text"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_text_refits_a_saved_shrink() {
|
||||
use deckcraft_model::text::AutoFit;
|
||||
@@ -105,13 +125,45 @@ fn editing_text_refits_a_saved_shrink() {
|
||||
s.edit(|doc, sel| {
|
||||
let shapes = crate::shapes_mut(doc, sel).unwrap();
|
||||
let sh = deckcraft_model::find_shape_mut(shapes, title).unwrap();
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.5, line_reduction: 0.2 });
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.5), line_reduction: 0.2 });
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.execute("text.insert", &json!({"text": "!"})).unwrap();
|
||||
let sh = s.doc().unwrap().shape(title).cloned().unwrap();
|
||||
assert_eq!(sh.text.unwrap().body.autofit, Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }));
|
||||
assert_eq!(sh.text.unwrap().body.autofit, Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }));
|
||||
}
|
||||
|
||||
/// After an edit, the shrink-on-overflow scale the layout computes is stored, so the saved file
|
||||
/// carries the scale DeckCraft draws instead of a bare normAutofit (#724).
|
||||
#[test]
|
||||
fn editing_shrink_text_stores_the_layout_scale() {
|
||||
use deckcraft_model::text::AutoFit;
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 200.0, 40.0]})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
s.execute("format.autofit", &json!({"mode": "shrink"})).unwrap();
|
||||
let text = (0..12).map(|i| format!("Line {i}")).collect::<Vec<_>>().join("\n");
|
||||
s.execute("text.set", &json!({"id": id.0, "text": text})).unwrap();
|
||||
let sh = s.doc().unwrap().shape(id).cloned().unwrap();
|
||||
let Some(AutoFit::Shrink { font_scale, line_reduction }) = sh.text.unwrap().body.autofit else { panic!("shrink") };
|
||||
assert!(font_scale.is_some_and(|s| s < 1.0), "the computed fit is stored: {font_scale:?} / {line_reduction}");
|
||||
// The saved file must carry the scale DeckCraft draws, so PowerPoint opens it the same way.
|
||||
let bytes = deckcraft_pptx::export(&s.doc().unwrap().doc).unwrap();
|
||||
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let mut xml = String::new();
|
||||
std::io::Read::read_to_string(&mut z.by_name("ppt/slides/slide1.xml").unwrap(), &mut xml).unwrap();
|
||||
let scales: Vec<i64> = xml.split("fontScale=\"").skip(1).filter_map(|s| s.split('"').next()?.parse().ok()).collect();
|
||||
assert!(scales.iter().any(|v| *v < 100_000), "the fitted scale is saved: {scales:?}");
|
||||
// Undo restores the state before the edit, and redo brings the stored fit back.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
let sh = s.doc().unwrap().shape(id).cloned().unwrap();
|
||||
let Some(AutoFit::Shrink { font_scale, .. }) = sh.text.unwrap().body.autofit else { panic!("shrink") };
|
||||
assert_eq!(font_scale, Some(1.0), "undo restores the scale the edit started from");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let sh = s.doc().unwrap().shape(id).cloned().unwrap();
|
||||
let Some(AutoFit::Shrink { font_scale, .. }) = sh.text.unwrap().body.autofit else { panic!("shrink") };
|
||||
assert!(font_scale.is_some_and(|v| v < 1.0), "redo brings the stored fit back: {font_scale:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1425,6 +1477,41 @@ fn slide_ranges_parse_like_powerpoints_print_box() {
|
||||
assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped");
|
||||
}
|
||||
|
||||
/// #725: a layout with a small body placeholder ahead of its content placeholder (as Keynote
|
||||
/// exports "Title & Bullets") still gets the body in the content placeholder (`idx="1"`).
|
||||
#[test]
|
||||
fn new_slide_body_goes_to_the_content_placeholder() {
|
||||
for before in [true, false] {
|
||||
let mut s = session();
|
||||
let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc);
|
||||
let id = deckcraft_model::ShapeId(doc.alloc_id().expect("id"));
|
||||
let lay = std::sync::Arc::make_mut(&mut doc.masters[0])
|
||||
.layouts
|
||||
.iter_mut()
|
||||
.find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent)
|
||||
.unwrap();
|
||||
let at = lay.shapes.iter().position(|x| x.ph.as_ref().is_some_and(|p| p.idx == 1)).unwrap();
|
||||
let mut small = lay.shapes[at].clone();
|
||||
small.id = id;
|
||||
small.name = "Subtitle 21".into();
|
||||
let ph = small.ph.as_mut().unwrap();
|
||||
ph.kind = deckcraft_model::PhType::Body;
|
||||
ph.idx = 21;
|
||||
ph.size = Some("quarter".into());
|
||||
lay.shapes.insert(if before { at } else { at + 1 }, small);
|
||||
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "T", "body": "Line one\nLine two"})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let slide = &doc.slides[doc.slides.len() - 1];
|
||||
let text = |idx: u32| {
|
||||
let sh = slide.shapes.iter().find(|x| x.ph.as_ref().is_some_and(|p| p.idx == idx)).unwrap();
|
||||
sh.text.as_ref().map(|t| t.text()).unwrap_or_default()
|
||||
};
|
||||
assert_eq!(text(1), "Line one\nLine two", "before = {before}");
|
||||
assert_eq!(text(21), "", "before = {before}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_fill_walks_only_the_cells_a_huge_selection_covers() {
|
||||
// A selection may reach far past the table; filling it touches the real cells and nothing more.
|
||||
@@ -2710,6 +2797,33 @@ fn a_png_export_records_the_slide_resolution() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #784: `transparentBackground` leaves the slide background out of a PNG export, so only the
|
||||
/// slide's content is opaque; formats without transparency refuse it and write nothing.
|
||||
#[test]
|
||||
fn a_png_export_can_leave_the_background_transparent() {
|
||||
let mut s = session();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
s.execute("design.background", &json!({"color": "#0000C8"})).unwrap();
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-transparent-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let read = |path: &std::path::Path| image::load_from_memory(&std::fs::read(path).unwrap()).unwrap().to_rgba8();
|
||||
let opaque = dir.join("opaque.png");
|
||||
s.execute("file.export", &json!({"path": opaque.to_str().unwrap(), "scale": 1})).unwrap();
|
||||
assert_eq!(read(&opaque).get_pixel(5, 5).0, [0, 0, 200, 255], "the background draws by default");
|
||||
let clear = dir.join("clear.png");
|
||||
s.execute("file.export", &json!({"path": clear.to_str().unwrap(), "scale": 1, "transparentBackground": true})).unwrap();
|
||||
let img = read(&clear);
|
||||
assert_eq!(img.get_pixel(5, 5).0[3], 0, "no background: {:?}", img.get_pixel(5, 5));
|
||||
assert_eq!(img.get_pixel(200, 150).0[3], 255, "the shape stays opaque: {:?}", img.get_pixel(200, 150));
|
||||
for (name, format) in [("slide.jpg", "jpeg"), ("deck.pdf", "pdf"), ("deck.gif", "gif"), ("deck.pptx", "pptx")] {
|
||||
let path = dir.join(name);
|
||||
let e = s.execute("file.export", &json!({"path": path.to_str().unwrap(), "format": format, "transparentBackground": true})).unwrap_err();
|
||||
assert!(e.to_string().contains("for PNG export"), "{format}: {e}");
|
||||
assert!(!path.exists(), "{format}: nothing is written");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
let w: [u8; 4] = bytes.get(16..20)?.try_into().ok()?;
|
||||
let h: [u8; 4] = bytes.get(20..24)?.try_into().ok()?;
|
||||
@@ -3053,6 +3167,37 @@ fn autocorrect_options_as_you_type() {
|
||||
assert_eq!(s.execute("text.get", &json!({"id": title})).unwrap()["text"], "monday notes ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocorrect_superscripts_ordinals_as_you_type() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
// The typed text with each superscript run in ^(…).
|
||||
let typed = |s: &mut Session, text: &str| -> String {
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.selectAll", &json!({})).ok();
|
||||
s.execute("text.delete", &json!({"dir": "backward"})).ok();
|
||||
for c in text.chars() {
|
||||
s.execute("text.insert", &json!({"text": c.to_string()})).unwrap();
|
||||
}
|
||||
let st = s.doc().unwrap();
|
||||
let sh = st.current_slide().unwrap().shapes.iter().find(|sh| sh.id.0 == title).unwrap();
|
||||
sh.text.as_ref().unwrap().paragraphs[0]
|
||||
.runs
|
||||
.iter()
|
||||
.map(|r| if r.props.baseline.unwrap_or(0.0) > 0.0 { format!("^({})", r.text) } else { r.text.clone() })
|
||||
.collect()
|
||||
};
|
||||
assert_eq!(typed(&mut s, "1st, 22nd and 103rd. 4th 11th x"), "1^(st), 22^(nd) and 103^(rd). 4^(th) 11^(th) x");
|
||||
// Not a word, a number without a suffix it takes, or a suffix not yet followed by a space.
|
||||
assert_eq!(typed(&mut s, "first x1st 1th 2st 21st"), "First x1st 1th 2st 21st");
|
||||
assert_eq!(s.execute("text.autocorrect", &json!({})).unwrap()["ordinals"], true);
|
||||
s.execute("text.autocorrect", &json!({"ordinals": false})).unwrap();
|
||||
assert_eq!(typed(&mut s, "2nd "), "2nd ");
|
||||
// AutoCorrect off turns it off too.
|
||||
s.execute("text.autocorrect", &json!({"ordinals": true, "on": false})).unwrap();
|
||||
assert_eq!(typed(&mut s, "2nd "), "2nd ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_variants_apply_and_report_the_current_one() {
|
||||
let mut s = session();
|
||||
@@ -3450,7 +3595,8 @@ fn background_loads_are_adopted_through_a_command() {
|
||||
replay.execute(&id, ¶ms).unwrap();
|
||||
assert_eq!(replay.doc().unwrap().doc.slides.len(), 2);
|
||||
// A token staged for another path is not taken for this one.
|
||||
let other = s.stage_loaded(cmd::file::LoadedFile { path: "/elsewhere.pptx".into(), doc: Default::default(), native: true, damaged: vec![] });
|
||||
let other =
|
||||
s.stage_loaded(cmd::file::LoadedFile { path: "/elsewhere.pptx".into(), doc: Default::default(), native: true, damaged: Default::default() });
|
||||
s.execute("file.adopt", &json!({"path": path, "token": other})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), 2);
|
||||
assert!(s.execute("file.adopt", &json!({})).is_err());
|
||||
@@ -3490,3 +3636,439 @@ fn an_exhausted_id_space_refuses_growth() {
|
||||
sorted.dedup();
|
||||
assert_eq!(sorted.len(), ids.len(), "no id repeats");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_ids_beyond_u32_are_rejected_not_wrapped() {
|
||||
let (mut s, a, b) = two_shapes();
|
||||
let wrap = |id: deckcraft_model::ShapeId| u64::from(id.0) + (1u64 << 32);
|
||||
let shapes_before = s.doc().unwrap().shapes().len();
|
||||
let r = s.execute("shape.connect", &json!({"from": wrap(a), "to": wrap(b)}));
|
||||
assert!(r.is_err(), "connect must reject ids beyond u32, got {r:?}");
|
||||
let r = s.execute("shape.sites", &json!({"id": wrap(a)}));
|
||||
assert!(r.is_err(), "sites must reject ids beyond u32, got {r:?}");
|
||||
let r = s.execute("shape.points", &json!({"id": wrap(a)}));
|
||||
assert!(r.is_err(), "points must reject ids beyond u32, got {r:?}");
|
||||
let r = s.execute("shape.movePoint", &json!({"id": wrap(a), "index": 0, "x": 1.0, "y": 1.0}));
|
||||
assert!(r.is_err_and(|e| e.to_string().contains("not a valid shape id")), "movePoint must reject ids beyond u32");
|
||||
assert_eq!(s.doc().unwrap().shapes().len(), shapes_before, "no connector was created");
|
||||
// In-range ids still work.
|
||||
assert!(s.execute("shape.sites", &json!({"id": a.0})).is_ok());
|
||||
assert!(s.execute("shape.connect", &json!({"from": a.0, "to": b.0})).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_insert_below_or_right_of_a_huge_index_appends_at_the_end() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
|
||||
let r = s.execute("table.insertRowBelow", &json!({"id": id.0, "row": u64::MAX}));
|
||||
assert!(r.is_ok(), "insertRowBelow must clamp a huge row, got {r:?}");
|
||||
let t = table_of(&s, id);
|
||||
assert_eq!(t.n_rows(), 3);
|
||||
assert_eq!(t.cell(0, 0).unwrap().text.text(), "a", "the original rows keep their place");
|
||||
let r = s.execute("table.insertColumnRight", &json!({"id": id.0, "col": u64::MAX}));
|
||||
assert!(r.is_ok(), "insertColumnRight must clamp a huge col, got {r:?}");
|
||||
let t = table_of(&s, id);
|
||||
assert_eq!(t.n_cols(), 3);
|
||||
assert_eq!(t.cell(0, 1).unwrap().text.text(), "b", "the original columns keep their place");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_column_width_of_a_huge_column_is_ignored() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
|
||||
for keep_total in [true, false] {
|
||||
let r = s.execute("table.columnWidth", &json!({"id": id.0, "col": u64::MAX, "width": 50.0, "keepTotal": keep_total}));
|
||||
assert!(r.is_ok(), "columnWidth must ignore a huge col (keepTotal {keep_total}), got {r:?}");
|
||||
assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0], "no column changes");
|
||||
}
|
||||
}
|
||||
|
||||
/// Slides imported from an outline join the section of the slide they follow, like `slide.new`.
|
||||
#[test]
|
||||
fn slides_from_outline_join_the_section_of_the_slide_they_follow() {
|
||||
for markdown in [false, true] {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"title": "A"})).unwrap();
|
||||
s.execute("section.add", &json!({"name": "B", "at": 1})).unwrap();
|
||||
let text = if markdown { "# Imported\n- bullet" } else { "Imported\n\tbullet" };
|
||||
s.execute("slide.fromOutline", &json!({"text": text, "markdown": markdown})).unwrap();
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let orphans: Vec<_> = doc.slides.iter().map(|x| x.id).filter(|id| doc.section_of(*id).is_none()).collect();
|
||||
assert!(orphans.is_empty(), "markdown={markdown}: slides in no section: {orphans:?}; sections {:?}", doc.sections);
|
||||
let order: Vec<_> = doc.sections.iter().flat_map(|x| x.slides.iter().copied()).collect();
|
||||
let slides: Vec<_> = doc.slides.iter().map(|x| x.id).collect();
|
||||
assert_eq!(order, slides, "markdown={markdown}: sections follow the slide order");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_a_pptx_with_an_unreadable_slide_says_so() {
|
||||
use std::io::{Cursor, Read, Write};
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), 2);
|
||||
let good = deckcraft_pptx::export(s.doc().unwrap().doc.as_ref()).unwrap();
|
||||
let mut zin = zip::ZipArchive::new(Cursor::new(good.clone())).unwrap();
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let o = zip::write::SimpleFileOptions::default();
|
||||
for i in 0..zin.len() {
|
||||
let mut f = zin.by_index(i).unwrap();
|
||||
let mut d = vec![];
|
||||
f.read_to_end(&mut d).unwrap();
|
||||
if f.name() == "ppt/slides/slide2.xml" {
|
||||
d = b"<p:sld".to_vec();
|
||||
}
|
||||
out.start_file(f.name(), o).unwrap();
|
||||
out.write_all(&d).unwrap();
|
||||
}
|
||||
let broken = out.finish().unwrap().into_inner();
|
||||
|
||||
let mut t = session();
|
||||
let r = t.execute("file.openBytes", &json!({"name": "Broken.pptx", "data": cmd::base64_encode(&broken)})).unwrap();
|
||||
assert_eq!(r["lostSlides"], json!(["ppt/slides/slide2.xml"]), "{r}");
|
||||
assert_eq!(t.doc().unwrap().doc.slides.len(), 1);
|
||||
assert!(t.doc().unwrap().title.ends_with("(Repaired)"), "{}", t.doc().unwrap().title);
|
||||
assert!(t.ui_requests.iter().any(|u| matches!(u, crate::UiRequest::Message { text } if text.contains("1 slide in this file couldn't be read"))));
|
||||
// A good file reports nothing.
|
||||
let mut u = session();
|
||||
let r = u.execute("file.openBytes", &json!({"name": "Fine.pptx", "data": cmd::base64_encode(&good)})).unwrap();
|
||||
assert_eq!(r["lostSlides"], json!([]), "{r}");
|
||||
assert!(u.ui_requests.is_empty());
|
||||
}
|
||||
|
||||
/// A file with absurd effect sizes opens with them capped at what Format Shape allows: drawn as
|
||||
/// they were, a glow of 10^18 pt made the renderer ask for 34 GB and abort.
|
||||
#[test]
|
||||
fn opening_a_file_caps_absurd_effect_sizes() {
|
||||
use deckcraft_model::style::{Glow, Reflection};
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 100], "text": "Glow"})).unwrap();
|
||||
s.execute("shape.effects", &json!({"shadow": "outer", "reflection": "tight", "softEdges": 4, "glow": {"radius": 8}})).unwrap();
|
||||
s.execute("insert.table", &json!({"rows": 1, "cols": 1, "rect": [400, 300, 100, 40]})).unwrap();
|
||||
let huge = 1e18;
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).unwrap();
|
||||
for sh in list.iter_mut() {
|
||||
if let Some(e) = sh.effects.as_mut() {
|
||||
e.glow.as_mut().unwrap().radius = huge;
|
||||
e.soft_edge = Some(f64::NAN);
|
||||
let sw = e.outer_shadow.as_mut().unwrap();
|
||||
(sw.blur, sw.dist, sw.sx) = (huge, huge, huge);
|
||||
*e.reflection.as_mut().unwrap() = Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge };
|
||||
}
|
||||
let glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge });
|
||||
let runs: Vec<&mut deckcraft_model::text::Run> = match &mut sh.kind {
|
||||
deckcraft_model::ShapeKind::Table(t) => t
|
||||
.rows
|
||||
.iter_mut()
|
||||
.flat_map(|r| r.cells.iter_mut())
|
||||
.flat_map(|c| c.text.paragraphs.iter_mut())
|
||||
.flat_map(|p| p.runs.iter_mut())
|
||||
.collect(),
|
||||
_ => sh.text.iter_mut().flat_map(|t| t.paragraphs.iter_mut()).flat_map(|p| p.runs.iter_mut()).collect(),
|
||||
};
|
||||
for r in runs {
|
||||
r.props.glow = glow.clone();
|
||||
}
|
||||
}
|
||||
// Inherited styles too: the deck default, a master's body style, a text box's own list
|
||||
// style, and the theme's effect styles that shape styles point at.
|
||||
let level = deckcraft_model::text::LevelStyle {
|
||||
run: deckcraft_model::text::RunProps {
|
||||
glow: Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge }),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
doc.default_text_style.set(0, level.clone());
|
||||
let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
|
||||
m.body_style.set(0, level.clone());
|
||||
if let Some(fx) = m.theme.format.effects.first_mut() {
|
||||
fx.glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge });
|
||||
}
|
||||
let list = crate::shapes_mut(doc, sel).unwrap();
|
||||
if let Some(t) = list.iter_mut().find_map(|sh| sh.text.as_mut()) {
|
||||
t.list_style.set(0, level);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
for format in ["deckcraft", "pptx"] {
|
||||
let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, format).unwrap();
|
||||
let mut t = Session::new();
|
||||
t.execute("file.openBytes", &json!({"name": format!("fx.{format}"), "data": cmd::base64_encode(&bytes)})).unwrap();
|
||||
let st = t.doc().unwrap();
|
||||
let slide = st.doc.slides.last().unwrap();
|
||||
let mut glows = vec![];
|
||||
deckcraft_model::walk(&slide.shapes, &mut |sh, _| {
|
||||
if let Some(e) = &sh.effects {
|
||||
glows.extend(e.glow.as_ref().map(|g| g.radius));
|
||||
assert!(e.soft_edge.is_none_or(|v| (0.0..=100.0).contains(&v)), "{format}: {:?}", e.soft_edge);
|
||||
if let Some(sw) = &e.outer_shadow {
|
||||
assert!(sw.blur <= 200.0 && sw.dist <= 200.0 && sw.sx <= 10.0, "{format}: {sw:?}");
|
||||
}
|
||||
if let Some(r) = &e.reflection {
|
||||
assert!(r.blur <= 100.0 && r.dist <= 100.0, "{format}: {r:?}");
|
||||
}
|
||||
}
|
||||
for p in sh.text.iter().flat_map(|t| t.paragraphs.iter()) {
|
||||
glows.extend(p.runs.iter().filter_map(|r| r.props.glow.as_ref().map(|g| g.radius)));
|
||||
}
|
||||
if let deckcraft_model::ShapeKind::Table(tb) = &sh.kind {
|
||||
let cells = tb.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.text.paragraphs.iter());
|
||||
glows.extend(cells.flat_map(|p| p.runs.iter()).filter_map(|r| r.props.glow.as_ref().map(|g| g.radius)));
|
||||
}
|
||||
});
|
||||
let inherited = |ls: &deckcraft_model::text::ListStyle| {
|
||||
ls.levels.iter().flatten().filter_map(|l| l.run.glow.as_ref().map(|g| g.radius)).collect::<Vec<_>>()
|
||||
};
|
||||
glows.extend(inherited(&st.doc.default_text_style));
|
||||
let m = &st.doc.masters[0];
|
||||
for ls in [&m.title_style, &m.body_style, &m.other_style] {
|
||||
glows.extend(inherited(ls));
|
||||
}
|
||||
glows.extend(m.theme.format.effects.iter().filter_map(|e| e.glow.as_ref().map(|g| g.radius)));
|
||||
deckcraft_model::walk(&slide.shapes, &mut |sh, _| glows.extend(sh.text.iter().flat_map(|t| inherited(&t.list_style))));
|
||||
assert!(!glows.is_empty(), "{format}: the glows were kept");
|
||||
assert!(glows.iter().all(|g| (0.0..=150.0).contains(g)), "{format}: {glows:?}");
|
||||
// Capped, the slide renders (checked last: uncapped, this is what ran out of memory).
|
||||
let i = st.doc.slides.len() - 1;
|
||||
t.execute("file.render", &json!({"slide": i, "scale": 0.1})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// A .deckcraft file whose shapes repeat an id (groups included) opens with fresh ids for the
|
||||
/// repeats: the deck passes `validate`, the first shape keeps its id and none is lost.
|
||||
#[test]
|
||||
fn opening_a_file_gives_repeated_shape_ids_fresh_ones() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
for x in [0, 100, 200, 300] {
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, 50, 80, 80]})).unwrap();
|
||||
}
|
||||
s.execute("edit.selectAll", &json!({})).unwrap();
|
||||
s.execute("arrange.group", &json!({})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [400, 300, 80, 80]})).unwrap();
|
||||
let first = s.doc().unwrap().shapes()[0].id;
|
||||
// Every shape, the group's children too, takes the group's id.
|
||||
s.edit(|doc, sel| {
|
||||
fn all(shapes: &mut [deckcraft_model::Shape], id: deckcraft_model::ShapeId) {
|
||||
for sh in shapes {
|
||||
sh.id = id;
|
||||
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind {
|
||||
all(children, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
all(crate::shapes_mut(doc, sel).unwrap(), first);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!s.doc().unwrap().doc.validate().is_empty(), "the deck repeats ids");
|
||||
let bytes = cmd::file::save_bytes(&s.doc().unwrap().doc, "deckcraft").unwrap();
|
||||
let mut t = Session::new();
|
||||
t.execute("file.openBytes", &json!({"name": "dup.deckcraft", "data": cmd::base64_encode(&bytes)})).unwrap();
|
||||
let st = t.doc().unwrap();
|
||||
assert_eq!(st.doc.validate(), Vec::<String>::new());
|
||||
let slide = st.doc.slides.last().unwrap();
|
||||
let mut ids = vec![];
|
||||
deckcraft_model::walk(&slide.shapes, &mut |sh, _| ids.push(sh.id));
|
||||
assert_eq!(ids.len(), 6, "the group, its four children and the ellipse");
|
||||
assert_eq!(ids[0], first, "the first shape keeps its id");
|
||||
assert!(ids.iter().all(|id| id.0 <= st.doc.next_id), "new ids come from the deck's counter");
|
||||
// Each shape can be selected by its own id now.
|
||||
let last = *ids.last().unwrap();
|
||||
t.execute("slide.go", &json!({"index": st.doc.slides.len() - 1})).unwrap();
|
||||
t.execute("selection.select", &json!({"id": last.0})).unwrap();
|
||||
assert_eq!(t.doc().unwrap().selected_shapes()[0].id, last);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn change_case_spans_formatting_runs() {
|
||||
let setup = |text: &str, bold_to: usize| {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80.0, 80.0, 420.0, 160.0], "text": text})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap() as u32;
|
||||
s.execute("text.edit", &json!({"id": id})).unwrap();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, bold_to]})).unwrap();
|
||||
s.execute("format.bold", &json!({"on": true})).unwrap();
|
||||
(s, id)
|
||||
};
|
||||
let text_of = |s: &Session, id: u32| s.doc().unwrap().shape(deckcraft_model::ShapeId(id)).unwrap().text.clone().unwrap();
|
||||
for (mode, input, want) in [("title", "hello world", "Hello World"), ("sentence", "hello world. bye", "Hello world. Bye")] {
|
||||
// Text range.
|
||||
let (mut s, id) = setup(input, 3);
|
||||
let n = input.chars().count();
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, n]})).unwrap();
|
||||
s.execute("format.case", &json!({"mode": mode})).unwrap();
|
||||
assert_eq!(text_of(&s, id).text(), want, "{mode} range");
|
||||
// Whole shape.
|
||||
let (mut s, id) = setup(input, 3);
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("format.case", &json!({"mode": mode})).unwrap();
|
||||
assert_eq!(text_of(&s, id).text(), want, "{mode} shape");
|
||||
assert_eq!(text_of(&s, id).paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
// A length-changing mapping keeps each piece in its own run.
|
||||
let (mut s, id) = setup("straße", 4);
|
||||
s.execute("text.select", &json!({"anchor": [0, 0], "caret": [0, 6]})).unwrap();
|
||||
s.execute("format.case", &json!({"mode": "upper"})).unwrap();
|
||||
let body = text_of(&s, id);
|
||||
assert_eq!(body.text(), "STRASSE");
|
||||
assert_eq!(body.paragraphs[0].runs[0].text, "STRA");
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flipping_a_rotated_shape_mirrors_its_outline() {
|
||||
let corners = |s: &Session, id: deckcraft_model::ShapeId| {
|
||||
let x = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap();
|
||||
let a = x.affine();
|
||||
[(0.0, 0.0), (x.w, 0.0), (x.w, x.h), (0.0, x.h)].map(|(px, py)| {
|
||||
let p = a * deckcraft_geom::Point::new(px, py);
|
||||
((p.x * 100.0).round() as i64, (p.y * 100.0).round() as i64)
|
||||
})
|
||||
};
|
||||
let sorted = |mut c: [(i64, i64); 4]| {
|
||||
c.sort_unstable();
|
||||
c
|
||||
};
|
||||
for (cmd, horizontal) in [("arrange.flipHorizontal", true), ("arrange.flipVertical", false)] {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 200, 50]})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
s.execute("shape.rotate", &json!({"deg": 30})).unwrap();
|
||||
let before = corners(&s, id);
|
||||
// Mirror about the box's centre line (centre is 200, 125).
|
||||
let mirrored = before.map(|(x, y)| if horizontal { (40000 - x, y) } else { (x, 25000 - y) });
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
assert_eq!(sorted(corners(&s, id)), sorted(mirrored), "{cmd} of a 30° rotated rectangle is not its mirror image");
|
||||
let rot = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().rot;
|
||||
assert!((rot - 330.0).abs() < 1e-9, "{cmd} negates the rotation, got {rot}");
|
||||
s.execute(cmd, &json!({})).unwrap();
|
||||
assert_eq!(sorted(corners(&s, id)), sorted(before), "{cmd} twice restores the outline");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_geometry_commands_limit_size_and_position() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "moon", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
let xfrm = |s: &Session| s.doc().unwrap().selected_shapes()[0].xfrm.unwrap();
|
||||
let within = |s: &Session| {
|
||||
let x = xfrm(s);
|
||||
[x.w, x.h].iter().all(|v| v.is_finite() && (0.0..=15_840.0).contains(v)) && [x.x, x.y].iter().all(|v| v.is_finite() && v.abs() <= 100_000.0)
|
||||
};
|
||||
s.execute("shape.setBounds", &json!({"x": 1e100, "y": -1e100, "w": 100000, "h": 1e100})).unwrap();
|
||||
let x = xfrm(&s);
|
||||
assert!(within(&s), "setBounds stored {x:?}");
|
||||
s.execute("shape.resize", &json!({"w": 10, "h": 10})).unwrap();
|
||||
s.execute("shape.resize", &json!({"h": 1e100})).unwrap();
|
||||
let x = xfrm(&s);
|
||||
assert!(within(&s), "resize stored {x:?}");
|
||||
s.execute("shape.resize", &json!({"h": 1e100, "lockAspect": true})).unwrap();
|
||||
let x = xfrm(&s);
|
||||
assert!(within(&s), "locked resize stored {x:?}");
|
||||
s.execute("shape.move", &json!({"x": 1e100, "y": -1e100})).unwrap();
|
||||
let x = xfrm(&s);
|
||||
assert!(within(&s), "move stored {x:?}");
|
||||
// A move that overflows to infinity lands on the limit, not back at 0.
|
||||
s.execute("shape.move", &json!({"x": 1e308, "dx": 1e308})).unwrap();
|
||||
assert_eq!(xfrm(&s).x, 100_000.0);
|
||||
// A rotated shape resizes about its centre and still stays inside the limit.
|
||||
s.execute("shape.setBounds", &json!({"x": 99_000, "y": 0, "w": 10, "h": 10})).unwrap();
|
||||
s.execute("shape.rotate", &json!({"deg": 45})).unwrap();
|
||||
s.execute("shape.resize", &json!({"w": 1e100})).unwrap();
|
||||
let x = xfrm(&s);
|
||||
assert!(within(&s), "rotated resize stored {x:?}");
|
||||
}
|
||||
|
||||
/// Edit Points turns a preset into a freeform: one undo step, so Undo brings the preset back
|
||||
/// (and leaves Edit Points), Redo the freeform; leaving Edit Points adds no step.
|
||||
#[test]
|
||||
fn edit_points_conversion_is_one_undo_step() {
|
||||
use deckcraft_model::Geom;
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
s.execute("shape.insert", &json!({"preset": "star5", "rect": [100, 100, 200, 100]})).unwrap();
|
||||
let preset = |s: &Session| matches!(s.doc().unwrap().selected_shapes()[0].geom, Geom::Preset { .. });
|
||||
s.execute("shape.editPoints", &json!({"on": true})).unwrap();
|
||||
assert!(!preset(&s), "Edit Points made a freeform");
|
||||
assert!(s.tool.editing_points(s.doc().unwrap()).is_some());
|
||||
s.execute("shape.editPoints", &json!({"on": false})).unwrap();
|
||||
// Leaving changed nothing, so the next undo is the conversion itself.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(preset(&s), "Undo brings the star back");
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert!(!preset(&s), "Redo makes it a freeform again");
|
||||
// Undo while still editing points ends Edit Points: the star has no points to edit.
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("shape.editPoints", &json!({"on": true})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(preset(&s));
|
||||
assert!(s.tool.editing_points(s.doc().unwrap()).is_none());
|
||||
// A freeform needs no conversion: entering Edit Points again adds no undo step.
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
s.execute("shape.editPoints", &json!({"on": false})).unwrap();
|
||||
s.execute("shape.editPoints", &json!({"on": true})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(preset(&s), "the only step was the conversion");
|
||||
}
|
||||
|
||||
/// #931: while the text of a placeholder is being edited, a shape in front of it still takes a
|
||||
/// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret.
|
||||
#[test]
|
||||
fn a_shape_in_front_of_edited_text_takes_the_click() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "titleAndContent"})).unwrap();
|
||||
let square = s.execute("shape.insert", &json!({"preset": "rect", "rect": [380, 260, 200, 120]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 4.0, pressure: None };
|
||||
let click = |s: &mut Session, x: f64, y: f64| {
|
||||
s.pointer(ev(tools::PointerKind::Down, x, y)).unwrap();
|
||||
s.pointer(ev(tools::PointerKind::Up, x, y)).unwrap();
|
||||
};
|
||||
// The content placeholder's edge: its (empty) text is now being edited.
|
||||
click(&mut s, 66.0, 300.0);
|
||||
let body = s.doc().unwrap().selection.text.as_ref().map(|t| t.shape).expect("editing the content placeholder");
|
||||
assert_ne!(body.0 as u64, square);
|
||||
// A click on the text away from the square keeps editing it.
|
||||
click(&mut s, 200.0, 300.0);
|
||||
assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape), Some(body));
|
||||
// A click on the square, in front of the text, selects the square.
|
||||
click(&mut s, 480.0, 320.0);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.shapes.iter().map(|id| id.0 as u64).collect::<Vec<_>>(), vec![square]);
|
||||
assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 == square), "{:?}", st.selection.text);
|
||||
|
||||
// A shape over the edited box's edge takes the click there too: the edge would otherwise drag
|
||||
// the box.
|
||||
let edge = {
|
||||
let st = s.doc().unwrap();
|
||||
let x = cmd::xfrm_of(&st.doc, &st.selection, st.shape(body).unwrap());
|
||||
(x.x, x.y + x.h / 2.0)
|
||||
};
|
||||
let corner =
|
||||
s.execute("shape.insert", &json!({"preset": "ellipse", "rect": [edge.0 - 10.0, edge.1 - 10.0, 20, 20]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": body.0})).unwrap();
|
||||
// On the box's left edge, inside the band that drags it, clear of its middle handle (handles
|
||||
// stay on top, as in PowerPoint).
|
||||
click(&mut s, edge.0 + 1.0, edge.1 - 9.0);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(st.selection.shapes.iter().map(|id| id.0 as u64).collect::<Vec<_>>(), vec![corner], "{:?}", st.selection);
|
||||
|
||||
// Text inside a group: a member in front of it takes the click instead of the caret.
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let tb =
|
||||
s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 150], "text": "Grouped text"})).unwrap()["id"].as_u64().unwrap();
|
||||
let cover = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 150, 60, 40]})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("arrange.group", &json!({"ids": [tb, cover]})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": tb})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().selection.text.as_ref().map(|t| t.shape.0 as u64), Some(tb));
|
||||
click(&mut s, 230.0, 170.0);
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.selection.text.as_ref().is_none_or(|t| t.shape.0 as u64 != tb), "the click on the member in front left the text: {:?}", st.selection);
|
||||
}
|
||||
|
||||
@@ -280,7 +280,12 @@ impl ToolState {
|
||||
|
||||
/// The shape in Edit Points, while it is still the one selected shape.
|
||||
pub fn editing_points(&self, st: &crate::DocState) -> Option<ShapeId> {
|
||||
self.edit_points.filter(|id| st.selection.shapes.as_slice() == [*id] && st.selection.text.is_none() && st.shape(*id).is_some())
|
||||
// Only a freeform has points: after Undo takes the conversion back, Edit Points is over.
|
||||
self.edit_points.filter(|id| {
|
||||
st.selection.shapes.as_slice() == [*id]
|
||||
&& st.selection.text.is_none()
|
||||
&& st.shape(*id).is_some_and(|sh| matches!(sh.geom, deckcraft_model::Geom::Custom { .. }))
|
||||
})
|
||||
}
|
||||
|
||||
/// A freeform path is being drawn (Enter/Escape/double-click finish it).
|
||||
@@ -409,10 +414,12 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The frame of the text being edited: grabbing it moves the box.
|
||||
// The frame of the text being edited: grabbing it moves the box, unless a shape in front
|
||||
// of the text covers that spot (#931).
|
||||
if let Some(e) = edited_box(st)
|
||||
&& e.movable
|
||||
&& edit_zone(&e.xfrm, p, tol) == EditZone::Frame
|
||||
&& !shape_in_front(st, st.shapes(), e.id, p, tol, 0)
|
||||
{
|
||||
return Some(Hit::Frame { id: e.id });
|
||||
}
|
||||
@@ -1228,7 +1235,10 @@ impl Session {
|
||||
None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside),
|
||||
Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p),
|
||||
};
|
||||
// A shape in front of the edited text takes the click, as in PowerPoint (#931).
|
||||
let in_front = shape_in_front(st, st.shapes(), t.shape, p, tol, 0);
|
||||
if !inside
|
||||
|| in_front
|
||||
|| matches!(
|
||||
self.hit_test(p, tol),
|
||||
Some(
|
||||
@@ -1650,6 +1660,27 @@ struct EditedBox {
|
||||
movable: bool,
|
||||
}
|
||||
|
||||
/// Whether a visible shape drawn in front of `target` is under `p`: the shapes above it in its
|
||||
/// list, topmost first, and inside a group that holds it, the members above it.
|
||||
fn shape_in_front(st: &crate::DocState, shapes: &[Shape], target: ShapeId, p: Point, tol: f64, depth: usize) -> bool {
|
||||
if depth > 64 {
|
||||
return false;
|
||||
}
|
||||
for s in shapes.iter().rev() {
|
||||
if s.id == target {
|
||||
return false;
|
||||
}
|
||||
if deckcraft_model::find_shape(std::slice::from_ref(s), target).is_some() {
|
||||
// The group holding the text: look among its members.
|
||||
return shape_in_front(st, s.children(), target, p, tol, depth + 1);
|
||||
}
|
||||
if !s.hidden && hit_shape(st, s, p, tol).is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn edited_box(st: &crate::DocState) -> Option<EditedBox> {
|
||||
let t = st.selection.text.as_ref()?;
|
||||
if t.notes || t.cell.is_some() {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
//! Native saves must not discard an imported chart's opaque package parts.
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_engine::cmd::{base64_decode, base64_encode};
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind, defaults};
|
||||
use serde_json::json;
|
||||
|
||||
const WORKBOOK: &[u8] = b"original-synthetic-workbook\0\xff\x80";
|
||||
const DRAWING: &[u8] = br#"<c:userShapes xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"/>"#;
|
||||
const RT: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
|
||||
|
||||
fn chart(p: &Presentation) -> &Chart {
|
||||
let ShapeKind::Chart(c) = &p.slides[0].shapes[0].kind else { panic!("chart") };
|
||||
c
|
||||
}
|
||||
|
||||
fn source() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let id = ShapeId(p.alloc_id().expect("id"));
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape {
|
||||
id,
|
||||
name: "Owned chart".into(),
|
||||
xfrm: Some(Xfrm::new(40.0, 40.0, 400.0, 300.0)),
|
||||
kind: ShapeKind::Chart(Box::new(Chart::sample(ChartType::Pie))),
|
||||
..Default::default()
|
||||
}];
|
||||
// Start with our own chart XML, then give it two original, synthetic package parts.
|
||||
let mut p = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("import");
|
||||
let ShapeKind::Chart(c) = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("chart") };
|
||||
c.raw = c
|
||||
.raw
|
||||
.as_ref()
|
||||
.map(|raw| raw.replace("</c:chartSpace>", r#"<c:externalData r:id="rWorkbook"/><c:userShapes r:id="rDrawing"/></c:chartSpace>"#));
|
||||
c.kept = ChartKept {
|
||||
rels: vec![
|
||||
ChartRel {
|
||||
id: "rWorkbook".into(),
|
||||
rel_type: format!("{RT}/package"),
|
||||
target: "../embeddings/owned.xlsx".into(),
|
||||
part: "ppt/embeddings/owned.xlsx".into(),
|
||||
external: false,
|
||||
},
|
||||
ChartRel {
|
||||
id: "rDrawing".into(),
|
||||
rel_type: format!("{RT}/chartUserShapes"),
|
||||
target: "../drawings/owned.xml".into(),
|
||||
part: "ppt/drawings/owned.xml".into(),
|
||||
external: false,
|
||||
},
|
||||
],
|
||||
parts: vec![
|
||||
ChartPart {
|
||||
name: "ppt/embeddings/owned.xlsx".into(),
|
||||
content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(),
|
||||
bytes: WORKBOOK.to_vec(),
|
||||
native_entry: None,
|
||||
},
|
||||
ChartPart {
|
||||
name: "ppt/drawings/owned.xml".into(),
|
||||
content_type: "application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml".into(),
|
||||
bytes: DRAWING.to_vec(),
|
||||
native_entry: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
deckcraft_pptx::export(&p).expect("PPTX fixture")
|
||||
}
|
||||
|
||||
fn open(bytes: &[u8], name: &str) -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.openBytes", &json!({"name":name,"data":base64_encode(bytes)})).expect("open command");
|
||||
s
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, format: &str) -> Vec<u8> {
|
||||
let out = s.execute("file.saveBytes", &json!({"format":format})).expect("save command");
|
||||
base64_decode(out["data"].as_str().expect("data")).expect("base64")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pptx_native_edit_save_reopen_keeps_chart_parts() {
|
||||
let mut s = open(&source(), "owned.pptx");
|
||||
let kept = chart(&s.doc().expect("doc").doc).kept.clone();
|
||||
assert_eq!(kept.rels.len(), 2, "fixture relationships must have been imported");
|
||||
assert_eq!(kept.parts.len(), 2);
|
||||
s.execute("file.properties", &json!({"title":"Edited title"})).expect("edit command");
|
||||
for _ in 0..2 {
|
||||
s = open(&save(&mut s, "deckcraft"), "owned.deckcraft");
|
||||
let p = &s.doc().expect("doc").doc;
|
||||
assert_eq!(p.props.title, "Edited title");
|
||||
assert_eq!(chart(p).kept, kept);
|
||||
}
|
||||
let bytes = save(&mut s, "pptx");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(&bytes)).expect("zip");
|
||||
let mut payloads = vec![];
|
||||
for i in 0..z.len() {
|
||||
let mut f = z.by_index(i).expect("entry");
|
||||
let mut data = vec![];
|
||||
f.read_to_end(&mut data).expect("read");
|
||||
payloads.push((f.name().to_string(), data));
|
||||
}
|
||||
assert!(payloads.iter().any(|(n, d)| n.starts_with("ppt/embeddings/") && d == WORKBOOK));
|
||||
assert!(payloads.iter().any(|(n, d)| n.starts_with("ppt/drawings/") && d == DRAWING));
|
||||
let back = deckcraft_pptx::import(&bytes).expect("reopen PPTX");
|
||||
assert_eq!(chart(&back).kept.rels.len(), 2);
|
||||
for part in &kept.parts {
|
||||
assert!(chart(&back).kept.parts.iter().any(|p| p.content_type == part.content_type && p.bytes == part.bytes));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_chart_after_native_reopen_does_not_export_a_stale_workbook() {
|
||||
let mut s = open(&source(), "owned.pptx");
|
||||
s = open(&save(&mut s, "deckcraft"), "owned.deckcraft");
|
||||
let doc = s.doc_mut().expect("doc");
|
||||
let p = Arc::make_mut(&mut doc.doc);
|
||||
let ShapeKind::Chart(c) = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("chart") };
|
||||
assert_eq!(c.kept.rels.len(), 2);
|
||||
c.series[0].values[0] = Some(99.0);
|
||||
let bytes = save(&mut s, "pptx");
|
||||
let back = deckcraft_pptx::import(&bytes).expect("PPTX");
|
||||
assert_eq!(chart(&back).series[0].values[0], Some(99.0));
|
||||
assert!(chart(&back).kept.parts.is_empty(), "stale source workbook must not be attached to edited chart data");
|
||||
}
|
||||
@@ -59,6 +59,34 @@ fn start(p: &mut Presentation) {
|
||||
p.slides.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_trigger_reveals_then_hides_on_separate_clicks() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
start(&mut p);
|
||||
let button = shape(&mut p, "Button", "Toggle", 20.0, 20.0, 100.0, 40.0);
|
||||
let target = shape(&mut p, "Target", "Answer", 200.0, 100.0, 200.0, 80.0);
|
||||
let animations = vec![
|
||||
Animation {
|
||||
shape: target.id,
|
||||
trigger: Some(button.id),
|
||||
effect: "appear".into(),
|
||||
start: AnimStart::OnClick,
|
||||
duration_ms: 0,
|
||||
..Default::default()
|
||||
},
|
||||
disappear_on_click(button.id, target.id),
|
||||
];
|
||||
add_slide(&mut p, vec![button, target], animations, false);
|
||||
let mut show = Show::open(&p);
|
||||
assert!(!show.shown("Target"));
|
||||
for _ in 0..2 {
|
||||
assert_eq!(show.click("Button"), Click::Animated);
|
||||
assert!(show.shown("Target"), "first click reveals the answer until the next click");
|
||||
assert_eq!(show.click("Button"), Click::Animated);
|
||||
assert!(!show.shown("Target"), "second click hides the answer");
|
||||
}
|
||||
}
|
||||
|
||||
/// The slide show window's click handling, on a deck read from a PPTX file.
|
||||
struct Show {
|
||||
pres: Presentation,
|
||||
|
||||
@@ -188,7 +188,12 @@ impl FontDb {
|
||||
// A letter with marks the run's font lacks (stacked combining marks): a loaded plain
|
||||
// face that draws the whole cluster, as PowerPoint draws the marks, before the run's
|
||||
// font with missing-glyph boxes.
|
||||
_ => self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned().unwrap_or_else(first),
|
||||
_ => {
|
||||
// Searched before `first` runs: it may load the default family, which needs the
|
||||
// faces' write lock, so the read lock must not be held across it.
|
||||
let found = self.read_faces().iter().find(|f| !f.is_color() && covers_cluster(f, cluster)).cloned();
|
||||
found.unwrap_or_else(first)
|
||||
}
|
||||
};
|
||||
// A pictograph that defaults to text presentation (⚠ ♥ ⬆ ▶) whose first fallback is the
|
||||
// colour emoji font: PowerPoint draws it from a plain symbol font.
|
||||
@@ -277,6 +282,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn falling_back_to_the_default_face_does_not_deadlock() {
|
||||
// An empty chain and a character no loaded face draws: the loaded faces are searched,
|
||||
// then the default family is looked up, which loads it (taking the faces' write lock).
|
||||
// Holding the search's read lock across that lookup hung the thread.
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
let _ = tx.send(db.cascade(&['\u{10FFFD}'], &[], "Regular").family.clone());
|
||||
});
|
||||
let family = rx.recv_timeout(std::time::Duration::from_secs(30)).expect("cascade did not return");
|
||||
assert!(!family.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ligature_carets_never_fail() {
|
||||
let face = FontDb::global().face(crate::DEFAULT_FAMILY, "Regular");
|
||||
|
||||
@@ -1175,17 +1175,40 @@ impl FontDb {
|
||||
None
|
||||
}
|
||||
|
||||
/// A covering fallback accepted by the caller (for example, an interface that cannot use
|
||||
/// colour or very large faces). Rejected faces do not stop the search for another face.
|
||||
/// A policy-specific miss is not stored as a general document-font miss.
|
||||
pub fn fallback_for_filtered(&self, c: char, exclude: u32, accept: impl Fn(&FontFace) -> bool) -> Option<Arc<FontFace>> {
|
||||
if let Some(f) = self.loaded_fallback_filtered(c, exclude, &accept) {
|
||||
return Some(f);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.system_fallback_filtered(c, &|f| f.id != exclude && accept(f), false) {
|
||||
return self.loaded_fallback_filtered(c, exclude, &accept);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn loaded_fallback(&self, c: char, exclude: u32) -> Option<Arc<FontFace>> {
|
||||
self.loaded_fallback_filtered(c, exclude, &|_| true)
|
||||
}
|
||||
|
||||
fn loaded_fallback_filtered(&self, c: char, exclude: u32, accept: &impl Fn(&FontFace) -> bool) -> Option<Arc<FontFace>> {
|
||||
let faces = self.read_faces();
|
||||
let mut order: Vec<&Arc<FontFace>> = faces.iter().filter(|f| f.id != exclude).collect();
|
||||
order.sort_by_key(|f| (!f.family.eq_ignore_ascii_case(FALLBACK_FAMILY), f.italic, (f.weight - 400.0).abs() as i32));
|
||||
order.into_iter().find(|f| f.covers(c)).cloned()
|
||||
order.into_iter().find(|f| f.covers(c) && accept(f)).cloned()
|
||||
}
|
||||
|
||||
/// Load a system font covering `c` (preferred fallback families first, then any cataloged
|
||||
/// file under 40 MB). Returns true if one was loaded. Misses are remembered.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn system_fallback(&self, c: char) -> bool {
|
||||
self.system_fallback_filtered(c, &|_| true, true)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn system_fallback_filtered(&self, c: char, accept: &impl Fn(&FontFace) -> bool, remember_miss: bool) -> bool {
|
||||
if c.is_control() || c.is_whitespace() {
|
||||
return false;
|
||||
}
|
||||
@@ -1196,7 +1219,7 @@ impl FontDb {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c));
|
||||
let covered = |db: &FontDb| db.read_faces().iter().any(|f| f.covers(c) && accept(f));
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
let loading = self.loading.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Another thread may have loaded a face for `c` (faces are only ever added), or found
|
||||
@@ -1229,7 +1252,9 @@ impl FontDb {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
if remember_miss {
|
||||
self.sys.lock().unwrap_or_else(|e| e.into_inner()).misses.insert(c);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
@@ -226,6 +226,35 @@ mod tests {
|
||||
assert!(!shape(&f, "Hello", &[], |c| c).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filtered_fallback_continues_after_a_rejected_loaded_face() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
db.set_system_fallback(false);
|
||||
assert_eq!(db.add_font(include_bytes!("../tests/fixtures/legacy-regular.ttf").to_vec()), 1);
|
||||
let first = db.fallback_for('A', u32::MAX).unwrap();
|
||||
let accepted_family = if first.family == LAST_RESORT_FAMILY { "DeckCraft Legacy" } else { LAST_RESORT_FAMILY };
|
||||
let fallback = db.fallback_for_filtered('A', u32::MAX, |f| f.family == accepted_family).unwrap();
|
||||
assert_eq!(fallback.family, accepted_family);
|
||||
assert!(fallback.covers('A'));
|
||||
assert_ne!(fallback.id(), first.id());
|
||||
assert_eq!(db.fallback_for('A', u32::MAX).unwrap().id(), first.id(), "document fallback order is unchanged");
|
||||
assert!(db.fallback_for_filtered('A', fallback.id(), |f| f.family == accepted_family).is_none(), "the excluded face stays excluded");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn filtered_fallback_loads_an_eligible_system_face_and_does_not_cache_policy_misses() {
|
||||
let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
|
||||
let db = FontDb::with_font_dirs(vec![dir]);
|
||||
let first = db.fallback_for('A', u32::MAX).unwrap();
|
||||
assert_ne!(first.family, "DeckCraft Legacy");
|
||||
assert!(db.fallback_for_filtered('A', u32::MAX, |_| false).is_none());
|
||||
assert!(db.fallback_for('A', u32::MAX).is_some(), "an interface policy miss does not hide document glyphs");
|
||||
let accepted = db.fallback_for_filtered('A', u32::MAX, |f| f.family == "DeckCraft Legacy").unwrap();
|
||||
assert_eq!(accepted.family, "DeckCraft Legacy");
|
||||
assert!(accepted.covers('A'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrent_lookups_all_find_an_installed_family() {
|
||||
// Threads resolving an installed family nobody had loaded yet used to fall back to
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Keep opaque chart payloads out of the JSON manifest and within the media load budget.
|
||||
use std::collections::HashSet;
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::chart::ChartPart;
|
||||
use deckcraft_model::{Master, Presentation, Shape, ShapeKind};
|
||||
|
||||
use crate::{FormatError, MAX_ENTRY, Result, read_entry_max};
|
||||
|
||||
const PREFIX: &str = "chart-parts/";
|
||||
type Payload = (String, String, Vec<u8>);
|
||||
|
||||
pub(super) fn separate(p: &mut Presentation) -> Result<Vec<Payload>> {
|
||||
let mut payloads = Vec::new();
|
||||
visit_parts(p, |part| {
|
||||
// The native namespace is independent of the original OPC names, which may repeat
|
||||
// when charts from several decks are combined. No user-supplied path is extracted.
|
||||
let entry = format!("{PREFIX}{}", payloads.len());
|
||||
part.native_entry = Some(entry.clone());
|
||||
payloads.push((entry, part.content_type.clone(), std::mem::take(&mut part.bytes)));
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(payloads)
|
||||
}
|
||||
|
||||
pub(super) fn restore(
|
||||
p: &mut Presentation,
|
||||
z: &mut zip::ZipArchive<Cursor<&[u8]>>,
|
||||
left: &mut u64,
|
||||
mut items: usize,
|
||||
max_items: usize,
|
||||
damaged: &mut Vec<String>,
|
||||
) -> Result<()> {
|
||||
let mut seen = HashSet::new();
|
||||
visit_parts(p, |part| {
|
||||
if items >= max_items {
|
||||
return Err(FormatError::Damaged(format!("media and chart parts exceed {max_items} items")));
|
||||
}
|
||||
items += 1;
|
||||
if let Some(entry) = part.native_entry.take() {
|
||||
// Accept only names produced by separate(), and inflate each member once.
|
||||
let canonical = entry.strip_prefix(PREFIX).and_then(|s| s.parse::<usize>().ok()).is_some_and(|n| entry == format!("{PREFIX}{n}"));
|
||||
if !canonical || !seen.insert(entry.clone()) || !part.bytes.is_empty() {
|
||||
return Err(FormatError::Damaged("invalid or ambiguous chart part reference".into()));
|
||||
}
|
||||
if z.index_for_name(&entry).is_none() {
|
||||
// Missing members consume no inflation work and can be reported for recovery.
|
||||
damaged.push(format!("{entry}: missing chart part"));
|
||||
return Ok(());
|
||||
}
|
||||
if z.by_name(&entry).is_ok_and(|f| f.size() > *left) {
|
||||
return Err(FormatError::Damaged("embedded media and chart parts are larger than DeckCraft can open".into()));
|
||||
}
|
||||
// A CRC/decode error may already have inflated bytes. Stop on that first error
|
||||
// rather than letting later corrupt members reuse the unchanged byte budget.
|
||||
let bytes = read_entry_max(z, &entry, (*left).min(MAX_ENTRY))?;
|
||||
*left -= bytes.len() as u64;
|
||||
part.bytes = bytes;
|
||||
} else {
|
||||
// Older manifests used JSON byte arrays. Charge those too, after the existing
|
||||
// bounded manifest parser has read them, so resaving migrates them losslessly.
|
||||
let size = part.bytes.len() as u64;
|
||||
if size > (*left).min(MAX_ENTRY) {
|
||||
return Err(FormatError::Damaged("inline chart part is larger than DeckCraft can open".into()));
|
||||
}
|
||||
*left -= size;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn visit_parts(p: &mut Presentation, mut visit: impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
|
||||
// Use an explicit stack for groups. Charts can also live on masters and their layouts,
|
||||
// including the notes and handout masters; none of these owners may leak inline bytes.
|
||||
for slide in &mut p.slides {
|
||||
shapes(&mut Arc::make_mut(slide).shapes, &mut visit)?;
|
||||
}
|
||||
for master in &mut p.masters {
|
||||
master_parts(Arc::make_mut(master), &mut visit)?;
|
||||
}
|
||||
for master in [&mut p.notes_master, &mut p.handout_master].into_iter().flatten() {
|
||||
master_parts(Arc::make_mut(master), &mut visit)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn master_parts(master: &mut Master, visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
|
||||
shapes(&mut master.shapes, visit)?;
|
||||
for layout in &mut master.layouts {
|
||||
shapes(&mut layout.shapes, visit)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shapes(root: &mut [Shape], visit: &mut impl FnMut(&mut ChartPart) -> Result<()>) -> Result<()> {
|
||||
let mut pending = vec![root];
|
||||
while let Some(shapes) = pending.pop() {
|
||||
for shape in shapes {
|
||||
match &mut shape.kind {
|
||||
ShapeKind::Group { children, .. } => pending.push(children),
|
||||
ShapeKind::Chart(chart) => {
|
||||
for part in &mut chart.kept.parts {
|
||||
visit(part)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
use super::*;
|
||||
use deckcraft_model::chart::{Chart, ChartPart, ChartRel, ChartType};
|
||||
use deckcraft_model::{Master, Shape, ShapeId};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const PART: &str = "ppt/embeddings/owned.xlsx";
|
||||
|
||||
fn fixture(payload: Vec<u8>) -> Presentation {
|
||||
// Construct through the old manifest representation: this also exercises compatibility.
|
||||
let mut part: ChartPart = serde_json::from_value(json!({
|
||||
"name": PART, "contentType": "application/zip", "bytes": []
|
||||
}))
|
||||
.unwrap();
|
||||
part.bytes = payload;
|
||||
let mut chart = Chart::sample(ChartType::Pie);
|
||||
chart.kept.rels.push(ChartRel {
|
||||
id: "rWorkbook".into(),
|
||||
rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(),
|
||||
target: "../embeddings/owned.xlsx".into(),
|
||||
part: PART.into(),
|
||||
external: false,
|
||||
});
|
||||
chart.kept.parts.push(part);
|
||||
let mut p = Presentation::default();
|
||||
let child = Shape { id: ShapeId(p.alloc_id().unwrap()), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() };
|
||||
let group = Shape {
|
||||
id: ShapeId(p.alloc_id().unwrap()),
|
||||
kind: ShapeKind::Group { children: vec![child], child: Default::default() },
|
||||
..Default::default()
|
||||
};
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = vec![group];
|
||||
p
|
||||
}
|
||||
|
||||
fn part_json(value: &mut Value) -> Option<&mut Value> {
|
||||
if value.get("name").and_then(Value::as_str) == Some(PART) && value.get("contentType").is_some() {
|
||||
return Some(value);
|
||||
}
|
||||
match value {
|
||||
Value::Array(values) => values.iter_mut().find_map(part_json),
|
||||
Value::Object(values) => values.values_mut().find_map(part_json),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn rewrite_manifest(bytes: &[u8], edit: impl FnOnce(&mut Value)) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
let mut json: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
|
||||
edit(&mut json);
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let f = z.by_index(i).unwrap();
|
||||
if f.name() == "presentation.json" {
|
||||
out.start_file("presentation.json", zip::write::SimpleFileOptions::default()).unwrap();
|
||||
out.write_all(&serde_json::to_vec(&json).unwrap()).unwrap();
|
||||
} else {
|
||||
out.raw_copy_file(f).unwrap();
|
||||
}
|
||||
}
|
||||
out.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_chart_payload_is_a_referenced_zip_entry() {
|
||||
let payload = vec![200; 3 * 1024 * 1024];
|
||||
let p = fixture(payload.clone());
|
||||
let bytes = save(&p).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
assert!(z.by_name("presentation.json").unwrap().size() < 128 * 1024, "binary chart bytes must not become JSON numbers");
|
||||
let mut manifest: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
|
||||
let part = part_json(&mut manifest).unwrap();
|
||||
assert!(part.get("bytes").is_none(), "new manifests keep only metadata and a reference");
|
||||
let entry = part["nativeEntry"].as_str().unwrap();
|
||||
assert!(entry.starts_with("chart-parts/"));
|
||||
assert_eq!(read_entry(&mut z, entry).unwrap(), payload);
|
||||
assert!(read_entry_max(&mut z, entry, payload.len() as u64 - 1).is_err());
|
||||
assert_eq!(load(&bytes).unwrap(), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_parts_share_media_byte_and_item_budgets() {
|
||||
let mut p = fixture(vec![7; 600]);
|
||||
p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
|
||||
let bytes = save(&p).unwrap();
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
|
||||
assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget");
|
||||
assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget");
|
||||
let duplicated = rewrite_manifest(&bytes, |manifest| {
|
||||
let media = manifest["presentation"]["media"].as_array_mut().unwrap();
|
||||
media.push(media[0].clone());
|
||||
});
|
||||
assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_inline_chart_bytes_are_budgeted_and_preserved() {
|
||||
let mut p = fixture(vec![9; 600]);
|
||||
p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
|
||||
let legacy = rewrite_manifest(&save(&p).unwrap(), |manifest| {
|
||||
let part = part_json(manifest).unwrap().as_object_mut().unwrap();
|
||||
part.remove("nativeEntry");
|
||||
part.insert("bytes".into(), json!(vec![9_u8; 600]));
|
||||
});
|
||||
assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p);
|
||||
assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err());
|
||||
assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_payloads_in_every_shape_owner_get_distinct_entries() {
|
||||
let mut p = fixture(vec![42; 4096]);
|
||||
let shapes = p.slides[0].shapes.clone();
|
||||
let master = Arc::make_mut(&mut p.masters[0]);
|
||||
master.shapes = shapes.clone();
|
||||
master.layouts[0].shapes = shapes.clone();
|
||||
p.notes_master = Some(Arc::new(Master { shapes: shapes.clone(), ..Default::default() }));
|
||||
p.handout_master = Some(Arc::new(Master { shapes, ..Default::default() }));
|
||||
let bytes = save(&p).unwrap();
|
||||
let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
assert_eq!(z.file_names().filter(|name| name.starts_with("chart-parts/")).count(), 5);
|
||||
assert_eq!(load(&bytes).unwrap(), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_corrupt_chart_payload_is_reported() {
|
||||
let p = fixture(b"SYNTHETIC-CHART-PAYLOAD-0123456789".to_vec());
|
||||
let bytes = save(&p).unwrap();
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
let mut json: Value = serde_json::from_slice(&read_entry(&mut z, "presentation.json").unwrap()).unwrap();
|
||||
let name = part_json(&mut json).unwrap()["nativeEntry"].as_str().unwrap().to_owned();
|
||||
let start = z.by_name(&name).unwrap().data_start() as usize;
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for i in 0..z.len() {
|
||||
let f = z.by_index(i).unwrap();
|
||||
if f.name() != name {
|
||||
out.raw_copy_file(f).unwrap();
|
||||
}
|
||||
}
|
||||
let missing = out.finish().unwrap().into_inner();
|
||||
let (q, damaged) = load_checked(&missing).unwrap();
|
||||
assert_eq!(damaged.len(), 1, "{damaged:?}");
|
||||
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
|
||||
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
|
||||
assert!(chart.kept.parts[0].bytes.is_empty());
|
||||
let mut corrupt = bytes.clone();
|
||||
corrupt[start] ^= 0xff;
|
||||
assert!(load_checked(&corrupt).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_references_cannot_alias_other_members_or_mix_inline_bytes() {
|
||||
let p = fixture(vec![3; 10]);
|
||||
let bytes = save(&p).unwrap();
|
||||
for entry in ["media/1", "chart-parts/../0", "chart-parts/00", "chart-parts/-1"] {
|
||||
let changed = rewrite_manifest(&bytes, |manifest| {
|
||||
part_json(manifest).unwrap()["nativeEntry"] = json!(entry);
|
||||
});
|
||||
assert!(load(&changed).is_err(), "accepted {entry}");
|
||||
}
|
||||
let ambiguous = rewrite_manifest(&bytes, |manifest| {
|
||||
part_json(manifest).unwrap()["bytes"] = json!([3]);
|
||||
});
|
||||
assert!(load(&ambiguous).is_err());
|
||||
|
||||
let mut p = p;
|
||||
let ShapeKind::Group { children, .. } = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("group") };
|
||||
let ShapeKind::Chart(chart) = &mut children[0].kind else { panic!("chart") };
|
||||
chart.kept.parts.push(chart.kept.parts[0].clone());
|
||||
let aliased = rewrite_manifest(&save(&p).unwrap(), |manifest| {
|
||||
fn alias(value: &mut Value) {
|
||||
if let Some(entry) = value.get_mut("nativeEntry") {
|
||||
*entry = json!("chart-parts/0");
|
||||
}
|
||||
match value {
|
||||
Value::Array(values) => values.iter_mut().for_each(alias),
|
||||
Value::Object(values) => values.values_mut().for_each(alias),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
alias(manifest);
|
||||
});
|
||||
assert!(load(&aliased).is_err(), "one ZIP member must not be inflated repeatedly");
|
||||
}
|
||||
|
||||
fn with_two_parts() -> Presentation {
|
||||
let mut p = fixture(vec![3; 10]);
|
||||
let ShapeKind::Group { children, .. } = &mut Arc::make_mut(&mut p.slides[0]).shapes[0].kind else { panic!("group") };
|
||||
let ShapeKind::Chart(chart) = &mut children[0].kind else { panic!("chart") };
|
||||
let mut second = chart.kept.parts[0].clone();
|
||||
second.bytes = vec![4; 10];
|
||||
chart.kept.parts.push(second);
|
||||
p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() {
|
||||
let mut bytes = save(&with_two_parts()).unwrap();
|
||||
let offsets: Vec<_> = {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
(0..2).map(|i| z.by_name(&format!("chart-parts/{i}")).unwrap().data_start() as usize).collect()
|
||||
};
|
||||
for offset in offsets {
|
||||
bytes[offset] ^= 0xff;
|
||||
}
|
||||
// A failed CRC may already have inflated bytes. Continuing with the same remaining
|
||||
// budget would let each corrupt member consume that work budget again.
|
||||
let result = load_within(&bytes, Budget { total: 10, items: 2 });
|
||||
assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget");
|
||||
assert!(result.unwrap_err().to_string().contains("chart-parts/0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_physical_zip_names_are_indexed_and_inflated_once() {
|
||||
let mut bytes = save(&with_two_parts()).unwrap();
|
||||
// ZipWriter refuses duplicate names. Forge equal-length local/central-directory names
|
||||
// instead, to exercise ZipArchive's duplicate-name indexing on an untrusted archive.
|
||||
let original = b"chart-parts/1";
|
||||
let mut replaced = 0;
|
||||
for offset in 0..=bytes.len() - original.len() {
|
||||
if &bytes[offset..offset + original.len()] == original {
|
||||
bytes[offset + original.len() - 1] = b'0';
|
||||
replaced += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(replaced, 2);
|
||||
let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
|
||||
assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1);
|
||||
let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap();
|
||||
assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again");
|
||||
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
|
||||
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
|
||||
assert_eq!(chart.kept.parts[0].bytes, vec![4; 10]);
|
||||
assert!(chart.kept.parts[1].bytes.is_empty());
|
||||
}
|
||||
+297
-17
@@ -1,16 +1,20 @@
|
||||
//! The native `.deckcraft` format: a zip holding `presentation.json` (the model, minus media
|
||||
//! bytes), `media/<id>` files with the embedded media, and `mimetype`. Readers ignore unknown
|
||||
//! bytes), `media/<id>` and `chart-parts/<index>` binary entries, and `mimetype`. Readers ignore unknown
|
||||
//! fields, so newer files open in older builds with what those builds understand.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod chart_parts;
|
||||
mod finite;
|
||||
|
||||
#[cfg(test)]
|
||||
mod chart_parts_tests;
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::text::{Paragraph, TextBody};
|
||||
use deckcraft_model::{LayoutType, Presentation, defaults};
|
||||
use deckcraft_model::{LayoutType, Presentation, ShapeKind, defaults};
|
||||
|
||||
pub const MIMETYPE: &str = "application/vnd.storyteller.deckcraft+zip";
|
||||
/// Files written before the SlideCraft → DeckCraft rename (`.slidecraft`) are still read.
|
||||
@@ -20,9 +24,9 @@ pub const EXTENSION: &str = "deckcraft";
|
||||
pub const VERSION: u32 = 1;
|
||||
/// Largest entry we will inflate (guards against zip bombs).
|
||||
const MAX_ENTRY: u64 = 2 << 30;
|
||||
/// All embedded media together, inflated (#393).
|
||||
/// All embedded media and chart sidecars together, inflated (#393).
|
||||
const MAX_TOTAL_MEDIA: u64 = 4 << 30;
|
||||
/// Media items one file may list.
|
||||
/// Media items and chart sidecar parts one file may list.
|
||||
const MAX_MEDIA_ITEMS: usize = 100_000;
|
||||
|
||||
/// What one load may inflate.
|
||||
@@ -62,12 +66,10 @@ pub fn save(p: &Presentation) -> Result<Vec<u8>> {
|
||||
let w = |e: zip::result::ZipError| FormatError::Write(e.to_string());
|
||||
z.start_file("mimetype", stored).map_err(w)?;
|
||||
z.write_all(MIMETYPE.as_bytes()).map_err(|e| FormatError::Write(e.to_string()))?;
|
||||
let m = Manifest {
|
||||
format: "deckcraft".into(),
|
||||
version: VERSION,
|
||||
generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")),
|
||||
presentation: p.clone(),
|
||||
};
|
||||
let mut presentation = p.clone();
|
||||
let parts = chart_parts::separate(&mut presentation)?;
|
||||
let m =
|
||||
Manifest { format: "deckcraft".into(), version: VERSION, generator: format!("DeckCraft {}", env!("CARGO_PKG_VERSION")), presentation };
|
||||
let json = serde_json::to_vec(&finite::Finite(&m)).map_err(|e| FormatError::Write(e.to_string()))?;
|
||||
z.start_file("presentation.json", deflate).map_err(w)?;
|
||||
z.write_all(&json).map_err(|e| FormatError::Write(e.to_string()))?;
|
||||
@@ -80,6 +82,11 @@ pub fn save(p: &Presentation) -> Result<Vec<u8>> {
|
||||
z.start_file(format!("media/{}", item.id.0), opts).map_err(w)?;
|
||||
z.write_all(&item.data).map_err(|e| FormatError::Write(e.to_string()))?;
|
||||
}
|
||||
for (name, content_type, bytes) in parts {
|
||||
let opts = if is_compressed(&content_type) { stored } else { deflate };
|
||||
z.start_file(name, opts).map_err(w)?;
|
||||
z.write_all(&bytes).map_err(|e| FormatError::Write(e.to_string()))?;
|
||||
}
|
||||
z.finish().map_err(w)?;
|
||||
}
|
||||
Ok(buf.into_inner())
|
||||
@@ -119,14 +126,15 @@ fn read_entry_max(z: &mut zip::ZipArchive<Cursor<&[u8]>>, name: &str, max: u64)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Read a presentation. Embedded media that is missing or damaged is left empty; use
|
||||
/// [`load_checked`] to learn which.
|
||||
/// Read a presentation. Missing media/chart parts are left empty; use [`load_checked`] to
|
||||
/// learn which. A damaged chart member refuses the file before further chart inflation.
|
||||
pub fn load(bytes: &[u8]) -> Result<Presentation> {
|
||||
load_checked(bytes).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// Read a presentation and list the embedded media that couldn't be read (missing, too large
|
||||
/// or failing its CRC), one message each. Linked media saved without its bytes isn't damage.
|
||||
/// Read a presentation and list media that couldn't be read, plus missing chart members.
|
||||
/// A chart member that is present but fails decoding, CRC or its entry limit refuses the file.
|
||||
/// Linked media saved without its bytes isn't damage.
|
||||
pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>)> {
|
||||
load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS })
|
||||
}
|
||||
@@ -140,7 +148,8 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String
|
||||
}
|
||||
let mut p = m.presentation;
|
||||
let mut damaged = vec![];
|
||||
if p.media.len() > budget.items {
|
||||
let items = p.media.len();
|
||||
if items > budget.items {
|
||||
return Err(FormatError::Damaged(format!("{} media items (at most {})", p.media.len(), budget.items)));
|
||||
}
|
||||
// A repeated id names the same member: keep the first item, so the member is inflated once
|
||||
@@ -163,6 +172,7 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec<String
|
||||
Err(e) => damaged.push(e.to_string()),
|
||||
}
|
||||
}
|
||||
chart_parts::restore(&mut p, &mut z, &mut left, items, budget.items, &mut damaged)?;
|
||||
repair(&mut p);
|
||||
Ok((p, damaged))
|
||||
}
|
||||
@@ -197,6 +207,7 @@ pub fn repair(p: &mut Presentation) {
|
||||
}
|
||||
}
|
||||
}
|
||||
reid_duplicate_shapes(p);
|
||||
p.slide_size = sane_slide_size(p.slide_size);
|
||||
// The notes page only lays out notes, but its size divides too.
|
||||
if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) {
|
||||
@@ -205,6 +216,57 @@ pub fn repair(p: &mut Presentation) {
|
||||
sane_numbers(p);
|
||||
}
|
||||
|
||||
/// Give a shape that repeats an id already used on its slide, layout or master (groups included)
|
||||
/// a fresh one, so edits, selection and the Selection Pane find one shape per id. The first keeps
|
||||
/// its id, and with it what animations and connectors point at. An exhausted id space leaves the
|
||||
/// rest as they are (#407).
|
||||
fn reid_duplicate_shapes(p: &mut Presentation) {
|
||||
fn has_duplicates(shapes: &[deckcraft_model::Shape]) -> bool {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut dup = false;
|
||||
deckcraft_model::walk(shapes, &mut |sh, _| dup |= !seen.insert(sh.id));
|
||||
dup
|
||||
}
|
||||
fn reid(shapes: &mut [deckcraft_model::Shape], seen: &mut std::collections::HashSet<deckcraft_model::ShapeId>, next: &mut u32, depth: usize) {
|
||||
if depth > 64 {
|
||||
return;
|
||||
}
|
||||
for sh in shapes {
|
||||
if !seen.insert(sh.id)
|
||||
&& let Some(id) = (*next).max(1).checked_add(1)
|
||||
{
|
||||
*next = id;
|
||||
sh.id = deckcraft_model::ShapeId(id);
|
||||
seen.insert(sh.id);
|
||||
}
|
||||
if let deckcraft_model::ShapeKind::Group { children, .. } = &mut sh.kind {
|
||||
reid(children, seen, next, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut next = p.next_id;
|
||||
let mut fix = |shapes: &mut Vec<deckcraft_model::Shape>| {
|
||||
if has_duplicates(shapes) {
|
||||
reid(shapes, &mut std::collections::HashSet::new(), &mut next, 0);
|
||||
}
|
||||
};
|
||||
for s in &mut p.slides {
|
||||
if has_duplicates(&s.shapes) {
|
||||
fix(&mut Arc::make_mut(s).shapes);
|
||||
}
|
||||
}
|
||||
for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) {
|
||||
if has_duplicates(&m.shapes) || m.layouts.iter().any(|l| has_duplicates(&l.shapes)) {
|
||||
let m = Arc::make_mut(m);
|
||||
fix(&mut m.shapes);
|
||||
for l in &mut m.layouts {
|
||||
fix(&mut l.shapes);
|
||||
}
|
||||
}
|
||||
}
|
||||
p.next_id = next;
|
||||
}
|
||||
|
||||
/// Smallest and largest slide side, in points: PowerPoint's 1–56 in, as Design › Slide Size allows.
|
||||
pub const SLIDE_SIDE: std::ops::RangeInclusive<f64> = 72.0..=4032.0;
|
||||
|
||||
@@ -350,6 +412,66 @@ fn sane_xfrm(x: &mut deckcraft_model::Xfrm) {
|
||||
|
||||
fn sane_run(p: &mut deckcraft_model::text::RunProps) {
|
||||
p.size = p.size.filter(|v| v.is_finite() && *v > 0.0).map(|v| v.clamp(1.0, 4000.0));
|
||||
for s in p.shadow.iter_mut().chain(p.inner_shadow.iter_mut()) {
|
||||
sane_shadow(s);
|
||||
}
|
||||
if let Some(g) = p.glow.as_mut() {
|
||||
g.radius = effect_size(g.radius, MAX_GLOW);
|
||||
}
|
||||
if let Some(r) = p.reflection.as_mut() {
|
||||
sane_reflection(r);
|
||||
}
|
||||
p.soft_edge = p.soft_edge.map(|v| effect_size(v, MAX_SOFT_EDGE));
|
||||
}
|
||||
|
||||
/// The largest effect sizes, in points: what Format Shape and `shape.effects` allow. A file can
|
||||
/// say anything, and the renderer's memory grows with these (a glow of 10^18 pt asked for 34 GB).
|
||||
const MAX_GLOW: f64 = 150.0;
|
||||
const MAX_SOFT_EDGE: f64 = 100.0;
|
||||
const MAX_SHADOW: f64 = 200.0;
|
||||
const MAX_REFLECTION: f64 = 100.0;
|
||||
|
||||
/// `v` within 0..=`max`; not a number is 0.
|
||||
fn effect_size(v: f64, max: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(0.0, max) } else { 0.0 }
|
||||
}
|
||||
|
||||
fn sane_shadow(s: &mut deckcraft_model::style::Shadow) {
|
||||
s.blur = effect_size(s.blur, MAX_SHADOW);
|
||||
s.dist = effect_size(s.dist, MAX_SHADOW);
|
||||
s.dir = if s.dir.is_finite() { s.dir.rem_euclid(360.0) } else { 0.0 };
|
||||
// Perspective scale (1 = 100%) and skew (degrees).
|
||||
for v in [&mut s.sx, &mut s.sy] {
|
||||
*v = if v.is_finite() { v.clamp(-10.0, 10.0) } else { 1.0 };
|
||||
}
|
||||
for v in [&mut s.kx, &mut s.ky] {
|
||||
*v = if v.is_finite() { v.clamp(-89.0, 89.0) } else { 0.0 };
|
||||
}
|
||||
}
|
||||
|
||||
fn sane_reflection(r: &mut deckcraft_model::style::Reflection) {
|
||||
r.blur = effect_size(r.blur, MAX_REFLECTION);
|
||||
r.dist = effect_size(r.dist, MAX_REFLECTION);
|
||||
}
|
||||
|
||||
/// Run properties a list style hands down to the text that inherits it.
|
||||
fn sane_list_style(ls: &mut deckcraft_model::text::ListStyle) {
|
||||
for level in ls.levels.iter_mut().flatten() {
|
||||
sane_run(&mut level.run);
|
||||
}
|
||||
}
|
||||
|
||||
fn sane_effects(e: &mut deckcraft_model::Effects) {
|
||||
for s in e.outer_shadow.iter_mut().chain(e.inner_shadow.iter_mut()) {
|
||||
sane_shadow(s);
|
||||
}
|
||||
if let Some(g) = e.glow.as_mut() {
|
||||
g.radius = effect_size(g.radius, MAX_GLOW);
|
||||
}
|
||||
e.soft_edge = e.soft_edge.map(|v| effect_size(v, MAX_SOFT_EDGE));
|
||||
if let Some(r) = e.reflection.as_mut() {
|
||||
sane_reflection(r);
|
||||
}
|
||||
}
|
||||
|
||||
fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
|
||||
@@ -363,7 +485,11 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
|
||||
if let Some(l) = sh.line.as_mut() {
|
||||
l.width = l.width.filter(|v| v.is_finite() && *v >= 0.0).map(|v| v.min(1584.0));
|
||||
}
|
||||
if let Some(e) = sh.effects.as_mut() {
|
||||
sane_effects(e);
|
||||
}
|
||||
if let Some(t) = sh.text.as_mut() {
|
||||
sane_list_style(&mut t.list_style);
|
||||
for para in &mut t.paragraphs {
|
||||
sane_run(&mut para.end_props);
|
||||
for r in &mut para.runs {
|
||||
@@ -381,6 +507,17 @@ fn sane_shapes(shapes: &mut [deckcraft_model::Shape], depth: usize) {
|
||||
sane_xfrm(child);
|
||||
sane_shapes(children, depth + 1);
|
||||
}
|
||||
deckcraft_model::ShapeKind::Table(t) => {
|
||||
for cell in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()) {
|
||||
sane_list_style(&mut cell.text.list_style);
|
||||
}
|
||||
for para in t.rows.iter_mut().flat_map(|r| r.cells.iter_mut()).flat_map(|c| c.text.paragraphs.iter_mut()) {
|
||||
sane_run(&mut para.end_props);
|
||||
for r in &mut para.runs {
|
||||
sane_run(&mut r.props);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -395,12 +532,21 @@ pub fn sane_numbers(p: &mut Presentation) {
|
||||
*slide = Arc::new(fixed);
|
||||
}
|
||||
}
|
||||
// What text and shapes inherit: the deck's default text style, each master's title, body and
|
||||
// other styles, and the theme's effect styles that shape styles point at.
|
||||
sane_list_style(&mut p.default_text_style);
|
||||
for m in p.masters.iter_mut().chain(p.notes_master.iter_mut()).chain(p.handout_master.iter_mut()) {
|
||||
let mut fixed = (**m).clone();
|
||||
sane_shapes(&mut fixed.shapes, 0);
|
||||
for l in &mut fixed.layouts {
|
||||
sane_shapes(&mut l.shapes, 0);
|
||||
}
|
||||
for ls in [&mut fixed.title_style, &mut fixed.body_style, &mut fixed.other_style] {
|
||||
sane_list_style(ls);
|
||||
}
|
||||
for e in &mut fixed.theme.format.effects {
|
||||
sane_effects(e);
|
||||
}
|
||||
if fixed != **m {
|
||||
*m = Arc::new(fixed);
|
||||
}
|
||||
@@ -418,7 +564,12 @@ pub fn outline_to_slides(p: &mut Presentation, text: &str) -> usize {
|
||||
let tabs = raw.chars().take_while(|c| *c == '\t').count();
|
||||
let spaces = raw.chars().take_while(|c| *c == ' ').count() / 2;
|
||||
let level = tabs.max(spaces);
|
||||
let line = raw.trim().trim_start_matches(['-', '*', '•']).trim().to_string();
|
||||
let t = raw.trim();
|
||||
let line = match t.strip_prefix(['-', '*', '•']) {
|
||||
Some(rest) if rest.starts_with([' ', '\t']) => rest.trim(),
|
||||
_ => t,
|
||||
}
|
||||
.to_string();
|
||||
if level == 0 {
|
||||
slides.push((line, Vec::new()));
|
||||
} else if let Some((_, body)) = slides.last_mut() {
|
||||
@@ -487,7 +638,8 @@ pub fn markdown_to_slides(p: &mut Presentation, text: &str) -> usize {
|
||||
pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
let mut out = String::new();
|
||||
for s in &p.slides {
|
||||
out.push_str(&s.title());
|
||||
// One line per title: a newline would start another slide on re-import.
|
||||
out.push_str(&s.title().replace('\n', " "));
|
||||
out.push('\n');
|
||||
for sh in &s.shapes {
|
||||
if sh.ph_type().is_some_and(|k| k.is_title()) {
|
||||
@@ -505,6 +657,13 @@ pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
if let ShapeKind::Table(t) = &sh.kind {
|
||||
for row in &t.rows {
|
||||
let cells: Vec<String> = row.cells.iter().map(|c| c.text.text().replace(['\u{b}', '\n'], " ")).collect();
|
||||
out.push_str(&cells.join("\t"));
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -514,6 +673,54 @@ pub fn slides_to_outline(p: &Presentation) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chart_sidecars_are_optional_for_older_native_files() {
|
||||
use deckcraft_model::chart::{Chart, ChartType};
|
||||
let chart = Chart::sample(ChartType::Pie);
|
||||
let mut json = serde_json::to_value(&chart).expect("chart JSON");
|
||||
assert!(json.get("kept").is_none(), "empty sidecars should not enlarge ordinary files");
|
||||
assert_eq!(serde_json::from_value::<Chart>(json.clone()).expect("old chart without kept"), chart);
|
||||
json["kept"] = serde_json::json!({});
|
||||
assert_eq!(serde_json::from_value::<Chart>(json).expect("empty sidecar"), chart);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_save_keeps_chart_relationships_and_payloads() {
|
||||
use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
|
||||
use deckcraft_model::{Shape, ShapeId, ShapeKind};
|
||||
let kept = ChartKept {
|
||||
rels: vec![ChartRel {
|
||||
id: "rWorkbook".into(),
|
||||
rel_type: "http://schemas.openxmlformats.org/officeDocument/2006/relationships/package".into(),
|
||||
target: "../embeddings/data.xlsx".into(),
|
||||
part: "ppt/embeddings/data.xlsx".into(),
|
||||
external: false,
|
||||
}],
|
||||
parts: vec![ChartPart {
|
||||
name: "ppt/embeddings/data.xlsx".into(),
|
||||
content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into(),
|
||||
bytes: vec![0, 255, 128, 42],
|
||||
native_entry: None,
|
||||
}],
|
||||
};
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let chart = Chart { kept: kept.clone(), ..Chart::sample(ChartType::Pie) };
|
||||
let s = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Chart(Box::new(chart)), ..Default::default() };
|
||||
// Serialization must reach charts inside groups as well as top-level charts.
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = vec![Shape {
|
||||
id: ShapeId(p.alloc_id().expect("group id")),
|
||||
kind: ShapeKind::Group { children: vec![s], child: Default::default() },
|
||||
..Default::default()
|
||||
}];
|
||||
let mut q = load(&save(&p).expect("save")).expect("load");
|
||||
for _ in 0..2 {
|
||||
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
|
||||
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
|
||||
assert_eq!(chart.kept, kept);
|
||||
q = load(&save(&q).expect("save again")).expect("load again");
|
||||
}
|
||||
}
|
||||
|
||||
/// The same package without the entries `drop` names.
|
||||
fn without(bytes: &[u8], drop: &str) -> Vec<u8> {
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
|
||||
@@ -690,6 +897,29 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A body whose file had no `fontScale` (`<a:normAutofit/>`) keeps that state through the
|
||||
/// native format: it is drawn at 100 % and only fitted when its text changes (#724).
|
||||
#[test]
|
||||
fn a_bare_normautofit_body_round_trips() {
|
||||
use deckcraft_model::text::{AutoFit, TextBody};
|
||||
let mut p = Presentation::default();
|
||||
let mut body = TextBody::from_text("Overflowing");
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 });
|
||||
let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(sh);
|
||||
let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}"));
|
||||
let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap();
|
||||
assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }));
|
||||
// A scale the file saved round-trips as itself.
|
||||
let mut body = TextBody::from_text("Overflowing");
|
||||
body.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 });
|
||||
let sh = deckcraft_model::Shape { text: Some(body), ..Default::default() };
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(sh);
|
||||
let back = load(&save(&p).unwrap()).unwrap_or_else(|e| panic!("{e}"));
|
||||
let body = back.slides[0].shapes.last().unwrap().text.clone().unwrap();
|
||||
assert_eq!(body.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.62), line_reduction: 0.2 }));
|
||||
}
|
||||
|
||||
/// Only `null`s where numbers belong are patched: other damage still fails.
|
||||
#[test]
|
||||
fn other_damage_is_still_reported() {
|
||||
@@ -788,6 +1018,27 @@ mod tests {
|
||||
assert!(o.contains("Intro\n\tPoint one\n\t\tSub point\nSecond"), "{o}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_keeps_leading_dash_text() {
|
||||
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
|
||||
let n = outline_to_slides(&mut p, "-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\n- Bullets\n\t- Point\n\t• Other");
|
||||
assert_eq!(n, 2);
|
||||
let o = slides_to_outline(&p);
|
||||
assert!(o.contains("-5 °C tonight\n\t--verbose flag\n\t*args and **kwargs\nBullets\n\tPoint\n\tOther"), "{o}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outline_export_keeps_multi_paragraph_title_on_one_line() {
|
||||
let mut p = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
|
||||
assert_eq!(outline_to_slides(&mut p, "Intro\n\tPoint one\nSecond\n\tMore"), 2);
|
||||
let title = Arc::make_mut(&mut p.slides[0]).shapes.iter_mut().find(|s| s.ph_type().is_some_and(|k| k.is_title())).expect("title placeholder");
|
||||
title.text = Some(TextBody::from_text("Line one\nLine two"));
|
||||
let o = slides_to_outline(&p);
|
||||
assert_eq!(o, "Line one Line two\n\tPoint one\nSecond\n\tMore\n");
|
||||
let mut q = defaults::blank_presentation(defaults::WIDE, Default::default(), false);
|
||||
assert_eq!(outline_to_slides(&mut q, &o), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_headings_and_list_items() {
|
||||
assert_eq!(md_heading("# Intro"), Some("Intro"));
|
||||
@@ -807,3 +1058,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #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}");
|
||||
}
|
||||
|
||||
+164
-15
@@ -1,11 +1,15 @@
|
||||
//! Where MCP tool calls end up: a control-channel method call.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::net::{TcpStream, ToSocketAddrs};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
// Includes the newline, matching the control server's request budget. In particular a peer
|
||||
// must not be able to grow a reply indefinitely by withholding its newline.
|
||||
const MAX_REPLY_BYTES: u64 = 16 * 1024 * 1024;
|
||||
|
||||
/// Something that answers control-channel methods (`engine.execute`, `document.inspect`,
|
||||
/// `ui.pointer`, `ui.render`, …). See `deckcraft_ui_egui::control` for the full list.
|
||||
pub trait Backend {
|
||||
@@ -58,7 +62,7 @@ impl Remote {
|
||||
/// Write one request line. A failure here means the app never got a complete line, so retrying is safe.
|
||||
/// A connection the app has already closed (it restarted since the last call) is replaced before writing.
|
||||
fn send(&mut self, line: &str) -> std::io::Result<()> {
|
||||
if self.conn.as_ref().is_none_or(|(_, writer)| is_closed(writer)) {
|
||||
if self.conn.as_ref().is_none_or(|(reader, writer)| !reader.buffer().is_empty() || is_closed(writer)) {
|
||||
self.reconnect()?;
|
||||
}
|
||||
let Some((_, writer)) = self.conn.as_mut() else {
|
||||
@@ -69,15 +73,34 @@ impl Remote {
|
||||
}
|
||||
|
||||
/// Read the reply to a request that was already sent.
|
||||
fn receive(&mut self) -> std::io::Result<String> {
|
||||
fn receive(&mut self, id: u64) -> std::io::Result<Result<Value, String>> {
|
||||
let Some((reader, _)) = self.conn.as_mut() else {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::NotConnected, "not connected"));
|
||||
};
|
||||
let mut reply = String::new();
|
||||
if reader.read_line(&mut reply)? == 0 {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"));
|
||||
let mut reply = Vec::new();
|
||||
reader.take(MAX_REPLY_BYTES + 1).read_until(b'\n', &mut reply)?;
|
||||
if reply.len() as u64 > MAX_REPLY_BYTES {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "control reply exceeds 16 MiB"));
|
||||
}
|
||||
if reply.last() != Some(&b'\n') {
|
||||
return Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control reply has no terminating newline"));
|
||||
}
|
||||
let invalid = |message| std::io::Error::new(std::io::ErrorKind::InvalidData, message);
|
||||
let v: Value = serde_json::from_slice(&reply).map_err(|e| invalid(format!("bad reply from app: {e}")))?;
|
||||
if v.get("id").and_then(Value::as_u64) != Some(id) {
|
||||
return Err(invalid("control reply has an unexpected request id".to_string()));
|
||||
}
|
||||
match v.get("ok").and_then(Value::as_bool) {
|
||||
Some(true) if v.get("error").is_none() => {
|
||||
v.get("result").cloned().map(Ok).ok_or_else(|| invalid("successful control reply has no result".to_string()))
|
||||
}
|
||||
Some(false) if v.get("result").is_none() => v
|
||||
.get("error")
|
||||
.and_then(Value::as_str)
|
||||
.map(|error| Err(error.to_string()))
|
||||
.ok_or_else(|| invalid("failed control reply has no error string".to_string())),
|
||||
_ => Err(invalid("control reply has an invalid envelope".to_string())),
|
||||
}
|
||||
Ok(reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,19 +130,13 @@ impl Backend for Remote {
|
||||
format!("DeckCraft app at {} is not reachable: {e}", self.addr)
|
||||
})?;
|
||||
}
|
||||
let reply = self.receive().map_err(|e| {
|
||||
self.receive(id).map_err(|e| {
|
||||
self.conn = None;
|
||||
format!(
|
||||
"lost the reply from the DeckCraft app at {} ({e}); the command may or may not have run, check the document before retrying",
|
||||
self.addr
|
||||
)
|
||||
})?;
|
||||
let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?;
|
||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
Err(v.get("error").and_then(Value::as_str).unwrap_or("unknown error").to_string())
|
||||
}
|
||||
})?
|
||||
}
|
||||
|
||||
fn has_ui(&self) -> bool {
|
||||
@@ -138,6 +155,138 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn reply_once(reply: Vec<u8>) -> (Remote, std::thread::JoinHandle<()>) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let addr = listener.local_addr().expect("addr").to_string();
|
||||
let peer = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept");
|
||||
stream.set_read_timeout(Some(Duration::from_secs(5))).expect("timeout");
|
||||
stream.set_write_timeout(Some(Duration::from_secs(5))).expect("write timeout");
|
||||
let mut line = String::new();
|
||||
BufReader::new(&stream).read_line(&mut line).expect("request");
|
||||
assert_eq!(serde_json::from_str::<Value>(&line).expect("JSON")["id"], 1);
|
||||
// An oversized reply may be rejected before the peer finishes writing it.
|
||||
let _ = stream.write_all(&reply);
|
||||
});
|
||||
(Remote::connect(&addr).expect("connect"), peer)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_reply_id_is_not_command_success() {
|
||||
let (mut remote, peer) = reply_once(b"{\"id\":999,\"ok\":true,\"result\":\"unrelated\"}\n".to_vec());
|
||||
let result = remote.call("engine.execute", json!({"command":"slide.new"}));
|
||||
peer.join().expect("peer");
|
||||
let error = result.expect_err("an unrelated reply cannot confirm a mutation");
|
||||
assert!(error.contains("may or may not have run"), "{error}");
|
||||
assert!(remote.conn.is_none(), "discard the invalid connection without replaying");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_success_and_error_replies_keep_their_meaning() {
|
||||
for (reply, expected) in [
|
||||
("{\"id\":1,\"ok\":true,\"result\":null}\r\n", Ok(Value::Null)),
|
||||
("{\"id\":1,\"ok\":true,\"result\":{\"slides\":2}}\n", Ok(json!({"slides":2}))),
|
||||
("{\"id\":1,\"ok\":false,\"error\":\"disabled\"}\n", Err("disabled".into())),
|
||||
] {
|
||||
let (mut remote, peer) = reply_once(reply.as_bytes().to_vec());
|
||||
assert_eq!(remote.call("engine.execute", json!({})), expected);
|
||||
assert!(remote.conn.is_some(), "valid responses do not invalidate the connection");
|
||||
peer.join().expect("peer");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_reply_envelopes_leave_the_outcome_unknown() {
|
||||
for reply in [
|
||||
"not JSON\n",
|
||||
"[]\n",
|
||||
"{\"ok\":true,\"result\":null}\n",
|
||||
"{\"id\":\"1\",\"ok\":true,\"result\":null}\n",
|
||||
"{\"id\":1,\"ok\":1,\"result\":null}\n",
|
||||
"{\"id\":1,\"ok\":true}\n",
|
||||
"{\"id\":1,\"ok\":false}\n",
|
||||
"{\"id\":1,\"ok\":false,\"error\":42}\n",
|
||||
"{\"id\":1,\"ok\":true,\"result\":null,\"error\":\"failed\"}\n",
|
||||
"{\"id\":1,\"ok\":false,\"result\":null,\"error\":\"failed\"}\n",
|
||||
] {
|
||||
let (mut remote, peer) = reply_once(reply.as_bytes().to_vec());
|
||||
let result = remote.call("engine.execute", json!({}));
|
||||
peer.join().expect("peer");
|
||||
let error = result.expect_err(reply);
|
||||
assert!(error.contains("may or may not have run"), "{reply}: {error}");
|
||||
assert!(remote.conn.is_none(), "{reply}: discard the invalid connection");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eof_without_a_newline_is_not_a_complete_reply() {
|
||||
let (mut remote, peer) = reply_once(b"{\"id\":1,\"ok\":true,\"result\":null}".to_vec());
|
||||
let result = remote.call("engine.execute", json!({}));
|
||||
peer.join().expect("peer");
|
||||
assert!(result.is_err(), "a truncated frame cannot confirm a mutation");
|
||||
assert!(remote.conn.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_reply_is_rejected() {
|
||||
let reply = format!("{{\"id\":1,\"ok\":true,\"result\":\"{}\"}}\n", "x".repeat(16 * 1024 * 1024));
|
||||
let (mut remote, peer) = reply_once(reply.into_bytes());
|
||||
let result = remote.call("engine.execute", json!({}));
|
||||
let invalidated = remote.conn.is_none();
|
||||
// Close the socket before joining: the bounded reader need not drain the peer's reply.
|
||||
remote.conn = None;
|
||||
peer.join().expect("peer");
|
||||
assert!(result.is_err(), "a reply is limited to 16 MiB");
|
||||
let error = result.expect_err("checked above");
|
||||
assert!(error.contains("may or may not have run"), "{error}");
|
||||
assert!(invalidated, "discard the oversized reply's connection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_at_the_size_limit_is_accepted() {
|
||||
let prefix = "{\"id\":1,\"ok\":true,\"result\":\"";
|
||||
let suffix = "\"}\n";
|
||||
let payload_len = 16 * 1024 * 1024 - prefix.len() - suffix.len();
|
||||
let reply = format!("{prefix}{}{suffix}", "x".repeat(payload_len));
|
||||
let (mut remote, peer) = reply_once(reply.into_bytes());
|
||||
let result = remote.call("document.inspect", json!({})).expect("reply at the limit");
|
||||
assert_eq!(result.as_str().map(str::len), Some(payload_len));
|
||||
peer.join().expect("peer");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buffered_unsolicited_reply_is_discarded_before_sending() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let addr = listener.local_addr().expect("addr").to_string();
|
||||
let peer = std::thread::spawn(move || {
|
||||
let (mut first, _) = listener.accept().expect("first connection");
|
||||
first.set_read_timeout(Some(Duration::from_secs(5))).expect("timeout");
|
||||
let mut line = String::new();
|
||||
BufReader::new(&first).read_line(&mut line).expect("initial request");
|
||||
// Even a stale frame whose ID happens to match must not answer a new request.
|
||||
first
|
||||
.write_all(b"{\"id\":1,\"ok\":true,\"result\":\"first\"}\n{\"id\":2,\"ok\":true,\"result\":\"stale\"}\n")
|
||||
.expect("two replies together");
|
||||
line.clear();
|
||||
let sent_on_stale = BufReader::new(&first).read_line(&mut line).expect("first request") != 0;
|
||||
if !sent_on_stale {
|
||||
let (mut fresh, _) = listener.accept().expect("fresh connection");
|
||||
fresh.set_read_timeout(Some(Duration::from_secs(5))).expect("timeout");
|
||||
BufReader::new(&fresh).read_line(&mut line).expect("fresh request");
|
||||
fresh.write_all(b"{\"id\":2,\"ok\":false,\"error\":\"rejected\"}\n").expect("actual reply");
|
||||
}
|
||||
sent_on_stale
|
||||
});
|
||||
let mut remote = Remote::connect(&addr).expect("connect");
|
||||
assert_eq!(remote.call("document.inspect", json!({})), Ok(json!("first")));
|
||||
// Force the stale frame into BufReader, where a socket-only peek cannot see it.
|
||||
assert!(!remote.conn.as_mut().expect("connection").0.fill_buf().expect("buffer").is_empty());
|
||||
let result = remote.call("engine.execute", json!({"command":"slide.new"}));
|
||||
let sent_on_stale = peer.join().expect("peer");
|
||||
assert_eq!(result, Err("rejected".into()), "only the fresh peer's response counts");
|
||||
assert!(!sent_on_stale, "replace a connection with buffered unsolicited bytes before sending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_reply_is_not_resent() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
|
||||
+26
-3
@@ -92,9 +92,15 @@ impl Container {
|
||||
} else if at(0, &ASF_HEADER) {
|
||||
Container::Asf
|
||||
} else if at(0, b"ID3") {
|
||||
// ID3v2 is followed by MP3 frames (or, rarely, ADTS).
|
||||
let size = b.get(6..10).map(|s| s.iter().fold(0usize, |a, &x| (a << 7) | (x & 0x7f) as usize)).unwrap_or(0);
|
||||
match Container::sniff(b.get(10 + size..).unwrap_or(&[])) {
|
||||
// ID3v2 is followed by MP3 frames (or, rarely, ADTS). The chain of tags is walked
|
||||
// iteratively — a crafted file can carry thousands of empty tags — and only the
|
||||
// bytes after the last one are sniffed.
|
||||
let mut rest = b;
|
||||
while rest.starts_with(b"ID3") {
|
||||
let size = rest.get(6..10).map(|s| s.iter().fold(0usize, |a, &x| (a << 7) | (x & 0x7f) as usize)).unwrap_or(0);
|
||||
rest = rest.get(10 + size..).unwrap_or(&[]);
|
||||
}
|
||||
match Container::sniff(rest) {
|
||||
Container::Adts => Container::Adts,
|
||||
_ => Container::Mp3,
|
||||
}
|
||||
@@ -126,3 +132,20 @@ impl Container {
|
||||
|
||||
/// Worker threads are available (not on wasm32).
|
||||
pub(crate) const THREADS: bool = !cfg!(target_arch = "wasm32");
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn id3_tag_chains_do_not_recurse_the_stack() {
|
||||
// 2 MB of empty ID3v2 tags (10 bytes each) chained ahead of the MP3 frames: walking
|
||||
// the chain must not cost one stack frame per tag.
|
||||
let mut b = Vec::with_capacity(200_000 * 10 + 2);
|
||||
for _ in 0..200_000 {
|
||||
b.extend_from_slice(&[b'I', b'D', b'3', 4, 0, 0, 0, 0, 0, 0]);
|
||||
}
|
||||
b.extend_from_slice(&[0xFF, 0xFB]); // an MP3 frame sync
|
||||
assert_eq!(Container::sniff(&b), Container::Mp3);
|
||||
}
|
||||
}
|
||||
|
||||
+1455
-1441
File diff suppressed because it is too large
Load Diff
@@ -203,13 +203,13 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
let title_box = |b: &mut B| {
|
||||
let (x, y, ww, hh) = r(60.0, 29.0, 840.0, 104.0);
|
||||
let mut s = b.shape("Title 1", PhType::Title, 0, x, y, ww, hh);
|
||||
s.text = Some(body_with(Some(Anchor::Middle), Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 })));
|
||||
s.text = Some(body_with(Some(Anchor::Middle), Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 })));
|
||||
s
|
||||
};
|
||||
let body_box = |b: &mut B, name: &str, idx: u32, x: f64, y: f64, ww: f64, hh: f64| {
|
||||
let (x, y, ww, hh) = r(x, y, ww, hh);
|
||||
let mut s = b.shape(name, PhType::Body, idx, x, y, ww, hh);
|
||||
s.text = Some(body_with(Some(Anchor::Top), Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 })));
|
||||
s.text = Some(body_with(Some(Anchor::Top), Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 })));
|
||||
s
|
||||
};
|
||||
|
||||
@@ -263,7 +263,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
t.text = Some(TextBody {
|
||||
body: BodyProps {
|
||||
anchor: Some(Anchor::Bottom),
|
||||
autofit: Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }),
|
||||
autofit: Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }),
|
||||
..Default::default()
|
||||
},
|
||||
list_style: ls,
|
||||
@@ -288,7 +288,7 @@ pub fn build_master(size: Size, theme: Theme, first_id: u32) -> (Master, u32) {
|
||||
st.text = Some(TextBody {
|
||||
body: BodyProps {
|
||||
anchor: Some(Anchor::Top),
|
||||
autofit: Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 }),
|
||||
autofit: Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }),
|
||||
..Default::default()
|
||||
},
|
||||
list_style: ls,
|
||||
|
||||
+31
-36
@@ -459,44 +459,39 @@ pub fn next_case(s: &str) -> &'static str {
|
||||
|
||||
/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
|
||||
pub fn change_case(s: &str, mode: &str) -> String {
|
||||
match mode {
|
||||
"upper" => s.to_uppercase(),
|
||||
"lower" => s.to_lowercase(),
|
||||
"toggle" => s.chars().map(|c| if c.is_uppercase() { c.to_lowercase().collect::<String>() } else { c.to_uppercase().collect() }).collect(),
|
||||
"title" => {
|
||||
let mut out = String::new();
|
||||
let mut start = true;
|
||||
for c in s.chars() {
|
||||
if start && c.is_alphabetic() {
|
||||
out.extend(c.to_uppercase());
|
||||
start = false;
|
||||
} else {
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
if c.is_whitespace() {
|
||||
start = true;
|
||||
change_case_parts(&[s], mode).concat()
|
||||
}
|
||||
|
||||
/// Change the case of consecutive pieces of text (the runs of a paragraph) as one string, so
|
||||
/// `title` and `sentence` carry their word/sentence state across piece boundaries. Each output
|
||||
/// piece is the changed form of the matching input piece, even where a character changes length.
|
||||
pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
||||
let mut start = true;
|
||||
parts
|
||||
.iter()
|
||||
.map(|s| match mode {
|
||||
"upper" => s.to_uppercase(),
|
||||
"lower" => s.to_lowercase(),
|
||||
"toggle" => s.chars().map(|c| if c.is_uppercase() { c.to_lowercase().collect::<String>() } else { c.to_uppercase().collect() }).collect(),
|
||||
_ => {
|
||||
// `title` restarts after whitespace, sentence case after sentence punctuation.
|
||||
let title = mode == "title";
|
||||
let mut out = String::new();
|
||||
for c in s.chars() {
|
||||
if start && c.is_alphabetic() {
|
||||
out.extend(c.to_uppercase());
|
||||
start = false;
|
||||
} else {
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
if (title && c.is_whitespace()) || (!title && matches!(c, '.' | '!' | '?')) {
|
||||
start = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
out
|
||||
}
|
||||
_ => {
|
||||
// sentence
|
||||
let mut out = String::new();
|
||||
let mut start = true;
|
||||
for c in s.chars() {
|
||||
if start && c.is_alphabetic() {
|
||||
out.extend(c.to_uppercase());
|
||||
start = false;
|
||||
} else {
|
||||
out.extend(c.to_lowercase());
|
||||
}
|
||||
if matches!(c, '.' | '!' | '?') {
|
||||
start = true;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -227,6 +227,29 @@ impl Table {
|
||||
if r1 >= self.rows.len() || c1 >= self.cols.len() || (r0 == r1 && c0 == c1) {
|
||||
return false;
|
||||
}
|
||||
// Grow the rectangle until no existing merge sticks out of it.
|
||||
let (mut r0, mut c0, mut r1, mut c1) = (r0, c0, r1, c1);
|
||||
loop {
|
||||
let before = (r0, c0, r1, c1);
|
||||
for (r, row) in self.rows.iter().enumerate() {
|
||||
for (c, cell) in row.cells.iter().enumerate().take(self.cols.len()) {
|
||||
if cell.is_covered() {
|
||||
continue;
|
||||
}
|
||||
let ar1 = r.saturating_add((cell.row_span.max(1) as usize).saturating_sub(1)).min(self.rows.len() - 1);
|
||||
let ac1 = c.saturating_add((cell.grid_span.max(1) as usize).saturating_sub(1)).min(self.cols.len() - 1);
|
||||
if r <= r1 && ar1 >= r0 && c <= c1 && ac1 >= c0 {
|
||||
r0 = r0.min(r);
|
||||
c0 = c0.min(c);
|
||||
r1 = r1.max(ar1);
|
||||
c1 = c1.max(ac1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r0, c0, r1, c1) == before {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut text = vec![];
|
||||
for r in r0..=r1 {
|
||||
for c in c0..=c1 {
|
||||
@@ -505,6 +528,20 @@ mod tests {
|
||||
t.cell(r, c).unwrap().text.text()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_over_an_existing_merge_takes_it_in() {
|
||||
let mut t = Table::new(3, 3, 300.0, 30.0);
|
||||
if let Some(c) = t.cell_mut(1, 2) {
|
||||
c.text = TextBody::from_text("X");
|
||||
}
|
||||
assert!(t.merge(1, 1, 1, 2));
|
||||
assert!(t.merge(0, 0, 1, 1));
|
||||
assert_topology(&t);
|
||||
let a = t.cell(0, 0).unwrap();
|
||||
assert_eq!((a.row_span, a.grid_span), (2, 3));
|
||||
assert_eq!(anchor_text(&t, 0, 0), "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_and_column_edits_keep_merges_consistent() {
|
||||
for at in 0..=4 {
|
||||
|
||||
@@ -41,8 +41,14 @@ impl Anchor {
|
||||
pub enum AutoFit {
|
||||
/// Do not autofit.
|
||||
None,
|
||||
/// Shrink text on overflow: font scale and line-spacing reduction (0–1).
|
||||
Shrink { font_scale: f64, line_reduction: f64 },
|
||||
/// Shrink text on overflow: font scale and line-spacing reduction (0–1). `font_scale` is
|
||||
/// `None` for a file's `<a:normAutofit/>` with no `fontScale`: the schema default 100 %,
|
||||
/// which PowerPoint draws as it is and only fits again when the text changes (#724).
|
||||
Shrink {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
font_scale: Option<f64>,
|
||||
line_reduction: f64,
|
||||
},
|
||||
/// Resize shape to fit text.
|
||||
Shape,
|
||||
}
|
||||
|
||||
+11
-4
@@ -3,7 +3,7 @@ name = "deckcraft-pdf"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "DeckCraft PDF export (krilla): slides, notes pages and handouts with a selectable text layer, links and bookmarks"
|
||||
description = "DeckCraft PDF export (craft-pdf/Krilla): slides, notes pages and handouts with a selectable text layer, links and bookmarks"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -13,13 +13,20 @@ deckcraft-model = { workspace = true }
|
||||
deckcraft-fonts = { workspace = true }
|
||||
deckcraft-text = { workspace = true }
|
||||
deckcraft-render = { workspace = true }
|
||||
krilla = { workspace = true }
|
||||
craft-pdf = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hayro-syntax = { version = "=0.7.2", default-features = false, features = ["std"] }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
# Off the web build, krilla compresses each slide's artwork on a background thread while the next
|
||||
# slide renders (the same bytes, in parallel).
|
||||
# slide renders (the same bytes, in parallel). The root patch selects the same pinned
|
||||
# backend as craft-pdf; this dependency only enables its native compression feature.
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
krilla = { workspace = true, features = ["rayon"] }
|
||||
krilla = { version = "=0.8.2", features = ["rayon"] }
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# DeckCraft PDF export
|
||||
|
||||
DeckCraft uses `craft-pdf` for writer construction, metadata, fallible font preparation and
|
||||
final serialization. Slide rasterization, the selectable text overlay, font discovery and
|
||||
shaping, hyperlinks, bookmarks, notes and handout layout remain app-owned. The existing
|
||||
renderer also resolves and rasterizes WMF/EMF pictures before export.
|
||||
|
||||
Only the shared `write` feature is enabled, with default features disabled. Krilla retains
|
||||
its raster-image and simple-text features, including notes/footer shaping. PDF embedding is
|
||||
disabled: DeckCraft does not use it. The production exporter has no Hayro parser, interpreter,
|
||||
image-decoder, fallback-font or CMap dependencies. Tests use `hayro-syntax` 0.7.2 directly,
|
||||
with only `std` enabled, to inspect the generated PDFs.
|
||||
|
||||
The exporter runs inside a native unwind guard. Writer failures return `PdfError::Write`.
|
||||
A font that cannot be prepared for embedding now produces an error instead of silently
|
||||
omitting its selectable text or notes. The shared Krilla 0.8.2 patch avoids a second panic
|
||||
in page/surface cleanup while unwinding and permanently poisons a document after cleanup
|
||||
failure, so a retained writer cannot serialize it. Successful serialization paths are
|
||||
unchanged. Native unwind guards cannot catch WASM aborts or process-wide allocation failures.
|
||||
|
||||
## Building and validation
|
||||
|
||||
Rust 1.95 or newer is required. The workspace pins `craft-pdf` 0.1.0 and its Krilla 0.8.2
|
||||
patch to `https://github.com/storytold/craft-libs` revision
|
||||
`39adbdf756569c8e03adb747d5e063f468ca98c1`. Both packages resolve from that immutable Git source;
|
||||
a sibling checkout is unnecessary. Publish and review this shared-library prerequisite
|
||||
before publishing the app migration, so fresh CI and release builds can fetch it.
|
||||
|
||||
Run `cargo test -p deckcraft-pdf` for export contracts and `cargo xtask ci` for all workspace
|
||||
gates. PDF contract tests also run with `wasm-bindgen-test-runner` on
|
||||
`wasm32-unknown-unknown`. The `pdf-deps` gate verifies the two writer source pins,
|
||||
retained Krilla image/text features and the development-only parser boundary.
|
||||
|
||||
This migration does not add vector slide artwork or change text-shaping/ToUnicode policy.
|
||||
+97
-47
@@ -8,8 +8,13 @@
|
||||
//! Layouts: one slide per page, notes pages (slide above its speaker notes) and handouts (1, 2, 3,
|
||||
//! 4, 6 or 9 slides per page).
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use craft_pdf::write::{self, Metadata, Writer, backend as krilla};
|
||||
|
||||
use deckcraft_fonts::FontDb;
|
||||
use deckcraft_model::Presentation;
|
||||
use deckcraft_model::text::Action as ClickAction;
|
||||
@@ -19,7 +24,6 @@ use krilla::annotation::{Annotation, LinkAnnotation, Target};
|
||||
use krilla::destination::XyzDestination;
|
||||
use krilla::geom::{Point, Size, Transform};
|
||||
use krilla::image::Image;
|
||||
use krilla::metadata::Metadata;
|
||||
use krilla::num::NormalizedF32;
|
||||
use krilla::outline::{Outline, OutlineNode};
|
||||
use krilla::page::PageSettings;
|
||||
@@ -37,6 +41,12 @@ pub enum PdfError {
|
||||
Write(String),
|
||||
}
|
||||
|
||||
impl From<write::WriteError> for PdfError {
|
||||
fn from(error: write::WriteError) -> Self {
|
||||
Self::Write(error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, PdfError>;
|
||||
|
||||
/// What each page shows.
|
||||
@@ -118,6 +128,10 @@ pub fn notes_slide_box(paper: (f64, f64), aspect: f64) -> Rect {
|
||||
|
||||
/// Export `pres` as PDF bytes.
|
||||
pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
write::guard(|| export_inner(pres, opts))
|
||||
}
|
||||
|
||||
fn export_inner(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
let slides: Vec<usize> = match &opts.slides {
|
||||
Some(v) => v.iter().copied().filter(|i| *i < pres.slides.len()).collect(),
|
||||
None => (0..pres.slides.len()).filter(|i| opts.include_hidden || !pres.slides[*i].hidden).collect(),
|
||||
@@ -125,16 +139,16 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
if slides.is_empty() {
|
||||
return Err(PdfError::NoPages);
|
||||
}
|
||||
let mut pdf = krilla::Document::new();
|
||||
let title = opts.title.clone().unwrap_or_else(|| pres.props.title.clone());
|
||||
let mut meta = Metadata::new().creator("DeckCraft".into()).producer("DeckCraft".into());
|
||||
if !title.trim().is_empty() {
|
||||
meta = meta.title(title);
|
||||
}
|
||||
if !pres.props.author.trim().is_empty() {
|
||||
meta = meta.authors(vec![pres.props.author.clone()]);
|
||||
}
|
||||
pdf.set_metadata(meta);
|
||||
let metadata = Metadata {
|
||||
creator: "DeckCraft".into(),
|
||||
producer: "DeckCraft".into(),
|
||||
title: (!title.trim().is_empty()).then_some(title),
|
||||
authors: if pres.props.author.trim().is_empty() { Vec::new() } else { vec![pres.props.author.clone()] },
|
||||
..Default::default()
|
||||
};
|
||||
let mut writer = Writer::new(Default::default(), metadata);
|
||||
let pdf = writer.document_mut();
|
||||
|
||||
let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0));
|
||||
let mut ex = Exporter { pres, opts, fonts: HashMap::new() };
|
||||
@@ -174,16 +188,16 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
{
|
||||
let mut s = page.surface();
|
||||
for (i, b) in v {
|
||||
ex.slide(&mut s, *i, *b);
|
||||
ex.slide(&mut s, *i, *b)?;
|
||||
annots.extend(ex.links(*i, *b, &page_of));
|
||||
}
|
||||
match opts.layout {
|
||||
PageLayout::Notes => {
|
||||
if let Some((i, _)) = v.first() {
|
||||
let notes = pres.slides[*i].notes_text();
|
||||
ex.paragraphs(&mut s, ¬es, notes_frames(pres, opts.paper).1, 12.0);
|
||||
ex.paragraphs(&mut s, ¬es, notes_frames(pres, opts.paper).1, 12.0)?;
|
||||
}
|
||||
ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1));
|
||||
ex.page_texts(&mut s, pres.notes_master.as_deref(), pw, ph, v.first().map(|x| x.0 + 1).unwrap_or(1))?;
|
||||
}
|
||||
PageLayout::Handouts { per_page } => {
|
||||
if per_page == 3 {
|
||||
@@ -191,7 +205,7 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
ex.note_lines(&mut s, Rect::new(b.x1 + 24.0, b.y0, pw - 54.0, b.y1));
|
||||
}
|
||||
}
|
||||
ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1));
|
||||
ex.page_texts(&mut s, pres.handout_master.as_deref(), pw, ph, page_of.get(&v[0].0).map(|p| p + 1).unwrap_or(1))?;
|
||||
}
|
||||
PageLayout::Slides => {}
|
||||
}
|
||||
@@ -202,7 +216,7 @@ pub fn export(pres: &Presentation, opts: &PdfOptions) -> Result<Vec<u8>> {
|
||||
}
|
||||
page.finish();
|
||||
}
|
||||
pdf.finish().map_err(|e| PdfError::Write(format!("{e:?}")))
|
||||
writer.finish().map_err(Into::into)
|
||||
}
|
||||
|
||||
/// A notes-size master's rect of placeholder `kind`, scaled to a page of `paper` points.
|
||||
@@ -368,7 +382,7 @@ pub fn handout_boxes(per_page: u8, paper: (f64, f64), aspect: f64) -> Vec<Rect>
|
||||
struct Exporter<'a> {
|
||||
pres: &'a Presentation,
|
||||
opts: &'a PdfOptions,
|
||||
fonts: HashMap<u32, Option<Font>>,
|
||||
fonts: HashMap<u32, Font>,
|
||||
}
|
||||
|
||||
fn tf(a: Affine) -> Transform {
|
||||
@@ -384,37 +398,68 @@ fn grey(v: u8, opacity: f32) -> Fill {
|
||||
}
|
||||
}
|
||||
|
||||
/// Premultiplied RGBA (the renderer's pixels) to the straight RGBA the writer stores.
|
||||
fn straight(rgba: &mut [u8]) {
|
||||
for px in rgba.as_chunks_mut::<4>().0 {
|
||||
let a = px[3] as u32;
|
||||
if a != 0 && a != 255 {
|
||||
for c in &mut px[..3] {
|
||||
*c = (((*c as u32 * 255 + a / 2) / a).min(255)) as u8;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A backdrop raster as one PDF image, composited onto white (slides are normally opaque). Equal
|
||||
/// backdrops become equal images, which the writer stores once.
|
||||
fn backdrop(mut img: deckcraft_render::Image) -> Image {
|
||||
for px in img.pixels.as_chunks_mut::<4>().0 {
|
||||
let a = px[3] as u32;
|
||||
for c in &mut px[..3] {
|
||||
*c = (*c as u32 + (255 - a)).min(255) as u8;
|
||||
}
|
||||
px[3] = 255;
|
||||
}
|
||||
Image::from_rgba8(img.pixels, img.width, img.height)
|
||||
}
|
||||
|
||||
impl Exporter<'_> {
|
||||
fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Option<Font> {
|
||||
self.fonts.entry(face.id()).or_insert_with(|| Font::new(face.data().to_vec().into(), face.index())).clone()
|
||||
fn font(&mut self, face: &deckcraft_fonts::FontFace) -> Result<Font> {
|
||||
if let Some(font) = self.fonts.get(&face.id()) {
|
||||
return Ok(font.clone());
|
||||
}
|
||||
let font = write::font(face.data(), face.index(), None)?;
|
||||
self.fonts.insert(face.id(), font.clone());
|
||||
Ok(font)
|
||||
}
|
||||
|
||||
/// Slide `i` drawn into `b` (page points).
|
||||
fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) {
|
||||
fn slide(&mut self, s: &mut Surface, i: usize, b: Rect) -> Result<()> {
|
||||
let pres = self.pres;
|
||||
let (sw, sh) = (pres.slide_size.width.max(1.0), pres.slide_size.height.max(1.0));
|
||||
let k = b.width() / sw;
|
||||
// Raster at the requested dpi relative to the box size on paper.
|
||||
let scale = (b.width() / 72.0 * self.opts.dpi.clamp(36.0, 600.0)) / sw;
|
||||
let img = deckcraft_render::render_slide(pres, i, &RenderOpts { scale, threads: 4, ..Default::default() });
|
||||
if img.width > 0 && img.height > 0 {
|
||||
let (w, h) = (img.width, img.height);
|
||||
let ropts = RenderOpts { scale, threads: 4, ..Default::default() };
|
||||
// Two rasters per slide: the backdrop (background, master and layout graphics) is the same
|
||||
// on every slide that shares its layout, so the writer stores it once, and the slide's own
|
||||
// shapes draw over it (#845).
|
||||
let back = deckcraft_render::render_slide_part(pres, i, deckcraft_render::SlidePart::Backdrop, &ropts);
|
||||
let front = deckcraft_render::render_slide_part(pres, i, deckcraft_render::SlidePart::Content, &ropts);
|
||||
let (w, h) = (back.width, back.height);
|
||||
if w > 0
|
||||
&& h > 0
|
||||
&& let Some(size) = Size::from_wh(b.width() as f32, b.height() as f32)
|
||||
{
|
||||
s.push_transform(&Transform::from_translate(b.x0 as f32, b.y0 as f32));
|
||||
s.draw_image(backdrop(back), size);
|
||||
// Taken, not copied: a print-resolution slide is ~16 MB.
|
||||
let mut rgba = img.pixels;
|
||||
for px in rgba.as_chunks_mut::<4>().0 {
|
||||
// Composite onto white (slides are normally opaque).
|
||||
let a = px[3] as u32;
|
||||
for c in &mut px[..3] {
|
||||
*c = (*c as u32 + (255 - a)).min(255) as u8;
|
||||
}
|
||||
px[3] = 255;
|
||||
}
|
||||
let image = Image::from_rgba8(rgba, w, h);
|
||||
if let Some(size) = Size::from_wh(b.width() as f32, b.height() as f32) {
|
||||
s.push_transform(&Transform::from_translate(b.x0 as f32, b.y0 as f32));
|
||||
s.draw_image(image, size);
|
||||
s.pop();
|
||||
let mut rgba = front.pixels;
|
||||
if rgba.as_chunks::<4>().0.iter().any(|px| px[3] != 0) {
|
||||
straight(&mut rgba);
|
||||
s.draw_image(Image::from_rgba8(rgba, w, h), size);
|
||||
}
|
||||
s.pop();
|
||||
}
|
||||
if self.opts.frame_slides && self.opts.layout != PageLayout::Slides {
|
||||
let p = kurbo::Shape::to_path(&b, 0.1);
|
||||
@@ -432,14 +477,15 @@ impl Exporter<'_> {
|
||||
if self.opts.text_layer {
|
||||
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
|
||||
for pt in deckcraft_render::place_slide(pres, i).texts {
|
||||
self.text_layer(s, &pt, view, false);
|
||||
self.text_layer(s, &pt, view, false)?;
|
||||
}
|
||||
let _ = sh;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Invisible glyphs exactly over the rasterised text.
|
||||
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) {
|
||||
fn text_layer(&mut self, s: &mut Surface, pt: &PlacedText, view: Affine, visible: bool) -> Result<()> {
|
||||
for run in &pt.layout.runs {
|
||||
if run.glyphs.is_empty() || run.size <= 0.0 {
|
||||
continue;
|
||||
@@ -457,7 +503,7 @@ impl Exporter<'_> {
|
||||
if text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(font) = self.font(&run.face) else { continue };
|
||||
let font = self.font(&run.face)?;
|
||||
let n = run.glyphs.len();
|
||||
let ranges = glyph_text_ranges(&text, run.chars, &run.cells, n);
|
||||
let (x0, y0) = (run.glyphs[0].1, run.glyphs[0].2);
|
||||
@@ -486,6 +532,7 @@ impl Exporter<'_> {
|
||||
s.set_fill(None);
|
||||
s.pop();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn links(&self, i: usize, b: Rect, page_of: &HashMap<usize, usize>) -> Vec<Annotation> {
|
||||
@@ -516,15 +563,15 @@ impl Exporter<'_> {
|
||||
out
|
||||
}
|
||||
|
||||
fn ui_font(&mut self) -> Option<(Font, std::sync::Arc<deckcraft_fonts::FontFace>)> {
|
||||
fn ui_font(&mut self) -> Result<(Font, std::sync::Arc<deckcraft_fonts::FontFace>)> {
|
||||
let face = FontDb::global().face(deckcraft_fonts::DEFAULT_FAMILY, "Regular");
|
||||
let f = self.font(&face)?;
|
||||
Some((f, face))
|
||||
Ok((f, face))
|
||||
}
|
||||
|
||||
/// Wrapped plain text (notes).
|
||||
fn paragraphs(&mut self, s: &mut Surface, text: &str, area: Rect, size: f64) {
|
||||
let Some((font, face)) = self.ui_font() else { return };
|
||||
fn paragraphs(&mut self, s: &mut Surface, text: &str, area: Rect, size: f64) -> Result<()> {
|
||||
let (font, face) = self.ui_font()?;
|
||||
let k = size / face.upem.max(1.0);
|
||||
let width = |w: &str| w.chars().map(|c| face.advance(face.glyph_for(c)) * k).sum::<f64>();
|
||||
let lh = size * 1.3;
|
||||
@@ -554,6 +601,7 @@ impl Exporter<'_> {
|
||||
y += lh;
|
||||
}
|
||||
s.set_fill(None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ruled lines for handwritten notes (3-per-page handouts).
|
||||
@@ -578,21 +626,23 @@ impl Exporter<'_> {
|
||||
|
||||
/// A notes or handout page's header, date, footer and page number from its master; just the
|
||||
/// page number when the deck has no such master.
|
||||
fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) {
|
||||
fn page_texts(&mut self, s: &mut Surface, master: Option<&deckcraft_model::Master>, pw: f64, ph: f64, n: usize) -> Result<()> {
|
||||
let Some(m) = master else { return self.footer(s, pw, ph, n) };
|
||||
// Laid out by the text engine in the placeholders, scaled from the notes size to the page:
|
||||
// wrapping, alignment and mixed formatting as the master has them.
|
||||
let pres = self.pres;
|
||||
let view = Affine::scale_non_uniform(pw / pres.notes_size.width.max(1.0), ph / pres.notes_size.height.max(1.0));
|
||||
for pt in deckcraft_render::place_master_texts(pres, m, n) {
|
||||
self.text_layer(s, &pt, view, true);
|
||||
self.text_layer(s, &pt, view, true)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) {
|
||||
let Some((font, _)) = self.ui_font() else { return };
|
||||
fn footer(&mut self, s: &mut Surface, pw: f64, ph: f64, n: usize) -> Result<()> {
|
||||
let (font, _) = self.ui_font()?;
|
||||
s.set_fill(Some(grey(0x60, 1.0)));
|
||||
s.draw_text(Point::from_xy((pw - 72.0) as f32, (ph - 30.0) as f32), font, 9.0, &n.to_string(), false, TextDirection::Auto);
|
||||
s.set_fill(None);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+305
-5
@@ -24,33 +24,49 @@ fn opts(layout: PageLayout) -> PdfOptions {
|
||||
PdfOptions { layout, dpi: 48.0, ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn exports_slides_notes_and_handouts() {
|
||||
let p = deck(5);
|
||||
for layout in [PageLayout::Slides, PageLayout::Notes, PageLayout::Handouts { per_page: 3 }, PageLayout::Handouts { per_page: 6 }] {
|
||||
let bytes = export(&p, &opts(layout)).expect("export");
|
||||
assert!(bytes.starts_with(b"%PDF-"), "{layout:?}");
|
||||
assert!(bytes.len() > 1000);
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let (pages, size) = match layout {
|
||||
PageLayout::Slides => (5, (960.0, 540.0)),
|
||||
PageLayout::Notes => (5, (612.0, 792.0)),
|
||||
PageLayout::Handouts { per_page: 3 } => (2, (612.0, 792.0)),
|
||||
_ => (1, (612.0, 792.0)),
|
||||
};
|
||||
assert_eq!(pdf.pages().len(), pages);
|
||||
assert!(pdf.pages().iter().all(|page| page.render_dimensions() == size));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn empty_range_is_an_error() {
|
||||
let p = deck(1);
|
||||
let o = PdfOptions { slides: Some(vec![7]), ..opts(PageLayout::Slides) };
|
||||
assert!(matches!(export(&p, &o), Err(PdfError::NoPages)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn hidden_slides_skipped_unless_asked() {
|
||||
let mut p = deck(2);
|
||||
Arc::make_mut(&mut p.slides[1]).hidden = true;
|
||||
let a = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let b = export(&p, &PdfOptions { include_hidden: true, ..opts(PageLayout::Slides) }).unwrap();
|
||||
assert!(b.len() > a.len());
|
||||
assert_eq!(hayro_syntax::Pdf::new(a.clone()).unwrap().pages().len(), 1);
|
||||
assert_eq!(hayro_syntax::Pdf::new(b.clone()).unwrap().pages().len(), 2);
|
||||
let ranged = export(&p, &PdfOptions { slides: Some(vec![1, 0, 9]), ..opts(PageLayout::Slides) }).unwrap();
|
||||
assert_eq!(hayro_syntax::Pdf::new(ranged.clone()).unwrap().pages().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn handout_boxes_fit_the_paper() {
|
||||
for n in [1, 2, 3, 4, 6, 9] {
|
||||
let v = handout_boxes(n, (612.0, 792.0), 16.0 / 9.0);
|
||||
@@ -67,6 +83,201 @@ fn handout_boxes_fit_the_paper() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn metadata_retains_values_and_omits_blank_fields() {
|
||||
let mut p = deck(1);
|
||||
p.props.title = " Deck title ".into();
|
||||
p.props.author = " Author ".into();
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
assert_eq!(pdf.metadata().title.as_deref(), Some(b" Deck title ".as_slice()));
|
||||
assert_eq!(pdf.metadata().author.as_deref(), Some(b" Author ".as_slice()));
|
||||
assert_eq!(pdf.metadata().creator.as_deref(), Some(b"DeckCraft".as_slice()));
|
||||
assert_eq!(pdf.metadata().producer.as_deref(), Some(b"DeckCraft".as_slice()));
|
||||
assert!(pdf.metadata().creation_date.is_none());
|
||||
p.props.author = " ".into();
|
||||
let bytes = export(&p, &PdfOptions { title: Some(" ".into()), ..opts(PageLayout::Slides) }).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
assert!(pdf.metadata().title.is_none());
|
||||
assert!(pdf.metadata().author.is_none());
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn text_layer_links_and_bookmarks_survive_serialization() {
|
||||
use deckcraft_model::text::{Action, Hyperlink};
|
||||
use hayro_syntax::object::{Array, Dict};
|
||||
let mut p = deck(2);
|
||||
let last = p.slides[1].id;
|
||||
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||
for (shape, action) in slide.shapes.iter_mut().zip([Action::Url { url: "https://example.com/deck".into() }, Action::Slide { slide: last }]) {
|
||||
shape.click = Some(Hyperlink { action, tooltip: "Link".into(), highlight_click: false, sound: None, stop_sound: false, zoom_return: None });
|
||||
}
|
||||
for text_layer in [true, false] {
|
||||
let bytes = export(&p, &PdfOptions { text_layer, ..opts(PageLayout::Slides) }).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let page = &pdf.pages()[0];
|
||||
let annotations = page.raw().get::<Array<'_>>(b"Annots").unwrap();
|
||||
assert_eq!(annotations.iter::<Dict<'_>>().count(), 2);
|
||||
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
|
||||
let fonts = resources.get::<Dict<'_>>(b"Font");
|
||||
assert_eq!(fonts.is_some_and(|fonts| !fonts.is_empty()), text_layer);
|
||||
let catalog = pdf.xref().get::<Dict<'_>>(pdf.xref().root_id()).unwrap();
|
||||
let outlines = catalog.get::<Dict<'_>>(b"Outlines").unwrap();
|
||||
let first = outlines.get::<Dict<'_>>(b"First").unwrap();
|
||||
assert!(first.get::<Dict<'_>>(b"Next").is_some());
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a small damaged derivative of our openly licensed last-resort font entirely in
|
||||
/// memory. Its naming/shaping tables still parse, but its missing head table prevents PDF
|
||||
/// embedding. No font bytes or vendor assets are stored as test fixtures.
|
||||
fn font_without_head() -> Vec<u8> {
|
||||
let mut bytes = FontDb::global().face("Ubuntu", "Regular").data().to_vec();
|
||||
for (old, new) in [(b"Ubuntu".as_slice(), b"BadPDF".as_slice()), (b"\0U\0b\0u\0n\0t\0u".as_slice(), b"\0B\0a\0d\0P\0D\0F".as_slice())] {
|
||||
let mut offset = 0;
|
||||
while offset + old.len() <= bytes.len() {
|
||||
if bytes[offset..offset + old.len()] == *old {
|
||||
bytes[offset..offset + old.len()].copy_from_slice(new);
|
||||
offset += old.len();
|
||||
} else {
|
||||
offset += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
let tables = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
|
||||
for i in 0..tables {
|
||||
let offset = 12 + i * 16;
|
||||
if &bytes[offset..offset + 4] == b"head" {
|
||||
bytes[offset..offset + 4].copy_from_slice(b"zzzz");
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
let db = FontDb::global();
|
||||
assert_eq!(db.add_font(font_without_head()), 1);
|
||||
assert_eq!(db.face("BadPDF", "Regular").family, "BadPDF");
|
||||
let mut p = deck(1);
|
||||
let slide = Arc::make_mut(&mut p.slides[0]);
|
||||
let mut body = TextBody::from_text("Text that must not disappear silently");
|
||||
for paragraph in &mut body.paragraphs {
|
||||
for run in &mut paragraph.runs {
|
||||
run.props.font = Some("BadPDF".into());
|
||||
run.props.size = Some(12.0);
|
||||
}
|
||||
}
|
||||
slide.shapes = vec![Shape { id: ShapeId(9000), xfrm: Some(Xfrm::new(20.0, 20.0, 500.0, 200.0)), text: Some(body), ..Default::default() }];
|
||||
let error = export(&p, &opts(PageLayout::Slides)).unwrap_err();
|
||||
assert!(matches!(error, PdfError::Write(_)));
|
||||
assert!(error.to_string().contains("font could not be read for embedding"));
|
||||
// Master text remains visible even when the optional slide text layer is off.
|
||||
// It must propagate the same embedding error through notes and handout pages.
|
||||
let mut header = ph(deckcraft_model::PhType::Header, 0.0, 0.0, 500.0, 40.0, "");
|
||||
header.text = p.slides[0].shapes[0].text.clone();
|
||||
for layout in [PageLayout::Notes, PageLayout::Handouts { per_page: 3 }] {
|
||||
let mut p = deck(1);
|
||||
let master = Some(Arc::new(deckcraft_model::Master { shapes: vec![header.clone()], ..Default::default() }));
|
||||
if layout == PageLayout::Notes {
|
||||
p.notes_master = master;
|
||||
} else {
|
||||
p.handout_master = master;
|
||||
}
|
||||
let error = export(&p, &PdfOptions { text_layer: false, ..opts(layout) }).unwrap_err();
|
||||
assert!(matches!(error, PdfError::Write(_)));
|
||||
assert!(error.to_string().contains("font could not be read for embedding"), "{layout:?}: {error}");
|
||||
}
|
||||
// The failed export leaves no poisoned writer state behind.
|
||||
assert!(export(&deck(1), &opts(PageLayout::Slides)).is_ok());
|
||||
}
|
||||
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
use hayro_syntax::content::ops::TypedInstruction;
|
||||
use hayro_syntax::object::{Dict, Object, Stream};
|
||||
|
||||
let text = "Café Ελληνικά Привет";
|
||||
let face = FontDb::global().face("Ubuntu", "Regular");
|
||||
assert_eq!(face.family, "Ubuntu");
|
||||
assert!(text.chars().all(|c| face.glyph_for(c) != 0), "missing: {:?}", text.chars().filter(|c| face.glyph_for(*c) == 0).collect::<Vec<_>>());
|
||||
let mut body = TextBody::from_text(text);
|
||||
for paragraph in &mut body.paragraphs {
|
||||
for run in &mut paragraph.runs {
|
||||
run.props.font = Some("Ubuntu".into());
|
||||
run.props.size = Some(20.0);
|
||||
}
|
||||
}
|
||||
let mut p = deck(1);
|
||||
Arc::make_mut(&mut p.slides[0]).shapes =
|
||||
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body), ..Default::default() }];
|
||||
let placed = deckcraft_render::place_slide(&p, 0);
|
||||
let runs: Vec<_> = placed.texts.iter().flat_map(|placed| &placed.layout.runs).collect();
|
||||
assert!(!runs.is_empty());
|
||||
assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0)));
|
||||
|
||||
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
|
||||
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
|
||||
let page = &pdf.pages()[0];
|
||||
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
|
||||
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
|
||||
assert_eq!(fonts.len(), 1);
|
||||
let font = fonts.get::<Dict<'_>>(fonts.keys().next().unwrap()).unwrap();
|
||||
let cmap = font.get::<Stream<'_>>(b"ToUnicode").unwrap().decoded().unwrap().into_owned();
|
||||
let cmap = std::str::from_utf8(&cmap).unwrap();
|
||||
// This fixture uses the writer's two-byte CIDs and bfchar mappings. Decode the
|
||||
// content's text-showing operators through that map, not document metadata.
|
||||
let mut mapping = HashMap::<u16, String>::new();
|
||||
let mut in_char_map = false;
|
||||
for line in cmap.lines() {
|
||||
if line.ends_with("beginbfchar") {
|
||||
in_char_map = true;
|
||||
} else if line == "endbfchar" {
|
||||
in_char_map = false;
|
||||
} else if in_char_map {
|
||||
let parts: Vec<_> = line.split_whitespace().collect();
|
||||
let cid = u16::from_str_radix(parts[0].trim_matches(['<', '>']), 16).unwrap();
|
||||
let value = parts[1].trim_matches(['<', '>']);
|
||||
let units: Vec<_> = value
|
||||
.as_bytes()
|
||||
.as_chunks::<4>()
|
||||
.0
|
||||
.iter()
|
||||
.map(|chunk| u16::from_str_radix(std::str::from_utf8(chunk).unwrap(), 16).unwrap())
|
||||
.collect();
|
||||
mapping.insert(cid, String::from_utf16(&units).unwrap());
|
||||
}
|
||||
}
|
||||
let mut extracted = String::new();
|
||||
let mut append = |bytes: &[u8]| {
|
||||
assert!(bytes.len().is_multiple_of(2));
|
||||
for pair in bytes.as_chunks::<2>().0 {
|
||||
extracted.push_str(mapping.get(&u16::from_be_bytes([pair[0], pair[1]])).unwrap());
|
||||
}
|
||||
};
|
||||
let mut operations = page.typed_operations();
|
||||
while let Some(operation) = operations.next() {
|
||||
match operation {
|
||||
TypedInstruction::ShowText(value) => append(value.0.as_bytes()),
|
||||
TypedInstruction::ShowTexts(values) => {
|
||||
for value in values.0.iter::<Object<'_>>() {
|
||||
if let Object::String(value) = value {
|
||||
append(value.as_bytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert_eq!(extracted, text);
|
||||
}
|
||||
|
||||
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
|
||||
/// on the page and room left for the notes.
|
||||
#[test]
|
||||
@@ -221,6 +432,95 @@ fn master_text_wraps_and_keeps_mixed_formatting() {
|
||||
assert!(export(&p, &PdfOptions { layout: PageLayout::Notes, ..Default::default() }).unwrap().starts_with(b"%PDF"));
|
||||
}
|
||||
|
||||
/// Slides that share their layout store their backdrop raster once in the exported PDF, over which
|
||||
/// each slide's own shapes draw as their own raster (#845).
|
||||
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), test)]
|
||||
fn slides_share_one_backdrop_raster_and_keep_their_pixels() {
|
||||
use deckcraft_model::{Shape, ShapeId, Xfrm};
|
||||
use hayro_syntax::object::{Dict, Number, Stream};
|
||||
|
||||
/// The image XObjects the export stores, one entry per distinct object: the pages that draw
|
||||
/// it, its filters, color channel, alpha channel (the SMask) and pixel size.
|
||||
fn images(p: &Presentation) -> Vec<(Vec<usize>, Vec<hayro_syntax::Filter>, Vec<u8>, Option<Vec<u8>>, (u32, u32))> {
|
||||
let bytes = export(p, &opts(PageLayout::Slides)).expect("export");
|
||||
let pdf = hayro_syntax::Pdf::new(bytes).expect("pdf");
|
||||
let mut out: Vec<(String, Vec<usize>, Vec<hayro_syntax::Filter>, Vec<u8>, Option<Vec<u8>>, (u32, u32))> = Vec::new();
|
||||
for (n, page) in pdf.pages().iter().enumerate() {
|
||||
let resources = page.raw().get::<Dict>(b"Resources").expect("resources");
|
||||
let xobjects = resources.get::<Dict>(b"XObject").expect("xobjects");
|
||||
for key in xobjects.keys() {
|
||||
let stream = xobjects.get::<Stream>(key).expect("stream");
|
||||
let id = format!("{:?}", stream.obj_id());
|
||||
match out.iter_mut().find(|(seen, ..)| *seen == id) {
|
||||
Some(entry) => entry.1.push(n),
|
||||
None => {
|
||||
let dict = stream.dict();
|
||||
let alpha = dict.get::<Stream>(b"SMask").map(|mask| mask.decoded().expect("decoded").into_owned());
|
||||
let size = (
|
||||
dict.get::<Number>(b"Width").expect("width").as_f64() as u32,
|
||||
dict.get::<Number>(b"Height").expect("height").as_f64() as u32,
|
||||
);
|
||||
out.push((id, vec![n], stream.filters().into_vec(), stream.decoded().expect("decoded").into_owned(), alpha, size));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.into_iter().map(|(_, pages, filters, rgb, alpha, size)| (pages, filters, rgb, alpha, size)).collect()
|
||||
}
|
||||
|
||||
// The raster scale the exporter uses for one slide per page.
|
||||
let scale = |p: &Presentation| (p.slide_size.width / 72.0 * opts(PageLayout::Slides).dpi) / p.slide_size.width;
|
||||
|
||||
// Three slides without shapes: every page is pure backdrop and shares one raster, which is
|
||||
// exactly what the renderer draws.
|
||||
let mut p = deck(3);
|
||||
for slide in &mut p.slides {
|
||||
Arc::make_mut(slide).shapes.clear();
|
||||
}
|
||||
let stored = images(&p);
|
||||
assert_eq!(stored.len(), 1, "all three pages must share one backdrop raster");
|
||||
let (pages, filters, rgb, alpha, size) = stored.into_iter().next().expect("one image");
|
||||
assert_eq!(pages, [0, 1, 2]);
|
||||
assert_eq!(filters, [hayro_syntax::Filter::FlateDecode], "backdrop artwork stays lossless");
|
||||
assert!(alpha.is_none(), "the backdrop is opaque and needs no alpha mask");
|
||||
let expect = deckcraft_render::render_slide(&p, 0, &RenderOpts { scale: scale(&p), threads: 4, ..Default::default() });
|
||||
assert_eq!(size, (expect.width, expect.height));
|
||||
let want: Vec<u8> = expect.pixels.as_chunks::<4>().0.iter().flat_map(|px| px[..3].iter().copied()).collect();
|
||||
assert_eq!(rgb, want, "the shared raster is exactly what the renderer draws");
|
||||
|
||||
// Slides with different text still share the backdrop: one raster on every page plus one per
|
||||
// slide's own shapes.
|
||||
let stored = images(&deck(3));
|
||||
assert_eq!(stored.len(), 4, "one shared backdrop plus three content rasters");
|
||||
assert_eq!(stored.iter().filter(|(pages, ..)| pages.len() == 3).count(), 1, "the backdrop is the only raster every page draws");
|
||||
|
||||
// A slide's own shapes draw over the shared backdrop as a second raster, stored as straight
|
||||
// RGBA (color channel plus alpha mask), never the renderer's premultiplied pixels.
|
||||
let mut p = deck(1);
|
||||
Arc::make_mut(&mut p.slides[0]).shapes.push(Shape {
|
||||
id: ShapeId(9002),
|
||||
xfrm: Some(Xfrm::new(20.0, 20.0, 400.0, 300.0)),
|
||||
fill: Some(deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_model::Rgba::rgb(200, 30, 30)))),
|
||||
..Default::default()
|
||||
});
|
||||
let stored = images(&p);
|
||||
assert_eq!(stored.len(), 2, "shared backdrop plus the slide's content");
|
||||
let (_, filters, rgb, alpha, _) = stored.into_iter().find(|(_, _, _, alpha, _)| alpha.is_some()).expect("content raster");
|
||||
assert_eq!(filters, [hayro_syntax::Filter::FlateDecode]);
|
||||
let expect = deckcraft_render::render_slide_part(
|
||||
&p,
|
||||
0,
|
||||
deckcraft_render::SlidePart::Content,
|
||||
&RenderOpts { scale: scale(&p), threads: 4, ..Default::default() },
|
||||
);
|
||||
let straight = expect.to_straight();
|
||||
let want_rgb: Vec<u8> = straight.as_chunks::<4>().0.iter().flat_map(|px| px[..3].iter().copied()).collect();
|
||||
let want_alpha: Vec<u8> = straight.as_chunks::<4>().0.iter().map(|px| px[3]).collect();
|
||||
assert_eq!(rgb, want_rgb, "content colors are straight, not premultiplied");
|
||||
assert_eq!(alpha, Some(want_alpha), "content alpha keeps the shape's coverage");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glyph_ranges_follow_shaping_clusters() {
|
||||
// "fl" ligature: its glyph maps to both characters, the rest stay one to one (#164).
|
||||
|
||||
@@ -63,6 +63,12 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
read::import(bytes)
|
||||
}
|
||||
|
||||
/// [`import`], plus the part names of slides the file lists but that couldn't be read and were
|
||||
/// left out.
|
||||
pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxError> {
|
||||
read::import_checked(bytes)
|
||||
}
|
||||
|
||||
/// Read the theme of a `.thmx` file, or of a `.pptx` / `.potx`'s first master, with its media.
|
||||
pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<deckcraft_model::MediaItem>), PptxError> {
|
||||
read::import_theme(bytes)
|
||||
|
||||
@@ -473,7 +473,7 @@ fn copy_part(
|
||||
return false;
|
||||
}
|
||||
let content_type = pkg.content_type(part).unwrap_or_default();
|
||||
out.push(ChartPart { name, content_type, bytes: bytes.to_vec() });
|
||||
out.push(ChartPart { name, content_type, bytes: bytes.to_vec(), native_entry: None });
|
||||
seen.insert(key, true);
|
||||
let rels_name = crate::opc::rels_path(part);
|
||||
if crate::opc::sidecar_part(&rels_name)
|
||||
@@ -482,7 +482,12 @@ fn copy_part(
|
||||
{
|
||||
seen.insert(crate::opc::norm(&rels_name), true);
|
||||
let name = pkg.original_name(&rels_name).unwrap_or(&rels_name).trim_start_matches('/').to_string();
|
||||
out.push(ChartPart { name, content_type: "application/vnd.openxmlformats-package.relationships+xml".into(), bytes: rels_bytes.to_vec() });
|
||||
out.push(ChartPart {
|
||||
name,
|
||||
content_type: "application/vnd.openxmlformats-package.relationships+xml".into(),
|
||||
bytes: rels_bytes.to_vec(),
|
||||
native_entry: None,
|
||||
});
|
||||
let targets: Vec<String> = pkg.rels(part).items.into_iter().filter(|r| !r.external).map(|r| r.target).collect();
|
||||
for target in targets {
|
||||
copy_part(pkg, &target, out, seen, depth + 1);
|
||||
|
||||
@@ -338,7 +338,7 @@ pub fn body_pr(b: &El) -> BodyProps {
|
||||
match e.local() {
|
||||
"normAutofit" => {
|
||||
p.autofit = Some(AutoFit::Shrink {
|
||||
font_scale: pct(e, "fontScale").unwrap_or(1.0).clamp(0.01, 1.0),
|
||||
font_scale: pct(e, "fontScale").map(|v| v.clamp(0.01, 1.0)),
|
||||
line_reduction: pct(e, "lnSpcReduction").unwrap_or(0.0).clamp(0.0, 1.0),
|
||||
})
|
||||
}
|
||||
@@ -645,8 +645,14 @@ mod tests {
|
||||
assert_eq!(b.wrap, Some(false));
|
||||
assert_eq!(b.vert, Some(TextDir::Vertical270));
|
||||
assert_eq!(b.columns, Some(2));
|
||||
assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 }));
|
||||
assert_eq!(b.autofit, Some(AutoFit::Shrink { font_scale: Some(0.625), line_reduction: 0.2 }));
|
||||
assert_eq!(b.space_first_last, None);
|
||||
// A bare `<a:normAutofit/>` keeps no scale: the schema default 100 % is drawn as it is,
|
||||
// not the same as a saved 100 % (`fontScale="100000"`) (#724).
|
||||
let d = parse(br#"<a:bodyPr><a:normAutofit/></a:bodyPr>"#).unwrap();
|
||||
assert_eq!(body_pr(&d.root).autofit, Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 }));
|
||||
let d = parse(br#"<a:bodyPr><a:normAutofit fontScale="100000"/></a:bodyPr>"#).unwrap();
|
||||
assert_eq!(body_pr(&d.root).autofit, Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 }));
|
||||
let d = parse(br#"<a:bodyPr spcFirstLastPara="1"/>"#).unwrap();
|
||||
assert_eq!(body_pr(&d.root).space_first_last, Some(true));
|
||||
// mongolianVert is its own direction (PowerPoint runs its lines left to right) and is
|
||||
|
||||
@@ -387,6 +387,13 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
|
||||
}
|
||||
|
||||
pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
import_checked(bytes).map(|(p, _)| p)
|
||||
}
|
||||
|
||||
/// [`import`], plus the parts of the slide list the file names but that couldn't be read (a
|
||||
/// missing or truncated slide): those slides are left out, so a caller can say so (#203).
|
||||
pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxError> {
|
||||
let mut lost_slides: Vec<String> = vec![];
|
||||
let pkg = Package::open(bytes)?;
|
||||
let root_rels = pkg.rels("");
|
||||
let pres_path = root_rels.first_of("officeDocument").map(|r| r.target.clone()).unwrap_or_else(|| "ppt/presentation.xml".into());
|
||||
@@ -447,6 +454,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
for (file_id, path) in &slide_entries {
|
||||
if !docs.contains_key(&opc::norm(path)) {
|
||||
log::warn!("pptx: slide {path} is missing or unreadable; skipped");
|
||||
lost_slides.push(path.clone());
|
||||
continue;
|
||||
}
|
||||
let id = SlideId(imp.alloc());
|
||||
@@ -677,7 +685,7 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
|
||||
p.media = std::mem::take(&mut imp.media);
|
||||
p.next_id = imp.next;
|
||||
p.fix_next_id();
|
||||
Ok(p)
|
||||
Ok((p, lost_slides))
|
||||
}
|
||||
|
||||
/// Embedded font faces in one file. Past this, further typefaces are dropped.
|
||||
|
||||
@@ -489,9 +489,11 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
Some(AutoFit::None) => w.empty0("a:noAutofit"),
|
||||
Some(AutoFit::Shape) => w.empty0("a:spAutoFit"),
|
||||
Some(AutoFit::Shrink { font_scale, line_reduction }) => {
|
||||
let fs = pct(font_scale.clamp(0.01, 1.0));
|
||||
// A scale the model holds is written, 100 % included: reopening then keeps the same
|
||||
// "draw the saved scale" state instead of becoming a bare `<a:normAutofit/>` (#724).
|
||||
let fs = font_scale.map(|s| pct(s.clamp(0.01, 1.0)));
|
||||
let lr = pct(line_reduction.clamp(0.0, 1.0));
|
||||
w.empty("a:normAutofit", A::new().o("fontScale", (fs != 100_000).then_some(fs)).o("lnSpcReduction", (lr != 0).then_some(lr)));
|
||||
w.empty("a:normAutofit", A::new().o("fontScale", fs).o("lnSpcReduction", (lr != 0).then_some(lr)));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
|
||||
@@ -655,21 +655,10 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
|
||||
w.close("p:seq");
|
||||
}
|
||||
for (trig, effs) in triggered {
|
||||
// Everything a trigger starts plays from its click.
|
||||
let effs: Vec<Eff> = effs
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, e)| Eff {
|
||||
start: if i == 0 {
|
||||
AnimStart::OnClick
|
||||
} else if e.start == AnimStart::OnClick {
|
||||
AnimStart::AfterPrevious
|
||||
} else {
|
||||
e.start
|
||||
},
|
||||
..e
|
||||
})
|
||||
.collect();
|
||||
// The first step starts with the button's click. Later On Click effects must remain
|
||||
// separate steps; With/After Previous effects still belong to their preceding step.
|
||||
let effs: Vec<Eff> =
|
||||
effs.into_iter().enumerate().map(|(i, e)| Eff { start: if i == 0 { AnimStart::OnClick } else { e.start }, ..e }).collect();
|
||||
let clicks = group(effs);
|
||||
let trig_cond = |w: &mut W| {
|
||||
w.open("p:cond", A::new().a("evt", "onClick").a("delay", 0));
|
||||
|
||||
@@ -109,7 +109,7 @@ fn corpus() -> Presentation {
|
||||
let mut columns = TextBody::from_text("Two columns\nof vertical text");
|
||||
columns.body.columns = Some(3);
|
||||
columns.body.col_spacing = Some(18.0);
|
||||
columns.body.autofit = Some(AutoFit::Shrink { font_scale: 0.85, line_reduction: 0.2 });
|
||||
columns.body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.85), line_reduction: 0.2 });
|
||||
columns.body.vert = Some(TextDir::Vertical270);
|
||||
let mut col = shape(&mut p, "Columns", 480.0, 40.0, 200.0, 160.0);
|
||||
col.text = Some(columns);
|
||||
@@ -176,7 +176,7 @@ fn chart_table_and_text_corpus_round_trips() {
|
||||
assert_eq!(plain(cols), "Two columns\nof vertical text");
|
||||
assert_eq!(cols.body.columns, Some(3));
|
||||
assert_eq!(cols.body.col_spacing, Some(18.0));
|
||||
assert_eq!(cols.body.autofit, Some(AutoFit::Shrink { font_scale: 0.85, line_reduction: 0.2 }));
|
||||
assert_eq!(cols.body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.85), line_reduction: 0.2 }));
|
||||
assert_eq!(cols.body.vert, Some(TextDir::Vertical270));
|
||||
|
||||
let warp = by_name(slide, "Warp").text.as_ref().expect("warp");
|
||||
|
||||
@@ -79,7 +79,7 @@ fn style_deck() -> Presentation {
|
||||
let mut word = shape(&mut p, "Styled text", 40.0, 40.0, 400.0, 180.0);
|
||||
word.text = Some(TextBody {
|
||||
body: BodyProps {
|
||||
autofit: Some(AutoFit::Shrink { font_scale: 0.8, line_reduction: 0.2 }),
|
||||
autofit: Some(AutoFit::Shrink { font_scale: Some(0.8), line_reduction: 0.2 }),
|
||||
columns: Some(2),
|
||||
col_spacing: Some(12.0),
|
||||
vert: Some(TextDir::Vertical270),
|
||||
@@ -167,7 +167,7 @@ fn style_kinds_round_trip() {
|
||||
let twice = round(&once);
|
||||
|
||||
let body = &by_name(&once, "Styled text").text.as_ref().expect("text").body;
|
||||
assert_eq!(body.autofit, Some(AutoFit::Shrink { font_scale: 0.8, line_reduction: 0.2 }));
|
||||
assert_eq!(body.autofit, Some(AutoFit::Shrink { font_scale: Some(0.8), line_reduction: 0.2 }));
|
||||
assert_eq!(body.columns, Some(2));
|
||||
assert_eq!(body.col_spacing, Some(12.0));
|
||||
assert_eq!(body.vert, Some(TextDir::Vertical270));
|
||||
|
||||
@@ -264,3 +264,26 @@ fn export_of_odd_models_is_valid() {
|
||||
let q = deckcraft_pptx::import(&deckcraft_pptx::export(&empty).expect("export")).expect("import");
|
||||
assert!(!q.masters.is_empty());
|
||||
}
|
||||
|
||||
/// A slide the file lists but that can't be read is left out, and `import_checked` names it
|
||||
/// where `import` would have said nothing (#203).
|
||||
#[test]
|
||||
fn unreadable_slide_is_reported() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let mut second = p.slides.first().expect("a slide").as_ref().clone();
|
||||
second.id = deckcraft_model::SlideId(second.id.0 + 1000);
|
||||
p.slides.push(std::sync::Arc::new(second));
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
|
||||
let (q, lost) = deckcraft_pptx::import_checked(&bytes).expect("import");
|
||||
assert_eq!((q.slides.len(), lost.len()), (2, 0), "{lost:?}");
|
||||
|
||||
let mut es = entries(&bytes);
|
||||
let slide2 = es.iter_mut().find(|(n, _)| n == "ppt/slides/slide2.xml").expect("slide2");
|
||||
slide2.1 = b"<p:sld".to_vec();
|
||||
let broken = rezip(&es);
|
||||
let (q, lost) = deckcraft_pptx::import_checked(&broken).expect("import");
|
||||
assert_eq!(q.slides.len(), 1);
|
||||
assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]);
|
||||
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Synthetic interactive sequences must keep one step per click across a save.
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_geom::Xfrm;
|
||||
use deckcraft_model::anim::{AnimStart, Animation, TextBuild};
|
||||
use deckcraft_model::text::TextBody;
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, defaults};
|
||||
|
||||
fn deck() -> Presentation {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let shapes: Vec<_> = ["Button", "Other button", "Content"]
|
||||
.into_iter()
|
||||
.map(|name| Shape {
|
||||
id: ShapeId(p.alloc_id().expect("id")),
|
||||
name: name.into(),
|
||||
xfrm: Some(Xfrm::new(40.0, 40.0, 200.0, 100.0)),
|
||||
text: Some(TextBody::from_text("First\nSecond\nThird")),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
Arc::make_mut(&mut p.slides[0]).shapes = shapes;
|
||||
p
|
||||
}
|
||||
|
||||
fn round(p: &Presentation) -> Presentation {
|
||||
deckcraft_pptx::import(&deckcraft_pptx::export(p).expect("export")).expect("import")
|
||||
}
|
||||
|
||||
fn slide_xml(p: &Presentation) -> String {
|
||||
let bytes = deckcraft_pptx::export(p).expect("export");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
z.by_name("ppt/slides/slide1.xml").expect("slide").read_to_string(&mut xml).expect("xml");
|
||||
xml
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggered_click_steps_survive_import_edit_save_reopen() {
|
||||
use AnimStart::{AfterPrevious, OnClick, WithPrevious};
|
||||
let mut p = deck();
|
||||
let ids: Vec<_> = p.slides[0].shapes.iter().map(|s| s.id).collect();
|
||||
let starts = [OnClick, WithPrevious, AfterPrevious, OnClick, WithPrevious, OnClick];
|
||||
for (i, start) in starts.into_iter().enumerate() {
|
||||
Arc::make_mut(&mut p.slides[0]).animations.push(Animation {
|
||||
shape: ids[2],
|
||||
trigger: Some(ids[0]),
|
||||
start,
|
||||
effect: "appear".into(),
|
||||
delay_ms: i as u32 * 100,
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
// An independent button and the main sequence must not borrow this button's steps.
|
||||
for trigger in [Some(ids[1]), None] {
|
||||
for start in [OnClick, AfterPrevious, OnClick] {
|
||||
Arc::make_mut(&mut p.slides[0]).animations.push(Animation { shape: ids[2], trigger, start, ..Default::default() });
|
||||
}
|
||||
}
|
||||
let mut q = round(&p);
|
||||
for pass in 0..2 {
|
||||
for trigger in [Some(ids[0]), Some(ids[1]), None] {
|
||||
let state = |deck: &Presentation| {
|
||||
deck.slides[0].animations.iter().filter(|a| a.trigger == trigger).map(|a| (a.start, a.delay_ms)).collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(state(&q), state(&p), "trigger {trigger:?}, pass {pass}");
|
||||
}
|
||||
assert_eq!(q.slides[0].animations.len(), p.slides[0].animations.len());
|
||||
Arc::make_mut(&mut q.slides[0]).shapes[2].name = "Edited content".into();
|
||||
q = round(&q);
|
||||
}
|
||||
let xml = slide_xml(&p);
|
||||
assert_eq!(xml.matches("nodeType=\"interactiveSeq\"").count(), 2);
|
||||
assert_eq!(xml.matches("nodeType=\"clickEffect\"").count(), 7);
|
||||
// The first step in each interactive sequence begins at zero; subsequent steps wait.
|
||||
assert_eq!(xml.matches("delay=\"indefinite\"").count(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggered_paragraph_build_keeps_a_click_for_each_paragraph() {
|
||||
let mut p = deck();
|
||||
let ids: Vec<_> = p.slides[0].shapes.iter().map(|s| s.id).collect();
|
||||
Arc::make_mut(&mut p.slides[0]).animations =
|
||||
vec![Animation { shape: ids[2], trigger: Some(ids[0]), text_build: TextBuild::ByParagraph, start: AnimStart::OnClick, ..Default::default() }];
|
||||
for q in [p.clone(), round(&p)] {
|
||||
let xml = slide_xml(&q);
|
||||
assert_eq!(xml.matches("nodeType=\"clickEffect\"").count(), 3);
|
||||
assert_eq!(xml.matches("delay=\"indefinite\"").count(), 2);
|
||||
let back = round(&q);
|
||||
assert_eq!(back.slides[0].animations.len(), 1);
|
||||
assert_eq!(back.slides[0].animations[0].text_build, TextBuild::ByParagraph);
|
||||
assert_eq!(back.slides[0].animations[0].trigger, Some(ids[0]));
|
||||
}
|
||||
}
|
||||
@@ -222,6 +222,9 @@ pub struct RenderOpts<'a> {
|
||||
pub skip: &'a [ShapeId],
|
||||
/// Hide the text of this shape (the UI draws it while editing).
|
||||
pub skip_text: Option<ShapeId>,
|
||||
/// Text being edited: replace its prompt with gray markers on empty list paragraphs.
|
||||
/// Ignored outside editor rendering.
|
||||
pub editing_text: Option<ShapeId>,
|
||||
/// Animated GIF pictures and how far into their play they are (seconds); others show frame 1.
|
||||
pub gif_times: &'a [(ShapeId, f64)],
|
||||
/// Worker threads (0 = this thread; required when effects use filters).
|
||||
@@ -235,6 +238,10 @@ pub struct RenderOpts<'a> {
|
||||
pub date_text: Option<String>,
|
||||
/// The local date and time date fields show (`None`: the system clock).
|
||||
pub now: Option<DateTime>,
|
||||
/// Leave the slide background out (neither the white underlay nor the background fill), so
|
||||
/// only what is on the slide is drawn over transparency: PNG export with a transparent
|
||||
/// background. Shapes filled with the background (`useBgFill`) still show it.
|
||||
pub transparent_background: bool,
|
||||
}
|
||||
|
||||
impl Default for RenderOpts<'_> {
|
||||
@@ -246,6 +253,7 @@ impl Default for RenderOpts<'_> {
|
||||
state: None,
|
||||
skip: &[],
|
||||
skip_text: None,
|
||||
editing_text: None,
|
||||
gif_times: &[],
|
||||
threads: 0,
|
||||
offset: (0.0, 0.0),
|
||||
@@ -253,6 +261,7 @@ impl Default for RenderOpts<'_> {
|
||||
clear: None,
|
||||
date_text: None,
|
||||
now: None,
|
||||
transparent_background: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -790,6 +799,10 @@ impl Renderer {
|
||||
|
||||
fn background(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, slide: Option<&Slide>, view: Affine) {
|
||||
let (fill, ph) = resolve::background(rctx, slide);
|
||||
if f.opts.transparent_background {
|
||||
self.bg = Some((fill, ph, view));
|
||||
return;
|
||||
}
|
||||
let r = Rect::new(0.0, 0.0, f.pres.slide_size.width, f.pres.slide_size.height);
|
||||
ctx.set_transform(view);
|
||||
let path = r.to_path(0.1);
|
||||
@@ -921,7 +934,7 @@ impl Renderer {
|
||||
}
|
||||
ShapeKind::Table(t) => {
|
||||
// Rows grow with their text: what the cells cover, borders included.
|
||||
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields);
|
||||
let cells = table::draw(ctx, f.pres, rctx, t, m, x.w, x.h, &f.fields, f.opts.edit && f.opts.editing_text == Some(s.id));
|
||||
self.track(m, cells.unwrap_or(Rect::new(0.0, 0.0, x.w, x.h)).inflate(2.0, 2.0));
|
||||
}
|
||||
ShapeKind::Chart(c) => {
|
||||
@@ -1300,8 +1313,21 @@ impl Renderer {
|
||||
&text_geo
|
||||
};
|
||||
let tr = geo.text_rect;
|
||||
if !body.is_empty() {
|
||||
let l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
||||
let editing = f.opts.edit && f.opts.editing_text == Some(s.id);
|
||||
if !body.is_empty() || editing {
|
||||
// Imported placeholders can have no paragraph until the first keystroke.
|
||||
let mut empty;
|
||||
let body = if editing && body.paragraphs.is_empty() {
|
||||
empty = body.clone();
|
||||
empty.paragraphs.push(Default::default());
|
||||
&empty
|
||||
} else {
|
||||
body
|
||||
};
|
||||
let mut l = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields: &f.fields, prompt_color: None, no_shrink: false });
|
||||
if editing {
|
||||
preview_empty_bullets(&mut l);
|
||||
}
|
||||
let tm = text_transform(m, tr, l.rotation);
|
||||
for b in l.lines.iter().filter_map(line_bounds) {
|
||||
// Glyphs reach a little past their carets and line box (italics, swashes).
|
||||
@@ -2034,6 +2060,15 @@ pub fn draw_text(ctx: &mut RenderContext, rctx: &Ctx, s: &Shape, body: &TextBody
|
||||
draw_layout(ctx, &l, tr, m);
|
||||
}
|
||||
|
||||
/// Add transient list markers to an editor layout, using the document's own bullet glyphs.
|
||||
fn preview_empty_bullets(l: &mut deckcraft_text::TextLayout) {
|
||||
for mut run in l.empty_bullets.drain(..) {
|
||||
run.color = Rgba::rgb(0xB3, 0xB3, 0xB3);
|
||||
run.alpha = 1.0;
|
||||
l.runs.push(run);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where text laid out in text rect `tr` is drawn, for a shape drawn with `m` (shape-local →
|
||||
/// output): `m`, without mirroring, then the layout's `rotation` about the rect's centre.
|
||||
///
|
||||
|
||||
+12
-10
@@ -188,16 +188,6 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
|
||||
return;
|
||||
};
|
||||
let px_scale = crate::scale_of(m);
|
||||
let want = (bounds.width().max(bounds.height()) * px_scale).max(1.0);
|
||||
let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t));
|
||||
let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else {
|
||||
missing(ctx, bounds);
|
||||
return;
|
||||
};
|
||||
let (iw, ih) = (pm.width() as f64, pm.height() as f64);
|
||||
if iw < 1.0 || ih < 1.0 {
|
||||
return;
|
||||
}
|
||||
let dest = match &pf.mode {
|
||||
PictureMode::Stretch { fill_rect } => {
|
||||
let [l, t, r, b] = *fill_rect;
|
||||
@@ -211,6 +201,18 @@ pub fn picture(ctx: &mut RenderContext, pres: &Presentation, pf: &PictureFill, b
|
||||
}
|
||||
PictureMode::Tile { .. } => bounds,
|
||||
};
|
||||
// Decode for the whole image's size on screen, not the box's: a cropped picture shows only part
|
||||
// of it, magnified (#762).
|
||||
let want = (dest.width().abs().max(dest.height().abs()) * px_scale).max(1.0);
|
||||
let frame = gif_time.and_then(|t| images::gif_pixmap(&item.data, &pf.adjust, t));
|
||||
let Some(pm) = frame.or_else(|| images::decode_for(&item.data, want, &pf.adjust)) else {
|
||||
missing(ctx, bounds);
|
||||
return;
|
||||
};
|
||||
let (iw, ih) = (pm.width() as f64, pm.height() as f64);
|
||||
if iw < 1.0 || ih < 1.0 {
|
||||
return;
|
||||
}
|
||||
let alpha = pf.alpha.unwrap_or(1.0).clamp(0.0, 1.0);
|
||||
let image = vello_cpu::Image {
|
||||
image: vello_cpu::ImageSource::Pixmap(pm.clone()),
|
||||
|
||||
@@ -327,6 +327,7 @@ pub fn draw(
|
||||
_w: f64,
|
||||
_h: f64,
|
||||
fields: &dyn deckcraft_text::Fields,
|
||||
editing: bool,
|
||||
) -> Option<kurbo::Rect> {
|
||||
let _ = pres;
|
||||
let rects = cell_rects(t, &row_heights(rctx, t, fields));
|
||||
@@ -418,14 +419,18 @@ pub fn draw(
|
||||
// Text.
|
||||
for (r, c, rect) in &rects {
|
||||
let Some(cell) = t.cell(*r, *c) else { continue };
|
||||
if cell.text.is_empty() || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) {
|
||||
if (cell.text.is_empty() && !editing) || text_off_target(cell, *rect, m, (ctx.width(), ctx.height())) {
|
||||
continue;
|
||||
}
|
||||
let body = cell_body(rctx, t, *r, *c, cell);
|
||||
let tmp = Shape { text: Some(body.clone()), ..Default::default() };
|
||||
#[cfg(test)]
|
||||
TEXT_CELLS_DRAWN.with(|n| n.set(n.get() + 1));
|
||||
crate::draw_text(ctx, rctx, &tmp, &body, *rect, m, fields, None);
|
||||
let mut l = deckcraft_text::layout(rctx, &tmp, &body, &deckcraft_text::Opts { rect: *rect, fields, prompt_color: None, no_shrink: false });
|
||||
if editing {
|
||||
crate::preview_empty_bullets(&mut l);
|
||||
}
|
||||
crate::draw_layout(ctx, &l, *rect, m);
|
||||
let _ = peniko::Fill::NonZero;
|
||||
}
|
||||
covered
|
||||
|
||||
@@ -992,6 +992,33 @@ fn a_background_is_laid_over_white() {
|
||||
assert!(g[3] == 255 && (g[0] as i32 - 127).abs() <= 3, "half-transparent black over white is grey: {g:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transparent_background_leaves_the_slide_background_out() {
|
||||
// #784: PNG export with a transparent background draws only what is on the slide. The
|
||||
// background (a solid colour here) and its white underlay are left out; the shapes, and a
|
||||
// shape filled with the background (`useBgFill`), still draw.
|
||||
use deckcraft_model::style::Fill;
|
||||
let blue = ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 200));
|
||||
let red = Shape {
|
||||
id: ShapeId(784),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 200.0, 100.0)),
|
||||
fill: Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(220, 0, 0)))),
|
||||
..Default::default()
|
||||
};
|
||||
let bg_filled = Shape { id: ShapeId(785), xfrm: Some(Xfrm::new(500.0, 100.0, 200.0, 100.0)), fill: Some(Fill::Background), ..Default::default() };
|
||||
let mut p = deck_with(vec![red, bg_filled]);
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).background = Some(deckcraft_model::Background::Fill { fill: Fill::solid(blue) });
|
||||
let opaque = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
assert_eq!(opaque.pixel(20, 20), [0, 0, 200, 255], "without the option the background draws");
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, transparent_background: true, ..Default::default() });
|
||||
assert_eq!(img.pixel(20, 20), [0, 0, 0, 0], "no background, no white underlay");
|
||||
assert_eq!(img.pixel(200, 150), [220, 0, 0, 255], "the slide's shapes still draw");
|
||||
assert_eq!(img.pixel(600, 150), [0, 0, 200, 255], "a background-filled shape still shows the background");
|
||||
// The default theme background (a style reference) is left out too.
|
||||
let img = render_slide(&deck_with(vec![]), 0, &RenderOpts { scale: 0.25, transparent_background: true, ..Default::default() });
|
||||
assert!(img.pixels.as_chunks::<4>().0.iter().all(|px| px[3] == 0), "a blank slide is fully transparent");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colour_glyphs_paint_their_layers() {
|
||||
// A COLRv0 test font (crates/render/tests/fixtures): U+E000 is a red square with a blue
|
||||
@@ -2509,3 +2536,62 @@ fn single_series_column_chart_varies_colours_by_point() {
|
||||
};
|
||||
assert_ne!(render(true), render(false));
|
||||
}
|
||||
|
||||
/// A 1024×1024 PNG of 2 px wide black and white vertical stripes.
|
||||
fn fine_stripes_png() -> Vec<u8> {
|
||||
let img =
|
||||
image::RgbaImage::from_fn(1024, 1024, |x, _| if (x / 2) % 2 == 0 { image::Rgba([0, 0, 0, 255]) } else { image::Rgba([255, 255, 255, 255]) });
|
||||
let mut out = std::io::Cursor::new(Vec::new());
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut out, image::ImageFormat::Png).expect("png");
|
||||
out.into_inner()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cropped_picture_is_decoded_at_the_resolution_of_the_whole_image() {
|
||||
// The top-left sixteenth of the image in a 64 pt box: 64 image pixels over 64 output pixels.
|
||||
// Decoding at the box's size instead of the whole image's blurs the stripes to grey (#762).
|
||||
let mut p = Presentation::default();
|
||||
let media = p.add_media("stripes.png", "image/png", fine_stripes_png()).expect("media");
|
||||
let fill = deckcraft_model::style::PictureFill { media, crop: [0.0, 0.0, 15.0 / 16.0, 15.0 / 16.0], ..Default::default() };
|
||||
let sh = Shape { id: ShapeId(600), xfrm: Some(Xfrm::new(100.0, 100.0, 64.0, 64.0)), kind: ShapeKind::Picture { fill }, ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let (mut sharp, mut total) = (0, 0);
|
||||
for y in 110..154 {
|
||||
for x in 110..154 {
|
||||
let v = img.pixel(x, y)[0];
|
||||
total += 1;
|
||||
if !(48..=208).contains(&v) {
|
||||
sharp += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(sharp * 10 >= total * 9, "the stripes stay sharp: {sharp} of {total} pixels black or white");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
|
||||
use deckcraft_model::text::{Bullet, Paragraph};
|
||||
let mut para = Paragraph::default();
|
||||
para.props.bullet = Some(Bullet::Char { char: "•".into() });
|
||||
para.props.margin_left = Some(24.0);
|
||||
para.props.indent = Some(-24.0);
|
||||
para.end_props.size = Some(32.0);
|
||||
let sh = Shape {
|
||||
id: ShapeId(901),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 120.0)),
|
||||
text: Some(TextBody { paragraphs: vec![para], ..Default::default() }),
|
||||
..Default::default()
|
||||
};
|
||||
let p = deck_with(vec![sh]);
|
||||
let render = |edit, editing_text| render_slide(&p, 0, &RenderOpts { edit, editing_text, ..Default::default() });
|
||||
let plain = render(false, None);
|
||||
assert_eq!(plain.pixels, render(true, None).pixels);
|
||||
assert_eq!(plain.pixels, render(false, Some(ShapeId(901))).pixels, "export ignores editor markers");
|
||||
assert_eq!(plain.pixels, render(true, Some(ShapeId(902))).pixels, "other shapes stay empty");
|
||||
let editing = render(true, Some(ShapeId(901)));
|
||||
let changed: Vec<_> = editing.pixels.as_chunks::<4>().0.iter().zip(plain.pixels.as_chunks::<4>().0).filter(|(a, b)| a != b).collect();
|
||||
assert!(!changed.is_empty(), "an empty bullet must be visible");
|
||||
assert!(changed.iter().any(|(px, _)| px[0] == 179 && px[1] == 179 && px[2] == 179));
|
||||
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> {
|
||||
state: o.state,
|
||||
skip: o.skip,
|
||||
skip_text: o.skip_text,
|
||||
editing_text: o.editing_text,
|
||||
gif_times: o.gif_times,
|
||||
threads: o.threads,
|
||||
offset: o.offset,
|
||||
@@ -112,6 +113,7 @@ fn copy_opts<'a>(o: &RenderOpts<'a>) -> RenderOpts<'a> {
|
||||
clear: o.clear,
|
||||
date_text: o.date_text.clone(),
|
||||
now: o.now,
|
||||
transparent_background: o.transparent_background,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+78
-56
@@ -292,6 +292,9 @@ pub struct LineInfo {
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct TextLayout {
|
||||
pub runs: Vec<GlyphRun>,
|
||||
/// Empty list markers for the editor only; ordinary rendering and export omit them.
|
||||
/// Their space is still reserved so the caret starts where the first character will.
|
||||
pub empty_bullets: Vec<GlyphRun>,
|
||||
pub decos: Vec<Deco>,
|
||||
pub lines: Vec<LineInfo>,
|
||||
/// Inner text box (insets applied), shape-local.
|
||||
@@ -739,56 +742,52 @@ fn shape_para(ctx: &Ctx, shape: &Shape, body: &TextBody, pi: usize, scale: f64,
|
||||
let lead_size = lead_style.size;
|
||||
let lead_face = lead_style.face.clone();
|
||||
// Bullet.
|
||||
let bullet = if para.is_empty() && opts.prompt_color.is_none() {
|
||||
None
|
||||
} else {
|
||||
match &props.bullet {
|
||||
Some(Bullet::Char { char }) => {
|
||||
let mut rp = lead.clone();
|
||||
if let Some(f) = &props.bullet_font {
|
||||
rp.font = Some(f.clone());
|
||||
}
|
||||
rp.bold = Some(false);
|
||||
rp.italic = Some(false);
|
||||
if let Some(c) = &props.bullet_color {
|
||||
rp.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||||
rp.underline = None;
|
||||
rp.strike = None;
|
||||
rp.highlight = None;
|
||||
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
st.baseline = 0.0;
|
||||
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
|
||||
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
|
||||
bullet_face(db, &mut st, &ch);
|
||||
let bullet = match &props.bullet {
|
||||
Some(Bullet::Char { char }) => {
|
||||
let mut rp = lead.clone();
|
||||
if let Some(f) = &props.bullet_font {
|
||||
rp.font = Some(f.clone());
|
||||
}
|
||||
rp.bold = Some(false);
|
||||
rp.italic = Some(false);
|
||||
if let Some(c) = &props.bullet_color {
|
||||
rp.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||||
rp.underline = None;
|
||||
rp.strike = None;
|
||||
rp.highlight = None;
|
||||
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
st.baseline = 0.0;
|
||||
// Symbol fonts (Wingdings) aren't available: map common private-use bullets.
|
||||
let ch = map_symbol_bullet(char, props.bullet_font.as_deref());
|
||||
bullet_face(db, &mut st, &ch);
|
||||
|
||||
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
|
||||
// at U+F000 + the character code as such fonts map them; otherwise common symbol
|
||||
// bullets are mapped to Unicode look-alikes.
|
||||
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
|
||||
Some(c) => c.to_string(),
|
||||
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
|
||||
};
|
||||
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
|
||||
st.face = db.face("Inter", "Regular");
|
||||
}
|
||||
Some((ch, st))
|
||||
// A symbol font (Wingdings, Symbol…) that is installed draws its own glyph, found
|
||||
// at U+F000 + the character code as such fonts map them; otherwise common symbol
|
||||
// bullets are mapped to Unicode look-alikes.
|
||||
let ch = match symbol_font_char(&st.face, char, props.bullet_font.as_deref()) {
|
||||
Some(c) => c.to_string(),
|
||||
None => map_symbol_bullet(char, props.bullet_font.as_deref()),
|
||||
};
|
||||
if !st.face.covers(ch.chars().next().unwrap_or('•')) {
|
||||
st.face = db.face("Inter", "Regular");
|
||||
}
|
||||
Some(Bullet::AutoNum { scheme, start_at }) => {
|
||||
let mut rp = lead.clone();
|
||||
if let Some(c) = &props.bullet_color {
|
||||
rp.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||||
rp.underline = None;
|
||||
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
|
||||
bullet_face(db, &mut st, &label);
|
||||
Some((label, st))
|
||||
}
|
||||
_ => None,
|
||||
Some((ch, st))
|
||||
}
|
||||
Some(Bullet::AutoNum { scheme, start_at }) => {
|
||||
let mut rp = lead.clone();
|
||||
if let Some(c) = &props.bullet_color {
|
||||
rp.fill = Some(Fill::solid(c.clone()));
|
||||
}
|
||||
rp.size = Some(lead.size.unwrap_or(18.0) * props.bullet_size.unwrap_or(1.0));
|
||||
rp.underline = None;
|
||||
let mut st = make_style(ctx, &rp, scale, opts.prompt_color);
|
||||
let label = autonum(scheme, start_at.saturating_sub(1).saturating_add(number.unwrap_or(1)));
|
||||
bullet_face(db, &mut st, &label);
|
||||
Some((label, st))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
ParaShaped { cells, styles, faces, props, lead_size, lead_face, bullet, level: para.level, rtl }
|
||||
}
|
||||
@@ -1549,18 +1548,21 @@ pub fn layout(ctx: &Ctx, shape: &Shape, body: &TextBody, opts: &Opts) -> TextLay
|
||||
let autofit = bp.autofit.unwrap_or(AutoFit::None);
|
||||
// Shrink on overflow starts from the scale the file was saved with (`fontScale`,
|
||||
// `lnSpcReduction`), which is what PowerPoint draws; editing the text resets it.
|
||||
let (scale0, reduce0) = match (autofit, opts.no_shrink) {
|
||||
let (scale0, reduce0, no_saved_scale) = match (autofit, opts.no_shrink) {
|
||||
(AutoFit::Shrink { font_scale, line_reduction }, false) => (
|
||||
if font_scale.is_finite() { font_scale.clamp(0.1, 1.0) } else { 1.0 },
|
||||
font_scale.filter(|s| s.is_finite()).map(|s| s.clamp(0.1, 1.0)).unwrap_or(1.0),
|
||||
if line_reduction.is_finite() { line_reduction.clamp(0.0, 0.2) } else { 0.0 },
|
||||
font_scale.is_none(),
|
||||
),
|
||||
_ => (1.0, 0.0),
|
||||
_ => (1.0, 0.0, false),
|
||||
};
|
||||
let mut l = layout_scaled(ctx, shape, body, &bp, opts, scale0, reduce0);
|
||||
// A scale PowerPoint saved is drawn as it is: PowerPoint fits the text again only when it
|
||||
// changes (and editing resets the saved scale), not when a file opens, even where our
|
||||
// metrics would call it overflowing (trailing empty paragraphs, substituted fonts).
|
||||
let saved = scale0 < 1.0 || reduce0 > 0.0;
|
||||
// A body whose file has no `fontScale` at all (`<a:normAutofit/>`) is the schema default
|
||||
// 100 % and is drawn as it is too, not refitted on open (#724).
|
||||
let saved = no_saved_scale || scale0 < 1.0 || reduce0 > 0.0;
|
||||
if let (AutoFit::Shrink { .. }, false) = (autofit, opts.no_shrink)
|
||||
&& l.overflow
|
||||
&& !saved
|
||||
@@ -1654,6 +1656,10 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
*deeper = 0;
|
||||
}
|
||||
numbering.get(lvl).copied()
|
||||
} else if matches!(pp.bullet, Some(Bullet::AutoNum { .. })) {
|
||||
// Preview the next number without consuming it: empty paragraphs do not advance
|
||||
// numbering in the saved document or affect later populated paragraphs.
|
||||
numbering.get(lvl).map(|n| n.saturating_add(1))
|
||||
} else {
|
||||
if !para.is_empty() {
|
||||
for n in numbering.iter_mut().skip(lvl) {
|
||||
@@ -1908,9 +1914,20 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
// PowerPoint draws justified and distributed anchoring at the bottom.
|
||||
Anchor::Bottom | Anchor::Justified | Anchor::Distributed => inner.height() - used_h,
|
||||
};
|
||||
// anchorCtr: centre the block horizontally.
|
||||
let dx = if bp.anchor_ctr.unwrap_or(false) { ((inner.width() - out.content_width) / 2.0).max(0.0) } else { 0.0 };
|
||||
let ox = inner.x0 + dx;
|
||||
// anchorCtr: centre the drawn text block on the box. Lines sit where their alignment put
|
||||
// them, so the origin is set so the block's centre lands on the box's centre — a left-edge
|
||||
// offset alone pushed right-aligned lines out of the box (#736). Empty lines carry no ink
|
||||
// and stay out of the block. Overflowing content centres symmetrically past both edges.
|
||||
// Not applied to vertical text (there the offset would run along the wrong axis) or
|
||||
// multi-column bodies (centring unimplemented for them).
|
||||
let centre_block = bp.anchor_ctr.unwrap_or(false) && ncols == 1 && matches!(vert, TextDir::Horizontal);
|
||||
let ox = if centre_block && !all_lines.is_empty() {
|
||||
let ink = all_lines.iter().filter(|(li, ..)| li.end > li.start);
|
||||
let (bmin, bmax) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), (li, ..)| (a.min(li.extent.0), b.max(li.extent.1)));
|
||||
if bmax >= bmin { inner.x0 + inner.width() / 2.0 - (bmin + bmax) / 2.0 } else { inner.x0 }
|
||||
} else {
|
||||
inner.x0
|
||||
};
|
||||
let oy = inner.y0 + dy;
|
||||
// Emit runs, in visual order.
|
||||
for (line, place, _, bullet) in all_lines.iter_mut() {
|
||||
@@ -1930,7 +1947,7 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
&& let Some((_, st)) = p.bullet.as_ref()
|
||||
{
|
||||
let (_, g) = measure_str(&st.face, st.size, &s);
|
||||
out.runs.push(GlyphRun {
|
||||
let run = GlyphRun {
|
||||
face: st.face.clone(),
|
||||
size: st.size,
|
||||
color: st.color,
|
||||
@@ -1946,7 +1963,12 @@ fn layout_scaled(ctx: &Ctx, shape: &Shape, body: &TextBody, bp: &BodyProps, opts
|
||||
gradient: None,
|
||||
effects: None,
|
||||
upright: false,
|
||||
});
|
||||
};
|
||||
if body.paragraphs.get(line.para).is_some_and(|p| p.is_empty()) && opts.prompt_color.is_none() {
|
||||
out.empty_bullets.push(run);
|
||||
} else {
|
||||
out.runs.push(run);
|
||||
}
|
||||
}
|
||||
// Group visually consecutive cells by (style, face).
|
||||
let mut cur: Option<GlyphRun> = None;
|
||||
|
||||
+143
-6
@@ -186,26 +186,38 @@ fn saved_shrink_scale_is_drawn() {
|
||||
// A file saved with fontScale 62.5% and lnSpcReduction 20% draws at that scale even though
|
||||
// the text would fit at full size, as PowerPoint draws it.
|
||||
let (p, mut sh) = setup("Short", 600.0, 300.0);
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.625, line_reduction: 0.2 });
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.625), line_reduction: 0.2 });
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
assert_eq!((l.font_scale, l.line_reduction), (0.625, 0.2));
|
||||
// Text our metrics find overflowing at the saved scale is still drawn at it, as PowerPoint
|
||||
// draws it until the text changes; with no saved scale it shrinks to fit.
|
||||
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
|
||||
let (p, mut sh) = setup(&text, 600.0, 200.0);
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 0.9, line_reduction: 0.1 });
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(0.9), line_reduction: 0.1 });
|
||||
let l = lay(&p, &sh, 600.0, 200.0);
|
||||
assert_eq!((l.font_scale, l.line_reduction), (0.9, 0.1));
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: 1.0, line_reduction: 0.0 });
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(1.0), line_reduction: 0.0 });
|
||||
let l = lay(&p, &sh, 600.0, 200.0);
|
||||
assert!(l.font_scale < 0.9, "{}", l.font_scale);
|
||||
// Nonsense saved values are ignored.
|
||||
let (p, mut sh) = setup("Short", 600.0, 300.0);
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: f64::NAN, line_reduction: 5.0 });
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: Some(f64::NAN), line_reduction: 5.0 });
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
assert_eq!((l.font_scale, l.line_reduction), (1.0, 0.2));
|
||||
}
|
||||
|
||||
/// A body whose file has `<a:normAutofit/>` with no `fontScale` is the schema default 100 %:
|
||||
/// PowerPoint draws it as it is, even overflowing. DeckCraft draws it the same (#724).
|
||||
#[test]
|
||||
fn bare_normautofit_draws_at_full_size() {
|
||||
let text = (0..30).map(|i| format!("Line number {i}")).collect::<Vec<_>>().join("\n");
|
||||
let (p, mut sh) = setup(&text, 600.0, 200.0);
|
||||
sh.text.as_mut().unwrap().body.autofit = Some(AutoFit::Shrink { font_scale: None, line_reduction: 0.0 });
|
||||
let l = lay(&p, &sh, 600.0, 200.0);
|
||||
assert_eq!(l.font_scale, 1.0, "a body with no saved fontScale draws at 100%");
|
||||
assert!(l.overflow, "it overflows, as PowerPoint draws it");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_line_spacing_ends_at_the_last_descent() {
|
||||
// 12 pt text at exactly 26.59 pt per line (a table cell PowerPoint sizes to 24.3 pt of text):
|
||||
@@ -942,8 +954,11 @@ fn symbol_font_characters_draw_in_the_sym_font() {
|
||||
// U+F0DE is the Symbol font's double arrow (⇒). It must come from that font, not from
|
||||
// whichever installed font happens to map U+F0DE (Wingdings draws an arrow at another angle).
|
||||
let symbol = "Symbol";
|
||||
if !FontDb::global().has_family(symbol) {
|
||||
eprintln!("skipped: no Symbol font installed");
|
||||
let db = FontDb::global();
|
||||
// Some installed Symbol faces use Unicode mappings only, without the legacy U+F0xx cmap.
|
||||
// This integration test needs a face that can draw the symbol code used by the document.
|
||||
if !db.has_family(symbol) || !db.face(symbol, "Regular").covers('\u{F0DE}') {
|
||||
eprintln!("skipped: no installed Symbol face covers U+F0DE");
|
||||
return;
|
||||
}
|
||||
let (p, mut sh) = setup("x\u{F0DE}y", 600.0, 300.0);
|
||||
@@ -1330,3 +1345,125 @@ fn stacked_combining_marks_take_a_face_that_draws_them() {
|
||||
}
|
||||
let _ = f;
|
||||
}
|
||||
|
||||
/// #736: `anchorCtr` centres the text block horizontally, whatever the paragraphs' own
|
||||
/// alignment, and overflowing content centres symmetrically past both edges.
|
||||
#[test]
|
||||
fn anchorctr_centres_the_text_block() {
|
||||
let text = "The shape is top - center - overriding paragraph right alignment.";
|
||||
// The block's centre: (min left + max right) over the laid-out lines, in box coordinates.
|
||||
let block_centre = |l: &TextLayout| -> f64 {
|
||||
// Empty lines carry no ink and sit where their alignment left them: not part of the block.
|
||||
let ink = l.lines.iter().filter(|li| li.end > li.start);
|
||||
let (min, max) = ink.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), li| (a.min(li.extent.0), b.max(li.extent.1)));
|
||||
(min + max) / 2.0
|
||||
};
|
||||
let para = |align: Option<Align>, text: &str| Paragraph {
|
||||
props: ParaProps { align, ..Default::default() },
|
||||
runs: vec![Run::new(text)],
|
||||
..Default::default()
|
||||
};
|
||||
let anchored =
|
||||
|paragraphs: Vec<Paragraph>| TextBody { body: BodyProps { anchor_ctr: Some(true), ..Default::default() }, paragraphs, ..Default::default() };
|
||||
// One right-aligned line in a wide box: the block is centred, not pushed past the right edge.
|
||||
let body_right = anchored(vec![para(Some(Align::Right), text)]);
|
||||
let (p, mut sh) = setup("", 828.0, 104.0);
|
||||
sh.text = Some(body_right);
|
||||
let l = lay(&p, &sh, 828.0, 104.0);
|
||||
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "right-aligned: centre {}, want 414", block_centre(&l));
|
||||
// Left-aligned text centres the same way.
|
||||
let body_left = anchored(vec![para(None, text)]);
|
||||
let (p, mut sh) = setup("", 828.0, 104.0);
|
||||
sh.text = Some(body_left);
|
||||
let l = lay(&p, &sh, 828.0, 104.0);
|
||||
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "left-aligned: centre {}, want 414", block_centre(&l));
|
||||
// A trailing empty paragraph carries no ink and must not skew the block.
|
||||
let body_empty = anchored(vec![para(Some(Align::Right), text), para(None, "")]);
|
||||
let (p, mut sh) = setup("", 828.0, 104.0);
|
||||
sh.text = Some(body_empty);
|
||||
let l = lay(&p, &sh, 828.0, 104.0);
|
||||
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "trailing empty paragraph: centre {}, want 414", block_centre(&l));
|
||||
// Mixed alignments: the drawn block (not either alignment edge) is what centres.
|
||||
let body_mixed = anchored(vec![para(Some(Align::Right), text), para(None, "short")]);
|
||||
let (p, mut sh) = setup("", 828.0, 104.0);
|
||||
sh.text = Some(body_mixed);
|
||||
let l = lay(&p, &sh, 828.0, 104.0);
|
||||
assert!((block_centre(&l) - 414.0).abs() <= 1.0, "mixed: centre {}, want 414", block_centre(&l));
|
||||
// Content wider than the box overflows symmetrically past both edges (no wrap).
|
||||
let body_wide = TextBody {
|
||||
body: BodyProps { anchor_ctr: Some(true), wrap: Some(false), ..Default::default() },
|
||||
paragraphs: vec![para(Some(Align::Right), text)],
|
||||
..Default::default()
|
||||
};
|
||||
let (p, mut sh) = setup("", 300.0, 104.0);
|
||||
sh.text = Some(body_wide);
|
||||
let l = lay(&p, &sh, 300.0, 104.0);
|
||||
assert!((block_centre(&l) - 150.0).abs() <= 2.0, "overflowing: centre {}, want 150", block_centre(&l));
|
||||
// Control: without anchorCtr a right-aligned line still ends at the box's right edge.
|
||||
let body_control = TextBody { body: BodyProps::default(), paragraphs: vec![para(Some(Align::Right), text)], ..Default::default() };
|
||||
let (p, mut sh) = setup(text, 828.0, 104.0);
|
||||
sh.text = Some(body_control);
|
||||
let l = lay(&p, &sh, 828.0, 104.0);
|
||||
assert!(
|
||||
l.lines.iter().all(|li| li.extent.1 > 810.0),
|
||||
"control: right-aligned at the box edge, got {:?}",
|
||||
l.lines.iter().map(|li| li.extent).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_bullets_reserve_the_typed_indent_without_becoming_content() {
|
||||
for rtl in [false, true] {
|
||||
for level in [0, 1, 2] {
|
||||
let (p, mut sh) = setup("First\n", 600.0, 300.0);
|
||||
for para in &mut sh.text.as_mut().unwrap().paragraphs {
|
||||
para.level = level;
|
||||
para.props.rtl = Some(rtl);
|
||||
para.props.align = Some(if rtl { Align::Right } else { Align::Left });
|
||||
}
|
||||
let empty = lay(&p, &sh, 600.0, 300.0);
|
||||
assert_eq!(empty.empty_bullets.len(), 1);
|
||||
assert_eq!(empty.empty_bullets[0].para, 1);
|
||||
assert!(empty.runs.iter().all(|r| r.para == 0), "empty bullet must not be exported");
|
||||
let caret = empty.caret(Pos { para: 1, ch: 0 }).unwrap();
|
||||
let bx = empty.empty_bullets[0].glyphs[0].1;
|
||||
assert!(if rtl { bx >= caret.0 } else { bx < caret.0 });
|
||||
deckcraft_model::edit::insert(sh.text.as_mut().unwrap(), (1, 0), if rtl { "אבג" } else { "Next" }, None);
|
||||
let typed = lay(&p, &sh, 600.0, 300.0);
|
||||
assert!(typed.empty_bullets.is_empty());
|
||||
let after = typed.caret(Pos { para: 1, ch: 0 }).unwrap();
|
||||
assert!(
|
||||
(caret.0 - after.0).abs() < 1e-6 && (caret.1 - after.1).abs() < 1e-6 && (caret.2 - after.2).abs() < 1e-6,
|
||||
"typing must not move the line's start: {caret:?} vs {after:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_numbered_paragraph_previews_the_next_number_without_consuming_it() {
|
||||
let (p, mut sh) = setup("One\n\nTwo", 600.0, 300.0);
|
||||
for para in &mut sh.text.as_mut().unwrap().paragraphs {
|
||||
para.props.bullet = Some(Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 4 });
|
||||
}
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
let preview = &l.empty_bullets[0];
|
||||
let next = l.runs.iter().find(|r| r.para == 2 && r.cells.is_empty()).unwrap();
|
||||
let ids = |r: &GlyphRun| r.glyphs.iter().map(|g| g.0).collect::<Vec<_>>();
|
||||
assert_eq!(preview.para, 1);
|
||||
assert_eq!(ids(preview), ids(next), "both should use 5.");
|
||||
let expected = measure_str(&preview.face, preview.size, "5.").1;
|
||||
assert_eq!(ids(preview), expected.iter().map(|g| g.0).collect::<Vec<_>>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_plain_paragraphs_and_soft_breaks_have_no_bullet_preview() {
|
||||
let (p, mut sh) = setup("", 600.0, 300.0);
|
||||
sh.text.as_mut().unwrap().paragraphs[0].props.bullet = Some(Bullet::None);
|
||||
assert!(lay(&p, &sh, 600.0, 300.0).empty_bullets.is_empty());
|
||||
sh.text = Some(TextBody::from_text("First"));
|
||||
deckcraft_model::edit::insert(sh.text.as_mut().unwrap(), (0, 5), "\u{b}", None);
|
||||
let l = lay(&p, &sh, 600.0, 300.0);
|
||||
assert_eq!(l.lines.len(), 2);
|
||||
assert!(l.empty_bullets.is_empty(), "a soft line break is not a new list item");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! 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 picked for that selection.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_model::ShapeId;
|
||||
use egui::accesskit::Toggled;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::{NodeT, Queryable};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// A blank slide with three rectangles at different places; `select` picks which are selected.
|
||||
fn harness(select: &[usize]) -> (Harness<'static, SlideApp>, Vec<ShapeId>) {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let ids: Vec<ShapeId> = [[100.0, 100.0], [300.0, 200.0], [500.0, 250.0]]
|
||||
.iter()
|
||||
.map(|[x, y]| {
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [x, y, 80.0, 60.0]})).unwrap();
|
||||
ShapeId(r["id"].as_u64().unwrap() as u32)
|
||||
})
|
||||
.collect();
|
||||
let picked: Vec<u32> = select.iter().map(|&i| ids[i].0).collect();
|
||||
s.execute("selection.select", &json!({"ids": picked})).unwrap();
|
||||
let mut app = SlideApp::new(s, Services::default());
|
||||
app.ui.tab = "Shape Format".into();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1800.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
(h, ids)
|
||||
}
|
||||
|
||||
fn pick(h: &mut Harness<'_, SlideApp>, item: &str) {
|
||||
h.get_by_label("Align").click();
|
||||
h.run_steps(2);
|
||||
h.get_by_label(item).click();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
fn marked(h: &mut Harness<'_, SlideApp>) -> (bool, bool) {
|
||||
h.get_by_label("Align").click();
|
||||
h.run_steps(2);
|
||||
let m = (
|
||||
h.get_by_label("Align to Slide").accesskit_node().toggled() == Some(Toggled::True),
|
||||
h.get_by_label("Align Selected Objects").accesskit_node().toggled() == Some(Toggled::True),
|
||||
);
|
||||
// Esc would also clear the selection: a second click on Align closes the menu.
|
||||
h.get_by_label("Align").click();
|
||||
h.run_steps(2);
|
||||
m
|
||||
}
|
||||
|
||||
fn pos(h: &Harness<'_, SlideApp>, id: ShapeId) -> (f64, f64) {
|
||||
let x = h.state().session.active().unwrap().shape(id).unwrap().xfrm.unwrap();
|
||||
(x.x, x.y)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_to_slide_moves_align_left_to_the_slide_edge() {
|
||||
let (mut h, ids) = harness(&[0, 1]);
|
||||
pick(&mut h, "Align to Slide");
|
||||
pick(&mut h, "Align Left");
|
||||
assert_eq!(pos(&h, ids[0]).0, 0.0);
|
||||
assert_eq!(pos(&h, ids[1]).0, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_object_aligns_to_the_slide_and_two_to_each_other() {
|
||||
let (mut h, ids) = harness(&[1]);
|
||||
assert_eq!(marked(&mut h), (true, false));
|
||||
pick(&mut h, "Align Left");
|
||||
assert_eq!(pos(&h, ids[1]).0, 0.0);
|
||||
|
||||
let (mut h, ids) = harness(&[0, 1]);
|
||||
assert_eq!(marked(&mut h), (false, true));
|
||||
pick(&mut h, "Align Left");
|
||||
assert_eq!(pos(&h, ids[1]).0, 100.0, "the left edge of the selection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_choice_stays_until_the_selection_changes() {
|
||||
let (mut h, ids) = harness(&[0, 1]);
|
||||
pick(&mut h, "Align to Slide");
|
||||
pick(&mut h, "Align Left");
|
||||
pick(&mut h, "Align Top");
|
||||
assert_eq!(pos(&h, ids[0]), (0.0, 0.0));
|
||||
assert_eq!(pos(&h, ids[1]), (0.0, 0.0));
|
||||
assert_eq!(marked(&mut h), (true, false));
|
||||
|
||||
// Another selection starts again from its own default.
|
||||
h.state_mut().run("selection.select", json!({"ids": [ids[1].0, ids[2].0]})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(marked(&mut h), (false, true));
|
||||
pick(&mut h, "Align Bottom");
|
||||
assert_eq!(pos(&h, ids[1]).1, 250.0, "the bottom of the selection, not of the slide");
|
||||
}
|
||||
@@ -175,6 +175,8 @@ pub struct Backstage {
|
||||
pub file_type: &'static str,
|
||||
pub handouts_per_page: u8,
|
||||
pub pdf_hidden: bool,
|
||||
/// Export › Change File Type › PNG: leave the slide background out (#784).
|
||||
pub png_transparent: bool,
|
||||
}
|
||||
|
||||
impl Backstage {
|
||||
@@ -197,6 +199,7 @@ impl Backstage {
|
||||
file_type: "pptx",
|
||||
handouts_per_page: 3,
|
||||
pdf_hidden: false,
|
||||
png_transparent: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1310,8 +1313,15 @@ fn export(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage) {
|
||||
}
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
let png = bs.file_type == "png";
|
||||
if png {
|
||||
ui.checkbox(&mut bs.png_transparent, "Transparent background")
|
||||
.on_hover_text("Leave the slide background out: only what is on the slide is drawn");
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
if square_button(ui, Icon::SaveAs, "Save As", true).clicked() {
|
||||
app.export_as(bs.file_type, json!({}));
|
||||
let extra = if png && bs.png_transparent { json!({"transparentBackground": true}) } else { json!({}) };
|
||||
app.export_as(bs.file_type, extra);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,6 +302,32 @@ fn save_as_and_export_run_the_file_commands() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Export › Change File Type › PNG offers "Transparent background" (#784): the PNG it saves
|
||||
/// leaves the slide background out. Other types don't show the option.
|
||||
#[test]
|
||||
fn change_file_type_png_can_leave_the_background_transparent() {
|
||||
let dir = temp_dir("png-clear");
|
||||
let (services, _) = saving_into(&dir);
|
||||
let mut h = harness(SlideApp::new(Session::with_new(), services));
|
||||
h.state_mut().run("app.backstage", json!({"page": "export"})).unwrap();
|
||||
h.run_steps(3);
|
||||
click_on_page(&h, "Change File Type");
|
||||
h.run_steps(3);
|
||||
assert!(h.query_by_label("Transparent background").is_none(), "only PNG has the option");
|
||||
click_on_page(&h, "PNG Picture (*.png)");
|
||||
h.run_steps(2);
|
||||
h.get_by_label("Transparent background").click();
|
||||
h.run_steps(2);
|
||||
assert!(h.state().backstage.as_ref().is_some_and(|b| b.png_transparent));
|
||||
click_on_page(&h, "Save As");
|
||||
h.run_steps(3);
|
||||
let title = h.state().session.active().unwrap().title();
|
||||
let png = std::fs::read(dir.join(format!("{title}.png"))).unwrap();
|
||||
let img = deckcraft_render::decode_image(&png).unwrap();
|
||||
assert_eq!(img.get_pixel(2, 2).0[3], 0, "the background is left out");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Close closes a saved presentation at once and asks about unsaved changes first.
|
||||
#[test]
|
||||
fn close_closes_and_asks_about_unsaved_changes() {
|
||||
|
||||
@@ -181,20 +181,18 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
// One texture pixel in screen points, and the canvas in pixels from the slide's corner.
|
||||
let pixel = vec2(slide_rect.width() / px.0 as f32, slide_rect.height() / px.1 as f32);
|
||||
let visible = visible_region(avail, slide_rect, pixel);
|
||||
let typing_into = st.and_then(|d| d.selection.text.as_ref()).filter(|t| !t.notes).map(|t| t.shape);
|
||||
let tex = match st.map(|d| (d.selection.target, d)) {
|
||||
Some((Target::Slides, d)) => {
|
||||
let idx = d.selection.slide;
|
||||
let doc = d.doc.clone();
|
||||
let frame = anim.as_ref().map(|(tl, t)| (tl, *t));
|
||||
// A placeholder's prompt makes way for the caret once it is being typed into.
|
||||
let typing_into = d.selection.text.as_ref().filter(|t| !t.notes && t.cell.is_none()).map(|t| t.shape);
|
||||
let no_prompt = typing_into.filter(|id| d.shape(*id).and_then(|sh| sh.text.as_ref()).is_none_or(|body| body.is_empty()));
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×, no_prompt, frame)
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×, typing_into, frame)
|
||||
}
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible),
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible, typing_into),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible, typing_into),
|
||||
Some((t @ (Target::Handout | Target::NotesMaster), d)) => {
|
||||
deckcraft_engine::master_page(&d.doc, t).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible))
|
||||
deckcraft_engine::master_page(&d.doc, t).and_then(|p| master_tex(ui.ctx(), &p, 0, None, px, visible, typing_into))
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
@@ -418,6 +416,7 @@ fn master_tex(
|
||||
layout: Option<usize>,
|
||||
px: (u32, u32),
|
||||
visible: deckcraft_render::Region,
|
||||
editing_text: Option<deckcraft_model::ShapeId>,
|
||||
) -> Option<(egui::TextureHandle, deckcraft_render::Region)> {
|
||||
let scale = px.0 as f64 / doc.slide_size.width.max(1.0);
|
||||
let region = crate::textures::fit_texture(
|
||||
@@ -429,7 +428,7 @@ fn master_tex(
|
||||
doc,
|
||||
master,
|
||||
layout,
|
||||
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
||||
&deckcraft_render::RenderOpts { scale, edit: true, editing_text, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
||||
);
|
||||
Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
|
||||
}
|
||||
@@ -1476,6 +1475,61 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The first empty placeholder and the paragraph after Enter keep a list marker and indent.
|
||||
#[test]
|
||||
fn empty_bullet_preview_follows_click_type_enter_and_toggle() {
|
||||
let mut h = harness();
|
||||
h.state_mut().run("slide.new", json!({"layout": "titleAndContent"})).unwrap();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let sh = st
|
||||
.shapes()
|
||||
.iter()
|
||||
.find(|s| s.ph_type() == Some(deckcraft_model::PhType::Body) || s.ph_type() == Some(deckcraft_model::PhType::Obj))
|
||||
.unwrap();
|
||||
let id = sh.id;
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
let p = screen(h.state(), x.x + 40.0, x.y + 20.0);
|
||||
click(&mut h, p);
|
||||
h.run_steps(3);
|
||||
let caret = |h: &Harness<'_, SlideApp>| {
|
||||
let st = h.state().session.active().unwrap();
|
||||
let ts = st.selection.text.as_ref().expect("click starts typing");
|
||||
assert_eq!(ts.shape, id);
|
||||
deckcraft_engine::cmd::text::caret_line(st, ts).unwrap().0
|
||||
};
|
||||
let first = caret(&h);
|
||||
h.event(Event::Text("First point".into()));
|
||||
h.run_steps(3);
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
let second = caret(&h);
|
||||
assert!((first.x - second.x).abs() < 0.01, "empty lines retain the bullet indent");
|
||||
assert!(second.y > first.y);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let l = deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap();
|
||||
assert_eq!(l.empty_bullets.len(), 1);
|
||||
assert_eq!(l.empty_bullets[0].para, 1);
|
||||
let renders = h.state().textures.canvas_renders;
|
||||
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().textures.canvas_renders > renders, "leaving text editing refreshes the marker texture");
|
||||
h.state_mut().run("text.edit", json!({"id": id, "at": [1, 0]})).unwrap();
|
||||
h.run_steps(3);
|
||||
h.event(Event::Text("Second point".into()));
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
let ts = st.selection.text.as_ref().unwrap();
|
||||
assert_eq!(deckcraft_engine::cmd::text::body_of(st, ts).unwrap().text(), "First point\nSecond point");
|
||||
assert!(deckcraft_engine::cmd::text::layout_for(st, ts).unwrap().empty_bullets.is_empty());
|
||||
h.key_press(egui::Key::Enter);
|
||||
h.run_steps(3);
|
||||
h.state_mut().run("format.bullets", json!({})).unwrap();
|
||||
h.run_steps(3);
|
||||
let st = h.state().session.active().unwrap();
|
||||
assert!(deckcraft_engine::cmd::text::layout_for(st, st.selection.text.as_ref().unwrap()).unwrap().empty_bullets.is_empty());
|
||||
}
|
||||
|
||||
/// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked.
|
||||
#[test]
|
||||
fn double_click_in_a_table_cell_shows_the_caret_there() {
|
||||
|
||||
@@ -28,12 +28,34 @@ pub struct ControlRequest {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub reply: Sender<ControlResponse>,
|
||||
/// Who has the request: [`PENDING`], [`TAKEN`] (the app runs it) or [`ABANDONED`] (the
|
||||
/// sender gave up on it, so the app must not run it late: issue #719).
|
||||
pub state: std::sync::Arc<std::sync::atomic::AtomicU8>,
|
||||
}
|
||||
|
||||
/// Queued, not yet run.
|
||||
pub const PENDING: u8 = 0;
|
||||
/// The app has started it: it will answer.
|
||||
pub const TAKEN: u8 = 1;
|
||||
/// Its sender gave up waiting (the reply timeout expired): it is dropped unrun.
|
||||
pub const ABANDONED: u8 = 2;
|
||||
|
||||
impl ControlRequest {
|
||||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<ControlResponse>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
(Self { method: method.into(), params, reply: tx }, rx)
|
||||
(Self { method: method.into(), params, reply: tx, state: Default::default() }, rx)
|
||||
}
|
||||
|
||||
/// Claim the request for running; false when its sender already gave up on it.
|
||||
pub fn take(&self) -> bool {
|
||||
use std::sync::atomic::Ordering;
|
||||
self.state.compare_exchange(PENDING, TAKEN, Ordering::SeqCst, Ordering::SeqCst).is_ok() || self.state.load(Ordering::SeqCst) == TAKEN
|
||||
}
|
||||
|
||||
/// Give up on the request; false when the app has already started it (it will answer).
|
||||
pub fn abandon(state: &std::sync::atomic::AtomicU8) -> bool {
|
||||
use std::sync::atomic::Ordering;
|
||||
state.compare_exchange(PENDING, ABANDONED, Ordering::SeqCst, Ordering::SeqCst).is_ok() || state.load(Ordering::SeqCst) == ABANDONED
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,3 +424,24 @@ pub fn save_screenshot(app: &mut SlideApp, image: &egui::ColorImage, path: Optio
|
||||
None => json!({"ok": false, "error": "no writer configured"}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `take` and `abandon` are exclusive: whoever wins, the request runs at most once (#719).
|
||||
#[test]
|
||||
fn take_and_abandon_are_exclusive() {
|
||||
let (req, _rx) = ControlRequest::new("m", json!({}));
|
||||
assert!(req.take(), "a pending request is taken");
|
||||
assert!(!ControlRequest::abandon(&req.state), "a taken request is not abandoned");
|
||||
assert!(req.take(), "a taken request stays taken");
|
||||
|
||||
let (req, _rx) = ControlRequest::new("m", json!({}));
|
||||
assert!(ControlRequest::abandon(&req.state), "a pending request is abandoned");
|
||||
assert!(!req.take(), "an abandoned request is never taken");
|
||||
assert!(ControlRequest::abandon(&req.state), "abandoning twice stays abandoned");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,9 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||
let escape = ctx.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
if open && !close && !escape {
|
||||
app.dialog = Some(d);
|
||||
} else if !close && d.id == "preferences" {
|
||||
// Esc or the window's close button: the same as Cancel.
|
||||
restore_preferences(app, &d);
|
||||
} else if app.dialog.is_none()
|
||||
&& let Some(ret) = d.params.get("returnParams").filter(|v| v.is_object()).cloned()
|
||||
{
|
||||
@@ -137,6 +140,31 @@ fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) {
|
||||
(ok, cancel)
|
||||
}
|
||||
|
||||
/// What the Preferences dialog can change, as it was when the dialog opened.
|
||||
fn preferences_snapshot(app: &SlideApp) -> Value {
|
||||
json!({
|
||||
"dark": app.ui.brightness == theme::Brightness::Dark,
|
||||
"uiScale": app.ui.ui_scale,
|
||||
"prefs": serde_json::to_value(&app.session.prefs).unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
/// Puts back the settings the Preferences dialog opened with (Cancel, Esc, the close button).
|
||||
fn restore_preferences(app: &mut SlideApp, d: &Dialog) {
|
||||
let Some(before) = d.params.get("before") else { return };
|
||||
if let Some(dark) = before.get("dark").and_then(Value::as_bool)
|
||||
&& dark != (app.ui.brightness == theme::Brightness::Dark)
|
||||
{
|
||||
let _ = app.run("view.dark", json!({ "on": dark }));
|
||||
}
|
||||
if let Some(scale) = before.get("uiScale").and_then(Value::as_f64) {
|
||||
app.ui.ui_scale = scale as f32;
|
||||
}
|
||||
if let Some(prefs) = before.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()) {
|
||||
app.session.prefs = prefs;
|
||||
}
|
||||
}
|
||||
|
||||
fn field(ui: &mut Ui, d: &mut Dialog, label: &str, key: &str, default: &str) {
|
||||
let mut v = d.get(key, default);
|
||||
ui.horizontal(|ui| {
|
||||
@@ -1565,6 +1593,18 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
d.fields.insert("pdfHidden".into(), hidden.to_string());
|
||||
}
|
||||
}
|
||||
let png = matches!(fmt.as_str(), "png" | "pngAll");
|
||||
if png {
|
||||
ui.add_space(4.0);
|
||||
let mut clear = d.get("pngTransparent", "false") == "true";
|
||||
if ui
|
||||
.checkbox(&mut clear, "Transparent background")
|
||||
.on_hover_text("Leave the slide background out: only what is on the slide is drawn")
|
||||
.changed()
|
||||
{
|
||||
d.fields.insert("pngTransparent".into(), clear.to_string());
|
||||
}
|
||||
}
|
||||
let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) };
|
||||
if let Err(e) = &gif_params {
|
||||
ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim));
|
||||
@@ -1590,6 +1630,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
let extra = match fmt.as_str() {
|
||||
"pdf" => pdf_params,
|
||||
"gif" => gif_params,
|
||||
_ if png && d.get("pngTransparent", "false") == "true" => json!({"transparentBackground": true}),
|
||||
_ => json!({}),
|
||||
};
|
||||
app.export_as(&fmt, extra);
|
||||
@@ -2067,6 +2108,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.checkbox(&mut pr.ac_days, "Capitalize names of days");
|
||||
ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key");
|
||||
ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)");
|
||||
ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript");
|
||||
ui.checkbox(&mut pr.autocorrect, "Replace text as you type");
|
||||
ui.add_space(4.0);
|
||||
let mut from = d.get("from", "");
|
||||
@@ -2104,14 +2146,31 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
}
|
||||
"gridOptions" => grid_options(app, ui, d),
|
||||
"preferences" => {
|
||||
// The controls apply as they change, so the dialog remembers what it opened with and
|
||||
// Cancel (or Esc, or the close button: see `show`) puts that back.
|
||||
if d.params.get("before").is_none() {
|
||||
d.params = json!({ "before": preferences_snapshot(app) });
|
||||
}
|
||||
ui.label(egui::RichText::new("General").font(theme::bold(13.0)));
|
||||
let mut dark = app.ui.brightness == theme::Brightness::Dark;
|
||||
if ui.checkbox(&mut dark, "Dark appearance").changed() {
|
||||
let _ = app.run("view.dark", json!({"on": dark}));
|
||||
}
|
||||
let mut scale = app.ui.ui_scale;
|
||||
if ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size")).changed() {
|
||||
app.ui.ui_scale = scale;
|
||||
// While the pointer holds the slider its value only waits in the dialog: applying it
|
||||
// would rescale the slider under the pointer and run it to an end (#767). It applies
|
||||
// on release, and at once from the keyboard.
|
||||
let pending = d.fields.get("uiScale").and_then(|v| v.parse::<f32>().ok());
|
||||
let mut scale = pending.unwrap_or(app.ui.ui_scale);
|
||||
let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75).text("Interface size"));
|
||||
if r.is_pointer_button_down_on() {
|
||||
if r.changed() || pending.is_some() {
|
||||
d.fields.insert("uiScale".into(), scale.to_string());
|
||||
}
|
||||
} else {
|
||||
if r.changed() || pending.is_some() {
|
||||
app.ui.ui_scale = scale;
|
||||
}
|
||||
d.fields.remove("uiScale");
|
||||
}
|
||||
ui.label(egui::RichText::new("Editing").font(theme::bold(13.0)));
|
||||
ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides");
|
||||
@@ -2130,8 +2189,11 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.label("Your name (comments):");
|
||||
ui.text_edit_singleline(&mut app.session.prefs.author);
|
||||
});
|
||||
let (ok, _) = buttons(ui, "Done");
|
||||
ok
|
||||
let (ok, cancel) = buttons(ui, "Done");
|
||||
if cancel {
|
||||
restore_preferences(app, d);
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
"photoAlbum" => photo_album_dialog(app, ui, d),
|
||||
"themeColors" => theme_colors_dialog(app, ui, d),
|
||||
@@ -3384,6 +3446,47 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// The Export… dialog offers "Transparent background" for PNG only, and the picture it
|
||||
/// writes then leaves the slide background out (#784).
|
||||
#[test]
|
||||
fn export_dialog_writes_a_png_with_a_transparent_background() {
|
||||
use egui::vec2;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SlideApp;
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-ui-png-clear-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("slide.png");
|
||||
let picked = path.to_string_lossy().to_string();
|
||||
let services = crate::Services { pick_save: Some(Box::new(move |_: &str| Some(picked.clone()))), ..Default::default() };
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(deckcraft_engine::Session::with_new(), services),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
harness.state_mut().run("app.exportDialog", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.query_by_label("Transparent background").is_none(), "PDF has no transparent background");
|
||||
harness.get_by_label("JPEG image (current slide)").click_accesskit();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.query_by_label("Transparent background").is_none(), "nor has JPEG");
|
||||
harness.get_by_label("PNG images (current slide)").click_accesskit();
|
||||
harness.run_steps(2);
|
||||
harness.get_by_label("Transparent background").click_accesskit();
|
||||
harness.run_steps(2);
|
||||
assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("pngTransparent")).map(String::as_str), Some("true"));
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none(), "the dialog closes after exporting");
|
||||
let img = deckcraft_render::decode_image(&std::fs::read(&path).unwrap()).unwrap();
|
||||
assert_eq!(img.get_pixel(2, 2).0[3], 0, "the background is left out");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_link_opens_as_a_new_web_address() {
|
||||
let seed = seed_from(CurrentLink::None, &[]);
|
||||
|
||||
+166
-6
@@ -496,6 +496,8 @@ pub struct SlideApp {
|
||||
pub(crate) thumb_drag: Option<(usize, usize)>,
|
||||
/// Selection pane: folded groups, renaming, drag and drop.
|
||||
pub selpane: selpane::State,
|
||||
/// Arrange ▸ Align: Align to Slide (`true`) or Align Selected Objects, ticked for that selection.
|
||||
pub(crate) align_to: Option<(Vec<deckcraft_model::ShapeId>, bool)>,
|
||||
/// View › New Window: a second window on one presentation. `None` when it is closed.
|
||||
pub doc_window: Option<doc_window::DocWindow>,
|
||||
/// Slide Sorter: folded sections, inline rename, dragging a whole section.
|
||||
@@ -572,6 +574,7 @@ impl SlideApp {
|
||||
ui_fonts: uifonts::UiFonts::default(),
|
||||
thumb_drag: None,
|
||||
selpane: selpane::State::default(),
|
||||
align_to: None,
|
||||
doc_window: None,
|
||||
sorter: sorter::State::default(),
|
||||
slides_focused: false,
|
||||
@@ -769,7 +772,8 @@ impl SlideApp {
|
||||
// Every slide for "all slides", one download each, named like the desktop export (#64).
|
||||
let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into());
|
||||
let mut any = false;
|
||||
for (file, bytes) in web_image_files(&doc, slide, all, &name, ext) {
|
||||
let transparent = extra.get("transparentBackground").and_then(Value::as_bool).unwrap_or(false);
|
||||
for (file, bytes) in web_image_files(&doc, slide, all, &name, ext, transparent) {
|
||||
any = self.download(&file, Ok(bytes)) || any;
|
||||
}
|
||||
return any;
|
||||
@@ -1184,6 +1188,26 @@ impl SlideApp {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Files the system hands over (the command line, a double-click through the file
|
||||
/// association), opened in order. When the last one is a PowerPoint Show (`.ppsx`), its slide
|
||||
/// show starts at once from where Play from Start begins, as PowerPoint does; anything else
|
||||
/// opens for editing, and File › Open opens a `.ppsx` for editing too. Returns the files that
|
||||
/// couldn't be opened, with the reason.
|
||||
pub fn open_from_system(&mut self, paths: &[String]) -> Vec<(String, String)> {
|
||||
let mut failed = Vec::new();
|
||||
let mut last = None;
|
||||
for p in paths {
|
||||
match self.open_path(p) {
|
||||
Ok(()) => last = Some(p),
|
||||
Err(e) => failed.push((p.clone(), e)),
|
||||
}
|
||||
}
|
||||
if last.is_some_and(|p| p.to_ascii_lowercase().ends_with(".ppsx")) {
|
||||
self.start_show(self.show_start_index(), false);
|
||||
}
|
||||
failed
|
||||
}
|
||||
|
||||
/// A presentation was opened from `path`: first in the recent list, shown in Normal view.
|
||||
pub(crate) fn note_recent(&mut self, path: &str) {
|
||||
self.status = None; // an older error (a missing file just before this one) must not linger
|
||||
@@ -1879,6 +1903,10 @@ impl SlideApp {
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
let Some(rx) = self.control_rx.take() else { return };
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
// A request whose sender gave up waiting (reply timeout) must not run late (#719).
|
||||
if !req.take() {
|
||||
continue;
|
||||
}
|
||||
let reply = req.reply.clone();
|
||||
match control::handle(self, ctx, &req) {
|
||||
control::Outcome::Done(v) => {
|
||||
@@ -1983,8 +2011,16 @@ pub fn now_ms() -> f64 {
|
||||
}
|
||||
|
||||
/// Images for the browser download: the current slide, or every slide (named `<name>1.png`,
|
||||
/// `<name>2.png`... like the native export) when `all`.
|
||||
fn web_image_files(doc: &deckcraft_model::Presentation, current: usize, all: bool, name: &str, ext: &str) -> Vec<(String, Vec<u8>)> {
|
||||
/// `<name>2.png`... like the native export) when `all`. `transparent` leaves the slide
|
||||
/// background out (PNG only, #784).
|
||||
fn web_image_files(
|
||||
doc: &deckcraft_model::Presentation,
|
||||
current: usize,
|
||||
all: bool,
|
||||
name: &str,
|
||||
ext: &str,
|
||||
transparent: bool,
|
||||
) -> Vec<(String, Vec<u8>)> {
|
||||
let slides: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![current] };
|
||||
let single = slides.len() == 1;
|
||||
let scale = 2.0;
|
||||
@@ -1993,7 +2029,8 @@ fn web_image_files(doc: &deckcraft_model::Presentation, current: usize, all: boo
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(k, i)| {
|
||||
let img = deckcraft_render::render_slide(doc, i, &deckcraft_render::RenderOpts { scale, ..Default::default() });
|
||||
let opts = deckcraft_render::RenderOpts { scale, transparent_background: transparent && ext == "png", ..Default::default() };
|
||||
let img = deckcraft_render::render_slide(doc, i, &opts);
|
||||
let bytes = if ext == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) };
|
||||
let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) };
|
||||
(file, bytes)
|
||||
@@ -2094,14 +2131,38 @@ mod tests {
|
||||
doc.slides.push(first);
|
||||
}
|
||||
assert_eq!(doc.slides.len(), 3);
|
||||
let all = web_image_files(&doc, 1, true, "Deck", "png");
|
||||
let all = web_image_files(&doc, 1, true, "Deck", "png", false);
|
||||
let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(names, ["Deck1.png", "Deck2.png", "Deck3.png"]);
|
||||
let one = web_image_files(&doc, 1, false, "Deck", "png");
|
||||
let one = web_image_files(&doc, 1, false, "Deck", "png", false);
|
||||
assert_eq!(one.len(), 1);
|
||||
assert_eq!(one[0].0, "Deck.png");
|
||||
}
|
||||
|
||||
/// The web PNG download honours "Transparent background" (#784): the slide background is
|
||||
/// left out, so the corner is transparent; a JPEG download stays opaque.
|
||||
#[test]
|
||||
fn web_png_export_can_leave_the_background_transparent() {
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
let got: Rc<RefCell<Vec<(String, Vec<u8>)>>> = Rc::default();
|
||||
let log = got.clone();
|
||||
let services = Services {
|
||||
download: Some(Box::new(move |name: &str, b: &[u8]| log.borrow_mut().push((name.to_string(), b.to_vec())))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SlideApp::new(Session::with_new(), services);
|
||||
let corner_alpha = |bytes: &[u8]| deckcraft_render::decode_image(bytes).unwrap().get_pixel(2, 2).0[3];
|
||||
assert!(app.export_as("png", json!({})));
|
||||
assert!(app.export_as("png", json!({"transparentBackground": true})));
|
||||
assert!(app.export_as("jpeg", json!({"transparentBackground": true})));
|
||||
let got = got.borrow();
|
||||
assert_eq!(got.len(), 3);
|
||||
assert_eq!(corner_alpha(&got[0].1), 255, "opaque by default");
|
||||
assert_eq!(corner_alpha(&got[1].1), 0, "transparent background");
|
||||
assert_eq!(corner_alpha(&got[2].1), 255, "JPEG has no transparency");
|
||||
}
|
||||
|
||||
fn title_text(app: &SlideApp) -> String {
|
||||
let d = app.session.active().unwrap();
|
||||
let sh = &d.doc.slides[0].shapes[0];
|
||||
@@ -2573,6 +2634,71 @@ mod tests {
|
||||
assert!((harness.state().session.prefs.grid_spacing - 6.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// Preferences (#752): Cancel, Esc and the window's close button put back what the dialog
|
||||
/// changed while it was open; Done keeps it.
|
||||
#[test]
|
||||
fn preferences_cancel_restores_the_settings_done_keeps_them() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let before = harness.state().session.prefs.clone();
|
||||
let dark_before = harness.state().ui.brightness;
|
||||
let change = |harness: &mut egui_kittest::Harness<'static, SlideApp>| {
|
||||
harness.state_mut().run("app.preferences", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
for label in ["Dark appearance", "Show smart guides", "Snap objects to grid"] {
|
||||
harness
|
||||
.query_all_by_role_and_label(egui::accesskit::Role::CheckBox, label)
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no {label} checkbox"))
|
||||
.click_accesskit();
|
||||
harness.run_steps(2);
|
||||
}
|
||||
assert_ne!(harness.state().ui.brightness, dark_before, "the dialog previews the appearance");
|
||||
assert_ne!(harness.state().session.prefs.smart_guides, before.smart_guides);
|
||||
};
|
||||
let unchanged = |harness: &egui_kittest::Harness<'static, SlideApp>, how: &str| {
|
||||
let s = harness.state();
|
||||
assert!(s.dialog.is_none(), "{how} closes Preferences");
|
||||
assert_eq!(s.ui.brightness, dark_before, "{how} restores the appearance");
|
||||
assert_eq!(s.session.prefs.smart_guides, before.smart_guides, "{how} restores smart guides");
|
||||
assert_eq!(s.session.prefs.snap_to_grid, before.snap_to_grid, "{how} restores snap to grid");
|
||||
};
|
||||
|
||||
change(&mut harness);
|
||||
harness
|
||||
.query_all_by_role_and_label(egui::accesskit::Role::Button, "Cancel")
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no Cancel button"))
|
||||
.click_accesskit();
|
||||
harness.run_steps(2);
|
||||
unchanged(&harness, "Cancel");
|
||||
|
||||
change(&mut harness);
|
||||
harness.key_press(egui::Key::Escape);
|
||||
harness.run_steps(2);
|
||||
unchanged(&harness, "Esc");
|
||||
|
||||
change(&mut harness);
|
||||
harness
|
||||
.query_all_by_role_and_label(egui::accesskit::Role::Button, "Done")
|
||||
.next()
|
||||
.unwrap_or_else(|| panic!("no Done button"))
|
||||
.click_accesskit();
|
||||
harness.run_steps(2);
|
||||
let s = harness.state();
|
||||
assert!(s.dialog.is_none(), "Done closes Preferences");
|
||||
assert_ne!(s.ui.brightness, dark_before, "Done keeps the appearance");
|
||||
assert_ne!(s.session.prefs.smart_guides, before.smart_guides, "Done keeps smart guides");
|
||||
assert_ne!(s.session.prefs.snap_to_grid, before.snap_to_grid, "Done keeps snap to grid");
|
||||
}
|
||||
|
||||
fn canvas_harness() -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
@@ -3253,4 +3379,38 @@ mod tests {
|
||||
flick(&mut harness, egui::vec2(-24.0, 0.0));
|
||||
assert_eq!(harness.state().show.as_ref().map(|s| s.state.slide), Some(0));
|
||||
}
|
||||
|
||||
/// Preferences › Interface size (#767): dragging the slider leaves the interface size alone
|
||||
/// until the pointer lets go, so the slider doesn't grow under the pointer mid-drag.
|
||||
#[test]
|
||||
fn interface_size_applies_when_the_slider_is_let_go() {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
harness.state_mut().run("app.preferences", json!({})).unwrap();
|
||||
harness.run_steps(4);
|
||||
let before = harness.state().ui.ui_scale;
|
||||
let rect =
|
||||
harness.query_all_by_role_and_label(egui::accesskit::Role::Slider, "Interface size").next().expect("the Interface size slider").rect();
|
||||
let (from, to) = (rect.left_center() + egui::vec2(4.0, 0.0), rect.right_center() - egui::vec2(4.0, 0.0));
|
||||
harness.hover_at(from);
|
||||
harness.run_steps(1);
|
||||
harness.drag_at(from);
|
||||
harness.run_steps(1);
|
||||
for k in 1..=8 {
|
||||
harness.hover_at(from.lerp(to, k as f32 / 8.0));
|
||||
harness.run_steps(1);
|
||||
}
|
||||
assert_eq!(harness.state().ui.ui_scale, before, "nothing rescales while the slider is held");
|
||||
harness.drop_at(to);
|
||||
harness.run_steps(2);
|
||||
let after = harness.state().ui.ui_scale;
|
||||
assert!(after > before + 0.3, "letting go applies the size dragged to: {before} -> {after}");
|
||||
assert!(harness.state().dialog.is_some(), "Preferences stays open");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,4 +199,93 @@ mod tests {
|
||||
assert!(harness.state().opening.is_none());
|
||||
assert_eq!(harness.state().session.documents().len(), 1);
|
||||
}
|
||||
|
||||
/// A deck of `slides` slides saved as `file` (its extension picks the format).
|
||||
fn saved_as(file: &str, slides: usize) -> String {
|
||||
let mut s = Session::with_new();
|
||||
for _ in 1..slides {
|
||||
s.execute("slide.new", &serde_json::json!({})).unwrap();
|
||||
}
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-opening-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join(file).to_string_lossy().to_string();
|
||||
s.execute("file.saveAs", &serde_json::json!({"path": path})).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
/// #862: a PowerPoint Show handed over by the system (command line, double-click) starts its
|
||||
/// slide show at once, full screen and from the first slide.
|
||||
#[test]
|
||||
fn a_ppsx_from_the_system_starts_the_show() {
|
||||
let path = saved_as("Talk.PPSX", 3);
|
||||
let mut app = SlideApp::new(Session::new(), crate::Services::default());
|
||||
assert!(app.open_from_system(std::slice::from_ref(&path)).is_empty());
|
||||
assert_eq!(app.session.documents().len(), 1);
|
||||
let show = app.show.as_ref().expect("the slide show started");
|
||||
assert_eq!(show.state.slide, 0);
|
||||
assert!(!show.reading, "full screen, not Reading View");
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
/// #862: a `.pptx` from the system opens for editing, and so does a `.ppsx` followed by a
|
||||
/// `.pptx`: the show would otherwise play the deck opened last.
|
||||
#[test]
|
||||
fn a_pptx_from_the_system_opens_for_editing() {
|
||||
let pptx = saved_as("edit.pptx", 3);
|
||||
let ppsx = saved_as("first.ppsx", 2);
|
||||
let mut app = SlideApp::new(Session::new(), crate::Services::default());
|
||||
assert!(app.open_from_system(std::slice::from_ref(&pptx)).is_empty());
|
||||
assert!(app.show.is_none());
|
||||
assert!(app.open_from_system(&[ppsx.clone(), pptx.clone()]).is_empty());
|
||||
assert_eq!(app.session.documents().len(), 3);
|
||||
assert!(app.show.is_none(), "the last file is a .pptx");
|
||||
std::fs::remove_file(&pptx).ok();
|
||||
std::fs::remove_file(&ppsx).ok();
|
||||
}
|
||||
|
||||
/// #862: File › Open opens a `.ppsx` for editing, as PowerPoint does.
|
||||
#[test]
|
||||
fn file_open_opens_a_ppsx_for_editing() {
|
||||
let path = saved_as("open.ppsx", 2);
|
||||
let mut app = SlideApp::new(Session::new(), crate::Services::default());
|
||||
app.open_path_in_background(&path);
|
||||
wait(&mut app);
|
||||
assert_eq!(app.session.active().unwrap().doc.slides.len(), 2);
|
||||
assert!(app.show.is_none());
|
||||
assert_eq!(app.ui.view, crate::ViewMode::Normal);
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
/// #862: Esc, or a click past the end screen, ends the `.ppsx` show and leaves the deck open in
|
||||
/// the editor (PowerPoint closes it instead).
|
||||
#[test]
|
||||
fn leaving_the_ppsx_show_keeps_the_deck_open() {
|
||||
let path = saved_as("leave.ppsx", 2);
|
||||
for leave in [egui::Key::Escape, egui::Key::ArrowRight] {
|
||||
let mut app = SlideApp::new(Session::new(), crate::Services::default());
|
||||
assert!(app.open_from_system(std::slice::from_ref(&path)).is_empty());
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(3);
|
||||
assert!(harness.state().show.is_some(), "the show is running");
|
||||
if leave == egui::Key::ArrowRight {
|
||||
for _ in 0..2 {
|
||||
harness.key_press(egui::Key::ArrowRight);
|
||||
harness.run_steps(2);
|
||||
}
|
||||
assert!(harness.state().show.as_ref().is_some_and(|s| s.ended), "the end screen");
|
||||
}
|
||||
harness.key_press(leave);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().show.is_none(), "{leave:?} ends the show");
|
||||
assert_eq!(harness.state().session.documents().len(), 1, "{leave:?}: the deck stays open");
|
||||
assert_eq!(harness.state().ui.view, crate::ViewMode::Normal);
|
||||
}
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Title bar, ribbon tabs and the ribbon card with every tab's groups.
|
||||
|
||||
use deckcraft_engine::Target;
|
||||
use deckcraft_model::{ShapeKind, anim::AnimClass};
|
||||
use deckcraft_model::{ShapeId, ShapeKind, anim::AnimClass};
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -1134,6 +1134,9 @@ pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
|
||||
pub fn align_menu(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let selection = app.session.active().map(|d| d.selection.shapes.clone()).unwrap_or_default();
|
||||
let to_slide = align_to_slide(app.align_to.as_ref(), &selection);
|
||||
let to = if to_slide { "slide" } else { "selection" };
|
||||
for (l, e, ic) in [
|
||||
("Align Left", "left", Icon::AlignObjLeft),
|
||||
("Align Center", "center", Icon::AlignObjCenter),
|
||||
@@ -1142,11 +1145,28 @@ pub fn align_menu(app: &mut SlideApp, ui: &mut Ui) {
|
||||
("Align Middle", "middle", Icon::AlignObjMiddle),
|
||||
("Align Bottom", "bottom", Icon::AlignObjBottom),
|
||||
] {
|
||||
menu_item(app, ui, ic, l, "arrange.align", json!({"edge": e}));
|
||||
menu_item(app, ui, ic, l, "arrange.align", json!({"edge": e, "to": to}));
|
||||
}
|
||||
ui.separator();
|
||||
menu_item(app, ui, Icon::DistributeH, "Distribute Horizontally", "arrange.distribute", json!({"dir": "horizontal"}));
|
||||
menu_item(app, ui, Icon::DistributeV, "Distribute Vertically", "arrange.distribute", json!({"dir": "vertical"}));
|
||||
menu_item(app, ui, Icon::DistributeH, "Distribute Horizontally", "arrange.distribute", json!({"dir": "horizontal", "to": to}));
|
||||
menu_item(app, ui, Icon::DistributeV, "Distribute Vertically", "arrange.distribute", json!({"dir": "vertical", "to": to}));
|
||||
ui.separator();
|
||||
let en = enabled(app, "arrange.align");
|
||||
for (l, slide) in [("Align to Slide", true), ("Align Selected Objects", false)] {
|
||||
if widgets::check_item(ui, l, en, to_slide == slide).clicked() {
|
||||
app.align_to = Some((selection.clone(), slide));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Align to Slide or to the selected objects, as in PowerPoint: one object aligns to the slide and
|
||||
/// several to each other, unless the other choice was ticked for this same selection.
|
||||
fn align_to_slide(choice: Option<&(Vec<ShapeId>, bool)>, selection: &[ShapeId]) -> bool {
|
||||
match choice {
|
||||
Some((ids, to_slide)) if ids.as_slice() == selection => *to_slide,
|
||||
_ => selection.len() < 2,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn menu_item(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Value) {
|
||||
@@ -3454,6 +3474,10 @@ mod effects_tests;
|
||||
#[path = "master_layout_tests.rs"]
|
||||
mod master_layout_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "align_tests.rs"]
|
||||
mod align_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod title_bar_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -650,12 +650,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
show.step_end = now;
|
||||
}
|
||||
} else if show.trans.is_none() {
|
||||
// Auto-advance (After: timings) or autoplay preview.
|
||||
// Auto-advance (After: timings) or autoplay preview. An automatic first step is started
|
||||
// by `ShowState` as the slide appears; starting it here too replayed it after Back (#844).
|
||||
let idle = now - show.step_end.max(show.slide_start);
|
||||
let cur_step = show.state.step;
|
||||
let next_auto = show.timeline(&doc).step_is_auto(cur_step);
|
||||
let n_steps = show.timeline(&doc).steps();
|
||||
let step_due = cur_step < n_steps && ((show.autoplay && idle > 0.3) || next_auto);
|
||||
let step_due = show.autoplay && cur_step < n_steps && idle > 0.3;
|
||||
let holding = show.grid || show.zoom.is_some() || show.zoom_arm;
|
||||
if !holding && (step_due || (!show.rehearse && show.state.auto_advance_due(&doc, idle) && !show.ended)) {
|
||||
actions.push(show.state.next(&doc));
|
||||
@@ -918,10 +918,12 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
||||
} else if show.zoom_arm || show.grid {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::Crosshair);
|
||||
} else if now - show.last_move > 2.5 && !show.reading {
|
||||
// Idle first: a still pointer hides also over a clickable shape, such as a playing clip.
|
||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
||||
} else if pointing {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
} else if now - show.last_move > 2.5 && !show.reading {
|
||||
ctx.set_cursor_icon(egui::CursorIcon::None);
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
let pen_col = theme::to_color32(doc.show.pen_color);
|
||||
@@ -1881,6 +1883,32 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
/// #968: over a shape a click acts on (a clip with its play trigger, a link) the pointing hand
|
||||
/// never gave way to the idle hide, so a still pointer stayed on the playing video.
|
||||
#[test]
|
||||
fn a_still_cursor_hides_over_a_clickable_shape() {
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
let size = session.active().unwrap().doc.slide_size;
|
||||
let r = session.execute("shape.insert", &json!({"preset": "rect", "rect": [0.0, 0.0, size.width, size.height]})).unwrap();
|
||||
session.execute("insert.hyperlink", &json!({"url": "https://example.org", "ids": [r["id"]]})).unwrap();
|
||||
let app = SlideApp::new(session, crate::Services::default());
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).with_step_dt(0.25).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(3);
|
||||
harness.state_mut().start_show(0, false);
|
||||
harness.run_steps(2);
|
||||
harness.hover_at(pos2(500.0, 350.0));
|
||||
harness.run_steps(2);
|
||||
assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::PointingHand);
|
||||
harness.run_steps(12);
|
||||
assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::None, "still for 3 s: hidden");
|
||||
}
|
||||
|
||||
/// Send real egui key events to the presenter UI, without the audience window's handler.
|
||||
#[test]
|
||||
fn presenter_navigation_uses_its_own_keyboard_events() {
|
||||
@@ -2246,4 +2274,38 @@ mod tests {
|
||||
let doc = s.active().unwrap().doc.clone();
|
||||
assert_eq!(next_rect(right, &doc).width(), 400.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn back_leaves_a_slide_whose_first_animation_is_with_previous() {
|
||||
// #844: Back on such a slide undid its first step, and the next frame played that step
|
||||
// again by itself, so Back never reached the slide before.
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
session.execute("slide.new", &json!({})).unwrap();
|
||||
let id = session.active().unwrap().doc.slides[1].shapes[0].id;
|
||||
session
|
||||
.execute("animation.add", &json!({"effect": "fade", "class": "entrance", "ids": [id.0], "start": "withPrevious", "duration": 500}))
|
||||
.unwrap();
|
||||
let app = SlideApp::new(session, crate::Services::default());
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1000.0, 700.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.run_steps(3);
|
||||
harness.state_mut().start_show(0, false);
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::ArrowRight);
|
||||
// The first step plays by itself and ends (0.5 s; a frame is 0.25 s).
|
||||
harness.run_steps(8);
|
||||
let st = &harness.state().show.as_ref().unwrap().state;
|
||||
assert_eq!((st.slide, st.step, st.playing), (1, 1, false), "on slide 2, fully built");
|
||||
for _ in 0..5 {
|
||||
harness.key_press(egui::Key::ArrowLeft);
|
||||
harness.run_steps(2);
|
||||
}
|
||||
let st = &harness.state().show.as_ref().unwrap().state;
|
||||
assert_eq!(st.slide, 0, "five Backs leave slide 2 (step {}, playing {})", st.step, st.playing);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +233,9 @@ fn notes_menu_language_dialog_and_repeated_words() {
|
||||
if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_NOTES"), h.render()) {
|
||||
let _ = img.save(path);
|
||||
}
|
||||
// Right-click the notes' first word.
|
||||
// Right-click the notes' first word, once the (background) notes check is in.
|
||||
assert_eq!(h.state_mut().squiggles.settle_notes("Remmeber the the plan", "xx-NM"), vec![(0, 8), (13, 16)]);
|
||||
h.run_steps(2);
|
||||
let notes = h.query_all_by_role(Role::MultilineTextInput).next().map(|n| n.rect()).unwrap();
|
||||
right_click(&mut h, egui::pos2(notes.left() + 14.0, notes.top() + 8.0));
|
||||
if let (Ok(path), Ok(img)) = (std::env::var("DECKCRAFT_PNG_OUT_NOTES_MENU"), h.render()) {
|
||||
@@ -245,6 +247,7 @@ fn notes_menu_language_dialog_and_repeated_words() {
|
||||
let notes_text = |h: &Harness<'_, SlideApp>| h.state().session.active().unwrap().doc.slides[0].notes_text();
|
||||
assert_eq!(notes_text(&h), "Remember the the plan");
|
||||
// The repeated "the": its squiggle starts after "Remember the ".
|
||||
h.state_mut().squiggles.settle_notes("Remember the the plan", "xx-NM");
|
||||
h.run_steps(4);
|
||||
let x = h.query_all_by_role(Role::MultilineTextInput).next().map(|n| n.rect()).unwrap();
|
||||
let mut found = false;
|
||||
|
||||
@@ -21,7 +21,7 @@ struct Key {
|
||||
extra: u64,
|
||||
/// The pasteboard region the slide's drawing calls for (canvas only).
|
||||
want: Region,
|
||||
skip_text: Option<ShapeId>,
|
||||
editing_text: Option<ShapeId>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
@@ -248,8 +248,7 @@ impl Textures {
|
||||
/// The canvas texture for slide `index`, the slide being `size` pixels, and the region it
|
||||
/// covers: the slide, grown over the pasteboard to show what reaches off the slide within
|
||||
/// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the
|
||||
/// GIF frames. `skip_text` leaves out one shape's text (the prompt of a placeholder being typed
|
||||
/// into).
|
||||
/// GIF frames. `editing_text` replaces its placeholder prompt with gray empty list markers.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn slide(
|
||||
&mut self,
|
||||
@@ -262,7 +261,7 @@ impl Textures {
|
||||
extra: u64,
|
||||
grayscale: bool,
|
||||
gif_times: &[(ShapeId, f64)],
|
||||
skip_text: Option<ShapeId>,
|
||||
editing_text: Option<ShapeId>,
|
||||
anim: Option<(&deckcraft_anim::Timeline, f64)>,
|
||||
) -> Option<(TextureHandle, Region)> {
|
||||
let slide = p.slides.get(index)?.clone();
|
||||
@@ -290,7 +289,7 @@ impl Textures {
|
||||
edit,
|
||||
extra: extra ^ grayscale as u64 ^ anim_key(anim),
|
||||
want,
|
||||
skip_text,
|
||||
editing_text,
|
||||
};
|
||||
if let Some(e) = &self.canvas
|
||||
&& e.key == key
|
||||
@@ -309,7 +308,7 @@ impl Textures {
|
||||
offset: region.offset(),
|
||||
size: Some(region.size()),
|
||||
state,
|
||||
skip_text,
|
||||
editing_text,
|
||||
..Default::default()
|
||||
};
|
||||
deckcraft_render::render_slide_drawn(p, index, &opts)
|
||||
@@ -367,7 +366,7 @@ impl Textures {
|
||||
edit: false,
|
||||
extra: 0,
|
||||
want: Region::slide(width_px, h),
|
||||
skip_text: None,
|
||||
editing_text: None,
|
||||
};
|
||||
self.clock += 1;
|
||||
let clock = self.clock;
|
||||
|
||||
@@ -19,6 +19,10 @@ use deckcraft_fonts::{FontDb, FontFace};
|
||||
/// 55 MB, the only Hangul face) and `PingFang.ttc` (about 78 MB, Simplified Chinese).
|
||||
const MAX_FACE_BYTES: usize = 128 << 20;
|
||||
|
||||
fn accepts_face(byte_len: usize, color: bool) -> bool {
|
||||
!color && byte_len <= MAX_FACE_BYTES
|
||||
}
|
||||
|
||||
/// Faces borrowed for the interface, and the characters already looked at.
|
||||
#[derive(Default)]
|
||||
pub struct UiFonts {
|
||||
@@ -53,7 +57,7 @@ pub fn faces_for_text(
|
||||
continue;
|
||||
}
|
||||
let Some(f) = lookup(c) else { continue };
|
||||
if f.is_color() || f.data().len() > MAX_FACE_BYTES || have.iter().chain(&out).any(|h| h.id() == f.id()) {
|
||||
if !accepts_face(f.data().len(), f.is_color()) || have.iter().chain(&out).any(|h| h.id() == f.id()) {
|
||||
continue;
|
||||
}
|
||||
out.push(f);
|
||||
@@ -69,7 +73,13 @@ impl UiFonts {
|
||||
}
|
||||
let font = egui::FontId::proportional(13.0);
|
||||
let new = ctx.fonts_mut(|f| {
|
||||
faces_for_text(text, &self.faces, &mut self.seen, |c| f.has_glyph(&font, c), |c| FontDb::global().fallback_for(c, u32::MAX))
|
||||
faces_for_text(
|
||||
text,
|
||||
&self.faces,
|
||||
&mut self.seen,
|
||||
|c| f.has_glyph(&font, c),
|
||||
|c| FontDb::global().fallback_for_filtered(c, u32::MAX, |face| accepts_face(face.data().len(), face.is_color())),
|
||||
)
|
||||
});
|
||||
if !new.is_empty() {
|
||||
log::debug!("interface fallback fonts: {}", new.iter().map(|f| f.family.as_str()).collect::<Vec<_>>().join(", "));
|
||||
@@ -168,6 +178,14 @@ mod tests {
|
||||
FontDb::global().face(family, "Regular")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_face_policy_rejects_colour_and_oversize_faces() {
|
||||
assert!(accepts_face(MAX_FACE_BYTES, false), "the documented size boundary remains accepted");
|
||||
assert!(!accepts_face(MAX_FACE_BYTES + 1, false), "an oversized outline face is still rejected");
|
||||
assert!(!accepts_face(1, true), "even a small colour face is still rejected");
|
||||
assert!(!accepts_face(MAX_FACE_BYTES + 1, true));
|
||||
}
|
||||
|
||||
/// One face for a whole string, looked up once per character, never for characters the
|
||||
/// interface already draws, and never twice.
|
||||
#[test]
|
||||
|
||||
@@ -152,6 +152,25 @@ pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, sele
|
||||
(if split { main_resp } else { resp }, arrow_resp)
|
||||
}
|
||||
|
||||
/// A menu choice ticked in the icon column (Align to Slide…), its text lined up with the
|
||||
/// [`small_button`] items around it.
|
||||
pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let font = theme::font(12.0);
|
||||
let tw = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0;
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(24.0 + tw, 22.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Checkbox, enabled, on, label));
|
||||
let painter = ui.painter_at(rect);
|
||||
if let Some(c) = bg(ui, &resp, false, &t) {
|
||||
painter.rect_filled(rect, CornerRadius::same(4), c);
|
||||
}
|
||||
if on {
|
||||
icons::paint(&painter, Rect::from_center_size(pos2(rect.min.x + 12.0, rect.center().y), vec2(17.0, 17.0)), Icon::Check, t.text, !enabled);
|
||||
}
|
||||
painter.text(pos2(rect.min.x + 24.0, rect.center().y), Align2::LEFT_CENTER, label, font, if enabled { t.text } else { t.text_faint });
|
||||
resp
|
||||
}
|
||||
|
||||
/// A square icon toggle (B, I, U, alignment…).
|
||||
pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
|
||||
@@ -67,7 +67,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
| Effect Options (direction, sequence by paragraph) | yes | yes |
|
||||
| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat and bounce end round-trip; rewind lost on PPTX save (#77) |
|
||||
| By-paragraph text builds, sub-bullets follow parents | yes | yes |
|
||||
| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step |
|
||||
| Triggers (on click of a shape) | yes | yes; played in the show: a step per click, the sequence starts over after its last step, a click completes a playing step. Separate click steps, mixed starts and paragraph builds survive PPTX save (`trigger_steps.rs`) |
|
||||
| Triggers on a media bookmark | yes | no |
|
||||
| Animation Painter | yes | yes (#54) |
|
||||
| Animation Pane: list, reorder, remove | yes | yes |
|
||||
@@ -94,6 +94,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-15 | trivial | PPTX interactive sequences preserve later On Click effects and one click per paragraph; no change to playback or feature scores |
|
||||
| 2026-10-14 | trivial | Community PR wave folded: the Animation Pane timeline row says yes (it landed), preview split into canvas (yes) and from the selected effect (no); estimates drop the timeline and transition sounds (~15–27 h) |
|
||||
| 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) |
|
||||
| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide's animations on the editor canvas; preview from the selected effect is still open |
|
||||
|
||||
+13
-4
@@ -1,6 +1,6 @@
|
||||
# DeckCraft architecture
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (community PR wave 2026-10-14: 320 engine commands, 41 dialogs, the `metafile` crate, 1,641 tests; duplicated L2/L5/L6 rows and headers folded; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: minor, community PR wave 2026-10-14: 320 engine commands, 41 dialogs, the `metafile` crate, 1,641 tests; duplicated L2/L5/L6 rows and headers folded; previous: minor, transition frames) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
How DeckCraft is built today. The binding rules (layering, everything is a command, thin UI, never
|
||||
break wasm, never crash) are in [AGENTS.md](../AGENTS.md) §3–§5; this document describes what
|
||||
@@ -19,7 +19,7 @@ nothing below L6 uses egui, eframe, winit or rfd.
|
||||
| L1 | `model`, `fonts`; `h264`, `hevc`, `vp9`, `av1`, `isobmff` | Presentation, masters, layouts, slides, shapes, text, themes, tables, charts, transitions, animations, placeholder inheritance (`resolve`); font database (craft-fonts + system) with metric-compatible substitutes for Office fonts (Carlito for Calibri, Caladea for Cambria, Liberation for Arial/Times); video decoders and the MP4/MOV demuxer |
|
||||
| L2 | `text`, `anim`, `format`, `media` | Shaping (HarfRust), UAX #9 bidi, UAX #14 line breaking, autofit, caret hit testing; per-shape animation state over time, the show state machine, transition frame layouts, Morph matching; `.deckcraft` (zip + JSON + media) and outline text; media probing, decoding, mixer and clock |
|
||||
| L3 | `render`, `pptx` | `vello_cpu` raster of slides, masters and layouts (fills, outlines, effects, pictures, tables, charts, Morph frames); PPTX import/export from ECMA-376 |
|
||||
| L4 | `pdf` | PDF export through krilla |
|
||||
| L4 | `pdf` | App-owned export layout and rendering through the shared `craft-pdf` writer |
|
||||
| L5 | `engine` | Sessions, documents, selection, undo history on shared slide snapshots, the command registry (320 commands in `cmd/*`), pointer tools, keys, AutoRecover, the panic guard (`guard.rs`) |
|
||||
| L6 | `ui-egui`, `mcp` | The PowerPoint-style interface (ribbon with 10 tabs + contextual tabs, canvas, thumbnails, panes, 41 dialogs, show and presenter windows, the JSON control channel); the MCP server (JSON-RPC 2.0 over stdio), headless or connected to a running app |
|
||||
| apps | `deckcraft` (desktop: eframe/wgpu, cpal audio, logging), `deckcraft-cli` (run, render, convert, mcp, app), `deckcraft-web` (trunk; WebGPU with WebGL2 fallback), `xtask` | Exempt from layering |
|
||||
@@ -64,8 +64,10 @@ overlays an invisible real-text layer with embedded font subsets.
|
||||
- **PPTX** (`crates/pptx`): lenient reader (unknown parts skipped, malformed parts logged and
|
||||
ignored, sizes and depths bounded, zip bombs capped), writer that PowerPoint opens without
|
||||
repair. Unknown elements round-trip through `Opaque` and `raw_ext` fields.
|
||||
- **Native** (`crates/format`): zip with `mimetype`, `presentation.json` and `media/<id>`; readers
|
||||
ignore unknown fields; `.slidecraft` files from before the rename still open.
|
||||
- **Native** (`crates/format`): zip with `mimetype`, `presentation.json`, `media/<id>` and
|
||||
`chart-parts/<index>`. Chart relationships and payload references stay in the manifest; binary
|
||||
chart parts use bounded ZIP reads and share the media byte/item budgets. Earlier inline chart
|
||||
bytes still load and migrate on save; `.slidecraft` files from before the rename still open.
|
||||
- Formats and their gaps: [file-format-parity.md](file-format-parity.md).
|
||||
|
||||
## Quality gates
|
||||
@@ -79,6 +81,7 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-14 | minor | Community PR wave folded: one L2/L5/L6 row each; command registry 320 (`cmd!` registrations in `cmd/*`), 41 dialogs (titled in `dialogs.rs`), 202k lines, 24 crates (`metafile` added at L0), 1,641 tests. The per-PR rows below give the counts each PR saw on its own branch |
|
||||
| 2026-10-14 | minor | Size and Position dialog (`shape.sizePosition`): position, size, rotation, scale and lock aspect in one undo step. 227 commands, 28 dialogs |
|
||||
| 2026-10-14 | trivial | Command registry: 228 (`comment.next`, `comment.prev`) |
|
||||
@@ -94,3 +97,9 @@ macOS, Windows, Linux, FreeBSD and web artifacts.
|
||||
| 2026-10-14 | minor | Font dialog; command registry 227; 28 dialogs |
|
||||
| 2026-10-13 | minor | Transitions: perspective meshes, background/content parts, CPU compositor |
|
||||
| 2026-10-10 | major | Created from the code on main (d556ff9) and AGENTS.md §4 |
|
||||
|
||||
The PDF writer dependency enables only `write`. Krilla handles serialization and simple-text
|
||||
shaping for notes and footers; slide layout, rasterization, font selection, text mapping,
|
||||
links and outlines remain app-owned. PDF embedding and all Hayro production dependencies
|
||||
are disabled. Native builds enable parallel compression on the same pinned Krilla backend;
|
||||
WASM keeps it disabled. Generated-PDF assertions use a std-only parser as a development dependency.
|
||||
|
||||
@@ -3,10 +3,22 @@
|
||||
Start the app with `--control <port>`. The server listens on `127.0.0.1` only and speaks JSON
|
||||
lines: one request object per line, one reply per line.
|
||||
|
||||
A line that is not a JSON object with a string `method` (an HTTP request, text that isn't JSON,
|
||||
bytes that aren't UTF-8) gets one error reply and the server closes the connection; nothing sent
|
||||
after it runs. Blank lines are skipped.
|
||||
|
||||
The connected MCP client requires the reply's numeric `id` to match its request, `ok` to be a
|
||||
boolean, and exactly one of `result` (success) or a string `error` (failure). It caps each reply at
|
||||
16 MiB including its terminating newline, accepts CRLF, and rejects EOF inside a frame. Invalid
|
||||
replies invalidate the connection and leave the command's outcome unknown; the client does not
|
||||
replay it. Unsolicited bytes already buffered between calls force a reconnect before sending.
|
||||
|
||||
The server bounds what a client can hold: 16 connections at once (more get `{"ok":false,"error":"busy: …"}`
|
||||
and are closed), 16 MiB per request line (longer closes the connection), 64 queued requests (more are
|
||||
answered `busy`), 10 minutes idle and 30 seconds per blocked reply write before the socket is closed, and
|
||||
60 seconds waiting for the UI (`"error":"timeout"`).
|
||||
60 seconds waiting for the UI (`"error":"timeout"`). A request still queued when that wait expires is
|
||||
abandoned: the UI skips it, so a retry cannot run it twice. If the UI had already started it, the reply
|
||||
says the outcome is unknown instead of `timeout`.
|
||||
|
||||
```json
|
||||
{"id": 1, "method": "engine.execute", "params": {"command": "shape.insert", "params": {"preset": "roundRect", "rect": [100, 100, 240, 120], "text": "Hello"}}}
|
||||
@@ -37,7 +49,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly |
|
||||
| `ui.resize` / `ui.focus` | | Window control |
|
||||
| `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`) |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`, or `{"path": "slide.png", "transparentBackground": true}` for a PNG without the slide background) |
|
||||
|
||||
The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude
|
||||
and other agents; `deckcraft-cli app --method METHOD [PARAMS]` sends one request from a shell.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# File-format parity with PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested.
|
||||
Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready**
|
||||
@@ -18,7 +18,7 @@ PowerPoint opens and saves; Viewer = opens only).
|
||||
|---|---|---|---|---|---|
|
||||
| `.pptx` presentation | Editor | yes | yes | Broad (see below); an owned corpus of decks we author round-trips tables, charts, animations, media, sections, masters and layouts, comments, links and actions, custom shows, rotated groups, inherited placeholders, text effects and body layout, theme effects, fills, bevels, connectors, 3-D models and animation bounce end. Not measured against PowerPoint renders or decks from outside this repo | `crates/pptx/tests/corpus.rs`, `corpus_kinds.rs`, `corpus_elements.rs`, `corpus_style.rs`, `corpus_themes.rs`, `corpus_charts.rs`, `corpus_media.rs`, `roundtrip.rs`, `sample.rs`, `malformed.rs`, `sniff.rs`; opened in PowerPoint by hand 2026-10-06 |
|
||||
| `.potx` template | Editor | yes (as a presentation) | no: written with the `.pptx` content type | `file.saveTemplate` is Save As under another name | none |
|
||||
| `.ppsx` slide show | Editor | yes (as a presentation) | no: written as `.pptx` content | Doesn't start in show mode | none |
|
||||
| `.ppsx` slide show | Editor | yes (as a presentation) | no: written as `.pptx` content | Handed over by the system (command line, file association) it starts the slide show, as in PowerPoint; File › Open opens it for editing. The macOS Finder event arrives with #432 | `crates/ui-egui/src/opening.rs` tests |
|
||||
| `.pptm` / `.potm` / `.ppsm` macro-enabled | Editor | no | no | Should open and keep `vbaProject.bin` without running it | none |
|
||||
| `.ppt` / `.pot` / `.pps` 97–2003 binary | Editor | no | no | Owner decision; import only would be enough | none |
|
||||
| `.odp` OpenDocument | Editor | no | no | | none |
|
||||
@@ -26,7 +26,7 @@ PowerPoint opens and saves; Viewer = opens only).
|
||||
| `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests |
|
||||
| PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests |
|
||||
| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` outline; `.md` as Markdown (headings are slides, list items bullets) (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests |
|
||||
| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size | render and engine tests |
|
||||
| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size. PNG can leave the slide background out (`transparentBackground`, Export… “Transparent background”, `deckcraft-cli render --transparent`), so only the slide's content is opaque (#784) | render, engine, UI and CLI tests |
|
||||
| TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none |
|
||||
| Animated GIF of the deck | export | n/a | yes (`file.export` format `gif`, Export…, `deckcraft-cli`, MCP `export`) | One frame per slide from the slide renderer; size small/medium/large/extra large or 16–3840 px wide, 0.1–600 s per slide, loop forever or play once, slide range, hidden slides skipped; a 256-colour palette per frame (exact, or NeuQuant above 256 colours). Transitions and build animations are not animated | `crates/engine/src/gif_export.rs` tests (decoded frames, size, delay, loop, hidden slides, hostile params), MCP, CLI and export dialog tests |
|
||||
| MP4 / MOV video of the deck | export | n/a | no | Needs timeline render + H.264 encode | none |
|
||||
@@ -34,6 +34,22 @@ PowerPoint opens and saves; Viewer = opens only).
|
||||
|
||||
## Pictures and media inside decks
|
||||
|
||||
Native files retain a nonempty chart `kept` sidecar: relationships, part names, content types
|
||||
and `nativeEntry` references live in `presentation.json`; exact binary payloads live in distinct
|
||||
`chart-parts/<index>` ZIP members. This keeps workbooks and drawings through a native save before
|
||||
exporting back to PPTX without expanding workbook bytes into JSON numbers. Chart parts and
|
||||
media share the 4 GiB inflated-byte and 100,000-item budgets; each member is read through the
|
||||
existing 2 GiB entry limit. Missing chart members are reported by `load_checked` and left empty.
|
||||
A present chart member that fails decoding, CRC or its entry limit refuses the file immediately,
|
||||
so later corrupt members cannot reuse the remaining inflation budget. Invalid, reused or
|
||||
ambiguous references are refused. A synthetic 3 MiB payload test
|
||||
checks the ZIP layout and bounded reads, alongside shared-budget and all-owner round trips.
|
||||
|
||||
Older native files without a sidecar still load. Earlier inline chart byte arrays are read
|
||||
under the existing manifest-size limit, then charged to the shared budget; saving migrates them
|
||||
to ZIP members. Builds predating chart-sidecar support ignore it and lose it on resave. The
|
||||
intermediate build that required inline `bytes` cannot read the new referenced representation.
|
||||
|
||||
| Kind | PowerPoint | DeckCraft | Notes |
|
||||
|---|---|---|---|
|
||||
| PNG, JPEG, GIF (animated), BMP, TIFF, WebP | yes | yes | `image` 0.25; animated GIFs play since #39 |
|
||||
@@ -103,6 +119,12 @@ the round trip even though DeckCraft doesn't model it.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-15 | trivial | Native `.deckcraft` files serialize a nonempty chart `kept` sidecar (relationships, part names, content types and bytes). Older files without it still load; empty sidecars are omitted. Headless PPTX → edit → native save/reopen → PPTX checks keep the workbook and drawing bytes; editing chart data still drops the stale workbook |
|
||||
| 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen |
|
||||
| 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) |
|
||||
| 2026-10-16 | minor | PNG slide export with a transparent background (#784) |
|
||||
| 2026-10-16 | trivial | A `.ppsx` opened from the command line or the file association starts the slide show (#862) |
|
||||
| 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) |
|
||||
| 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box |
|
||||
| 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain |
|
||||
|
||||
+15
-9
@@ -1,6 +1,6 @@
|
||||
# Where DeckCraft falls short of PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: soak tests, PPTX corpus, comment threads (#70), embedded fonts, SmartArt Text Pane, Animation Pane timeline, Hunspell spelling, thesaurus, AutoCorrect, 14 new dialogs, pen pressure and trackpad gestures; duplicated entries and headers folded; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -180,7 +180,9 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
neither label. A combo chart's secondary value axis is read (`y2_*`,
|
||||
`Series.secondary_axis`) and is not written back when the chart is rebuilt.
|
||||
An unchanged chart's embedded workbook now survives save (#335); editing the chart still
|
||||
drops it rather than writing a stale spreadsheet.
|
||||
drops it rather than writing a stale spreadsheet. The workbook and other retained chart parts
|
||||
also survive `.deckcraft` save/reopen (`crates/engine/tests/chart_native.rs`), with binary
|
||||
payloads in bounded ZIP entries sharing the media load budget (`crates/format` tests).
|
||||
- **Impact:** most business decks have charts; editing their data is routine.
|
||||
- **Estimate:** 12–20 h. Data and elements are **beta blockers** (6–10 h).
|
||||
|
||||
@@ -212,11 +214,10 @@ of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8
|
||||
counters): a click on a shape whose action is "play sound" (`ppaction://media`, 126 and 486
|
||||
shapes in two decks) does nothing; triggers that start on `onStopAudio` or `begin` conditions are
|
||||
not read as triggers; click boxes are the shape's bounding box (a hexagon's corners count) and do
|
||||
not follow a shape that a motion path has moved; the PPTX writer turns every "on click" after the
|
||||
first effect of a trigger into "after previous", so a trigger with several click steps saved from
|
||||
DeckCraft plays all of them at once.
|
||||
- **Impact:** games built around sound effects on buttons, or around a button that counts up with
|
||||
repeated clicks, lose that part when they are saved from DeckCraft or played in it.
|
||||
not follow a shape that a motion path has moved. Trigger sequences now keep their separate
|
||||
click steps on PPTX save, including paragraph builds (`crates/pptx/tests/trigger_steps.rs`).
|
||||
- **Impact:** games built around sound effects on buttons, audio-triggered effects or moving
|
||||
clickable shapes do not play as authored.
|
||||
- **Estimate:** 4–8 h.
|
||||
|
||||
### Notes and Handout masters
|
||||
@@ -258,8 +259,8 @@ of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8
|
||||
installed (always on Windows and the web) only a short list of common misspellings is found.
|
||||
`review.thesaurus` looks up the selected word (checklist row partial). AutoCorrect has
|
||||
PowerPoint's options (two initial capitals, sentence and day capitals, caps lock, fractions, an
|
||||
editable replace list); no AutoFormat of ordinals or automatic bulleted lists, no undo of a
|
||||
single correction.
|
||||
editable replace list), and AutoFormat superscripts ordinals (1st) as you type; no automatic
|
||||
bulleted lists, no undo of a single correction.
|
||||
- **Estimate:** 1–2 h (bundle openly licensed dictionaries); 2–4 h (AutoFormat as you type).
|
||||
|
||||
### Equations
|
||||
@@ -355,6 +356,11 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-15 | trivial | Native saves retain chart package parts and relationships, including charts in groups; edited chart data still does not export a stale source workbook. Chart editing gaps and scores remain unchanged |
|
||||
| 2026-10-15 | trivial | PPTX trigger sequences keep later On Click steps instead of converting them to After Previous; mixed starts, independent buttons and paragraph builds have synthetic round-trip regressions |
|
||||
| 2026-10-16 | trivial | AutoFormat superscripts ordinals (1st, 22nd) as you type, with an AutoCorrect option to turn it off; automatic bulleted lists remain |
|
||||
| 2026-10-14 | trivial | Fixed #718: remote MCP no longer accepts unrelated or buffered stale success; loopback regressions cover invalid envelopes, incomplete and oversized frames |
|
||||
| 2026-10-14 | minor | Community PR wave folded: one entry each for the soak tests (engine and UI), the PPTX corpus (seven owned corpus files), edit points / text warp / 3-D, proofing (Hunspell spelling, thesaurus, AutoCorrect), the smaller missing rows, dialogs (41 vs ~60; Print and Insert Object left) and pen/trackpad input; ranked rows 3, 4 and 10 de-duplicated (comment threads #70 and the pane timeline are done); the fixed command-palette entry (#79) removed |
|
||||
| 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain |
|
||||
| 2026-10-13 | minor | Embedded fonts are read, written and drawn (TrueType, OpenType, Embedded OpenType). The gap is now MicroType Express subsets, which are kept but not drawn |
|
||||
|
||||
+12
@@ -5,6 +5,18 @@ headless engine (no window); with `--connect PORT` it drives a running DeckCraft
|
||||
`--control PORT`, so an agent and a person can work on the same deck. `--sample` opens the sample
|
||||
deck.
|
||||
|
||||
Connected mode accepts only newline-terminated replies of at most 16 MiB (including the newline),
|
||||
with the current request's numeric `id`, a boolean `ok`, and either `result` on success or a string
|
||||
`error` on failure. CRLF is accepted. Invalid replies close the connection and report that the
|
||||
command may or may not have run; they never cause a replay. Check the document before retrying a
|
||||
mutation. Before sending another request, unsolicited bytes in the reader's buffer or on the
|
||||
socket cause a fresh connection to be opened. Large image replies must fit this budget; render
|
||||
to a file or use a smaller scale when necessary.
|
||||
|
||||
Regression coverage for #718 lives in `crates/mcp/src/backend.rs`: synthetic loopback peers test
|
||||
reply correlation, envelopes, framing, the size cap and buffered unsolicited bytes, alongside the
|
||||
lost-reply and reconnect tests.
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# PowerPoint feature checklist
|
||||
|
||||
> **Generated:** 2026-10-14 (summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
The summary between `<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 84%** over 191 features.
|
||||
@@ -54,7 +54,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Application shell | Multiple open presentations | P1 | P |
|
||||
| Application shell | Preferences (autosave, units, defaults) | P2 | P |
|
||||
| Files | New blank presentation | P0 | D |
|
||||
| Files | Native file format (open/save) | P0 | D |
|
||||
| Files | Native file format (open/save; chart package parts retained, `crates/engine/tests/chart_native.rs`) | P0 | D |
|
||||
| Files | Open PowerPoint .pptx | P0 | D |
|
||||
| Files | Save as PowerPoint .pptx | P0 | D |
|
||||
| Files | Open/save .potx/.ppsx/.pptm variants (.potx/.ppsx open; saved with the .pptx content type; no .pptm) | P2 | P |
|
||||
@@ -145,7 +145,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Text | Change case, grow/shrink font, clear formatting | P1 | D |
|
||||
| Text | Text outline and text effects | P2 | P |
|
||||
| Text | Paragraph alignment, indents, spacing, line spacing | P0 | D |
|
||||
| Text | Bullets and numbering | P0 | D |
|
||||
| Text | Bullets and numbering (including gray empty-line markers while editing) | P0 | D |
|
||||
| Text | Picture bullets, custom bullet symbols | P2 | P |
|
||||
| Text | List levels (Tab / Shift+Tab) | P0 | D |
|
||||
| Text | Tab stops | P2 | D |
|
||||
@@ -206,7 +206,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Animations | Effect options, start (on click/with/after), duration, delay | P0 | D |
|
||||
| Animations | Animation pane (reorder, timing) | P0 | P |
|
||||
| Animations | By-paragraph text builds | P1 | D |
|
||||
| Animations | Triggers | P2 | D |
|
||||
| Animations | Triggers (separate click steps and paragraph builds round-trip; `crates/pptx/tests/trigger_steps.rs`) | P2 | D |
|
||||
| Animations | Animation Painter | P2 | D |
|
||||
| Animations | Preview in editor | P1 | D |
|
||||
| Slide show | Full-screen show from start / current | P0 | D |
|
||||
@@ -228,7 +228,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Undo & clipboard | Unlimited undo/redo | P0 | D |
|
||||
| Undo & clipboard | Cut, copy, paste shapes and slides | P0 | D |
|
||||
| Undo & clipboard | Paste special (keep formatting, picture, text only) | P1 | D |
|
||||
| Automation (✱) | Every command scriptable (CLI, control channel, MCP) | P0 | D |
|
||||
| Automation (✱) | Every command scriptable (CLI, control channel, MCP; remote reply IDs, envelopes and framing validated, #718) | P0 | D |
|
||||
| Automation (✱) | Headless rendering and conversion | P0 | D |
|
||||
| Platforms (✱) | macOS, Windows, Linux builds | P0 | P |
|
||||
| Platforms (✱) | Web (WASM) build | P1 | D |
|
||||
|
||||
+8
-1
@@ -1,6 +1,6 @@
|
||||
# DeckCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** major (community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
Forward-looking: where DeckCraft is going and in what order. The one-page summary is
|
||||
[ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in
|
||||
@@ -115,6 +115,10 @@ plan above.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-15 | trivial | Native save/reopen retains imported chart workbooks and related parts; headless command tests cover a PPTX → native → PPTX cycle and stale-workbook rejection. Milestone scope and scores unchanged |
|
||||
| 2026-10-15 | trivial | PPTX trigger click steps survive save, including by-paragraph builds. Interactive-deck gaps remain media actions, audio triggers and click-box geometry; milestone scope and feature scores unchanged |
|
||||
| 2026-10-14 | trivial | M13 reliability: remote MCP validates IDs and envelopes, caps replies and rejects incomplete frames; #718 |
|
||||
| 2026-10-14 | minor | Community PR wave folded: one Review gate row, one Current focus item 2, one beta row each for items 9 and 10 (timeline and timer controls are in), one after-beta paragraph, one M9 and M10 row, one corpus row in the 2026-10-07 history |
|
||||
| 2026-10-13 | trivial | Beta item 3: embedded fonts are in, except MicroType Express subsets |
|
||||
| 2026-10-13 | minor | M9: the WordArt Transform gallery applies a text warp; adjust handles stay open |
|
||||
@@ -138,3 +142,6 @@ plan above.
|
||||
| 2026-10-14 | trivial | After beta: pen pressure scales ink width; tilt and pinch zoom remain |
|
||||
| 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha |
|
||||
| 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history |
|
||||
|
||||
PDF writer consolidation preserves the current raster-artwork export and its selectable
|
||||
text layer, notes and handouts. It does not advance vector-PDF or printing parity.
|
||||
|
||||
@@ -186,7 +186,7 @@ Features row of the dimension table keeps ~62% until the next full re-measure).
|
||||
| Masters and themes | 7 | 92% | ~70% | 8–14 | Notes/Handout master view polish, effect-scheme depth, Designer suggestions | [parity-checklist.md](parity-checklist.md) |
|
||||
| Shapes | 7 | 91% | ~75% | 8–14 | Edit points depth, WordArt depth (the Transform gallery and warp adjust handles are in) | [ui-parity.md](ui-parity.md) |
|
||||
| Format (fill, line, effects) | 6 | 83% | ~65% | 8–14 | Format Shape pane depth, bevel and 3-D, picture/texture fill options | [ui-parity.md](ui-parity.md) |
|
||||
| Arrange | 3 | 100% | ~90% | 1–2 | Align to slide vs selected as a menu choice, More Rotation Options | [ui-parity.md](ui-parity.md) |
|
||||
| Arrange | 3 | 100% | ~90% | 1–2 | More Rotation Options | [ui-parity.md](ui-parity.md) |
|
||||
| Text | 15 | 83% | ~62% | 18–30 | Font weights beyond Regular/Bold/Italic (#69), true vertical CJK, dictionaries, AutoFormat as you type, thesaurus depth, equations (PR #55 open) | [localization-parity.md](localization-parity.md) |
|
||||
| Tables | 5 | 97% | ~75% | 3–6 | Draw table depth, borders and cell effects depth | [parity-checklist.md](parity-checklist.md) |
|
||||
| Charts | 7 | 65% | ~45% | 12–20 | Data grid editing, chart elements, the `chartex` types (waterfall, funnel, treemap, sunburst, histogram, box) | [parity-checklist.md](parity-checklist.md) |
|
||||
@@ -289,6 +289,7 @@ scored done (16 open issues).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | trivial | Arrange shortfall: Align to Slide and Align Selected Objects are in the Align menu. Percentages unchanged |
|
||||
| 2026-10-14 | minor | Community PR wave folded: one headline breadth row (84% over 191 features after `cargo xtask parity`; duplicate checklist rows removed), one row per feature area with the latest breadth and shortfalls, one Themes / Slide show / Views / Review / Automation bullet each (320 engine commands). SmartArt ready ~35% (Text Pane, 6e40335) moves the area mean to 62.8%; dimension and headline percentages unchanged |
|
||||
| 2026-10-13 | minor | Shapes shortfall "text warp" narrowed to adjust handles: the Transform gallery applies a preset |
|
||||
| 2026-10-14 | minor | Media remainder: Insert › Icons places Lucide stroke icons. 3-D models and stock content remain. Headline percentages unchanged; the checklist row stays missing |
|
||||
|
||||
+10
-1
@@ -7,6 +7,13 @@ direct manipulation, keyboard and precision. Observations of PowerPoint are kept
|
||||
`plan/powerpoint/01-observed-ui.md` and `menu-tree.txt` (gitignored, clean-room rules in
|
||||
AGENTS.md §2). Dimension score: **~60% ready** (estimated), **30–50 h** to parity.
|
||||
|
||||
## Empty list paragraphs
|
||||
|
||||
While editing slide text, empty bulleted and numbered paragraphs show gray markers at the list's
|
||||
indent, including the first click into a content placeholder and the paragraph after Enter.
|
||||
Typing gives the marker its normal text color. These temporary markers are hidden when editing
|
||||
ends and are omitted from thumbnails, slide shows and exports.
|
||||
|
||||
## Shell
|
||||
|
||||
| Element | PowerPoint for Mac | DeckCraft | Gap |
|
||||
@@ -48,7 +55,7 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Save As | Places, folder, file name, type, Save, More options | Type (PowerPoint, DeckCraft, PDF, Outline, PNG, JPEG) and Save, which opens the system Save dialog (web: Download). .pptx/.deckcraft take the new file; the others are copies (`file.saveAs`, `file.export`) |
|
||||
| Print | Print and Copies, Printer, Print All / Selection / Current / Custom Range, Slides box, layout (Full Page Slides, Notes Pages, Outline, Handouts 1–9), sides, collation, colour, Edit Header & Footer; page preview with pager and zoom | Print, Print All Slides / Current Slide / Custom Range, Slides box (`1-3, 5`), Full Page Slides / Notes Pages / Handouts 1–9, Print Hidden Slides, Frame Slides, Edit Header & Footer; preview with pager. DeckCraft prints through a PDF (`file.export`) opened in the system viewer, which has the printers (web: downloaded). No printer list, copies, sides or colour |
|
||||
| Share | Cloud sharing | Not offered |
|
||||
| Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type. No video, GIF or CD package |
|
||||
| Export | Create PDF/XPS Document, Create a Video, Create an Animated GIF, Package Presentation for CD, Create Handouts, Change File Type | Create PDF Document, Create Handouts (a PDF), Change File Type (PNG with an optional transparent background, #784). No video, GIF or CD package |
|
||||
| Close | Closes; asks Save / Don't Save / Cancel about unsaved changes | Same (`file.close` after the prompt); Ctrl+W asks too |
|
||||
| Account | User information, sign-in, Office Background and Office Theme, product information, About | Appearance (light/dark, `view.dark`), product information, About DeckCraft. No accounts to sign in to |
|
||||
| Options | Opens the PowerPoint Options dialog | Opens Preferences |
|
||||
@@ -158,6 +165,8 @@ consistency is open (#73, #56).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-16 | minor | Export › Change File Type › PNG and the Export… dialog offer a transparent background (#784) |
|
||||
| 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: one row each for Format Shape, Dialogs (41, listed from `dialogs.rs`), Animation Pane (timeline is in), window, Quick Access Toolbar, Format Background, rulers, image paste, context menus, exact size and edit points; estimates re-totalled (~26–46 h) |
|
||||
| 2026-10-13 | trivial | Eyedropper in the colour menus (#367) |
|
||||
| 2026-10-12 | minor | Title bar: an Export button next to Save opens the Export dialog, a quick-access shortcut beside File › Export |
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
CC0 1.0 Universal
|
||||
|
||||
Statement of Purpose
|
||||
|
||||
The laws of most jurisdictions throughout the world automatically confer
|
||||
exclusive Copyright and Related Rights (defined below) upon the creator and
|
||||
subsequent owner(s) (each and all, an "owner") of an original work of
|
||||
authorship and/or a database (each, a "Work").
|
||||
|
||||
Certain owners wish to permanently relinquish those rights to a Work for the
|
||||
purpose of contributing to a commons of creative, cultural and scientific
|
||||
works ("Commons") that the public can reliably and without fear of later
|
||||
claims of infringement build upon, modify, incorporate in other works, reuse
|
||||
and redistribute as freely as possible in any form whatsoever and for any
|
||||
purposes, including without limitation commercial purposes. These owners may
|
||||
contribute to the Commons to promote the ideal of a free culture and the
|
||||
further production of creative, cultural and scientific works, or to gain
|
||||
reputation or greater distribution for their Work in part through the use and
|
||||
efforts of others.
|
||||
|
||||
For these and/or other purposes and motivations, and without any expectation
|
||||
of additional consideration or compensation, the person associating CC0 with a
|
||||
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
|
||||
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
|
||||
and publicly distribute the Work under its terms, with knowledge of his or her
|
||||
Copyright and Related Rights in the Work and the meaning and intended legal
|
||||
effect of CC0 on those rights.
|
||||
|
||||
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||
protected by copyright and related or neighboring rights ("Copyright and
|
||||
Related Rights"). Copyright and Related Rights include, but are not limited
|
||||
to, the following:
|
||||
|
||||
i. the right to reproduce, adapt, distribute, perform, display, communicate,
|
||||
and translate a Work;
|
||||
|
||||
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||
|
||||
iii. publicity and privacy rights pertaining to a person's image or likeness
|
||||
depicted in a Work;
|
||||
|
||||
iv. rights protecting against unfair competition in regards to a Work,
|
||||
subject to the limitations in paragraph 4(a), below;
|
||||
|
||||
v. rights protecting the extraction, dissemination, use and reuse of data in
|
||||
a Work;
|
||||
|
||||
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||
European Parliament and of the Council of 11 March 1996 on the legal
|
||||
protection of databases, and under any national implementation thereof,
|
||||
including any amended or successor version of such directive); and
|
||||
|
||||
vii. other similar, equivalent or corresponding rights throughout the world
|
||||
based on applicable law or treaty, and any national implementations thereof.
|
||||
|
||||
2. Waiver. To the greatest extent permitted by, but not in contravention of,
|
||||
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
|
||||
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
|
||||
and Related Rights and associated claims and causes of action, whether now
|
||||
known or unknown (including existing as well as future claims and causes of
|
||||
action), in the Work (i) in all territories worldwide, (ii) for the maximum
|
||||
duration provided by applicable law or treaty (including future time
|
||||
extensions), (iii) in any current or future medium and for any number of
|
||||
copies, and (iv) for any purpose whatsoever, including without limitation
|
||||
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
|
||||
the Waiver for the benefit of each member of the public at large and to the
|
||||
detriment of Affirmer's heirs and successors, fully intending that such Waiver
|
||||
shall not be subject to revocation, rescission, cancellation, termination, or
|
||||
any other legal or equitable action to disrupt the quiet enjoyment of the Work
|
||||
by the public as contemplated by Affirmer's express Statement of Purpose.
|
||||
|
||||
3. Public License Fallback. Should any part of the Waiver for any reason be
|
||||
judged legally invalid or ineffective under applicable law, then the Waiver
|
||||
shall be preserved to the maximum extent permitted taking into account
|
||||
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
|
||||
is so judged Affirmer hereby grants to each affected person a royalty-free,
|
||||
non transferable, non sublicensable, non exclusive, irrevocable and
|
||||
unconditional license to exercise Affirmer's Copyright and Related Rights in
|
||||
the Work (i) in all territories worldwide, (ii) for the maximum duration
|
||||
provided by applicable law or treaty (including future time extensions), (iii)
|
||||
in any current or future medium and for any number of copies, and (iv) for any
|
||||
purpose whatsoever, including without limitation commercial, advertising or
|
||||
promotional purposes (the "License"). The License shall be deemed effective as
|
||||
of the date CC0 was applied by Affirmer to the Work. Should any part of the
|
||||
License for any reason be judged legally invalid or ineffective under
|
||||
applicable law, such partial invalidity or ineffectiveness shall not
|
||||
invalidate the remainder of the License, and in such case Affirmer hereby
|
||||
affirms that he or she will not (i) exercise any of his or her remaining
|
||||
Copyright and Related Rights in the Work or (ii) assert any associated claims
|
||||
and causes of action with respect to the Work, in either case contrary to
|
||||
Affirmer's express Statement of Purpose.
|
||||
|
||||
4. Limitations and Disclaimers.
|
||||
|
||||
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||
surrendered, licensed or otherwise affected by this document.
|
||||
|
||||
b. Affirmer offers the Work as-is and makes no representations or warranties
|
||||
of any kind concerning the Work, express, implied, statutory or otherwise,
|
||||
including without limitation warranties of title, merchantability, fitness
|
||||
for a particular purpose, non infringement, or the absence of latent or
|
||||
other defects, accuracy, or the present or absence of errors, whether or not
|
||||
discoverable, all to the greatest extent permissible under applicable law.
|
||||
|
||||
c. Affirmer disclaims responsibility for clearing rights of other persons
|
||||
that may apply to the Work or any use thereof, including without limitation
|
||||
any person's Copyright and Related Rights in the Work. Further, Affirmer
|
||||
disclaims responsibility for obtaining any necessary consents, permissions
|
||||
or other rights required for any use of the Work.
|
||||
|
||||
d. Affirmer understands and acknowledges that Creative Commons is not a
|
||||
party to this document and has no duty or obligation with respect to this
|
||||
CC0 or use of the Work.
|
||||
|
||||
For more information, please see
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
@@ -37,6 +37,12 @@
|
||||
<keyword>slide show</keyword>
|
||||
<keyword>pptx</keyword>
|
||||
</keywords>
|
||||
<screenshots>
|
||||
<screenshot type="default">
|
||||
<caption>Slide editor with thumbnails, a process diagram and notes</caption>
|
||||
<image type="source" width="1600" height="1000">https://raw.githubusercontent.com/storytold/deckcraft/main/docs/images/editor.png</image>
|
||||
</screenshot>
|
||||
</screenshots>
|
||||
<branding>
|
||||
<color type="primary" scheme_preference="light">#ffc9a8</color>
|
||||
<color type="primary" scheme_preference="dark">#b8471f</color>
|
||||
|
||||
+5
-1
@@ -8,6 +8,7 @@ mod bench;
|
||||
mod ico;
|
||||
mod layers;
|
||||
mod parity;
|
||||
mod pdf_deps;
|
||||
mod stats;
|
||||
mod version;
|
||||
|
||||
@@ -19,9 +20,10 @@ usage: cargo xtask <command>
|
||||
|
||||
commands:
|
||||
assets check that every icon/image/font/asset is attributed in ATTRIBUTION.md
|
||||
pdf-deps verify pinned writer sources and the production feature boundary
|
||||
layers enforce the crate dependency layering (plan/architecture.md §3)
|
||||
wasm cargo check --target wasm32-unknown-unknown for the wasm-safe crates
|
||||
ci fmt --check, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
ci fmt --check, pdf-deps, clippy -D warnings, test, assets, layers, wasm (stops at first failure)
|
||||
stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`)
|
||||
bench [--save] [DECK...]
|
||||
time open, render, save and export of the sample (and DECKs) in release, and
|
||||
@@ -37,6 +39,7 @@ fn main() -> ExitCode {
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("assets") => assets::run(&root()),
|
||||
Some("layers") => cmd_layers(),
|
||||
Some("pdf-deps") => pdf_deps::run(&root()),
|
||||
Some("wasm") => cmd_wasm(),
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("stats") => stats::run(&root(), rest.contains(&"--exact")),
|
||||
@@ -161,6 +164,7 @@ fn cmd_ci() -> Result<(), String> {
|
||||
run(c, "cargo fmt --all -- --check")
|
||||
}),
|
||||
),
|
||||
("pdf-deps", Box::new(|| pdf_deps::run(&root()))),
|
||||
(
|
||||
"clippy",
|
||||
Box::new(|| {
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Check the immutable writer sources and keep PDF reading out of production exports.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
const SHARED_REV: &str = "c3487320b2d1de657b860b3c8abb44e1f2b32bd8";
|
||||
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
|
||||
.current_dir(root)
|
||||
.args(["metadata", "--locked", "--format-version", "1"])
|
||||
.output()
|
||||
.map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("cargo metadata: {}", String::from_utf8_lossy(&output.stderr)));
|
||||
}
|
||||
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|e| format!("cargo metadata: {e}"))?;
|
||||
validate(&metadata)?;
|
||||
// Metadata merges target features even with --filter-platform. Check Cargo's
|
||||
// selected feature graph separately so native compression never enables threads on WASM.
|
||||
let output =
|
||||
Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into())).arg("-vV").output().map_err(|e| format!("rustc host: {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("rustc host: {}", String::from_utf8_lossy(&output.stderr)));
|
||||
}
|
||||
let version = String::from_utf8(output.stdout).map_err(|e| format!("rustc host: {e}"))?;
|
||||
let host = version.lines().find_map(|line| line.strip_prefix("host: ")).ok_or("rustc did not report its host target")?;
|
||||
for (target, parallel) in [(host, true), ("wasm32-unknown-unknown", false)] {
|
||||
let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into()))
|
||||
.current_dir(root)
|
||||
.args([
|
||||
"tree",
|
||||
"--locked",
|
||||
"--target",
|
||||
target,
|
||||
"--edges",
|
||||
"normal,build",
|
||||
"--package",
|
||||
"deckcraft-pdf",
|
||||
"--depth",
|
||||
"2",
|
||||
"--no-dedupe",
|
||||
"--prefix",
|
||||
"none",
|
||||
"--format",
|
||||
"{p}|{f}",
|
||||
])
|
||||
.output()
|
||||
.map_err(|e| format!("cargo tree ({target}): {e}"))?;
|
||||
if !output.status.success() {
|
||||
return Err(format!("cargo tree ({target}): {}", String::from_utf8_lossy(&output.stderr)));
|
||||
}
|
||||
validate_target_features(&String::from_utf8_lossy(&output.stdout), parallel).map_err(|e| format!("{target}: {e}"))?;
|
||||
}
|
||||
println!("PDF writer: pinned craft-pdf/Krilla; native-only parallel compression; parser assertions are development-only");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate(metadata: &Value) -> Result<(), String> {
|
||||
let packages = metadata.get("packages").and_then(Value::as_array).ok_or("cargo metadata has no packages")?;
|
||||
let nodes = metadata.get("resolve").and_then(|r| r.get("nodes")).and_then(Value::as_array).ok_or("cargo metadata has no resolved nodes")?;
|
||||
let one_package = |name: &str| -> Result<&Value, String> {
|
||||
let matches: Vec<_> = packages.iter().filter(|p| p.get("name").and_then(Value::as_str) == Some(name)).collect();
|
||||
if matches.len() != 1 {
|
||||
return Err(format!("{name}: expected one resolved package, found {}", matches.len()));
|
||||
}
|
||||
matches.first().copied().ok_or_else(|| format!("missing {name}"))
|
||||
};
|
||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
|
||||
for (name, version, expected_source, expected_features) in [
|
||||
("craft-pdf", "0.1.0", source.as_str(), vec!["write"]),
|
||||
("krilla", "0.8.2", source.as_str(), vec!["default", "raster-images", "rayon", "simple-text"]),
|
||||
("hayro-syntax", "0.7.2", REGISTRY, vec!["std"]),
|
||||
] {
|
||||
let package = one_package(name)?;
|
||||
if package.get("version").and_then(Value::as_str) != Some(version) || package.get("source").and_then(Value::as_str) != Some(expected_source) {
|
||||
return Err(format!("{name}: expected {version} from {expected_source}; got {package}"));
|
||||
}
|
||||
let id = package.get("id").and_then(Value::as_str).ok_or_else(|| format!("{name}: missing package id"))?;
|
||||
let node = nodes.iter().find(|n| n.get("id").and_then(Value::as_str) == Some(id)).ok_or_else(|| format!("{name}: no resolved node"))?;
|
||||
let features = node.get("features").and_then(Value::as_array).ok_or_else(|| format!("{name}: no resolved features"))?;
|
||||
let actual: BTreeSet<_> = features.iter().filter_map(Value::as_str).collect();
|
||||
let expected: BTreeSet<_> = expected_features.into_iter().collect();
|
||||
if actual != expected {
|
||||
return Err(format!("{name}: expected features {expected:?}; got {features:?}"));
|
||||
}
|
||||
}
|
||||
// Metadata includes development dependencies. Follow only normal/build edges from
|
||||
// the production exporter so a parser accidentally promoted into it cannot hide there.
|
||||
let by_id: HashMap<_, _> = packages.iter().filter_map(|p| p.get("id").and_then(Value::as_str).map(|id| (id, p))).collect();
|
||||
let root = one_package("deckcraft-pdf")?.get("id").and_then(Value::as_str).ok_or("deckcraft-pdf has no id")?;
|
||||
let mut pending = vec![root];
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let package = by_id.get(id).ok_or_else(|| format!("unresolved package {id}"))?;
|
||||
let name = package.get("name").and_then(Value::as_str).ok_or("resolved package has no name")?;
|
||||
if name == "hayro" || name.starts_with("hayro-") {
|
||||
return Err(format!("unexpected production reader dependency {name}"));
|
||||
}
|
||||
let node = nodes.iter().find(|n| n.get("id").and_then(Value::as_str) == Some(id)).ok_or_else(|| format!("no resolved node for {id}"))?;
|
||||
for dep in node.get("deps").and_then(Value::as_array).ok_or_else(|| format!("no dependency edges for {id}"))? {
|
||||
let kinds = dep.get("dep_kinds").and_then(Value::as_array).ok_or("dependency has no kinds")?;
|
||||
if kinds.is_empty() {
|
||||
return Err("dependency has no kinds".into());
|
||||
}
|
||||
if kinds.iter().any(|k| k.get("kind").and_then(Value::as_str) != Some("dev")) {
|
||||
pending.push(dep.get("pkg").and_then(Value::as_str).ok_or("dependency has no package id")?);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_target_features(tree: &str, parallel: bool) -> Result<(), String> {
|
||||
let mut expected = BTreeSet::from(["default", "raster-images", "simple-text"]);
|
||||
if parallel {
|
||||
expected.insert("rayon");
|
||||
}
|
||||
let mut found = false;
|
||||
for (package, features) in tree.lines().filter_map(|line| line.split_once('|')).filter(|(package, _)| package.starts_with("krilla v")) {
|
||||
found = true;
|
||||
let actual: BTreeSet<_> = features.split(',').collect();
|
||||
if actual != expected {
|
||||
return Err(format!("{package}: expected features {expected:?}; got {actual:?}"));
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return Err("missing Krilla in the exporter's target dependency graph".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn metadata() -> Value {
|
||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
|
||||
json!({
|
||||
"packages": [
|
||||
{"id":"app", "name":"deckcraft-pdf"},
|
||||
{"id":"writer", "name":"craft-pdf", "version":"0.1.0", "source":source},
|
||||
{"id":"backend", "name":"krilla", "version":"0.8.2", "source":source},
|
||||
{"id":"parser", "name":"hayro-syntax", "version":"0.7.2", "source":REGISTRY}
|
||||
],
|
||||
"resolve":{"nodes":[
|
||||
{"id":"app", "deps":[{"pkg":"writer", "dep_kinds":[{"kind":null}]}, {"pkg":"parser", "dep_kinds":[{"kind":"dev"}]}]},
|
||||
{"id":"writer", "features":["write"], "deps":[{"pkg":"backend", "dep_kinds":[{"kind":null}]}]},
|
||||
{"id":"backend", "features":["default","raster-images","rayon","simple-text"], "deps":[]},
|
||||
{"id":"parser", "features":["std"], "deps":[]}
|
||||
]}
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parallel_compression_is_required_only_on_native_targets() {
|
||||
let serial = "krilla v0.8.2 (pinned)|default,raster-images,simple-text\n";
|
||||
let parallel = "krilla v0.8.2 (pinned)|default,raster-images,rayon,simple-text\n";
|
||||
assert!(validate_target_features(serial, false).is_ok());
|
||||
assert!(validate_target_features(parallel, true).is_ok());
|
||||
assert!(validate_target_features(serial, true).is_err());
|
||||
assert!(validate_target_features(parallel, false).is_err());
|
||||
assert!(validate_target_features("", false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unpinned_or_duplicate_writer_sources() {
|
||||
assert!(validate(&metadata()).is_ok());
|
||||
for source in [Value::Null, json!(REGISTRY), json!("git+https://github.com/storytold/craft-libs#other")] {
|
||||
let mut m = metadata();
|
||||
m["packages"][1]["source"] = source;
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
let mut m = metadata();
|
||||
let duplicate = m["packages"][2].clone();
|
||||
m["packages"].as_array_mut().unwrap().push(duplicate);
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_import_features_and_missing_text_shaping() {
|
||||
for (node, features) in [
|
||||
(1, json!([])),
|
||||
(1, json!(["write", "write-pdf"])),
|
||||
(2, json!(["default", "raster-images", "rayon", "simple-text", "pdf"])),
|
||||
(2, json!(["raster-images"])),
|
||||
(3, json!(["std", "images"])),
|
||||
] {
|
||||
let mut m = metadata();
|
||||
m["resolve"]["nodes"][node]["features"] = features;
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_dev_parser_promoted_to_production_directly_or_transitively() {
|
||||
for kind in [Value::Null, json!("build")] {
|
||||
let mut m = metadata();
|
||||
m["resolve"]["nodes"][0]["deps"][1]["dep_kinds"][0]["kind"] = kind;
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
let mut m = metadata();
|
||||
m["resolve"]["nodes"][2]["deps"] = json!([{"pkg":"parser", "dep_kinds":[{"kind":null}]}]);
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_dependency_metadata() {
|
||||
let mut m = metadata();
|
||||
m["resolve"]["nodes"][2]["deps"] = json!([{"pkg":"missing", "dep_kinds":[{"kind":null}]}]);
|
||||
assert!(validate(&m).is_err());
|
||||
let mut m = metadata();
|
||||
m["resolve"] = Value::Null;
|
||||
assert!(validate(&m).is_err());
|
||||
let mut m = metadata();
|
||||
m["packages"].as_array_mut().unwrap().retain(|p| p["name"] != "hayro-syntax");
|
||||
assert!(validate(&m).is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user