Compare commits

..
Author SHA1 Message Date
FredAppsandClaude Opus 5.5 a3e2254d3d Release 0.16.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:31:10 +01:00
FredAppsandClaude Opus 5.5 6bae03795f Merge main into release
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 20:31:09 +01:00
Dylan M. Taylor 341a565048 Merge main into release 2026-11-04 21:13:22 -05:00
Dylan M. Taylor 0b7e1e3272 Merge main into release 2026-11-04 21:08:10 -05:00
Dylan M. Taylor 9bac60ff53 Release 0.15.1 2026-11-04 19:50:33 -05:00
Dylan M. Taylor 00ce289f3c Release 0.15.1 2026-11-04 19:47:55 -05:00
Dylan M. Taylor 86cd956454 Run the signed release from the release environment 2026-11-04 19:36:48 -05:00
Dylan M. Taylor d9102e1eb6 Release 0.15.1 2026-11-04 18:50:06 -05:00
59 changed files with 197 additions and 1387 deletions
-4
View File
@@ -26,10 +26,6 @@ jobs:
# Same craft-fonts commit as release.yml.
CRAFT_FONTS_REF: 37827adc41fd165f9a9234ac40f94dbad353aa69
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- name: Check out craft-fonts
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
-4
View File
@@ -27,10 +27,6 @@ jobs:
name: Windows installation policy (no app build)
runs-on: windows-latest
steps:
- name: Harden runner
uses: step-security/harden-runner@1d88384fd173af6ecb012dfd0264a306b711241a # v2.22.4
with:
egress-policy: audit
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
Generated
+37 -49
View File
@@ -847,12 +847,12 @@ dependencies = [
[[package]]
name = "craft-fonts-build"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
[[package]]
name = "craft-i18n"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
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=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
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=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
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=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
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=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"chrono",
"dunce",
@@ -922,7 +922,6 @@ dependencies = [
"image",
"log",
"plist",
"rustix 1.1.5",
"semver",
"serde",
"serde_json",
@@ -936,21 +935,10 @@ 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=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -1029,7 +1017,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"arboard",
"chrono",
@@ -1051,7 +1039,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -1060,7 +1048,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1069,14 +1057,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-cli"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -1086,14 +1074,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"chrono",
"deckcraft-anim",
@@ -1119,7 +1107,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -1131,7 +1119,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -1142,7 +1130,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"kurbo",
"serde",
@@ -1150,7 +1138,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1159,7 +1147,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1168,21 +1156,21 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1192,7 +1180,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1210,7 +1198,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"image",
"kurbo",
@@ -1219,7 +1207,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1231,15 +1219,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.18.0"
version = "0.16.0"
[[package]]
name = "deckcraft-opus"
version = "0.18.0"
version = "0.16.0"
[[package]]
name = "deckcraft-pdf"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1257,7 +1245,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1273,7 +1261,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1292,7 +1280,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1306,7 +1294,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -1332,7 +1320,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1341,9 +1329,8 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"craft-web-api",
"craft-web-host",
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -2792,7 +2779,7 @@ dependencies = [
[[package]]
name = "krilla"
version = "0.8.2"
source = "git+https://github.com/storytold/craft-libs?rev=6e03f65bf45181db77ba6229716e6624db9eb25b#6e03f65bf45181db77ba6229716e6624db9eb25b"
source = "git+https://github.com/storytold/craft-libs?rev=c5a1cbfb5f9049f9270955caffb5bee5997aebf7#c5a1cbfb5f9049f9270955caffb5bee5997aebf7"
dependencies = [
"base64 0.22.1",
"bumpalo",
@@ -6326,7 +6313,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.18.0"
version = "0.16.0"
dependencies = [
"serde_json",
]
@@ -6572,6 +6559,7 @@ dependencies = [
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
[[package]]
+6 -6
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.18.0"
version = "0.16.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 = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false }
craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0", default-features = false, features = ["write", "write-simple-text"] }
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-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 = "6e03f65bf45181db77ba6229716e6624db9eb25b", features = ["egui"] }
craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.1.0" }
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" }
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 = "6e03f65bf45181db77ba6229716e6624db9eb25b", version = "=0.8.2" }
krilla = { git = "https://github.com/storytold/craft-libs", rev = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7", 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" }
+1 -3
View File
@@ -16,10 +16,8 @@ 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 = "fd5e2feff6ea25bed371a99cc732b3968d621505", version = "0.1.0" }
craft-web-host = { git = "https://github.com/storytold/craft-libs", rev = "2e2312ddaf21bc3d85cd3395d0955c7d239306f6", version = "0.1.0" }
egui = { workspace = true }
log = { workspace = true }
serde_json = { workspace = true }
-151
View File
@@ -1,151 +0,0 @@
//! `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");
}
}
-2
View File
@@ -21,8 +21,6 @@
#![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")]
-1
View File
@@ -79,7 +79,6 @@ 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 }))
}),
)
+3 -20
View File
@@ -148,19 +148,7 @@ 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);
// 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")
format!("{ts} {level:<5} [{thread}] {target}: {message}\n")
}
/// Where formatted lines go besides standard error: kept in memory until a file is attached,
@@ -458,13 +446,8 @@ 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, 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'));
// 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"));
}
#[test]
-3
View File
@@ -51,9 +51,6 @@ 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
+7 -11
View File
@@ -466,26 +466,22 @@ 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 mut ids = targets(s, p)?;
let 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| {
// 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 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()
};
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.get(id).copied();
sh.xfrm = boxes.iter().find(|(i, _)| i == id).map(|(_, x)| *x);
}
f(sh)?;
n += 1;
+9 -3
View File
@@ -120,7 +120,13 @@ 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| parse_align("format.paragraph", a)).transpose()?;
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 ml = f64_param(p, "indentLeft");
let ind = f64_param(p, "indentFirst");
let sb = f64_param(p, "spaceBefore");
@@ -176,10 +182,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" | "centre" | "ctr" => Align::Center,
"center" | "ctr" => Align::Center,
"right" | "r" => Align::Right,
"justify" | "just" => Align::Justify,
"justLow" | "justifyLow" => Align::JustLow,
"justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
"thaiDist" => Align::ThaiDistributed,
_ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
-8
View File
@@ -135,14 +135,6 @@ 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();
+1 -20
View File
@@ -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, opt_usize_param, str_param, targets, usize_param, with_param, xfrm_of,
has_slide, id_param, ok, str_param, targets, usize_param, with_param, xfrm_of,
};
use crate::{Result, Session};
@@ -374,25 +374,6 @@ 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);
+3 -3
View File
@@ -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, opt_usize_param, requested_fill, str_param, targets,
edit_shapes, f64_param, inherited_fills, json, requested_fill, str_param, targets, usize_param,
};
pub(super) fn adjust(s: &mut Session, p: &Value) -> Result<Value> {
let i = opt_usize_param(p, "index", "shape.adjust")?.unwrap_or(0).min(16);
let i = usize_param(p, "index").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 = opt_usize_param(p, "index", "shape.quickStyle")?.unwrap_or(1).min(41);
let i = usize_param(p, "index").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());
+1 -1
View File
@@ -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(crate::tools::MAX_GESTURE_POINTS)
.take(20_000)
.collect()
})
.unwrap_or_default();
+7 -11
View File
@@ -1,8 +1,6 @@
//! 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};
@@ -14,13 +12,8 @@ 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());
@@ -34,8 +27,12 @@ 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();
// 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();
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);
}
}
out.sort_by_key(|f| f.to_lowercase());
out
}
@@ -43,10 +40,9 @@ 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() && seen.insert(f.to_ascii_lowercase()) {
if !f.is_empty() && !out.iter().any(|o| o.eq_ignore_ascii_case(&f)) {
out.push(f);
}
};
+3 -25
View File
@@ -824,32 +824,10 @@ 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, text_cost);
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 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
}
@@ -867,7 +845,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) + slide_extra_cost(s);
n += 512 + shapes_cost(&s.shapes);
}
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>();
+8 -58
View File
@@ -171,9 +171,7 @@ impl Dictionary {
["COMPOUNDWORDMAX", n, ..] => d.compound_max = n.parse().unwrap_or(0),
["REP", n] => {
for _ in 0..n.parse::<usize>().unwrap_or(0) {
// 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();
let r: Vec<&str> = lines.next().unwrap_or("").split_whitespace().collect();
if let [_, a, b, ..] = r.as_slice() {
d.rep.push((a.replace('_', " "), b.replace('_', " ")));
}
@@ -184,8 +182,7 @@ 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 Some(line) = lines.next() else { break };
let r: Vec<&str> = line.split_whitespace().collect();
let r: Vec<&str> = lines.next().unwrap_or("").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)),
@@ -307,12 +304,7 @@ 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, 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;
}
fn compounded(&self, w: &[char], index: usize) -> bool {
if self.compound.is_none() && self.compound_end.is_none() {
return false;
}
@@ -330,11 +322,10 @@ 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, failed) {
if self.compounded(rest, index + 1) {
return true;
}
}
failed.insert((w.len(), index));
false
}
@@ -361,7 +352,7 @@ impl Dictionary {
return true;
}
}
self.compounded(&w, 0, &mut HashSet::new())
self.compounded(&w, 0)
}
/// Is `word` spelt correctly? Capitalised and upper-case forms of a lower-case word pass.
@@ -585,15 +576,6 @@ 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();
@@ -604,23 +586,10 @@ pub fn dictionary(lang: &str) -> Option<Arc<Dictionary>> {
return d.clone();
}
let loaded = find_in(&search_dirs(), lang).and_then(|(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)
let (a, d) = (std::fs::read(a).ok()?, std::fs::read(d).ok()?);
Some(Arc::new(Dictionary::parse(&a, &d)))
});
if let Ok(mut c) = cache.lock()
&& c.len() < MAX_CACHED_LANGS
{
if let Ok(mut c) = cache.lock() {
c.insert(key, loaded.clone());
}
loaded
@@ -928,14 +897,6 @@ 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]
@@ -970,17 +931,6 @@ 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};
-124
View File
@@ -1713,31 +1713,6 @@ 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();
@@ -3745,25 +3720,6 @@ 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(&notes);
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] = &[
@@ -5452,38 +5408,6 @@ 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 {
@@ -6921,39 +6845,6 @@ 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();
@@ -7138,18 +7029,3 @@ 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();
}
-3
View File
@@ -17,7 +17,6 @@ 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)]
@@ -276,8 +275,6 @@ 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.
+2 -6
View File
@@ -4,12 +4,8 @@ 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 (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)
{
// 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) {
points.pop();
}
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
+6 -22
View File
@@ -12,16 +12,9 @@ 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 } => {
@@ -188,7 +181,7 @@ impl Session {
}
Drag::Freeform { mode, mut points, pressed } => {
// Dragging scribbles (except for curves, which only take clicked points).
if pressed && mode != "curve" && points.len() < MAX_GESTURE_POINTS && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
points.push(p);
}
let mut v = points.clone();
@@ -198,24 +191,15 @@ impl Session {
self.tool.path_preview = v;
self.tool.drag = Some(Drag::Freeform { mode, points, pressed });
}
// Handled above.
Drag::Ink { .. } => {}
Drag::Ink { mut points } => {
points.push(self.ink_sample(p));
self.tool.ink_preview = points.clone();
self.tool.drag = Some(Drag::Ink { points });
}
}
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;
+1 -4
View File
@@ -228,10 +228,7 @@ impl Session {
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: false });
return self.finish_freeform(true);
}
self.tool.press_added_vertex = points.len() < super::MAX_GESTURE_POINTS;
if self.tool.press_added_vertex {
points.push(p);
}
points.push(p);
self.tool.path_preview = points.clone();
self.tool.drag = Some(Drag::Freeform { mode, points, pressed: true });
return Ok(Value::Null);
+1 -1
View File
@@ -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()?;
+10 -43
View File
@@ -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::{HashMap, VecDeque};
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
/// One slice of a picture job.
@@ -90,10 +90,6 @@ 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>,
@@ -179,8 +175,6 @@ 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,
@@ -289,7 +283,7 @@ impl Decoder {
if !o.frame.is_published(o.frame.mb_h() - 1) && self.out_queue.len() <= max_queue {
break;
}
let Some(o) = self.pop_out() else { break };
let Some(o) = self.out_queue.pop_front() else { break };
pics.push(make_picture(&o));
}
Ok(pics)
@@ -301,7 +295,7 @@ impl Decoder {
let mut outs = Vec::new();
self.dpb.flush(&mut outs);
self.emit(outs);
let pics = std::iter::from_fn(|| self.pop_out()).map(|o| make_picture(&o)).collect();
let pics = self.out_queue.drain(..).map(|o| make_picture(&o)).collect();
while let Some(f) = self.in_flight.pop_front() {
f.wait_complete();
}
@@ -457,24 +451,13 @@ impl Decoder {
chroma_planes: if chroma == 0 { 0 } else { 2 },
bytes_per_sample: 1,
};
// 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());
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()
);
Ok(())
}
@@ -579,24 +562,8 @@ 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.
+10 -53
View File
@@ -139,9 +139,8 @@ 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
// (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.
// (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.
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);
@@ -150,17 +149,10 @@ impl Dpb {
e.mark = RefMark::Unused;
}
self.prune();
// 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);
for _ in 0..skip {
poc_state(unused);
unused = (unused + 1) % max_frame_num;
}
poc_state(last);
unused = (last + 1) % max_frame_num;
}
let mut guard = 0;
while unused != frame_num && guard < max_frame_num {
@@ -446,15 +438,14 @@ impl Dpb {
}
}
} else {
// 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) {
while self.entries.len() >= self.capacity {
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;
}
}
}
@@ -555,37 +546,6 @@ 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();
@@ -593,9 +553,8 @@ 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 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());
// The POC state still walks every missing frame_num, in order.
assert_eq!(walk.len(), 65533);
assert_eq!(walk.first(), Some(&2));
assert_eq!(walk.last(), Some(&65534));
assert_eq!(left, vec![(65531, true), (65532, true), (65533, true), (65534, true)]);
@@ -607,9 +566,7 @@ mod tests {
short(&mut dpb, 1, 65000);
let (made, walk, left) = fill(&mut dpb, 65000, 3, 65536, 2);
assert_eq!(made, 2);
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.len(), 538);
assert_eq!(walk.first(), Some(&65001));
assert_eq!(walk.last(), Some(&2));
assert_eq!(left, vec![(1, true), (2, true)]);
-8
View File
@@ -160,14 +160,6 @@ 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);
+4 -8
View File
@@ -346,11 +346,8 @@ impl<'a> SliceDecoder<'a> {
// Neighbour helpers
// ------------------------------------------------------------------------------------------
/// 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");
/// Set up the current macroblock address and neighbour availability.
pub fn start_mb(&mut self, addr: usize) {
self.mb_addr = addr;
self.mb_x = addr % self.mb_w;
self.mb_y = addr / self.mb_w;
@@ -367,7 +364,6 @@ impl<'a> SliceDecoder<'a> {
];
self.mv_done = 0;
self.pic.mbs[addr] = MbState { slice_num: sn, ..MbState::default() };
Ok(())
}
#[inline]
@@ -1471,7 +1467,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;
@@ -1481,7 +1477,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;
+1 -64
View File
@@ -113,7 +113,6 @@ fn avcc_rejects_a_valid_picture_slice_without_starting_a_picture() {
poc_lsb: Some(0),
poc_delta: None,
wt: None,
first_mb: 0,
},
false,
);
@@ -154,8 +153,6 @@ 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 {
@@ -168,7 +165,7 @@ impl Pic {
}
fn slice_header(w: &mut BitWriter, p: &Pic, cabac: bool) {
w.write_ue(p.first_mb);
w.write_ue(0); // first_mb
w.write_ue(if p.b {
6
} else if p.is_p() {
@@ -663,63 +660,3 @@ 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);
}
+1 -3
View File
@@ -81,9 +81,7 @@ pub fn decode(bytes: &Bytes) -> Result<Pcm> {
Some(r) => r,
None => contained(|| decode_symphonia(bytes, "mkv")),
},
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()))
}
Container::Mp4 if !crate::mp4guard::tables_fit(bytes) => Err(MediaError::Corrupt("the MP4's sample tables do not fit the file".into())),
c => contained(|| decode_symphonia(bytes, c.name())),
}
}
-49
View File
@@ -12,29 +12,6 @@ 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)
}
@@ -97,29 +74,3 @@ 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");
}
}
+1 -2
View File
@@ -71,8 +71,7 @@ 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.
// 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) {
if c == Container::Mp4 && !crate::mp4guard::tables_fit(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()))
+21 -39
View File
@@ -11,14 +11,6 @@ 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;
@@ -106,18 +98,16 @@ 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`, 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>> {
/// 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();
}
let mut poly = poly.to_vec();
for i in 0..clip.len() {
// The work actually done: this edge visits the polygon as it now stands.
if poly.len() > *budget {
return None;
if poly.len().saturating_mul(clip.len() - i) > 1_000_000 {
return Vec::new();
}
*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| {
@@ -143,7 +133,7 @@ pub(crate) fn clip_polygon_within(poly: &[P], clip: &[P], budget: &mut usize) ->
break;
}
}
Some(poly)
poly
}
fn disjoint(b: [f64; 4], c: [f64; 4]) -> bool {
@@ -168,17 +158,15 @@ 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 Some(f) = clip_fill(&subs, clip, &mut budget) else { return Vec::new() };
let f = clip_fill(&subs, clip);
if !f.is_empty() {
out.push(Raw::Path { ops: f, fill, stroke: None, even_odd, region: region.clone() });
}
}
if stroke.is_some() {
let Some(s) = clip_stroke(&subs, clip, &mut budget) else { return Vec::new() };
let s = clip_stroke(&subs, clip);
if !s.is_empty() {
out.push(Raw::Path { ops: s, fill: None, stroke, even_odd, region });
}
@@ -193,8 +181,7 @@ pub(crate) fn clip_raw(raw: Raw, clip: &[P]) -> Vec<Raw> {
if disjoint(b, cb) {
return Vec::new();
}
let subs = flatten(&ops);
let f = clip_fill(&subs, clip, &mut { MAX_CLIP_WORK }).unwrap_or_default();
let f = clip_fill(&flatten(&ops), clip);
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(),
@@ -243,23 +230,20 @@ pub(crate) fn flatten(ops: &[Op]) -> Vec<(Vec<P>, bool)> {
subs
}
/// 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>> {
fn clip_fill(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
let mut ops = Vec::new();
for (pts, _) in subs {
let poly = clip_polygon_within(pts, clip, budget)?;
let poly = clip_polygon(pts, clip);
if poly.len() >= 3 {
ops.push(Op::M(poly[0]));
ops.extend(poly[1..].iter().map(|p| Op::L(*p)));
ops.push(Op::Z);
}
}
Some(ops)
ops
}
/// 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>> {
fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P]) -> Vec<Op> {
let mut ops = Vec::new();
for (pts, closed) in subs {
let mut pts = pts.clone();
@@ -268,7 +252,7 @@ fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P], budget: &mut usize) -> Optio
}
let mut last: Option<P> = None;
for w in pts.windows(2) {
let Some((a, b)) = segment(w[0], w[1], clip, budget)? else {
let Some((a, b)) = segment(w[0], w[1], clip) else {
last = None;
continue;
};
@@ -279,23 +263,21 @@ fn clip_stroke(subs: &[(Vec<P>, bool)], clip: &[P], budget: &mut usize) -> Optio
last = Some(b);
}
}
Some(ops)
ops
}
/// 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)>> {
/// Cyrus–Beck: the part of segment `a`–`b` inside convex `clip`.
fn segment(a: P, b: P, clip: &[P]) -> 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 Some(None);
return None;
}
} else {
let t = -f0 / df;
@@ -306,7 +288,7 @@ fn segment(a: P, b: P, clip: &[P], budget: &mut usize) -> Option<Option<(P, P)>>
}
}
}
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))))
(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
+2 -8
View File
@@ -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::{MAX_AREA_CLIP_WORK, clip_polygon_within, convex, flatten, ops_bbox, pts_bbox, rect_poly};
use crate::cliprect::{clip_polygon, convex, flatten, ops_bbox, pts_bbox, rect_poly};
use crate::dib::Rgba;
use crate::geom::{Op, P};
use crate::text::{ETO_CLIPPED, Font};
@@ -1170,13 +1170,7 @@ 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 => {
// 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(a), Some(b)) if a.len() >= 3 && b.len() >= 3 => Some(convex(&clip_polygon(&a, &b)).unwrap_or_default()),
_ => Some(Vec::new()),
}
}
+2 -83
View File
@@ -2071,87 +2071,6 @@ 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();
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());
let clipped = crate::cliprect::clip_polygon(&poly, &clip);
assert!(clipped.is_empty(), "excessive polygon clip product rejected");
}
+1 -13
View File
@@ -498,11 +498,6 @@ 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()
@@ -537,7 +532,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, '.' | '!' | '?') && !(c == '.' && is_decimal_point(&all, i))) {
if (title && c.is_whitespace()) || (!title && matches!(c, '.' | '!' | '?')) {
start = true;
}
}
@@ -563,13 +558,6 @@ 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"), ["Οδοσ", "ας"]);
-1
View File
@@ -314,7 +314,6 @@ pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
compat_ln_spc: None,
vert_overflow: None,
horz_overflow: None,
raw3d: None,
});
b
}
-6
View File
@@ -124,9 +124,6 @@ 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)]
@@ -190,9 +187,6 @@ 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);
}
}
}
+6 -71
View File
@@ -337,31 +337,16 @@ 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 = v * u.powi(i as i32);
let xi = x.powi((i + 1) as i32);
atb[i] += dy * xi;
for j in i..m {
ata[i][j] += xi * v * u.powi(j as i32);
ata[i][j] += xi * x.powi((j + 1) as i32);
}
}
}
@@ -370,11 +355,10 @@ fn polyfit_with_intercept(xs: &[f64], ys: &[f64], order: usize, b0: f64) -> Opti
ata[i][j] = ata[j][i];
}
}
let centred: Vec<f64> = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / su.powi(k as i32)).collect();
let q = expand_shift(&centred, xm);
let solved = solve(ata, atb)?;
let mut coeff = Vec::with_capacity(order + 1);
coeff.push(b0);
coeff.extend(q.iter().map(|c| c / sx));
coeff.extend(solved);
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
}
@@ -390,13 +374,7 @@ 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;
// 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 us: Vec<f64> = xs.iter().map(|x| x - xm).collect();
let m = order + 1;
let mut ata = vec![vec![0.0; m]; m];
let mut atb = vec![0.0; m];
@@ -415,7 +393,7 @@ fn polyfit(xs: &[f64], ys: &[f64], order: usize, intercept: Option<f64>) -> Opti
ata[i][j] = ata[j][i];
}
}
let centred: Vec<f64> = solve(ata, atb)?.iter().enumerate().map(|(k, c)| c / s.powi(k as i32)).collect();
let centred = solve(ata, atb)?;
let coeff = expand_shift(&centred, xm);
coeff.iter().all(|v| v.is_finite()).then_some(coeff)
}
@@ -593,49 +571,6 @@ 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];
+3 -31
View File
@@ -403,7 +403,7 @@ pub fn data_xml(d: &Diagram, seed: u32, drawing_rel: &str, pics: &[(usize, Strin
w.finish()
}
/// The outline of a data part: the `parOf` tree under the document point, depth first, or the data points in `ptLst` order when no `parOf` connections exist.
/// The outline of a data part: the `parOf` tree under the document point, depth first.
pub fn outline(data: &El) -> Vec<DiagramNode> {
outline_ids(data).into_iter().map(|(_, n)| n).collect()
}
@@ -429,13 +429,11 @@ pub fn node_pictures(data: &El) -> Vec<(usize, String)> {
out
}
/// Extracts diagram outline `(modelId, DiagramNode)` pairs in tree order from a `dgm:dataModel` element.
fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> {
let Some(pts) = data.child("ptLst") else { return vec![] };
let mut texts: HashMap<&str, String> = HashMap::new();
let mut assistants: std::collections::HashSet<&str> = std::collections::HashSet::new();
let mut titles: HashMap<&str, String> = HashMap::new();
let mut pt_order: Vec<&str> = Vec::new();
let mut doc: Option<&str> = None;
for pt in pts.children_named("pt").take(MAX_NODES * 4) {
let Some(id) = pt.attr("modelId") else { continue };
@@ -444,7 +442,6 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> {
ty @ ("node" | "asst") => {
let t = pt.child("t").map(paragraphs).unwrap_or_default();
texts.insert(id, t);
pt_order.push(id);
if ty == "asst" {
assistants.insert(id);
}
@@ -462,6 +459,7 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> {
_ => {}
}
}
let Some(doc) = doc else { return vec![] };
let mut kids: HashMap<&str, Vec<(i64, &str)>> = HashMap::new();
if let Some(cx) = data.child("cxnLst") {
for c in cx.children_named("cxn").take(MAX_NODES * 4) {
@@ -480,7 +478,7 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> {
let mut out = vec![];
let mut seen = std::collections::HashSet::new();
// Depth first, iteratively; a node reached twice (a cycle in a hostile file) is skipped.
let mut stack: Vec<(&str, u8)> = doc.and_then(|d| kids.get(d)).map(|v| v.iter().rev().map(|(_, id)| (*id, 0u8)).collect()).unwrap_or_default();
let mut stack: Vec<(&str, u8)> = kids.get(doc).map(|v| v.iter().rev().map(|(_, id)| (*id, 0u8)).collect()).unwrap_or_default();
while let Some((id, level)) = stack.pop() {
if out.len() >= MAX_NODES || !seen.insert(id) {
continue;
@@ -499,23 +497,6 @@ fn outline_ids(data: &El) -> Vec<(String, DiagramNode)> {
stack.extend(v.iter().rev().map(|(_, c)| (*c, level.saturating_add(1))));
}
}
if out.is_empty() && kids.is_empty() {
for id in pt_order {
if out.len() >= MAX_NODES || !seen.insert(id) {
continue;
}
out.push((
id.to_string(),
DiagramNode {
text: texts.get(id).cloned().unwrap_or_default(),
level: 0,
picture: None,
assistant: assistants.contains(id),
title: titles.get(id).cloned().unwrap_or_default(),
},
));
}
}
out
}
@@ -897,14 +878,5 @@ mod tests {
assert_eq!(colors_for(&cs_id("accent4")), "accent4");
assert_eq!(colors_for(&cs_id("")), "");
assert_eq!(style_for(&qs_id("outline")), "outline");
let unconnected = br#"<dgm:dataModel xmlns:dgm="d"><dgm:ptLst><dgm:pt modelId="0" type="doc"/><dgm:pt modelId="1" type="node"><dgm:t><a:p xmlns:a="a"><a:r><a:t>Alpha</a:t></a:r></a:p></dgm:t></dgm:pt><dgm:pt modelId="2" type="node"><dgm:t><a:p xmlns:a="a"><a:r><a:t>Beta</a:t></a:r></a:p></dgm:t></dgm:pt></dgm:ptLst><dgm:cxnLst/></dgm:dataModel>"#;
let unconnected_doc = crate::xml::parse(unconnected).unwrap();
assert_eq!(outline(&unconnected_doc.root), nodes(&[("Alpha", 0), ("Beta", 0)]));
// When parOf connections exist but no root (type="doc") reaches them, do not flatten the hierarchy to level 0.
let unrooted_hierarchy = br#"<dgm:dataModel xmlns:dgm="d"><dgm:ptLst><dgm:pt modelId="1" type="node"><dgm:t><a:p xmlns:a="a"><a:r><a:t>Parent</a:t></a:r></a:p></dgm:t></dgm:pt><dgm:pt modelId="2" type="node"><dgm:t><a:p xmlns:a="a"><a:r><a:t>Child</a:t></a:r></a:p></dgm:t></dgm:pt></dgm:ptLst><dgm:cxnLst><dgm:cxn srcId="1" destId="2"/></dgm:cxnLst></dgm:dataModel>"#;
let unrooted_doc = crate::xml::parse(unrooted_hierarchy).unwrap();
assert!(outline(&unrooted_doc.root).is_empty());
}
}
+1 -13
View File
@@ -629,19 +629,7 @@ 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 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 body = tx.child("bodyPr").map(body_pr).unwrap_or_default();
let list_style = tx.child("lstStyle").map(|l| list_style(imp, part, l)).unwrap_or_default();
let paragraphs = tx.children_named("p").take(100_000).map(|p| paragraph(imp, part, p)).collect();
TextBody { body, list_style, paragraphs }
-6
View File
@@ -395,12 +395,6 @@ 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();
+1 -20
View File
@@ -461,21 +461,6 @@ 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))
@@ -496,8 +481,7 @@ 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()));
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() {
if warp.is_none() && b.autofit.is_none() {
w.empty("a:bodyPr", a);
return;
}
@@ -531,9 +515,6 @@ pub fn body_pr(w: &mut W, b: &BodyProps) {
}
None => {}
}
if let Some(raw) = raw3d {
w.raw(raw);
}
w.close("a:bodyPr");
}
+4 -29
View File
@@ -334,20 +334,7 @@ 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 {
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);
}
dml::effects(w, fx);
}
w.close("p:grpSpPr");
shapes(w, x, o, cx, children, depth + 1);
@@ -542,22 +529,10 @@ fn diagram(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, d: &dec
let (sx, sy) = (frame.w / child.w.max(1e-6), frame.h / child.h.max(1e-6));
let mut k = 0;
let mut flat = vec![];
walk(children, &mut |c, _| match &c.kind {
ShapeKind::Shape => flat.push(c.clone()),
ShapeKind::Connector { .. } => {
let mut sc = c.clone();
if sc.geom.preset_name() == Some("rect") {
sc.geom = Geom::preset("line");
}
flat.push(sc);
walk(children, &mut |c, _| {
if matches!(c.kind, ShapeKind::Shape) {
flat.push(c.clone());
}
ShapeKind::Picture { fill } => {
let mut sc = c.clone();
sc.kind = ShapeKind::Shape;
sc.fill = Some(deckcraft_model::style::Fill::Picture(fill.clone()));
flat.push(sc);
}
_ => {}
});
for mut c in flat.into_iter().take(10_000) {
if let Some(xf) = &mut c.xfrm {
-61
View File
@@ -241,67 +241,6 @@ 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);
-8
View File
@@ -137,14 +137,6 @@ 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>) {
+12 -83
View File
@@ -13,11 +13,13 @@ 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 }
Self { updater: Updater::new(&crate::UPDATE_APP, VERSION), enabled: false, check_requested: false, result_open: false }
}
}
@@ -29,15 +31,10 @@ 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) {
@@ -49,31 +46,21 @@ pub(crate) fn tick(app: &mut SlideApp, ctx: &egui::Context) {
}
pub(crate) fn show(app: &mut SlideApp, ctx: &egui::Context) {
// An installation starts, and a ready update asks the app to close, only while nothing is unsaved.
// The restart helper is scheduled before the app closes, so no update starts over unsaved work.
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; `on_exit` starts the restart helper.
// The app's close path asks about unsaved work; the restart helper relaunches once the process exits.
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 !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 => {}
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 => {}
}
}
}
@@ -90,62 +77,4 @@ 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);
}
}
+1 -1
View File
@@ -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, "Start", s.unwrap_or(""), 112.0, cur.is_some(), |ui| {
dropdown(ui, "astart", 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}));
+2 -2
View File
@@ -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, "Font size", &size_text, 52.0, en, |ui| {
dropdown(ui, "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}));
-50
View File
@@ -1223,56 +1223,6 @@ 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() {
+7 -18
View File
@@ -330,9 +330,7 @@ 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`.
/// `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 {
pub fn dropdown(ui: &mut Ui, id: &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);
@@ -352,10 +350,8 @@ pub fn dropdown(ui: &mut Ui, label: &str, current: &str, width: f32, enabled: bo
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 {
current_text_value: (!current.is_empty()).then(|| current.to_string()),
..egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, label)
});
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, enabled, if current.is_empty() { id } else { current }));
let _ = id;
egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| {
egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items);
});
@@ -746,26 +742,19 @@ mod tests {
}
/// The custom ribbon dropdown is a real combo box in the accessibility tree, named by its
/// 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).
/// current value (0.37's strict unnamed-input check would otherwise reject it).
#[test]
fn the_dropdown_is_named_by_its_purpose_and_carries_its_value() {
fn the_dropdown_has_a_combo_box_role_and_a_name() {
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, "Font size");
assert_eq!(combo.value().as_deref(), Some("18 pt"));
let combo = harness.get_by_role_and_label(Role::ComboBox, "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
-27
View File
@@ -1,27 +0,0 @@
//! Release and source Flatpaks must allow release checks and host update helpers.
fn finish_args(manifest: &str) -> Vec<&str> {
manifest
.split("finish-args:")
.nth(1)
.unwrap_or_default()
.lines()
.take_while(|line| line.is_empty() || line.starts_with(' ') || line.starts_with('#'))
.filter_map(|line| line.trim().strip_prefix("- "))
.map(|arg| arg.trim_matches(['\'', '"']))
.collect()
}
#[test]
fn flatpak_update_permissions() {
let manifests = [
include_str!("../../../packaging/linux/flatpak/ai.storyteller.deckcraft.bundle.yml"),
include_str!("../../../packaging/linux/flatpak/ai.storyteller.deckcraft.yml"),
];
let args = manifests.map(finish_args);
assert_eq!(args[0], args[1], "source and release permissions must agree");
for permissions in args {
for required in ["--share=network", "--talk-name=org.freedesktop.Flatpak"] {
assert!(permissions.contains(&required), "missing update permission: {required}");
}
}
}
+1 -1
View File
@@ -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. `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 |
| 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 |
| 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 |
@@ -17,8 +17,6 @@ runtime-version: '25.08'
sdk: org.freedesktop.Sdk
command: deckcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
@@ -18,8 +18,6 @@ sdk-extensions:
- org.freedesktop.Sdk.Extension.rust-stable
command: deckcraft
finish-args:
# The shared updater inspects and updates this installation via flatpak-spawn --host.
- --talk-name=org.freedesktop.Flatpak
- --filesystem=xdg-data/ArtCraft/versions:ro
# Display: Wayland first; X11 only when no Wayland compositor is running.
- --socket=wayland
-12
View File
@@ -136,18 +136,6 @@ 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
+7 -23
View File
@@ -61,23 +61,16 @@ 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. 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.
/// `*` matches any run of characters except `/`; everything else literally.
fn glob(pat: &str, s: &str) -> bool {
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 };
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..]))
}
row = next;
}
row[t.len()]
}
/// Splits `git ls-files -z` output into real file names (no display quoting of Unicode or special characters).
@@ -127,15 +120,6 @@ 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());
+3 -4
View File
@@ -6,8 +6,7 @@ use std::process::Command;
use serde_json::Value;
const SHARED_REPO: &str = "https://github.com/storytold/craft-libs";
const SHARED_REV: &str = "6e03f65bf45181db77ba6229716e6624db9eb25b";
const SHARED_REV: &str = "c5a1cbfb5f9049f9270955caffb5bee5997aebf7";
const REGISTRY: &str = "registry+https://github.com/rust-lang/crates.io-index";
pub fn run(root: &Path) -> Result<(), String> {
@@ -71,7 +70,7 @@ fn validate(metadata: &Value) -> Result<(), String> {
}
matches.first().copied().ok_or_else(|| format!("missing {name}"))
};
let source = format!("git+{SHARED_REPO}?rev={SHARED_REV}#{SHARED_REV}");
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
for (name, version, expected_source, expected_features) in [
("craft-pdf", "0.1.0", source.as_str(), vec!["write", "write-simple-text"]),
("krilla", "0.8.2", source.as_str(), vec!["raster-images", "rayon", "simple-text"]),
@@ -144,7 +143,7 @@ mod tests {
use serde_json::json;
fn metadata() -> Value {
let source = format!("git+{SHARED_REPO}?rev={SHARED_REV}#{SHARED_REV}");
let source = format!("git+https://github.com/storytold/craft-libs?rev={SHARED_REV}#{SHARED_REV}");
json!({
"packages": [
{"id":"app", "name":"deckcraft-pdf"},