mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-10 12:29:34 -06:00
Compare commits
25
Commits
house-rules
...
release
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc51f46c83 | ||
|
|
0623b43032 | ||
|
|
5ee82b4e45 | ||
|
|
61423ea35b | ||
|
|
c4674f24fb | ||
|
|
4ba9a1a5d9 | ||
|
|
3504a9da42 | ||
|
|
ccab980657 | ||
|
|
48e2203123 | ||
|
|
76bd26cca0 | ||
|
|
02fbb6a976 | ||
|
|
c16db2ef3e | ||
|
|
809c44654d | ||
|
|
220b52dfdb | ||
|
|
424f575ca5 | ||
|
|
aefd54d28e | ||
|
|
25809faf1a | ||
|
|
fc35ad67c6 | ||
|
|
57780f9e61 | ||
|
|
0627fb2589 | ||
|
|
170c5591b5 | ||
|
|
a883d6b2d9 | ||
|
|
4b1e50a6b0 | ||
|
|
c7919102ac | ||
|
|
159ed07eb3 |
Generated
+48
-37
@@ -847,12 +847,12 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-fonts-build"
|
name = "craft-fonts-build"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-i18n"
|
name = "craft-i18n"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fixed_decimal",
|
"fixed_decimal",
|
||||||
"icu_decimal",
|
"icu_decimal",
|
||||||
@@ -875,7 +875,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-media"
|
name = "craft-media"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
]
|
]
|
||||||
@@ -883,7 +883,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-pdf"
|
name = "craft-pdf"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"krilla",
|
"krilla",
|
||||||
"thiserror 2.0.21",
|
"thiserror 2.0.21",
|
||||||
@@ -892,7 +892,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-ui"
|
name = "craft-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"craft-fonts-build",
|
"craft-fonts-build",
|
||||||
"craft-i18n",
|
"craft-i18n",
|
||||||
@@ -912,7 +912,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-update"
|
name = "craft-update"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"dunce",
|
"dunce",
|
||||||
@@ -935,10 +935,21 @@ dependencies = [
|
|||||||
"zip 9.1.0",
|
"zip 9.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "craft-web-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"serde_json",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "craft-web-host"
|
name = "craft-web-host"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
|
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
@@ -1017,7 +1028,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft"
|
name = "deckcraft"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"chrono",
|
"chrono",
|
||||||
@@ -1039,7 +1050,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-anim"
|
name = "deckcraft-anim"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-geom",
|
"deckcraft-geom",
|
||||||
"deckcraft-model",
|
"deckcraft-model",
|
||||||
@@ -1048,7 +1059,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-av1"
|
name = "deckcraft-av1"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-bitstream",
|
"deckcraft-bitstream",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -1057,14 +1068,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-bitstream"
|
name = "deckcraft-bitstream"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"craft-media",
|
"craft-media",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-cli"
|
name = "deckcraft-cli"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-engine",
|
"deckcraft-engine",
|
||||||
"deckcraft-mcp",
|
"deckcraft-mcp",
|
||||||
@@ -1074,14 +1085,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-color"
|
name = "deckcraft-color"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-engine"
|
name = "deckcraft-engine"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"deckcraft-anim",
|
"deckcraft-anim",
|
||||||
@@ -1107,7 +1118,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-fonts"
|
name = "deckcraft-fonts"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"epaint_default_fonts",
|
"epaint_default_fonts",
|
||||||
"harfrust 0.13.3",
|
"harfrust 0.13.3",
|
||||||
@@ -1119,7 +1130,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-format"
|
name = "deckcraft-format"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-model",
|
"deckcraft-model",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1130,7 +1141,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-geom"
|
name = "deckcraft-geom"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kurbo",
|
"kurbo",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1138,7 +1149,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-h264"
|
name = "deckcraft-h264"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-bitstream",
|
"deckcraft-bitstream",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -1147,7 +1158,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-hevc"
|
name = "deckcraft-hevc"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-bitstream",
|
"deckcraft-bitstream",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -1156,21 +1167,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-isobmff"
|
name = "deckcraft-isobmff"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"craft-media",
|
"craft-media",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-matroska"
|
name = "deckcraft-matroska"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"craft-media",
|
"craft-media",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-mcp"
|
name = "deckcraft-mcp"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-engine",
|
"deckcraft-engine",
|
||||||
"deckcraft-render",
|
"deckcraft-render",
|
||||||
@@ -1180,7 +1191,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-media"
|
name = "deckcraft-media"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-av1",
|
"deckcraft-av1",
|
||||||
"deckcraft-h264",
|
"deckcraft-h264",
|
||||||
@@ -1198,7 +1209,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-metafile"
|
name = "deckcraft-metafile"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"image",
|
"image",
|
||||||
"kurbo",
|
"kurbo",
|
||||||
@@ -1207,7 +1218,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-model"
|
name = "deckcraft-model"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-color",
|
"deckcraft-color",
|
||||||
"deckcraft-geom",
|
"deckcraft-geom",
|
||||||
@@ -1219,15 +1230,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-ogg"
|
name = "deckcraft-ogg"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-opus"
|
name = "deckcraft-opus"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-pdf"
|
name = "deckcraft-pdf"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"craft-pdf",
|
"craft-pdf",
|
||||||
"deckcraft-fonts",
|
"deckcraft-fonts",
|
||||||
@@ -1245,7 +1256,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-pptx"
|
name = "deckcraft-pptx"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-color",
|
"deckcraft-color",
|
||||||
"deckcraft-engine",
|
"deckcraft-engine",
|
||||||
@@ -1261,7 +1272,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-render"
|
name = "deckcraft-render"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-anim",
|
"deckcraft-anim",
|
||||||
"deckcraft-color",
|
"deckcraft-color",
|
||||||
@@ -1280,7 +1291,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-text"
|
name = "deckcraft-text"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"deckcraft-color",
|
"deckcraft-color",
|
||||||
@@ -1294,7 +1305,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-ui-egui"
|
name = "deckcraft-ui-egui"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"craft-ui",
|
"craft-ui",
|
||||||
"craft-update",
|
"craft-update",
|
||||||
@@ -1320,7 +1331,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-vp9"
|
name = "deckcraft-vp9"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deckcraft-bitstream",
|
"deckcraft-bitstream",
|
||||||
"rayon",
|
"rayon",
|
||||||
@@ -1329,8 +1340,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deckcraft-web"
|
name = "deckcraft-web"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"craft-web-api",
|
||||||
"craft-web-host",
|
"craft-web-host",
|
||||||
"deckcraft-engine",
|
"deckcraft-engine",
|
||||||
"deckcraft-ui-egui",
|
"deckcraft-ui-egui",
|
||||||
@@ -2779,7 +2791,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "krilla"
|
name = "krilla"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
|
source = "git+https://github.com/storytold/craft-libs?rev=1b34eba41e62138fc2438d2734523b302840b45d#1b34eba41e62138fc2438d2734523b302840b45d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bumpalo",
|
"bumpalo",
|
||||||
@@ -6313,7 +6325,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xtask"
|
name = "xtask"
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
@@ -6559,7 +6571,6 @@ dependencies = [
|
|||||||
"nix",
|
"nix",
|
||||||
"typed-path",
|
"typed-path",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"zopfli",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
+6
-6
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.15.0"
|
version = "0.17.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
@@ -59,11 +59,11 @@ harfrust = "0.13"
|
|||||||
vello_cpu = "0.2"
|
vello_cpu = "0.2"
|
||||||
vello_common = { version = "0.2", default-features = false }
|
vello_common = { version = "0.2", default-features = false }
|
||||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false }
|
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false }
|
||||||
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
|
||||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||||
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1b34eba41e62138fc2438d2734523b302840b45d", features = ["egui"] }
|
||||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" }
|
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0" }
|
||||||
i_overlay = "9"
|
i_overlay = "9"
|
||||||
quick-xml = "0.41"
|
quick-xml = "0.41"
|
||||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||||
@@ -109,7 +109,7 @@ codegen-units = 16
|
|||||||
debug = 0
|
debug = 0
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.8.2" }
|
krilla = { git = "https://github.com/storytold/craft-libs", rev = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.8.2" }
|
||||||
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||||
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||||
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
|
||||||
|
|||||||
@@ -16,8 +16,10 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
deckcraft-engine = { workspace = true }
|
deckcraft-engine = { workspace = true }
|
||||||
deckcraft-ui-egui = { workspace = true }
|
deckcraft-ui-egui = { workspace = true }
|
||||||
|
# The page command API (storytold/craft-libs#413): window.deckcraft over the control channel.
|
||||||
|
craft-web-api = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||||
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
# The host messages (storytold/craft-libs): one contract and one bridge for the web builds.
|
||||||
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
|
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
|
||||||
egui = { workspace = true }
|
egui = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
//! `window.deckcraft`: the page command API (`craft-web-api`, storytold/craft-libs#413) over the
|
||||||
|
//! app's own control channel, the one the desktop control server drives (`docs/control-protocol.md`).
|
||||||
|
//!
|
||||||
|
//! The crate owns the JavaScript side (`request`, `execute`, `commands`, `docInspect`, `uiInspect`,
|
||||||
|
//! `screenshot`, as promises with a timeout). This module is DeckCraft's part: a [`Request`] that
|
||||||
|
//! queues a [`ControlRequest`] for the app, which answers it on its next frame, and waits for the
|
||||||
|
//! reply. A call the page gave up on is released ([`Pending`]), so the app skips it if it has not started it, instead of
|
||||||
|
//! applying a command the page was told had failed.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicU8;
|
||||||
|
use std::sync::mpsc::{Receiver, TryRecvError};
|
||||||
|
|
||||||
|
use deckcraft_ui_egui::ControlRequest;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
/// How often a waiting call looks for the app's reply, in milliseconds (about a frame).
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
const POLL_MS: i32 = 16;
|
||||||
|
|
||||||
|
/// A queued control request whose reply has not arrived. Dropping it before the reply (the page's
|
||||||
|
/// timeout fired, or the call was abandoned) releases the request: the app skips it if it has not started it (one it has taken still finishes).
|
||||||
|
struct Pending {
|
||||||
|
reply: Receiver<Value>,
|
||||||
|
state: Arc<AtomicU8>,
|
||||||
|
answered: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pending {
|
||||||
|
/// `Some` once the app has answered or gone away; `None` while the request is still waiting.
|
||||||
|
fn poll(&mut self) -> Option<Value> {
|
||||||
|
match self.reply.try_recv() {
|
||||||
|
Ok(reply) => {
|
||||||
|
self.answered = true;
|
||||||
|
Some(reply)
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => None,
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
self.answered = true;
|
||||||
|
Some(json!({"ok": false, "error": "the app dropped the request"}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for Pending {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.answered {
|
||||||
|
ControlRequest::abandon(&self.state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub use web::install;
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
mod web {
|
||||||
|
use std::rc::Rc;
|
||||||
|
use std::sync::mpsc::channel;
|
||||||
|
|
||||||
|
use craft_web_api::methods::Methods;
|
||||||
|
use craft_web_api::page::{PageApi, Request};
|
||||||
|
use deckcraft_ui_egui::{ControlRequest, SlideApp};
|
||||||
|
use serde_json::json;
|
||||||
|
use wasm_bindgen_futures::JsFuture;
|
||||||
|
|
||||||
|
use super::{POLL_MS, Pending};
|
||||||
|
|
||||||
|
/// The app id in `window.<app>`.
|
||||||
|
const APP: &str = "deckcraft";
|
||||||
|
|
||||||
|
/// Give the app its control queue and put `window.deckcraft` on the page. An existing
|
||||||
|
/// `window.deckcraft` keeps the functions it already has.
|
||||||
|
pub fn install(app: &mut SlideApp, ctx: egui::Context) {
|
||||||
|
let (queue, requests) = channel::<ControlRequest>();
|
||||||
|
app.control_rx = Some(requests);
|
||||||
|
let request: Request = Rc::new(move |method, params| {
|
||||||
|
let (control, reply) = ControlRequest::new(method, params);
|
||||||
|
let mut pending = Pending { reply, state: control.state.clone(), answered: false };
|
||||||
|
if queue.send(control).is_err() {
|
||||||
|
pending.answered = true;
|
||||||
|
return Box::pin(async { json!({"ok": false, "error": "the app is not running"}) });
|
||||||
|
}
|
||||||
|
ctx.request_repaint();
|
||||||
|
Box::pin(async move {
|
||||||
|
loop {
|
||||||
|
if let Some(reply) = pending.poll() {
|
||||||
|
return reply;
|
||||||
|
}
|
||||||
|
sleep(POLL_MS).await;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if let Err(e) = Rc::new(PageApi::new(APP, Methods::default(), request)).install() {
|
||||||
|
log::warn!("couldn't install window.{APP}: {e:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn sleep(ms: i32) {
|
||||||
|
let promise = js_sys::Promise::new(&mut |resolve, _| {
|
||||||
|
if let Some(window) = web_sys::window() {
|
||||||
|
let _ = window.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, ms);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let _ = JsFuture::from(promise).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::atomic::Ordering;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `ControlRequest`'s `state` values (`deckcraft_ui_egui::control`): the app has started it, or its
|
||||||
|
/// sender gave up.
|
||||||
|
const TAKEN: u8 = 1;
|
||||||
|
const ABANDONED: u8 = 2;
|
||||||
|
|
||||||
|
fn pending() -> (Pending, ControlRequest) {
|
||||||
|
let (control, reply) = ControlRequest::new("document.inspect", json!({}));
|
||||||
|
(Pending { reply, state: control.state.clone(), answered: false }, control)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_call_the_page_gave_up_on_is_released_so_the_app_skips_it() {
|
||||||
|
let (waiting, control) = pending();
|
||||||
|
drop(waiting);
|
||||||
|
assert_eq!(control.state.load(Ordering::SeqCst), ABANDONED);
|
||||||
|
assert!(!control.take(), "the app must not run a request the page was told had failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_answered_call_is_not_released() {
|
||||||
|
let (mut waiting, control) = pending();
|
||||||
|
assert!(control.take());
|
||||||
|
control.reply.send(json!({"ok": true, "result": 1})).unwrap_or_default();
|
||||||
|
assert_eq!(waiting.poll(), Some(json!({"ok": true, "result": 1})));
|
||||||
|
drop(waiting);
|
||||||
|
assert_eq!(control.state.load(Ordering::SeqCst), TAKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_request_the_app_never_runs_waits_until_it_is_dropped() {
|
||||||
|
let (mut waiting, control) = pending();
|
||||||
|
assert_eq!(waiting.poll(), None);
|
||||||
|
drop(control);
|
||||||
|
assert_eq!(waiting.poll().and_then(|r| r["ok"].as_bool()), Some(false), "the app went away");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
#[cfg(any(target_arch = "wasm32", test))]
|
||||||
|
mod api;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod host;
|
mod host;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ pub fn start() {
|
|||||||
{
|
{
|
||||||
crate::host::Bridge::set_author(&mut app, &author);
|
crate::host::Bridge::set_author(&mut app, &author);
|
||||||
}
|
}
|
||||||
|
crate::api::install(&mut app, cc.egui_ctx.clone());
|
||||||
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
|
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
fn on_exit(&mut self) {
|
fn on_exit(&mut self) {
|
||||||
save_prefs(&self.0);
|
save_prefs(&self.0);
|
||||||
|
// The window is closing for good: a ready update's helper starts now and installs it once
|
||||||
|
// the process has exited.
|
||||||
|
self.0.start_pending_update();
|
||||||
// macOS (#279): end the process here, before eframe tears the window down. AccessKit's
|
// macOS (#279): end the process here, before eframe tears the window down. AccessKit's
|
||||||
// macOS adapter subclasses the window's content view and restores its class on teardown;
|
// macOS adapter subclasses the window's content view and restores its class on teardown;
|
||||||
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
|
// AppKit's Touch Bar support observes that view (KVO), so in the next display cycle
|
||||||
|
|||||||
@@ -120,13 +120,7 @@ pub(super) fn line_spacing(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
|
pub(super) fn paragraph(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let a = str_param(p, "align").map(|a| match a {
|
let a = str_param(p, "align").map(|a| parse_align("format.paragraph", a)).transpose()?;
|
||||||
"center" => Align::Center,
|
|
||||||
"right" => Align::Right,
|
|
||||||
"justify" => Align::Justify,
|
|
||||||
"distributed" => Align::Distributed,
|
|
||||||
_ => Align::Left,
|
|
||||||
});
|
|
||||||
let ml = f64_param(p, "indentLeft");
|
let ml = f64_param(p, "indentLeft");
|
||||||
let ind = f64_param(p, "indentFirst");
|
let ind = f64_param(p, "indentFirst");
|
||||||
let sb = f64_param(p, "spaceBefore");
|
let sb = f64_param(p, "spaceBefore");
|
||||||
@@ -182,10 +176,10 @@ pub(super) fn clamp_pt(v: f64) -> f64 {
|
|||||||
fn parse_align(cmd: &str, s: &str) -> Result<Align> {
|
fn parse_align(cmd: &str, s: &str) -> Result<Align> {
|
||||||
Ok(match s {
|
Ok(match s {
|
||||||
"left" | "l" => Align::Left,
|
"left" | "l" => Align::Left,
|
||||||
"center" | "ctr" => Align::Center,
|
"center" | "centre" | "ctr" => Align::Center,
|
||||||
"right" | "r" => Align::Right,
|
"right" | "r" => Align::Right,
|
||||||
"justify" | "just" => Align::Justify,
|
"justify" | "just" => Align::Justify,
|
||||||
"justLow" => Align::JustLow,
|
"justLow" | "justifyLow" => Align::JustLow,
|
||||||
"distributed" | "dist" => Align::Distributed,
|
"distributed" | "dist" => Align::Distributed,
|
||||||
"thaiDist" => Align::ThaiDistributed,
|
"thaiDist" => Align::ThaiDistributed,
|
||||||
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
|
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, color_param, color_value, edit_geometry, edit_shapes, f64_param, has_selection,
|
CommandSpec, NO_ID_SPACE, bad, bool_or, bool_param, cmd, color_param, color_value, edit_geometry, edit_shapes, f64_param, has_selection,
|
||||||
has_slide, id_param, ok, str_param, targets, usize_param, with_param, xfrm_of,
|
has_slide, id_param, ok, opt_usize_param, str_param, targets, usize_param, with_param, xfrm_of,
|
||||||
};
|
};
|
||||||
use crate::{Result, Session};
|
use crate::{Result, Session};
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
use super::{
|
use super::{
|
||||||
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
|
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
|
||||||
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
|
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
|
||||||
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param,
|
edit_shapes, f64_param, inherited_fills, json, opt_usize_param, requested_fill, str_param, targets,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let i = usize_param(p, "index").unwrap_or(0).min(16);
|
let i = opt_usize_param(p, "index", "shape.adjust")?.unwrap_or(0).min(16);
|
||||||
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
let v = f64_param(p, "value").ok_or_else(|| bad("shape.adjust", "missing `value`"))?;
|
||||||
edit_geometry(s, p, "shape.adjust", |sh| {
|
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||||
if let Geom::Preset { name, adj } = &mut sh.geom {
|
if let Geom::Preset { name, adj } = &mut sh.geom {
|
||||||
@@ -556,7 +556,7 @@ pub fn quick_style_for(index: usize) -> (ShapeStyle, Option<Fill>, Option<Line>)
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
|
pub(super) fn quick_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||||
let i = usize_param(p, "index").unwrap_or(1).min(41);
|
let i = opt_usize_param(p, "index", "shape.quickStyle")?.unwrap_or(1).min(41);
|
||||||
let (st, f, l) = quick_style_for(i);
|
let (st, f, l) = quick_style_for(i);
|
||||||
edit_shapes(s, p, "shape.quickStyle", |sh| {
|
edit_shapes(s, p, "shape.quickStyle", |sh| {
|
||||||
sh.style = Some(st.clone());
|
sh.style = Some(st.clone());
|
||||||
|
|||||||
@@ -1713,6 +1713,31 @@ fn tab_moves_between_cells_skipping_merged_ones() {
|
|||||||
assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row");
|
assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_paragraph_align_names_match_format_align() {
|
||||||
|
use deckcraft_model::text::Align;
|
||||||
|
let mut s = session();
|
||||||
|
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||||
|
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||||
|
let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.align;
|
||||||
|
for (name, want) in [
|
||||||
|
("justLow", Align::JustLow),
|
||||||
|
("justifyLow", Align::JustLow),
|
||||||
|
("thaiDist", Align::ThaiDistributed),
|
||||||
|
("ctr", Align::Center),
|
||||||
|
("centre", Align::Center),
|
||||||
|
("r", Align::Right),
|
||||||
|
("just", Align::Justify),
|
||||||
|
("dist", Align::Distributed),
|
||||||
|
("distributed", Align::Distributed),
|
||||||
|
("left", Align::Left),
|
||||||
|
] {
|
||||||
|
s.execute("format.paragraph", &json!({"align": name})).unwrap();
|
||||||
|
assert_eq!(para(&s), Some(want), "{name}");
|
||||||
|
}
|
||||||
|
assert!(s.execute("format.paragraph", &json!({"align": "bogus"})).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn right_to_left_paragraphs() {
|
fn right_to_left_paragraphs() {
|
||||||
let mut s = session();
|
let mut s = session();
|
||||||
@@ -7029,3 +7054,18 @@ fn outline_export_skips_footer_date_and_slide_number() {
|
|||||||
assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{rtf:?}");
|
assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{rtf:?}");
|
||||||
assert!(!rtf.contains("Company Confidential") && !rtf.contains("1 May 2026"), "{rtf:?}");
|
assert!(!rtf.contains("Company Confidential") && !rtf.contains("1 May 2026"), "{rtf:?}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shape_adjust_and_quick_style_reject_invalid_index() {
|
||||||
|
let mut s = session();
|
||||||
|
let r = s.execute("shape.insert", &json!({"preset": "roundRect", "rect": [100, 100, 200, 100]})).unwrap();
|
||||||
|
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||||
|
let before = s.doc().unwrap().shape(id).unwrap().clone();
|
||||||
|
for bad in [json!(-1), json!(1.5)] {
|
||||||
|
assert!(s.execute("shape.adjust", &json!({"index": bad, "value": 0.4})).is_err(), "adjust accepted {bad}");
|
||||||
|
assert!(s.execute("shape.quickStyle", &json!({"index": bad})).is_err(), "quickStyle accepted {bad}");
|
||||||
|
}
|
||||||
|
assert_eq!(s.doc().unwrap().shape(id).unwrap().geom, before.geom);
|
||||||
|
assert_eq!(s.doc().unwrap().shape(id).unwrap().style, before.style);
|
||||||
|
s.execute("shape.adjust", &json!({"value": 0.4})).unwrap();
|
||||||
|
}
|
||||||
|
|||||||
@@ -498,6 +498,11 @@ pub fn next_case(s: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the `.` at `i` sits between two ASCII digits (a decimal point or version number).
|
||||||
|
fn is_decimal_point(all: &[char], i: usize) -> bool {
|
||||||
|
i > 0 && all[i - 1].is_ascii_digit() && all.get(i + 1).is_some_and(|n| n.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
|
/// Change case: `upper`, `lower`, `sentence`, `title`, `toggle`.
|
||||||
pub fn change_case(s: &str, mode: &str) -> String {
|
pub fn change_case(s: &str, mode: &str) -> String {
|
||||||
change_case_parts(&[s], mode).concat()
|
change_case_parts(&[s], mode).concat()
|
||||||
@@ -532,7 +537,7 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
|||||||
} else {
|
} else {
|
||||||
out.extend(c.to_lowercase());
|
out.extend(c.to_lowercase());
|
||||||
}
|
}
|
||||||
if (title && c.is_whitespace()) || (!title && matches!(c, '.' | '!' | '?')) {
|
if (title && c.is_whitespace()) || (!title && matches!(c, '.' | '!' | '?') && !(c == '.' && is_decimal_point(&all, i))) {
|
||||||
start = true;
|
start = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -558,6 +563,13 @@ mod tests {
|
|||||||
assert_eq!(change_case("ΟΔΟΣ", "lower"), "οδος");
|
assert_eq!(change_case("ΟΔΟΣ", "lower"), "οδος");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sentence_case_decimal_point_is_not_a_boundary() {
|
||||||
|
assert_eq!(change_case("I HAVE 3.5 APPLES", "sentence"), "I have 3.5 apples");
|
||||||
|
assert_eq!(change_case_parts(&["V 1.", "2 BETA"], "sentence").concat(), "V 1.2 beta");
|
||||||
|
assert_eq!(change_case("HELLO. WORLD", "sentence"), "Hello. World");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn title_sigma_across_runs_is_not_final() {
|
fn title_sigma_across_runs_is_not_final() {
|
||||||
assert_eq!(change_case_parts(&["ΟΔΟΣ", "ΑΣ"], "title"), ["Οδοσ", "ας"]);
|
assert_eq!(change_case_parts(&["ΟΔΟΣ", "ΑΣ"], "title"), ["Οδοσ", "ας"]);
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
|
|||||||
compat_ln_spc: None,
|
compat_ln_spc: None,
|
||||||
vert_overflow: None,
|
vert_overflow: None,
|
||||||
horz_overflow: None,
|
horz_overflow: None,
|
||||||
|
raw3d: None,
|
||||||
});
|
});
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,9 @@ pub struct BodyProps {
|
|||||||
pub compat_ln_spc: Option<bool>,
|
pub compat_ln_spc: Option<bool>,
|
||||||
pub vert_overflow: Option<TextOverflow>,
|
pub vert_overflow: Option<TextOverflow>,
|
||||||
pub horz_overflow: Option<TextOverflow>,
|
pub horz_overflow: Option<TextOverflow>,
|
||||||
|
/// Unmodelled 3-D XML on the text body (`a:scene3d`, `a:sp3d`, `a:flatTx`).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub raw3d: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
@@ -187,6 +190,9 @@ impl BodyProps {
|
|||||||
self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc);
|
self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc);
|
||||||
self.vert_overflow = self.vert_overflow.or(p.vert_overflow);
|
self.vert_overflow = self.vert_overflow.or(p.vert_overflow);
|
||||||
self.horz_overflow = self.horz_overflow.or(p.horz_overflow);
|
self.horz_overflow = self.horz_overflow.or(p.horz_overflow);
|
||||||
|
if self.raw3d.is_none() {
|
||||||
|
self.raw3d.clone_from(&p.raw3d);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -337,16 +337,31 @@ fn fit_moving_avg(pairs: &[(f64, f64)], period: usize) -> Option<(Model, Vec<f64
|
|||||||
|
|
||||||
#[allow(clippy::needless_range_loop)]
|
#[allow(clippy::needless_range_loop)]
|
||||||
fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Option<Vec<f64>> {
|
fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Option<Vec<f64>> {
|
||||||
|
// Fit `y - b0 = (x / sx) * q(u)` with `u = (x - xm) / su` and `q` of degree `order - 1`. The
|
||||||
|
// basis `(x / sx) * u^j` spans the same polynomials as `x^(j + 1)`, but keeps the pivots in
|
||||||
|
// `solve` independent of the units and the offset of x.
|
||||||
|
let n = xs.len() as f64;
|
||||||
|
let sx = xs.iter().fold(0.0_f64, |acc, x| acc.max(x.abs()));
|
||||||
|
if sx <= 0.0 || !sx.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let xm = xs.iter().sum::<f64>() / n;
|
||||||
|
let su = match xs.iter().fold(0.0_f64, |acc, x| acc.max((x - xm).abs())) {
|
||||||
|
s if s > 0.0 && s.is_finite() => s,
|
||||||
|
_ => 1.0,
|
||||||
|
};
|
||||||
let m = order;
|
let m = order;
|
||||||
let mut ata = vec![vec![0.0; m]; m];
|
let mut ata = vec![vec![0.0; m]; m];
|
||||||
let mut atb = vec![0.0; m];
|
let mut atb = vec![0.0; m];
|
||||||
for (x, y) in xs.iter().zip(ys) {
|
for (x, y) in xs.iter().zip(ys) {
|
||||||
|
let v = x / sx;
|
||||||
|
let u = (x - xm) / su;
|
||||||
let dy = y - b0;
|
let dy = y - b0;
|
||||||
for i in 0..m {
|
for i in 0..m {
|
||||||
let xi = x.powi((i + 1) as i32);
|
let xi = v * u.powi(i as i32);
|
||||||
atb[i] += dy * xi;
|
atb[i] += dy * xi;
|
||||||
for j in i..m {
|
for j in i..m {
|
||||||
ata[i][j] += xi * x.powi((j + 1) as i32);
|
ata[i][j] += xi * v * u.powi(j as i32);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -355,10 +370,11 @@ fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Opti
|
|||||||
ata[i][j] = ata[j][i];
|
ata[i][j] = ata[j][i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let solved = solve(ata, atb)?;
|
let centred: Vec<f64> = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / su.powi(k as i32)).collect();
|
||||||
|
let q = expand_shift(¢red, xm);
|
||||||
let mut coeff = Vec::with_capacity(order + 1);
|
let mut coeff = Vec::with_capacity(order + 1);
|
||||||
coeff.push(b0);
|
coeff.push(b0);
|
||||||
coeff.extend(solved);
|
coeff.extend(q.iter().map(|c| c / sx));
|
||||||
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +390,13 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Opti
|
|||||||
return polyfit_with_intercept(xs, ys, order, b0);
|
return polyfit_with_intercept(xs, ys, order, b0);
|
||||||
}
|
}
|
||||||
let xm = xs.iter().sum::<f64>() / n as f64;
|
let xm = xs.iter().sum::<f64>() / n as f64;
|
||||||
let us: Vec<f64> = xs.iter().map(|x| x - xm).collect();
|
// Scale the centred x to unit range so the pivot threshold in `solve` does not depend on the
|
||||||
|
// units of x.
|
||||||
|
let s = xs.iter().fold(0.0_f64, |acc, x| acc.max((x - xm).abs()));
|
||||||
|
if s <= 0.0 || !s.is_finite() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let us: Vec<f64> = xs.iter().map(|x| (x - xm) / s).collect();
|
||||||
let m = order + 1;
|
let m = order + 1;
|
||||||
let mut ata = vec![vec![0.0; m]; m];
|
let mut ata = vec![vec![0.0; m]; m];
|
||||||
let mut atb = vec![0.0; m];
|
let mut atb = vec![0.0; m];
|
||||||
@@ -393,7 +415,7 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Opti
|
|||||||
ata[i][j] = ata[j][i];
|
ata[i][j] = ata[j][i];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let centred = solve(ata, atb)?;
|
let centred: Vec<f64> = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / s.powi(k as i32)).collect();
|
||||||
let coeff = expand_shift(¢red, xm);
|
let coeff = expand_shift(¢red, xm);
|
||||||
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
|
||||||
}
|
}
|
||||||
@@ -571,6 +593,49 @@ mod tests {
|
|||||||
assert!((fit.y(3e-10).unwrap() - 3.0).abs() < 1e-6);
|
assert!((fit.y(3e-10).unwrap() - 3.0).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poly_fits_tiny_x_values() {
|
||||||
|
let xs = [1e-5, 2e-5, 3e-5, 4e-5, 5e-5];
|
||||||
|
let f = |x: f64| 1.0 + 2.0 * x + 3.0 * x * x;
|
||||||
|
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
|
||||||
|
let fit = line(TrendlineType::Poly).fit(&xs, &ys).expect("poly fit of tiny x values");
|
||||||
|
assert!((fit.y(3e-5).unwrap() - f(3e-5)).abs() < 1e-9);
|
||||||
|
let big: Vec<f64> = xs.iter().map(|x| x * 1e5).collect();
|
||||||
|
let scaled = line(TrendlineType::Poly).fit(&big, &ys).expect("poly fit of scaled x values");
|
||||||
|
for x in xs {
|
||||||
|
assert!((fit.y(x).unwrap() - scaled.y(x * 1e5).unwrap()).abs() < 1e-9, "{x}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poly_with_intercept_fits_tiny_x_values() {
|
||||||
|
let xs = [1e-5, 2e-5, 3e-5, 4e-5, 5e-5];
|
||||||
|
let f = |x: f64| 1.0 + 2.0 * x + 3.0 * x * x;
|
||||||
|
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
|
||||||
|
let mut poly = line(TrendlineType::Poly);
|
||||||
|
poly.intercept = Some(1.0);
|
||||||
|
let fit = poly.fit(&xs, &ys).expect("poly fit with intercept of tiny x values");
|
||||||
|
assert!((fit.y(3e-5).unwrap() - f(3e-5)).abs() < 1e-9);
|
||||||
|
let big: Vec<f64> = xs.iter().map(|x| x * 1e5).collect();
|
||||||
|
let scaled = poly.fit(&big, &ys).expect("poly fit with intercept of scaled x values");
|
||||||
|
for x in xs {
|
||||||
|
assert!((fit.y(x).unwrap() - scaled.y(x * 1e5).unwrap()).abs() < 1e-9, "{x}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn poly_with_intercept_fits_offset_but_narrow_x_range() {
|
||||||
|
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
|
||||||
|
let f = |x: f64| 1.0 + x * (1.0 + 100.0 * (x - 45000.02));
|
||||||
|
let ys: Vec<f64> = xs.iter().map(|x| f(*x)).collect();
|
||||||
|
let mut poly = line(TrendlineType::Poly);
|
||||||
|
poly.intercept = Some(1.0);
|
||||||
|
let fit = poly.fit(&xs, &ys).expect("poly fit with intercept of offset x values");
|
||||||
|
for x in xs {
|
||||||
|
assert!((fit.y(x).unwrap() - f(x)).abs() < 1e-2, "{x}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn linear_fits_offset_but_narrow_x_range() {
|
fn linear_fits_offset_but_narrow_x_range() {
|
||||||
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
|
let xs = [45000.00, 45000.01, 45000.02, 45000.03, 45000.04];
|
||||||
|
|||||||
@@ -629,7 +629,19 @@ pub fn list_style(imp: &mut Imp, part: &Part, ls: &El) -> ListStyle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn text_body(imp: &mut Imp, part: &Part, tx: &El) -> TextBody {
|
pub fn text_body(imp: &mut Imp, part: &Part, tx: &El) -> TextBody {
|
||||||
let body = tx.child("bodyPr").map(body_pr).unwrap_or_default();
|
let mut body = tx.child("bodyPr").map(body_pr).unwrap_or_default();
|
||||||
|
if let Some(b) = tx.child("bodyPr") {
|
||||||
|
let mut raw3d = String::new();
|
||||||
|
if let Some(sc) = b.child("scene3d") {
|
||||||
|
raw3d.push_str(&part.keep_inner(sc));
|
||||||
|
}
|
||||||
|
if let Some(sp) = b.child("sp3d").or_else(|| b.child("flatTx")) {
|
||||||
|
raw3d.push_str(&part.keep_inner(sp));
|
||||||
|
}
|
||||||
|
if !raw3d.is_empty() && raw3d.len() <= 64 * 1024 {
|
||||||
|
body.raw3d = Some(raw3d);
|
||||||
|
}
|
||||||
|
}
|
||||||
let list_style = tx.child("lstStyle").map(|l| list_style(imp, part, l)).unwrap_or_default();
|
let list_style = tx.child("lstStyle").map(|l| list_style(imp, part, l)).unwrap_or_default();
|
||||||
let paragraphs = tx.children_named("p").take(100_000).map(|p| paragraph(imp, part, p)).collect();
|
let paragraphs = tx.children_named("p").take(100_000).map(|p| paragraph(imp, part, p)).collect();
|
||||||
TextBody { body, list_style, paragraphs }
|
TextBody { body, list_style, paragraphs }
|
||||||
|
|||||||
@@ -395,6 +395,12 @@ fn grp(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> Sha
|
|||||||
if let Some(fx) = pr.child("effectLst") {
|
if let Some(fx) = pr.child("effectLst") {
|
||||||
s.effects = Some(dml::effects(fx));
|
s.effects = Some(dml::effects(fx));
|
||||||
}
|
}
|
||||||
|
if let Some(sc) = pr.child("scene3d") {
|
||||||
|
let raw = part.keep_inner(sc);
|
||||||
|
if !raw.is_empty() && raw.len() <= 64 * 1024 {
|
||||||
|
s.effects.get_or_insert_with(Effects::default).raw3d = Some(raw);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let children = sp_tree(imp, part, ctx, e, depth + 1);
|
let children = sp_tree(imp, part, ctx, e, depth + 1);
|
||||||
let child = child.or_else(|| s.xfrm.map(|x| Xfrm::new(x.x, x.y, x.w, x.h))).unwrap_or_default();
|
let child = child.or_else(|| s.xfrm.map(|x| Xfrm::new(x.x, x.y, x.w, x.h))).unwrap_or_default();
|
||||||
|
|||||||
@@ -461,6 +461,21 @@ fn sound_label(name: &str) -> String {
|
|||||||
if label.is_empty() { "sound.wav".into() } else { label }
|
if label.is_empty() { "sound.wav".into() } else { label }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate an unmodelled 3-D fragment (`a:scene3d`, `a:sp3d`, `a:flatTx`) before writing raw XML.
|
||||||
|
pub fn valid_raw3d<'a>(raw: &'a str, allowed: &[&str]) -> Option<&'a str> {
|
||||||
|
if raw.is_empty() || raw.len() > 64 * 1024 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let wrap = format!("<r xmlns:a=\"{}\">{raw}</r>", crate::opc::NS_A);
|
||||||
|
let doc = crate::xml::parse(wrap.as_bytes()).ok()?;
|
||||||
|
let els: Vec<_> = doc.root.elements().collect();
|
||||||
|
(!els.is_empty()
|
||||||
|
&& els.len() <= allowed.len()
|
||||||
|
&& doc.root.children.len() == els.len()
|
||||||
|
&& els.iter().all(|e| e.prefix() == "a" && allowed.contains(&e.local()) && !crate::read::has_rel_refs(e)))
|
||||||
|
.then_some(raw)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn body_pr(w: &mut W, b: &BodyProps) {
|
pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||||
let a = A::new()
|
let a = A::new()
|
||||||
.o("rot", b.rot.map(|r| (r * 60_000.0).round().clamp(-21_600_000.0, 21_600_000.0) as i64))
|
.o("rot", b.rot.map(|r| (r * 60_000.0).round().clamp(-21_600_000.0, 21_600_000.0) as i64))
|
||||||
@@ -481,7 +496,8 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
|||||||
.o("vertOverflow", b.vert_overflow.map(|v| v.xml()))
|
.o("vertOverflow", b.vert_overflow.map(|v| v.xml()))
|
||||||
.o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(b.horz_overflow));
|
.o("horzOverflow", deckcraft_model::text::TextOverflow::horz_xml(b.horz_overflow));
|
||||||
let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()));
|
let warp = b.warp.as_deref().filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||||
if warp.is_none() && b.autofit.is_none() {
|
let raw3d = b.raw3d.as_deref().and_then(|s| valid_raw3d(s, &["scene3d", "sp3d", "flatTx"]));
|
||||||
|
if warp.is_none() && b.autofit.is_none() && raw3d.is_none() {
|
||||||
w.empty("a:bodyPr", a);
|
w.empty("a:bodyPr", a);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -515,6 +531,9 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
|||||||
}
|
}
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
if let Some(raw) = raw3d {
|
||||||
|
w.raw(raw);
|
||||||
|
}
|
||||||
w.close("a:bodyPr");
|
w.close("a:bodyPr");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -334,7 +334,20 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
|
|||||||
dml::fill(w, x, o, f);
|
dml::fill(w, x, o, f);
|
||||||
}
|
}
|
||||||
if let Some(fx) = &s.effects {
|
if let Some(fx) = &s.effects {
|
||||||
dml::effects(w, fx);
|
if fx.fill_overlay.is_some()
|
||||||
|
|| fx.glow.is_some()
|
||||||
|
|| fx.inner_shadow.is_some()
|
||||||
|
|| fx.outer_shadow.is_some()
|
||||||
|
|| fx.reflection.is_some()
|
||||||
|
|| fx.soft_edge.is_some()
|
||||||
|
{
|
||||||
|
dml::effects(w, fx);
|
||||||
|
}
|
||||||
|
if let Some(raw) =
|
||||||
|
fx.raw3d.as_deref().map(|s| s.find("<a:sp3d").map_or(s, |i| &s[..i])).and_then(|s| dml::valid_raw3d(s, &["scene3d"]))
|
||||||
|
{
|
||||||
|
w.raw(raw);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
w.close("p:grpSpPr");
|
w.close("p:grpSpPr");
|
||||||
shapes(w, x, o, cx, children, depth + 1);
|
shapes(w, x, o, cx, children, depth + 1);
|
||||||
|
|||||||
@@ -241,6 +241,67 @@ fn theme_and_bevel_survive(once: &Presentation, twice: &Presentation) {
|
|||||||
assert_eq!(by_name(twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw));
|
assert_eq!(by_name(twice, "Bevel").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(raw));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_and_group_3d_survive() {
|
||||||
|
let text_3d =
|
||||||
|
"<a:scene3d><a:camera prst=\"orthographicFront\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d><a:sp3d extrusionH=\"12700\"/>";
|
||||||
|
let flat_tx = "<a:scene3d><a:camera prst=\"orthographicFront\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d><a:flatTx z=\"0\"/>";
|
||||||
|
let mut p3 = defaults::new_presentation(None);
|
||||||
|
let mut sh = shape(&mut p3, "Text3D", 10.0, 10.0, 100.0, 40.0);
|
||||||
|
sh.text = Some(TextBody {
|
||||||
|
body: BodyProps { raw3d: Some(text_3d.into()), ..Default::default() },
|
||||||
|
paragraphs: vec![Paragraph::new("3D Text")],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let mut sh_flat = shape(&mut p3, "FlatTx3D", 10.0, 55.0, 100.0, 40.0);
|
||||||
|
sh_flat.text = Some(TextBody {
|
||||||
|
body: BodyProps { raw3d: Some(flat_tx.into()), ..Default::default() },
|
||||||
|
paragraphs: vec![Paragraph::new("Flat Text")],
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let grp_3d = "<a:scene3d><a:camera prst=\"isometricTopUp\"/><a:lightRig rig=\"threePt\" dir=\"t\"/></a:scene3d>";
|
||||||
|
let mut grp = shape(&mut p3, "Group3D", 10.0, 60.0, 100.0, 80.0);
|
||||||
|
grp.effects = Some(Effects { raw3d: Some(grp_3d.into()), ..Default::default() });
|
||||||
|
grp.kind = ShapeKind::Group { children: vec![sh, sh_flat], child: Xfrm::new(10.0, 10.0, 100.0, 80.0) };
|
||||||
|
let layout = defaults::layout_of_kind(&p3, deckcraft_model::LayoutType::Blank).unwrap_or_default();
|
||||||
|
let mut s3 = defaults::new_slide(&mut p3, layout).expect("slide");
|
||||||
|
s3.shapes.push(grp);
|
||||||
|
p3.slides.push(Arc::new(s3));
|
||||||
|
|
||||||
|
let bytes = deckcraft_pptx::export(&p3).expect("export");
|
||||||
|
let slide_xml = {
|
||||||
|
use std::io::Read;
|
||||||
|
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
|
||||||
|
let mut s = String::new();
|
||||||
|
z.by_name("ppt/slides/slide2.xml").expect("slide2").read_to_string(&mut s).expect("utf8");
|
||||||
|
s
|
||||||
|
};
|
||||||
|
assert!(!slide_xml.contains("<a:effectLst/>"), "Group3D with only raw3d must not emit empty effectLst: {slide_xml}");
|
||||||
|
assert!(slide_xml.contains("<a:flatTx z=\"0\"/>"), "{slide_xml}");
|
||||||
|
|
||||||
|
let r3 = deckcraft_pptx::import(&bytes).expect("import");
|
||||||
|
let r3_twice = round(&r3);
|
||||||
|
let g3 = by_name(&r3, "Group3D");
|
||||||
|
let g3_raw = g3.effects.as_ref().and_then(|e| e.raw3d.as_deref()).unwrap_or("");
|
||||||
|
let t3_raw = g3.children()[0].text.as_ref().and_then(|t| t.body.raw3d.as_deref()).unwrap_or("");
|
||||||
|
let flat_raw = g3.children()[1].text.as_ref().and_then(|t| t.body.raw3d.as_deref()).unwrap_or("");
|
||||||
|
assert!(g3_raw.contains("isometricTopUp"), "{g3_raw}");
|
||||||
|
assert!(t3_raw.contains("orthographicFront") && t3_raw.contains("extrusionH=\"12700\""), "{t3_raw}");
|
||||||
|
assert!(flat_raw.contains("flatTx"), "{flat_raw}");
|
||||||
|
assert_eq!(by_name(&r3_twice, "Group3D").effects.as_ref().and_then(|e| e.raw3d.as_deref()), Some(g3_raw));
|
||||||
|
assert_eq!(by_name(&r3_twice, "Group3D").children()[0].text.as_ref().and_then(|t| t.body.raw3d.as_deref()), Some(t3_raw));
|
||||||
|
assert_eq!(by_name(&r3_twice, "Group3D").children()[1].text.as_ref().and_then(|t| t.body.raw3d.as_deref()), Some(flat_raw));
|
||||||
|
|
||||||
|
// BodyProps::inherit propagates parent raw3d only when child raw3d is None.
|
||||||
|
let parent_body = BodyProps { raw3d: Some(text_3d.into()), ..Default::default() };
|
||||||
|
let mut child_body = BodyProps::default();
|
||||||
|
child_body.inherit(&parent_body);
|
||||||
|
assert_eq!(child_body.raw3d.as_deref(), Some(text_3d));
|
||||||
|
let mut override_body = BodyProps { raw3d: Some(flat_tx.into()), ..Default::default() };
|
||||||
|
override_body.inherit(&parent_body);
|
||||||
|
assert_eq!(override_body.raw3d.as_deref(), Some(flat_tx));
|
||||||
|
}
|
||||||
|
|
||||||
fn connector_glue_survives(p: &Presentation, once: &Presentation, twice: &Presentation) {
|
fn connector_glue_survives(p: &Presentation, once: &Presentation, twice: &Presentation) {
|
||||||
let c = by_name(once, "Connector");
|
let c = by_name(once, "Connector");
|
||||||
assert!(matches!(c.kind, ShapeKind::Connector { start: Some((s, 3)), end: Some((e, 1)) } if s == left_of(p) && e == right_of(p)), "{:?}", c.kind);
|
assert!(matches!(c.kind, ShapeKind::Connector { start: Some((s, 3)), end: Some((e, 1)) } if s == left_of(p) && e == right_of(p)), "{:?}", c.kind);
|
||||||
|
|||||||
@@ -137,6 +137,14 @@ impl SlideApp {
|
|||||||
updates_ui::enable(self);
|
updates_ui::enable(self);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Start the helper of an update that is ready, so that it installs once the process has
|
||||||
|
/// exited. The desktop shell calls it in `on_exit`, which ends the process on macOS before the
|
||||||
|
/// app is dropped. Later calls do nothing.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub fn start_pending_update(&mut self) {
|
||||||
|
updates_ui::start_pending_update(self);
|
||||||
|
}
|
||||||
|
|
||||||
/// A message that must be seen wherever the user is: a toast, which also shows over the start
|
/// A message that must be seen wherever the user is: a toast, which also shows over the start
|
||||||
/// screen and the File tab, where there is no status bar.
|
/// screen and the File tab, where there is no status bar.
|
||||||
pub fn notify(&mut self, msg: impl Into<String>) {
|
pub fn notify(&mut self, msg: impl Into<String>) {
|
||||||
|
|||||||
@@ -13,13 +13,11 @@ pub(crate) struct Runtime {
|
|||||||
/// The desktop host turns the check at start on; tests and the web build leave it off.
|
/// The desktop host turns the check at start on; tests and the web build leave it off.
|
||||||
enabled: bool,
|
enabled: bool,
|
||||||
check_requested: bool,
|
check_requested: bool,
|
||||||
/// A manual check's result dialog is showing (checking, up to date or failed).
|
|
||||||
result_open: bool,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Runtime {
|
impl Default for Runtime {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false, result_open: false }
|
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,10 +29,15 @@ pub(crate) fn enable(app: &mut SlideApp) {
|
|||||||
pub(crate) fn request_check(app: &mut SlideApp) {
|
pub(crate) fn request_check(app: &mut SlideApp) {
|
||||||
if !matches!(app.updates_rt.updater.status(), Status::Checking) {
|
if !matches!(app.updates_rt.updater.status(), Status::Checking) {
|
||||||
app.updates_rt.check_requested = true;
|
app.updates_rt.check_requested = true;
|
||||||
app.updates_rt.result_open = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts the helper of an update that is ready, so that it installs once the process has exited.
|
||||||
|
/// Later calls do nothing.
|
||||||
|
pub(crate) fn start_pending_update(app: &mut SlideApp) {
|
||||||
|
app.updates_rt.updater.start_pending_update();
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
|
pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
|
||||||
let rt = &mut app.updates_rt;
|
let rt = &mut app.updates_rt;
|
||||||
if std::mem::take(&mut rt.check_requested) {
|
if std::mem::take(&mut rt.check_requested) {
|
||||||
@@ -46,21 +49,31 @@ pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
pub(crate) fn show(app: &mut SlideApp, ctx: &egui::Context) {
|
||||||
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
|
// An installation starts, and a ready update asks the app to close, only while nothing is unsaved.
|
||||||
let clean = !app.session.documents().iter().any(|d| d.is_dirty());
|
let clean = !app.session.documents().iter().any(|d| d.is_dirty());
|
||||||
match app.updates_rt.updater.show_if(ctx, clean) {
|
match app.updates_rt.updater.show_if(ctx, clean) {
|
||||||
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
Action::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||||
// The app's close path asks about unsaved work; the restart helper relaunches once the process exits.
|
// The app's close path asks about unsaved work; `on_exit` starts the restart helper.
|
||||||
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
Action::Close => ctx.send_viewport_cmd(egui::ViewportCommand::Close),
|
||||||
Action::None => {}
|
Action::None => {}
|
||||||
}
|
}
|
||||||
|
// The result window reports a requested check, and at the first start after an update what
|
||||||
|
// that update came to (installed, or why it failed).
|
||||||
let rt = &mut app.updates_rt;
|
let rt = &mut app.updates_rt;
|
||||||
if rt.result_open {
|
if !matches!(rt.updater.status(), Status::Idle) {
|
||||||
match CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) {
|
let response = CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status());
|
||||||
CheckResultResponse::Close => rt.result_open = false,
|
answer(rt, response);
|
||||||
CheckResultResponse::CheckAgain => rt.check_requested = true,
|
}
|
||||||
CheckResultResponse::None => {}
|
}
|
||||||
}
|
|
||||||
|
/// Acts on the result window's answer.
|
||||||
|
fn answer(rt: &mut Runtime, response: CheckResultResponse) {
|
||||||
|
match response {
|
||||||
|
CheckResultResponse::Close => rt.updater.dismiss_status(),
|
||||||
|
CheckResultResponse::CheckAgain => rt.check_requested = true,
|
||||||
|
// The next frame's `show_if` asks the window to close once nothing is unsaved.
|
||||||
|
CheckResultResponse::RestartToUpdate => rt.updater.restart_to_update(),
|
||||||
|
CheckResultResponse::None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,4 +90,62 @@ mod tests {
|
|||||||
ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui.ctx())).textures_delta.clear();
|
ctx.run_ui(egui::RawInput::default(), |ui| show(&mut app, ui.ctx())).textures_delta.clear();
|
||||||
assert!(app.updates_rt.updater.prompt_open());
|
assert!(app.updates_rt.updater.prompt_open());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An app whose updater keeps its outcome records under `root`, reads no launcher choice and
|
||||||
|
/// asks no server, with the check at start turned on.
|
||||||
|
fn app_with_records(root: &std::path::Path) -> SlideApp {
|
||||||
|
let mut app = SlideApp::new(deckcraft_engine::Session::new(), crate::Services::default());
|
||||||
|
app.updates_rt.updater = Updater::new(&crate::UPDATE_APP, VERSION)
|
||||||
|
.with_paths(craft_update::Paths::under(root))
|
||||||
|
.with_pins_path(root.join("pins.json"))
|
||||||
|
.with_source(std::sync::Arc::new(|| Ok(None)));
|
||||||
|
app.updates_rt.enabled = true;
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn records_dir(name: &str) -> std::path::PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("deckcraft-updates-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first start after an update reports it; OK dismisses the report.
|
||||||
|
#[test]
|
||||||
|
fn the_start_after_an_update_reports_it_until_dismissed() {
|
||||||
|
let root = records_dir("installed");
|
||||||
|
std::fs::create_dir_all(root.join("data")).unwrap();
|
||||||
|
std::fs::write(root.join("data").join("self-update-done.txt"), VERSION).unwrap();
|
||||||
|
let mut app = app_with_records(&root);
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||||
|
tick(&mut app, ui.ctx());
|
||||||
|
show(&mut app, ui.ctx());
|
||||||
|
})
|
||||||
|
.textures_delta
|
||||||
|
.clear();
|
||||||
|
assert!(matches!(app.updates_rt.updater.status(), Status::Updated(_)), "{:?}", app.updates_rt.updater.status());
|
||||||
|
answer(&mut app.updates_rt, CheckResultResponse::Close);
|
||||||
|
assert_eq!(app.updates_rt.updater.status(), &Status::Idle);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first start after a failed update says why; Check Again asks for a new check.
|
||||||
|
#[test]
|
||||||
|
fn the_start_after_a_failed_update_reports_why_and_offers_a_new_check() {
|
||||||
|
let root = records_dir("failed");
|
||||||
|
std::fs::create_dir_all(root.join("data").join("logs")).unwrap();
|
||||||
|
std::fs::write(root.join("data").join("logs").join("self-update-error.txt"), "the disk is full").unwrap();
|
||||||
|
let mut app = app_with_records(&root);
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||||
|
tick(&mut app, ui.ctx());
|
||||||
|
show(&mut app, ui.ctx());
|
||||||
|
})
|
||||||
|
.textures_delta
|
||||||
|
.clear();
|
||||||
|
assert_eq!(app.updates_rt.updater.status(), &Status::InstallFailed("the disk is full".into()));
|
||||||
|
answer(&mut app.updates_rt, CheckResultResponse::CheckAgain);
|
||||||
|
assert!(app.updates_rt.check_requested);
|
||||||
|
let _ = std::fs::remove_dir_all(&root);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) {
|
|||||||
deckcraft_model::AnimStart::WithPrevious => "With Previous",
|
deckcraft_model::AnimStart::WithPrevious => "With Previous",
|
||||||
deckcraft_model::AnimStart::AfterPrevious => "After Previous",
|
deckcraft_model::AnimStart::AfterPrevious => "After Previous",
|
||||||
});
|
});
|
||||||
dropdown(ui, "astart", s.unwrap_or(""), 112.0, cur.is_some(), |ui| {
|
dropdown(ui, "Start", s.unwrap_or(""), 112.0, cur.is_some(), |ui| {
|
||||||
for (l, v) in [("On Click", "onClick"), ("With Previous", "withPrevious"), ("After Previous", "afterPrevious")] {
|
for (l, v) in [("On Click", "onClick"), ("With Previous", "withPrevious"), ("After Previous", "afterPrevious")] {
|
||||||
if ui.button(l).clicked() {
|
if ui.button(l).clicked() {
|
||||||
run(app, "animation.timing", json!({"start": v}));
|
run(app, "animation.timing", json!({"start": v}));
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
|||||||
let size = st.get("size").and_then(Value::as_f64);
|
let size = st.get("size").and_then(Value::as_f64);
|
||||||
let fams = font_families(app);
|
let fams = font_families(app);
|
||||||
let theme_fonts: Vec<String> = fams.iter().take(2).cloned().collect();
|
let theme_fonts: Vec<String> = fams.iter().take(2).cloned().collect();
|
||||||
dropdown(ui, "font", &font, 150.0, en, |ui| {
|
dropdown(ui, "Font", &font, 150.0, en, |ui| {
|
||||||
for (k, f) in fams.iter().enumerate() {
|
for (k, f) in fams.iter().enumerate() {
|
||||||
let label = if k == 0 && theme_fonts.len() == 2 {
|
let label = if k == 0 && theme_fonts.len() == 2 {
|
||||||
format!("{f} (Headings)")
|
format!("{f} (Headings)")
|
||||||
@@ -196,7 +196,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default();
|
let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default();
|
||||||
dropdown(ui, "size", &size_text, 52.0, en, |ui| {
|
dropdown(ui, "Font size", &size_text, 52.0, en, |ui| {
|
||||||
for s in deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) {
|
for s in deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) {
|
||||||
if ui.button(widgets::fmt_num(*s, "")).clicked() {
|
if ui.button(widgets::fmt_num(*s, "")).clicked() {
|
||||||
run(app, "format.size", json!({"size": s}));
|
run(app, "format.size", json!({"size": s}));
|
||||||
|
|||||||
@@ -1223,6 +1223,56 @@ fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> {
|
|||||||
h
|
h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// #1855: text pasted into the focused speaker-notes field lands at its caret. While a stock
|
||||||
|
/// field has the focus, `SlideApp::keyboard` leaves the batch to it and nothing in the app drops
|
||||||
|
/// `Paste`, whether it arrives alone or inside the ⌘V batch egui-winit sends (V down, Paste, V up).
|
||||||
|
#[test]
|
||||||
|
fn paste_reaches_the_focused_notes_field() {
|
||||||
|
let mut h = step_app(Services::default());
|
||||||
|
let id = egui::Id::unique("notes_edit");
|
||||||
|
h.ctx.memory_mut(|m| m.request_focus(id));
|
||||||
|
h.run_steps(2);
|
||||||
|
assert!(h.ctx.memory(|m| m.has_focus(id)), "the notes field has the focus");
|
||||||
|
h.event(egui::Event::Text("ab".into()));
|
||||||
|
h.run_steps(2);
|
||||||
|
h.event(egui::Event::Paste("PASTED".into()));
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(h.state().notes_buf.2.text, "abPASTED", "a bare Paste event");
|
||||||
|
let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND };
|
||||||
|
h.input_mut().events.extend([v(true), egui::Event::Paste("TWO".into()), v(false)]);
|
||||||
|
h.run_steps(2);
|
||||||
|
assert_eq!(h.state().notes_buf.2.text, "abPASTEDTWO", "the ⌘V batch");
|
||||||
|
// Leaving the field commits the notes to the slide.
|
||||||
|
h.ctx.memory_mut(|m| m.surrender_focus(id));
|
||||||
|
h.run_steps(3);
|
||||||
|
assert_eq!(h.state().session.active().unwrap().doc.slides[0].notes_text(), "abPASTEDTWO");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #1956: the ribbon's drop-downs are announced by their purpose, not by their value or an
|
||||||
|
/// internal key. Animations › Start with a shape that has no animation was "astart"; Home's Font
|
||||||
|
/// and Font size boxes were "font" and "size" while nothing told them a font or size.
|
||||||
|
#[test]
|
||||||
|
fn ribbon_dropdowns_are_named_by_purpose_even_when_empty() {
|
||||||
|
use egui::accesskit::Role;
|
||||||
|
let mut h = step_app(Services::default());
|
||||||
|
// A fresh shape, selected but not edited and without an animation.
|
||||||
|
h.state_mut().run("shape.insert", json!({"preset": "rect", "rect": [100.0, 100.0, 200.0, 100.0]})).unwrap();
|
||||||
|
h.state_mut().run("text.exit", json!({})).unwrap();
|
||||||
|
h.state_mut().ui.tab = "Animations".into();
|
||||||
|
h.run_steps(2);
|
||||||
|
let start = h.get_by_role_and_label(Role::ComboBox, "Start");
|
||||||
|
assert_eq!(start.value(), None, "no animation, no start value");
|
||||||
|
assert!(h.query_by_label("astart").is_none(), "the internal key is not a name");
|
||||||
|
|
||||||
|
h.state_mut().ui.tab = "Home".into();
|
||||||
|
h.state_mut().run("edit.select", json!({"ids": []})).unwrap();
|
||||||
|
h.run_steps(2);
|
||||||
|
for (name, key) in [("Font", "font"), ("Font size", "size")] {
|
||||||
|
assert!(h.query_by_role_and_label(Role::ComboBox, name).is_some(), "{name} is named while empty");
|
||||||
|
assert!(h.query_by_role_and_label(Role::ComboBox, key).is_none(), "{key} is not announced");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold.
|
/// Copy of a bold selection offers HTML; paste of Pages-like HTML keeps the bold.
|
||||||
#[test]
|
#[test]
|
||||||
fn copy_offers_html_and_paste_keeps_bold() {
|
fn copy_offers_html_and_paste_keeps_bold() {
|
||||||
|
|||||||
@@ -330,7 +330,9 @@ pub fn rows<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A text dropdown field (font name, size): shows `current`, opens a popup with `add_items`.
|
/// A text dropdown field (font name, size): shows `current`, opens a popup with `add_items`.
|
||||||
pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui)) -> Response {
|
/// `label` is what assistive technology calls it ("Font", "Start"); `current` is exposed as its
|
||||||
|
/// value, so an empty box is still announced by its purpose and never by an internal key (#1956).
|
||||||
|
pub fn dropdown(ui: &mut Ui, label: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui)) -> Response {
|
||||||
let t = Tokens::get(ui.ctx());
|
let t = Tokens::get(ui.ctx());
|
||||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 22.0), if enabled { Sense::click() } else { Sense::hover() });
|
let (rect, resp) = ui.allocate_exact_size(vec2(width, 22.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||||
let painter = ui.painter_at(rect);
|
let painter = ui.painter_at(rect);
|
||||||
@@ -350,8 +352,10 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool,
|
|||||||
if enabled { t.text } else { t.text_faint },
|
if enabled { t.text } else { t.text_faint },
|
||||||
);
|
);
|
||||||
chevron(&painter, pos2(rect.max.x - 9.0, rect.center().y), 3.0, t.text_dim);
|
chevron(&painter, pos2(rect.max.x - 9.0, rect.center().y), 3.0, t.text_dim);
|
||||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, if current.is_empty() { id } else { current }));
|
resp.widget_info(|| egui::WidgetInfo {
|
||||||
let _ = id;
|
current_text_value: (!current.is_empty()).then(|| current.to_string()),
|
||||||
|
..egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, label)
|
||||||
|
});
|
||||||
egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| {
|
egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| {
|
||||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items);
|
egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items);
|
||||||
});
|
});
|
||||||
@@ -742,19 +746,26 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The custom ribbon dropdown is a real combo box in the accessibility tree, named by its
|
/// The custom ribbon dropdown is a real combo box in the accessibility tree, named by its
|
||||||
/// current value (0.37's strict unnamed-input check would otherwise reject it).
|
/// purpose with the shown text as its value. An empty one (no selection to read a font or
|
||||||
|
/// size from) keeps its name rather than falling back to an internal key (#1956).
|
||||||
#[test]
|
#[test]
|
||||||
fn the_dropdown_has_a_combo_box_role_and_a_name() {
|
fn the_dropdown_is_named_by_its_purpose_and_carries_its_value() {
|
||||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| {
|
let mut harness = egui_kittest::Harness::builder().with_size(vec2(400.0, 200.0)).build_ui(|ui| {
|
||||||
theme::install_fonts(ui.ctx());
|
theme::install_fonts(ui.ctx());
|
||||||
dropdown(ui, "font-size", "18 pt", 120.0, true, |ui| {
|
dropdown(ui, "Font size", "18 pt", 120.0, true, |ui| {
|
||||||
ui.label("12 pt");
|
ui.label("12 pt");
|
||||||
ui.label("18 pt");
|
ui.label("18 pt");
|
||||||
});
|
});
|
||||||
|
dropdown(ui, "Start", "", 112.0, false, |_| {});
|
||||||
});
|
});
|
||||||
harness.run();
|
harness.run();
|
||||||
let combo = harness.get_by_role_and_label(Role::ComboBox, "18 pt");
|
let combo = harness.get_by_role_and_label(Role::ComboBox, "Font size");
|
||||||
|
assert_eq!(combo.value().as_deref(), Some("18 pt"));
|
||||||
assert!(combo.rect().width() > 100.0);
|
assert!(combo.rect().width() > 100.0);
|
||||||
|
assert!(harness.query_by_role_and_label(Role::ComboBox, "18 pt").is_none(), "the value is not its name");
|
||||||
|
let empty = harness.get_by_role_and_label(Role::ComboBox, "Start");
|
||||||
|
assert_eq!(empty.value(), None, "nothing shown, no value");
|
||||||
|
assert!(harness.query_by_label("astart").is_none() && harness.query_by_label("start").is_none(), "no internal key is announced");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// #32: clicking the "More Colors" hex field closed the colour menu before anything could be
|
/// #32: clicking the "More Colors" hex field closed the colour menu before anything could be
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ the round trip even though DeckCraft doesn't model it.
|
|||||||
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
|
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
|
||||||
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Run `lang` and `altLang` are independently retained through native and PPTX saves; other round-trip tests cover `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) |
|
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Run `lang` and `altLang` are independently retained through native and PPTX saves; other round-trip tests cover `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return". Gradient and outline on a run, autofit, columns, vertical and RTL text, tab stops and line spacing round-trip (`corpus_style.rs`) |
|
||||||
| Effects: shadow, glow, soft edges, reflection | yes | yes | On a run, inner shadow, reflection and soft edges are kept along with the outer shadow and glow |
|
| Effects: shadow, glow, soft edges, reflection | yes | yes | On a run, inner shadow, reflection and soft edges are kept along with the outer shadow and glow |
|
||||||
| 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. Bevels, cameras, face lighting and extrusion sides draw approximately |
|
| 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. `scene3d`, `sp3d` and `flatTx` on a text body (`a:bodyPr`) and `scene3d` on a group (`p:grpSpPr`) are kept as raw XML too. Bevels, cameras, face lighting and extrusion sides draw approximately |
|
||||||
| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
|
| Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip |
|
||||||
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
|
| Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | |
|
||||||
| Tables with styles | yes | yes | Merges, both diagonal borders and optional RTL column direction round-trip; Reuse Slides transfers definitions referenced by source tables, including grouped tables, with conflicting ids remapped |
|
| Tables with styles | yes | yes | Merges, both diagonal borders and optional RTL column direction round-trip; Reuse Slides transfers definitions referenced by source tables, including grouped tables, with conflicting ids remapped |
|
||||||
|
|||||||
@@ -136,6 +136,18 @@ beside it. The page guards leaving itself (DeckCraft adds no guard of its own),
|
|||||||
|
|
||||||
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `deckcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. DeckCraft reads bytes, so a host sends `bytes`, not a `File`.
|
The messages and both transports are the shared contract of [`craft-web-host`](https://github.com/storytold/craft-libs/tree/main/crates/web-host) (storytold/craft-libs#420), the same for every Craft app: its README lists every message, including `saved`, with the compatibility notes. In `deckcraft:ready`, `version` is the contract's (`"1"`) and `appVersion` is this app's release. DeckCraft reads bytes, so a host sends `bytes`, not a `File`.
|
||||||
|
|
||||||
|
### Driving it from the page (`window.deckcraft`)
|
||||||
|
|
||||||
|
Every DeckCraft web build puts `window.deckcraft` on the page, from [`craft-web-api`](https://github.com/storytold/craft-libs/tree/main/crates/web-api) (storytold/craft-libs#413), the same shape in every Craft app. `deckcraft.request(method, params)` calls any method of the control channel the desktop app serves (`docs/control-protocol.md`), and `execute(command, params)`, `commands()`, `docInspect(params)`, `uiInspect(params)` and `screenshot(params)` are shortcuts for the common ones. Each returns a Promise that resolves with the method's result and rejects with an `Error`; a call the app does not answer within 30 seconds rejects.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const deck = await deckcraft.docInspect(); // a summary of the open presentation
|
||||||
|
await deckcraft.execute("shape.insert", { preset: "roundRect", rect: [100, 100, 240, 120], text: "Hello" });
|
||||||
|
const { data } = await deckcraft.screenshot(); // the canvas as PNG (base64)
|
||||||
|
```
|
||||||
|
|
||||||
|
The API is there in every mode, with or without a host. Opening a file, handing a saved or exported file to the page, the file name and the unsaved flag stay with the host messages above.
|
||||||
|
|
||||||
## Renderer selection and fallback flags
|
## Renderer selection and fallback flags
|
||||||
|
|
||||||
DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ use std::process::Command;
|
|||||||
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
const SHARED_REV: &str = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7";
|
const SHARED_REPO: &str = "https://github.com/storytold/craft-libs";
|
||||||
|
const SHARED_REV: &str = "1b34eba41e62138fc2438d2734523b302840b45d";
|
||||||
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
|
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
|
||||||
|
|
||||||
pub fn run(root: &Path) -> Result<(), String> {
|
pub fn run(root: &Path) -> Result<(), String> {
|
||||||
@@ -70,7 +71,7 @@ fn validate(metadata: &Value) -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
matches.first().copied().ok_or_else(|| format!("missing {name}"))
|
matches.first().copied().ok_or_else(|| format!("missing {name}"))
|
||||||
};
|
};
|
||||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
|
let source = format!("git+{SHARED_REPO}?rev={SHARED_REV}#{SHARED_REV}");
|
||||||
for (name, version, expected_source, expected_features) in [
|
for (name, version, expected_source, expected_features) in [
|
||||||
("craft-pdf", "0.1.0", source.as_str(), vec!["write", "write-simple-text"]),
|
("craft-pdf", "0.1.0", source.as_str(), vec!["write", "write-simple-text"]),
|
||||||
("krilla", "0.8.2", source.as_str(), vec!["raster-images", "rayon", "simple-text"]),
|
("krilla", "0.8.2", source.as_str(), vec!["raster-images", "rayon", "simple-text"]),
|
||||||
@@ -143,7 +144,7 @@ mod tests {
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
fn metadata() -> Value {
|
fn metadata() -> Value {
|
||||||
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
|
let source = format!("git+{SHARED_REPO}?rev={SHARED_REV}#{SHARED_REV}");
|
||||||
json!({
|
json!({
|
||||||
"packages": [
|
"packages": [
|
||||||
{"id":"app", "name":"deckcraft-pdf"},
|
{"id":"app", "name":"deckcraft-pdf"},
|
||||||
|
|||||||
Reference in New Issue
Block a user