Compare commits

..
Author SHA1 Message Date
Dylan M. Taylor 875029b502 Repair: bound the notes page size like a slide 2026-11-04 03:24:13 +00:00
58 changed files with 162 additions and 1877 deletions
-1
View File
@@ -45,4 +45,3 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
Generated
+6 -35
View File
@@ -1275,7 +1275,6 @@ dependencies = [
"deckcraft-render",
"deckcraft-text",
"egui",
"egui_extras",
"egui_kittest",
"log",
"serde",
@@ -1521,19 +1520,6 @@ dependencies = [
"winit",
]
[[package]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
dependencies = [
"ahash",
"egui",
"enum-map",
"itertools",
"log",
"profiling",
]
[[package]]
name = "egui_glow"
version = "0.37.0"
@@ -1601,26 +1587,6 @@ version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
[[package]]
name = "enum-map"
version = "3.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06"
dependencies = [
"enum-map-derive",
]
[[package]]
name = "enum-map-derive"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -3521,7 +3487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.45.0",
]
[[package]]
@@ -6545,6 +6511,11 @@ name = "egui_demo_lib"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_extras"
version = "0.37.0"
source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a"
[[patch.unused]]
name = "egui_inspection"
version = "0.37.0"
-7
View File
@@ -2,9 +2,6 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-11-04 · **Last updated:** 2026-11-04 · **Change:** trivial (Ogg Opus recovery includes trailing packets without granules in its reported duration; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (progress log: web `?host=parent` bridge opens and saves through a same-origin framing page; scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -153,8 +150,6 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-11-04 | Ogg Opus recovery keeps trailing packets without granules in the total duration, matching their recovered positions; valid end trimming stays covered |
| 2026-11-03 | Web: the `?host=parent` bridge (as in GridCraft, WordCraft and PdfCraft) lets a same-origin page that frames DeckCraft send a presentation and store its saves; Save writes an opened .pptx back as .pptx under its own name, as on the desktop |
| 2026-11-02 | Native and PPTX saves retain optional table right-to-left direction, including explicit false and omission; two-save round-trip tests keep labeled columns in order |
| 2026-11-02 | Text runs preserve alternate-language metadata independently from the main language through native and PPTX saves |
| 2026-11-02 | Picture compression measures displayed dimensions through nested group transforms; checklist score unchanged |
@@ -335,10 +330,8 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-11-04 | trivial | Ogg Opus recovery duration includes its recovered tail; parity scores unchanged |
| 2026-11-02 | trivial | Progress log: run alternate-language metadata round-trips through native and PPTX formats; product parity scores unchanged |
| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged |
| 2026-11-03 | trivial | Progress log: web `?host=parent` bridge to a framing page; scores unchanged |
| 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged |
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
+1 -1
View File
@@ -27,4 +27,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget", "UrlSearchParams"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] }
-192
View File
@@ -1,192 +0,0 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! DeckCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
//! same messages as GridCraft's, WordCraft's and PdfCraft's bridges; each is a plain object with a
//! `type`:
//!
//! - `deckcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `deckcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`, name a bare file name. It opens like a file picked with File › Open on the
//! desktop: a presentation sent with its name (.pptx, .potx, .ppsx, .deckcraft) has that name
//! as its file, so Save goes back to it; an outline (.txt, .md, .rtf), a file without a name and
//! a damaged file open untitled.
//! - `deckcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file DeckCraft would otherwise download. The parent stores it and reports failures
//! itself. `saveAs` says whether the file becomes the presentation's file:
//! - Save: `false`. It writes the opened file under its own name and format (a .pptx stays a
//! .pptx), as the desktop app does. A name the parent never sent (an untitled presentation, an
//! outline, a file sent without a name, a repaired copy of a damaged file, a file the user
//! picked or dropped) is a new file: `true`, as for Save As, named `<title>.deckcraft` as
//! Save suggests on the desktop.
//! - Save As (File › Save As as .pptx or .deckcraft, Ctrl+Shift+S): `true`. The presentation is
//! now that file; the next Save sends its name with `false`.
//! - Export (PDF, pictures, outline, GIF, handouts, a Save As type that isn't a presentation)
//! and Print: `false`. They are copies; the presentation's file stays the same.
//! - `deckcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `deckcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the app
//! starts with an empty presentation instead of the sample, which gives way to the first file the
//! parent sends; the parent guards leaving the page, and `?author=` names the user for comments.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use deckcraft_ui_egui::{Services, SlideApp};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
/// The longest user name taken from `?author=` (the preferences' own limit).
const MAX_AUTHOR_CHARS: usize = 256;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
/// Files the parent sent (the name, if it sent one), waiting for the next frame.
opened: Rc<RefCell<Vec<(Option<String>, Vec<u8>)>>>,
/// The unsaved-changes state last reported to the parent.
reported: Rc<Cell<Option<bool>>>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
/// on another origin: messages only go to this origin, so the browser would drop every save
/// without an error while the presentation showed as saved.
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
if origin == "null" {
return None;
}
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin, opened: Rc::default(), reported: Rc::default() })
}
/// Hand saved and exported files to the parent instead of downloading them, and hear the
/// files it sends.
pub fn connect(&self, services: &mut Services, ctx: egui::Context) {
self.listen(ctx);
let bridge = self.clone();
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
// Exports and Print are copies.
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
if let Err(e) = bridge.post_save(name, bytes, false) {
log::error!("couldn't hand {name} to the page: {e}");
}
}));
}
/// `?author=`: the host's name for the user signs new comments.
pub fn set_author(app: &mut SlideApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.prefs.author = author;
}
}
pub fn post_ready(&self) {
self.send(&[("type", "deckcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "deckcraft:failed".into()), ("error", error.into())]);
}
/// Before each frame: open the files the parent sent.
pub fn open_files(&self, app: &mut SlideApp) {
let opened = std::mem::take(&mut *self.opened.borrow_mut());
for (name, bytes) in opened {
app.open_from_host(name.as_deref(), &bytes);
}
}
/// After each frame: tell the parent when unsaved changes appear or go away.
pub fn report_dirty(&self, app: &SlideApp) {
let dirty = app.session.documents().iter().any(|d| d.is_dirty());
if self.reported.get() != Some(dirty) && self.send(&[("type", "deckcraft:dirty".into()), ("dirty", dirty.into())]) {
self.reported.set(Some(dirty));
}
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[
("type", "deckcraft:save".into()),
("name", name.as_str().into()),
("saveAs", save_as.into()),
("bytes", buffer.clone().into()),
])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
/// Hear `deckcraft:open` from the parent and queue the file for the next frame.
fn listen(&self, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let opened = self.opened.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
}
let data = event.data();
if string(&data, "type").as_deref() != Some("deckcraft:open") {
return;
}
// No name is no file: the presentation opens untitled, never under an invented name.
let name = string(&data, "name").filter(|n| !n.trim().is_empty());
let Some(bytes) = bytes(&data) else { return };
opened.borrow_mut().push((name, bytes));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
}
}
+1 -6
View File
@@ -9,20 +9,15 @@
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
//! asynchronously through `Services::inbox`, applied to the document, slide and operation
//! that started the pick (a dismissed picker or a closed document drops the file);
//! - Save and Export trigger a browser download (or go to the framing page with `?host=parent`);
//! - Save and Export trigger a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
//! - preferences are not persisted.
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?blank` starts with an
//! empty presentation instead of the sample deck; `?show` starts the slide show.
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! presentation to open and stores what DeckCraft saves (`host`), and `&author=` names the user
//! for comments.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
mod presenter;
#[cfg(target_arch = "wasm32")]
-3
View File
@@ -251,9 +251,6 @@ impl Popup {
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
data.next = previous.next.clone();
}
if data.slides.is_none() {
data.slides = previous.slides.clone();
}
}
*target.borrow_mut() = Some(data);
*received.borrow_mut() = js_sys::Date::now();
+7 -54
View File
@@ -24,16 +24,8 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing DeckCraft opens presentations in it and
// stores what it saves (`host`). The presenter popup is never the framed page.
let host = query_param("host").as_deref() == Some("parent") && crate::presenter::token().is_none();
let bridge = if host { crate::host::Bridge::new() } else { None };
if host && bridge.is_none() {
log::warn!("?host=parent needs a framing page on the same origin; saves download instead");
}
let app_bridge = bridge.clone();
let mut options = eframe::WebOptions::default();
if query_has("webgl")
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
@@ -52,42 +44,22 @@ pub fn start() {
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
}
let inbox: Inbox = Inbox::default();
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, cc.egui_ctx.clone());
}
let mut app = SlideApp::new(Session::new(), services);
// In host mode the parent sends the presentation to edit; no sample meanwhile.
if app_bridge.is_some() {
if let Err(e) = app.start_for_host() {
log::error!("new presentation: {e}");
}
} else if !query_has("blank") {
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let q = query();
if !q.contains("blank") {
if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
log::error!("sample deck: {e}");
}
} else if let Err(e) = app.run("file.new", serde_json::json!({})) {
log::error!("new presentation: {e}");
}
if query_has("show") {
if q.contains("show") {
app.start_show(0, false);
}
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
crate::host::Bridge::set_author(&mut app, &author);
}
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
Ok(Box::new(WebShell { app, inbox }))
}),
)
.await;
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -101,36 +73,17 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
/// Whether the URL has the flag `key` (`?show`, `&blank=1`). A whole parameter, never a substring:
/// `?author=` is free text.
fn query_has(key: &str) -> bool {
web_sys::UrlSearchParams::new_with_str(&query()).is_ok_and(|q| q.has(key))
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
struct WebShell {
app: SlideApp,
inbox: Inbox,
/// The parent page that keeps the files (`?host=parent`).
bridge: Option<crate::host::Bridge>,
}
impl eframe::App for WebShell {
/// Delegate one eframe logic pass to the wrapped application, opening the files the parent
/// page sent first and telling it about unsaved changes after.
/// Delegate one eframe logic pass to the wrapped application.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if let Some(bridge) = &self.bridge {
bridge.open_files(&mut self.app);
}
self.app.logic(ctx);
if let Some(bridge) = &self.bridge {
bridge.report_dirty(&self.app);
}
if self.app.show.is_none()
&& let Some(host) = self.app.services.presenter.as_mut()
{
+3 -13
View File
@@ -253,15 +253,8 @@ pub struct LoadedFile {
#[cfg(not(target_arch = "wasm32"))]
pub fn read_presentation_file(path: &str) -> Result<LoadedFile> {
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
loaded_file(path, &bytes)
}
/// Parse `bytes` as the presentation file `path`, as [`read_presentation_file`] does after
/// reading it. The web host bridge uses it for a file its page sent, which then saves back to
/// that name like a file opened on the desktop.
pub fn loaded_file(path: &str, bytes: &[u8]) -> Result<LoadedFile> {
let (doc, damaged) = open_checked(path, bytes)?;
let native = deckcraft_format::sniff(bytes) || deckcraft_pptx::sniff(bytes);
let (doc, damaged) = open_checked(path, &bytes)?;
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
Ok(LoadedFile { path: path.to_string(), doc, native, damaged })
}
@@ -477,10 +470,7 @@ pub fn format_for_path(path: &str) -> &'static str {
}
}
/// The active presentation is saved as it is now: no unsaved changes, and `path` (when given)
/// becomes its file. `file.save` calls it after writing; the web host bridge calls it once the
/// page has the bytes.
pub fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
let st = s.doc_mut()?;
st.saved_doc = st.doc.clone();
if path.is_some() {
+1 -35
View File
@@ -219,7 +219,7 @@ fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result<Value> {
let cmd = if forward { "comment.next" } else { "comment.prev" };
let (threads, slide, index) = {
let st = s.doc()?;
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), super::opt_usize_param(p, "index", cmd)?)
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index"))
};
if threads.is_empty() {
return Err(bad(cmd, "no comments"));
@@ -862,40 +862,6 @@ mod tests {
assert_eq!(here["text"], "on first");
}
#[test]
fn malformed_comment_navigation_index_does_not_change_selection() {
let mut s = Session::with_new();
s.execute("comment.add", &json!({"text": "first"})).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s.execute("comment.add", &json!({"text": "second"})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
let shape = s.doc().unwrap().doc.slides.first().and_then(|slide| slide.shapes.first()).map(|shape| shape.id).unwrap();
s.doc_mut().unwrap().selection.shapes = vec![shape];
for (command, value) in [
("comment.next", json!(-1)),
("comment.next", json!(1.5)),
("comment.next", json!("bad")),
("comment.prev", json!(-1)),
("comment.prev", json!(1.5)),
("comment.prev", json!("bad")),
] {
let before = s.doc().unwrap().selection.clone();
let error = s.execute(command, &json!({"index": value})).unwrap_err();
assert!(error.to_string().contains("valid index"), "{command} {value}: {error}");
assert_eq!(s.doc().unwrap().selection, before, "{command} {value}");
}
let zero = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap();
assert_eq!(zero["slide"], 1);
assert_eq!(zero["index"], 0);
s.execute("slide.go", &json!({"index": 0})).unwrap();
let omitted = s.execute("comment.next", &json!({})).unwrap();
assert_eq!(omitted["slide"], 0);
assert_eq!(omitted["index"], 0);
}
#[test]
fn timestamps_are_iso_utc() {
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
+1 -1
View File
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Fill",
["Shape Format", "Shape Styles"],
None,
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool (false keeps linear and radial gradients upright on the slide; rectangular and path gradients turn with the shape when drawn)} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
has_selection,
fill
),
+1 -2
View File
@@ -74,8 +74,7 @@ pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
/// rotateWithShape?}`. The renderer keeps only linear and radial gradients upright when
/// `rotateWithShape` is false; rectangular and path gradients turn with the shape.
/// rotateWithShape?}`.
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
let stops: Vec<GradientStop> = v
.get("stops")?
+4 -15
View File
@@ -3,9 +3,8 @@ use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad
pub(super) fn sites(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
let st = s.doc()?;
// In slide space, as shape.connect uses them, for a shape inside a group too.
let v = crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
Ok(json!(v.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
}
pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
@@ -13,13 +12,8 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("shape.connect", "missing `from` / `to`"));
};
let st = s.doc()?;
// Anywhere in the slide's tree: a shape inside a group takes a connector too (PowerPoint
// glues to grouped shapes), its sites where the group draws them.
let site_list = |id| {
crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id)
.ok_or_else(|| bad("shape.connect", format!("no shape {} on the current slide", id.0)))
};
let (a, b) = (site_list(from)?, site_list(to)?);
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
let (a, b) = (site_list(from), site_list(to));
if a.is_empty() || b.is_empty() {
return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));
}
@@ -41,11 +35,6 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
};
let (p0, p1) = (a[ia], b[ib]);
let id = match id_param(p, "id", "shape.connect")? {
// The ends are in slide space and rerouting follows the slide's own connectors, so a line
// inside a group would be drawn in the group's space and never follow its shapes.
Some(id) if !st.shapes().iter().any(|sh| sh.id == id) && st.shape(id).is_some_and(|sh| sh.is_line()) => {
return Err(bad("shape.connect", "`id` names a line inside a group; ungroup it first, or leave `id` out for a new connector"));
}
Some(id) => id,
None => {
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
-7
View File
@@ -191,13 +191,6 @@ pub(super) fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn flip(s: &mut Session, p: &Value) -> Result<Value> {
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
edit_geometry(s, p, "shape.flip", |sh| {
// PowerPoint greys Flip out for a SmartArt graphic (SmartArt Design > Right to Left mirrors it),
// also inside a group: ungrouping would hand the flip to the graphic.
let mut graphic = false;
deckcraft_model::walk(std::slice::from_ref(&*sh), &mut |x, _| graphic |= x.diagram.is_some());
if graphic {
return Err(bad("shape.flip", "Flip is not available for a SmartArt graphic; use `smartArt.layout` to change how it is laid out"));
}
if let Some(x) = sh.xfrm.as_mut() {
if v {
x.flip_v = !x.flip_v;
+21 -43
View File
@@ -253,10 +253,9 @@ fn cell_param(p: &Value, key: &str, cmd: &str) -> Result<Option<(usize, usize)>>
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
// Exactly [row, col]: an entry with more is as malformed as one with less.
let idx = |x: &Value| x.as_u64().and_then(|x| usize::try_from(x).ok());
match v.as_array().map(Vec::as_slice) {
Some([r, c]) if let (Some(r), Some(c)) = (idx(r), idx(c)) => Ok(Some((r, c))),
let idx = |i: usize| v.as_array().and_then(|a| a.get(i)).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
match (idx(0), idx(1)) {
(Some(r), Some(c)) => Ok(Some((r, c))),
_ => Err(bad(cmd, format!("`{key}` is not a valid cell: {v}"))),
}
}
@@ -390,54 +389,33 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// At most this many `cells` in one table cell command.
const MAX_CELLS: usize = 100_000;
/// The cells a command acts on: `cells`, else the selected cell range, else the cell holding the
/// caret, else every cell.
fn target_cells(s: &Session, p: &Value, cmd: &str) -> Result<impl Fn(&Table) -> Result<Vec<(usize, usize)>> + use<>> {
// `cells` is a list of [row, col]: anything else, or an entry that is not one, is refused, never
// dropped or read as absent (which would act on the selection or the whole table).
let cells: Option<Vec<(usize, usize)>> = match p.get("cells") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) if a.len() > MAX_CELLS => {
return Err(bad(cmd, format!("`cells` lists {} cells; at most {MAX_CELLS}", a.len())));
}
Some(Value::Array(a)) => Some(
a.iter()
.map(|c| {
cell_param(&json!({ "cell": c }), "cell", cmd)?.ok_or_else(|| bad(cmd, format!("`cells` entry {c} is not a [row, col] cell")))
})
.collect::<Result<_>>()?,
),
Some(v) => return Err(bad(cmd, format!("`cells` is a list of [row, col] cells, not {v}"))),
};
fn target_cells(s: &Session, p: &Value) -> Result<impl Fn(&Table) -> Vec<(usize, usize)> + use<>> {
let cells: Option<Vec<(usize, usize)>> = p
.get("cells")
.and_then(Value::as_array)
.map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
let st = s.doc()?;
// A selected range grows over the merged cells it touches; with only a caret, the caret's cell is used.
let sel = st.selection.cells.map(|(_, a, b)| (a, b, true)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c, false)));
let cmd = cmd.to_string();
Ok(move |t: &Table| match (&cells, sel) {
// Each listed cell must be in the table, so a command never reports success for a cell
// it could not edit.
(Some(c), _) => match c.iter().find(|(r, c)| *r >= t.n_rows() || *c >= t.n_cols()) {
Some((r, c)) => Err(bad(&cmd, format!("no cell [{r}, {c}]: the table has {} rows and {} columns", t.n_rows(), t.n_cols()))),
None => Ok(c.clone()),
},
(Some(c), _) => c.clone(),
(None, Some((a, b, range))) => {
let (a, b) = if range { cell_block(t, a, b) } else { (a, b) };
// The selection can name cells outside the table; only walk the ones that exist.
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
Ok((a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect())
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
}
_ => Ok((0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect()),
_ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(),
})
}
fn cell_fill(s: &mut Session, p: &Value) -> Result<Value> {
let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) };
let targets = target_cells(s, p, "table.cellFill")?;
let targets = target_cells(s, p)?;
with_table(s, p, "table.cellFill", move |t| {
for (r, c) in targets(t)? {
for (r, c) in targets(t) {
if let Some(cell) = t.cell_mut(r, c) {
cell.fill.clone_from(&fill);
}
@@ -469,9 +447,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
}
// "none" clears to no line, so the table style's own lines don't show through either.
let line = if which == "none" { Line::none() } else { line };
let targets = target_cells(s, p, "table.borders")?;
let targets = target_cells(s, p)?;
with_table(s, p, "table.borders", move |t| {
let list = targets(t)?;
let list = targets(t);
let (Some(r0), Some(r1), Some(c0), Some(c1)) =
(list.iter().map(|x| x.0).min(), list.iter().map(|x| x.0).max(), list.iter().map(|x| x.1).min(), list.iter().map(|x| x.1).max())
else {
@@ -485,9 +463,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
}
let n = match side {
LEFT => c.checked_sub(1).map(|c| (r, c, RIGHT)),
RIGHT => c.checked_add(1).filter(|c| *c < cols).map(|c| (r, c, LEFT)),
RIGHT => (c + 1 < cols).then_some((r, c + 1, LEFT)),
TOP => r.checked_sub(1).map(|r| (r, c, BOTTOM)),
_ => r.checked_add(1).filter(|r| *r < rows).map(|r| (r, c, TOP)),
_ => (r + 1 < rows).then_some((r + 1, c, TOP)),
};
if let Some((r, c, side)) = n
&& let Some(cell) = t.cell_mut(r, c)
@@ -545,9 +523,9 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
};
let side = |k: &str, i: usize| f64_param(p, k).filter(|v| v.is_finite()).map_or(base[i], |v| v.clamp(0.0, 400.0));
let m = [side("left", 0), side("right", 1), side("top", 2), side("bottom", 3)];
let targets = target_cells(s, p, "table.cellMargins")?;
let targets = target_cells(s, p)?;
with_table(s, p, "table.cellMargins", move |t| {
for (r, c) in targets(t)? {
for (r, c) in targets(t) {
if let Some(cell) = t.cell_mut(r, c) {
cell.margins = Some(m);
}
@@ -582,9 +560,9 @@ fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
}),
};
let targets = target_cells(s, p, "table.cellAlign")?;
let targets = target_cells(s, p)?;
with_table(s, p, "table.cellAlign", move |t| {
for (r, c) in targets(t)? {
for (r, c) in targets(t) {
if let Some(cell) = t.cell_mut(r, c) {
if anchor.is_some() {
cell.anchor = anchor;
+5 -44
View File
@@ -1,21 +1,14 @@
//! Connectors glued to shapes: connection sites, snapping line ends to them, and rerouting attached
//! connectors whenever the shapes they connect move, resize or rotate.
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
use deckcraft_geom::{Point, Xfrm};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use crate::Selection;
use crate::cmd::xfrm_of;
/// Connection sites of a top-level shape `s` in slide coordinates (index = site index stored on
/// connectors). For a shape inside a group use [`sites_of`] or [`sites_in`].
/// Connection sites of `s` in slide coordinates (index = site index stored on connectors).
pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
sites_in(doc, sel, s, Affine::IDENTITY)
}
/// Connection sites of `s` in slide coordinates, `to_abs` mapping the space its box lives in (the
/// slide, or the child space of the groups around it) onto the slide.
pub fn sites_in(doc: &Presentation, sel: &Selection, s: &Shape, to_abs: Affine) -> Vec<Point> {
if s.is_line() || matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Ink { .. }) {
return vec![];
}
@@ -24,42 +17,10 @@ pub fn sites_in(doc: &Presentation, sel: &Selection, s: &Shape, to_abs: Affine)
if local.is_empty() {
local = vec![Point::new(x.w / 2.0, 0.0), Point::new(0.0, x.h / 2.0), Point::new(x.w / 2.0, x.h), Point::new(x.w, x.h / 2.0)];
}
let a = to_abs * x.affine();
let a = x.affine();
local.into_iter().map(|p| a * p).collect()
}
/// The shape `id` anywhere in `shapes` (a group's member included) with the affine that maps the
/// space its box lives in onto the slide: a connector glues to a grouped shape, as in PowerPoint,
/// and its sites are where the group draws them.
pub fn locate<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId) -> Option<(&'a Shape, Affine)> {
fn rec<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId, to_abs: Affine, depth: usize) -> Option<(&'a Shape, Affine)> {
if depth > 64 {
return None;
}
for s in shapes {
if s.id == id {
return Some((s, to_abs));
}
if let ShapeKind::Group { children, child } = &s.kind {
let x = xfrm_of(doc, sel, s);
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
let nested = to_abs * deckcraft_geom::group_child_affine(&x, ch);
if let Some(f) = rec(doc, sel, children, id, nested, depth + 1) {
return Some(f);
}
}
}
None
}
rec(doc, sel, shapes, id, Affine::IDENTITY, 0)
}
/// Connection sites of shape `id` anywhere in `shapes`, in slide coordinates; `None` when there is
/// no such shape.
pub fn sites_of(doc: &Presentation, sel: &Selection, shapes: &[Shape], id: ShapeId) -> Option<Vec<Point>> {
locate(doc, sel, shapes, id).map(|(s, a)| sites_in(doc, sel, s, a))
}
/// The nearest connection site within `radius` of `p` on any visible shape except `exclude`.
pub fn nearest_site(
doc: &Presentation,
@@ -107,8 +68,8 @@ pub fn reroute(doc: &mut Presentation, sel: &Selection) {
let snapshot: Vec<Shape> = list.clone();
let view = doc.clone();
let site = |id: ShapeId, idx: u32| -> Option<Point> {
// Anywhere in the tree: a connector glued to a shape that is then grouped keeps following it.
let v = sites_of(&view, sel, &snapshot, id)?;
let s = snapshot.iter().find(|s| s.id == id)?;
let v = sites(&view, sel, s);
v.get(idx as usize).or(v.first()).copied()
};
let mut updates: Vec<(usize, Xfrm, bool, bool)> = vec![];
-161
View File
@@ -4321,9 +4321,6 @@ fn shape_fill_documents_each_gradient_key_and_kind() {
let params = cmd::parse_params(find_command("shape.fill").unwrap().params).unwrap();
let about = params.iter().find(|p| p.name == "gradient").unwrap().about;
assert_eq!(cmd::param_keys(about).unwrap(), ["stops", "kind", "angle", "focus", "rotateWithShape"]);
// The renderer keeps only linear and radial gradients upright (`paint::fill_path`).
let rotate = cmd::parse_params(about).unwrap().into_iter().find(|p| p.name == "rotateWithShape").unwrap().about;
assert!(rotate.contains("rectangular and path gradients turn with the shape"), "{rotate}");
for kind in ["linear", "radial", "rectangular", "path"] {
assert!(about.contains(kind), "{kind}: {about}");
let g = cmd::shape::gradient_param(&json!({
@@ -6509,161 +6506,3 @@ fn save_to_image_path_is_an_error_not_zip_bytes() {
}
let _ = std::fs::remove_dir_all(&dir);
}
/// `shape.connect` glues to a shape inside a group, as PowerPoint does: the connector's
/// end is set to the grouped shape and follows it when the group moves. The glue was dropped at
/// once (the end stayed `None`) because rerouting only looked at the slide's top-level shapes,
/// and the sites of a grouped shape were computed in the group's child space.
#[test]
fn shape_connect_glues_to_a_shape_inside_a_group() {
use deckcraft_model::{ShapeId, ShapeKind};
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
let a = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap();
let b = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 200, 100, 50]})).unwrap()["id"].as_u64().unwrap();
let g = s.execute("arrange.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
let c = s.execute("shape.insert", &json!({"preset": "rect", "rect": [400, 10, 100, 50]})).unwrap()["id"].as_u64().unwrap();
let glue = |s: &Session, id: u64| {
let st = s.doc().unwrap();
match st.shape(ShapeId(id as u32)).unwrap().kind {
ShapeKind::Connector { start, end } => (start.map(|(i, _)| i.0 as u64), end.map(|(i, _)| i.0 as u64)),
_ => (None, None),
}
};
let near = |p: deckcraft_geom::Point, x: f64, y: f64| (p.x - x).abs() < 1e-6 && (p.y - y).abs() < 1e-6;
// A new connector into the group.
let r = s.execute("shape.connect", &json!({"from": c, "to": a})).unwrap();
let k = r["id"].as_u64().unwrap();
assert_eq!(glue(&s, k), (Some(c), Some(a)), "{r}");
let (p0, p1) = line_ends(&s, k);
assert!(near(p0, 400.0, 35.0), "from C's left site: {p0:?}");
assert!(near(p1, 110.0, 35.0), "to A's right site in slide space: {p1:?}");
s.execute("shape.move", &json!({"ids": [g], "dy": 100})).unwrap();
assert_eq!(glue(&s, k), (Some(c), Some(a)), "the glue survives the move");
let (_, p1) = line_ends(&s, k);
assert!(near(p1, 110.0, 135.0), "the connector follows the grouped shape: {p1:?}");
// Re-gluing an existing line with `id`.
let l = s.execute("shape.insert", &json!({"preset": "straightConnector1", "rect": [600, 400, 50, 50]})).unwrap()["id"].as_u64().unwrap();
let r = s.execute("shape.connect", &json!({"id": l, "from": c, "to": b})).unwrap();
assert_eq!(r["id"], l, "{r}");
assert_eq!(glue(&s, l), (Some(c), Some(b)), "{r}");
let (_, p1) = line_ends(&s, l);
assert!(near(p1, 110.0, 325.0), "to B's right site (C is mostly to its right) in slide space, B moved with the group: {p1:?}");
s.execute("shape.move", &json!({"ids": [g], "dx": 50})).unwrap();
assert_eq!(glue(&s, l), (Some(c), Some(b)));
let (_, p1) = line_ends(&s, l);
assert!(near(p1, 160.0, 325.0), "{p1:?}");
// shape.sites answers in slide space, as shape.connect uses them: A's right site, A moved
// with the group.
let v = s.execute("shape.sites", &json!({"id": a})).unwrap();
assert!(
v.as_array().unwrap().iter().any(|q| near(deckcraft_geom::Point::new(q[0].as_f64().unwrap(), q[1].as_f64().unwrap()), 160.0, 135.0)),
"{v}"
);
// A line inside a group is refused as `id`: its ends would be written in slide space into
// the group's space, and rerouting does not follow grouped connectors.
let m = s.execute("shape.insert", &json!({"preset": "line", "rect": [600, 300, 50, 0]})).unwrap()["id"].as_u64().unwrap();
let n = s.execute("shape.insert", &json!({"preset": "rect", "rect": [600, 450, 50, 50]})).unwrap()["id"].as_u64().unwrap();
s.execute("arrange.group", &json!({"ids": [m, n]})).unwrap();
let before = s.doc().unwrap().doc.clone();
let e = s.execute("shape.connect", &json!({"id": m, "from": c, "to": b})).unwrap_err().to_string();
assert!(e.contains("inside a group"), "{e}");
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
// A grouped shape that is not a line is refused as not a line, not as a grouped line.
let e = s.execute("shape.connect", &json!({"id": n, "from": c, "to": b})).unwrap_err().to_string();
assert!(e.contains("not a line"), "{e}");
assert_eq!(s.doc().unwrap().doc, before, "nothing changed");
}
#[test]
fn flip_is_refused_on_a_smartart_graphic_and_leaves_it_a_graphic() {
let mut s = session();
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
s.execute("insert.textBox", &json!({"text": "Goals\nRevenue"})).unwrap();
let gid = s.execute("smartArt.fromText", &json!({"kind": "blockList"})).unwrap()["id"].as_u64().unwrap();
for cmd in ["arrange.flipHorizontal", "arrange.flipVertical", "shape.flip"] {
let e = s.execute(cmd, &json!({"ids": [gid]})).unwrap_err().to_string();
assert!(e.contains("SmartArt") && e.contains("smartArt.layout"), "{cmd}: {e}");
}
// Grouped with another shape it cannot be flipped either: ungrouping would hand it the flip.
let other = s.execute("insert.textBox", &json!({"text": "x"})).unwrap()["id"].as_u64().unwrap();
let group = s.execute("arrange.group", &json!({"ids": [gid, other]})).unwrap()["id"].as_u64().unwrap();
let e = s.execute("shape.flip", &json!({"ids": [group]})).unwrap_err().to_string();
assert!(e.contains("SmartArt"), "{e}");
s.execute("arrange.ungroup", &json!({"ids": [group]})).unwrap();
// Still a SmartArt graphic: smartArt commands keep working.
s.execute("smartArt.layout", &json!({"id": gid, "kind": "cycle"})).unwrap();
// Ordinary shapes still flip.
let b = s.execute("insert.textBox", &json!({"text": "x"})).unwrap();
let id = b["id"].as_u64().unwrap();
assert_eq!(s.execute("arrange.flipHorizontal", &json!({"ids": [id]})).unwrap(), json!(1));
}
/// Table cell commands refuse a `cells` that is not a list of [row, col] cells in the table,
/// instead of dropping the entry or reading the list as absent (which acts on the selection).
#[test]
fn table_cell_commands_refuse_a_malformed_cells_list() {
let mut s = session();
let t = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
let rev = s.doc().unwrap().revision;
for cells in [
json!([[-1, 0]]),
json!([[0]]),
json!([[0, 0], "x"]),
json!([[0, 0, "x"]]),
json!([[0, 2]]),
json!([[0, u64::MAX]]),
json!("0,0"),
json!({"0": 0}),
] {
for (cmd, extra) in [
("table.cellFill", json!({"color": "#FF0000"})),
("table.cellMargins", json!({"left": 4})),
("table.cellAlign", json!({"align": "center"})),
("table.borders", json!({"which": "right"})),
] {
let mut p = extra.clone();
p["id"] = json!(t);
p["cells"] = cells.clone();
let e = s.execute(cmd, &p).unwrap_err().to_string();
assert!(e.contains(cmd) && e.contains("cell"), "{cmd} {cells}: {e}");
}
}
assert_eq!(s.doc().unwrap().revision, rev, "nothing changed");
let many = json!(vec![[0, 0]; 100_001]);
assert!(s.execute("table.cellFill", &json!({"id": t, "color": "#FF0000", "cells": many})).unwrap_err().to_string().contains("at most"));
s.execute("table.cellFill", &json!({"id": t, "color": "#FF0000", "cells": [[1, 1]]})).unwrap();
assert!(table_of(&s, t).cell(1, 1).unwrap().fill.is_some() && table_of(&s, t).cell(0, 0).unwrap().fill.is_none());
}
/// The outline exports (PowerPoint's outline view) holds titles and body text only: footer,
/// date and slide-number placeholders are not body lines.
#[test]
fn outline_export_skips_footer_date_and_slide_number() {
let mut s = session();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap();
s.execute(
"design.headerFooter",
&json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}),
)
.unwrap();
let dir = std::env::temp_dir().join(format!("deckcraft-outline-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("deck.md");
s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "outline"})).unwrap();
let text = std::fs::read_to_string(&path).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(text.contains("Agenda") && text.contains("First point") && text.contains("Second point"), "{text:?}");
assert!(!text.contains("Company Confidential"), "footer text is not a body line: {text:?}");
assert!(!text.contains("‹#›"), "the slide-number field is not a body line: {text:?}");
assert!(!text.contains("1 May 2026"), "the date is not a body line: {text:?}");
// The RTF outline (Save As ▸ Outline/RTF) too.
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("deck.rtf");
s.execute("file.export", &json!({"path": path.to_string_lossy(), "format": "rtf"})).unwrap();
let rtf = std::fs::read_to_string(&path).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert!(rtf.contains("Agenda") && rtf.contains("First point"), "{rtf:?}");
assert!(!rtf.contains("Company Confidential") && !rtf.contains("1 May 2026"), "{rtf:?}");
}
+1 -6
View File
@@ -236,12 +236,7 @@ impl Session {
}
}
// A connector dragged away from shapes that stay put comes unglued at those ends.
let mut moved: Vec<ShapeId> = vec![];
for (id, _) in &origs2 {
if let Some(sh) = deckcraft_model::find_shape(list, *id) {
deckcraft_model::walk(std::slice::from_ref(sh), &mut |s, _| moved.push(s.id));
}
}
let moved: Vec<ShapeId> = origs2.iter().map(|o| o.0).collect();
for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) {
if let ShapeKind::Connector { start, end } = &mut sh.kind {
for c in [start, end] {
+3 -7
View File
@@ -1260,8 +1260,9 @@ impl FontDb {
fn instance(&self, family: &str, style: &str) -> Option<Arc<FontFace>> {
let open = style.find('{')?;
let base = self.find(family, style[..open].trim())?;
let Some(font) = base.skrifa().filter(|f| !f.axes().is_empty()) else { return Some(base) };
let axis_tags: Vec<[u8; 4]> = font.axes().iter().map(|a| a.tag().to_be_bytes()).collect();
if base.skrifa().is_none_or(|f| f.axes().is_empty()) {
return Some(base);
}
let mut coords = base.coords.clone();
for kv in style[open + 1..].trim_end_matches('}').split(',') {
let (k, v) = kv.split_once(':')?;
@@ -1271,11 +1272,6 @@ impl FontDb {
return None;
}
let tag = [k[0], k[1], k[2], k[3]];
// A tag the font has no axis for changes nothing; keeping it would grow the list with
// every distinct tag in the style text.
if !axis_tags.contains(&tag) {
continue;
}
match coords.iter_mut().find(|(t, _)| *t == tag) {
Some(c) => c.1 = v,
None => coords.push((tag, v)),
+1 -3
View File
@@ -694,9 +694,7 @@ pub fn slides_to_outline(p: &Presentation) -> String {
out.push_str(&s.title().replace('\n', " "));
out.push('\n');
for sh in &s.shapes {
// The title is the slide's line; footer, date and slide-number placeholders are not
// body text (PowerPoint's outline view shows title and body placeholders only).
if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) {
if sh.ph_type().is_some_and(|k| k.is_title()) {
continue;
}
if let Some(t) = &sh.text {
+2 -10
View File
@@ -71,8 +71,7 @@ pub fn slides_to_rtf(p: &Presentation) -> String {
}
out.push_str("\\b0\\par\n");
for sh in &s.shapes {
// As in the text outline: footer, date and slide-number placeholders are not body text.
if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) {
if sh.ph_type().is_some_and(|k| k.is_title()) {
continue;
}
let Some(t) = &sh.text else { continue };
@@ -300,8 +299,7 @@ impl Outline<'_> {
/// A one-character control symbol (`\\`, `\{`, `\~`, ...).
fn symbol(&mut self, n: u8) {
let c = self.rtf.get(self.i..).and_then(|r| r.chars().next());
self.i = advance(self.rtf, self.i, 1);
self.i += 1;
if self.st.skip {
return;
}
@@ -310,7 +308,6 @@ impl Outline<'_> {
b'~' => self.line.push('\u{a0}'),
b'_' => self.line.push('\u{2011}'),
b'\n' | b'\r' => self.flush(),
_ if !n.is_ascii() => self.line.extend(c),
_ => {}
}
}
@@ -422,11 +419,6 @@ mod tests {
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
}
#[test]
fn multibyte_control_symbol_keeps_character_and_following_text() {
assert_eq!(rtf_to_outline("{\\rtf1 A\\éB\\par}"), "AéB\n");
}
/// `\\_` is RTF's non-breaking hyphen: a visible character, not markup.
#[test]
fn non_breaking_hyphen_is_kept() {
-6
View File
@@ -415,11 +415,6 @@ impl CaptureScanner {
}
fn read_parts(src: &(impl ByteSource + ?Sized), parts: &[(u64, u32)]) -> Result<Vec<u8>> {
// Ranges come from a scan of some source; check them against this one before sizing a buffer.
let len = src.len();
if parts.iter().any(|&(o, n)| o.checked_add(u64::from(n)).is_none_or(|end| end > len)) {
return Err(Error::Invalid("packet range lies outside the source".into()));
}
let mut v = Vec::with_capacity(parts.iter().map(|p| p.1 as usize).sum());
for &(o, n) in parts {
let a = v.len();
@@ -513,7 +508,6 @@ impl OpusTiming {
c = timing_add(c, i64::from(durations[k]))?;
ends[k] = Some(c);
}
cursor = Some(c);
break;
}
let Some(g) = stream.packets[j].granule else { break };
-31
View File
@@ -183,27 +183,6 @@ fn opus_toc_durations() {
assert_eq!(opus_packet_samples([0xF8, 0], 0), None);
}
#[test]
fn opus_timing_includes_packets_after_the_last_known_granule() {
let f = opus_file(6, 312, 0);
let o = open(&f).unwrap();
let mut stream = o.streams[0].clone();
// Missing granules on complete-packet pages are non-conforming. The existing recovery
// fallback still assigns their packet positions; total must include the same samples.
stream.packets.last_mut().unwrap().granule = None;
let timing = OpusTiming::of(&stream, 312).unwrap();
assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]);
assert_eq!(timing.total, 6 * 960 - 312);
// With no usable granule, every complete packet still contributes its TOC duration.
for packet in &mut stream.packets {
packet.granule = None;
}
let timing = OpusTiming::of(&stream, 312).unwrap();
assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]);
assert_eq!(timing.total, 6 * 960 - 312);
}
#[test]
fn opus_timing_rejects_a_granule_that_overflows() {
let packet = |granule: i64| Stream {
@@ -295,13 +274,3 @@ fn a_budget_too_small_to_search_is_spent() {
let f = opus_file(6, 312, 0);
assert_eq!(scanner.find(&f, 0, f.len() as u64).unwrap(), None);
}
#[test]
fn read_packet_checks_its_ranges_against_the_source_before_allocating() {
let f = opus_file(10, 312, 500);
let o = open(&f).unwrap();
let s = o.stream_of(Codec::Opus).unwrap();
// The last packet lies past the end of a source cut short: an error, not a read of missing bytes.
let short = &f[..f.len() / 2];
assert!(matches!(o.read_packet(&short, s, 9), Err(Error::Invalid(_))));
}
+4 -5
View File
@@ -307,7 +307,7 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
@@ -522,8 +522,7 @@ impl Exporter<'_> {
let k = b.width() / sw;
// Raster at the requested dpi relative to the box size on paper.
let scale = raster_scale(b.width(), (sw, sh), self.opts.dpi);
// One clock reading for the raster and the text layer, so a date field cannot differ between them.
let ropts = RenderOpts { scale, threads: 4, now: deckcraft_text::datetime::DateTime::now_local(), ..Default::default() };
let ropts = RenderOpts { scale, threads: 4, ..Default::default() };
// Two rasters per slide: the backdrop (background, master and layout graphics) is the same
// on every slide that shares its layout, so the writer stores it once, and the slide's own
// shapes draw over it (#845).
@@ -564,7 +563,7 @@ impl Exporter<'_> {
}
if self.opts.text_layer {
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
for pt in deckcraft_render::place_slide_with(pres, i, &ropts).texts {
for pt in deckcraft_render::place_slide(pres, i).texts {
self.text_layer(s, &pt, view, false)?;
}
let _ = sh;
@@ -582,7 +581,7 @@ impl Exporter<'_> {
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
+5 -56
View File
@@ -269,6 +269,8 @@ fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
use deckcraft_model::{Shape, ShapeId, Xfrm};
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object, Stream};
let text = "Café Ελληνικά Привет";
let face = FontDb::global().face("Ubuntu", "Regular");
@@ -290,17 +292,8 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0)));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
assert_eq!(page_text(&bytes, 0), text);
}
/// The text of page `index`, read the way a PDF reader does: the content stream's text-showing
/// operators decoded through the page font's ToUnicode map.
fn page_text(bytes: &[u8], index: usize) -> String {
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object, Stream};
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
let page = &pdf.pages()[index];
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let page = &pdf.pages()[0];
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
assert_eq!(fonts.len(), 1);
@@ -351,51 +344,7 @@ fn page_text(bytes: &[u8], index: usize) -> String {
_ => {}
}
}
extracted
}
fn ubuntu_runs(runs: Vec<deckcraft_model::text::Run>) -> TextBody {
let paragraph = deckcraft_model::text::Paragraph { runs, ..Default::default() };
let mut body = TextBody { paragraphs: vec![paragraph], ..Default::default() };
for run in &mut body.paragraphs[0].runs {
run.props.font = Some("Ubuntu".into());
run.props.size = Some(20.0);
}
body
}
fn deck_with_body(n: usize, body: TextBody) -> Presentation {
use deckcraft_model::{Shape, ShapeId, Xfrm};
let mut p = deck(n);
for slide in &mut p.slides {
Arc::make_mut(slide).shapes =
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body.clone()), ..Default::default() }];
}
p
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn slide_number_field_is_recovered_from_the_page_text_layer() {
use deckcraft_model::text::{Run, RunKind};
let mut field = Run::new("<#>");
field.kind = RunKind::Field { field: "slidenum".into() };
let p = deck_with_body(3, ubuntu_runs(vec![Run::new("Page "), field]));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
for (i, want) in ["Page 1", "Page 2", "Page 3"].into_iter().enumerate() {
assert_eq!(page_text(&bytes, i), want);
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn line_break_keeps_the_text_layer_aligned_with_the_layout() {
use deckcraft_model::text::{Run, RunKind};
let mut brk = Run::new("");
brk.kind = RunKind::Break;
let p = deck_with_body(1, ubuntu_runs(vec![Run::new("one"), brk, Run::new("two")]));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
assert_eq!(page_text(&bytes, 0), "onetwo");
assert_eq!(extracted, text);
}
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
-1
View File
@@ -1351,7 +1351,6 @@ fn read_show(sp: &El, shows: &HashMap<String, String>) -> ShowSettings {
without_narration: sp.bool("showNarration") == Some(false),
without_animation: sp.bool("showAnimation") == Some(false),
use_timings: sp.bool("useTimings").unwrap_or(true),
show_media_controls: sp.find("showMediaCtrls").and_then(|e| e.bool("val")).unwrap_or(true),
..Default::default()
};
if sp.child("browse").is_some() {
-8
View File
@@ -1206,14 +1206,6 @@ fn pres_props_xml(p: &Presentation) -> Vec<u8> {
w.open0("p:penClr");
w.val("a:srgbClr", s.pen_color.hex());
w.close("p:penClr");
if !s.show_media_controls {
// "Show media controls" is PowerPoint's p14 extension; absent means shown.
w.open0("p:extLst");
w.open("p:ext", A::new().a("uri", "{2FDB2607-1784-4EEB-B798-7EB5836EED8A}"));
w.empty("p14:showMediaCtrls", A::new().a("xmlns:p14", NS_P14).a("val", 0));
w.close("p:ext");
w.close("p:extLst");
}
w.close("p:showPr");
w.close("p:presentationPr");
w.finish()
-28
View File
@@ -2258,34 +2258,6 @@ fn default_durations_match_powerpoint() {
}
}
/// Show media controls is PowerPoint's `p14:showMediaCtrls` extension inside `p:showPr`: off is
/// written there, under its extension uri and with the p14 namespace declared on the element;
/// on (the default) writes nothing.
#[test]
fn hidden_media_controls_survive_save_and_reopen() {
let props = |p: &Presentation| {
let bytes = deckcraft_pptx::export(p).expect("export");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut x = String::new();
std::io::Read::read_to_string(&mut zip.by_name("ppt/presProps.xml").expect("presProps"), &mut x).expect("read");
x
};
let mut p = Presentation::default();
p.show.show_media_controls = false;
let xml = props(&p);
let show = xml.split("<p:showPr").nth(1).and_then(|r| r.split("</p:showPr>").next()).expect("p:showPr");
assert!(
show.contains(
"<p:ext uri=\"{2FDB2607-1784-4EEB-B798-7EB5836EED8A}\"><p14:showMediaCtrls xmlns:p14=\"http://schemas.microsoft.com/office/powerpoint/2010/main\" val=\"0\"/></p:ext>"
),
"{xml}"
);
assert!(!round(&p).show.show_media_controls);
let default = Presentation::default();
assert!(!props(&default).contains("showMediaCtrls"));
assert!(round(&default).show.show_media_controls);
}
#[test]
fn reflection_angles_and_skews_out_of_range_are_written_inside_the_schema() {
let mut p = Presentation::default();
+1 -1
View File
@@ -54,7 +54,7 @@ pub use images::{decode as decode_image, gif_frame};
pub use morph_path::PathMorph;
pub use morph_text::TextMorph;
pub use pattern::{is_fg as pattern_is_fg, presets as pattern_presets, tile as pattern_tile};
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, place_slide_with, text_outline};
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline};
pub use table::cell_body as table_cell_body;
pub use table::cell_rects as table_cell_rects;
pub use table::row_heights as table_row_heights;
+5 -26
View File
@@ -2,9 +2,9 @@
//! that put real text over a raster (PDF text layer, links) and for text extraction.
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::{Hyperlink, RunKind, TextBody};
use deckcraft_model::text::{Hyperlink, TextBody};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use deckcraft_text::{Fields, Opts, TextLayout};
use deckcraft_text::{Opts, TextLayout};
use kurbo::{Affine, Rect};
use crate::{SlideFields, shape_geometry, table};
@@ -52,16 +52,10 @@ pub struct Placed {
/// Text and links of slide `index` (master and layout graphics first, as rendered).
pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
place_slide_with(pres, index, &crate::RenderOpts::default())
}
/// [`place_slide`] with the render options the slide was drawn with, so date fields take the same
/// date and time as the raster (`opts.now`).
pub fn place_slide_with(pres: &Presentation, index: usize, opts: &crate::RenderOpts) -> Placed {
let mut out = Placed::default();
let Some(slide) = pres.slides.get(index) else { return out };
let Some(rctx) = Ctx::for_slide(pres, slide) else { return out };
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts);
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default());
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
let mctx = Ctx::for_master(pres, rctx.master);
@@ -160,24 +154,9 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId, s: &Shape, body: &TextBody, tr: Rect, m: Affine) {
let layout = deckcraft_text::layout(rctx, s, body, &Opts { rect: tr, fields, prompt_color: None, no_shrink: false });
let m = crate::text_transform(m, tr, layout.rotation);
// Field runs carry their saved text (`‹#›`); the layout drew their values, so the body that
// goes with it (character offsets, text layer) must hold the same values.
let mut placed = body.clone();
for (pi, para) in body.paragraphs.iter().enumerate() {
for (ri, run) in para.runs.iter().enumerate() {
let RunKind::Field { field } = &run.kind else { continue };
let lang = resolve::run(rctx, s, para, &run.props).lang;
if let Some(v) = fields.field_in(field, lang.as_deref())
&& let Some(out_run) = placed.paragraphs.get_mut(pi).and_then(|p| p.runs.get_mut(ri))
{
out_run.text = v;
out_run.kind = RunKind::Text;
}
}
}
// Text hyperlinks: one box per line segment of a linked run.
for run in layout.runs.iter().filter(|r| r.link && !r.glyphs.is_empty()) {
let Some(link) = run_link(&placed, run.para, run.chars.0) else { continue };
let Some(link) = run_link(body, run.para, run.chars.0) else { continue };
let Some(line) = layout.lines.iter().find(|l| l.para == run.para && l.start <= run.chars.0 && run.chars.0 <= l.end) else { continue };
// The run's visual extent (right-to-left runs run from right to left).
let first = run.glyphs.first().map(|g| g.1).unwrap_or(0.0);
@@ -185,7 +164,7 @@ fn place_text(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, owner: ShapeId
let r = Rect::new(x0, line.top, x1, line.bottom);
out.links.push(PlacedLink { rect: m.transform_rect_bbox(r), link, shape: owner, text: true });
}
out.texts.push(PlacedText { layout, transform: m, body: placed });
out.texts.push(PlacedText { layout, transform: m, body: body.clone() });
}
/// The hyperlink of the model run containing character `at` of paragraph `para`.
+1 -22
View File
@@ -1,7 +1,7 @@
use super::{
Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph,
crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide,
place_slide_with, render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
};
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
use deckcraft_model::style::ColorRef;
@@ -2971,27 +2971,6 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
}
#[test]
fn placed_text_holds_the_field_value_not_the_saved_placeholder() {
let mut p = deck_with(vec![field_shape(Some("slidenum"), "<#>")]);
let second = p.slides[0].clone();
p.slides.push(second);
let placed = place_slide(&p, 1);
assert_eq!(placed.texts.len(), 1);
assert_eq!(placed.texts[0].body.text(), "2");
// The layout was drawn from the same value, so the offsets agree with the body.
assert_eq!(placed.texts[0].layout.runs.iter().map(|r| r.chars.1).max(), Some(1));
}
#[test]
fn placed_text_takes_its_date_from_the_given_render_options() {
use deckcraft_text::datetime::DateTime;
let p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]);
let at = |now: DateTime| place_slide_with(&p, 0, &RenderOpts { now: Some(now), ..Default::default() });
assert_eq!(at(DateTime::new(2026, 10, 9, 20, 5, 7)).texts[0].body.text(), "10/9/2026");
assert_eq!(at(DateTime::new(2027, 3, 4, 0, 0, 0)).texts[0].body.text(), "3/4/2027");
}
/// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour
/// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible
/// there), 595959 grey on Harbor's light ones.
-1
View File
@@ -19,7 +19,6 @@ deckcraft-anim = { workspace = true }
deckcraft-engine = { workspace = true }
deckcraft-media = { workspace = true }
egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] }
egui_extras = { workspace = true }
craft-ui = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
-1
View File
@@ -106,7 +106,6 @@ impl SlideApp {
twisting: false,
next_file_id: 1,
pending_files: Vec::new(),
host_start: None,
ime_tracker: egui::ime_context::ImeContextTracker::new(),
ime_pending: None,
ime_frame_viewport: None,
-5
View File
@@ -20,11 +20,6 @@ impl SlideApp {
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
let r = if UI_COMMANDS.iter().any(|c| c.0 == id) {
self.run_ui(id, &params)
} else if id == "file.save" && self.services.host_save.is_some() && params.get("path").is_none() {
// A page that keeps the files (`?host=parent`): Save by id (the command palette) goes
// to the page like Ctrl+S. The engine would write to the presentation's path, which a
// browser can't.
self.save_to_host(None).map(|name| json!({"path": name}))
} else {
self.session.execute(id, &params).map_err(|e| e.to_string())
};
-85
View File
@@ -462,8 +462,6 @@ impl SlideApp {
Err(e) => self.set_status(e.to_string()),
}
}
} else if self.services.host_save.is_some() {
let _ = self.save_to_host(Some(ext));
} else if let Some(dl) = self.services.download.as_mut()
&& let Some(d) = self.session.active()
{
@@ -489,10 +487,6 @@ impl SlideApp {
}
pub fn save(&mut self) {
if self.services.host_save.is_some() {
let _ = self.save_to_host(None);
return;
}
if self.session.active().and_then(|d| d.path.clone()).is_some() {
match self.session.execute("file.save", &json!({})) {
Ok(_) => self.set_status("Saved"),
@@ -503,85 +497,6 @@ impl SlideApp {
}
}
/// Saves to the page that keeps the files ([`Services::host_save`](crate::Services::host_save)).
/// Save (`ext` `None`) writes the presentation's own file (the one the page sent, or the
/// last one saved there) in that file's format, as the desktop app does; a presentation with
/// no file of its own is a new `.deckcraft` file, the name Save suggests on the desktop. Save
/// As (`pptx`, `deckcraft`) is a new file named after the presentation. Once the page has the
/// bytes, the presentation is saved and that name is its file. Returns the name, or why it
/// wasn't saved (also shown in the status bar).
pub(super) fn save_to_host(&mut self, ext: Option<&str>) -> Result<String, String> {
use deckcraft_engine::cmd::file::{format_for_path, mark_saved, save_bytes};
let Some(d) = self.session.active() else { return Err("no presentation is open".into()) };
let own = d.path.as_deref().and_then(|p| std::path::Path::new(p).file_name()).map(|n| n.to_string_lossy().to_string());
let (name, save_as) = match (ext, own) {
(None, Some(own)) => (own, false),
(ext, _) => (format!("{}.{}", d.title(), ext.unwrap_or("deckcraft")), true),
};
let saved = save_bytes(&d.doc, format_for_path(&name)).map_err(|e| e.to_string()).and_then(|bytes| {
let host = self.services.host_save.as_mut().ok_or("no page to save to")?;
host(&name, &bytes, save_as).map_err(|e| format!("Couldn't save {name}: {e}"))?;
mark_saved(&mut self.session, Some(name.clone())).map_err(|e| e.to_string())
});
match saved {
Ok(()) => {
self.set_status(format!("Saved {name}"));
Ok(name)
}
Err(e) => {
self.set_status(e.clone());
Err(e)
}
}
}
/// A file the page that keeps the files sent (`?host=parent`), with the name it sent, if any.
/// It opens as File › Open opens it on the desktop: a presentation as itself, an outline
/// (.txt, .md, .rtf) as a new untitled presentation. The name is the presentation's file only
/// when the page sent one with a presentation extension and the file is an intact
/// presentation, so Save hands it back under that name and format; anything else (no name, an
/// outline, a damaged file opened as a repaired copy) is untitled, and its first Save is a new
/// file. The first presentation that opens takes the place of the empty one host mode started
/// with ([`Self::start_for_host`]), if that one is still open, untitled and unchanged; later
/// files open beside the others.
pub fn open_from_host(&mut self, name: Option<&str>, bytes: &[u8]) {
let blank = self.host_start.filter(|&uid| self.session.documents().iter().any(|d| d.uid == uid && d.path.is_none() && !d.is_dirty()));
let before = self.session.documents().len();
let shown = name.unwrap_or("Presentation");
let named = name.is_some_and(|n| is_presentation_name(&n.to_ascii_lowercase()));
let opened = deckcraft_engine::cmd::file::loaded_file(shown, bytes).and_then(|mut f| {
// `file.adopt` keeps the path only for a native file; a name the page didn't send
// for a presentation must not become one.
f.native &= named;
let token = self.session.stage_loaded(f);
self.session.execute("file.adopt", &json!({"path": shown, "token": token}))
});
if let Err(e) = opened {
self.notify(e.to_string());
}
self.drain_requests();
if self.session.documents().len() <= before {
return;
}
self.host_start = None;
if let Some(opened) = self.session.active().map(|d| d.uid)
&& let Some(i) = self.session.documents().iter().position(|d| Some(d.uid) == blank)
{
self.session.close_document(i);
if let Some(i) = self.session.documents().iter().position(|d| d.uid == opened) {
self.session.set_active(i);
}
}
}
/// Host mode (`?host=parent`) starts with an empty presentation instead of the sample, for
/// the first file the page sends to take its place ([`Self::open_from_host`]).
pub fn start_for_host(&mut self) -> Result<(), String> {
self.run("file.new", json!({}))?;
self.host_start = self.session.active().map(|d| d.uid);
Ok(())
}
/// Act on requests commands left for the UI.
pub(super) fn drain_requests(&mut self) {
for r in std::mem::take(&mut self.session.ui_requests) {
-4
View File
@@ -144,10 +144,6 @@ impl SlideApp {
let visible = show::viewport_visible(ctx);
let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return };
let Some(show) = self.show.as_mut() else { return };
if show.presenter_grid && self.services.presenter.as_ref().is_some_and(|host| !host.is_open()) {
// A web popup closed with its grid open must not hold auto-advance while this tab is hidden (#1866).
show.presenter_grid = false;
}
if visible {
// A visible logic-only pass (`run_logic`) carries the last full frame's input time.
// Anchoring that stale time to `now` would move the hidden clock backwards if the
+1 -2
View File
@@ -249,8 +249,7 @@ fn act(app: &mut SlideApp, nav: Nav, current: Page) -> Option<Page> {
match nav {
Nav::Page(p) => Some(p),
Nav::Save => {
// A page that keeps the files (`host_save`) saves as the desktop does.
let web = app.services.pick_save.is_none() && app.services.download.is_some() && app.services.host_save.is_none();
let web = app.services.pick_save.is_none() && app.services.download.is_some();
let has_path = app.session.active().is_some_and(|d| d.path.is_some());
if !has_path && !web {
// A presentation that was never saved goes to Save As first, as in PowerPoint.
+4 -17
View File
@@ -166,28 +166,15 @@ pub(super) fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out:
})
.response
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type"));
// A page that keeps the files (`host_save`) saves; plain web downloads.
let web = app.services.pick_save.is_none() && app.services.host_save.is_none();
let web = app.services.pick_save.is_none();
let label = if web { "Download" } else { "Save" };
let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0)));
if save
.on_hover_text(if web {
"Download the file"
} else if app.services.host_save.is_some() {
"Save to the page"
} else {
"Choose where to save"
})
.clicked()
{
if save.on_hover_text(if web { "Download the file" } else { "Choose where to save" }).clicked() {
let fmt = bs.file_type;
let done = if matches!(fmt, "pptx" | "deckcraft") {
// A save bumps the revision, also one under the same name that leaves the path
// and the dirty flag as they were; a cancelled dialog or a failed save doesn't.
let snapshot = |app: &SlideApp| app.session.active().map(|d| (d.path.clone(), d.is_dirty(), d.revision));
let before = snapshot(app);
let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty()));
app.save_as_dialog(fmt);
web || snapshot(app) != before
web || app.session.active().map(|d| (d.path.clone(), d.is_dirty())) != before
} else {
app.export_as(fmt, json!({}))
};
+1 -2
View File
@@ -207,8 +207,7 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_
let mut budget = 4u32;
let mut draw_slide = |ui: &mut Ui, i: usize, r: Rect| {
let px = (r.width() * ui.ctx().pixels_per_point()).round().clamp(16.0, 1600.0) as u32;
// Print preview matches the actual exported PDF output (always full color).
match app.textures.thumb(ui.ctx(), doc, i, px, false, &mut budget) {
match app.textures.thumb(ui.ctx(), doc, i, px, &mut budget) {
Some(tex) => {
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
-58
View File
@@ -393,64 +393,6 @@ fn ctrl_w_asks_before_closing_unsaved_changes() {
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
}
/// Framed by a page that keeps the files (`?host=parent`), the Close prompt's Save is a real save:
/// when the page doesn't get the file the presentation stays open with its changes, and when it
/// does the presentation closes. (A plain browser download closes either way.)
#[test]
fn host_close_prompt_keeps_the_presentation_when_the_save_fails() {
for fail in [true, false] {
let services = Services {
host_save: Some(Box::new(move |_: &str, _: &[u8], _: bool| if fail { Err("the page is gone".into()) } else { Ok(()) })),
download: Some(Box::new(|_: &str, _: &[u8]| {})),
..Default::default()
};
let mut h = harness(SlideApp::new(Session::with_new(), services));
h.state_mut().run("slide.new", json!({})).unwrap();
h.run_steps(2);
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
// Enter is the prompt's Save.
h.key_press(Key::Enter);
h.run_steps(3);
assert!(h.state().dialog.is_none(), "fail={fail}");
let open = h.state().session.documents().len();
assert_eq!(open, usize::from(fail), "fail={fail}: open presentations");
if fail {
assert!(h.state().session.active().unwrap().is_dirty());
}
}
}
/// Framed by a page that keeps the files, Save As › Save under the presentation's own name goes
/// back to the slide once the page has the file. The bug this prevents: that save leaves the
/// file and the dirty flag as they were, so the Save As page stayed open and a second click sent
/// the file again.
#[test]
fn host_save_as_under_the_same_name_returns_to_the_slide() {
let got: Rc<RefCell<Vec<(String, bool)>>> = Default::default();
let saves = got.clone();
let services = Services {
host_save: Some(Box::new(move |name: &str, _: &[u8], save_as: bool| {
saves.borrow_mut().push((name.to_string(), save_as));
Ok(())
})),
download: Some(Box::new(|_: &str, _: &[u8]| {})),
..Default::default()
};
let mut app = SlideApp::new(Session::new(), services);
app.start_for_host().unwrap();
let deck = deckcraft_engine::cmd::file::save_bytes(&Session::with_new().doc().unwrap().doc, "pptx").unwrap();
app.open_from_host(Some("deck.pptx"), &deck);
let mut h = harness(app);
h.state_mut().run("app.backstage", json!({"page": "saveAs"})).unwrap();
h.run_steps(3);
click_on_page(&h, "Save");
h.run_steps(3);
assert_eq!(*got.borrow(), [("deck.pptx".to_string(), true)]);
assert!(h.state().backstage.is_none(), "the save returns to the slide");
}
/// Info shows the presentation's counts and properties, and edits them through file.properties.
#[test]
fn info_shows_and_edits_the_properties() {
+1 -1
View File
@@ -299,7 +299,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
vec2(w as f32 * scale, h as f32 * scale),
);
let mut budget = 2;
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, app.ui.grayscale, &mut budget) {
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, &mut budget) {
painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
use super::{Color32, CornerRadius, Rect, Sense, SlideApp, Stroke, Tokens, Ui, pos2, theme, vec2};
/// Room the About credits need for their table; the About tab keeps its compact size.
pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(820.0, 380.0);
pub(super) const CREDITS_SIZE: egui::Vec2 = vec2(640.0, 380.0);
/// The Contributors / Models body in a block of its own fixed size, below the tab row. Sizing the
/// dialog's `ui` instead (`set_min_size` / `set_max_height`) squeezed the tab row and the first
+2 -3
View File
@@ -339,9 +339,8 @@ fn dlg_close(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool {
let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0)));
if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
app.save();
// In the browser a save is a download: the presentation stays "unsaved". A page
// that keeps the files saves it for real, so a failed save keeps it open.
let web = app.services.pick_save.is_none() && app.services.download.is_some() && app.services.host_save.is_none();
// In the browser a save is a download: the presentation stays "unsaved".
let web = app.services.pick_save.is_none() && app.services.download.is_some();
if web || app.session.active().is_some_and(|d| !d.is_dirty()) {
run(app, "file.close", json!({}));
}
+50 -319
View File
@@ -246,14 +246,12 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
ui.label(RichText::new(format!("{} contributors · {} commits", list.len(), group(TOTAL_COMMITS))).small().weak());
ui.separator();
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
// The table scrolls vertically itself, under a fixed header; it only scrolls sideways
// once its columns are dragged wider than the dialog.
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
} else {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
table(ui, &list, &mut v);
} else {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
@@ -262,153 +260,39 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
}
});
});
}
}
});
ui.data_mut(|d| d.insert_temp(id, v));
}
/// How the contributor table's spare width is shared out: all of it to Name.
const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0];
/// How the model table's spare width is shared out: to Company and Model, the text columns.
const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0];
/// The narrowest a column can be dragged.
const MIN_COLUMN: f32 = 28.0;
/// The width a column needs for its header and its widest cell.
fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator<Item = String>) -> f32 {
let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x;
let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max);
widest + 2.0 * ui.spacing().button_padding.x
}
/// Opening column widths: each column's natural width, with the spare room shared out by `grow`,
/// so the columns fill `room` exactly. When there is too little room, only the growing columns
/// give way, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
let slack = |w: f32| (w - MIN_COLUMN).max(0.0);
let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum();
natural
.iter()
.zip(grow)
.map(|(w, g)| {
if *g > 0.0 && spare >= 0.0 {
w + spare * g / weight
} else if *g > 0.0 && flexible > 0.0 {
w - (-spare).min(flexible) * slack(*w) / flexible
} else {
*w
}
})
.collect()
}
/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls
/// sideways. Every column, the last included, can be dragged wider or narrower at its right edge:
/// only a table dragged wider than the window scrolls sideways. Text too long for its column is
/// clipped. When a column's natural width or the window changes, the columns go back to a new fit,
/// dropping any dragged widths.
fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> {
// Every cell is followed by the item gap, and the vertical scrollbar keeps its margins.
let s = ui.spacing();
let gaps = s.item_spacing.x * natural.len() as f32;
let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin;
let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0));
// The table keeps its own column widths, so a change in what they were fitted to has to clear
// them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available
// width, so this compares the natural widths and the clip rect, which scrolling never moves.
let fit = egui::Id::unique(("credits_fit", id));
let measured = (natural.to_vec(), ui.clip_rect().width());
let refit = ui.data_mut(|d| d.get_temp::<(Vec<f32>, f32)>(fit)).as_ref() != Some(&measured);
ui.data_mut(|d| d.insert_temp(fit, measured));
let mut t =
egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY);
if refit {
t.reset();
}
for w in widths {
t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).clip(true));
}
t
}
/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred.
fn aligned<R>(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
ui.with_layout(layout, add).inner
}
fn row_height(ui: &egui::Ui) -> f32 {
ui.text_style_height(&egui::TextStyle::Body) + 6.0
}
/// One contributor's table cells after the name, in `SortKey::ALL` order.
fn cells(c: &Contributor) -> [String; 9] {
[
group(c.prs),
group(c.commits),
group(c.lines_added),
group(c.lines_deleted),
signed(c.lines_delta()),
group(c.binary_added),
group(c.binary_deleted),
day(c.first_commit).to_string(),
day(c.last_commit).to_string(),
]
}
/// A contributor-table header. The sorted column is highlighted; its direction shows on the
/// toolbar's sort-order button, so no arrow widens the column.
fn header_text(k: SortKey) -> RichText {
RichText::new(k.label().1).strong()
}
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
let h = row_height(ui);
let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect();
let widths: Vec<f32> = SortKey::ALL
.iter()
.enumerate()
.map(|(i, &k)| match i.checked_sub(1) {
None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))),
Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())),
})
.collect();
fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW)
.header(h + 4.0, |mut header| {
for k in SortKey::ALL {
header.col(|ui| {
let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))).on_hover_text(k.label().0);
if r.clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
})
.body(|mut body| {
for (c, cells) in list.iter().zip(rows) {
body.row(h, |mut row| {
row.col(|ui| {
aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)))
.on_hover_text(c.summary());
});
for text in cells {
row.col(|ui| {
aligned(ui, true, |ui| ui.label(text));
});
}
});
}
});
}
ui.end_row();
for c in list {
ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary());
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
ui.label(group(c.lines_deleted));
ui.label(signed(c.lines_delta()));
ui.label(group(c.binary_added));
ui.label(group(c.binary_deleted));
ui.label(day(c.first_commit));
ui.label(day(c.last_commit));
ui.end_row();
}
});
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
@@ -418,48 +302,22 @@ pub fn models_ui(ui: &mut egui::Ui) {
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
let h = row_height(ui);
let titles = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
// Company, Model and Version read as text; the counts line up on the right.
let right = [false, false, false, true, true, true];
let rows: Vec<[String; 6]> = MODELS
.iter()
.map(|m| {
[
m.company.to_string(),
m.model.to_string(),
m.version.to_string(),
group(m.commits),
format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64),
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
]
})
.collect();
let widths: Vec<f32> = titles
.iter()
.enumerate()
.map(|(i, title)| natural(ui, RichText::new(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned())))
.collect();
egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| {
fitted_table(ui, "credits_models", &widths, &MODEL_GROW)
.header(h + 4.0, |mut header| {
for (title, r) in titles.into_iter().zip(right) {
header.col(|ui| {
aligned(ui, r, |ui| ui.label(RichText::new(title).strong()));
});
}
})
.body(|mut body| {
for cells in rows {
body.row(h, |mut row| {
for (text, r) in cells.into_iter().zip(right) {
row.col(|ui| {
aligned(ui, r, |ui| ui.label(text));
});
}
});
}
});
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] {
ui.label(RichText::new(h).strong());
}
ui.end_row();
for m in MODELS {
ui.label(m.company);
ui.label(m.model);
ui.label(m.version);
ui.label(group(m.commits));
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
ui.end_row();
}
});
});
}
@@ -539,131 +397,4 @@ mod tests {
assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07");
assert_eq!(day(""), "—");
}
#[test]
fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]);
let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0);
assert!((w.iter().sum::<f32>() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}");
}
#[test]
fn too_little_room_shrinks_only_growing_columns() {
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]);
assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]);
}
#[test]
fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() {
let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0);
assert!((w.iter().sum::<f32>() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}");
assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}");
assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]);
}
const WINDOW: f32 = 640.5;
const NATURAL: [f32; 3] = [120.0, 80.0, 80.0];
const GROW: [f32; 3] = [1.0, 0.0, 0.0];
/// One frame of a table long enough to scroll; the rects of its header cells.
fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec<egui::Event>) -> Vec<egui::Rect> {
scrolled_frame(ctx, width, natural, 0.0, events)
}
/// [`table_frame`], scrolled `offset` points sideways.
fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec<egui::Event>) -> Vec<egui::Rect> {
let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 300.0)));
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
let mut cells = Vec::new();
let out = ctx.run_ui(raw, |ui| {
cells.clear();
egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| {
fitted_table(ui, "test", natural, &GROW)
.header(20.0, |mut header| {
for _ in natural {
header.col(|ui| cells.push(ui.max_rect()));
}
})
.body(|mut body| {
for _ in 0..40 {
body.row(20.0, |mut row| {
for _ in natural {
row.col(|ui| {
ui.label("x");
});
}
});
}
});
});
});
out.drop_without_applying_deltas();
cells
}
/// A new context with the table open and settled, and the rects of its header cells.
fn opened_table() -> (egui::Context, Vec<egui::Rect>) {
let ctx = egui::Context::default();
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
(ctx, cells)
}
/// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go.
fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) {
use egui::{Event, Modifiers, PointerButton};
let start = egui::pos2(cell.right() + 4.0, cell.center().y);
let halfway = start + egui::vec2(by / 2.0, 0.0);
let end = start + egui::vec2(by, 0.0);
let moved = Event::PointerMoved;
let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
table_frame(ctx, WINDOW, &NATURAL, vec![event]);
}
}
#[test]
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
let (_, opened) = opened_table();
for (dragged, cell) in opened.iter().enumerate() {
let (ctx, _) = opened_table();
drag_edge(&ctx, *cell, -30.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
}
}
}
#[test]
fn a_changed_fit_replaces_the_column_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], -30.0);
let longer_name = table_frame(&ctx, WINDOW, &[200.0, 80.0, 80.0], Vec::new());
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
let wider = table_frame(&ctx, WINDOW, &[120.0, 200.0, 80.0], Vec::new());
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
let narrower = table_frame(&ctx, 400.0, &NATURAL, Vec::new());
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
}
#[test]
fn scrolling_sideways_keeps_dragged_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], 100.0);
table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new());
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
for offset in [13.3, 27.77, 41.1, 0.6] {
scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new());
assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}");
}
}
}
-213
View File
@@ -1,213 +0,0 @@
//! The web build framed by a page that keeps the files (`?host=parent`): what Save, Save As,
//! the exports and Print hand that page, under which name, and whether it is a new file.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::json;
use super::{Services, SlideApp};
use crate::Session;
/// What the page was handed: (name, Save As, bytes).
type Got = Rc<RefCell<Vec<(String, bool, Vec<u8>)>>>;
/// The web app inside such a page, with `host_save` and `download` pointed at it as the bridge
/// does; `fail` makes every save miss the page.
fn host_app(fail: bool) -> (SlideApp, Got) {
let got: Got = Rc::default();
let (saves, downloads) = (got.clone(), got.clone());
let services = Services {
host_save: Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
if fail {
return Err("the page is gone".into());
}
saves.borrow_mut().push((name.to_string(), save_as, bytes.to_vec()));
Ok(())
})),
download: Some(Box::new(move |name: &str, bytes: &[u8]| downloads.borrow_mut().push((name.to_string(), false, bytes.to_vec())))),
..Default::default()
};
// Host mode starts as the web shell starts it: an empty presentation for the page's file.
let mut app = SlideApp::new(Session::new(), services);
app.start_for_host().unwrap();
(app, got)
}
/// A .pptx with `slides` slides, as the page would send it.
fn pptx(slides: usize) -> Vec<u8> {
let mut s = Session::with_new();
while s.doc().unwrap().doc.slides.len() < slides {
s.execute("slide.new", &json!({})).unwrap();
}
deckcraft_engine::cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap()
}
fn edit(app: &mut SlideApp) {
app.session.execute("slide.new", &json!({})).unwrap();
assert!(app.session.active().unwrap().is_dirty());
}
fn calls(got: &Got) -> Vec<(String, bool)> {
got.borrow().iter().map(|(n, s, _)| (n.clone(), *s)).collect()
}
fn path(app: &SlideApp) -> Option<String> {
app.session.active().and_then(|d| d.path.clone())
}
/// The bug this prevents: Save of a .pptx the page sent came back as `<name>.deckcraft`. Save
/// now hands the page the opened file under its own name and format, not as a new file; Save As
/// says it is a new file and becomes the file the next Save writes.
#[test]
fn host_saves_go_back_to_the_opened_file() {
let (mut app, got) = host_app(false);
app.open_from_host(Some("Lecture 3.pptx"), &pptx(2));
assert_eq!(app.session.documents().len(), 1, "the untouched blank presentation gave way");
edit(&mut app);
app.save();
assert!(!app.session.active().unwrap().is_dirty());
edit(&mut app);
app.save_as_dialog("deckcraft");
assert!(!app.session.active().unwrap().is_dirty());
edit(&mut app);
app.save();
let names = [("Lecture 3.pptx", false), ("Lecture 3.deckcraft", true), ("Lecture 3.deckcraft", false)];
assert_eq!(calls(&got), names.map(|(n, s)| (n.to_string(), s)));
// The first save is a .pptx that reopens with the edit in it.
let first = got.borrow()[0].2.clone();
assert!(first.windows(20).any(|w| w == b"ppt/presentation.xml"), "Save writes PowerPoint, not .deckcraft");
let reopened = deckcraft_engine::cmd::file::loaded_file("Lecture 3.pptx", &first).unwrap();
assert_eq!(reopened.doc.slides.len(), 3);
}
/// A presentation the page never sent is a new file, as Save As: the page must not overwrite a
/// file of the same name. The next Save goes back to it.
#[test]
fn host_save_of_an_untitled_presentation_is_save_as() {
let (mut app, got) = host_app(false);
let name = format!("{}.deckcraft", app.session.active().unwrap().title());
edit(&mut app);
app.save();
edit(&mut app);
app.save();
assert_eq!(calls(&got), [(name.clone(), true), (name, false)]);
}
/// A presentation the user dropped (or picked with File › Open) didn't come from the page, so
/// its name isn't one the page holds: Save makes a new file.
#[test]
fn a_file_the_page_never_sent_saves_as_a_new_file() {
let (mut app, got) = host_app(false);
app.open_or_insert_bytes("dropped.pptx", &pptx(1));
app.save();
assert_eq!(calls(&got), [("dropped.deckcraft".to_string(), true)]);
}
/// Exports and Print are copies: named after the presentation, not a new file, and the
/// presentation keeps its own file and its unsaved changes.
#[test]
fn host_exports_and_print_are_copies() {
let (mut app, got) = host_app(false);
app.open_from_host(Some("deck.pptx"), &pptx(1));
edit(&mut app);
assert!(app.export_as("pdf", json!({})));
assert!(app.print_pdf(json!({})));
assert_eq!(calls(&got), [("deck.pdf".to_string(), false), ("deck.pdf".to_string(), false)]);
assert!(app.session.active().unwrap().is_dirty());
assert_eq!(path(&app).as_deref(), Some("deck.pptx"));
}
/// The page not getting the file is a failed save: the presentation keeps its changes.
#[test]
fn a_host_save_that_fails_leaves_the_presentation_unsaved() {
let (mut app, _) = host_app(true);
app.open_from_host(Some("deck.pptx"), &pptx(1));
edit(&mut app);
app.save();
assert!(app.session.active().unwrap().is_dirty());
assert!(app.status.as_ref().is_some_and(|(m, _)| m.contains("Couldn't save deck.pptx")));
}
/// Only an untouched start presentation gives way: one with changes stays open beside the file.
#[test]
fn an_edited_start_presentation_stays_open() {
let (mut app, _) = host_app(false);
edit(&mut app);
app.open_from_host(Some("deck.pptx"), &pptx(1));
assert_eq!(app.session.documents().len(), 2);
assert_eq!(path(&app).as_deref(), Some("deck.pptx"));
}
/// Only the first file from the page takes the start presentation's place. The bug this
/// prevents: a file sent without a name opens untitled and unchanged, as the start presentation
/// is, so the page's next file closed it.
#[test]
fn only_the_first_file_from_the_page_replaces_the_start_presentation() {
let (mut app, _) = host_app(false);
app.open_from_host(None, &pptx(2));
app.open_from_host(Some("deck.pptx"), &pptx(3));
let slides: Vec<usize> = app.session.documents().iter().map(|d| d.doc.slides.len()).collect();
assert_eq!(slides, [2, 3], "the file sent without a name stays open beside the next one");
assert_eq!(path(&app).as_deref(), Some("deck.pptx"));
}
/// The command palette runs `file.save` by id. With a file of its own the engine would write to
/// that path, which a browser can't; in host mode it goes to the page like Ctrl+S.
#[test]
fn palette_save_goes_to_the_page() {
let (mut app, got) = host_app(false);
app.open_from_host(Some("deck.pptx"), &pptx(1));
edit(&mut app);
let r = app.run("file.save", json!({})).unwrap();
assert_eq!(r["path"], "deck.pptx");
assert_eq!(calls(&got), [("deck.pptx".to_string(), false)]);
assert!(!app.session.active().unwrap().is_dirty());
}
/// A file the page sent without a name, or with a name that isn't a presentation's, has no file
/// of its own: Save is a new `.deckcraft`, never an overwrite of an invented `Presentation.pptx`.
#[test]
fn a_file_sent_without_a_presentation_name_saves_as_a_new_file() {
for (name, saved) in [(None, "Presentation.deckcraft"), (Some("Talk"), "Talk.deckcraft")] {
let (mut app, got) = host_app(false);
app.open_from_host(name, &pptx(2));
assert_eq!(app.session.documents().len(), 1, "{name:?}");
assert_eq!(app.session.active().unwrap().doc.slides.len(), 2, "{name:?}");
assert_eq!(path(&app), None, "{name:?}");
app.save();
assert_eq!(calls(&got), [(saved.to_string(), true)], "{name:?}");
}
}
/// An outline from the page opens as a new untitled presentation, as File › Open does on the
/// desktop: the blank start presentation gives way, and Save is a new file.
#[test]
fn an_outline_from_the_page_opens_as_a_new_presentation() {
let (mut app, got) = host_app(false);
app.open_from_host(Some("notes.md"), b"# One\n\n# Two\n\n# Three\n");
assert_eq!(app.session.documents().len(), 1);
assert_eq!(app.session.active().unwrap().doc.slides.len(), 3);
assert_eq!(path(&app), None);
app.save();
assert_eq!(calls(&got), [("notes.deckcraft".to_string(), true)]);
}
/// A damaged file from the page opens as an untitled "(Repaired)" copy, so Save is a new file
/// and can't overwrite the page's original with the emptied media.
#[test]
fn a_damaged_file_from_the_page_saves_as_a_new_repaired_file() {
let mut p = deckcraft_model::Presentation::default();
p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec());
let mut bad = deckcraft_engine::cmd::file::save_bytes(&p, "deckcraft").unwrap();
// Pictures are stored as they are, so flipping a byte breaks the entry's CRC.
let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap();
bad[at] ^= 0xff;
let (mut app, got) = host_app(false);
app.open_from_host(Some("talk.deckcraft"), &bad);
assert_eq!(path(&app), None);
assert!(app.session.active().unwrap().title().ends_with("(Repaired)"));
app.save();
assert_eq!(calls(&got), [("talk (Repaired).deckcraft".to_string(), true)]);
}
+1 -7
View File
@@ -72,7 +72,7 @@ pub use panels::notes;
pub use panels::selpane;
pub use panels::thumbs;
pub use ribbon::qat;
pub use services::{DownloadFn, HostSaveFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn};
pub use services::{DownloadFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn};
use state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp};
pub use state::{RECENT_MAX, UiState, ViewMode, ZOOM_RANGE};
@@ -335,9 +335,6 @@ pub struct SlideApp {
next_file_id: u64,
/// Picks and drops whose bytes have not arrived yet.
pending_files: Vec<PendingFile>,
/// The empty presentation host mode starts with ([`SlideApp::start_for_host`]), until the
/// first file the page sends takes its place.
host_start: Option<u64>,
/// Owner/generation/cause bookkeeping for the canvas text IME producer.
ime_tracker: egui::ime_context::ImeContextTracker,
/// Canvas IME geometry for this pass: the caret while editing, or the selected shape.
@@ -490,8 +487,5 @@ mod datetime_tests;
#[cfg(test)]
mod inbox_tests;
#[cfg(test)]
mod host_save_tests;
#[cfg(test)]
mod tests;
+1 -1
View File
@@ -219,7 +219,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
// Only rows in view (plus a little margin) ask for a thumbnail: with every row asking, a
// deck bigger than the thumbnail cache evicts its own thumbnails and never settles (#969).
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * row.height())).intersects(row);
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, app.ui.grayscale, &mut budget) } else { None };
let tex = if near { app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) } else { None };
match tex {
Some(tex) => {
p.image(
+5 -102
View File
@@ -49,15 +49,10 @@ pub struct Snapshot {
pub paused: bool,
pub notes_px: f32,
pub blank: Option<String>,
/// All slides is open in the presenter window (#1866); the audience keeps the current slide.
pub grid: bool,
pub zoom_armed: bool,
pub current: Option<Preview>,
pub next: Option<Preview>,
/// The All slides thumbnails, in slide order, while `grid` is on. `None` in a sent delta
/// means "unchanged since the last snapshot the window received".
#[serde(default)]
pub slides: Option<Vec<Preview>>,
}
/// Images acknowledged by a successful transport send, never merely attempted images.
@@ -65,7 +60,6 @@ pub struct Snapshot {
pub struct DeltaCache {
current: Option<Preview>,
next: Option<Preview>,
slides: Option<Vec<Preview>>,
token: String,
}
impl DeltaCache {
@@ -81,12 +75,6 @@ impl DeltaCache {
let next_changed = fresh || self.next != snapshot.next;
let current = snapshot.current.clone();
let next = snapshot.next.clone();
let slides = snapshot.slides.clone();
if !fresh && self.slides == slides {
snapshot.slides = None;
} else if snapshot.slides.is_none() {
snapshot.slides = Some(Vec::new());
}
if !current_changed {
snapshot.current = None;
}
@@ -96,28 +84,14 @@ impl DeltaCache {
send(snapshot, current_changed, next_changed)?;
self.current = current;
self.next = next;
self.slides = slides;
self.token = token.to_owned();
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Command {
Key {
name: String,
command: bool,
shift: bool,
alt: bool,
ctrl: bool,
},
Zoom {
x: f32,
y: f32,
},
/// A slide chosen in the presenter window's All slides grid (#1866).
Goto {
slide: usize,
},
Key { name: String, command: bool, shift: bool, alt: bool, ctrl: bool },
Zoom { x: f32, y: f32 },
End,
}
/// Implemented by the browser host. Native hosts retain the existing egui viewport.
@@ -138,8 +112,6 @@ pub struct View {
next: Option<egui::TextureHandle>,
previous_current: Option<Preview>,
previous_next: Option<Preview>,
grid_source: Option<Vec<Preview>>,
grid: Vec<Option<egui::TextureHandle>>,
}
impl View {
/// Install the same interface and dynamic fallback fonts as SlideApp before layout.
@@ -175,10 +147,6 @@ impl View {
.collect::<Vec<_>>()
});
for (key, modifiers) in keys {
if snapshot.grid && matches!(key, egui::Key::Space | egui::Key::Enter) {
// A focused thumbnail takes these to choose its slide; the show ignores them while the grid is open.
continue;
}
// Consume once before widgets see Space/Enter, avoiding a second button activation.
ui.input_mut(|i| i.consume_key(modifiers, key));
commands.push(Command::Key {
@@ -232,11 +200,6 @@ impl View {
}
});
ui.separator();
if snapshot.grid {
// All slides opens here, as in the desktop Presenter View; the audience keeps the current slide.
self.grid_ui(ui, snapshot, &mut commands);
return commands;
}
ui.columns(2, |columns| {
let left = &mut columns[0];
if snapshot.ended {
@@ -264,6 +227,9 @@ impl View {
if snapshot.zoom_armed {
left.label("Click the preview to zoom.");
}
if snapshot.grid {
left.label("Choose a slide in the audience window, or press G to return.");
}
let right = &mut columns[1];
right.label("Next");
if let Some(texture) = &self.next {
@@ -294,43 +260,6 @@ impl View {
commands
}
}
impl View {
fn grid_ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot, commands: &mut Vec<Command>) {
let slides = snapshot.slides.as_deref().unwrap_or_default();
if self.grid_source.as_deref() != Some(slides) {
self.grid_source = Some(slides.to_vec());
self.grid = slides
.iter()
.enumerate()
.map(|(i, p)| p.image().map(|image| ui.ctx().load_texture(format!("remote-grid-{i}"), image, egui::TextureOptions::LINEAR)))
.collect();
}
ui.heading("All slides");
ui.label("Choose a slide to show it, or press G to return.");
if snapshot.total > crate::show::GRID_CAP {
ui.label(format!("First {} slides", crate::show::GRID_CAP));
}
egui::ScrollArea::vertical().show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (i, texture) in self.grid.iter().enumerate() {
let size = egui::vec2(160.0, 90.0);
let label = format!("{}", i + 1);
let label = if i == snapshot.slide { egui::RichText::new(label).strong() } else { egui::RichText::new(label) };
let response = match texture {
Some(t) => ui.add(egui::Button::image_and_text(
egui::Image::new(t).fit_to_exact_size(fit_preview(t.size_vec2(), size.x, size.y)),
label,
)),
None => ui.add_sized(size, egui::Button::new(label)),
};
if response.clicked() {
commands.push(Command::Goto { slide: i });
}
}
});
});
}
}
fn fit_preview(size: egui::Vec2, width: f32, height: f32) -> egui::Vec2 {
size * (width.max(1.0) / size.x.max(1.0)).min(height.max(1.0) / size.y.max(1.0))
}
@@ -353,7 +282,6 @@ mod tests {
zoom_armed: false,
current: Some(Preview { width: 1, height: 1, rgba: vec![255; 4] }),
next: Some(Preview { width: 1, height: 1, rgba: vec![0; 4] }),
slides: None,
}
}
@@ -490,7 +418,6 @@ mod tests {
zoom_armed: false,
current: None,
next: None,
slides: None,
};
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
@@ -510,30 +437,6 @@ mod tests {
assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name()));
}
#[test]
fn all_slides_opens_in_the_popup_and_picks_a_slide() {
use egui_kittest::kittest::Queryable;
let thumb = Preview { width: 16, height: 9, rgba: vec![200; 16 * 9 * 4] };
let mut snapshot = snapshot();
snapshot.total = 3;
snapshot.grid = true;
snapshot.slides = Some(vec![thumb.clone(), thumb.clone(), thumb]);
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
sent.extend(view.ui(ui, snapshot));
},
(View::default(), snapshot, Vec::new()),
);
h.run_steps(2);
assert!(h.query_by_label("Notes").is_none(), "the grid replaces the presenter panes, as on desktop (#1866)");
h.get_by_label("3").click();
h.run_steps(2);
assert_eq!(h.state().2, vec![Command::Goto { slide: 2 }]);
h.key_press(egui::Key::Enter);
h.run_steps(2);
assert!(!h.state().2.iter().any(|c| matches!(c, Command::Key { .. })), "Enter is left to the focused thumbnail");
}
#[test]
fn previews_are_bounded_and_bad_dimensions_are_rejected() {
let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED);
-13
View File
@@ -11,10 +11,6 @@ pub type OpenAsyncFn = Box<dyn FnMut(&str, u64)>;
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
/// [`Services::host_save`]: `(file name, bytes, save_as)`. An error means the page didn't get
/// the file, and the save failed.
pub type HostSaveFn = Box<dyn FnMut(&str, &[u8], bool) -> Result<(), String>>;
/// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`].
#[derive(Clone, Debug)]
pub struct InboxFile {
@@ -53,15 +49,6 @@ pub struct Services {
pub open_async: Option<OpenAsyncFn>,
/// Hand bytes to the user as a named file (browser download).
pub download: Option<DownloadFn>,
/// Web, framed by a page that keeps the files (`?host=parent`): Save and Save As hand the
/// presentation to that page instead of downloading it, with whether it is a new file
/// ([`HostSaveFn`]). Save writes a presentation the page sent back under its own name and
/// format, as the desktop app writes back to the file it opened; a presentation the page
/// never sent (an untitled one, a repaired copy) goes as a new `.deckcraft` file, as Save
/// suggests on the desktop. Save As (.pptx or .deckcraft) is always a new file. Either way the
/// presentation is then that file, and the next Save goes to it. Exports and Print still go
/// through `download`, which the host also points at the page: they are copies.
pub host_save: Option<HostSaveFn>,
pub inbox: Option<Inbox>,
/// Image on the system clipboard (PNG bytes), when the host can read one.
pub clipboard_image: Option<Box<dyn FnMut() -> Option<Vec<u8>>>>,
-4
View File
@@ -18,7 +18,6 @@ mod presenter;
mod render;
mod ui;
mod zoom;
pub(crate) use zoom::GRID_CAP;
#[cfg(test)]
use media::{on_screen, pointer_on_media_bar, shown_slide_rect, slide_media_bars};
@@ -66,8 +65,6 @@ pub struct Show {
preview_spawns: usize,
remote_preview: Option<crate::presenter::Preview>,
remote_next: Option<(usize, crate::presenter::Preview)>,
/// All slides thumbnails sent to the web presenter window while its grid is open (#1866).
remote_grid: Vec<crate::presenter::Preview>,
remote_sent: f64,
remote_opened: bool,
/// The next slide's first frame, rendered in the background while this one is shown so
@@ -240,7 +237,6 @@ impl Show {
preview_spawns: 0,
remote_preview: None,
remote_next: None,
remote_grid: vec![],
remote_sent: f64::NEG_INFINITY,
remote_opened: false,
prefetch: None,
+2 -39
View File
@@ -1132,8 +1132,6 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
opens: usize,
closes: usize,
commands: Vec<Command>,
last: Option<Snapshot>,
gone: bool,
}
struct Mock(Rc<RefCell<Bridge>>);
impl Host for Mock {
@@ -1142,14 +1140,12 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
Ok(())
}
fn is_open(&self) -> bool {
!self.0.borrow().gone
true
}
fn commands(&mut self) -> Vec<Command> {
std::mem::take(&mut self.0.borrow_mut().commands)
}
fn update(&mut self, snapshot: Snapshot) {
self.0.borrow_mut().last = Some(snapshot);
}
fn update(&mut self, _: Snapshot) {}
fn close(&mut self) {
self.0.borrow_mut().closes += 1;
}
@@ -1177,39 +1173,6 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
h.run_steps(2);
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide);
}
// #1864: the web popup's Previous/Next buttons send the DOM key names.
for (name, slide) in [("ArrowRight", 1), ("ArrowLeft", 0)] {
bridge.borrow_mut().commands.push(Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false });
h.run_steps(2);
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide, "{name}");
}
// #1866: All slides opens in the web Presenter View, as on desktop; the audience keeps its slide.
bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false });
h.run_steps(2);
let show = h.state().show.as_ref().unwrap();
assert!(show.presenter_grid && !show.grid, "G from the popup opens its own grid, not the audience's");
for _ in 0..20 {
h.step();
std::thread::sleep(std::time::Duration::from_millis(15));
}
let last = bridge.borrow().last.clone().unwrap();
assert!(last.grid);
assert_eq!(last.slides.as_ref().map(Vec::len), Some(3), "the popup gets a thumbnail per slide");
assert!(last.slides.iter().flatten().all(|p| p.width.max(p.height) <= 96), "thumbnails stay small enough for one message");
assert!(last.current.is_none() && last.next.is_none(), "the grid replaces both previews");
bridge.borrow_mut().commands.push(Command::Goto { slide: 2 });
h.run_steps(2);
let show = h.state().show.as_ref().unwrap();
assert!(!show.presenter_grid && !show.grid);
assert_eq!(show.state.slide, 2);
// Closing the popup with its grid open must not hold auto-advance for the rest of the show.
bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false });
h.run_steps(2);
assert!(h.state().show.as_ref().unwrap().presenter_grid);
bridge.borrow_mut().gone = true;
h.run_steps(2);
assert!(!h.state().show.as_ref().unwrap().presenter_grid);
bridge.borrow_mut().gone = false;
bridge.borrow_mut().commands.push(Command::End);
h.run_steps(2);
assert!(h.state().show.is_none());
+5 -37
View File
@@ -80,10 +80,6 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
&& !show.reading
&& let Some(host) = app.services.presenter.as_mut()
{
if show.remote_opened && show.presenter_grid && !host.is_open() {
// A grid left open in the closed popup would hold auto-advance for the rest of the show.
show.presenter_grid = false;
}
if !show.remote_opened {
show.remote_opened = true;
if let Err(message) = host.open() {
@@ -98,20 +94,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
show.zoom_arm = false;
}
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
if let Some(key) = egui::Key::from_name(&name) {
if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) {
let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl };
// The popup is the Presenter View: G opens All slides there, not on the audience screen (#1866).
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], true).0;
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0;
}
}
crate::presenter::Command::Goto { slide } if show.presenter_grid && slide < doc.slides.len() => {
show.presenter_grid = false;
show.blank = None;
show.zoom = None;
show.zoom_arm = false;
let action = show.state.goto(&doc, slide);
out.keep &= show.apply(action, &doc, now);
}
_ => {}
}
}
@@ -156,20 +143,6 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
);
show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image));
}
if !show.presenter_grid {
show.remote_grid.clear();
} else if show.remote_grid.len() < grid_indices(doc.slides.len()).len() {
// A few thumbnails per update keep the audience window responsive on the web.
for i in grid_indices(doc.slides.len()).into_iter().skip(show.remote_grid.len()).take(4) {
// At most 96 px on the long side: 48 thumbnails stay well under the popup's message limit.
let (w, h) = (doc.slide_size.width.max(1.0), doc.slide_size.height.max(1.0));
let scale = REMOTE_THUMB / w.max(h);
let size = (((w * scale).round() as u32).max(1), ((h * scale).round() as u32).max(1));
let image = deckcraft_render::render_slide(&doc, i, &RenderOpts { scale, size: Some(size), ..Default::default() });
let preview = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false));
show.remote_grid.push(preview.unwrap_or(crate::presenter::Preview { width: 1, height: 1, rgba: vec![0; 4] }));
}
}
host.update(crate::presenter::Snapshot {
slide: show.state.slide,
total: doc.slides.len(),
@@ -179,12 +152,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
paused: show.timer.paused(),
notes_px: show.notes_px,
blank: show.blank_name().map(str::to_owned),
grid: show.presenter_grid,
grid: show.grid,
zoom_armed: show.zoom_arm,
// The grid replaces both previews in the popup; leaving them out keeps one message small.
current: show.remote_preview.clone().filter(|_| !show.presenter_grid),
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next && !show.presenter_grid).map(|(_, image)| image.clone()),
slides: show.presenter_grid.then(|| show.remote_grid.clone()),
current: show.remote_preview.clone(),
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()),
});
show.remote_sent = now;
}
@@ -193,9 +164,6 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
finish(app, show, &f, out.keep);
}
/// Longest side of an All slides thumbnail sent to the web presenter window (#1866).
const REMOTE_THUMB: f64 = 96.0;
fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) {
if show.started {
let full = ui.max_rect();
+1 -1
View File
@@ -182,7 +182,7 @@ const WHEEL_NOTCHES_PER_DOUBLING: f32 = 4.0;
/// PowerPoint's slide show magnifier goes up to 400%.
const WHEEL_ZOOM_MAX: f32 = 4.0;
pub(crate) const GRID_CAP: usize = 48;
pub(super) const GRID_CAP: usize = 48;
const TOOL_BAR_W: f32 = 520.0;
+4 -6
View File
@@ -209,8 +209,7 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
// Only cells in view (plus a little margin) ask for a thumbnail, so a deck bigger
// than the thumbnail cache does not evict its own thumbnails forever (#969).
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * cell.height())).intersects(cell);
let tex =
if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, app.ui.grayscale, budget) } else { None };
let tex = if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) } else { None };
match tex {
Some(tex) => {
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
@@ -392,7 +391,7 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
}
let ppp = ui.ctx().pixels_per_point();
let mut budget = 2;
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, app.ui.grayscale, &mut budget) {
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, &mut budget) {
ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside);
@@ -477,7 +476,7 @@ fn outline_estimate(s: &deckcraft_model::Slide) -> f32 {
let lines: usize = s
.shapes
.iter()
.filter(|x| !x.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()))
.filter(|x| !x.ph_type().is_some_and(|k| k.is_title()))
.filter_map(|x| x.text.as_ref())
.filter(|tb| !tb.is_empty())
.map(|tb| tb.paragraphs.len().max(1))
@@ -513,8 +512,7 @@ fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deck
ui.data_mut(|d| d.insert_temp(id, v));
}
});
// Footer, date and slide-number placeholders are not outline text, as in PowerPoint.
for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind())) {
for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) {
let Some(tb) = &sh.text else { continue };
if tb.is_empty() {
continue;
-21
View File
@@ -259,24 +259,3 @@ fn shift_and_ctrl_shift_clicks_select_ranges() {
click(&mut h, 4, Modifiers::COMMAND | Modifiers::SHIFT);
assert_eq!(selected(&h), vec![0, 2, 3]);
}
/// The outline view lists a slide's title and body text, not its footer, date and slide-number
/// placeholders, as PowerPoint's does: turning them on leaves the slide's outline block as it was.
#[test]
fn the_outline_view_leaves_footer_date_and_slide_number_out() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap();
let slide = |s: &Session| {
let st = s.doc().unwrap();
st.doc.slides[st.selection.slide].clone()
};
let plain = super::outline_estimate(&slide(&s));
s.execute(
"design.headerFooter",
&json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}),
)
.unwrap();
let with_footer = slide(&s);
assert!(with_footer.shapes.iter().any(|x| x.ph_type().is_some_and(|k| k.is_footer_kind())), "the footers are on the slide");
assert_eq!(super::outline_estimate(&with_footer), plain);
}
+6 -31
View File
@@ -361,19 +361,9 @@ impl Textures {
self.thumbs.clear();
}
/// A thumbnail texture; renders at most `budget` new thumbnails per frame. Over budget it
/// returns the slide's previous thumbnail if there is one (and asks for another frame), else
/// None, so the caller draws a placeholder.
/// When `grayscale` is true, the thumbnail renders in black-and-white (matching Grayscale view).
pub fn thumb(
&mut self,
ctx: &egui::Context,
p: &Presentation,
index: usize,
width_px: u32,
grayscale: bool,
budget: &mut u32,
) -> Option<TextureHandle> {
/// A thumbnail texture; renders at most `budget` new thumbnails per frame (returns None when
/// it is over budget, so the caller draws a placeholder and asks for another frame).
pub fn thumb(&mut self, ctx: &egui::Context, p: &Presentation, index: usize, width_px: u32, budget: &mut u32) -> Option<TextureHandle> {
let slide = p.slides.get(index)?.clone();
let master = master_of(p, &slide);
let h = (width_px as f64 * p.slide_size.height / p.slide_size.width.max(1.0)).round().max(1.0) as u32;
@@ -382,7 +372,7 @@ impl Textures {
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
size: (width_px, h),
edit: false,
extra: grayscale as u64,
extra: 0,
want: Region::slide(width_px, h),
editing_text: None,
};
@@ -395,8 +385,7 @@ impl Textures {
return Some(e.tex.clone());
}
if *budget == 0 {
// Keep showing the stale thumbnail while the new one waits; request a repaint to finish rendering.
ctx.request_repaint();
// Keep showing the stale thumbnail while the new one waits.
return self.thumbs.get(&key.slide).map(|e| e.tex.clone());
}
*budget -= 1;
@@ -404,7 +393,7 @@ impl Textures {
let scale = tw as f64 / p.slide_size.width.max(1.0);
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() });
self.thumb_renders += 1;
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, grayscale));
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, false));
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
let region = key.want;
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
@@ -444,20 +433,6 @@ mod tests {
assert_eq!(device_pixel_rect(aligned, ppp), aligned);
}
#[test]
fn to_color_image_turns_colors_to_grayscale_when_flag_is_set() {
let img = deckcraft_render::Image { width: 2, height: 1, pixels: vec![0, 0, 255, 255, 255, 0, 0, 255] };
let color = super::to_color_image(&img, false);
assert_eq!(color.pixels[0], egui::Color32::from_rgba_premultiplied(0, 0, 255, 255));
assert_eq!(color.pixels[1], egui::Color32::from_rgba_premultiplied(255, 0, 0, 255));
let gray = super::to_color_image(&img, true);
assert_eq!(gray.pixels[0].r(), gray.pixels[0].g());
assert_eq!(gray.pixels[0].g(), gray.pixels[0].b());
assert_eq!(gray.pixels[1].r(), gray.pixels[1].g());
assert_eq!(gray.pixels[1].g(), gray.pixels[1].b());
}
#[test]
fn a_bad_scale_does_not_move_the_rect() {
let r = Rect::from_min_size(pos2(3.0, 4.0), vec2(10.0, 12.0));
+2 -66
View File
@@ -65,75 +65,12 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
- The app fills the iframe and follows its size, so size the iframe and not the app.
- Keyboard shortcuts go to the iframe after the user clicks into it, as with any embedded app.
- **Cross-origin embeds** work. The web build keeps no preferences between visits; it starts
with the sample deck (or `?blank`) every time. Opening and saving through the page
(`?host=parent`, below) needs the same origin.
with the sample deck (or `?blank`) every time.
- **Sandboxed iframes** need at least
`sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"`. Without `allow-downloads`, Save and Export (browser
downloads) are blocked. With `?host=parent`, `allow-scripts allow-same-origin` is enough for
opening and saving (without `allow-same-origin` the frame's origin is `null` and there is no
bridge), and `allow-downloads` isn't needed.
downloads) are blocked.
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
## Opening and saving through the page (`?host=parent`)
A page that keeps the files itself (a Nextcloud app, a file server) can frame DeckCraft with
`?host=parent` on the iframe `src`, hand it a presentation and take back what it saves, instead of
the user downloading. GridCraft, WordCraft and PdfCraft have the same bridge with their own prefix.
The page and DeckCraft must be on the **same origin**: DeckCraft only hears and answers its parent
window on its own origin, and without one it downloads as usual.
Messages are plain objects with a `type`:
| Message | Direction | Fields |
|---|---|---|
| `deckcraft:ready` | to the page | `version`. The app runs and listens: send the file now. |
| `deckcraft:open` | from the page | `name` (a bare file name, such as `Talk.pptx`), `bytes` (`ArrayBuffer` or `Uint8Array`). Opens like File › Open: a presentation (.pptx, .potx, .ppsx, .deckcraft) has that name as its file; an outline (.txt, .md, .rtf), a file sent without a name and a damaged file open untitled. |
| `deckcraft:save` | to the page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`), for every file DeckCraft would otherwise download. The page stores it. |
| `deckcraft:dirty` | to the page | `dirty`, whenever unsaved changes appear or go away. |
| `deckcraft:failed` | to the page | `error`, when the app couldn't start (no WebGPU or WebGL2). |
`saveAs` says whether the file becomes the presentation's file, as on the desktop:
- **Save** sends the file the page sent, under its own name and format (`Talk.pptx` comes back
as `Talk.pptx`), with `saveAs: false`. DeckCraft keeps only the part after the last `/`, so
`docs/Talk.pptx` also comes back as `Talk.pptx`. A presentation that has no file the page sent
(a new one, an outline, a file sent without a name, a file the user dropped, a repaired copy of
a damaged file) is a new file with `saveAs: true`: Ctrl+S names it `<title>.deckcraft` as Save
suggests on the desktop, and File › Save opens the Save As page first, as on the desktop.
- **Save As** (.pptx or .deckcraft) sends `saveAs: true`. The presentation is now that file; the
next Save sends its name with `saveAs: false`.
- **Exports and Print** (PDF, handouts, pictures, outline, GIF) send `saveAs: false` and are
copies: the presentation keeps its file and its unsaved changes.
`saveAs: false` doesn't mean "safe to overwrite": an export is `false` under a name the page may
already hold (`Talk.pdf`, or `Talk.pptx` exported from a presentation that is now
`Talk.deckcraft`). Replace a file without asking only when the name is the one the page sent, or
the name of the last `saveAs: true` file it stored for this presentation; ask for anything else.
DeckCraft doesn't learn the name the page finally stores a Save As under, so a page that renames
on a conflict should map later Saves of the old name itself.
In host mode DeckCraft starts with an empty presentation instead of the sample, and the first
presentation the page sends takes its place unless the user has changed it; later ones open
beside it. The page guards leaving itself (DeckCraft adds no guard of its own), and
`&author=Name` signs the user's comments.
```html
<iframe id="deck" src="/deckcraft/?host=parent&author=Ada" style="width: 100%; height: 720px; border: 0;"></iframe>
<script>
const frame = document.getElementById("deck");
addEventListener("message", async (e) => {
if (e.origin !== location.origin || e.source !== frame.contentWindow) return;
if (e.data.type === "deckcraft:ready") {
const bytes = await (await fetch("/files/Talk.pptx")).arrayBuffer();
frame.contentWindow.postMessage({ type: "deckcraft:open", name: "Talk.pptx", bytes }, location.origin, [bytes]);
} else if (e.data.type === "deckcraft:save") {
// Store e.data.bytes as e.data.name. Replace silently only when !e.data.saveAs and the name
// is the one sent (or the last saveAs file stored); ask before replacing anything else.
}
});
</script>
```
## Renderer selection and fallback flags
DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
@@ -145,7 +82,6 @@ DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and fall
| `?webgl` | Force the WebGL2 backend (useful when a WebGPU driver misbehaves) |
| `?blank` | Start with an empty presentation instead of the sample deck |
| `?show` | Start the slide show immediately (kiosk/embedded decks) |
| `?host=parent` | Open and save through the framing page ([above](#opening-and-saving-through-the-page-hostparent)); `&author=` names the user |
For example: `<iframe src="https://example.com/deckcraft/?webgl" ...>`.
+1 -7
View File
@@ -18,13 +18,7 @@ fn walk(dir: &Path, c: &mut Counts) {
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
// `file_type` does not follow links, so a directory link is not walked (and aliases are
// not counted twice).
let Ok(kind) = e.file_type() else { continue };
if kind.is_symlink() {
continue;
}
if kind.is_dir() {
if p.is_dir() {
// Nested fuzz workspaces and build output are not part of the crate.
if name != "target" && name != "fuzz" && !name.starts_with('.') {
walk(&p, c);