Compare commits

...
23 Commits
Author SHA1 Message Date
Dylan M. Taylor 0623b43032 Merge pull request #1985 from dotCooCoo/chore/craft-update-1b34eba41
Updates: take craft-update from craft-libs main at 1b34eba41
2026-11-08 22:55:36 -05:00
dotCooCoo 5ee82b4e45 Updates: take craft-update from craft-libs main at 1b34eba41 2026-11-08 19:43:26 -08:00
Dylan M. Taylor 61423ea35b Merge pull request #1980 from dotCooCoo/chore/craft-update-updater-fixes
Updates: the updater reports installing, ready, installed and failed, and installs at exit
2026-11-08 22:12:17 -05:00
aiongg c4674f24fb Merge pull request #1982 from Silvertree2010/fix/h13151-polynomial-trendline-fit-fails-f
Make polynomial trendline fits independent of x scale and offset
2026-11-08 15:46:38 -05:00
NetterSilverandClaude Opus 5.5 4ba9a1a5d9 Make polynomial trendline fits independent of x scale and offset
Found while testing crates/model/src/trendline.rs:~419 (solve, used by polyfit)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 21:33:22 +01:00
dotCooCoo 3504a9da42 xtask: pdf-deps expects craft-pdf and krilla from the moved craft-libs pin
The pdf-deps check compared craft-pdf and krilla against storytold/craft-libs
at c5a1cbfb, so the gate stopped at that step once the pin moved to
dotCooCoo/craft-libs c7c84230. The expected repository and revision now
match the pin in Cargo.toml.
2026-11-08 12:11:03 -08:00
dotCooCoo ccab980657 Updates: the updater reports installing, ready, installed and failed, and installs at exit
craft-media, craft-pdf, craft-ui, craft-update and krilla move from craft-libs
c5a1cbfb to c7c84230, which carries storytold/craft-libs#687 and #696.

The check result window now shows whenever the updater's status is not
idle: after Help > Check for Updates, and at the first start after an
update, to say that it installed or why it failed. Restart to Update in
that window asks the app to close once nothing is unsaved. on_exit starts
the helper of a ready update, since on macOS it ends the process before
the app is dropped.
2026-11-08 11:30:36 -08:00
aiongg 48e2203123 Merge pull request #1969 from Silvertree2010/fix/h11403-sentence-case-capitalizes-the-wo
Don't capitalize the word after a decimal point in sentence case
2026-11-08 03:35:42 -05:00
NetterSilverandClaude Haiku 5.5 76bd26cca0 Don't capitalize the word after a decimal point in sentence case
Found while testing crates/model/src/edit.rs:~505 (change_case_parts, sentence branch)

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 08:26:05 +00:00
Nasim 02fbb6a976 Notes: pin that Paste reaches the focused speaker-notes field, bare and inside the ⌘V batch (refs #1855) (#1856) 2026-11-08 01:59:22 -05:00
aiongg c16db2ef3e Merge pull request #1943 from neilcawse/fix/pptx-body-and-group-3d
pptx: preserve 3-D scene and extrusion XML on text bodies and groups
2026-11-08 01:18:16 -05:00
aiongg 809c44654d Apply review fixes
- crates/pptx/src/write/shapes.rs
2026-11-08 05:59:15 +00:00
aiongg 220b52dfdb Apply review fixes
- docs/file-format-parity.md
2026-11-08 05:43:41 +00:00
Neil Cawse 424f575ca5 fix(pptx): bound and validate body/group raw3d XML on read/write and expand 3-D tests 2026-11-08 05:26:33 +00:00
Neil Cawse aefd54d28e pptx: preserve 3-D scene and extrusion XML on text bodies and groups 2026-11-08 05:24:59 +00:00
Nasim 25809faf1a Accessibility: name ribbon drop-downs by purpose and expose the shown text as their value (#1956) (#1963) 2026-11-08 00:18:14 -05:00
aiongg fc35ad67c6 Merge pull request #1950 from Silvertree2010/fix/h9583-format-paragraph-silently-left-a
Fix format.paragraph silently left-aligning known align names
2026-11-07 15:10:55 -05:00
NetterSilverandClaude Haiku 5.5 57780f9e61 Fix format.paragraph silently left-aligning known align names
Found while testing crates/engine/src/cmd/format/paragraph.rs:123-129

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 20:00:33 +00:00
FredApps 0627fb2589 Merge pull request #1932 from helenkwok/web/page-api
deckcraft-web: window.deckcraft, the page command API from craft-web-api
2026-11-07 15:37:54 +01:00
aiongg 170c5591b5 Merge pull request #1934 from Silvertree2010/fix/h7742-shape-adjust-with-an-invalid-neg
Reject invalid index in shape.adjust and shape.quickStyle
2026-11-07 03:34:42 -05:00
NetterSilverandClaude Haiku 5.5 a883d6b2d9 Reject invalid index in shape.adjust and shape.quickStyle
Found while testing crates/engine/src/cmd/shape/appearance.rs:8 (adjust, index via usize_param); same pattern in quick_style at :559

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