Compare commits

..
16 Commits
Author SHA1 Message Date
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
24 changed files with 464 additions and 59 deletions
Generated
+41 -29
View File
@@ -935,10 +935,21 @@ dependencies = [
"zip 9.1.0",
]
[[package]]
name = "craft-web-api"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"js-sys",
"serde_json",
"wasm-bindgen",
"wasm-bindgen-futures",
]
[[package]]
name = "craft-web-host"
version = "0.1.0"
source = "git+https://github.com/storytold/craft-libs?rev=2e2312ddaf21bc3d85cd3395d0955c7d239306f6#2e2312ddaf21bc3d85cd3395d0955c7d239306f6"
source = "git+https://github.com/storytold/craft-libs?rev=fd5e2feff6ea25bed371a99cc732b3968d621505#fd5e2feff6ea25bed371a99cc732b3968d621505"
dependencies = [
"wasm-bindgen",
"web-sys",
@@ -1017,7 +1028,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"arboard",
"chrono",
@@ -1039,7 +1050,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -1048,7 +1059,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1057,14 +1068,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-cli"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -1074,14 +1085,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"chrono",
"deckcraft-anim",
@@ -1107,7 +1118,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -1119,7 +1130,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -1130,7 +1141,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"kurbo",
"serde",
@@ -1138,7 +1149,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1147,7 +1158,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1156,21 +1167,21 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1180,7 +1191,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1198,7 +1209,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"image",
"kurbo",
@@ -1207,7 +1218,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1219,15 +1230,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
version = "0.16.0"
version = "0.15.0"
[[package]]
name = "deckcraft-opus"
version = "0.16.0"
version = "0.15.0"
[[package]]
name = "deckcraft-pdf"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1245,7 +1256,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1261,7 +1272,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1280,7 +1291,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1294,7 +1305,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -1320,7 +1331,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1329,8 +1340,9 @@ dependencies = [
[[package]]
name = "deckcraft-web"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"craft-web-api",
"craft-web-host",
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -6313,7 +6325,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
version = "0.16.0"
version = "0.15.0"
dependencies = [
"serde_json",
]
+1 -1
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
version = "0.16.0"
version = "0.15.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
+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 -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);
}
}
}
+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);
+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