mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22db9261f9 | ||
|
|
19724866a6 | ||
|
|
83f21e7cb9 | ||
|
|
bdcbee8d0b | ||
|
|
23144e36fe | ||
|
|
a178b9955b | ||
|
|
2cf4535c5d | ||
|
|
2a4adf3024 | ||
|
|
7f1979a1cc | ||
|
|
1dc69720db | ||
|
|
1b64de3499 | ||
|
|
ad6be08603 | ||
|
|
e5620b538a | ||
|
|
1bb408462c | ||
|
|
f4731822c9 | ||
|
|
f9af68a06f | ||
|
|
be2f10d8cb | ||
|
|
acea4e3f14 | ||
|
|
c8dc372519 | ||
|
|
d7509e7ad6 | ||
|
|
595d8f17c1 | ||
|
|
59d5893793 | ||
|
|
0954289c62 | ||
|
|
062adfe405 | ||
|
|
aa681eafad | ||
|
|
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,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
+113
-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.8.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"cpal",
|
||||
@@ -896,7 +911,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -905,7 +920,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -914,14 +929,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"proptest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -930,14 +945,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -962,7 +977,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -974,7 +989,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -985,7 +1000,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -993,7 +1008,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1002,7 +1017,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1011,7 +1026,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1019,11 +1034,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1033,7 +1048,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1051,7 +1066,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1060,7 +1075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1072,30 +1087,33 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.7.0"
|
||||
version = "0.8.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.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1111,7 +1129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1130,7 +1148,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1144,7 +1162,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1166,7 +1184,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1175,7 +1193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.7.0"
|
||||
version = "0.8.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -1586,12 +1604,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 +2017,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 +2025,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 +2032,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 +2425,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 +2581,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 +2701,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 +3107,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 +4633,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 +4648,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 +4776,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 +5720,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.7.0"
|
||||
version = "0.8.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.8.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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+10
@@ -131,6 +131,13 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | What landed |
|
||||
|---|---|
|
||||
| 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-15 | CLI: .txt and .md export includes table cell text, which it used to skip silently (#814) |
|
||||
| 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 +228,9 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged |
|
||||
| 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 |
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
@@ -1089,7 +1089,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,
|
||||
};
|
||||
|
||||
@@ -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"))?;
|
||||
|
||||
@@ -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> {
|
||||
@@ -308,6 +317,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");
|
||||
|
||||
@@ -1150,19 +1150,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 +1184,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));
|
||||
@@ -615,6 +637,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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();
|
||||
|
||||
+135
-2
@@ -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.
|
||||
@@ -3490,3 +3577,49 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -10,7 +10,7 @@ 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.
|
||||
@@ -418,7 +418,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() {
|
||||
@@ -505,6 +510,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
|
||||
@@ -690,6 +702,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 +823,15 @@ 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 markdown_headings_and_list_items() {
|
||||
assert_eq!(md_heading("# Intro"), Some("Intro"));
|
||||
@@ -807,3 +851,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
+54
-30
@@ -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 {
|
||||
@@ -385,12 +399,17 @@ fn grey(v: u8, opacity: f32) -> Fill {
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -432,14 +451,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 +477,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 +506,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 +537,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 +575,7 @@ impl Exporter<'_> {
|
||||
y += lh;
|
||||
}
|
||||
s.set_fill(None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ruled lines for handwritten notes (3-per-page handouts).
|
||||
@@ -578,21 +600,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(())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+216
-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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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).
|
||||
@@ -246,6 +249,7 @@ impl Default for RenderOpts<'_> {
|
||||
state: None,
|
||||
skip: &[],
|
||||
skip_text: None,
|
||||
editing_text: None,
|
||||
gif_times: &[],
|
||||
threads: 0,
|
||||
offset: (0.0, 0.0),
|
||||
@@ -921,7 +925,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 +1304,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 +2051,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
|
||||
|
||||
@@ -2509,3 +2509,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,
|
||||
|
||||
+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");
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
@@ -2104,14 +2132,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 +2175,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),
|
||||
|
||||
@@ -1879,6 +1879,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) => {
|
||||
@@ -2573,6 +2577,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 +3322,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
@@ -94,3 +94,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"}}}
|
||||
|
||||
@@ -103,6 +103,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-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 |
|
||||
|
||||
+6
-5
@@ -212,11 +212,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
|
||||
@@ -355,6 +354,8 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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-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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -115,6 +115,8 @@ plan above.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 +140,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.
|
||||
|
||||
@@ -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 |
|
||||
@@ -158,6 +165,7 @@ consistency is open (#73, #56).
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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