mirror of
https://github.com/storytold/deckcraft.git
synced 2026-11-05 01:49:41 -06:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1520a6692 |
@@ -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
+11
-132
@@ -565,42 +565,18 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop"
|
||||
version = "0.14.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"polling",
|
||||
"rustix 1.1.5",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
|
||||
dependencies = [
|
||||
"calloop 0.13.0",
|
||||
"calloop",
|
||||
"rustix 0.38.44",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "calloop-wayland-source"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
|
||||
dependencies = [
|
||||
"calloop 0.14.5",
|
||||
"rustix 1.1.5",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cast"
|
||||
version = "0.3.0"
|
||||
@@ -1299,7 +1275,6 @@ dependencies = [
|
||||
"deckcraft-render",
|
||||
"deckcraft-text",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"egui_kittest",
|
||||
"log",
|
||||
"serde",
|
||||
@@ -1532,35 +1507,19 @@ dependencies = [
|
||||
"block2 0.6.2",
|
||||
"bytemuck",
|
||||
"egui",
|
||||
"image",
|
||||
"log",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-ui-kit 0.3.2",
|
||||
"parking_lot",
|
||||
"profiling",
|
||||
"raw-window-handle",
|
||||
"smithay-clipboard",
|
||||
"web-time",
|
||||
"webbrowser",
|
||||
"windows-sys 0.61.2",
|
||||
"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"
|
||||
@@ -1628,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"
|
||||
@@ -3548,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]]
|
||||
@@ -4464,8 +4403,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"calloop 0.13.0",
|
||||
"calloop-wayland-source 0.3.0",
|
||||
"calloop",
|
||||
"calloop-wayland-source",
|
||||
"cursor-icon",
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4482,44 +4421,6 @@ dependencies = [
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-client-toolkit"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"calloop 0.14.5",
|
||||
"calloop-wayland-source 0.4.1",
|
||||
"cursor-icon",
|
||||
"libc",
|
||||
"log",
|
||||
"memmap2",
|
||||
"rustix 1.1.5",
|
||||
"thiserror 2.0.21",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-csd-frame",
|
||||
"wayland-cursor",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-experimental",
|
||||
"wayland-protocols-misc",
|
||||
"wayland-protocols-wlr",
|
||||
"wayland-scanner",
|
||||
"xkeysym",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smithay-clipboard"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"smithay-client-toolkit 0.20.0",
|
||||
"wayland-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "smol_str"
|
||||
version = "0.2.2"
|
||||
@@ -5027,7 +4928,6 @@ version = "0.1.44"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
||||
dependencies = [
|
||||
"log",
|
||||
"pin-project-lite",
|
||||
"tracing-attributes",
|
||||
"tracing-core",
|
||||
@@ -5470,32 +5370,6 @@ dependencies = [
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-experimental"
|
||||
version = "20250721.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-misc"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-plasma"
|
||||
version = "0.3.12"
|
||||
@@ -6106,7 +5980,7 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.5.1",
|
||||
"bytemuck",
|
||||
"calloop 0.13.0",
|
||||
"calloop",
|
||||
"cfg_aliases",
|
||||
"concurrent-queue",
|
||||
"core-foundation 0.9.4",
|
||||
@@ -6127,7 +6001,7 @@ dependencies = [
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.4.1",
|
||||
"rustix 0.38.44",
|
||||
"smithay-client-toolkit 0.19.2",
|
||||
"smithay-client-toolkit",
|
||||
"smol_str",
|
||||
"tracing",
|
||||
"unicode-segmentation",
|
||||
@@ -6637,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"
|
||||
|
||||
-12
@@ -2,12 +2,8 @@
|
||||
|
||||
**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 (progress log: fix for a workflow-breaking regression: Ctrl+C/X/V use the system clipboard again, broken for all text and objects from 0.11.1 to 0.15.0; pasted and duplicated placeholders keep their look; stage and 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)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -154,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 |
|
||||
@@ -165,9 +159,6 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-11-02 | PPTX reflection effects keep their authored start position, affine scale/skew, direction and fade, alignment, and rotation flag through native and PPTX saves |
|
||||
| 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables |
|
||||
| 2026-11-02 | Tiled picture fills in groups: a tile (`a:blipFill` with `a:tile`) keeps its size, offset and anchor in slide points whatever the group's child-coordinate scale, uniform or stretched unevenly, nested or flipped (also a group's own tile fill passed to its members, and a picture shape's own tile, with its shadow and reflection), and tiles with `rotWithShape="0"` keep their size in points at any zoom, as PowerPoint draws them (measured on its own PNG export of a synthetic probe deck: every grouped case within ~3/255 of the ungrouped control's own difference from PowerPoint, which is ~13/255 from resampling). Before, a group whose child space was ~1800 times smaller than its box showed one flat texel of the texture (a Venn diagram with paper-textured circles and a hatched intersection) |
|
||||
| 2026-11-01 | Ctrl+V of a picture copied in a browser (Copy Image) pastes the picture, not a text box with its link and alt text; the link still pastes as text when no picture came with it |
|
||||
| 2026-11-01 | Copy, Cut, Paste, Duplicate and Alt+drag of a title, body or other placeholder on a slide keep its look: the copy is a regular shape that takes the size, font, bullets, indents, autofit and box its layout and master gave it, instead of 18 pt unbulleted text |
|
||||
| 2026-11-01 | Clipboard: Ctrl+C, Ctrl+X and Ctrl+V use the system clipboard again, so text and pictures paste from other apps and in-app copies paste back. Since the egui 0.37 migration (#1350) the desktop build had a clipboard that only worked inside the app. On native Wayland, text goes through egui's Wayland clipboard; HTML stays on X11, macOS and Windows |
|
||||
| 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) |
|
||||
| 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits |
|
||||
| 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up |
|
||||
@@ -339,13 +330,10 @@ 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-11-01 | trivial | Workflow-breaking regression fixed: the desktop build uses the system clipboard again (eframe `clipboard` feature) |
|
||||
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
|
||||
| 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged |
|
||||
| 2026-10-29 | trivial | Progress log: going back in the slide show reverses the left slide's transition (#1531). Scores and milestone names unchanged |
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")]
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -14,9 +14,7 @@ workspace = true
|
||||
deckcraft-engine = { workspace = true }
|
||||
deckcraft-ui-egui = { workspace = true }
|
||||
deckcraft-media = { workspace = true }
|
||||
# `clipboard` is the OS clipboard (Ctrl+C/X/V with other apps). Without it egui simulates one inside
|
||||
# the app; the egui 0.37 migration (#1350) dropped it from this list.
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "links", "wayland", "x11"] }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -103,13 +103,7 @@ fn save_prefs(app: &SlideApp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The desktop services. `native_wayland`: the window is a Wayland client (not Xwayland).
|
||||
fn services(native_wayland: bool) -> Services {
|
||||
// On native Wayland, egui-winit owns the clipboard through winit's data device, which carries
|
||||
// text and PNG but no HTML. `pasteboard` (arboard, built for X11 only) would put a copy on the
|
||||
// Xwayland selection instead and skip egui's Wayland claim, so Ctrl+V would read something
|
||||
// else. There, text goes through egui and pictures arrive as `PasteImage` events.
|
||||
let pasteboard = !native_wayland;
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|purpose: &str| {
|
||||
let d = rfd::FileDialog::new();
|
||||
@@ -132,9 +126,9 @@ fn services(native_wayland: bool) -> Services {
|
||||
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
|
||||
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
|
||||
clipboard_image: pasteboard.then(|| Box::new(pasteboard::image_png) as Box<dyn FnMut() -> Option<Vec<u8>>>),
|
||||
clipboard_html: pasteboard.then(|| Box::new(pasteboard::html) as Box<dyn FnMut() -> Option<String>>),
|
||||
clipboard_set_html: pasteboard.then(|| Box::new(pasteboard::set_html) as Box<dyn FnMut(&str, &str) -> bool>),
|
||||
clipboard_image: Some(Box::new(pasteboard::image_png)),
|
||||
clipboard_html: Some(Box::new(pasteboard::html)),
|
||||
clipboard_set_html: Some(Box::new(pasteboard::set_html)),
|
||||
// Media is mixed and clocked by the output device; `DECKCRAFT_NO_AUDIO` plays silently.
|
||||
audio_out: std::env::var_os("DECKCRAFT_NO_AUDIO")
|
||||
.is_none()
|
||||
@@ -253,11 +247,6 @@ fn main() -> eframe::Result {
|
||||
false
|
||||
}
|
||||
};
|
||||
// `Default` lets winit choose, and winit takes Wayland whenever `WAYLAND_DISPLAY` is set.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
let native_wayland = !x11 && std::env::var_os("WAYLAND_DISPLAY").is_some_and(|w| !w.is_empty());
|
||||
#[cfg(not(all(unix, not(target_os = "macos"))))]
|
||||
let native_wayland = false;
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if x11 {
|
||||
options.event_loop_builder = Some(Box::new(|b| {
|
||||
@@ -279,7 +268,7 @@ fn main() -> eframe::Result {
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
|
||||
let mut app = SlideApp::new(Session::new(), services(native_wayland));
|
||||
let mut app = SlideApp::new(Session::new(), services());
|
||||
load_prefs(&mut app);
|
||||
app.enable_update_checks();
|
||||
// AutoRecover: reopen what a previous run left unsaved, then keep it current.
|
||||
@@ -346,26 +335,6 @@ mod tests {
|
||||
assert_eq!(stdout.contains("on_exit returned"), !cfg!(target_os = "macos"), "{stdout}");
|
||||
}
|
||||
|
||||
/// The egui 0.37 migration (#1350) left `clipboard` out of eframe's features. egui then
|
||||
/// simulated a clipboard inside the app: nothing pasted from other apps, and Ctrl+C/Ctrl+V
|
||||
/// lost text that went out as HTML. Keep the OS clipboard on.
|
||||
#[test]
|
||||
fn eframe_uses_the_os_clipboard() {
|
||||
let manifest = include_str!("../Cargo.toml");
|
||||
let eframe = manifest.lines().find(|l| l.starts_with("eframe = ")).unwrap_or_default();
|
||||
assert!(eframe.contains("\"clipboard\""), "eframe must enable the OS clipboard: {eframe}");
|
||||
}
|
||||
|
||||
/// On native Wayland, the X11-only pasteboard would bypass egui's Wayland clipboard; elsewhere
|
||||
/// it carries HTML and pictures beside egui's text.
|
||||
#[test]
|
||||
fn the_pasteboard_stays_off_native_wayland() {
|
||||
let s = services(true);
|
||||
assert!(s.clipboard_html.is_none() && s.clipboard_set_html.is_none() && s.clipboard_image.is_none());
|
||||
let s = services(false);
|
||||
assert!(s.clipboard_html.is_some() && s.clipboard_set_html.is_some() && s.clipboard_image.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_exits_instead_of_opening_a_file() {
|
||||
assert!(matches!(startup(None, ["--help".to_string()]), Startup::Help));
|
||||
|
||||
@@ -107,7 +107,7 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
s.clipboard = crate::Clipboard { text: Some(TextBody::from_text(&plain)), plain: plain.clone(), ..Default::default() };
|
||||
return Ok(plain);
|
||||
}
|
||||
let mut shapes: Vec<Shape> = st
|
||||
let shapes: Vec<Shape> = st
|
||||
.selection
|
||||
.shapes
|
||||
.iter()
|
||||
@@ -120,33 +120,14 @@ fn copy_inner(s: &mut Session, p: &Value) -> Result<String> {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// Copying is the last chance to read what a placeholder inherits: a cut removes it, and the
|
||||
// paste can be in another deck. Slide Master view pastes the originals instead.
|
||||
let placeholders: Vec<Shape> = shapes.iter().filter(|x| x.ph.is_some()).cloned().collect();
|
||||
detach_on_slide(&st.doc, &st.selection, &mut shapes);
|
||||
let mids = crate::media_refs::shapes_media(&shapes);
|
||||
let media = st.doc.media.iter().filter(|m| mids.contains(&m.id)).cloned().collect();
|
||||
let plain = shapes.iter().filter_map(|x| x.text.as_ref().map(|t| t.text())).collect::<Vec<_>>().join("\n");
|
||||
// The theme of what is being edited: a slide's or layout's colour map, else the master's.
|
||||
let source_theme = super::text::ctx_for(&st.doc, &st.selection).map(|ctx| crate::SourceTheme {
|
||||
colors: ctx.master.theme.colors.clone(),
|
||||
color_map: ctx.color_map.clone(),
|
||||
fonts: ctx.master.theme.fonts.clone(),
|
||||
});
|
||||
s.clipboard = crate::Clipboard { shapes, placeholders, media, plain: plain.clone(), source_theme, ..Default::default() };
|
||||
let source_theme = st.current_slide().and_then(|sl| st.doc.master_for(sl)).map(|(m, _)| (m.scheme(), m.theme.fonts.clone()));
|
||||
s.clipboard = crate::Clipboard { shapes, media, plain: plain.clone(), source_theme, ..Default::default() };
|
||||
Ok(plain)
|
||||
}
|
||||
|
||||
/// On a slide, a copy of a placeholder is a regular shape: give `shapes` the look they inherit
|
||||
/// there. In Slide Master view a copy stays a placeholder, so this changes nothing.
|
||||
pub(crate) fn detach_on_slide(doc: &deckcraft_model::Presentation, sel: &crate::Selection, shapes: &mut [Shape]) {
|
||||
if sel.target == crate::Target::Slides
|
||||
&& let Some(ctx) = super::text::ctx_for(doc, sel)
|
||||
{
|
||||
shapes.iter_mut().for_each(|sh| deckcraft_model::resolve::detach_placeholder(&ctx, sh));
|
||||
}
|
||||
}
|
||||
|
||||
fn copying_caret(s: &Session) -> bool {
|
||||
s.active().is_some_and(|d| d.selection.text.as_ref().is_some_and(|t| !t.is_range()))
|
||||
}
|
||||
@@ -338,13 +319,6 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return s.edit(|doc, sel| {
|
||||
let media = bring_media("edit.paste", doc, &clip.media)?;
|
||||
let mut shapes = clip.shapes.clone();
|
||||
if sel.target != crate::Target::Slides {
|
||||
for sh in &mut shapes {
|
||||
if let Some(original) = clip.placeholders.iter().find(|p| p.id == sh.id) {
|
||||
sh.clone_from(original);
|
||||
}
|
||||
}
|
||||
}
|
||||
reid("edit.paste", doc, &mut shapes)?;
|
||||
crate::media_refs::visit_mut::shapes(&mut shapes, &mut remap_media(&media));
|
||||
let (mut taken, mut count) = super::current_names(doc, sel);
|
||||
@@ -366,8 +340,7 @@ fn paste(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
x.x += off;
|
||||
x.y += off;
|
||||
}
|
||||
// A placeholder pasted on a slide becomes a regular shape. One copied from a slide
|
||||
// already has its look; see `copy_inner`.
|
||||
// A pasted placeholder becomes a regular shape keeping its look.
|
||||
if sh.ph.is_some() && sel.target == crate::Target::Slides {
|
||||
sh.ph = None;
|
||||
}
|
||||
@@ -431,9 +404,8 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"text" | "keepTextOnly" => paste_text(s, &json!({})),
|
||||
"keepSource" | "keepSourceFormatting" => {
|
||||
let saved = s.clipboard.clone();
|
||||
if let Some(theme) = &saved.source_theme {
|
||||
bake_theme(&mut s.clipboard.shapes, theme);
|
||||
bake_theme(&mut s.clipboard.placeholders, theme);
|
||||
if let Some((scheme, fonts)) = &saved.source_theme {
|
||||
bake_theme(&mut s.clipboard.shapes, scheme, fonts);
|
||||
}
|
||||
let r = paste(s, &json!({}));
|
||||
let pastes = s.clipboard.pastes;
|
||||
@@ -448,8 +420,8 @@ fn paste_special(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
/// Replace theme colours and theme fonts with the values they have in `scheme` / `fonts`, so the
|
||||
/// shapes keep their look under another theme.
|
||||
pub(crate) fn bake_theme(shapes: &mut [Shape], theme: &crate::SourceTheme) {
|
||||
fn rec(v: &mut Value, theme: &crate::SourceTheme, depth: usize) {
|
||||
pub(crate) fn bake_theme(shapes: &mut [Shape], scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme) {
|
||||
fn rec(v: &mut Value, scheme: &deckcraft_color::ColorScheme, fonts: &deckcraft_model::theme::FontScheme, depth: usize) {
|
||||
if depth > 256 {
|
||||
return;
|
||||
}
|
||||
@@ -458,7 +430,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], theme: &crate::SourceTheme) {
|
||||
if o.get("kind").and_then(Value::as_str) == Some("scheme")
|
||||
&& let Some(slot) = o.get("slot").and_then(|x| serde_json::from_value::<deckcraft_color::SchemeSlot>(x.clone()).ok())
|
||||
&& slot != deckcraft_color::SchemeSlot::PhClr
|
||||
&& let Ok(rgb) = serde_json::to_value(theme.colors.get(deckcraft_model::map_slot(&theme.color_map, slot)))
|
||||
&& let Ok(rgb) = serde_json::to_value(scheme.get(slot))
|
||||
{
|
||||
o.clear();
|
||||
o.insert("kind".into(), json!("rgb"));
|
||||
@@ -466,14 +438,14 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], theme: &crate::SourceTheme) {
|
||||
return;
|
||||
}
|
||||
for x in o.values_mut() {
|
||||
rec(x, theme, depth + 1);
|
||||
rec(x, scheme, fonts, depth + 1);
|
||||
}
|
||||
}
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, theme, depth + 1)),
|
||||
Value::Array(a) => a.iter_mut().for_each(|x| rec(x, scheme, fonts, depth + 1)),
|
||||
Value::String(t) if t.starts_with('+') => {
|
||||
let set = match t.get(1..3) {
|
||||
Some("mj") => &theme.fonts.major,
|
||||
Some("mn") => &theme.fonts.minor,
|
||||
Some("mj") => &fonts.major,
|
||||
Some("mn") => &fonts.minor,
|
||||
_ => return,
|
||||
};
|
||||
let face = match t.get(3..) {
|
||||
@@ -491,7 +463,7 @@ pub(crate) fn bake_theme(shapes: &mut [Shape], theme: &crate::SourceTheme) {
|
||||
}
|
||||
for sh in shapes.iter_mut() {
|
||||
let Ok(mut v) = serde_json::to_value(&*sh) else { continue };
|
||||
rec(&mut v, theme, 0);
|
||||
rec(&mut v, scheme, fonts, 0);
|
||||
if let Ok(baked) = serde_json::from_value::<Shape>(v) {
|
||||
*sh = baked;
|
||||
}
|
||||
@@ -504,8 +476,8 @@ fn paste_picture(s: &mut Session) -> Result<Value> {
|
||||
if clip.shapes.is_empty() {
|
||||
return Err(bad("edit.pasteSpecial", "copy shapes first to paste them as a picture"));
|
||||
}
|
||||
if let Some(theme) = &clip.source_theme {
|
||||
bake_theme(&mut clip.shapes, theme);
|
||||
if let Some((scheme, fonts)) = &clip.source_theme {
|
||||
bake_theme(&mut clip.shapes, scheme, fonts);
|
||||
}
|
||||
let st = s.doc()?;
|
||||
let mut doc = (*st.doc).clone();
|
||||
@@ -561,7 +533,6 @@ fn duplicate(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
|
||||
let sources: Vec<ShapeId> = copies.iter().map(|c| c.id).collect();
|
||||
let boxes: Vec<_> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
|
||||
detach_on_slide(doc, sel, &mut copies);
|
||||
// Duplicating the copies of the last Duplicate again repeats how far they were moved
|
||||
// since (#966); anything else gets the usual offset.
|
||||
let (dx, dy) = last
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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")?
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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![];
|
||||
|
||||
@@ -454,30 +454,17 @@ pub struct MediaStatus {
|
||||
}
|
||||
|
||||
/// Clipboard contents copied within the app.
|
||||
/// The theme a copy drew with. The colours and the map stay apart, as in
|
||||
/// `deckcraft_model::resolve::Ctx::color`: the map renames only `tx1`, `bg1`, `tx2` and `bg2`, and
|
||||
/// a direct `dk1` keeps the theme's colour.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SourceTheme {
|
||||
/// The theme's own colours, no colour map applied.
|
||||
pub colors: deckcraft_color::ColorScheme,
|
||||
pub color_map: Vec<(String, String)>,
|
||||
pub fonts: deckcraft_model::theme::FontScheme,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Clipboard {
|
||||
pub shapes: Vec<Shape>,
|
||||
/// The copied placeholders as they were, by id. `shapes` holds a slide's placeholders as
|
||||
/// regular shapes; a paste in Slide Master view takes these, which stay placeholders.
|
||||
pub placeholders: Vec<Shape>,
|
||||
pub slides: Vec<Slide>,
|
||||
/// Media the copied shapes/slides reference.
|
||||
pub media: Vec<deckcraft_model::MediaItem>,
|
||||
pub text: Option<deckcraft_model::TextBody>,
|
||||
pub plain: String,
|
||||
/// The theme the shapes were copied under, for Paste Special › Keep Source Formatting.
|
||||
pub source_theme: Option<SourceTheme>,
|
||||
/// The copied shapes' theme colours (colour map applied) and fonts, for Paste Special ›
|
||||
/// Keep Source Formatting.
|
||||
pub source_theme: Option<(deckcraft_color::ColorScheme, deckcraft_model::theme::FontScheme)>,
|
||||
/// How many times the shapes were pasted (each paste offsets further).
|
||||
pub pastes: u32,
|
||||
}
|
||||
|
||||
@@ -214,368 +214,6 @@ fn clipboard_copy_paste_shapes_and_slides() {
|
||||
assert!(s.doc().unwrap().doc.validate().is_empty());
|
||||
}
|
||||
|
||||
/// The resolved look of every paragraph and run of shape `id` on the current slide: size, font,
|
||||
/// colour, bold, bullet, indent, alignment, and the box's insets and anchor.
|
||||
fn resolved_look(s: &Session, id: ShapeId) -> String {
|
||||
use deckcraft_model::resolve;
|
||||
let st = s.doc().unwrap();
|
||||
let slide = st.current_slide().unwrap();
|
||||
let ctx = resolve::Ctx::for_slide(&st.doc, slide).unwrap();
|
||||
let sh = st.shape(id).unwrap();
|
||||
let t = sh.text.as_ref().unwrap();
|
||||
let b = resolve::body(&ctx, sh);
|
||||
let mut out = format!("body {:?} {:?} {:?}\n", b.anchor, b.inset_l, b.autofit);
|
||||
for p in &t.paragraphs {
|
||||
let pp = resolve::para(&ctx, sh, p);
|
||||
out += &format!("para {:?} {:?} {:?} {:?}\n", pp.align, pp.margin_left, pp.indent, pp.bullet);
|
||||
for r in &p.runs {
|
||||
let rp = resolve::run(&ctx, sh, p, &r.props);
|
||||
out += &format!(" run {:?} {:?} {:?} {:?}\n", rp.size, rp.font, rp.bold, rp.fill);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A placeholder pasted or duplicated on a slide becomes a regular shape, and keeps the look its
|
||||
/// layout and master gave it: the title's size and font, the body's bullets and indents. Before,
|
||||
/// the copy fell back to the master's other-text style (18 pt, no bullets).
|
||||
#[test]
|
||||
fn a_pasted_or_duplicated_placeholder_keeps_its_inherited_look() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let phs: Vec<ShapeId> = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect();
|
||||
assert!(phs.len() >= 2, "a title and a body placeholder");
|
||||
for (k, id) in phs.iter().enumerate() {
|
||||
s.execute("text.set", &json!({"id": id.0, "text": format!("Placeholder {k}\nSecond line")})).unwrap();
|
||||
}
|
||||
for id in phs {
|
||||
let want = resolved_look(&s, id);
|
||||
for cmd in ["paste", "duplicate"] {
|
||||
s.execute("selection.select", &json!({"id": id.0})).unwrap();
|
||||
if cmd == "paste" {
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
} else {
|
||||
s.execute("edit.duplicate", &json!({})).unwrap();
|
||||
}
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_ne!(copy, id);
|
||||
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "{cmd}: the copy is a regular shape");
|
||||
assert_eq!(resolved_look(&s, copy), want, "{cmd} of placeholder {}", id.0);
|
||||
}
|
||||
}
|
||||
assert!(s.doc().unwrap().doc.validate().is_empty());
|
||||
}
|
||||
|
||||
/// Dragging a copy of a placeholder off it keeps the look too: the title's size and font, the
|
||||
/// body's bullets. `edit.duplicate` is not the only way to duplicate.
|
||||
#[test]
|
||||
fn a_placeholder_duplicated_by_dragging_keeps_its_look() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let phs: Vec<ShapeId> = s.doc().unwrap().current_slide().unwrap().shapes.iter().filter(|x| x.ph.is_some()).map(|x| x.id).collect();
|
||||
assert!(phs.len() >= 2, "a title and a body placeholder");
|
||||
for id in phs {
|
||||
s.execute("text.set", &json!({"id": id.0, "text": "First\nSecond"})).unwrap();
|
||||
let want = resolved_look(&s, id);
|
||||
s.execute("selection.select", &json!({"id": id.0})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let x = crate::cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap());
|
||||
let (cx, cy) = (x.x + x.w / 2.0, x.y + x.h / 2.0);
|
||||
let alt = |kind, x, y| PointerEvent { mods: Mods { alt: true, ..Default::default() }, ..tev(kind, x, y) };
|
||||
s.pointer(alt(PointerKind::Down, cx, cy)).unwrap();
|
||||
s.pointer(alt(PointerKind::Drag, cx + 30.0, cy + 30.0)).unwrap();
|
||||
s.pointer(alt(PointerKind::Up, cx + 30.0, cy + 30.0)).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_ne!(copy, id, "the drag made a copy");
|
||||
assert!(s.doc().unwrap().shape(id).unwrap().ph.is_some(), "the original stays a placeholder");
|
||||
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "the copy is a regular shape");
|
||||
assert_eq!(resolved_look(&s, copy), want, "placeholder {}", id.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep Source Formatting fixes a copy's theme colours as the source drew them: through the colour
|
||||
/// map of its slide or layout, not the master's. A title's inherited `tx1` stays the light colour
|
||||
/// an override gave it.
|
||||
#[test]
|
||||
fn keep_source_formatting_uses_the_slides_colour_map() {
|
||||
use deckcraft_model::resolve;
|
||||
let first_run_colour = |s: &Session, id: ShapeId| {
|
||||
let st = s.doc().unwrap();
|
||||
let ctx = resolve::Ctx::for_slide(&st.doc, st.current_slide().unwrap()).unwrap();
|
||||
let sh = st.shape(id).unwrap();
|
||||
let p = &sh.text.as_ref().unwrap().paragraphs[0];
|
||||
resolve::text_color(&ctx, &resolve::run(&ctx, sh, p, &p.runs[0].props))
|
||||
};
|
||||
for on_layout in [false, true] {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id;
|
||||
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
|
||||
let plain = first_run_colour(&s, title);
|
||||
let st = s.doc_mut().unwrap();
|
||||
let at = st.selection.slide;
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
if on_layout {
|
||||
let layout = doc.slides[at].layout;
|
||||
let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
|
||||
m.layouts.iter_mut().find(|l| l.id == layout).unwrap().color_map = swapped_colour_map();
|
||||
} else {
|
||||
std::sync::Arc::make_mut(&mut doc.slides[at]).color_map = swapped_colour_map();
|
||||
}
|
||||
let want = first_run_colour(&s, title);
|
||||
assert_ne!(want, plain, "the override changes how the title draws");
|
||||
|
||||
s.execute("selection.select", &json!({"id": title.0})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_ne!(copy, title);
|
||||
assert_eq!(first_run_colour(&s, copy), want, "override on the layout: {on_layout}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour map that swaps text and background, as a dark slide or layout has.
|
||||
fn swapped_colour_map() -> Option<Vec<(String, String)>> {
|
||||
Some([("bg1", "dk1"), ("tx1", "lt1"), ("bg2", "dk2"), ("tx2", "lt2")].map(|(a, b)| (a.to_string(), b.to_string())).to_vec())
|
||||
}
|
||||
|
||||
/// A new rectangle in the edited tree, filled with theme colour `slot`.
|
||||
fn rect_filled_with(s: &mut Session, slot: deckcraft_color::SchemeSlot) -> ShapeId {
|
||||
use deckcraft_model::{ColorRef, Fill};
|
||||
let id = s.execute("shape.insert", &json!({"preset": "rect", "rect": [10, 10, 50, 50]})).unwrap()["id"].as_u64().unwrap();
|
||||
let id = ShapeId(u32::try_from(id).unwrap());
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).unwrap();
|
||||
list.iter_mut().find(|x| x.id == id).unwrap().fill = Some(Fill::solid(ColorRef::scheme(slot)));
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
/// How shape `id`'s fill draws in the edited tree.
|
||||
fn drawn_fill(s: &Session, id: ShapeId) -> deckcraft_color::Rgba {
|
||||
use deckcraft_model::{Fill, resolve};
|
||||
let st = s.doc().unwrap();
|
||||
let ctx = crate::cmd::text::ctx_for(&st.doc, &st.selection).unwrap();
|
||||
match resolve::fill(&ctx, st.shape(id).unwrap()).0 {
|
||||
Some(Fill::Solid { color }) => ctx.color(&color, None),
|
||||
other => panic!("not a solid fill: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy the selected shape and paste it with Keep Source Formatting; the copy's id.
|
||||
fn keep_source_copy(s: &mut Session, id: ShapeId) -> ShapeId {
|
||||
s.execute("selection.select", &json!({"id": id.0})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.pasteSpecial", &json!({"as": "keepSource"})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_ne!(copy, id);
|
||||
copy
|
||||
}
|
||||
|
||||
/// A colour map renames only `tx1`, `bg1`, `tx2` and `bg2`. Keep Source Formatting fixes every slot
|
||||
/// as it draws: `tx1` follows the map, while a direct `dk1` stays the theme's dark colour.
|
||||
#[test]
|
||||
fn keep_source_formatting_maps_only_the_alias_colours() {
|
||||
use deckcraft_color::SchemeSlot::{self, *};
|
||||
let mut s = session();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let at = st.selection.slide;
|
||||
std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).slides[at]).color_map = swapped_colour_map();
|
||||
let slots: [SchemeSlot; 9] = [Dk1, Lt1, Dk2, Lt2, Tx1, Bg1, Tx2, Bg2, Accent1];
|
||||
let mut drawn = vec![];
|
||||
for slot in slots {
|
||||
let id = rect_filled_with(&mut s, slot);
|
||||
let want = drawn_fill(&s, id);
|
||||
let copy = keep_source_copy(&mut s, id);
|
||||
assert_eq!(drawn_fill(&s, copy), want, "{slot:?}");
|
||||
drawn.push(want);
|
||||
}
|
||||
assert_ne!(drawn[0], drawn[4], "the map sets `tx1` apart from `dk1`");
|
||||
}
|
||||
|
||||
/// In Slide Master view a copy draws through the edited master's map, not an override on the slide
|
||||
/// that happens to be current.
|
||||
#[test]
|
||||
fn keep_source_formatting_in_slide_master_view_ignores_the_slides_colour_map() {
|
||||
use deckcraft_color::SchemeSlot::Tx1;
|
||||
for master in [false, true] {
|
||||
let mut s = session();
|
||||
let st = s.doc_mut().unwrap();
|
||||
let at = st.selection.slide;
|
||||
std::sync::Arc::make_mut(&mut std::sync::Arc::make_mut(&mut st.doc).slides[at]).color_map = swapped_colour_map();
|
||||
s.execute("view.slideMaster", &if master { json!({"master": 0}) } else { json!({}) }).unwrap();
|
||||
let id = rect_filled_with(&mut s, Tx1);
|
||||
let want = drawn_fill(&s, id);
|
||||
let copy = keep_source_copy(&mut s, id);
|
||||
assert_eq!(drawn_fill(&s, copy), want, "editing the master: {master}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A slide's placeholder pasted in Slide Master view stays a placeholder that inherits from where
|
||||
/// it lands; only a paste on a slide turns it into a regular shape with the look it had.
|
||||
#[test]
|
||||
fn a_slide_placeholder_pasted_in_slide_master_view_stays_a_placeholder() {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes.iter().find(|x| x.ph.is_some()).unwrap().id;
|
||||
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
|
||||
let want = resolved_look(&s, title);
|
||||
let source = s.doc().unwrap().shape(title).unwrap().clone();
|
||||
s.execute("selection.select", &json!({"id": title.0})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
|
||||
s.execute("view.slideMaster", &json!({})).unwrap();
|
||||
for paste in ["edit.paste", "edit.pasteSpecial"] {
|
||||
s.execute(paste, &json!({"as": "keepSource"})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let copy = st.shape(st.selection.shapes[0]).unwrap();
|
||||
assert_eq!(copy.ph.as_ref().map(|p| p.kind), source.ph.as_ref().map(|p| p.kind), "{paste} keeps the placeholder");
|
||||
let list = copy.text.as_ref().map(|t| t.list_style.clone());
|
||||
assert_eq!(list, source.text.as_ref().map(|t| t.list_style.clone()), "{paste} adds no baked styles");
|
||||
}
|
||||
s.execute("view.closeMaster", &json!({})).unwrap();
|
||||
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert!(s.doc().unwrap().shape(copy).unwrap().ph.is_none(), "on a slide the copy is a regular shape");
|
||||
assert_eq!(resolved_look(&s, copy), want);
|
||||
}
|
||||
|
||||
/// The resolved fill, line and effects of shape `id` on the current slide, with their style colours.
|
||||
fn resolved_box(s: &Session, id: ShapeId) -> String {
|
||||
use deckcraft_model::resolve;
|
||||
let st = s.doc().unwrap();
|
||||
let ctx = resolve::Ctx::for_slide(&st.doc, st.current_slide().unwrap()).unwrap();
|
||||
let sh = st.shape(id).unwrap();
|
||||
format!("{:?}\n{:?}\n{:?}", resolve::fill(&ctx, sh), resolve::line(&ctx, sh), resolve::effects(&ctx, sh))
|
||||
}
|
||||
|
||||
/// Edit the title placeholders the current slide's title inherits from: its layout's and its
|
||||
/// master's. Returns the slide title's id.
|
||||
fn edit_title_parents(
|
||||
s: &mut Session,
|
||||
layout: impl FnOnce(&mut deckcraft_model::Shape),
|
||||
master: impl FnOnce(&mut deckcraft_model::Shape),
|
||||
) -> ShapeId {
|
||||
let st = s.doc_mut().unwrap();
|
||||
let slide = st.current_slide().unwrap().clone();
|
||||
let title = slide.shapes.iter().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap().id;
|
||||
let doc = std::sync::Arc::make_mut(&mut st.doc);
|
||||
let m = std::sync::Arc::make_mut(&mut doc.masters[0]);
|
||||
let lay = m.layouts.iter_mut().find(|l| l.id == slide.layout).unwrap();
|
||||
layout(lay.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap());
|
||||
master(m.shapes.iter_mut().find(|x| x.ph_type().is_some_and(|k| k.is_title())).unwrap());
|
||||
title
|
||||
}
|
||||
|
||||
/// A copied placeholder takes each of fill, line and effects from where the original does: a
|
||||
/// style on the layout's placeholder (or on the slide's own) wins over an explicit value on the
|
||||
/// master's, as `resolve::fill`, `resolve::line` and `resolve::effects` decide.
|
||||
#[test]
|
||||
fn a_pasted_placeholder_keeps_the_style_precedence_of_its_box() {
|
||||
use deckcraft_color::SchemeSlot;
|
||||
use deckcraft_model::{ColorRef, Effects, Fill, Line, ShapeStyle};
|
||||
for style_on_slide in [false, true] {
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let style = ShapeStyle {
|
||||
line_ref: (1, ColorRef::scheme(SchemeSlot::Accent2)),
|
||||
fill_ref: (1, ColorRef::scheme(SchemeSlot::Accent1)),
|
||||
effect_ref: (0, ColorRef::scheme(SchemeSlot::Accent3)),
|
||||
font_ref: ("minor".into(), None),
|
||||
};
|
||||
let layout_style = (!style_on_slide).then(|| style.clone());
|
||||
let title = edit_title_parents(
|
||||
&mut s,
|
||||
|l| l.style = layout_style,
|
||||
|m| {
|
||||
m.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Accent4)));
|
||||
m.line = Some(Line::solid(ColorRef::scheme(SchemeSlot::Accent5), 20.0));
|
||||
m.effects = Some(Effects { soft_edge: Some(5.0), ..Default::default() });
|
||||
},
|
||||
);
|
||||
if style_on_slide {
|
||||
s.edit(|doc, sel| {
|
||||
let list = crate::shapes_mut(doc, sel).unwrap();
|
||||
list.iter_mut().find(|x| x.id == title).unwrap().style = Some(style.clone());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
s.execute("text.set", &json!({"id": title.0, "text": "Styled title"})).unwrap();
|
||||
let want = (resolved_box(&s, title), resolved_look(&s, title));
|
||||
s.execute("selection.select", &json!({"id": title.0})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_eq!((resolved_box(&s, copy), resolved_look(&s, copy)), want, "style on the slide: {style_on_slide}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The master's placeholder style never reaches the copy's text: the original's text ignores it
|
||||
/// (only the shape's or the layout's font reference counts), so the copy must too.
|
||||
#[test]
|
||||
fn a_pasted_placeholder_ignores_the_masters_font_reference() {
|
||||
use deckcraft_color::SchemeSlot;
|
||||
use deckcraft_model::{ColorRef, ShapeStyle};
|
||||
let mut s = session();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
let title = edit_title_parents(
|
||||
&mut s,
|
||||
|l| l.style = None,
|
||||
|m| {
|
||||
m.style = Some(ShapeStyle {
|
||||
line_ref: (0, ColorRef::scheme(SchemeSlot::Accent2)),
|
||||
fill_ref: (0, ColorRef::scheme(SchemeSlot::Accent1)),
|
||||
effect_ref: (0, ColorRef::scheme(SchemeSlot::Accent3)),
|
||||
font_ref: ("minor".into(), Some(ColorRef::scheme(SchemeSlot::Accent6))),
|
||||
})
|
||||
},
|
||||
);
|
||||
s.execute("text.set", &json!({"id": title.0, "text": "Title"})).unwrap();
|
||||
let want = (resolved_box(&s, title), resolved_look(&s, title));
|
||||
assert!(want.1.contains("+mj-lt"), "the title draws in the heading font: {}", want.1);
|
||||
s.execute("selection.select", &json!({"id": title.0})).unwrap();
|
||||
s.execute("edit.duplicate", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_eq!((resolved_box(&s, copy), resolved_look(&s, copy)), want);
|
||||
}
|
||||
|
||||
/// The same on every text placeholder of the sample deck (titles, bulleted bodies, subtitles).
|
||||
#[test]
|
||||
fn sample_placeholders_paste_with_their_look() {
|
||||
let mut s = Session::new();
|
||||
crate::sample::open_sample(&mut s).unwrap();
|
||||
let slides = s.doc().unwrap().doc.slides.len();
|
||||
let mut checked = 0;
|
||||
for i in 0..slides {
|
||||
s.execute("slide.go", &json!({"index": i})).unwrap();
|
||||
let phs: Vec<ShapeId> = s
|
||||
.doc()
|
||||
.unwrap()
|
||||
.current_slide()
|
||||
.unwrap()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter(|x| x.ph.is_some() && x.text.as_ref().is_some_and(|t| !t.is_empty()))
|
||||
.map(|x| x.id)
|
||||
.collect();
|
||||
for id in phs {
|
||||
let want = resolved_look(&s, id);
|
||||
s.execute("selection.select", &json!({"id": id.0})).unwrap();
|
||||
s.execute("edit.copy", &json!({})).unwrap();
|
||||
s.execute("edit.paste", &json!({})).unwrap();
|
||||
let copy = s.doc().unwrap().selection.shapes[0];
|
||||
assert_eq!(resolved_look(&s, copy), want, "slide {i}, placeholder {}", id.0);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
assert!(checked >= 3, "the sample has text placeholders ({checked})");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pasted_shapes_get_the_next_automatic_name() {
|
||||
let mut s = session();
|
||||
@@ -4683,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!({
|
||||
@@ -6871,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:?}");
|
||||
}
|
||||
|
||||
@@ -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] {
|
||||
|
||||
@@ -101,7 +101,6 @@ impl Session {
|
||||
let list = cmd::current_shapes(doc, sel).cloned().unwrap_or_default();
|
||||
let mut copies: Vec<Shape> = list.iter().filter(|x| ids.contains(&x.id)).cloned().collect();
|
||||
let boxes: Vec<Xfrm> = copies.iter().map(|c| xfrm_of(doc, sel, c)).collect();
|
||||
cmd::edit::detach_on_slide(doc, sel, &mut copies);
|
||||
cmd::edit::reid("edit.duplicate", doc, &mut copies)?;
|
||||
for (c, b) in copies.iter_mut().zip(boxes) {
|
||||
c.xfrm = Some(b);
|
||||
|
||||
@@ -169,7 +169,7 @@ impl FontDb {
|
||||
if bundled.contains(fam) {
|
||||
continue;
|
||||
}
|
||||
if self.is_loaded(fam) || self.has_family(fam) {
|
||||
if self.is_loaded(fam) || (self.system_fallback_enabled() && self.has_family(fam)) {
|
||||
let f = self.face(fam, style);
|
||||
if f.family.eq_ignore_ascii_case(fam) && covers_cluster(&f, cluster) {
|
||||
return f;
|
||||
|
||||
@@ -976,6 +976,17 @@ impl FontDb {
|
||||
let _ = on;
|
||||
}
|
||||
|
||||
/// Whether automatic system-font lookups (fallback by character, symbol and script families)
|
||||
/// may touch installed fonts. Lookups of a family the document or host names are not gated.
|
||||
pub(crate) fn system_fallback_enabled(&self) -> bool {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
self.sys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).enabled
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
false
|
||||
}
|
||||
|
||||
/// Process-wide database: the bundled fonts, then the installed system fonts, cataloged the
|
||||
/// first time a lookup by family name needs them (or ahead of time by
|
||||
/// [`FontDb::scan_in_background`]).
|
||||
@@ -1260,8 +1271,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 +1283,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)),
|
||||
@@ -1473,7 +1480,8 @@ impl FontDb {
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let cataloged: Vec<String> = self.read_catalog().iter().map(|e| e.family.clone()).collect();
|
||||
let cataloged: Vec<String> =
|
||||
if self.system_fallback_enabled() { self.read_catalog().iter().map(|e| e.family.clone()).collect() } else { Vec::new() };
|
||||
for fam in SYSTEM_FALLBACKS.iter().filter(|f| f.contains("Symbol")) {
|
||||
if !self.is_loaded(fam) && cataloged.iter().any(|f| f.eq_ignore_ascii_case(fam)) {
|
||||
self.load_cataloged(fam);
|
||||
|
||||
@@ -425,3 +425,11 @@ fn the_font_scan_skips_fifos_folders_and_link_loops() {
|
||||
assert!(fifo || n >= 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_system_fallback_gates_automatic_family_loads() {
|
||||
let db = FontDb::with_font_dirs(Vec::new());
|
||||
assert!(cfg!(target_arch = "wasm32") || db.system_fallback_enabled());
|
||||
db.set_system_fallback(false);
|
||||
assert!(!db.system_fallback_enabled());
|
||||
}
|
||||
|
||||
@@ -247,11 +247,6 @@ pub fn repair(p: &mut Presentation) {
|
||||
if !(p.notes_size.width.is_finite() && p.notes_size.width > 0.0 && p.notes_size.height.is_finite() && p.notes_size.height > 0.0) {
|
||||
p.notes_size = Presentation::default().notes_size;
|
||||
}
|
||||
// A notes page is bounded like a slide: canvas and handout drawing scale with it.
|
||||
p.notes_size = deckcraft_model::Size::new(
|
||||
p.notes_size.width.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()),
|
||||
p.notes_size.height.clamp(*SLIDE_SIDE.start(), *SLIDE_SIDE.end()),
|
||||
);
|
||||
sane_numbers(p);
|
||||
}
|
||||
|
||||
@@ -694,9 +689,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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -451,13 +451,6 @@ fn degenerate_slide_sizes_are_repaired() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_bounds_the_notes_page_size() {
|
||||
let mut p = Presentation { notes_size: deckcraft_model::Size::new(1e12, 0.01), ..Default::default() };
|
||||
repair(&mut p);
|
||||
assert_eq!((p.notes_size.width, p.notes_size.height), (4032.0, 72.0));
|
||||
}
|
||||
|
||||
/// A file whose slide size is null, a string or missing a side (written by hand or by
|
||||
/// another tool; NaN is also saved as null) opens with the default size rather than failing
|
||||
/// as damaged.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use deckcraft_color::{ColorScheme, Rgba, SchemeSlot};
|
||||
use deckcraft_geom::Xfrm;
|
||||
|
||||
use crate::style::{ColorRef, Effects, Fill, Line, ShapeStyle};
|
||||
use crate::style::{ColorRef, Effects, Fill, Line};
|
||||
use crate::text::{Anchor, AutoFit, BodyProps, LevelStyle, ListStyle, ParaProps, RunProps};
|
||||
use crate::{Background, Layout, Master, PhType, Presentation, Shape, Slide};
|
||||
|
||||
@@ -218,68 +218,6 @@ pub fn effects(ctx: &Ctx, shape: &Shape) -> (Option<Effects>, Option<Rgba>) {
|
||||
(None, None)
|
||||
}
|
||||
|
||||
/// Turn a placeholder into a regular shape that looks the same: what it inherited from its layout
|
||||
/// and master placeholders (position, fill, line, effects, style, body properties and the text
|
||||
/// style of every outline level, the master's title or body style included) becomes its own, and
|
||||
/// `ph` is cleared. A shape that is not a placeholder is left as it is.
|
||||
///
|
||||
/// Theme references (`+mj-lt`, scheme colours) stay references, so the copy still follows the
|
||||
/// destination theme, as PowerPoint's default paste does.
|
||||
pub fn detach_placeholder(ctx: &Ctx, shape: &mut Shape) {
|
||||
if shape.ph.is_none() {
|
||||
return;
|
||||
}
|
||||
// The text first: `level_style` reads the shape's own style, which changes below.
|
||||
let text = shape.text.as_ref().map(|_| (body(ctx, shape), (0..9).map(|l| Some(level_style(ctx, shape, l))).collect::<Vec<_>>()));
|
||||
let own = shape.clone();
|
||||
let (lp, mp) = parents(ctx, &own);
|
||||
let chain = [Some(&own), lp, mp].into_iter().flatten();
|
||||
|
||||
// Each property comes from where `fill`, `line` and `effects` take it: the first shape in the
|
||||
// chain with an explicit value or a style. Lines merge explicit values up to the first styled
|
||||
// shape, whose style line fills the rest.
|
||||
let fill_src = chain.clone().find(|s| s.fill.is_some() || s.style.is_some());
|
||||
let effects_src = chain.clone().find(|s| s.effects.is_some() || s.style.is_some());
|
||||
let mut line = None;
|
||||
let mut line_src = None;
|
||||
for s in chain.clone() {
|
||||
if let Some(l) = &s.line {
|
||||
line.get_or_insert_with(Line::default).inherit(l);
|
||||
}
|
||||
if s.style.is_some() {
|
||||
line_src = Some(s);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Text takes its font reference from the shape's style or its layout placeholder's (a table's
|
||||
// from its own only), never the master's.
|
||||
let table = matches!(own.kind, crate::ShapeKind::Table(_));
|
||||
let font_src = own.style.as_ref().or(lp.filter(|_| !table).and_then(|s| s.style.as_ref()));
|
||||
|
||||
// One style for the copy, each reference taken from the shape that supplied that property. A
|
||||
// property with an explicit value ignores its reference, except a gradient without stops,
|
||||
// which takes them from its own shape's style; a neutral reference (index 0) adds nothing.
|
||||
let style_of = |s: Option<&Shape>| s.and_then(|s| s.style.clone());
|
||||
let neutral = (0, ColorRef::scheme(SchemeSlot::Tx1));
|
||||
let style = chain.clone().any(|s| s.style.is_some()).then(|| ShapeStyle {
|
||||
fill_ref: style_of(fill_src).map_or_else(|| neutral.clone(), |st| st.fill_ref),
|
||||
line_ref: style_of(line_src).map_or_else(|| neutral.clone(), |st| st.line_ref),
|
||||
effect_ref: style_of(effects_src).map_or_else(|| neutral.clone(), |st| st.effect_ref),
|
||||
font_ref: font_src.map_or_else(|| ("none".into(), None), |st| st.font_ref.clone()),
|
||||
});
|
||||
|
||||
shape.xfrm = Some(xfrm(ctx, &own));
|
||||
shape.fill = fill_src.and_then(|s| s.fill.clone());
|
||||
shape.effects = effects_src.and_then(|s| s.effects.clone());
|
||||
shape.line = line;
|
||||
shape.style = style;
|
||||
if let (Some(t), Some((body, levels))) = (shape.text.as_mut(), text) {
|
||||
t.body = body;
|
||||
t.list_style = ListStyle { levels };
|
||||
}
|
||||
shape.ph = None;
|
||||
}
|
||||
|
||||
/// Body properties with defaults filled in.
|
||||
pub fn body(ctx: &Ctx, shape: &Shape) -> BodyProps {
|
||||
let (lp, mp) = parents(ctx, shape);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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(_))));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -11,52 +11,6 @@ pub(crate) fn system_clipboard_text(t: &str) -> String {
|
||||
if t.is_empty() { OBJECTS_MARKER.to_string() } else { t.to_string() }
|
||||
}
|
||||
|
||||
/// Whether pasted `text` (and the clipboard's `html`) only name a picture that is on the clipboard
|
||||
/// too, as a browser's Copy Image leaves them: the picture's address, perhaps followed by its alt
|
||||
/// text, or HTML that is a lone `<img>`. Then Ctrl+V pastes the picture, as PowerPoint does.
|
||||
/// Text with NULs is UTF-16 read as UTF-8 (Firefox's `text/x-moz-url`), never words to keep.
|
||||
pub(crate) fn names_a_picture(text: &str, html: Option<&str>) -> bool {
|
||||
if text.contains('\0') {
|
||||
return true;
|
||||
}
|
||||
if let Some(h) = html
|
||||
&& h.to_ascii_lowercase().contains("<img")
|
||||
&& visible_text(h).trim().is_empty()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let mut lines = text.trim().lines();
|
||||
let first = lines.next().unwrap_or("").trim();
|
||||
let link = ["http://", "https://", "file://", "data:image/"].iter().any(|p| first.to_ascii_lowercase().starts_with(p));
|
||||
link && !first.contains(char::is_whitespace) && lines.count() <= 1
|
||||
}
|
||||
|
||||
/// The text of an HTML fragment outside its tags (and outside comments and `<style>`/`<script>`
|
||||
/// bodies, which browsers sometimes put around a copied picture).
|
||||
fn visible_text(html: &str) -> String {
|
||||
let lower = html.to_ascii_lowercase();
|
||||
let mut out = String::new();
|
||||
let mut i = 0;
|
||||
while i < html.len() {
|
||||
let rest = &lower[i..];
|
||||
let skip_to = |end: &str| rest.find(end).map(|e| i + e + end.len()).unwrap_or(html.len());
|
||||
if rest.starts_with("<!--") {
|
||||
i = skip_to("-->");
|
||||
} else if rest.starts_with("<style") {
|
||||
i = skip_to("</style>");
|
||||
} else if rest.starts_with("<script") {
|
||||
i = skip_to("</script>");
|
||||
} else if rest.starts_with('<') {
|
||||
i = skip_to(">");
|
||||
} else {
|
||||
let end = rest.find('<').map_or(html.len(), |e| i + e);
|
||||
out.push_str(&html[i..end]);
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
out.replace(" ", " ")
|
||||
}
|
||||
|
||||
impl SlideApp {
|
||||
/// Offer the copy or cut on the system clipboard.
|
||||
///
|
||||
|
||||
@@ -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, ¶ms)
|
||||
} 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, ¶ms).map_err(|e| e.to_string())
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -152,10 +152,8 @@ impl SlideApp {
|
||||
// Text keeps its HTML (bold, italic, underline) when the host can read it.
|
||||
let html = self.services.clipboard_html.as_mut().and_then(|f| f()).filter(|h| !h.is_empty());
|
||||
// egui 0.37 sends `PasteImage` directly; the services fallback stays for
|
||||
// integrations that still send an empty `Paste`, and for a browser's Copy
|
||||
// Image, whose text is only the picture's link. Never insert twice.
|
||||
let wants_picture = text.is_empty() || super::clipboard::names_a_picture(&text, html.as_deref());
|
||||
let img = if wants_picture && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None };
|
||||
// integrations that still send an empty `Paste`. Never insert twice.
|
||||
let img = if text.is_empty() && !pasted_image_event { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None };
|
||||
let revision = self.session.active().map(|d| d.revision);
|
||||
let r = match img {
|
||||
Some(png) => self
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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!({}))
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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!({}));
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>>>>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -378,54 +378,6 @@ fn title_bar_export_opens_the_export_dialog() {
|
||||
assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export"));
|
||||
}
|
||||
|
||||
/// A browser's Copy Image puts the picture on the clipboard with text that only names it: the
|
||||
/// address (and the alt text), and an `<img>` fragment as HTML. Ctrl+V pastes the picture, as
|
||||
/// PowerPoint does, instead of a text box with the link. Without a picture the link pastes as text.
|
||||
#[test]
|
||||
fn ctrl_v_pastes_the_picture_a_browser_copied_not_its_link() {
|
||||
let url = "https://github.com/storytold/deckcraft/raw/main/assets/app-icon/hicolor/256x256/apps/ai.storyteller.deckcraft.png";
|
||||
let caption = format!("{url}\nDeckCraft icon: a presentation screen with a bar chart on an orange tile");
|
||||
for (text, html, with_picture, pictures_after) in [
|
||||
(caption.clone(), Some(format!("<img src=\"{url}\" alt=\"DeckCraft icon\">")), true, 1),
|
||||
(url.to_string(), None, true, 1),
|
||||
// Firefox's text/x-moz-url is UTF-16; read as UTF-8 it carries NULs.
|
||||
(url.chars().flat_map(|c| [c, '\0']).collect(), None, true, 1),
|
||||
(url.to_string(), None, false, 0),
|
||||
("See https://example.com for the photos".to_string(), None, true, 0),
|
||||
] {
|
||||
let session = Session::with_new();
|
||||
let png = {
|
||||
let d = session.active().unwrap();
|
||||
deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0
|
||||
};
|
||||
let services = Services {
|
||||
clipboard_image: Some(Box::new(move || with_picture.then(|| png.clone()))),
|
||||
clipboard_html: Some(Box::new(move || html.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, services),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let shapes_before = harness.state().session.active().unwrap().doc.slides[0].shapes.len();
|
||||
harness.event(egui::Event::Paste(text.clone()));
|
||||
harness.run_steps(2);
|
||||
let d = harness.state().session.active().unwrap();
|
||||
let shapes = &d.doc.slides[0].shapes;
|
||||
let pictures = shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count();
|
||||
assert_eq!(pictures, pictures_after, "{text:?}");
|
||||
assert_eq!(shapes.len(), shapes_before + 1, "one shape, picture or text, for {text:?}");
|
||||
if pictures_after == 0 {
|
||||
let pasted = shapes.last().and_then(|s| s.text.as_ref()).map(deckcraft_model::TextBody::text);
|
||||
assert_eq!(pasted.as_deref(), Some(text.as_str()), "the text survives");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and
|
||||
/// swallows the key press, so only the V release reaches the app.
|
||||
#[test]
|
||||
|
||||
@@ -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
-3
@@ -1,6 +1,6 @@
|
||||
# Where DeckCraft falls short of PowerPoint
|
||||
|
||||
> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-04 · **Change:** trivial (workflow-breaking regression fixed: Ctrl+C/X/V use the system clipboard again; #1414 to re-test on Windows; previous: trivial, run alternate language and reflection transforms survive saves; reused slide contents scale to the destination, picker thumbnails remain a gap; previous: 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)
|
||||
> **Last reviewed:** 2026-10-30 · **Last updated:** 2026-11-03 · **Change:** trivial (run alternate language and reflection transforms survive saves; reused slide contents scale to the destination, picker thumbnails remain a gap; previous: 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)
|
||||
|
||||
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
|
||||
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
|
||||
@@ -161,7 +161,7 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
- **Estimate:** 3–5 h. **Beta blocker.**
|
||||
|
||||
### Image paste fails on Windows (#1414)
|
||||
- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. From the egui 0.37 migration (#1350, v0.11.1) to 0.15.0 the desktop build left out eframe's `clipboard` feature, so egui simulated a clipboard inside the app and Ctrl+V never read the system clipboard on any platform. Fixed 2026-11-01, as is a browser's Copy Image on Linux, which pasted its link because Ctrl+V preferred any text over the picture. #1414 may have either cause: re-test on Windows.
|
||||
- **Evidence:** #53 is closed (PR #88 pastes an image-only clipboard as a picture); #1414 (Ctrl+V fails to paste external images on Windows) is open. Linux was not re-checked after #53 closed.
|
||||
- **Impact:** copy-paste of screenshots is a core workflow.
|
||||
- **Estimate:** 2–4 h. **Beta blocker.**
|
||||
|
||||
@@ -371,7 +371,6 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-11-02 | trivial | Run alternate-language metadata now survives native and PPTX saves; UI and proofing gaps remain; scores unchanged |
|
||||
| 2026-11-02 | trivial | PPTX reflection geometry is retained through native and PPTX resaves; no rendering parity claim |
|
||||
| 2026-11-02 | trivial | Reused slide geometry and content scale to the destination; picker thumbnails remain the reuse gap |
|
||||
| 2026-11-01 | trivial | Workflow-breaking regression: image paste entry: the OS clipboard was off from #1350 to 0.15.0; Linux re-checked, Windows #1414 to re-test |
|
||||
| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
|
||||
| 2026-10-30 | minor | Slide-show click targets use each shape’s own animation state; scale and rotation test added |
|
||||
| 2026-10-28 | trivial | Automatic lists now run independently of Replace text as you type; proofing gap description updated |
|
||||
|
||||
@@ -42,7 +42,7 @@ The summary between `<!-- SUMMARY -->
|
||||
| Automation (✱) | 100% |
|
||||
| Platforms (✱) | 67% |
|
||||
|
||||
Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows, #1414, re-test after the 2026-11-01 OS-clipboard fix), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
|
||||
Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layout, Chart Design), Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending), Format Shape pane, Font, size, bold, italic, underline, strikethrough (only Regular/Bold/Italic faces, #69), Edit chart data, Crop (and crop to shape, aspect ratio), Paste image from clipboard (fails on Windows, #1414; Linux not re-checked), Audio/video playback in the show (WMA/WMV not decodable yet), Animation pane (reorder, timing), Presenter view (notes, next slide, timer), macOS, Windows, Linux builds.
|
||||
|
||||
## Rows
|
||||
|
||||
@@ -193,7 +193,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Pictures | Picture styles and borders | P1 | D |
|
||||
| Pictures | Change / reset picture | P1 | D |
|
||||
| Pictures | Compress pictures | P2 | D |
|
||||
| Pictures | Paste image from clipboard (fails on Windows, #1414, re-test after the 2026-11-01 OS-clipboard fix) | P0 | P |
|
||||
| Pictures | Paste image from clipboard (fails on Windows, #1414; Linux not re-checked) | P0 | P |
|
||||
| Pictures | Insert screenshot | P2 | M |
|
||||
| Media | Insert audio (any common format) | P0 | D |
|
||||
| Media | Insert video | P1 | D |
|
||||
|
||||
+2
-66
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user