mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-12 00:47:28 -06:00
Compare commits
36
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ba9e712fd | ||
|
|
a55457ee53 | ||
|
|
7bd141c1c9 | ||
|
|
e3e218a8fc | ||
|
|
c532ae749b | ||
|
|
e0f3639e94 | ||
|
|
eb10e2b9b3 | ||
|
|
92a0437ab6 | ||
|
|
4e87f2b850 | ||
|
|
54846750d4 | ||
|
|
1e9c392f67 | ||
|
|
c3fb184e9d | ||
|
|
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]]
|
||||
name = "craft-fonts-build"
|
||||
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]]
|
||||
name = "craft-i18n"
|
||||
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 = [
|
||||
"fixed_decimal",
|
||||
"icu_decimal",
|
||||
@@ -875,7 +875,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-media"
|
||||
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 = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -883,7 +883,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-pdf"
|
||||
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 = [
|
||||
"krilla",
|
||||
"thiserror 2.0.21",
|
||||
@@ -892,7 +892,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-ui"
|
||||
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 = [
|
||||
"craft-fonts-build",
|
||||
"craft-i18n",
|
||||
@@ -912,7 +912,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-update"
|
||||
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 = [
|
||||
"chrono",
|
||||
"dunce",
|
||||
@@ -935,10 +935,21 @@ dependencies = [
|
||||
"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]]
|
||||
name = "craft-web-host"
|
||||
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 = [
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -1017,7 +1028,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1039,7 +1050,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-anim"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-geom",
|
||||
"deckcraft-model",
|
||||
@@ -1048,7 +1059,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-av1"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1057,14 +1068,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-bitstream"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-cli"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-mcp",
|
||||
@@ -1074,14 +1085,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-color"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-engine"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-anim",
|
||||
@@ -1107,7 +1118,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-fonts"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"epaint_default_fonts",
|
||||
"harfrust 0.13.3",
|
||||
@@ -1119,7 +1130,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-format"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-model",
|
||||
"serde",
|
||||
@@ -1130,7 +1141,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-geom"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1138,7 +1149,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-h264"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1147,7 +1158,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-hevc"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1156,21 +1167,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-isobmff"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-matroska"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-media",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-mcp"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-engine",
|
||||
"deckcraft-render",
|
||||
@@ -1180,7 +1191,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-media"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-av1",
|
||||
"deckcraft-h264",
|
||||
@@ -1198,7 +1209,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-metafile"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"kurbo",
|
||||
@@ -1207,7 +1218,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-model"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-geom",
|
||||
@@ -1219,15 +1230,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ogg"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-opus"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pdf"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-pdf",
|
||||
"deckcraft-fonts",
|
||||
@@ -1245,7 +1256,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-pptx"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-color",
|
||||
"deckcraft-engine",
|
||||
@@ -1261,7 +1272,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-render"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-anim",
|
||||
"deckcraft-color",
|
||||
@@ -1280,7 +1291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-text"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"deckcraft-color",
|
||||
@@ -1294,7 +1305,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-ui-egui"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-ui",
|
||||
"craft-update",
|
||||
@@ -1320,7 +1331,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-vp9"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"deckcraft-bitstream",
|
||||
"rayon",
|
||||
@@ -1329,8 +1340,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "deckcraft-web"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"craft-web-api",
|
||||
"craft-web-host",
|
||||
"deckcraft-engine",
|
||||
"deckcraft-ui-egui",
|
||||
@@ -2779,7 +2791,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "krilla"
|
||||
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 = [
|
||||
"base64 0.22.1",
|
||||
"bumpalo",
|
||||
@@ -6313,7 +6325,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
@@ -6559,7 +6571,6 @@ dependencies = [
|
||||
"nix",
|
||||
"typed-path",
|
||||
"windows-sys 0.61.2",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+6
-6
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.15.0"
|
||||
version = "0.18.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -59,11 +59,11 @@ harfrust = "0.13"
|
||||
vello_cpu = "0.2"
|
||||
vello_common = { version = "0.2", default-features = false }
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] }
|
||||
craft-media = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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-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 = "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 = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", features = ["egui"] }
|
||||
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", version = "=0.1.0" }
|
||||
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 = "1b34eba41e62138fc2438d2734523b302840b45d", version = "=0.1.0" }
|
||||
i_overlay = "9"
|
||||
quick-xml = "0.41"
|
||||
zip = { version = "3", default-features = false, features = ["deflate"] }
|
||||
@@ -109,7 +109,7 @@ codegen-units = 16
|
||||
debug = 0
|
||||
|
||||
[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" }
|
||||
emath = { 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]
|
||||
deckcraft-engine = { 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.
|
||||
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 }
|
||||
log = { 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)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
mod api;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod host;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -79,6 +79,7 @@ pub fn start() {
|
||||
{
|
||||
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 }))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -148,7 +148,19 @@ pub fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> {
|
||||
/// One formatted log line: `<timestamp> <LEVEL> [<thread>] <target>: <message>\n`.
|
||||
pub fn format_line(ts: &str, level: log::Level, thread: &str, target: &str, message: &str) -> String {
|
||||
let message = message.strip_suffix('\n').unwrap_or(message);
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
|
||||
// Messages can embed document-controlled text (a part name, a relationship id): control
|
||||
// characters other than tab are escaped and each continuation line is marked, so such text
|
||||
// cannot start a line that looks like a record of its own.
|
||||
let mut text = String::with_capacity(message.len());
|
||||
for c in message.chars() {
|
||||
match c {
|
||||
'\n' => text.push_str("\n | "),
|
||||
'\t' => text.push(c),
|
||||
c if c.is_control() => text.extend(c.escape_default()),
|
||||
c => text.push(c),
|
||||
}
|
||||
}
|
||||
format!("{ts} {level:<5} [{thread}] {target}: {text}\n")
|
||||
}
|
||||
|
||||
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
|
||||
@@ -446,8 +458,13 @@ mod tests {
|
||||
),
|
||||
"2026-10-08T07:59:17.728Z WARN [main] deckcraft_pptx::opc: pptx: skipping oversized part ppt/media/image1.png\n"
|
||||
);
|
||||
// A multi-line message keeps its lines; the record still ends in exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\nb\n"));
|
||||
// A multi-line message keeps its lines, each continuation marked; the record still ends in
|
||||
// exactly one newline.
|
||||
assert!(format_line("t", log::Level::Error, "w", "x", "a\nb\n").ends_with("x: a\n | b\n"));
|
||||
// Injected line breaks and escape sequences cannot forge a record.
|
||||
let line = format_line("t", log::Level::Warn, "w", "x", "part a\r\n2026 ERROR [m] y: forged\x1b[2J");
|
||||
assert_eq!(line.matches('\n').count(), 2);
|
||||
assert!(!line.contains('\r') && !line.contains('\x1b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -51,6 +51,9 @@ impl eframe::App for App {
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
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 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
|
||||
|
||||
@@ -466,22 +466,26 @@ fn ctx_xfrm(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shape:
|
||||
|
||||
/// Run `f` on each target shape (copy-on-write), materialising inherited boxes first.
|
||||
pub(crate) fn edit_shapes(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut deckcraft_model::Shape) -> Result<()>) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let mut ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "no shapes selected"));
|
||||
}
|
||||
// A shape named twice is edited once.
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
ids.retain(|id| seen.insert(*id));
|
||||
s.edit(|doc, sel| {
|
||||
let boxes: Vec<(ShapeId, Xfrm)> = {
|
||||
let st_shapes: Vec<deckcraft_model::Shape> =
|
||||
ids.iter().filter_map(|id| current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, *id)).cloned()).collect();
|
||||
st_shapes.iter().map(|sh| (sh.id, xfrm_of(doc, sel, sh))).collect()
|
||||
};
|
||||
// The boxes are read from the shapes in place: no copy of a shape (and its text) per id.
|
||||
let boxes: std::collections::HashMap<ShapeId, Xfrm> = ids
|
||||
.iter()
|
||||
.filter_map(|id| current_shapes(doc, sel).and_then(|v| deckcraft_model::find_shape(v, *id)))
|
||||
.map(|sh| (sh.id, xfrm_of(doc, sel, sh)))
|
||||
.collect();
|
||||
let shapes = crate::shapes_mut(doc, sel).ok_or_else(|| bad(cmd, "no slide"))?;
|
||||
let mut n = 0;
|
||||
for id in &ids {
|
||||
if let Some(sh) = deckcraft_model::find_shape_mut(shapes, *id) {
|
||||
if sh.xfrm.is_none() {
|
||||
sh.xfrm = boxes.iter().find(|(i, _)| i == id).map(|(_, x)| *x);
|
||||
sh.xfrm = boxes.get(id).copied();
|
||||
}
|
||||
f(sh)?;
|
||||
n += 1;
|
||||
|
||||
@@ -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> {
|
||||
let a = str_param(p, "align").map(|a| match a {
|
||||
"center" => Align::Center,
|
||||
"right" => Align::Right,
|
||||
"justify" => Align::Justify,
|
||||
"distributed" => Align::Distributed,
|
||||
_ => Align::Left,
|
||||
});
|
||||
let a = str_param(p, "align").map(|a| parse_align("format.paragraph", a)).transpose()?;
|
||||
let ml = f64_param(p, "indentLeft");
|
||||
let ind = f64_param(p, "indentFirst");
|
||||
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> {
|
||||
Ok(match s {
|
||||
"left" | "l" => Align::Left,
|
||||
"center" | "ctr" => Align::Center,
|
||||
"center" | "centre" | "ctr" => Align::Center,
|
||||
"right" | "r" => Align::Right,
|
||||
"justify" | "just" => Align::Justify,
|
||||
"justLow" => Align::JustLow,
|
||||
"justLow" | "justifyLow" => Align::JustLow,
|
||||
"distributed" | "dist" => Align::Distributed,
|
||||
"thaiDist" => Align::ThaiDistributed,
|
||||
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
|
||||
|
||||
@@ -135,6 +135,14 @@ fn eye_show_all_and_hide_all() {
|
||||
assert!(!flags(&s, a).0 && flags(&s, g).0 && flags(&s, e).0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_group_with_its_child_targeted_leaves_child_locked() {
|
||||
let (mut s, ids) = deck();
|
||||
let (d, g) = (ids[3], ids[7]);
|
||||
s.execute("shape.lock", &json!({"ids": [g, d, d]})).unwrap();
|
||||
assert!(flags(&s, g).1 && flags(&s, d).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_cascades_through_groups_and_undoes() {
|
||||
let (mut s, ids) = deck();
|
||||
|
||||
@@ -10,7 +10,7 @@ use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
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};
|
||||
|
||||
@@ -374,6 +374,25 @@ fn visible(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
/// (a child can still be unlocked on its own afterwards), as PowerPoint does.
|
||||
fn lock(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let v = bool_param(p, "locked");
|
||||
// A targeted shape inside another targeted group is reached through the group: editing it again
|
||||
// would toggle it back.
|
||||
let mut ids = super::targets(s, p)?;
|
||||
if ids.len() > 1 {
|
||||
let doc = s.doc()?;
|
||||
// Walk each distinct group once, however often `ids` repeats it.
|
||||
let mut once = std::collections::HashSet::new();
|
||||
ids.retain(|id| once.insert(*id));
|
||||
let mut inside = std::collections::HashSet::new();
|
||||
for id in &ids {
|
||||
if let Some(g) = doc.shape(*id) {
|
||||
deckcraft_model::walk(g.children(), &mut |c, _| {
|
||||
inside.insert(c.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
ids.retain(|id| !inside.contains(id));
|
||||
}
|
||||
let p = &json!({"ids": ids, "locked": p.get("locked")});
|
||||
edit_shapes(s, p, "shape.lock", |sh| {
|
||||
let to = v.unwrap_or(!sh.locked);
|
||||
set_locked(sh, to, 0);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::{
|
||||
ColorRef, ColorTransform, Dash, Effects, Fill, Geom, Glow, Gradient, GradientShape, GradientStop, Line, LineEnd, PictureFill, PictureMode,
|
||||
Reflection, Result, Rgba, SchemeSlot, Session, Shadow, ShapeStyle, Value, apply_fill, bad, bool_or, color_param, color_value, edit_geometry,
|
||||
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param,
|
||||
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> {
|
||||
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`"))?;
|
||||
edit_geometry(s, p, "shape.adjust", |sh| {
|
||||
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> {
|
||||
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);
|
||||
edit_shapes(s, p, "shape.quickStyle", |sh| {
|
||||
sh.style = Some(st.clone());
|
||||
|
||||
@@ -104,7 +104,7 @@ pub(super) fn freeform(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
a.iter()
|
||||
.filter_map(|v| Some(deckcraft_geom::Point::new(v.get(0)?.as_f64()?, v.get(1)?.as_f64()?)))
|
||||
.filter(|q| q.x.is_finite() && q.y.is_finite())
|
||||
.take(20_000)
|
||||
.take(crate::tools::MAX_GESTURE_POINTS)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Fonts a presentation uses that this machine doesn't have, so opening it can say so (its text
|
||||
//! draws in a stand-in; see `FontDb::document_face`).
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use deckcraft_fonts::FontDb;
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{Bullet, TextBody};
|
||||
@@ -12,8 +14,13 @@ use deckcraft_model::{Presentation, Shape, ShapeKind};
|
||||
/// font to load and are left for the round trip.
|
||||
pub fn install_embedded_fonts(p: &Presentation) {
|
||||
let db = FontDb::global();
|
||||
// Faces can name one media item many times; each is copied and enumerated once.
|
||||
let mut seen = HashSet::new();
|
||||
for font in &p.embedded_fonts {
|
||||
for id in font.faces().into_iter().flatten() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
let Some(item) = p.media(id) else { continue };
|
||||
let Some(sfnt) = deckcraft_fonts::embedded_sfnt(&item.data) else { continue };
|
||||
db.add_font(sfnt.to_vec());
|
||||
@@ -27,12 +34,8 @@ pub fn install_embedded_fonts(p: &Presentation) {
|
||||
/// and can't be installed, so they aren't reported.
|
||||
pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
let db = FontDb::global();
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for family in used_fonts(p) {
|
||||
if !family.starts_with('.') && !db.has_family(&family) && !out.iter().any(|f| f.eq_ignore_ascii_case(&family)) {
|
||||
out.push(family);
|
||||
}
|
||||
}
|
||||
// used_fonts is already free of case-insensitive duplicates.
|
||||
let mut out: Vec<String> = used_fonts(p).into_iter().filter(|f| !f.starts_with('.') && !db.has_family(f)).collect();
|
||||
out.sort_by_key(|f| f.to_lowercase());
|
||||
out
|
||||
}
|
||||
@@ -40,9 +43,10 @@ pub fn missing_fonts(p: &Presentation) -> Vec<String> {
|
||||
/// The families the slides' text runs and bullets are drawn in.
|
||||
pub fn used_fonts(p: &Presentation) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut add = |f: String| {
|
||||
let f = f.trim().to_string();
|
||||
if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
|
||||
if !f.is_empty() && seen.insert(f.to_ascii_lowercase()) {
|
||||
out.push(f);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -824,10 +824,32 @@ fn clamp_selection(st: &mut DocState) {
|
||||
/// Default for [`History::byte_limit`].
|
||||
pub const UNDO_BYTE_BUDGET: usize = 256 << 20;
|
||||
|
||||
/// A rough size of a text body in memory: its paragraphs and runs, at their inline struct sizes.
|
||||
fn text_cost(t: &deckcraft_model::text::TextBody) -> usize {
|
||||
use deckcraft_model::text::{Paragraph, Run};
|
||||
let (para, run) = (std::mem::size_of::<Paragraph>(), std::mem::size_of::<Run>());
|
||||
t.paragraphs.iter().map(|p| para + p.runs.iter().map(|r| run + r.text.len()).sum::<usize>()).sum()
|
||||
}
|
||||
|
||||
/// A rough size of comments in memory, replies included.
|
||||
fn comments_cost(cs: &[deckcraft_model::Comment], depth: usize) -> usize {
|
||||
let kids = |c: &deckcraft_model::Comment| if depth < 64 { comments_cost(&c.replies, depth + 1) } else { 0 };
|
||||
cs.iter().map(|c| 256 + c.author.len() + c.initials.len() + c.text.len() + c.date.len() + kids(c)).sum()
|
||||
}
|
||||
|
||||
/// A rough size of a slide's own data besides its shapes: notes, comments, tags and kept XML,
|
||||
/// all copied with the slide when an edit changes it.
|
||||
fn slide_extra_cost(s: &deckcraft_model::Slide) -> usize {
|
||||
text_cost(&s.notes)
|
||||
+ comments_cost(&s.comments, 0)
|
||||
+ s.name.len()
|
||||
+ s.tags.iter().map(|(k, v)| 48 + k.len() + v.len()).sum::<usize>()
|
||||
+ s.raw_ext.as_ref().map_or(0, String::len)
|
||||
}
|
||||
|
||||
/// A rough size of a shape in memory: the struct, its text and kept XML, its children.
|
||||
fn shape_cost(sh: &deckcraft_model::Shape, depth: usize) -> usize {
|
||||
let text: usize =
|
||||
sh.text.as_ref().map_or(0, |t| t.paragraphs.iter().map(|p| 96 + p.runs.iter().map(|r| 128 + r.text.len()).sum::<usize>()).sum());
|
||||
let text: usize = sh.text.as_ref().map_or(0, text_cost);
|
||||
let kids: usize = if depth < 64 { sh.children().iter().map(|c| shape_cost(c, depth + 1)).sum() } else { 0 };
|
||||
768 + text + sh.raw_ext.as_ref().map_or(0, String::len) + kids
|
||||
}
|
||||
@@ -845,7 +867,7 @@ pub fn undo_cost(doc: &Presentation, newer: &Presentation) -> usize {
|
||||
let media: HashSet<*const Vec<u8>> = newer.media.iter().map(|m| Arc::as_ptr(&m.data)).collect();
|
||||
let mut n = 64 * doc.slides.len();
|
||||
for s in doc.slides.iter().filter(|s| !slides.contains(&Arc::as_ptr(s))) {
|
||||
n += 512 + shapes_cost(&s.shapes);
|
||||
n += 512 + shapes_cost(&s.shapes) + slide_extra_cost(s);
|
||||
}
|
||||
for m in doc.masters.iter().filter(|m| !masters.contains(&Arc::as_ptr(m))) {
|
||||
n += 4096 + shapes_cost(&m.shapes) + m.layouts.iter().map(|l| 512 + shapes_cost(&l.shapes)).sum::<usize>();
|
||||
|
||||
@@ -171,7 +171,9 @@ impl Dictionary {
|
||||
["COMPOUNDWORDMAX", n, ..] => d.compound_max = n.parse().unwrap_or(0),
|
||||
["REP", n] => {
|
||||
for _ in 0..n.parse::<usize>().unwrap_or(0) {
|
||||
let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
|
||||
// The count is the file's claim: stop when the records run out.
|
||||
let Some(line) = lines.next() else { break };
|
||||
let r: Vec<&str> = line.split_whitespace().collect();
|
||||
if let [_, a, b, ..] = r.as_slice() {
|
||||
d.rep.push((a.replace('_', " "), b.replace('_', " ")));
|
||||
}
|
||||
@@ -182,7 +184,8 @@ impl Dictionary {
|
||||
let Some(flag) = one_flag(Some(flag), mode) else { continue };
|
||||
let cross = cross.eq_ignore_ascii_case("y");
|
||||
for _ in 0..n.parse::<usize>().unwrap_or(0) {
|
||||
let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
|
||||
let Some(line) = lines.next() else { break };
|
||||
let r: Vec<&str> = line.split_whitespace().collect();
|
||||
let [_, _, strip, add, rest @ ..] = r.as_slice() else { continue };
|
||||
let (add, cont) = match add.split_once('/') {
|
||||
Some((a, c)) => (a, parse_flags(c, mode)),
|
||||
@@ -304,7 +307,12 @@ impl Dictionary {
|
||||
|
||||
/// `w` made of dictionary parts allowed in compounds (`COMPOUNDFLAG`, or the begin, middle and
|
||||
/// end flags), each at least `COMPOUNDMIN` characters long.
|
||||
fn compounded(&self, w: &[char], index: usize) -> bool {
|
||||
fn compounded(&self, w: &[char], index: usize, failed: &mut HashSet<(usize, usize)>) -> bool {
|
||||
// `w` is always a tail of the checked word, so its length names it: a (tail, part index)
|
||||
// that failed once fails again however the earlier parts were split.
|
||||
if failed.contains(&(w.len(), index)) {
|
||||
return false;
|
||||
}
|
||||
if self.compound.is_none() && self.compound_end.is_none() {
|
||||
return false;
|
||||
}
|
||||
@@ -322,10 +330,11 @@ impl Dictionary {
|
||||
if [self.compound, self.compound_end].into_iter().flatten().any(|f| self.compound_part(rest, f, true)) {
|
||||
return true;
|
||||
}
|
||||
if self.compounded(rest, index + 1) {
|
||||
if self.compounded(rest, index + 1, failed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
failed.insert((w.len(), index));
|
||||
false
|
||||
}
|
||||
|
||||
@@ -352,7 +361,7 @@ impl Dictionary {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
self.compounded(&w, 0)
|
||||
self.compounded(&w, 0, &mut HashSet::new())
|
||||
}
|
||||
|
||||
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
|
||||
@@ -576,6 +585,15 @@ fn cache() -> &'static Cache {
|
||||
CACHE.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// Language strings remembered by [`dictionary`]; a string past it is resolved again on each call.
|
||||
const MAX_CACHED_LANGS: usize = 256;
|
||||
|
||||
/// Parsed dictionaries by their affix file, so aliases of one language share it.
|
||||
fn by_files() -> &'static Mutex<HashMap<PathBuf, Arc<Dictionary>>> {
|
||||
static M: OnceLock<Mutex<HashMap<PathBuf, Arc<Dictionary>>>> = OnceLock::new();
|
||||
M.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// The dictionary for `lang` (a BCP-47 tag such as `en-US`), loaded once per process.
|
||||
pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> {
|
||||
let cache = cache();
|
||||
@@ -586,10 +604,23 @@ pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> {
|
||||
return d.clone();
|
||||
}
|
||||
let loaded = find_in(&search_dirs(), lang).and_then(|(a, d)| {
|
||||
let (a, d) = (std::fs::read(a).ok()?, std::fs::read(d).ok()?);
|
||||
Some(Arc::new(Dictionary::parse(&a, &d)))
|
||||
// Tags that resolve to the same files share one parsed dictionary.
|
||||
if let Ok(m) = by_files().lock()
|
||||
&& let Some(dict) = m.get(&a)
|
||||
{
|
||||
return Some(dict.clone());
|
||||
}
|
||||
let (bytes_a, bytes_d) = (std::fs::read(&a).ok()?, std::fs::read(d).ok()?);
|
||||
let dict = Arc::new(Dictionary::parse(&bytes_a, &bytes_d));
|
||||
// A concurrent loader of the same files may have inserted first: share its copy.
|
||||
if let Ok(mut m) = by_files().lock() {
|
||||
return Some(m.entry(a).or_insert(dict).clone());
|
||||
}
|
||||
Some(dict)
|
||||
});
|
||||
if let Ok(mut c) = cache.lock() {
|
||||
if let Ok(mut c) = cache.lock()
|
||||
&& c.len() < MAX_CACHED_LANGS
|
||||
{
|
||||
c.insert(key, loaded.clone());
|
||||
}
|
||||
loaded
|
||||
@@ -897,6 +928,14 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
assert!(d.suggest("workingslides", 5).contains(&"working slides".to_string()));
|
||||
}
|
||||
|
||||
/// An affix header can claim billions of records; parsing stops where the file does.
|
||||
#[test]
|
||||
fn a_header_count_past_the_records_ends_with_them() {
|
||||
let aff = b"SFX S Y 4000000000\nSFX S 0 s .\nREP 4000000000\nREP a b\n";
|
||||
let d = Dictionary::parse(aff, b"1\ncat/S\n".as_slice());
|
||||
assert!(d.check("cats"));
|
||||
}
|
||||
|
||||
/// #1175: compound splitting is quadratic in the word's length, so a word longer than
|
||||
/// `MAX_CHECK_CHARS` is not checked; shorter ones still are.
|
||||
#[test]
|
||||
@@ -931,6 +970,17 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_compound_roots_with_a_failing_ending_stay_cheap() {
|
||||
// Roots a..aaaaaaaa overlap, so many splits of "aaa…ab" reach the same (tail, part) state; unmemoized that is millions of searches.
|
||||
let d = Dictionary::parse(
|
||||
b"COMPOUNDFLAG X\nCOMPOUNDMIN 1\n".as_slice(),
|
||||
b"8\na/X\naa/X\naaa/X\naaaa/X\naaaaa/X\naaaaaa/X\naaaaaaa/X\naaaaaaaa/X\n".as_slice(),
|
||||
);
|
||||
assert!(!d.check(&format!("{}b", "a".repeat(99))));
|
||||
assert!(d.check(&"a".repeat(20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_give_words_their_language() {
|
||||
use deckcraft_model::{Paragraph, Run, RunProps};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn right_to_left_paragraphs() {
|
||||
let mut s = session();
|
||||
@@ -3720,6 +3745,25 @@ fn undo_history_is_capped_by_memory_as_well_as_steps() {
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), 120);
|
||||
}
|
||||
|
||||
/// A slide's notes count toward the undo budget: small edits to a slide with large notes keep a
|
||||
/// copy of those notes per step, and must not slip past the budget as if the slide were empty.
|
||||
#[test]
|
||||
fn undo_history_counts_slide_notes() {
|
||||
let mut s = Session::with_new();
|
||||
let notes = "n".repeat(1 << 20);
|
||||
s.edit(|doc, _| {
|
||||
std::sync::Arc::make_mut(&mut doc.slides[0]).notes = deckcraft_model::text::TextBody::from_text(¬es);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
s.doc_mut().unwrap().history = History { limit: 500, byte_limit: 8 << 20, ..Default::default() };
|
||||
for i in 0..40 {
|
||||
s.execute("slide.rename", &json!({"name": format!("v{i}"), "index": 0})).unwrap();
|
||||
}
|
||||
let n = s.doc().unwrap().history.undo.len();
|
||||
assert!((1..=8).contains(&n), "{n} steps of 1 MB notes kept under an 8 MB budget");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_styles_replace_each_other_in_one_step() {
|
||||
const PNG: &[u8] = &[
|
||||
@@ -5408,6 +5452,38 @@ fn pen_pressure_is_kept_and_a_mouse_draws_at_full_width() {
|
||||
assert_eq!(got, vec![vec![0.2, 1.0, 0.4], vec![1.0, 1.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_ink_stroke_is_extended_in_place_and_capped() {
|
||||
let mut s = session();
|
||||
s.set_tool(tools::ToolKind::Ink { mode: "pen".into(), color: deckcraft_model::Rgba::rgb(20, 20, 20), width: 4.0 });
|
||||
let ev = |kind, x: f64| tools::PointerEvent { kind, x, y: 80.0, mods: Mods::default(), tol: 3.0, pressure: Some(0.5) };
|
||||
s.pointer(ev(PointerKind::Down, 0.0)).unwrap();
|
||||
let n = tools::MAX_GESTURE_POINTS + 5_000;
|
||||
for i in 1..n {
|
||||
s.pointer(ev(PointerKind::Drag, (i % 500) as f64)).unwrap();
|
||||
}
|
||||
assert_eq!(s.tool.ink_preview.len(), tools::MAX_GESTURE_POINTS);
|
||||
s.pointer(ev(PointerKind::Up, 0.0)).unwrap();
|
||||
let kept: usize = ink_pressures(&s).iter().map(Vec::len).sum();
|
||||
assert!((2..=tools::MAX_GESTURE_POINTS).contains(&kept), "{kept}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_clicking_a_capped_freeform_keeps_its_vertices() {
|
||||
let mut s = session();
|
||||
let ev = |kind, x: f64, y: f64| tools::PointerEvent { kind, x, y, mods: Default::default(), tol: 3.0, pressure: None };
|
||||
s.set_tool(tools::ToolKind::Shape { preset: "freeform".into() });
|
||||
s.pointer(ev(tools::PointerKind::Down, 100.0, 100.0)).unwrap();
|
||||
// Densely clicked vertices, each within the double-click tolerance of the last.
|
||||
for i in 0..tools::MAX_GESTURE_POINTS + 10 {
|
||||
s.pointer(ev(tools::PointerKind::Down, 300.0 + i as f64 * 0.001, 100.0)).unwrap();
|
||||
}
|
||||
s.pointer(ev(tools::PointerKind::DoubleClick, 300.0, 100.0)).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let deckcraft_model::Geom::Custom { paths } = &st.shapes().last().unwrap().geom else { panic!("no freeform") };
|
||||
assert_eq!(paths[0].d.matches(" L ").count(), tools::MAX_GESTURE_POINTS - 1);
|
||||
}
|
||||
|
||||
fn ink_pressures(s: &Session) -> Vec<Vec<f32>> {
|
||||
let st = s.doc().unwrap();
|
||||
let Some(sh) = st.shapes().iter().rev().find(|sh| matches!(sh.kind, deckcraft_model::ShapeKind::Ink { .. })) else {
|
||||
@@ -6845,6 +6921,39 @@ fn master_body_placeholder_format_keeps_per_level_formatting() {
|
||||
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]);
|
||||
}
|
||||
|
||||
/// A shape named many times in one edit is edited once and not copied per mention.
|
||||
#[test]
|
||||
fn repeated_target_ids_edit_the_shape_once() {
|
||||
let mut s = session();
|
||||
let r = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 300, 100], "text": "x".repeat(100_000)})).unwrap();
|
||||
let id = r["id"].as_u64().unwrap();
|
||||
let edited = s.execute("shape.fill", &json!({"ids": vec![id; 50_000], "color": "#ff0000"})).unwrap();
|
||||
assert_eq!(edited, json!(1));
|
||||
let v = s.execute("shape.inspect", &json!({"id": id})).unwrap();
|
||||
assert_eq!(v["shape"]["fill"]["color"]["base"]["rgb"], json!({"a": 255, "b": 0, "g": 0, "r": 255}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_distinct_fonts_are_listed_once_each_without_a_quadratic_scan() {
|
||||
// 30,000 runs in 15,000 families, each written twice in different case.
|
||||
let mut p = deckcraft_model::Presentation::default();
|
||||
let slide = std::sync::Arc::make_mut(&mut p.slides[0]);
|
||||
let mut body = deckcraft_model::text::TextBody::from_text("x");
|
||||
let run = body.paragraphs[0].runs[0].clone();
|
||||
body.paragraphs[0].runs = (0..30_000)
|
||||
.map(|i| {
|
||||
let mut r = run.clone();
|
||||
let name = format!("Qqq Font {}", i / 2);
|
||||
r.props.font = Some(if i % 2 == 0 { name } else { name.to_uppercase() });
|
||||
r
|
||||
})
|
||||
.collect();
|
||||
slide.shapes[0].text = Some(body);
|
||||
let used = crate::fonts::used_fonts(&p);
|
||||
assert_eq!(used.iter().filter(|f| f.starts_with("Qqq Font ")).count(), 15_000);
|
||||
assert_eq!(crate::fonts::missing_fonts(&p).iter().filter(|f| f.to_ascii_lowercase().starts_with("qqq font ")).count(), 15_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slide_master_view_rejects_invalid_indices() {
|
||||
let mut s = Session::with_new();
|
||||
@@ -7029,3 +7138,18 @@ fn outline_export_skips_footer_date_and_slide_number() {
|
||||
assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{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();
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ mod geometry;
|
||||
mod hit;
|
||||
mod pointer;
|
||||
|
||||
pub(crate) use drag::MAX_GESTURE_POINTS;
|
||||
pub use hit::{line_xfrm, resize_box, table_rows, text_pos};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -275,6 +276,8 @@ pub struct ToolState {
|
||||
pub path_preview: Vec<Point>,
|
||||
pub path_smooth: bool,
|
||||
pub path_closing: bool,
|
||||
/// Whether the last freeform press added a vertex (it adds none at the point cap).
|
||||
pub press_added_vertex: bool,
|
||||
/// The shape in Edit Points: its vertices are handles instead of the box's.
|
||||
pub edit_points: Option<ShapeId>,
|
||||
/// The vertex (path, index) whose control handles show in Edit Points.
|
||||
|
||||
@@ -4,8 +4,12 @@ use super::{Drag, Hit, Point, Result, Session, ShapeId, ShapeKind, TextSel, Tool
|
||||
impl Session {
|
||||
pub(super) fn double_click(&mut self, p: Point, tol: f64) -> Result<Value> {
|
||||
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
|
||||
// The double-click's own click added a duplicate vertex.
|
||||
while points.len() > 2 && points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol) {
|
||||
// The double-click's own click added a duplicate vertex (none at the point cap, where
|
||||
// the existing vertices are all kept).
|
||||
while self.tool.press_added_vertex
|
||||
&& points.len() > 2
|
||||
&& points.last().zip(points.get(points.len() - 2)).is_some_and(|(a, b)| (*a - *b).hypot() <= tol)
|
||||
{
|
||||
points.pop();
|
||||
}
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
||||
|
||||
@@ -12,9 +12,16 @@ struct MoveDrag {
|
||||
enter_text: Option<(ShapeId, Point)>,
|
||||
}
|
||||
|
||||
/// Samples one ink or freeform gesture keeps; later pointer moves extend it no further.
|
||||
pub(crate) const MAX_GESTURE_POINTS: usize = 100_000;
|
||||
|
||||
impl Session {
|
||||
/// Update the active drag interaction using the pointer position and modifiers.
|
||||
pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
if matches!(self.tool.drag, Some(Drag::Ink { .. })) {
|
||||
self.extend_ink(p);
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
|
||||
match drag {
|
||||
Drag::Guide { index } => {
|
||||
@@ -181,7 +188,7 @@ impl Session {
|
||||
}
|
||||
Drag::Freeform { mode, mut points, pressed } => {
|
||||
// Dragging scribbles (except for curves, which only take clicked points).
|
||||
if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
|
||||
if pressed && mode != "curve" && points.len() < MAX_GESTURE_POINTS && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
|
||||
points.push(p);
|
||||
}
|
||||
let mut v = points.clone();
|
||||
@@ -191,15 +198,24 @@ impl Session {
|
||||
self.tool.path_preview = v;
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed });
|
||||
}
|
||||
Drag::Ink { mut points } => {
|
||||
points.push(self.ink_sample(p));
|
||||
self.tool.ink_preview = points.clone();
|
||||
self.tool.drag = Some(Drag::Ink { points });
|
||||
}
|
||||
// Handled above.
|
||||
Drag::Ink { .. } => {}
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// Ink grows by one sample per move: extend it (and its preview) in place rather than
|
||||
/// copying the whole stroke twice per event, up to [`MAX_GESTURE_POINTS`].
|
||||
fn extend_ink(&mut self, p: Point) {
|
||||
let sample = self.ink_sample(p);
|
||||
if let Some(Drag::Ink { points }) = self.tool.drag.as_mut()
|
||||
&& points.len() < MAX_GESTURE_POINTS
|
||||
{
|
||||
points.push(sample);
|
||||
self.tool.ink_preview.push(sample);
|
||||
}
|
||||
}
|
||||
|
||||
fn drag_move(&mut self, drag: MoveDrag, p: Point, mods: Mods, tol: f64) -> Result<Value> {
|
||||
let MoveDrag { ids, start, origs, moved, duplicate, enter_text } = drag;
|
||||
let mut d = p - start;
|
||||
|
||||
@@ -228,7 +228,10 @@ impl Session {
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
|
||||
return self.finish_freeform(true);
|
||||
}
|
||||
points.push(p);
|
||||
self.tool.press_added_vertex = points.len() < super::MAX_GESTURE_POINTS;
|
||||
if self.tool.press_added_vertex {
|
||||
points.push(p);
|
||||
}
|
||||
self.tool.path_preview = points.clone();
|
||||
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true });
|
||||
return Ok(Value::Null);
|
||||
|
||||
@@ -48,7 +48,7 @@ impl SliceDecoder<'_> {
|
||||
let intra_slice = self.sh.slice_type.is_intra();
|
||||
loop {
|
||||
ensure!(addr < total, "macroblock address {addr} out of range");
|
||||
self.start_mb(addr);
|
||||
self.start_mb(addr)?;
|
||||
let skip = !intra_slice && self.decode_skip_flag(&mut c);
|
||||
if skip {
|
||||
self.decode_skip()?;
|
||||
|
||||
+43
-10
@@ -15,7 +15,7 @@ use crate::slicedec::{PicState, SliceDecoder};
|
||||
use crate::transform::LevelScale;
|
||||
use crate::{ColorInfo, Picture};
|
||||
use deckcraft_bitstream::{BitReader, annexb_nals, length_prefixed_nals, unescape_rbsp};
|
||||
use std::collections::VecDeque;
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// One slice of a picture job.
|
||||
@@ -90,6 +90,10 @@ pub struct Decoder {
|
||||
ls_cache: Vec<(Arc<Pps>, Arc<LevelScale>)>,
|
||||
/// Pictures leaving the DPB, converted once their decoding job has finished.
|
||||
out_queue: VecDeque<Output>,
|
||||
/// Queued entries per frame id and their summed bytes, kept in step with `out_queue` so the
|
||||
/// budget check does not rescan it.
|
||||
queued_ids: HashMap<u32, u64>,
|
||||
queued_bytes: u64,
|
||||
shared: Arc<Shared>,
|
||||
#[cfg(feature = "threads")]
|
||||
pool: Option<rayon::ThreadPool>,
|
||||
@@ -175,6 +179,8 @@ impl Decoder {
|
||||
active_sps: None,
|
||||
ls_cache: Vec::new(),
|
||||
out_queue: VecDeque::new(),
|
||||
queued_ids: HashMap::new(),
|
||||
queued_bytes: 0,
|
||||
shared: Arc::new(Shared::default()),
|
||||
#[cfg(feature = "threads")]
|
||||
pool,
|
||||
@@ -283,7 +289,7 @@ impl Decoder {
|
||||
if !o.frame.is_published(o.frame.mb_h() - 1) && self.out_queue.len() <= max_queue {
|
||||
break;
|
||||
}
|
||||
let Some(o) = self.out_queue.pop_front() else { break };
|
||||
let Some(o) = self.pop_out() else { break };
|
||||
pics.push(make_picture(&o));
|
||||
}
|
||||
Ok(pics)
|
||||
@@ -295,7 +301,7 @@ impl Decoder {
|
||||
let mut outs = Vec::new();
|
||||
self.dpb.flush(&mut outs);
|
||||
self.emit(outs);
|
||||
let pics = self.out_queue.drain(..).map(|o| make_picture(&o)).collect();
|
||||
let pics = std::iter::from_fn(|| self.pop_out()).map(|o| make_picture(&o)).collect();
|
||||
while let Some(f) = self.in_flight.pop_front() {
|
||||
f.wait_complete();
|
||||
}
|
||||
@@ -451,13 +457,24 @@ impl Decoder {
|
||||
chroma_planes: if chroma == 0 { 0 } else { 2 },
|
||||
bytes_per_sample: 1,
|
||||
};
|
||||
let pictures = (sps.max_dpb_frames() + 1 + self.max_in_flight) as u64;
|
||||
ensure!(
|
||||
shape.fits(pictures, self.memory_budget),
|
||||
"{}x{} pictures with {pictures} in the decoder exceed the memory budget",
|
||||
sps.width(),
|
||||
sps.height()
|
||||
);
|
||||
// Pictures already held count at their own size, not the incoming SPS's: after a
|
||||
// resolution change, larger pictures of the old size can still be in the DPB or waiting
|
||||
// for output (one decode call may carry many access units). A buffer shared with a DPB
|
||||
// entry is counted once.
|
||||
let pic = shape.bytes().unwrap_or(u64::MAX);
|
||||
let held = |frames: &mut dyn Iterator<Item = &Arc<Frame>>| frames.fold(0u64, |n, f| n.saturating_add(f.bytes()));
|
||||
// Queued bytes minus the queue entries that share a DPB frame (each distinct DPB frame once).
|
||||
let mut ids: Vec<(u32, u64)> = self.dpb.entries.iter().map(|e| (e.frame.id, e.frame.bytes())).collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
let shared = ids.iter().fold(0u64, |n, (id, b)| n.saturating_add(self.queued_ids.get(id).copied().unwrap_or(0).saturating_mul(*b)));
|
||||
let queued = self.queued_bytes.saturating_sub(shared);
|
||||
let in_dpb = held(&mut self.dpb.entries.iter().map(|e| &e.frame));
|
||||
let dpb = sps.max_dpb_frames().max(self.dpb.entries.len()) as u64;
|
||||
// The DPB (the larger of its new slots and what it holds now), the picture being decoded,
|
||||
// and what is in flight or queued.
|
||||
let bytes = pic.saturating_mul(dpb).max(in_dpb).saturating_add(pic).saturating_add(pic.saturating_mul(self.max_in_flight as u64).max(queued));
|
||||
ensure!(bytes <= self.memory_budget, "{}x{} pictures with {bytes} bytes in the decoder exceed the memory budget", sps.width(), sps.height());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -562,8 +579,24 @@ impl Decoder {
|
||||
}
|
||||
|
||||
fn emit(&mut self, outs: Vec<Output>) {
|
||||
for o in &outs {
|
||||
*self.queued_ids.entry(o.frame.id).or_insert(0) += 1;
|
||||
self.queued_bytes = self.queued_bytes.saturating_add(o.frame.bytes());
|
||||
}
|
||||
self.out_queue.extend(outs);
|
||||
}
|
||||
|
||||
fn pop_out(&mut self) -> Option<Output> {
|
||||
let o = self.out_queue.pop_front()?;
|
||||
if let Some(n) = self.queued_ids.get_mut(&o.frame.id) {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
self.queued_ids.remove(&o.frame.id);
|
||||
}
|
||||
}
|
||||
self.queued_bytes = self.queued_bytes.saturating_sub(o.frame.bytes());
|
||||
Some(o)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decode all slices of one picture and publish its rows.
|
||||
|
||||
+53
-10
@@ -139,8 +139,9 @@ impl Dpb {
|
||||
let mut unused = (prev_ref_frame_num + 1) % max_frame_num;
|
||||
// Only the last `max_num_ref_frames` "non-existing" frames can survive the sliding window, and
|
||||
// inserting more than that evicts every earlier short-term reference. Skip straight to that tail
|
||||
// (keeping the POC state walk for every missing frame_num) so a hostile gap of up to 65535
|
||||
// frames costs at most `max_num_ref_frames` frame allocations instead of one per missing frame.
|
||||
// (advancing the POC state across the skipped run in constant time) so a hostile gap of up to
|
||||
// 65535 frames costs at most `max_num_ref_frames` frame allocations and POC updates instead of
|
||||
// one per missing frame.
|
||||
let gap = frame_num.wrapping_add(max_frame_num).wrapping_sub(unused) % max_frame_num;
|
||||
let keep = u32::try_from(max_num_ref_frames.max(1)).unwrap_or(u32::MAX);
|
||||
let skip = gap.saturating_sub(keep);
|
||||
@@ -149,10 +150,17 @@ impl Dpb {
|
||||
e.mark = RefMark::Unused;
|
||||
}
|
||||
self.prune();
|
||||
for _ in 0..skip {
|
||||
poc_state(unused);
|
||||
unused = (unused + 1) % max_frame_num;
|
||||
// The POC state only advances FrameNumOffset where frame_num decreases, so walking the
|
||||
// skipped run is the same as visiting its first value, the wrap (if the run crosses
|
||||
// it) and its last value: constant work however long the gap.
|
||||
let last = (unused + (skip - 1) % max_frame_num) % max_frame_num;
|
||||
poc_state(unused);
|
||||
if last < unused {
|
||||
poc_state(max_frame_num - 1);
|
||||
poc_state(0);
|
||||
}
|
||||
poc_state(last);
|
||||
unused = (last + 1) % max_frame_num;
|
||||
}
|
||||
let mut guard = 0;
|
||||
while unused != frame_num && guard < max_frame_num {
|
||||
@@ -438,14 +446,15 @@ impl Dpb {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
while self.entries.len() >= self.capacity {
|
||||
// Evict until there is room (frame_num gap concealment can leave the DPB over-full), so
|
||||
// the store never holds more pictures than the memory budget was checked for.
|
||||
while self.entries.len() >= self.capacity.max(1) {
|
||||
if !self.bump(out) {
|
||||
if let Some(pos) = self.entries.iter().position(|e| !e.needed_for_output) {
|
||||
self.entries.remove(pos);
|
||||
} else {
|
||||
self.entries.remove(0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -546,6 +555,37 @@ mod tests {
|
||||
(made, walk, left)
|
||||
}
|
||||
|
||||
/// (FrameNumOffset, prev frame_num) after `walk`, by the rule of `PocState::update_gap_frame`.
|
||||
fn poc_after(walk: &[u32], prev: u32, max: u32) -> (u64, u32) {
|
||||
walk.iter().fold((0, prev), |(off, prev), &f| (if prev > f { off + u64::from(max) } else { off }, f))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gap_walk_matches_every_frame_num_for_any_skip() {
|
||||
for (prev, cur) in [(1, 9), (10, 3), (14, 0), (15, 14), (0, 15), (7, 6)] {
|
||||
for refs in 1..4 {
|
||||
let mut dpb = Dpb::new();
|
||||
let (_, walk, _) = fill(&mut dpb, prev, cur, 16, refs);
|
||||
let every: Vec<u32> = (1..16).map(|i| (prev + i) % 16).take_while(|&f| f != cur).collect();
|
||||
assert_eq!(poc_after(&walk, prev, 16), poc_after(&every, prev, 16), "{prev} -> {cur}, {refs} refs");
|
||||
assert_eq!(walk.last(), every.last(), "{prev} -> {cur}, {refs} refs");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_store_evicts_down_to_capacity() {
|
||||
let mut dpb = Dpb::new();
|
||||
dpb.capacity = 2;
|
||||
// Gap concealment can leave more non-output references than the capacity.
|
||||
for i in 0..5 {
|
||||
short(&mut dpb, i, i);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
assert!(!dpb.bump_for_space(RefMark::Short, 0, &mut out));
|
||||
assert!(dpb.entries.len() < 2, "{} pictures left before storing into a 2-picture DPB", dpb.entries.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn huge_gap_makes_only_the_frames_that_stay_referenced() {
|
||||
let mut dpb = Dpb::new();
|
||||
@@ -553,8 +593,9 @@ mod tests {
|
||||
short(&mut dpb, 2, 1);
|
||||
let (made, walk, left) = fill(&mut dpb, 1, 65535, 65536, 4);
|
||||
assert_eq!(made, 4);
|
||||
// The POC state still walks every missing frame_num, in order.
|
||||
assert_eq!(walk.len(), 65533);
|
||||
// The POC state ends where walking every missing frame_num would leave it, in a few steps.
|
||||
assert_eq!(poc_after(&walk, 1, 65536), poc_after(&(2..65535).collect::<Vec<_>>(), 1, 65536));
|
||||
assert!(walk.len() <= 8, "{} POC updates", walk.len());
|
||||
assert_eq!(walk.first(), Some(&2));
|
||||
assert_eq!(walk.last(), Some(&65534));
|
||||
assert_eq!(left, vec![(65531, true), (65532, true), (65533, true), (65534, true)]);
|
||||
@@ -566,7 +607,9 @@ mod tests {
|
||||
short(&mut dpb, 1, 65000);
|
||||
let (made, walk, left) = fill(&mut dpb, 65000, 3, 65536, 2);
|
||||
assert_eq!(made, 2);
|
||||
assert_eq!(walk.len(), 538);
|
||||
let every: Vec<u32> = (65001..65536).chain(0..3).collect();
|
||||
assert_eq!(poc_after(&walk, 65000, 65536), poc_after(&every, 65000, 65536));
|
||||
assert!(walk.len() <= 6, "{} POC updates", walk.len());
|
||||
assert_eq!(walk.first(), Some(&65001));
|
||||
assert_eq!(walk.last(), Some(&2));
|
||||
assert_eq!(left, vec![(1, true), (2, true)]);
|
||||
|
||||
@@ -160,6 +160,14 @@ impl Frame {
|
||||
self.rows.len()
|
||||
}
|
||||
|
||||
/// Sample bytes this picture holds (luma and both chroma planes), as the decoder's memory
|
||||
/// budget counts a picture.
|
||||
pub fn bytes(&self) -> u64 {
|
||||
(self.width as u64)
|
||||
.saturating_mul(self.height as u64)
|
||||
.saturating_add((self.cwidth as u64).saturating_mul(self.cheight as u64).saturating_mul(2))
|
||||
}
|
||||
|
||||
/// Publish MB row `r` (ignored if already published).
|
||||
pub fn publish(&self, r: usize, row: FrameRow) {
|
||||
let _ = self.rows[r].set(row);
|
||||
|
||||
@@ -346,8 +346,11 @@ impl<'a> SliceDecoder<'a> {
|
||||
// Neighbour helpers
|
||||
// ------------------------------------------------------------------------------------------
|
||||
|
||||
/// Set up the current macroblock address and neighbour availability.
|
||||
pub fn start_mb(&mut self, addr: usize) {
|
||||
/// Set up the current macroblock address and neighbour availability. A macroblock an earlier
|
||||
/// slice of this picture already decoded is refused: overlapping slices would otherwise redo
|
||||
/// the picture's reconstruction once per slice.
|
||||
pub fn start_mb(&mut self, addr: usize) -> Result<()> {
|
||||
ensure!(self.pic.mbs[addr].slice_num == u32::MAX, "macroblock {addr} overlaps an earlier slice");
|
||||
self.mb_addr = addr;
|
||||
self.mb_x = addr % self.mb_w;
|
||||
self.mb_y = addr / self.mb_w;
|
||||
@@ -364,6 +367,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
];
|
||||
self.mv_done = 0;
|
||||
self.pic.mbs[addr] = MbState { slice_num: sn, ..MbState::default() };
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -1467,7 +1471,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
let run = r.read_ue()? as usize;
|
||||
ensure!(addr + run <= total, "mb_skip_run out of range");
|
||||
for _ in 0..run {
|
||||
self.start_mb(addr);
|
||||
self.start_mb(addr)?;
|
||||
self.decode_skip()?;
|
||||
self.pic.mb_done(addr);
|
||||
addr += 1;
|
||||
@@ -1477,7 +1481,7 @@ impl<'a> SliceDecoder<'a> {
|
||||
}
|
||||
ensure!(addr < total, "macroblock address {addr} out of range");
|
||||
}
|
||||
self.start_mb(addr);
|
||||
self.start_mb(addr)?;
|
||||
self.decode_mb_cavlc(r)?;
|
||||
self.pic.mb_done(addr);
|
||||
addr += 1;
|
||||
|
||||
@@ -113,6 +113,7 @@ fn avcc_rejects_a_valid_picture_slice_without_starting_a_picture() {
|
||||
poc_lsb: Some(0),
|
||||
poc_delta: None,
|
||||
wt: None,
|
||||
first_mb: 0,
|
||||
},
|
||||
false,
|
||||
);
|
||||
@@ -153,6 +154,8 @@ struct Pic {
|
||||
/// Explicit weights (luma, cb, cr) as (weight, offset) for refIdx 0 of list 0 and list 1;
|
||||
/// log2 denominators are 5 (luma) and 3 (chroma).
|
||||
wt: Option<[[(i32, i32); 3]; 2]>,
|
||||
/// first_mb_in_slice (`mbs` then lists the slice's macroblocks from there).
|
||||
first_mb: u32,
|
||||
}
|
||||
|
||||
impl Pic {
|
||||
@@ -165,7 +168,7 @@ impl Pic {
|
||||
}
|
||||
|
||||
fn slice_header(w: &mut BitWriter, p: &Pic, cabac: bool) {
|
||||
w.write_ue(0); // first_mb
|
||||
w.write_ue(p.first_mb);
|
||||
w.write_ue(if p.b {
|
||||
6
|
||||
} else if p.is_p() {
|
||||
@@ -660,3 +663,63 @@ fn frame_num_gap_is_filled_when_the_sps_allows_gaps() {
|
||||
assert_eq!(out.len(), 2);
|
||||
assert_eq!(dec.stats().frame_num_gaps, 1);
|
||||
}
|
||||
|
||||
/// An IDR picture followed by `pictures` reference P pictures of P_Skip macroblocks.
|
||||
fn skip_stream(pictures: u32) -> (Vec<u8>, Vec<Vec<u8>>) {
|
||||
let mut seed = 11u32;
|
||||
let a: Vec<Option<Vec<u8>>> = (0..NMB).map(|_| Some(random_mb(&mut seed))).collect();
|
||||
let mut head = sps(66, 2, false, PocType::T2);
|
||||
head.extend(pps(false, false));
|
||||
head.extend(slice_nal(&Pic { idr: true, frame_num: 0, ref_idc: 3, mbs: a, ..Default::default() }, false));
|
||||
let ps = (1..=pictures).map(|k| slice_nal(&Pic { frame_num: k % 16, ref_idc: 2, mbs: vec![None; NMB], ..Default::default() }, false)).collect();
|
||||
(head, ps)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_slices_are_not_reconstructed_twice() {
|
||||
let (head, ps) = skip_stream(1);
|
||||
let mut once = Decoder::with_threads(1);
|
||||
let mut out = once.decode(&[head.clone(), ps[0].clone()].concat(), 0).unwrap();
|
||||
out.extend(once.flush());
|
||||
assert!(once.take_error().is_none());
|
||||
assert_eq!(out.len(), 2);
|
||||
// A second slice of the same picture starting at macroblock 1 covers those macroblocks again.
|
||||
let again = slice_nal(&Pic { frame_num: 1, ref_idc: 2, mbs: vec![None; NMB - 1], first_mb: 1, ..Default::default() }, false);
|
||||
let mut twice = Decoder::with_threads(1);
|
||||
let result = twice.decode(&[head, ps[0].clone(), again].concat(), 0);
|
||||
twice.flush();
|
||||
let err = result.err().or_else(|| twice.take_error()).expect("overlapping slice refused");
|
||||
assert!(err.to_string().contains("overlaps"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_queue_counts_against_the_memory_budget() {
|
||||
let (head, ps) = skip_stream(40);
|
||||
let sps_nal = sps(66, 2, false, PocType::T2);
|
||||
let parsed = crate::params::Sps::parse(&deckcraft_bitstream::unescape_rbsp(&sps_nal[5..])).unwrap();
|
||||
let one = (NMB * 256 + 2 * NMB * 64) as u64;
|
||||
let budget = (parsed.max_dpb_frames() + 1 + 3) as u64 * one;
|
||||
// One access unit per call: output drains as it goes, and the stream fits.
|
||||
let mut dec = Decoder::with_threads(1);
|
||||
dec.set_memory_budget(budget);
|
||||
let mut out = dec.decode(&head, 0).unwrap();
|
||||
for (i, p) in ps.iter().enumerate() {
|
||||
out.extend(dec.decode(p, i as i64 + 1).unwrap());
|
||||
}
|
||||
out.extend(dec.flush());
|
||||
assert!(dec.take_error().is_none());
|
||||
assert_eq!(out.len(), 41);
|
||||
// Every access unit in one call: the pictures held for output stop fitting the budget.
|
||||
let mut dec = Decoder::with_threads(1);
|
||||
dec.set_memory_budget(budget);
|
||||
let err = dec.decode(&[head, ps.concat()].concat(), 0).expect_err("output queue is over budget");
|
||||
assert!(err.to_string().contains("memory budget"), "{err}");
|
||||
}
|
||||
|
||||
/// A held picture is charged at its own size (luma and both 4:2:0 chroma planes), the same
|
||||
/// count the budget uses for the incoming SPS's pictures.
|
||||
#[test]
|
||||
fn a_held_frame_is_charged_at_its_own_size() {
|
||||
let f = crate::picture::Frame::new(0, 0, 2, 3);
|
||||
assert_eq!(f.bytes(), (2 * 3 * 256 + 2 * 2 * 3 * 64) as u64);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,9 @@ pub fn decode(bytes: &Bytes) -> Result<Pcm> {
|
||||
Some(r) => r,
|
||||
None => contained(|| decode_symphonia(bytes, "mkv")),
|
||||
},
|
||||
Container::Mp4 if !crate::mp4guard::tables_fit(bytes) => Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into())),
|
||||
Container::Mp4 | Container::Unknown | Container::Mp3 if !crate::mp4guard::probe_fits(bytes) => {
|
||||
Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into()))
|
||||
}
|
||||
c => contained(|| decode_symphonia(bytes, c.name())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,29 @@ pub(crate) fn tables_fit(b: &[u8]) -> bool {
|
||||
boxes_fit(b, b.len() as u64, 0)
|
||||
}
|
||||
|
||||
/// Bytes symphonia's probe scans for a format marker before giving up.
|
||||
const PROBE_SEARCH_LIMIT: usize = 1024 * 1024;
|
||||
|
||||
/// Whether symphonia can be given `b`, whatever the sniffer made of it: its probe takes the first
|
||||
/// `ftyp` within its search window as the start of an MP4 (the size field 4 bytes before it), so
|
||||
/// the table check must start where that parser will, not at offset 0.
|
||||
pub(crate) fn probe_fits(b: &[u8]) -> bool {
|
||||
if crate::Container::sniff(b) == crate::Container::Mp4 && !tables_fit(b) {
|
||||
return false;
|
||||
}
|
||||
// The probe reads leading ID3v2 tags as metadata and searches again after the last one.
|
||||
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(&[]);
|
||||
}
|
||||
let head = &rest[..rest.len().min(PROBE_SEARCH_LIMIT + 16)];
|
||||
match head.windows(4).position(|w| w == b"ftyp") {
|
||||
Some(p) => tables_fit(&rest[p.saturating_sub(4)..]),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn be32(b: &[u8], at: usize) -> Option<u64> {
|
||||
b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as u64)
|
||||
}
|
||||
@@ -74,3 +97,29 @@ fn table_fits(kind: &[u8], body: &[u8], file_len: u64) -> bool {
|
||||
};
|
||||
be32(body, 4).is_some_and(|count| count <= body.len().saturating_sub(8) as u64 / entry)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// An MP4 whose `stsc` count asks for far more entries than the file holds.
|
||||
fn mp4_with_huge_stsc() -> Vec<u8> {
|
||||
let stsc = [&24u32.to_be_bytes()[..], b"stsc", &[0, 0, 0, 0], &u32::MAX.to_be_bytes(), &[0; 8]].concat();
|
||||
let stbl = [&(stsc.len() as u32 + 8).to_be_bytes()[..], b"stbl", &stsc].concat();
|
||||
let moov = [&(stbl.len() as u32 + 8).to_be_bytes()[..], b"moov", &stbl].concat();
|
||||
[&16u32.to_be_bytes()[..], b"ftyp", b"isom", &[0; 4], &moov].concat()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_prefixed_mp4_is_checked_where_symphonia_starts_it() {
|
||||
let mp4 = mp4_with_huge_stsc();
|
||||
assert!(!tables_fit(&mp4));
|
||||
assert!(!probe_fits(&mp4));
|
||||
let prefixed = [&b"junk before the file "[..], &mp4].concat();
|
||||
assert_eq!(crate::Container::sniff(&prefixed), crate::Container::Unknown);
|
||||
assert!(tables_fit(&prefixed), "offset 0 sees no MP4 boxes");
|
||||
assert!(!probe_fits(&prefixed));
|
||||
let far = [&vec![0u8; PROBE_SEARCH_LIMIT + 16][..], &mp4].concat();
|
||||
assert!(probe_fits(&far), "past the probe window symphonia never reads it as an MP4");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,8 @@ pub fn probe_headers(bytes: &Bytes) -> Result<MediaInfo> {
|
||||
};
|
||||
if info.audio.is_none() && info.video.is_none() || info.audio.as_ref().is_some_and(|a| a.sample_rate == 0) {
|
||||
// Plain audio files (and anything our demuxers didn't describe): symphonia's probe.
|
||||
if c == Container::Mp4 && !crate::mp4guard::tables_fit(bytes) {
|
||||
// Symphonia opens an MP4 wherever its probe finds an `ftyp` (after leading ID3v2 tags too); other known containers (a WAV's audio bytes can spell an `ftyp`) skip the check.
|
||||
if matches!(c, Container::Mp4 | Container::Unknown | Container::Mp3) && !crate::mp4guard::probe_fits(bytes) {
|
||||
return Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into()));
|
||||
}
|
||||
let (codec, rate, channels, frames) = std::panic::catch_unwind(|| crate::audio::probe_symphonia(bytes, c.name()))
|
||||
|
||||
@@ -11,6 +11,14 @@ use crate::geom::{Op, P};
|
||||
/// Line segments per Bézier when a crossing shape is flattened.
|
||||
const CURVE_STEPS: usize = 16;
|
||||
|
||||
/// Point-against-edge tests one clipped item may cost (all its subpaths together); an item over
|
||||
/// it is dropped, as a single polygon over it already was.
|
||||
const MAX_CLIP_WORK: usize = 1_000_000;
|
||||
|
||||
/// The same allowance for intersecting two clip regions, a budget of its own: two convex
|
||||
/// 1,000-gons cost about 1.5 million tests once the cut polygon has grown.
|
||||
pub(crate) const MAX_AREA_CLIP_WORK: usize = 4 * MAX_CLIP_WORK;
|
||||
|
||||
/// The bounding box `[l, t, r, b]` of the points in `ops` (control points included).
|
||||
pub(crate) fn ops_bbox(ops: &[Op]) -> Option<[f64; 4]> {
|
||||
let mut b: Option<[f64; 4]> = None;
|
||||
@@ -98,16 +106,18 @@ fn is_rect(clip: &[P]) -> bool {
|
||||
pts_bbox(clip).is_some_and(|b| clip.len() == 4 && clip.iter().all(|p| (p.0 == b[0] || p.0 == b[2]) && (p.1 == b[1] || p.1 == b[3])))
|
||||
}
|
||||
|
||||
/// Sutherland–Hodgman: `poly` cut by convex `clip`.
|
||||
pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
|
||||
if poly.len().saturating_mul(clip.len()) > 1_000_000 {
|
||||
return Vec::new();
|
||||
}
|
||||
/// Sutherland–Hodgman: `poly` cut by convex `clip`, charging each point visited against each clip
|
||||
/// edge to `budget`: the polygon
|
||||
/// can gain a vertex at every edge, so its cost isn't known from its input size alone. `None`
|
||||
/// once the budget is spent.
|
||||
pub(crate) fn clip_polygon_within(poly: &[P], clip: &[P], budget: &mut usize) -> Option<Vec<P>> {
|
||||
let mut poly = poly.to_vec();
|
||||
for i in 0..clip.len() {
|
||||
if poly.len().saturating_mul(clip.len() - i) > 1_000_000 {
|
||||
return Vec::new();
|
||||
// The work actually done: this edge visits the polygon as it now stands.
|
||||
if poly.len() > *budget {
|
||||
return None;
|
||||
}
|
||||
*budget -= poly.len();
|
||||
let (a, b) = (clip[i], clip[(i + 1) % clip.len()]);
|
||||
let keep = |p: P| cross(a, b, p) >= 0.0;
|
||||
let cut = |p: P, q: P| {
|
||||
@@ -133,7 +143,7 @@ pub(crate) fn clip_polygon(poly: &[P], clip: &[P]) -> Vec<P> {
|
||||
break;
|
||||
}
|
||||
}
|
||||
poly
|
||||
Some(poly)
|
||||
}
|
||||
|
||||
fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool {
|
||||
@@ -158,15 +168,17 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
|
||||
return Vec::new();
|
||||
}
|
||||
let subs = flatten(&ops);
|
||||
// One allowance for the fill and the stroke together; the item goes once it is spent.
|
||||
let mut budget = MAX_CLIP_WORK;
|
||||
let mut out = Vec::new();
|
||||
if fill.is_some() {
|
||||
let f = clip_fill(&subs, clip);
|
||||
let Some(f) = clip_fill(&subs, clip, &mut budget) else { return Vec::new() };
|
||||
if !f.is_empty() {
|
||||
out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() });
|
||||
}
|
||||
}
|
||||
if stroke.is_some() {
|
||||
let s = clip_stroke(&subs, clip);
|
||||
let Some(s) = clip_stroke(&subs, clip, &mut budget) else { return Vec::new() };
|
||||
if !s.is_empty() {
|
||||
out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region });
|
||||
}
|
||||
@@ -181,7 +193,8 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
|
||||
if disjoint(b, cb) {
|
||||
return Vec::new();
|
||||
}
|
||||
let f = clip_fill(&flatten(&ops), clip);
|
||||
let subs = flatten(&ops);
|
||||
let f = clip_fill(&subs, clip, &mut { MAX_CLIP_WORK }).unwrap_or_default();
|
||||
if f.is_empty() { Vec::new() } else { vec![Raw::Pattern { ops: f, even_odd, tile, region }] }
|
||||
}
|
||||
Raw::Bitmap { rect, img, region } => crop(rect, img, clip).map(|(rect, img)| vec![Raw::Bitmap { rect, img, region }]).unwrap_or_default(),
|
||||
@@ -230,20 +243,23 @@ pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec<P>, bool)> {
|
||||
subs
|
||||
}
|
||||
|
||||
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
/// The clipped fill of every subpath, its clipping work charged to `budget` with that of the
|
||||
/// stroke: `None` (the whole item goes) once it is spent.
|
||||
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P], budget: &mut usize) -> Option<Vec<Op>> {
|
||||
let mut ops = Vec::new();
|
||||
for (pts, _) in subs {
|
||||
let poly = clip_polygon(pts, clip);
|
||||
let poly = clip_polygon_within(pts, clip, budget)?;
|
||||
if poly.len() >= 3 {
|
||||
ops.push(Op::M(poly[0]));
|
||||
ops.extend(poly[1..].iter().map(|p| Op::L(*p)));
|
||||
ops.push(Op::Z);
|
||||
}
|
||||
}
|
||||
ops
|
||||
Some(ops)
|
||||
}
|
||||
|
||||
fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
/// The clipped outline of every subpath; `None` once `budget` is spent.
|
||||
fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P], budget: &mut usize) -> Option<Vec<Op>> {
|
||||
let mut ops = Vec::new();
|
||||
for (pts, closed) in subs {
|
||||
let mut pts = pts.clone();
|
||||
@@ -252,7 +268,7 @@ fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
}
|
||||
let mut last: Option<P> = None;
|
||||
for w in pts.windows(2) {
|
||||
let Some((a, b)) = segment(w[0], w[1], clip) else {
|
||||
let Some((a, b)) = segment(w[0], w[1], clip, budget)? else {
|
||||
last = None;
|
||||
continue;
|
||||
};
|
||||
@@ -263,21 +279,23 @@ fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
|
||||
last = Some(b);
|
||||
}
|
||||
}
|
||||
ops
|
||||
Some(ops)
|
||||
}
|
||||
|
||||
/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`.
|
||||
fn segment(a: P, b: P, clip: &[P]) -> Option<(P, P)> {
|
||||
/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`, one unit of `budget` per clip edge
|
||||
/// visited; the outer `None` is a spent budget.
|
||||
fn segment(a: P, b: P, clip: &[P], budget: &mut usize) -> Option<Option<(P, P)>> {
|
||||
let d = (b.0 - a.0, b.1 - a.1);
|
||||
let (mut t0, mut t1) = (0.0f64, 1.0f64);
|
||||
for i in 0..clip.len() {
|
||||
*budget = budget.checked_sub(1)?;
|
||||
let (e0, e1) = (clip[i], clip[(i + 1) % clip.len()]);
|
||||
// Inside is cross(e0, e1, p) >= 0, linear in t along the segment.
|
||||
let f0 = cross(e0, e1, a);
|
||||
let df = (e1.0 - e0.0) * d.1 - (e1.1 - e0.1) * d.0;
|
||||
if df == 0.0 {
|
||||
if f0 < 0.0 {
|
||||
return None;
|
||||
return Some(None);
|
||||
}
|
||||
} else {
|
||||
let t = -f0 / df;
|
||||
@@ -288,7 +306,7 @@ fn segment(a: P, b: P, clip: &[P]) -> Option<(P, P)> {
|
||||
}
|
||||
}
|
||||
}
|
||||
(t0 <= t1).then_some(((a.0 + d.0 * t0, a.1 + d.1 * t0), (a.0 + d.0 * t1, a.1 + d.1 * t1)))
|
||||
Some((t0 <= t1).then_some(((a.0 + d.0 * t0, a.1 + d.1 * t0), (a.0 + d.0 * t1, a.1 + d.1 * t1))))
|
||||
}
|
||||
|
||||
/// Crops a bitmap drawn into `rect` = `[x, y, w, h]` (device, positive extents) to the clip
|
||||
|
||||
@@ -28,7 +28,7 @@ use std::sync::Arc;
|
||||
use crate::bytes::Bytes;
|
||||
use crate::canvas::Raw;
|
||||
use crate::canvas::{BS_NULL, BS_SOLID, Brush, Canvas, LogFont, PS_NULL, Pen, RawTile, Xform, hatch_tile, mul};
|
||||
use crate::cliprect::{clip_polygon, convex, flatten, ops_bbox, pts_bbox, rect_poly};
|
||||
use crate::cliprect::{MAX_AREA_CLIP_WORK, clip_polygon_within, convex, flatten, ops_bbox, pts_bbox, rect_poly};
|
||||
use crate::dib::Rgba;
|
||||
use crate::geom::{Op, P};
|
||||
use crate::text::{ETO_CLIPPED, Font};
|
||||
@@ -1170,7 +1170,13 @@ fn area_of_ops(ops: &[Op]) -> Vec<P> {
|
||||
fn area_intersect(a: Area, b: Area) -> Area {
|
||||
match (a, b) {
|
||||
(None, x) | (x, None) => x,
|
||||
(Some(a), Some(b)) if a.len() >= 3 && b.len() >= 3 => Some(convex(&clip_polygon(&a, &b)).unwrap_or_default()),
|
||||
(Some(a), Some(b)) if a.len() >= 3 && b.len() >= 3 => {
|
||||
// Out of work: keep the area so far rather than call the region empty.
|
||||
match clip_polygon_within(&a, &b, &mut { MAX_AREA_CLIP_WORK }) {
|
||||
Some(p) => Some(convex(&p).unwrap_or_default()),
|
||||
None => Some(a),
|
||||
}
|
||||
}
|
||||
_ => Some(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2071,6 +2071,87 @@ fn emfplus_wrap_line_linear_performance() {
|
||||
fn clip_polygon_work_bound() {
|
||||
let poly: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect();
|
||||
let clip: Vec<(f64, f64)> = (0..2000).map(|i| (i as f64, i as f64)).collect();
|
||||
let clipped = crate::cliprect::clip_polygon(&poly, &clip);
|
||||
assert!(clipped.is_empty(), "excessive polygon clip product rejected");
|
||||
assert!(crate::cliprect::clip_polygon_within(&poly, &clip, &mut 1_000_000).is_none(), "excessive polygon clip product rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_polygon_charges_actual_work_not_projected_growth() {
|
||||
// Two convex 1,000-gons: the product is exactly the budget, and a cut that adds a vertex must
|
||||
// not make a later step look over budget.
|
||||
let gon = |cx: f64| -> Vec<(f64, f64)> {
|
||||
(0..1000).map(|i| (i as f64 / 1000.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (cx + 100.0 * c, 100.0 * s)).collect()
|
||||
};
|
||||
let clipped = crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut 4_000_000).unwrap();
|
||||
assert!(clipped.len() > 100, "the intersection is kept: {}", clipped.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_work_is_shared_by_fill_and_stroke() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
// One zigzag subpath crossing a 512-gon's edge: about 510k point-edge tests per pass, so a
|
||||
// fill alone fits the allowance but a fill plus a stroke would spend it twice.
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
let mut ops = vec![Op::M((0.0, 95.0))];
|
||||
ops.extend((1..1000).map(|i| Op::L((i as f64 * 0.19, if i % 2 == 0 { 95.0 } else { 300.0 }))));
|
||||
ops.push(Op::Z);
|
||||
let raw = |fill, stroke| Raw::Path { ops: ops.clone(), fill, stroke, even_odd: false, region: None };
|
||||
let black = [0, 0, 0, 255];
|
||||
assert!(!crate::cliprect::clip_raw(raw(Some(black), None), &clip).is_empty(), "a fill alone is within the allowance");
|
||||
assert!(crate::cliprect::clip_raw(raw(Some(black), Some((black, 1.0))), &clip).is_empty(), "fill and stroke share one allowance");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clip_work_is_bounded_across_subpaths() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
// 512-gon clip; 4,000 small triangles crossing its edge: each under the per-polygon cap, together
|
||||
// far over it, for both the fill and the stroke.
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
let tri = |x: f64| [Op::M((x, 95.0)), Op::L((x + 1.0, 95.0)), Op::L((x, 300.0)), Op::Z];
|
||||
let many: Vec<Op> = (0..4000).flat_map(|i| tri(i as f64 % 200.0)).collect();
|
||||
let raw = |ops| Raw::Path { ops, fill: Some([0, 0, 0, 255]), stroke: Some(([0, 0, 0, 255], 1.0)), even_odd: false, region: None };
|
||||
assert!(crate::cliprect::clip_raw(raw(many), &clip).is_empty(), "the whole item's clipping work is bounded");
|
||||
assert_eq!(crate::cliprect::clip_raw(raw(tri(100.0).to_vec()), &clip).len(), 2, "a small crossing shape is still clipped");
|
||||
}
|
||||
|
||||
/// Clipping a fill can add a vertex at every clip edge, so a polygon's work isn't its input size:
|
||||
/// shapes that pass the up-front estimate but grow while clipped are charged as they grow, against
|
||||
/// one budget for the whole item.
|
||||
#[test]
|
||||
fn growing_fill_clips_share_one_budget() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
// A triangle around the whole circle: clipped, it becomes the 512-gon, about 130,000 steps.
|
||||
let tri = [Op::M((-1000.0, -1000.0)), Op::L((1000.0, -1000.0)), Op::L((100.0, 1000.0)), Op::Z];
|
||||
let raw = |n: usize| Raw::Path { ops: tri.repeat(n), fill: Some([0, 0, 0, 255]), stroke: None, even_odd: false, region: None };
|
||||
// 400 of them pass the estimate (400 x 4 x 512 is under a million) but cost about 52 million.
|
||||
assert!(crate::cliprect::clip_raw(raw(400), &clip).is_empty(), "the growing work is charged");
|
||||
assert_eq!(crate::cliprect::clip_raw(raw(2), &clip).len(), 1, "a couple of them are still clipped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stroke_work_is_charged_and_outside_items_are_cheap() {
|
||||
use crate::canvas::Raw;
|
||||
use crate::geom::Op;
|
||||
let clip: Vec<(f64, f64)> =
|
||||
(0..512).map(|i| (i as f64 / 512.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (100.0 + 100.0 * c, 100.0 + 100.0 * s)).collect();
|
||||
// A stroke-only zigzag of 4,000 segments across the edge: 4,000 x 512 tests, over the allowance.
|
||||
let mut ops = vec![Op::M((0.0, 95.0))];
|
||||
ops.extend((1..4000).map(|i| Op::L((i as f64 * 0.05, if i % 2 == 0 { 95.0 } else { 300.0 }))));
|
||||
let raw = Raw::Path { ops, fill: None, stroke: Some(([0, 0, 0, 255], 1.0)), even_odd: false, region: None };
|
||||
assert!(crate::cliprect::clip_raw(raw, &clip).is_empty(), "stroke work is bounded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn region_intersection_over_budget_keeps_the_previous_area() {
|
||||
let gon = |cx: f64| -> Vec<(f64, f64)> {
|
||||
(0..1000).map(|i| (i as f64 / 1000.0 * std::f64::consts::TAU).sin_cos()).map(|(s, c)| (cx + 100.0 * c, 100.0 * s)).collect()
|
||||
};
|
||||
assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut 10).is_none());
|
||||
assert!(crate::cliprect::clip_polygon_within(&gon(0.0), &gon(30.0), &mut crate::cliprect::MAX_AREA_CLIP_WORK.clone()).is_some());
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
pub fn change_case(s: &str, mode: &str) -> String {
|
||||
change_case_parts(&[s], mode).concat()
|
||||
@@ -532,7 +537,7 @@ pub fn change_case_parts(parts: &[&str], mode: &str) -> Vec<String> {
|
||||
} else {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -558,6 +563,13 @@ mod tests {
|
||||
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]
|
||||
fn title_sigma_across_runs_is_not_final() {
|
||||
assert_eq!(change_case_parts(&["ΟΔΟΣ", "ΑΣ"], "title"), ["Οδοσ", "ας"]);
|
||||
|
||||
@@ -314,6 +314,7 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
|
||||
compat_ln_spc: None,
|
||||
vert_overflow: None,
|
||||
horz_overflow: None,
|
||||
raw3d: None,
|
||||
});
|
||||
b
|
||||
}
|
||||
|
||||
@@ -124,6 +124,9 @@ pub struct BodyProps {
|
||||
pub compat_ln_spc: Option<bool>,
|
||||
pub vert_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)]
|
||||
@@ -187,6 +190,9 @@ impl BodyProps {
|
||||
self.compat_ln_spc = self.compat_ln_spc.or(p.compat_ln_spc);
|
||||
self.vert_overflow = self.vert_overflow.or(p.vert_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)]
|
||||
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 mut ata = vec![vec![0.0; m]; m];
|
||||
let mut atb = vec![0.0; m];
|
||||
for (x, y) in xs.iter().zip(ys) {
|
||||
let v = x / sx;
|
||||
let u = (x - xm) / su;
|
||||
let dy = y - b0;
|
||||
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;
|
||||
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];
|
||||
}
|
||||
}
|
||||
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);
|
||||
coeff.push(b0);
|
||||
coeff.extend(solved);
|
||||
coeff.extend(q.iter().map(|c| c / sx));
|
||||
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);
|
||||
}
|
||||
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 mut ata = vec![vec![0.0; m]; 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];
|
||||
}
|
||||
}
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn linear_fits_offset_but_narrow_x_range() {
|
||||
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 {
|
||||
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 paragraphs = tx.children_named("p").take(100_000).map(|p| paragraph(imp, part, p)).collect();
|
||||
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") {
|
||||
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 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 }
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
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))
|
||||
@@ -481,7 +496,8 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
.o("vertOverflow", b.vert_overflow.map(|v| v.xml()))
|
||||
.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()));
|
||||
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);
|
||||
return;
|
||||
}
|
||||
@@ -515,6 +531,9 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
if let Some(raw) = raw3d {
|
||||
w.raw(raw);
|
||||
}
|
||||
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);
|
||||
}
|
||||
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");
|
||||
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));
|
||||
}
|
||||
|
||||
#[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) {
|
||||
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);
|
||||
|
||||
@@ -137,6 +137,14 @@ impl SlideApp {
|
||||
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
|
||||
/// screen and the File tab, where there is no status bar.
|
||||
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.
|
||||
enabled: 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 {
|
||||
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) {
|
||||
if !matches!(app.updates_rt.updater.status(), Status::Checking) {
|
||||
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) {
|
||||
let rt = &mut app.updates_rt;
|
||||
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) {
|
||||
// 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());
|
||||
match app.updates_rt.updater.show_if(ctx, clean) {
|
||||
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::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;
|
||||
if rt.result_open {
|
||||
match CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status()) {
|
||||
CheckResultResponse::Close => rt.result_open = false,
|
||||
CheckResultResponse::CheckAgain => rt.check_requested = true,
|
||||
CheckResultResponse::None => {}
|
||||
}
|
||||
if !matches!(rt.updater.status(), Status::Idle) {
|
||||
let response = CheckResultDialog::new(&crate::UPDATE_APP, VERSION).show(ctx, rt.updater.status());
|
||||
answer(rt, response);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
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::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")] {
|
||||
if ui.button(l).clicked() {
|
||||
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 fams = font_families(app);
|
||||
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() {
|
||||
let label = if k == 0 && theme_fonts.len() == 2 {
|
||||
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();
|
||||
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()) {
|
||||
if ui.button(widgets::fmt_num(*s, "")).clicked() {
|
||||
run(app, "format.size", json!({"size": s}));
|
||||
|
||||
@@ -1223,6 +1223,56 @@ fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> {
|
||||
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.
|
||||
#[test]
|
||||
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`.
|
||||
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 (rect, resp) = ui.allocate_exact_size(vec2(width, 22.0), if enabled { Sense::click() } else { Sense::hover() });
|
||||
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 },
|
||||
);
|
||||
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 }));
|
||||
let _ = id;
|
||||
resp.widget_info(|| egui::WidgetInfo {
|
||||
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::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
|
||||
/// 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]
|
||||
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| {
|
||||
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("18 pt");
|
||||
});
|
||||
dropdown(ui, "Start", "", 112.0, false, |_| {});
|
||||
});
|
||||
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!(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
|
||||
|
||||
@@ -84,7 +84,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| 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`) |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
@@ -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`.
|
||||
|
||||
### 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
|
||||
|
||||
DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
|
||||
|
||||
+23
-7
@@ -61,16 +61,23 @@ pub fn missing(files: &[String], assets_md: &str) -> Vec<String> {
|
||||
files.iter().filter(|f| is_asset(f) && !patterns.iter().any(|p| glob(p, f))).cloned().collect()
|
||||
}
|
||||
|
||||
/// `*` matches any run of characters except `/`; everything else literally.
|
||||
/// `*` matches any run of characters except `/`; everything else literally. Matched with a
|
||||
/// table over the pattern, so a pattern with many `*` costs pattern length times name length
|
||||
/// rather than exploring every way to split the name.
|
||||
fn glob(pat: &str, s: &str) -> bool {
|
||||
match pat.split_once('*') {
|
||||
None => pat == s,
|
||||
Some((head, tail)) => {
|
||||
let Some(rest) = s.strip_prefix(head) else { return false };
|
||||
let ends = rest.char_indices().map(|(i, _)| i).chain(std::iter::once(rest.len()));
|
||||
ends.take_while(|&i| !rest[..i].contains('/')).any(|i| glob(tail, &rest[i..]))
|
||||
let t: Vec<char> = s.chars().collect();
|
||||
// `row[k]`: the pattern so far matches the first k characters of the name.
|
||||
let mut row = vec![false; t.len() + 1];
|
||||
row[0] = true;
|
||||
for pc in pat.chars() {
|
||||
let mut next = vec![false; t.len() + 1];
|
||||
next[0] = pc == '*' && row[0];
|
||||
for k in 1..=t.len() {
|
||||
next[k] = if pc == '*' { row[k] || (next[k - 1] && t[k - 1] != '/') } else { row[k - 1] && t[k - 1] == pc };
|
||||
}
|
||||
row = next;
|
||||
}
|
||||
row[t.len()]
|
||||
}
|
||||
|
||||
/// Splits `git ls-files -z` output into real file names (no display quoting of Unicode or special characters).
|
||||
@@ -120,6 +127,15 @@ mod tests {
|
||||
assert_eq!(missing(&["assets/é.png".into()], "`assets/*.svg`"), vec!["assets/é.png".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_wildcards_on_a_failing_name_finish_at_once() {
|
||||
let pat = format!("{}b", "a*".repeat(40));
|
||||
assert!(!glob(&pat, &"a".repeat(60)));
|
||||
assert!(glob(&pat, &format!("{}b", "a".repeat(60))));
|
||||
assert!(!glob("a*b", "a/b"));
|
||||
assert!(glob("a*b", "ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ls_files_output_is_nul_separated_and_unquoted() {
|
||||
let names = parse_ls_files("assets/zażółć.png\0docs/images/a b\nc.png\0".as_bytes());
|
||||
|
||||
@@ -6,7 +6,8 @@ use std::process::Command;
|
||||
|
||||
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";
|
||||
|
||||
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}"))
|
||||
};
|
||||
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 [
|
||||
("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"]),
|
||||
@@ -143,7 +144,7 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
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!({
|
||||
"packages": [
|
||||
{"id":"app", "name":"deckcraft-pdf"},
|
||||
|
||||
Reference in New Issue
Block a user