Compare commits

..
Author SHA1 Message Date
FredAppsandClaude Opus 5.5 ea3bfac1db Refit credits tables on what they measure, not the fitted widths
Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.

It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.

The table now compares the natural widths and the clip rect's width,
which scrolling does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:44:18 +01:00
FredAppsandClaude Opus 5.5 e4b5e1e544 Let every credits column be dragged, and refit changed tables
The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.

A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.

fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:18:09 +01:00
FredAppsandClaude Opus 5.5 c92185dbe5 About: credits tables fit without side-scrolling and their columns resize
The Contributors table and the Models table were egui Grids inside a
two-way scroll area: they opened wider than the dialog, so they scrolled
sideways, and Company and Model sat almost touching. Both are now
egui_extras tables. Each column opens at the width of its header and widest
cell, the spare width goes to the text columns (Name; Company and Model),
and every column edge can be dragged. Widening a column grows the table
like a spreadsheet, and only then does it scroll sideways; the header stays
put while the rows scroll. Counts and dates are right-aligned and every
cell is vertically centred.

The sorted column stays highlighted and its direction shows on the
toolbar's sort-order button, so headers no longer reserve room for an
arrow. The opening widths come from fit_widths, a pure function with unit
tests that a fitted table fills its room exactly and that only the text
columns give way when room is short.

The About dialog opens 820 points wide, so every column fits at its natural width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:55:59 +01:00
95 changed files with 314 additions and 3405 deletions
-1
View File
@@ -45,4 +45,3 @@ jobs:
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
LINUX_SIGNING_KEY: ${{ secrets.LINUX_SIGNING_KEY }}
Generated
+5 -97
View File
@@ -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"
@@ -1532,16 +1508,13 @@ 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",
@@ -4464,8 +4437,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 +4455,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 +4962,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 +5404,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 +6014,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 +6035,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",
-12
View File
@@ -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 |
+1 -1
View File
@@ -27,4 +27,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget", "UrlSearchParams"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] }
-192
View File
@@ -1,192 +0,0 @@
//! The `postMessage` bridge to a same-origin parent page (`?host=parent`), for pages that embed
//! DeckCraft in an `<iframe>` and keep its files (a Nextcloud app, a personal file server). The
//! same messages as GridCraft's, WordCraft's and PdfCraft's bridges; each is a plain object with a
//! `type`:
//!
//! - `deckcraft:ready` (to the parent): `{ version }`, sent once the app is running and
//! listening; send a file now.
//! - `deckcraft:open` (from the parent): `{ name, bytes }`, bytes an `ArrayBuffer` or
//! `Uint8Array`, name a bare file name. It opens like a file picked with File › Open on the
//! desktop: a presentation sent with its name (.pptx, .potx, .ppsx, .deckcraft) has that name
//! as its file, so Save goes back to it; an outline (.txt, .md, .rtf), a file without a name and
//! a damaged file open untitled.
//! - `deckcraft:save` (to the parent): `{ name, saveAs, bytes }` (a transferred `ArrayBuffer`) for
//! every file DeckCraft would otherwise download. The parent stores it and reports failures
//! itself. `saveAs` says whether the file becomes the presentation's file:
//! - Save: `false`. It writes the opened file under its own name and format (a .pptx stays a
//! .pptx), as the desktop app does. A name the parent never sent (an untitled presentation, an
//! outline, a file sent without a name, a repaired copy of a damaged file, a file the user
//! picked or dropped) is a new file: `true`, as for Save As, named `<title>.deckcraft` as
//! Save suggests on the desktop.
//! - Save As (File › Save As as .pptx or .deckcraft, Ctrl+Shift+S): `true`. The presentation is
//! now that file; the next Save sends its name with `false`.
//! - Export (PDF, pictures, outline, GIF, handouts, a Save As type that isn't a presentation)
//! and Print: `false`. They are copies; the presentation's file stays the same.
//! - `deckcraft:dirty` (to the parent): `{ dirty }` whenever unsaved changes appear or go away.
//! - `deckcraft:failed` (to the parent): `{ error }` when the app couldn't start.
//!
//! Only the parent window on the page's own origin is heard or answered. In host mode the app
//! starts with an empty presentation instead of the sample, which gives way to the first file the
//! parent sends; the parent guards leaving the page, and `?author=` names the user for comments.
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use deckcraft_ui_egui::{Services, SlideApp};
use wasm_bindgen::JsCast as _;
use wasm_bindgen::JsValue;
use wasm_bindgen::closure::Closure;
/// The longest user name taken from `?author=` (the preferences' own limit).
const MAX_AUTHOR_CHARS: usize = 256;
#[derive(Clone)]
pub struct Bridge {
window: web_sys::Window,
parent: web_sys::Window,
origin: String,
/// Files the parent sent (the name, if it sent one), waiting for the next frame.
opened: Rc<RefCell<Vec<(Option<String>, Vec<u8>)>>>,
/// The unsaved-changes state last reported to the parent.
reported: Rc<Cell<Option<bool>>>,
}
impl Bridge {
/// `None` when the page isn't framed (there is no parent to talk to), or when the parent is
/// on another origin: messages only go to this origin, so the browser would drop every save
/// without an error while the presentation showed as saved.
pub fn new() -> Option<Self> {
let window = web_sys::window()?;
let parent = window.parent().ok().flatten()?;
if js_sys::Object::is(&parent, &window) {
return None;
}
let origin = window.location().origin().ok()?;
// An opaque origin (a `file://` page in some browsers) can't be a `postMessage` target.
if origin == "null" {
return None;
}
// Reading another origin's location throws, which is `None` too.
if parent.location().origin().ok()? != origin {
return None;
}
Some(Self { window, parent, origin, opened: Rc::default(), reported: Rc::default() })
}
/// Hand saved and exported files to the parent instead of downloading them, and hear the
/// files it sends.
pub fn connect(&self, services: &mut Services, ctx: egui::Context) {
self.listen(ctx);
let bridge = self.clone();
services.host_save = Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| bridge.post_save(name, bytes, save_as)));
// Exports and Print are copies.
let bridge = self.clone();
services.download = Some(Box::new(move |name: &str, bytes: &[u8]| {
if let Err(e) = bridge.post_save(name, bytes, false) {
log::error!("couldn't hand {name} to the page: {e}");
}
}));
}
/// `?author=`: the host's name for the user signs new comments.
pub fn set_author(app: &mut SlideApp, author: &str) {
let author: String = author.trim().chars().take(MAX_AUTHOR_CHARS).collect();
if !author.is_empty() {
app.session.prefs.author = author;
}
}
pub fn post_ready(&self) {
self.send(&[("type", "deckcraft:ready".into()), ("version", env!("CARGO_PKG_VERSION").into())]);
}
/// The app couldn't start (no WebGPU or WebGL2, for example): let the parent say so.
pub fn post_failed(&self, error: &str) {
self.send(&[("type", "deckcraft:failed".into()), ("error", error.into())]);
}
/// Before each frame: open the files the parent sent.
pub fn open_files(&self, app: &mut SlideApp) {
let opened = std::mem::take(&mut *self.opened.borrow_mut());
for (name, bytes) in opened {
app.open_from_host(name.as_deref(), &bytes);
}
}
/// After each frame: tell the parent when unsaved changes appear or go away.
pub fn report_dirty(&self, app: &SlideApp) {
let dirty = app.session.documents().iter().any(|d| d.is_dirty());
if self.reported.get() != Some(dirty) && self.send(&[("type", "deckcraft:dirty".into()), ("dirty", dirty.into())]) {
self.reported.set(Some(dirty));
}
}
fn post_save(&self, path: &str, bytes: &[u8], save_as: bool) -> Result<(), String> {
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
let buffer = js_sys::Uint8Array::from(bytes).buffer();
let message = message(&[
("type", "deckcraft:save".into()),
("name", name.as_str().into()),
("saveAs", save_as.into()),
("bytes", buffer.clone().into()),
])
.ok_or_else(|| "couldn't build the message".to_string())?;
self.parent
.post_message_with_transfer(&message, &self.origin, &js_sys::Array::of1(&buffer))
.map_err(|e| e.as_string().unwrap_or_else(|| format!("{e:?}")))
}
/// Hear `deckcraft:open` from the parent and queue the file for the next frame.
fn listen(&self, ctx: egui::Context) {
let parent = self.parent.clone();
let origin = self.origin.clone();
let opened = self.opened.clone();
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |event: web_sys::MessageEvent| {
if event.origin() != origin || !event.source().is_some_and(|s| js_sys::Object::is(&s, &parent)) {
return;
}
let data = event.data();
if string(&data, "type").as_deref() != Some("deckcraft:open") {
return;
}
// No name is no file: the presentation opens untitled, never under an invented name.
let name = string(&data, "name").filter(|n| !n.trim().is_empty());
let Some(bytes) = bytes(&data) else { return };
opened.borrow_mut().push((name, bytes));
ctx.request_repaint();
});
if self.window.add_event_listener_with_callback("message", on_message.as_ref().unchecked_ref()).is_ok() {
// The listener lives as long as the page.
on_message.forget();
}
}
/// Post a message to the parent; false if it couldn't be sent.
fn send(&self, fields: &[(&str, JsValue)]) -> bool {
message(fields).is_some_and(|m| self.parent.post_message(&m, &self.origin).is_ok())
}
}
fn message(fields: &[(&str, JsValue)]) -> Option<js_sys::Object> {
let m = js_sys::Object::new();
for (key, value) in fields {
js_sys::Reflect::set(&m, &JsValue::from_str(key), value).ok()?;
}
Some(m)
}
fn string(data: &JsValue, key: &str) -> Option<String> {
js_sys::Reflect::get(data, &JsValue::from_str(key)).ok()?.as_string()
}
/// `bytes` as an `ArrayBuffer` or `Uint8Array`.
fn bytes(data: &JsValue) -> Option<Vec<u8>> {
let bytes = js_sys::Reflect::get(data, &JsValue::from_str("bytes")).ok()?;
if let Some(array) = bytes.dyn_ref::<js_sys::Uint8Array>() {
Some(array.to_vec())
} else if bytes.is_instance_of::<js_sys::ArrayBuffer>() {
Some(js_sys::Uint8Array::new(&bytes).to_vec())
} else {
None
}
}
+1 -6
View File
@@ -9,20 +9,15 @@
//! - File → Open and Insert → Pictures/Audio/Video use the browser file picker; bytes arrive
//! asynchronously through `Services::inbox`, applied to the document, slide and operation
//! that started the pick (a dismissed picker or a closed document drops the file);
//! - Save and Export trigger a browser download (or go to the framing page with `?host=parent`);
//! - Save and Export trigger a browser download;
//! - dropped files are read asynchronously by `web::WebShell` and delivered through the inbox;
//! - preferences are not persisted.
//!
//! URL query flags: `?webgl` forces the WebGL2 backend instead of WebGPU; `?blank` starts with an
//! empty presentation instead of the sample deck; `?show` starts the slide show.
//! `?host=parent` (inside an `<iframe>`) talks to a same-origin parent page instead: it sends the
//! presentation to open and stores what DeckCraft saves (`host`), and `&author=` names the user
//! for comments.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
#[cfg(target_arch = "wasm32")]
mod host;
#[cfg(target_arch = "wasm32")]
mod presenter;
#[cfg(target_arch = "wasm32")]
-3
View File
@@ -251,9 +251,6 @@ impl Popup {
if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
data.next = previous.next.clone();
}
if data.slides.is_none() {
data.slides = previous.slides.clone();
}
}
*target.borrow_mut() = Some(data);
*received.borrow_mut() = js_sys::Date::now();
+7 -54
View File
@@ -24,16 +24,8 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
// `?host=parent`: a same-origin page framing DeckCraft opens presentations in it and
// stores what it saves (`host`). The presenter popup is never the framed page.
let host = query_param("host").as_deref() == Some("parent") && crate::presenter::token().is_none();
let bridge = if host { crate::host::Bridge::new() } else { None };
if host && bridge.is_none() {
log::warn!("?host=parent needs a framing page on the same origin; saves download instead");
}
let app_bridge = bridge.clone();
let mut options = eframe::WebOptions::default();
if query_has("webgl")
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
{
create.instance_descriptor.backends = eframe::wgpu::Backends::GL;
@@ -52,42 +44,22 @@ pub fn start() {
return Ok(Box::new(presenter) as Box<dyn eframe::App>);
}
let inbox: Inbox = Inbox::default();
let mut services = services(inbox.clone(), cc.egui_ctx.clone());
if let Some(bridge) = &app_bridge {
bridge.connect(&mut services, cc.egui_ctx.clone());
}
let mut app = SlideApp::new(Session::new(), services);
// In host mode the parent sends the presentation to edit; no sample meanwhile.
if app_bridge.is_some() {
if let Err(e) = app.start_for_host() {
log::error!("new presentation: {e}");
}
} else if !query_has("blank") {
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let q = query();
if !q.contains("blank") {
if let Err(e) = deckcraft_engine::sample::open_sample(&mut app.session) {
log::error!("sample deck: {e}");
}
} else if let Err(e) = app.run("file.new", serde_json::json!({})) {
log::error!("new presentation: {e}");
}
if query_has("show") {
if q.contains("show") {
app.start_show(0, false);
}
if app_bridge.is_some()
&& let Some(author) = query_param("author")
{
crate::host::Bridge::set_author(&mut app, &author);
}
Ok(Box::new(WebShell { app, inbox, bridge: app_bridge }))
Ok(Box::new(WebShell { app, inbox }))
}),
)
.await;
// Ready only once the runner is up: starting can still fail after the app is made.
if let Some(bridge) = &bridge {
match &result {
Ok(()) => bridge.post_ready(),
Err(e) => bridge.post_failed(&format!("{e:?}")),
}
}
if let Some(el) = document.get_element_by_id(LOADING_ID) {
match result {
Ok(()) => el.remove(),
@@ -101,36 +73,17 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
fn query_param(key: &str) -> Option<String> {
web_sys::UrlSearchParams::new_with_str(&query()).ok()?.get(key)
}
/// Whether the URL has the flag `key` (`?show`, `&blank=1`). A whole parameter, never a substring:
/// `?author=` is free text.
fn query_has(key: &str) -> bool {
web_sys::UrlSearchParams::new_with_str(&query()).is_ok_and(|q| q.has(key))
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
struct WebShell {
app: SlideApp,
inbox: Inbox,
/// The parent page that keeps the files (`?host=parent`).
bridge: Option<crate::host::Bridge>,
}
impl eframe::App for WebShell {
/// Delegate one eframe logic pass to the wrapped application, opening the files the parent
/// page sent first and telling it about unsaved changes after.
/// Delegate one eframe logic pass to the wrapped application.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
if let Some(bridge) = &self.bridge {
bridge.open_files(&mut self.app);
}
self.app.logic(ctx);
if let Some(bridge) = &self.bridge {
bridge.report_dirty(&self.app);
}
if self.app.show.is_none()
&& let Some(host) = self.app.services.presenter.as_mut()
{
+1 -3
View File
@@ -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 }
+5 -36
View File
@@ -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));
+16 -45
View File
@@ -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
+4 -25
View File
@@ -253,15 +253,8 @@ pub struct LoadedFile {
#[cfg(not(target_arch = "wasm32"))]
pub fn read_presentation_file(path: &str) -> Result<LoadedFile> {
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
loaded_file(path, &bytes)
}
/// Parse `bytes` as the presentation file `path`, as [`read_presentation_file`] does after
/// reading it. The web host bridge uses it for a file its page sent, which then saves back to
/// that name like a file opened on the desktop.
pub fn loaded_file(path: &str, bytes: &[u8]) -> Result<LoadedFile> {
let (doc, damaged) = open_checked(path, bytes)?;
let native = deckcraft_format::sniff(bytes) || deckcraft_pptx::sniff(bytes);
let (doc, damaged) = open_checked(path, &bytes)?;
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
Ok(LoadedFile { path: path.to_string(), doc, native, damaged })
}
@@ -404,7 +397,6 @@ pub fn pdf_options(p: &Value, count: usize) -> Result<deckcraft_pdf::PdfOptions>
pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
let invalid = || bad("file.export", format!("“{text}” isn't a valid slide range: use numbers and ranges such as 1-3, 5"));
let mut out: Vec<usize> = vec![];
let mut seen: std::collections::HashSet<usize> = std::collections::HashSet::new();
for part in text.split([',', ';']).map(str::trim).filter(|p| !p.is_empty()) {
let (a, b) = match part.split_once('-') {
Some((a, b)) => (a.trim(), b.trim()),
@@ -420,7 +412,7 @@ pub fn parse_slide_range(text: &str, count: usize) -> Result<Vec<usize>> {
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
}
for i in lo - 1..hi {
if seen.insert(i) {
if !out.contains(&i) {
out.push(i);
}
}
@@ -477,10 +469,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() {
@@ -787,16 +776,6 @@ mod tests {
use super::{parse_slide_range, write_file};
#[test]
fn broad_and_repeated_slide_ranges_expand_once() {
// 200 copies of a 100,000-slide range: each index once, in order, without a scan per index.
let text = vec!["1-100000"; 200].join(",");
let v = parse_slide_range(&text, 100_000).unwrap();
assert_eq!(v.len(), 100_000);
assert!(v.windows(2).all(|w| w[0] < w[1]));
assert_eq!(parse_slide_range("3, 1-3, 2", 3).unwrap(), vec![2, 0, 1]);
}
#[test]
fn slide_range_rejects_plus_sign() {
assert!(parse_slide_range("+2", 3).is_err());
+7 -91
View File
@@ -219,7 +219,7 @@ fn neighbour(s: &mut Session, p: &Value, forward: bool) -> Result<Value> {
let cmd = if forward { "comment.next" } else { "comment.prev" };
let (threads, slide, index) = {
let st = s.doc()?;
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), super::opt_usize_param(p, "index", cmd)?)
(comment_threads(&st.doc), usize_param(p, "slide").unwrap_or(st.selection.slide), usize_param(p, "index"))
};
if threads.is_empty() {
return Err(bad(cmd, "no comments"));
@@ -367,7 +367,7 @@ fn spelling(s: &mut Session, p: &Value) -> Result<Value> {
}
out.push(v);
}
let rep: std::collections::HashSet<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
let rep: Vec<usize> = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
for w in crate::spell::paragraph_words(para, &default) {
if ignore.contains(&w.text.to_lowercase()) || rep.contains(&w.start) {
continue;
@@ -421,30 +421,23 @@ fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result<Value> {
/// Replace whole-word `from` with `to` in `text`; how many were replaced.
fn replace_word(text: &mut String, from: &str, to: &str, all: bool) -> usize {
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
// The result is built in one pass; the text is not edited in place for every match.
let mut out = String::new();
let (mut n, mut at, mut copied) = (0, 0, 0);
let mut n = 0;
let mut at = 0;
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
let end = i + from.len();
// The character before the match as the edited text would have it.
let before = text.get(copied..i).and_then(|t| t.chars().next_back()).or_else(|| out.chars().next_back()).is_some_and(is_word);
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
if before || after {
at = end;
continue;
}
out.push_str(text.get(copied..i).unwrap_or_default());
out.push_str(to);
text.replace_range(i..end, to);
n += 1;
(at, copied) = (end, end);
at = i + to.len();
if !all {
break;
}
}
if n > 0 {
out.push_str(text.get(copied..).unwrap_or_default());
*text = out;
}
n
}
@@ -771,49 +764,6 @@ fn end_compare(s: &mut Session, _: &Value) -> Result<Value> {
#[cfg(test)]
mod tests {
/// The replacement as it ran before: edited in place for every match.
fn replace_word_in_place(text: &mut String, from: &str, to: &str, all: bool) -> usize {
let is_word = |c: char| c.is_alphanumeric() || c == '\'' || c == '’';
let mut n = 0;
let mut at = 0;
while let Some(i) = text.get(at..).and_then(|t| t.find(from)).map(|i| i + at) {
let end = i + from.len();
let before = text.get(..i).and_then(|t| t.chars().next_back()).is_some_and(is_word);
let after = text.get(end..).and_then(|t| t.chars().next()).is_some_and(is_word);
if before || after {
at = end;
continue;
}
text.replace_range(i..end, to);
n += 1;
at = i + to.len();
if !all {
break;
}
}
n
}
#[test]
fn replace_word_matches_the_in_place_version() {
let cases = [
("a a b a", "a", "x", true),
("aa a. a", "a", "bb", true),
("a-a -a-", "-", "ab", true),
("foo. foo.", "foo.", "", true),
("it's it", "it", "so", true),
("x.x. x.", "x.", "y", true),
("a a a", "a", "b", false),
("none", "z", "q", true),
("é é", "é", "e", true),
];
for (text, from, to, all) in cases {
let (mut a, mut b) = (text.to_string(), text.to_string());
let (na, nb) = (replace_word(&mut a, from, to, all), replace_word_in_place(&mut b, from, to, all));
assert_eq!((na, a), (nb, b), "{text:?} {from:?} -> {to:?}");
}
}
use super::*;
use serde_json::json;
@@ -862,40 +812,6 @@ mod tests {
assert_eq!(here["text"], "on first");
}
#[test]
fn malformed_comment_navigation_index_does_not_change_selection() {
let mut s = Session::with_new();
s.execute("comment.add", &json!({"text": "first"})).unwrap();
s.execute("shape.insert", &json!({"preset": "rect", "rect": [10.0, 10.0, 40.0, 40.0]})).unwrap();
s.execute("slide.new", &json!({})).unwrap();
s.execute("comment.add", &json!({"text": "second"})).unwrap();
s.execute("slide.go", &json!({"index": 0})).unwrap();
let shape = s.doc().unwrap().doc.slides.first().and_then(|slide| slide.shapes.first()).map(|shape| shape.id).unwrap();
s.doc_mut().unwrap().selection.shapes = vec![shape];
for (command, value) in [
("comment.next", json!(-1)),
("comment.next", json!(1.5)),
("comment.next", json!("bad")),
("comment.prev", json!(-1)),
("comment.prev", json!(1.5)),
("comment.prev", json!("bad")),
] {
let before = s.doc().unwrap().selection.clone();
let error = s.execute(command, &json!({"index": value})).unwrap_err();
assert!(error.to_string().contains("valid index"), "{command} {value}: {error}");
assert_eq!(s.doc().unwrap().selection, before, "{command} {value}");
}
let zero = s.execute("comment.next", &json!({"slide": 0, "index": 0})).unwrap();
assert_eq!(zero["slide"], 1);
assert_eq!(zero["index"], 0);
s.execute("slide.go", &json!({"index": 0})).unwrap();
let omitted = s.execute("comment.next", &json!({})).unwrap();
assert_eq!(omitted["slide"], 0);
assert_eq!(omitted["index"], 0);
}
#[test]
fn timestamps_are_iso_utc() {
assert_eq!(iso_utc(0), "1970-01-01T00:00:00Z");
+1 -1
View File
@@ -103,7 +103,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Shape Fill",
["Shape Format", "Shape Styles"],
None,
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool (false keeps linear and radial gradients upright on the slide; rectangular and path gradients turn with the shape when drawn)} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
"{color? | none?: true | background?: true (the slide background shows through; shapes and text boxes only) | gradient?: {stops: [[pos 0..1, color]], kind?: linear|radial|rectangular|path, angle?: deg (linear), focus?: [l, t, r, b] fractions (radial, rectangular and path: where the first stop sits), rotateWithShape?: bool} | picture?: base64 | pattern?: {preset, fg, bg} | transparency?: 0..1 | reset?: true, ids?}; pictureProps?: {tile?: bool, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0..10, align?: tl|t|tr|l|ctr|r|bl|b|br, mirror?: none|x|y|xy, stretch?: [l, t, r, b] fractions, rotateWithShape?: bool} adjusts a picture fill; a group also formats its members",
has_selection,
fill
),
+1 -2
View File
@@ -74,8 +74,7 @@ pub(super) fn change(s: &mut Session, p: &Value) -> Result<Value> {
/// `{stops: [[pos 0–1, color], …], kind?: linear|radial|rectangular|path, angle?: deg (linear),
/// focus?: [l, t, r, b] fractions (radial/rectangular/path: where the first stop sits),
/// rotateWithShape?}`. The renderer keeps only linear and radial gradients upright when
/// `rotateWithShape` is false; rectangular and path gradients turn with the shape.
/// rotateWithShape?}`.
pub(crate) fn gradient_param(v: &Value) -> Option<Gradient> {
let stops: Vec<GradientStop> = v
.get("stops")?
+4 -15
View File
@@ -3,9 +3,8 @@ use super::{ColorRef, Fill, Geom, Line, NO_ID_SPACE, Result, Session, Value, bad
pub(super) fn sites(s: &mut Session, p: &Value) -> Result<Value> {
let id = id_param(p, "id", "shape.sites")?.ok_or_else(|| bad("shape.sites", "missing `id`"))?;
let st = s.doc()?;
// In slide space, as shape.connect uses them, for a shape inside a group too.
let v = crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
Ok(json!(v.iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
let sh = st.shape(id).ok_or_else(|| bad("shape.sites", "no such shape"))?;
Ok(json!(crate::connect::sites(&st.doc, &st.selection, sh).iter().map(|q| [q.x, q.y]).collect::<Vec<_>>()))
}
pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
@@ -13,13 +12,8 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("shape.connect", "missing `from` / `to`"));
};
let st = s.doc()?;
// Anywhere in the slide's tree: a shape inside a group takes a connector too (PowerPoint
// glues to grouped shapes), its sites where the group draws them.
let site_list = |id| {
crate::connect::sites_of(&st.doc, &st.selection, st.shapes(), id)
.ok_or_else(|| bad("shape.connect", format!("no shape {} on the current slide", id.0)))
};
let (a, b) = (site_list(from)?, site_list(to)?);
let site_list = |id| st.shape(id).map(|sh| crate::connect::sites(&st.doc, &st.selection, sh)).unwrap_or_default();
let (a, b) = (site_list(from), site_list(to));
if a.is_empty() || b.is_empty() {
return Err(bad("shape.connect", "both shapes need connection sites (lines and groups have none)"));
}
@@ -41,11 +35,6 @@ pub(super) fn connect(s: &mut Session, p: &Value) -> Result<Value> {
};
let (p0, p1) = (a[ia], b[ib]);
let id = match id_param(p, "id", "shape.connect")? {
// The ends are in slide space and rerouting follows the slide's own connectors, so a line
// inside a group would be drawn in the group's space and never follow its shapes.
Some(id) if !st.shapes().iter().any(|sh| sh.id == id) && st.shape(id).is_some_and(|sh| sh.is_line()) => {
return Err(bad("shape.connect", "`id` names a line inside a group; ungroup it first, or leave `id` out for a new connector"));
}
Some(id) => id,
None => {
let preset = str_param(p, "preset").unwrap_or("straightConnector1");
-7
View File
@@ -191,13 +191,6 @@ pub(super) fn rotate(s: &mut Session, p: &Value) -> Result<Value> {
pub(super) fn flip(s: &mut Session, p: &Value) -> Result<Value> {
let v = str_param(p, "axis").unwrap_or("horizontal").starts_with('v');
edit_geometry(s, p, "shape.flip", |sh| {
// PowerPoint greys Flip out for a SmartArt graphic (SmartArt Design > Right to Left mirrors it),
// also inside a group: ungrouping would hand the flip to the graphic.
let mut graphic = false;
deckcraft_model::walk(std::slice::from_ref(&*sh), &mut |x, _| graphic |= x.diagram.is_some());
if graphic {
return Err(bad("shape.flip", "Flip is not available for a SmartArt graphic; use `smartArt.layout` to change how it is laid out"));
}
if let Some(x) = sh.xfrm.as_mut() {
if v {
x.flip_v = !x.flip_v;
+1 -2
View File
@@ -408,9 +408,8 @@ fn select_slides(s: &mut Session, p: &Value) -> Result<Value> {
};
s.select(|doc, sel| {
if add {
let mut have: std::collections::HashSet<_> = sel.slides.iter().copied().collect();
for i in &ids {
if have.insert(*i) {
if !sel.slides.contains(i) {
sel.slides.push(*i);
}
}
+21 -43
View File
@@ -253,10 +253,9 @@ fn cell_param(p: &Value, key: &str, cmd: &str) -> Result<Option<(usize, usize)>>
None | Some(Value::Null) => return Ok(None),
Some(v) => v,
};
// Exactly [row, col]: an entry with more is as malformed as one with less.
let idx = |x: &Value| x.as_u64().and_then(|x| usize::try_from(x).ok());
match v.as_array().map(Vec::as_slice) {
Some([r, c]) if let (Some(r), Some(c)) = (idx(r), idx(c)) => Ok(Some((r, c))),
let idx = |i: usize| v.as_array().and_then(|a| a.get(i)).and_then(Value::as_u64).and_then(|x| usize::try_from(x).ok());
match (idx(0), idx(1)) {
(Some(r), Some(c)) => Ok(Some((r, c))),
_ => Err(bad(cmd, format!("`{key}` is not a valid cell: {v}"))),
}
}
@@ -390,54 +389,33 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// At most this many `cells` in one table cell command.
const MAX_CELLS: usize = 100_000;
/// The cells a command acts on: `cells`, else the selected cell range, else the cell holding the
/// caret, else every cell.
fn target_cells(s: &Session, p: &Value, cmd: &str) -> Result<impl Fn(&Table) -> Result<Vec<(usize, usize)>> + use<>> {
// `cells` is a list of [row, col]: anything else, or an entry that is not one, is refused, never
// dropped or read as absent (which would act on the selection or the whole table).
let cells: Option<Vec<(usize, usize)>> = match p.get("cells") {
None | Some(Value::Null) => None,
Some(Value::Array(a)) if a.len() > MAX_CELLS => {
return Err(bad(cmd, format!("`cells` lists {} cells; at most {MAX_CELLS}", a.len())));
}
Some(Value::Array(a)) => Some(
a.iter()
.map(|c| {
cell_param(&json!({ "cell": c }), "cell", cmd)?.ok_or_else(|| bad(cmd, format!("`cells` entry {c} is not a [row, col] cell")))
})
.collect::<Result<_>>()?,
),
Some(v) => return Err(bad(cmd, format!("`cells` is a list of [row, col] cells, not {v}"))),
};
fn target_cells(s: &Session, p: &Value) -> Result<impl Fn(&Table) -> Vec<(usize, usize)> + use<>> {
let cells: Option<Vec<(usize, usize)>> = p
.get("cells")
.and_then(Value::as_array)
.map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
let st = s.doc()?;
// A selected range grows over the merged cells it touches; with only a caret, the caret's cell is used.
let sel = st.selection.cells.map(|(_, a, b)| (a, b, true)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c, false)));
let cmd = cmd.to_string();
Ok(move |t: &Table| match (&cells, sel) {
// Each listed cell must be in the table, so a command never reports success for a cell
// it could not edit.
(Some(c), _) => match c.iter().find(|(r, c)| *r >= t.n_rows() || *c >= t.n_cols()) {
Some((r, c)) => Err(bad(&cmd, format!("no cell [{r}, {c}]: the table has {} rows and {} columns", t.n_rows(), t.n_cols()))),
None => Ok(c.clone()),
},
(Some(c), _) => c.clone(),
(None, Some((a, b, range))) => {
let (a, b) = if range { cell_block(t, a, b) } else { (a, b) };
// The selection can name cells outside the table; only walk the ones that exist.
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
Ok((a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect())
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
}
_ => Ok((0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect()),
_ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(),
})
}
fn cell_fill(s: &mut Session, p: &Value) -> Result<Value> {
let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) };
let targets = target_cells(s, p, "table.cellFill")?;
let targets = target_cells(s, p)?;
with_table(s, p, "table.cellFill", move |t| {
for (r, c) in targets(t)? {
for (r, c) in targets(t) {
if let Some(cell) = t.cell_mut(r, c) {
cell.fill.clone_from(&fill);
}
@@ -469,9 +447,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
}
// "none" clears to no line, so the table style's own lines don't show through either.
let line = if which == "none" { Line::none() } else { line };
let targets = target_cells(s, p, "table.borders")?;
let targets = target_cells(s, p)?;
with_table(s, p, "table.borders", move |t| {
let list = targets(t)?;
let list = targets(t);
let (Some(r0), Some(r1), Some(c0), Some(c1)) =
(list.iter().map(|x| x.0).min(), list.iter().map(|x| x.0).max(), list.iter().map(|x| x.1).min(), list.iter().map(|x| x.1).max())
else {
@@ -485,9 +463,9 @@ fn borders(s: &mut Session, p: &Value) -> Result<Value> {
}
let n = match side {
LEFT => c.checked_sub(1).map(|c| (r, c, RIGHT)),
RIGHT => c.checked_add(1).filter(|c| *c < cols).map(|c| (r, c, LEFT)),
RIGHT => (c + 1 < cols).then_some((r, c + 1, LEFT)),
TOP => r.checked_sub(1).map(|r| (r, c, BOTTOM)),
_ => r.checked_add(1).filter(|r| *r < rows).map(|r| (r, c, TOP)),
_ => (r + 1 < rows).then_some((r + 1, c, TOP)),
};
if let Some((r, c, side)) = n
&& let Some(cell) = t.cell_mut(r, c)
@@ -545,9 +523,9 @@ fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
};
let side = |k: &str, i: usize| f64_param(p, k).filter(|v| v.is_finite()).map_or(base[i], |v| v.clamp(0.0, 400.0));
let m = [side("left", 0), side("right", 1), side("top", 2), side("bottom", 3)];
let targets = target_cells(s, p, "table.cellMargins")?;
let targets = target_cells(s, p)?;
with_table(s, p, "table.cellMargins", move |t| {
for (r, c) in targets(t)? {
for (r, c) in targets(t) {
if let Some(cell) = t.cell_mut(r, c) {
cell.margins = Some(m);
}
@@ -582,9 +560,9 @@ fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
}),
};
let targets = target_cells(s, p, "table.cellAlign")?;
let targets = target_cells(s, p)?;
with_table(s, p, "table.cellAlign", move |t| {
for (r, c) in targets(t)? {
for (r, c) in targets(t) {
if let Some(cell) = t.cell_mut(r, c) {
if anchor.is_some() {
cell.anchor = anchor;
+18 -112
View File
@@ -121,10 +121,9 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
let cur_matched: HashSet<usize> = pairs.iter().map(|p| p.0).collect();
let rev_matched: HashSet<usize> = pairs.iter().map(|p| p.1).collect();
let mut out = vec![];
let mut media = Media::new(cur, rev);
for (i, j) in &pairs {
let (Some(a), Some(b)) = (cur.slides.get(*i), rev.slides.get(*j)) else { continue };
let details = slide_details(cur, a, rev, b, &mut media);
let details = slide_details(cur, a, rev, b);
if !details.is_empty() {
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
}
@@ -141,18 +140,10 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec<Ch
});
}
}
// Each revised slide's open match (the first pair naming it).
let mut rev_to_cur: HashMap<usize, usize> = HashMap::new();
for p in &pairs {
rev_to_cur.entry(p.1).or_insert(p.0);
}
// After the open slide that matches the nearest matched revised slide before it.
let mut at = 0;
for (j, s) in rev.slides.iter().enumerate() {
if let Some(i) = rev_to_cur.get(&j) {
at = i + 1;
}
if !rev_matched.contains(&j) {
// After the open slide that matches the revised slide before it.
let at = (0..j).rev().find_map(|k| pairs.iter().find(|p| p.1 == k).map(|p| p.0 + 1)).unwrap_or(0);
out.push(Change {
kind: ChangeKind::Inserted,
slide: at,
@@ -183,34 +174,8 @@ fn shape_label(s: &Shape) -> String {
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
}
/// Both decks' media bytes by id, and which picture pairs were already compared: a deck can
/// show one picture many times, and its bytes are compared once per pair.
struct Media<'a> {
cur: HashMap<MediaId, &'a [u8]>,
rev: HashMap<MediaId, &'a [u8]>,
same: HashMap<(MediaId, MediaId), bool>,
}
impl<'a> Media<'a> {
fn new(cur: &'a Presentation, rev: &'a Presentation) -> Self {
// The first item with an id wins, as in `Presentation::media`.
let index = |p: &'a Presentation| {
let mut m = HashMap::new();
for x in &p.media {
m.entry(x.id).or_insert(x.data.as_slice());
}
m
};
Media { cur: index(cur), rev: index(rev), same: HashMap::new() }
}
/// Whether the pictures (by media id, `None` for a shape that is not a picture) differ.
fn differ(&mut self, a: Option<MediaId>, b: Option<MediaId>) -> bool {
let (ba, bb) = (a.and_then(|x| self.cur.get(&x)), b.and_then(|y| self.rev.get(&y)));
match (a, b) {
(Some(x), Some(y)) if ba.is_some() && bb.is_some() => !*self.same.entry((x, y)).or_insert_with(|| ba == bb),
_ => ba.is_some() || bb.is_some(),
}
}
fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> {
p.media(id).map(|m| m.data.as_slice())
}
/// A shape without its ids (shape and media), for comparing across decks.
@@ -239,7 +204,7 @@ fn turn(ctx: Option<&Ctx>, s: &Shape) -> (f64, bool, bool) {
drawn_box(ctx, s).map_or((0.0, false, false), |x| (x.rot, x.flip_h, x.flip_v))
}
fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media: &mut Media, out: &mut Vec<String>) {
fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentation, cb: Option<&Ctx>, b: &Shape, out: &mut Vec<String>) {
let label = shape_label(b);
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
let mut what = vec![];
@@ -269,11 +234,11 @@ fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
what.push("formatting");
}
let pic = |s: &Shape| match &s.kind {
ShapeKind::Picture { fill } => Some(fill.media),
let pic = |p: &Presentation, s: &Shape| match &s.kind {
ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()),
_ => None,
};
if media.differ(pic(a), pic(b)) {
if pic(cp, a) != pic(rp, b) {
what.push("picture");
}
// Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also
@@ -300,32 +265,21 @@ fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media
}
}
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide, media: &mut Media) -> Vec<String> {
fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec<String> {
let mut out = vec![];
let mut used: HashSet<usize> = HashSet::new();
let (ca, cb) = (Ctx::for_slide(cp, a), Ctx::for_slide(rp, b));
// The revised shapes by id (first wins) and by name (in order, with a cursor past used ones).
let mut by_id: HashMap<ShapeId, usize> = HashMap::new();
let mut by_name: HashMap<&str, (Vec<usize>, usize)> = HashMap::new();
for (k, x) in b.shapes.iter().enumerate() {
by_id.entry(x.id).or_insert(k);
if !x.name.is_empty() {
by_name.entry(x.name.as_str()).or_default().0.push(k);
}
}
for sa in &a.shapes {
let k = by_id.get(&sa.id).copied().or_else(|| {
let (ks, next) = by_name.get_mut(sa.name.as_str())?;
while ks.get(*next).is_some_and(|k| used.contains(k)) {
*next += 1;
}
ks.get(*next).copied()
});
let k = b
.shapes
.iter()
.position(|x| x.id == sa.id)
.or_else(|| b.shapes.iter().enumerate().position(|(k, x)| !used.contains(&k) && x.name == sa.name && !x.name.is_empty()));
match k {
Some(k) if !used.contains(&k) => {
used.insert(k);
if let Some(sb) = b.shapes.get(k) {
shape_details(ca.as_ref(), sa, cb.as_ref(), sb, media, &mut out);
shape_details(cp, ca.as_ref(), sa, rp, cb.as_ref(), sb, &mut out);
}
}
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
@@ -380,34 +334,19 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
let mut sl = slide.clone();
// Media.
let mut media_map: HashMap<MediaId, MediaId> = HashMap::new();
// Both decks' media by id (the first item with an id wins, as in `Presentation::media`),
// looked up once per reference instead of searched.
let mut rev_at: HashMap<MediaId, usize> = HashMap::new();
for (i, m) in rev.media.iter().enumerate() {
rev_at.entry(m.id).or_insert(i);
}
let mut doc_at: HashMap<MediaId, usize> = HashMap::new();
for (i, m) in doc.media.iter().enumerate() {
doc_at.entry(m.id).or_insert(i);
}
for id in crate::media_refs::slide_media(&sl) {
let Some(m) = rev_at.get(&id).and_then(|&i| rev.media.get(i)) else { continue };
match doc_at.get(&id).and_then(|&i| doc.media.get(i)) {
let Some(m) = rev.media(id) else { continue };
match doc.media(id) {
Some(x) if x.data == m.data => {}
None => {
// Keeps the revision's id, so later ids must start past it.
doc_at.insert(id, doc.media.len());
doc.media.push(m.clone());
doc.next_id = doc.next_id.max(id.0);
}
Some(_) => {
let before = doc.media.len();
let new = doc
.add_media(&m.name, &m.content_type, m.data.as_ref().clone())
.ok_or_else(|| crate::cmd::bad("review.accept", crate::cmd::NO_ID_SPACE))?;
if doc.media.len() > before {
doc_at.entry(new).or_insert(before);
}
media_map.insert(id, new);
}
}
@@ -580,39 +519,6 @@ mod tests {
assert_eq!(s.doc().unwrap().doc.slides.len(), n, "comparing alone changes nothing");
}
#[test]
fn many_inserted_slides_and_same_named_pictures_compare_by_index() {
use std::sync::Arc;
use deckcraft_model::style::PictureFill;
use deckcraft_model::{Shape, ShapeId, ShapeKind, SlideId, defaults};
// One slide of 3,000 pictures of one image, all named "Box"; the revision has the same
// pictures under other ids (matched by name, in order) and 3,000 slides inserted after it.
let mut cur = defaults::new_presentation(None);
let img = cur.add_media("a.png", "image/png", vec![7; 4096]).unwrap();
let mut rev = cur.clone();
let pic = |id: u32| Shape {
id: ShapeId(id),
name: "Box".into(),
xfrm: Some(deckcraft_model::Xfrm::new(10.0, 10.0, 50.0, 50.0)),
kind: ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } },
..Default::default()
};
Arc::make_mut(&mut cur.slides[0]).shapes = (0..3000).map(|k| pic(100_000 + k)).collect();
Arc::make_mut(&mut rev.slides[0]).shapes = (0..3000).map(|k| pic(200_000 + k)).collect();
let mut base = (*rev.slides[0]).clone();
base.shapes.clear();
for k in 0..3000 {
let mut s = base.clone();
s.id = SlideId(300_000 + k);
rev.slides.push(Arc::new(s));
}
let changes = super::diff(&cur, &rev, false);
assert_eq!(changes.len(), 3000, "only the inserted slides differ");
assert!(changes.iter().all(|c| c.kind == super::ChangeKind::Inserted && c.slide == 1));
}
/// A deck with one slide: a table whose cell has a picture fill, a picture background and a
/// transition sound. `tag` starts the bytes of each.
fn media_deck(tag: &str) -> deckcraft_model::Presentation {
+5 -44
View File
@@ -1,21 +1,14 @@
//! Connectors glued to shapes: connection sites, snapping line ends to them, and rerouting attached
//! connectors whenever the shapes they connect move, resize or rotate.
use deckcraft_geom::{Affine, Point, Rect, Xfrm};
use deckcraft_geom::{Point, Xfrm};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use crate::Selection;
use crate::cmd::xfrm_of;
/// Connection sites of a top-level shape `s` in slide coordinates (index = site index stored on
/// connectors). For a shape inside a group use [`sites_of`] or [`sites_in`].
/// Connection sites of `s` in slide coordinates (index = site index stored on connectors).
pub fn sites(doc: &Presentation, sel: &Selection, s: &Shape) -> Vec<Point> {
sites_in(doc, sel, s, Affine::IDENTITY)
}
/// Connection sites of `s` in slide coordinates, `to_abs` mapping the space its box lives in (the
/// slide, or the child space of the groups around it) onto the slide.
pub fn sites_in(doc: &Presentation, sel: &Selection, s: &Shape, to_abs: Affine) -> Vec<Point> {
if s.is_line() || matches!(s.kind, ShapeKind::Group { .. } | ShapeKind::Ink { .. }) {
return vec![];
}
@@ -24,42 +17,10 @@ pub fn sites_in(doc: &Presentation, sel: &Selection, s: &Shape, to_abs: Affine)
if local.is_empty() {
local = vec![Point::new(x.w / 2.0, 0.0), Point::new(0.0, x.h / 2.0), Point::new(x.w / 2.0, x.h), Point::new(x.w, x.h / 2.0)];
}
let a = to_abs * x.affine();
let a = x.affine();
local.into_iter().map(|p| a * p).collect()
}
/// The shape `id` anywhere in `shapes` (a group's member included) with the affine that maps the
/// space its box lives in onto the slide: a connector glues to a grouped shape, as in PowerPoint,
/// and its sites are where the group draws them.
pub fn locate<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId) -> Option<(&'a Shape, Affine)> {
fn rec<'a>(doc: &Presentation, sel: &Selection, shapes: &'a [Shape], id: ShapeId, to_abs: Affine, depth: usize) -> Option<(&'a Shape, Affine)> {
if depth > 64 {
return None;
}
for s in shapes {
if s.id == id {
return Some((s, to_abs));
}
if let ShapeKind::Group { children, child } = &s.kind {
let x = xfrm_of(doc, sel, s);
let ch = Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
let nested = to_abs * deckcraft_geom::group_child_affine(&x, ch);
if let Some(f) = rec(doc, sel, children, id, nested, depth + 1) {
return Some(f);
}
}
}
None
}
rec(doc, sel, shapes, id, Affine::IDENTITY, 0)
}
/// Connection sites of shape `id` anywhere in `shapes`, in slide coordinates; `None` when there is
/// no such shape.
pub fn sites_of(doc: &Presentation, sel: &Selection, shapes: &[Shape], id: ShapeId) -> Option<Vec<Point>> {
locate(doc, sel, shapes, id).map(|(s, a)| sites_in(doc, sel, s, a))
}
/// The nearest connection site within `radius` of `p` on any visible shape except `exclude`.
pub fn nearest_site(
doc: &Presentation,
@@ -107,8 +68,8 @@ pub fn reroute(doc: &mut Presentation, sel: &Selection) {
let snapshot: Vec<Shape> = list.clone();
let view = doc.clone();
let site = |id: ShapeId, idx: u32| -> Option<Point> {
// Anywhere in the tree: a connector glued to a shape that is then grouped keeps following it.
let v = sites_of(&view, sel, &snapshot, id)?;
let s = snapshot.iter().find(|s| s.id == id)?;
let v = sites(&view, sel, s);
v.get(idx as usize).or(v.first()).copied()
};
let mut updates: Vec<(usize, Xfrm, bool, bool)> = vec![];
-14
View File
@@ -48,8 +48,6 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
// A short fixed-size name: appending to the target's name could exceed the file name limit.
let tmp_name = format!(".deckcraft.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
let tmp = target_path.with_file_name(tmp_name);
// The sibling must pass the same check: when `path` is the write root itself it lands outside.
crate::file_access::check_write(&tmp.to_string_lossy())?;
match std::fs::OpenOptions::new().write(true).create_new(true).open(&tmp) {
Ok(f) => break (tmp, f),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && attempts < 16 => attempts += 1,
@@ -137,16 +135,4 @@ mod tests {
let _ = std::fs::remove_file(&file);
}
#[test]
fn write_file_to_the_write_root_itself_creates_no_sibling_outside_it() {
let base = std::env::temp_dir().join(format!("deckcraft_io_root_test_{}", std::process::id()));
let root = base.join("root");
std::fs::create_dir_all(&root).unwrap();
let roots = crate::file_access::AutomationRoots::new(&[], std::slice::from_ref(&root)).unwrap().unwrap();
let res = crate::file_access::confine(Some(&roots), || write_file(root.to_str().unwrap(), b"x"));
assert!(res.unwrap_err().contains("outside the write root"));
assert_eq!(std::fs::read_dir(&base).unwrap().count(), 1, "only the root is there");
let _ = std::fs::remove_dir_all(&base);
}
}
+3 -16
View File
@@ -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,
}
+8 -42
View File
@@ -700,29 +700,15 @@ pub struct Word {
pub repeated: Option<usize>,
}
/// Longest language tag a word is checked in. BCP 47 tags in documents are a few bytes; a longer
/// value from a file is no language we have a dictionary for, and copying it into every word of
/// a paragraph would multiply it.
const MAX_LANG_LEN: usize = 64;
/// The words of `p`, each with its run's language (`lang` on the run, else `default`). Words
/// with digits are skipped; apostrophes inside a word belong to it; hyphenated words are checked
/// part by part. A language over [`MAX_LANG_LEN`] counts as none.
/// part by part.
pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec<Word> {
let mut chars: Vec<(char, usize)> = vec![];
for (ri, r) in p.runs.iter().enumerate() {
chars.extend(r.content().chars().map(|c| (c, ri)));
}
let default = if default.len() > MAX_LANG_LEN { "" } else { default };
let lang_of = |ri: usize| {
p.runs
.get(ri)
.and_then(|r| r.props.lang.as_deref())
.filter(|l| !l.is_empty())
.map(|l| if l.len() > MAX_LANG_LEN { "" } else { l })
.unwrap_or(default)
.to_string()
};
let lang_of = |ri: usize| p.runs.get(ri).and_then(|r| r.props.lang.clone()).filter(|l| !l.is_empty()).unwrap_or_else(|| default.to_string());
// Combining marks (decomposed accents) belong to the letter before them.
let inside = |c: char| c.is_alphanumeric() || deckcraft_model::words::is_combining_mark(c) || c == '\'' || c == '’' || c == '-';
let mut out = vec![];
@@ -805,18 +791,15 @@ pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String]
.chain(repeated(p, default))
.collect::<Vec<_>>()
.into_iter()
.fold((vec![], HashMap::new()), |(mut v, mut at): (Vec<Word>, HashMap<usize, usize>), w| {
.fold(vec![], |mut v: Vec<Word>, w| {
// A repeated misspelt word is reported once, as repeated.
match at.get(&w.start).and_then(|&i| v.get_mut(i)) {
Some(x) => *x = w,
None => {
at.insert(w.start, v.len());
v.push(w);
}
if let Some(i) = v.iter().position(|x| x.start == w.start) {
v[i] = w;
} else {
v.push(w);
}
(v, at)
v
})
.0
}
/// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself).
@@ -970,23 +953,6 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
assert_eq!(w, vec!["check", "done"]);
}
#[test]
fn a_long_paragraph_with_a_huge_language_is_checked_in_linear_work() {
use deckcraft_model::{Paragraph, Run, RunProps};
// 100,000 copies of one word under a 1 MB language value: every word after the first is
// a repeat, and no word carries a copy of the value.
let lang = "x".repeat(1 << 20);
let p =
Paragraph { runs: vec![Run::with("the ".repeat(100_000), RunProps { lang: Some(lang), ..Default::default() })], ..Default::default() };
let words = paragraph_words(&p, "en-US");
assert_eq!(words.len(), 100_000);
assert!(words.iter().all(|w| w.lang.is_empty()));
let bad = misspelt(&p, "en-US", &[]);
assert_eq!(bad.len(), 99_999);
assert!(bad.iter().all(|w| w.repeated.is_some()));
assert!(paragraph_words(&Paragraph::new("word"), &"y".repeat(100)).iter().all(|w| w.lang.is_empty()));
}
#[test]
fn long_and_numeric_flags_and_latin1() {
let d = Dictionary::parse(b"FLAG long\nSFX Aa Y 1\nSFX Aa 0 s .\n", b"1\ncat/Aa\n");
-523
View File
@@ -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:?}");
}
+1 -6
View File
@@ -236,12 +236,7 @@ impl Session {
}
}
// A connector dragged away from shapes that stay put comes unglued at those ends.
let mut moved: Vec<ShapeId> = vec![];
for (id, _) in &origs2 {
if let Some(sh) = deckcraft_model::find_shape(list, *id) {
deckcraft_model::walk(std::slice::from_ref(sh), &mut |s, _| moved.push(s.id));
}
}
let moved: Vec<ShapeId> = origs2.iter().map(|o| o.0).collect();
for sh in list.iter_mut().filter(|s| moved.contains(&s.id)) {
if let ShapeKind::Connector { start, end } = &mut sh.kind {
for c in [start, end] {
-1
View File
@@ -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);
+1 -6
View File
@@ -227,9 +227,6 @@ impl FontDb {
}
}
/// Most caret records read for one ligature glyph.
const MAX_LIGATURE_CARETS: usize = 64;
/// Caret positions inside a ligature glyph from GDEF `LigCaretList`, in font units from the
/// glyph's origin (sorted ascending). Empty when the font has none for `gid`.
pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
@@ -239,12 +236,9 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
let Ok(cov) = list.coverage() else { return Vec::new() };
let Some(idx) = cov.get(skrifa::GlyphId::new(gid)) else { return Vec::new() };
let Ok(lig) = list.lig_glyphs().get(idx as usize) else { return Vec::new() };
// The limit applies to the records read, not to the carets kept: a font can list thousands of
// records it then has to be read through for every ligature laid out.
let mut out: Vec<i32> = lig
.caret_values()
.iter()
.take(MAX_LIGATURE_CARETS)
.filter_map(|cv| cv.ok())
.filter_map(|cv| match cv {
skrifa::raw::tables::gdef::CaretValue::Format1(f) => Some(f.coordinate() as i32),
@@ -252,6 +246,7 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec<i32> {
// Format 2 is a contour point index; outlines aren't consulted here.
skrifa::raw::tables::gdef::CaretValue::Format2(_) => None,
})
.take(64)
.collect();
out.sort_unstable();
out
+20 -149
View File
@@ -368,7 +368,7 @@ struct CatalogEntry {
/// Process-wide font database.
pub struct FontDb {
faces: RwLock<Vec<Arc<FontFace>>>,
outlines: Mutex<OutlineCache>,
outlines: Mutex<HashMap<(u32, u32), Arc<BezPath>>>,
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock<Vec<CatalogEntry>>,
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
@@ -439,38 +439,6 @@ const SYSTEM_FALLBACKS: &[&str] = &[
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
const OUTLINE_CACHE_MAX: usize = 50_000;
/// Most path elements one glyph outline may expand to. Composite and `VARC` glyphs can draw a
/// shared outline many times over; a glyph past this draws nothing rather than holding megabytes.
const MAX_OUTLINE_ELEMENTS: usize = 1 << 15;
/// Most path elements the outline cache holds across all glyphs (about 230 MB at most) before it
/// starts over: distinct glyph ids can each expand one dense outline.
const OUTLINE_CACHE_MAX_ELEMENTS: usize = 1 << 22;
/// Glyph outlines by (face, glyph), with the path elements they hold.
#[derive(Default)]
struct OutlineCache {
paths: HashMap<(u32, u32), Arc<BezPath>>,
elements: usize,
}
impl OutlineCache {
fn get(&self, key: &(u32, u32)) -> Option<Arc<BezPath>> {
self.paths.get(key).cloned()
}
fn insert(&mut self, key: (u32, u32), path: Arc<BezPath>) {
let n = path.elements().len();
if let Some(old) = self.paths.remove(&key) {
self.elements = self.elements.saturating_sub(old.elements().len());
}
if self.paths.len() >= OUTLINE_CACHE_MAX || self.elements.saturating_add(n) > OUTLINE_CACHE_MAX_ELEMENTS {
self.paths.clear();
self.elements = 0;
}
self.paths.insert(key, path);
self.elements = self.elements.saturating_add(n);
}
}
fn name(font: &skrifa::FontRef<'_>, ids: &[StringId]) -> Option<String> {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
@@ -726,10 +694,6 @@ const MAX_COLLECTION_FACES: u32 = 256;
const MAX_COLLECTION_ENTRIES_SCANNED: u32 = 16_384;
/// Maximum named instances read from a single face.
const MAX_NAMED_INSTANCES_PER_FACE: usize = 1024;
/// Maximum faces (named instances counted separately) enumerated from one font file, across all
/// of a collection's faces: the per-collection and per-face limits multiply, and each face found
/// is then built with its own shaper.
const MAX_ENUMERATED_FACES: usize = 4096;
fn distinct_face_indices(entries: impl IntoIterator<Item = (u32, u32)>) -> Vec<u32> {
let mut offsets = std::collections::HashSet::new();
@@ -766,30 +730,21 @@ fn enumerate_faces(data: &[u8]) -> Vec<Found> {
Err(_) => Vec::new(),
};
let mut out = Vec::new();
// Named instances read and plain faces kept, across the whole file.
let mut budget = MAX_ENUMERATED_FACES;
for i in indices {
if budget == 0 {
break;
}
let Ok(f) = skrifa::FontRef::from_index(data, i) else { continue };
let Some((family, style)) = face_names(&f) else { continue };
let axes = f.axes();
let mut named = Vec::new();
let mut seen = std::collections::HashSet::new();
let instances = f.named_instances();
for ni in instances.iter().take(capped_named_instance_count(instances.len())) {
let Some(left) = budget.checked_sub(1) else { break };
budget = left;
let Some(style) = name(&f, &[ni.subfamily_name_id()]) else { continue };
if !seen.insert(style.to_ascii_lowercase()) {
if named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
continue;
}
let coords: Vec<([u8; 4], f32)> = axes.iter().zip(ni.user_coords()).map(|(a, v)| (a.tag().to_be_bytes(), v)).collect();
named.push((i, style, coords));
}
if named.is_empty() {
budget = budget.saturating_sub(1);
out.push((i, family, style, Vec::new()));
} else {
out.extend(named.into_iter().map(|(i, s, c)| (i, family.clone(), s, c)));
@@ -950,7 +905,7 @@ impl FontDb {
}
Self {
faces: RwLock::new(faces),
outlines: Mutex::new(OutlineCache::default()),
outlines: Mutex::new(HashMap::new()),
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock::new(Vec::new()),
font_dirs,
@@ -1260,8 +1215,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 +1227,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)),
@@ -1562,72 +1513,41 @@ impl FontDb {
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
return p;
return p.clone();
}
let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS);
let mut pen = FlipPen(BezPath::new());
if let Some(f) = face.skrifa()
&& let Some(g) = f.outline_glyphs().get(GlyphId::new(gid))
{
let _ = g.draw(DrawSettings::unhinted(Size::unscaled(), face.location()), &mut pen);
}
let p = Arc::new(pen.finish());
self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone());
let p = Arc::new(pen.0);
let mut cache = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
if cache.len() >= OUTLINE_CACHE_MAX {
cache.clear();
}
cache.insert(key, p.clone());
p
}
}
/// Collects a glyph outline, y flipped, up to `limit` path elements. Past the limit it ignores
/// what follows and [`FlipPen::finish`] gives an empty path: a cut-off outline would fill wrongly.
struct FlipPen {
path: BezPath,
limit: usize,
over: bool,
}
impl FlipPen {
fn new(limit: usize) -> Self {
FlipPen { path: BezPath::new(), limit, over: false }
}
/// Room for one more element?
fn room(&mut self) -> bool {
if !self.over && self.path.elements().len() >= self.limit {
self.over = true;
self.path = BezPath::new();
}
!self.over
}
fn finish(self) -> BezPath {
if self.over { BezPath::new() } else { self.path }
}
}
struct FlipPen(BezPath);
impl OutlinePen for FlipPen {
fn move_to(&mut self, x: f32, y: f32) {
if self.room() {
self.path.move_to((x as f64, -y as f64));
}
self.0.move_to((x as f64, -y as f64));
}
fn line_to(&mut self, x: f32, y: f32) {
if self.room() {
self.path.line_to((x as f64, -y as f64));
}
self.0.line_to((x as f64, -y as f64));
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
if self.room() {
self.path.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
}
self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
}
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
if self.room() {
self.path.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
}
self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
}
fn close(&mut self) {
if self.room() {
self.path.close_path();
}
self.0.close_path();
}
}
@@ -1655,55 +1575,6 @@ mod font_dir_tests {
assert_eq!(capped_named_instance_count(MAX_NAMED_INSTANCES_PER_FACE + 1), MAX_NAMED_INSTANCES_PER_FACE);
}
#[test]
fn faces_of_one_file_are_bounded_across_the_collection() {
// The per-collection and per-face limits together would allow far more.
assert!((MAX_COLLECTION_FACES as usize) * MAX_NAMED_INSTANCES_PER_FACE > 16 * MAX_ENUMERATED_FACES);
for data in bundled() {
assert!(enumerate_faces(data).len() <= MAX_ENUMERATED_FACES);
}
}
#[test]
fn an_outline_past_the_element_limit_draws_nothing() {
let mut pen = FlipPen::new(8);
pen.move_to(0.0, 0.0);
pen.line_to(1.0, 1.0);
pen.close();
assert_eq!(pen.finish().elements().len(), 3);
// A shared outline drawn over and over (composite and VARC glyphs).
let mut pen = FlipPen::new(8);
for i in 0..1_000_000 {
pen.move_to(0.0, 0.0);
pen.line_to(i as f32, 1.0);
pen.close();
}
assert_eq!(pen.path.elements().len(), 0);
assert!(pen.finish().elements().is_empty());
}
#[test]
fn outline_cache_is_bounded_by_elements() {
let mut path = BezPath::new();
path.move_to((0.0, 0.0));
for i in 1..MAX_OUTLINE_ELEMENTS {
path.line_to((i as f64, 0.0));
}
let path = Arc::new(path);
let mut cache = OutlineCache::default();
let glyphs = 4 * OUTLINE_CACHE_MAX_ELEMENTS / MAX_OUTLINE_ELEMENTS;
for gid in 0..glyphs as u32 {
cache.insert((1, gid), path.clone());
assert!(cache.elements <= OUTLINE_CACHE_MAX_ELEMENTS);
assert_eq!(cache.elements, cache.paths.values().map(|p| p.elements().len()).sum::<usize>());
}
assert!(cache.get(&(1, glyphs as u32 - 1)).is_some());
// Replacing an entry does not count it twice.
let before = cache.elements;
cache.insert((1, glyphs as u32 - 1), path.clone());
assert_eq!(cache.elements, before);
}
#[test]
fn xdg_data_dirs_contribute_their_fonts_folders() {
let home = Path::new("/home/ann");
+1 -8
View File
@@ -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 {
+2 -10
View File
@@ -71,8 +71,7 @@ pub fn slides_to_rtf(p: &Presentation) -> String {
}
out.push_str("\\b0\\par\n");
for sh in &s.shapes {
// As in the text outline: footer, date and slide-number placeholders are not body text.
if sh.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()) {
if sh.ph_type().is_some_and(|k| k.is_title()) {
continue;
}
let Some(t) = &sh.text else { continue };
@@ -300,8 +299,7 @@ impl Outline<'_> {
/// A one-character control symbol (`\\`, `\{`, `\~`, ...).
fn symbol(&mut self, n: u8) {
let c = self.rtf.get(self.i..).and_then(|r| r.chars().next());
self.i = advance(self.rtf, self.i, 1);
self.i += 1;
if self.st.skip {
return;
}
@@ -310,7 +308,6 @@ impl Outline<'_> {
b'~' => self.line.push('\u{a0}'),
b'_' => self.line.push('\u{2011}'),
b'\n' | b'\r' => self.flush(),
_ if !n.is_ascii() => self.line.extend(c),
_ => {}
}
}
@@ -422,11 +419,6 @@ mod tests {
assert_eq!(rtf_to_outline(r"{\rtf1 x\u-70000?y\par}"), "x\u{ee90}y\n");
}
#[test]
fn multibyte_control_symbol_keeps_character_and_following_text() {
assert_eq!(rtf_to_outline("{\\rtf1 A\\éB\\par}"), "AéB\n");
}
/// `\\_` is RTF's non-breaking hyphen: a visible character, not markup.
#[test]
fn non_breaking_hyphen_is_kept() {
-7
View File
@@ -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.
+2 -31
View File
@@ -126,21 +126,6 @@ fn split_curve(c: &[Point], n: usize, out: &mut Vec<Point>) {
out.extend([p1, p2, p3]);
}
/// A piece length ordered totally, so it can sit in a heap.
#[derive(PartialEq)]
struct Share(f64);
impl Eq for Share {}
impl PartialOrd for Share {
fn partial_cmp(&self, o: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(o))
}
}
impl Ord for Share {
fn cmp(&self, o: &Self) -> std::cmp::Ordering {
self.0.total_cmp(&o.0)
}
}
/// Brings a contour to `n` curves. Unlike manim (which splits every curve equally often), the extra
/// splits go to the longest pieces, so a long side gets more points than a short one.
fn refine(c: &Contour, n: usize) -> Contour {
@@ -151,15 +136,11 @@ fn refine(c: &Contour, n: usize) -> Contour {
let curves: Vec<&[Point]> = (0..have).filter_map(|k| c.pts.get(3 * k..3 * k + 4)).collect();
let len: Vec<f64> = curves.iter().map(|q| curve_length(q) + 1e-9).collect();
let mut parts = vec![1usize; curves.len()];
// The curve with the longest piece gets the next split (the lowest index on a tie); a heap keeps
// this O(n log n) instead of rescanning every curve for each split.
let mut heap: std::collections::BinaryHeap<(Share, std::cmp::Reverse<usize>)> =
len.iter().enumerate().map(|(k, l)| (Share(*l), std::cmp::Reverse(k))).collect();
for _ in 0..n - have {
let Some((_, std::cmp::Reverse(best))) = heap.pop() else { break };
let share = |k: usize| len.get(k).copied().unwrap_or(0.0) / parts.get(k).copied().unwrap_or(1) as f64;
let best = (1..curves.len()).fold(0, |b, k| if share(k) > share(b) { k } else { b });
if let Some(p) = parts.get_mut(best) {
*p += 1;
heap.push((Share(len.get(best).copied().unwrap_or(0.0) / *p as f64), std::cmp::Reverse(best)));
}
}
let mut out = Vec::with_capacity(1 + 3 * n);
@@ -439,16 +420,6 @@ mod tests {
assert_eq!(contours(&to_path(&cs)), cs);
}
#[test]
fn refine_many_curves_finishes_at_the_asked_count() {
let mut c = Contour { closed: false, pts: vec![Point::ZERO] };
for i in 1..=20_000 {
line_to(&mut c.pts, Point::new(f64::from(i), f64::from(i % 7)));
}
let r = refine(&c, 40_000);
assert_eq!(r.curves(), 40_000);
}
#[test]
fn refine_splits_the_longest_curves_and_keeps_the_shape() {
let c = Contour { closed: false, pts: vec![Point::ZERO, Point::new(1.0, 0.0), Point::new(2.0, 0.0), Point::new(3.0, 0.0)] };
+9 -19
View File
@@ -928,25 +928,11 @@ impl Warp {
k_fill = 1.0;
}
zoom = zoom.max(k_fill);
// Per band, measured once: its widest line and its first and last lines' baselines.
let mut stats: Vec<Option<(f64, f64, f64)>> = vec![None; nb];
for (l, &b) in lines.iter().zip(&band_of) {
let w = l.extent.1 - l.extent.0;
if let Some(slot) = stats.get_mut(b) {
match slot {
Some((widest, _, last)) => {
*widest = widest.max(w);
*last = l.ref_y;
}
None => *slot = Some((0.0f64.max(w), l.ref_y, l.ref_y)),
}
}
}
let mut maps = Vec::with_capacity(n);
let mut offsets = Vec::new();
for (i, l) in lines.iter().enumerate() {
let b = band_of.get(i).copied().unwrap_or(0);
let band = stats.get(b).copied().flatten();
let in_band = |j: &usize| band_of.get(*j) == Some(&b);
match self.bands.get(b) {
Some(Band::Fill { .. }) | None => {
let (y0, y1) = band_y.get(b).copied().flatten().unwrap_or((0.0, 1.0));
@@ -955,12 +941,16 @@ impl Warp {
}
Some(Band::Follow { path, anchor }) => {
let len = path.length();
let widest = band.map_or(0.0, |s| s.0);
let widest = (0..n).filter(in_band).filter_map(|j| lines.get(j)).map(|l| l.extent.1 - l.extent.0).fold(0.0, f64::max);
let k = if widest > len && widest > 1e-9 { len / widest } else { 1.0 };
let members: Vec<&WarpLine> = (0..n).filter(in_band).filter_map(|j| lines.get(j)).collect();
let ref_y = match anchor {
LineAnchor::Last => band.map(|s| s.2),
LineAnchor::First => band.map(|s| s.1),
LineAnchor::Middle => band.map(|s| (s.1 + s.2) / 2.0),
LineAnchor::Last => members.last().map(|l| l.ref_y),
LineAnchor::First => members.first().map(|l| l.ref_y),
LineAnchor::Middle => match (members.first(), members.last()) {
(Some(a), Some(z)) => Some((a.ref_y + z.ref_y) / 2.0),
_ => None,
},
}
.unwrap_or(l.ref_y);
// Lines off the guide run on a parallel curve at their natural length.
-17
View File
@@ -319,20 +319,3 @@ fn arch_and_inflate_handles_follow_the_pointer() {
assert!(more > adjust_defaults("textInflate")[0], "{more}");
assert!((more - 20_000.0).abs() < 1.0, "{more}");
}
#[test]
fn many_lines_on_a_follow_band_are_mapped_from_one_measurement() {
// 50,000 lines on an arch: each line's scale and anchor come from the band measured once.
let w = warp("textArchUp", &[], rect()).unwrap();
let inner = Rect::new(7.2, 3.6, W - 7.2, H - 3.6);
let lines: Vec<WarpLine> = (0..50_000)
.map(|i| {
let y = i as f64 * 0.01;
WarpLine { ink: Some(Rect::new(190.0, y, 230.0, y + 1.0)), extent: (190.0, 230.0), ref_y: y + 1.0 }
})
.collect();
let m = w.mapper(&lines, inner);
// The last line sits on the arc, as with two lines.
let last = lines.len() - 1;
assert!(m.map(last, Point::new(210.0, lines[last].ref_y)).y.abs() < 0.05);
}
+21 -128
View File
@@ -78,57 +78,45 @@ pub fn insert(body: &mut TextBody, pos: Pos, text: &str, props: Option<RunProps>
if body.paragraphs.is_empty() {
body.paragraphs.push(Paragraph::default());
}
let (pi0, mut ch) = clamp(body, pos);
let props = props.unwrap_or_else(|| props_at(body, (pi0, ch)));
let (mut pi, mut ch) = clamp(body, pos);
let props = props.unwrap_or_else(|| props_at(body, (pi, ch)));
let text = text.replace("\r\n", "\n").replace('\r', "\n");
// Work on the paragraph being edited apart from the body, so each new paragraph is pushed to a
// list and the body's vector is spliced once instead of shifted for every `\n`.
let Some(slot) = body.paragraphs.get_mut(pi0) else { return (pi0, ch) };
let mut cur = std::mem::take(slot);
let mut done: Vec<Paragraph> = Vec::new();
let mut first = true;
for part in text.split('\n') {
if !first {
// Split the current paragraph at ch.
let idx = split_at(&mut cur, ch);
let tail: Vec<Run> = cur.runs.drain(idx..).collect();
let newp = Paragraph {
level: cur.level,
props: cur.props.clone(),
runs: tail,
def_props: cur.def_props.clone(),
end_props: cur.end_props.clone(),
};
cur.end_props = props.clone();
done.push(std::mem::replace(&mut cur, newp));
// Split paragraph pi at ch.
let Some(p) = body.paragraphs.get_mut(pi) else { break };
let idx = split_at(p, ch);
let tail: Vec<Run> = p.runs.drain(idx..).collect();
let newp =
Paragraph { level: p.level, props: p.props.clone(), runs: tail, def_props: p.def_props.clone(), end_props: p.end_props.clone() };
p.end_props = props.clone();
body.paragraphs.insert(pi + 1, newp);
pi += 1;
ch = 0;
}
first = false;
// The run index just after the last insertion: the next one lands there with no rescan.
let mut next: Option<usize> = None;
for (k, seg) in part.split('\u{b}').enumerate() {
let Some(p) = body.paragraphs.get_mut(pi) else { break };
if k > 0 {
let idx = next.unwrap_or_else(|| split_at(&mut cur, ch));
cur.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
let idx = split_at(p, ch);
p.runs.insert(idx, Run { text: String::new(), props: props.clone(), kind: RunKind::Break });
ch += 1;
next = Some(idx + 1);
}
if seg.is_empty() {
continue;
}
let idx = next.unwrap_or_else(|| split_at(&mut cur, ch));
cur.runs.insert(idx, Run::with(seg, props.clone()));
let idx = split_at(p, ch);
p.runs.insert(idx, Run::with(seg, props.clone()));
ch += seg.chars().count();
next = Some(idx + 1);
}
cur.normalize();
if cur.runs.is_empty() {
cur.end_props = props.clone();
if let Some(p) = body.paragraphs.get_mut(pi) {
p.normalize();
if p.runs.is_empty() {
p.end_props = props.clone();
}
}
}
let pi = pi0 + done.len();
done.push(cur);
body.paragraphs.splice(pi0..=pi0, done);
(pi, ch)
}
@@ -264,16 +252,8 @@ pub fn slice(body: &TextBody, a: Pos, b: Pos) -> TextBody {
/// Insert a formatted body at `pos` (paste): first paragraph merges into the current one.
pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
let mut caret = clamp(body, pos);
// Plain text runs go straight in at the run index after the previous one and the paragraph is
// normalized once, not rescanned and merged for every source run.
let mut next: Option<usize> = None;
let mut dirty = false;
for (k, sp) in src.paragraphs.iter().enumerate() {
if k > 0 {
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
p.normalize();
}
(next, dirty) = (None, false);
caret = insert(body, caret, "\n", None);
if let Some(p) = body.paragraphs.get_mut(caret.0) {
p.level = sp.level;
@@ -281,22 +261,6 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
}
}
for r in &sp.runs {
if r.kind == RunKind::Text
&& !r.text.is_empty()
&& !r.text.contains(['\n', '\r', '\u{b}'])
&& let Some(p) = body.paragraphs.get_mut(caret.0)
{
let idx = next.unwrap_or_else(|| split_at(p, caret.1));
p.runs.insert(idx, Run::with(&r.text, r.props.clone()));
caret.1 += r.text.chars().count();
(next, dirty) = (Some(idx + 1), true);
continue;
}
// Anything else takes the general path, after the pending runs are merged.
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
p.normalize();
}
(next, dirty) = (None, false);
match r.kind {
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
@@ -310,9 +274,6 @@ pub fn insert_body(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
}
}
}
if let (true, Some(p)) = (dirty, body.paragraphs.get_mut(caret.0)) {
p.normalize();
}
caret
}
@@ -607,74 +568,6 @@ mod tests {
assert_eq!(caret, (0, 5));
}
/// The paste as it ran before the fast path: one `insert` (and normalize) per source run.
fn insert_body_by_runs(body: &mut TextBody, pos: Pos, src: &TextBody) -> Pos {
let mut caret = clamp(body, pos);
for (k, sp) in src.paragraphs.iter().enumerate() {
if k > 0 {
caret = insert(body, caret, "\n", None);
if let Some(p) = body.paragraphs.get_mut(caret.0) {
p.level = sp.level;
p.props = sp.props.clone();
}
}
for r in &sp.runs {
match r.kind {
RunKind::Text => caret = insert(body, caret, &r.text, Some(r.props.clone())),
RunKind::Break => caret = insert(body, caret, "\u{b}", Some(r.props.clone())),
_ => {
if let Some(p) = body.paragraphs.get_mut(caret.0) {
let idx = split_at(p, caret.1);
p.runs.insert(idx, r.clone());
caret.1 += r.char_len();
}
}
}
}
}
caret
}
#[test]
fn paste_matches_inserting_run_by_run() {
let bold = |t: &str| Run::with(t, RunProps { bold: Some(true), ..Default::default() });
let mut src = body("");
src.paragraphs = vec![
Paragraph {
runs: vec![Run::with("ab", RunProps::default()), Run::with("cd", RunProps::default()), bold("ef"), bold("g")],
..Default::default()
},
Paragraph {
runs: vec![
bold("h"),
Run { text: String::new(), props: RunProps::default(), kind: RunKind::Break },
Run::with("i", RunProps::default()),
],
level: 1,
..Default::default()
},
Paragraph { runs: vec![Run::with("jk", RunProps::default())], ..Default::default() },
];
for at in [0, 1, 3, 6] {
let mut dest = body("XYZabc");
format(&mut dest, (0, 2), (0, 4), &|p| p.italic = Some(true));
let mut want = dest.clone();
let want_caret = insert_body_by_runs(&mut want, (0, at), &src);
let caret = insert_body(&mut dest, (0, at), &src);
assert_eq!(caret, want_caret, "caret at {at}");
assert_eq!(format!("{:?}", dest.paragraphs), format!("{:?}", want.paragraphs), "paste at {at}");
}
}
#[test]
fn multiline_insert_splits_into_paragraphs() {
let mut b = body("abcd");
let c = insert(&mut b, (0, 2), "x\ny\u{b}z\n\nw", None);
assert_eq!(c, (3, 1));
let texts: Vec<String> = b.paragraphs.iter().map(Paragraph::text).collect();
assert_eq!(texts, ["abx", "y\u{b}z", "", "wcd"]);
}
#[test]
fn words_and_moves() {
let b = body("Hello brave world");
+1 -63
View File
@@ -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);
-6
View File
@@ -415,11 +415,6 @@ impl CaptureScanner {
}
fn read_parts(src: &(impl ByteSource + ?Sized), parts: &[(u64, u32)]) -> Result<Vec<u8>> {
// Ranges come from a scan of some source; check them against this one before sizing a buffer.
let len = src.len();
if parts.iter().any(|&(o, n)| o.checked_add(u64::from(n)).is_none_or(|end| end > len)) {
return Err(Error::Invalid("packet range lies outside the source".into()));
}
let mut v = Vec::with_capacity(parts.iter().map(|p| p.1 as usize).sum());
for &(o, n) in parts {
let a = v.len();
@@ -513,7 +508,6 @@ impl OpusTiming {
c = timing_add(c, i64::from(durations[k]))?;
ends[k] = Some(c);
}
cursor = Some(c);
break;
}
let Some(g) = stream.packets[j].granule else { break };
-31
View File
@@ -183,27 +183,6 @@ fn opus_toc_durations() {
assert_eq!(opus_packet_samples([0xF8, 0], 0), None);
}
#[test]
fn opus_timing_includes_packets_after_the_last_known_granule() {
let f = opus_file(6, 312, 0);
let o = open(&f).unwrap();
let mut stream = o.streams[0].clone();
// Missing granules on complete-packet pages are non-conforming. The existing recovery
// fallback still assigns their packet positions; total must include the same samples.
stream.packets.last_mut().unwrap().granule = None;
let timing = OpusTiming::of(&stream, 312).unwrap();
assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]);
assert_eq!(timing.total, 6 * 960 - 312);
// With no usable granule, every complete packet still contributes its TOC duration.
for packet in &mut stream.packets {
packet.granule = None;
}
let timing = OpusTiming::of(&stream, 312).unwrap();
assert_eq!(timing.starts, vec![-312, 648, 1608, 2568, 3528, 4488]);
assert_eq!(timing.total, 6 * 960 - 312);
}
#[test]
fn opus_timing_rejects_a_granule_that_overflows() {
let packet = |granule: i64| Stream {
@@ -295,13 +274,3 @@ fn a_budget_too_small_to_search_is_spent() {
let f = opus_file(6, 312, 0);
assert_eq!(scanner.find(&f, 0, f.len() as u64).unwrap(), None);
}
#[test]
fn read_packet_checks_its_ranges_against_the_source_before_allocating() {
let f = opus_file(10, 312, 500);
let o = open(&f).unwrap();
let s = o.stream_of(Codec::Opus).unwrap();
// The last packet lies past the end of a source cut short: an error, not a read of missing bytes.
let short = &f[..f.len() / 2];
assert!(matches!(o.read_packet(&short, s, 9), Err(Error::Invalid(_))));
}
+4 -5
View File
@@ -307,7 +307,7 @@ pub fn master_text_pieces(pres: &Presentation, m: &deckcraft_model::Master, pape
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
@@ -522,8 +522,7 @@ impl Exporter<'_> {
let k = b.width() / sw;
// Raster at the requested dpi relative to the box size on paper.
let scale = raster_scale(b.width(), (sw, sh), self.opts.dpi);
// One clock reading for the raster and the text layer, so a date field cannot differ between them.
let ropts = RenderOpts { scale, threads: 4, now: deckcraft_text::datetime::DateTime::now_local(), ..Default::default() };
let ropts = RenderOpts { scale, threads: 4, ..Default::default() };
// Two rasters per slide: the backdrop (background, master and layout graphics) is the same
// on every slide that shares its layout, so the writer stores it once, and the slide's own
// shapes draw over it (#845).
@@ -564,7 +563,7 @@ impl Exporter<'_> {
}
if self.opts.text_layer {
let view = Affine::translate((b.x0, b.y0)) * Affine::scale(k);
for pt in deckcraft_render::place_slide_with(pres, i, &ropts).texts {
for pt in deckcraft_render::place_slide(pres, i).texts {
self.text_layer(s, &pt, view, false)?;
}
let _ = sh;
@@ -582,7 +581,7 @@ impl Exporter<'_> {
.body
.paragraphs
.get(run.para)
.map(|p| p.runs.iter().map(|r| r.content()).collect::<String>())
.map(|p| p.runs.iter().map(|r| r.text.as_str()).collect::<String>())
.unwrap_or_default()
.chars()
.skip(run.chars.0)
+5 -56
View File
@@ -269,6 +269,8 @@ fn unembeddable_font_returns_error_instead_of_a_pdf_missing_text() {
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
use deckcraft_model::{Shape, ShapeId, Xfrm};
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object, Stream};
let text = "Café Ελληνικά Привет";
let face = FontDb::global().face("Ubuntu", "Regular");
@@ -290,17 +292,8 @@ fn supported_unicode_is_shaped_and_recovered_from_the_page_text_layer() {
assert!(runs.iter().all(|run| run.face.family == "Ubuntu" && !run.glyphs.is_empty() && run.glyphs.iter().all(|glyph| glyph.0 != 0)));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
assert_eq!(page_text(&bytes, 0), text);
}
/// The text of page `index`, read the way a PDF reader does: the content stream's text-showing
/// operators decoded through the page font's ToUnicode map.
fn page_text(bytes: &[u8], index: usize) -> String {
use hayro_syntax::content::ops::TypedInstruction;
use hayro_syntax::object::{Dict, Object, Stream};
let pdf = hayro_syntax::Pdf::new(bytes.to_vec()).unwrap();
let page = &pdf.pages()[index];
let pdf = hayro_syntax::Pdf::new(bytes.clone()).unwrap();
let page = &pdf.pages()[0];
let resources = page.raw().get::<Dict<'_>>(b"Resources").unwrap();
let fonts = resources.get::<Dict<'_>>(b"Font").unwrap();
assert_eq!(fonts.len(), 1);
@@ -351,51 +344,7 @@ fn page_text(bytes: &[u8], index: usize) -> String {
_ => {}
}
}
extracted
}
fn ubuntu_runs(runs: Vec<deckcraft_model::text::Run>) -> TextBody {
let paragraph = deckcraft_model::text::Paragraph { runs, ..Default::default() };
let mut body = TextBody { paragraphs: vec![paragraph], ..Default::default() };
for run in &mut body.paragraphs[0].runs {
run.props.font = Some("Ubuntu".into());
run.props.size = Some(20.0);
}
body
}
fn deck_with_body(n: usize, body: TextBody) -> Presentation {
use deckcraft_model::{Shape, ShapeId, Xfrm};
let mut p = deck(n);
for slide in &mut p.slides {
Arc::make_mut(slide).shapes =
vec![Shape { id: ShapeId(9001), xfrm: Some(Xfrm::new(20.0, 20.0, 920.0, 100.0)), text: Some(body.clone()), ..Default::default() }];
}
p
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn slide_number_field_is_recovered_from_the_page_text_layer() {
use deckcraft_model::text::{Run, RunKind};
let mut field = Run::new("<#>");
field.kind = RunKind::Field { field: "slidenum".into() };
let p = deck_with_body(3, ubuntu_runs(vec![Run::new("Page "), field]));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
for (i, want) in ["Page 1", "Page 2", "Page 3"].into_iter().enumerate() {
assert_eq!(page_text(&bytes, i), want);
}
}
#[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
#[cfg_attr(not(target_arch = "wasm32"), test)]
fn line_break_keeps_the_text_layer_aligned_with_the_layout() {
use deckcraft_model::text::{Run, RunKind};
let mut brk = Run::new("");
brk.kind = RunKind::Break;
let p = deck_with_body(1, ubuntu_runs(vec![Run::new("one"), brk, Run::new("two")]));
let bytes = export(&p, &opts(PageLayout::Slides)).unwrap();
assert_eq!(page_text(&bytes, 0), "onetwo");
assert_eq!(extracted, text);
}
/// Notes pages and handouts print on the chosen paper, landscape included, with every slide box
+3 -33
View File
@@ -171,7 +171,6 @@ fn rels_of(doc: &xml::Doc, part: &str) -> Rels {
let external = r.attr("TargetMode").is_some_and(|m| m.eq_ignore_ascii_case("external"));
let target_raw = target.to_string();
let target = if external { target_raw.clone() } else { resolve(&base, target) };
out.by_id.entry(id.to_string()).or_insert(out.items.len());
out.items.push(Rel {
id: id.to_string(),
kind: ty.rsplit('/').next().unwrap_or(ty).to_string(),
@@ -212,13 +211,11 @@ pub struct Rel {
#[derive(Clone, Debug, Default)]
pub struct Rels {
pub items: Vec<Rel>,
/// Id → index of its first relationship in `items`.
by_id: HashMap<String, usize>,
}
impl Rels {
pub fn get(&self, id: &str) -> Option<&Rel> {
self.by_id.get(id).and_then(|&i| self.items.get(i))
self.items.iter().find(|r| r.id == id)
}
pub fn target(&self, id: &str) -> Option<&str> {
self.get(id).map(|r| r.target.as_str())
@@ -342,29 +339,23 @@ pub fn relative(from: &str, to: &str) -> String {
#[derive(Default)]
pub struct RelsOut {
pub items: Vec<(String, String, String, bool)>,
/// (type, target, external) → id of the relationship [`RelsOut::add`] made for it.
added: HashMap<(String, String, bool), String>,
}
impl RelsOut {
/// Add (or reuse) a relationship; `kind` is a full type URI or a suffix of the standard base.
pub fn add(&mut self, kind: &str, target: &str, external: bool) -> String {
let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") };
let key = (ty, target.to_string(), external);
if let Some(id) = self.added.get(&key) {
if let Some((id, ..)) = self.items.iter().find(|(_, t, tg, e)| *t == ty && tg == target && *e == external) {
return id.clone();
}
let id = format!("rId{}", self.items.len() + 1);
self.items.push((id.clone(), key.0.clone(), key.1.clone(), external));
self.added.insert(key, id.clone());
self.items.push((id.clone(), ty, target.to_string(), external));
id
}
/// Always add a new relationship even if an identical one exists.
pub fn add_new(&mut self, kind: &str, target: &str, external: bool) -> String {
let ty = if kind.starts_with("http") { kind.to_string() } else { format!("{RT_BASE}{kind}") };
let id = format!("rId{}", self.items.len() + 1);
// `add` reuses the first relationship for a target, whichever call made it.
self.added.entry((ty.clone(), target.to_string(), external)).or_insert_with(|| id.clone());
self.items.push((id.clone(), ty, target.to_string(), external));
id
}
@@ -509,27 +500,6 @@ mod tests {
assert!(p.get("e").is_some());
}
#[test]
fn many_relationships_are_found_by_index_and_first_id_wins() {
let n = 50_000;
let mut x = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for i in 0..n {
x.push_str(&format!(r#"<Relationship Id="rId{i}" Type="{RT_BASE}image" Target="../media/image{i}.png"/>"#));
}
x.push_str(&format!(r#"<Relationship Id="rId0" Type="{RT_BASE}image" Target="../media/dup.png"/></Relationships>"#));
let rels = rels_from_bytes(x.as_bytes(), "ppt/slides/slide1.xml");
assert_eq!(rels.target("rId0"), Some("ppt/media/image0.png"), "the first of a repeated id wins");
assert!((0..n).all(|i| rels.target(&format!("rId{i}")) == Some(&*format!("ppt/media/image{i}.png"))));
let mut out = RelsOut::default();
let ids: Vec<String> = (0..n).map(|i| out.add("hyperlink", &format!("https://example.com/{i}"), true)).collect();
assert_eq!(out.add("hyperlink", "https://example.com/7", true), ids[7], "an identical target reuses its id");
assert_eq!(out.add("hyperlink", "https://example.com/7", false), format!("rId{}", n + 1), "internal is distinct");
let fresh = out.add_new("image", "../media/a.png", false);
assert_ne!(out.add_new("image", "../media/a.png", false), fresh);
assert_eq!(out.add("image", "../media/a.png", false), fresh, "add reuses the first add_new made");
}
#[test]
fn backslash_zip_entry_names_are_found() {
let z = zip_of(&[("PPT\\Slides\\Slide1.xml", 3)]);
+17 -26
View File
@@ -46,9 +46,6 @@ pub struct Imp<'a> {
pub tag_bytes: usize,
/// The tag budget ran out and the warning was given.
pub tags_capped: bool,
/// Whether a part has relationships of its own, by normalized part name: looked up once
/// however many embedded objects point at the part.
pub has_rels: HashMap<String, bool>,
}
impl Imp<'_> {
@@ -449,7 +446,6 @@ pub fn import_theme(bytes: &[u8]) -> Result<(deckcraft_model::theme::Theme, Vec<
tags_by_part: HashMap::new(),
tag_bytes: 0,
tags_capped: false,
has_rels: HashMap::new(),
};
let part = Part::new(&pkg, &theme_path, &doc);
let theme = theme::read_theme(&mut imp, &part, &doc.root);
@@ -491,7 +487,6 @@ pub fn import_checked(bytes: &[u8]) -> Result<(Presentation, Vec<String>), PptxE
tags_by_part: HashMap::new(),
tag_bytes: 0,
tags_capped: false,
has_rels: HashMap::new(),
};
// Slide ids first so hyperlinks anywhere can point at slides.
let slide_ids = register_slides(&mut imp, &docs, &slide_entries, &mut lost_slides);
@@ -668,55 +663,52 @@ fn read_slides(
let pkg = imp.pkg;
let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
let mut file_slide_ids: HashMap<u32, SlideId> = HashMap::new();
// A notes or comments part belongs to one slide; one shared by many must not be read (and
// copied) once per slide that names it.
let mut owned_parts: HashSet<String> = HashSet::new();
let mut notes_by_target: HashMap<String, TextBody> = HashMap::new();
for (file_id, path, id) in slide_ids {
let Some(doc) = docs.get(&opc::norm(path)) else { continue };
let part = Part::new(pkg, path, doc);
let layout = part.rels.first_of("slideLayout").and_then(|r| layout_by_part.get(&opc::norm(&r.target)).copied()).unwrap_or(fallback_layout);
let mut slide = read_slide(imp, &part, doc, *id, layout);
if let Some(notes) = slide_notes(imp, &part, &mut owned_parts) {
if let Some(notes) = slide_notes(imp, &part, &mut notes_by_target) {
slide.notes = notes;
}
read_slide_comments(pkg, pres_part, &part, &mut slide, &mut owned_parts);
read_slide_comments(pkg, pres_part, &part, &mut slide);
file_slide_ids.insert(*file_id, *id);
p.slides.push(Arc::new(slide));
}
file_slide_ids
}
/// The slide's notes text. A notes part is the notes of the first slide that names it.
fn slide_notes(imp: &mut Imp, part: &Part, owned: &mut HashSet<String>) -> Option<TextBody> {
/// The slide's notes text, read once per notes part.
fn slide_notes(imp: &mut Imp, part: &Part, cache: &mut HashMap<String, TextBody>) -> Option<TextBody> {
let n = part.rels.first_of("notesSlide")?;
if !owned.insert(opc::norm(&n.target)) {
log::warn!("pptx: notes part {} is named by more than one slide; kept on the first", n.target);
return None;
let key = opc::norm(&n.target);
if let Some(cached) = cache.get(&key) {
return Some(cached.clone());
}
let pkg = imp.pkg;
let nd = load(pkg, &n.target)?;
let np = Part::new(pkg, &n.target, &nd);
Some(read_notes(imp, &np, &nd.root))
let notes = read_notes(imp, &np, &nd.root);
cache.insert(key, notes.clone());
Some(notes)
}
/// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
/// writes both): the modern one wins, its flattened legacy copy is skipped.
/// Each comments part is read once, for the first slide that names it.
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide, owned: &mut HashSet<String>) {
let mut parts: Vec<(Doc, bool, bool)> = vec![];
fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide) {
let mut parts: Vec<(Doc, bool)> = vec![];
for c in part.rels.all_of("comments") {
// An already-owned part is not re-read, but its kind still decides modern precedence.
let fresh = owned.insert(opc::norm(&c.target));
if let Some(cd) = pkg.xml(&c.target) {
// The content type names the modern part; a package that types it only through a
// Default entry instead of an Override is still modern when its root is p188.
let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188";
parts.push((cd, modern, fresh));
parts.push((cd, modern));
}
}
let has_modern = parts.iter().any(|(_, m, _)| *m);
for (cd, modern, fresh) in parts {
if !fresh || (has_modern && !modern) {
let has_modern = parts.iter().any(|(_, m)| *m);
for (cd, modern) in parts {
if has_modern && !modern {
continue;
}
slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern));
@@ -1351,7 +1343,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() {
+5 -26
View File
@@ -664,22 +664,12 @@ fn push_text_runs(para: &mut Paragraph, props: RunProps, text: &str) {
/// Appends an Office Math (a14:m) run: its plain text and its OMML.
fn push_math_run(para: &mut Paragraph, part: &Part, e: &El) {
if para.runs.len() < MAX_PARAGRAPH_RUNS {
// The text of the outermost `t` elements: a `t` nested in another is already part of
// the outer one's text, and taking it again would copy it once per nesting level.
fn outer_t(e: &El, depth: usize, text: &mut String) {
if depth > crate::xml::MAX_DEPTH {
return;
}
for c in e.elements() {
if c.is("t") {
text.push_str(&c.text());
} else {
outer_t(c, depth + 1, text);
}
}
}
let mut text = String::new();
outer_t(e, 0, &mut text);
let mut ts = vec![];
e.find_all("t", &mut ts);
for t in ts {
text.push_str(&t.text());
}
let omml = e.elements().map(|c| part.keep_inner(c)).collect::<String>();
para.runs.push(Run { text, props: RunProps::default(), kind: RunKind::Math { omml } });
}
@@ -724,17 +714,6 @@ mod tests {
use super::*;
use crate::xml::parse;
#[test]
fn nested_math_text_is_taken_once() {
// 200 `m:t` nested in each other around one word: the word, not 200 copies of it.
let x = format!(r#"<a14:m xmlns:a14="a" xmlns:m="m"><m:oMath>{}word{}</m:oMath></a14:m>"#, "<m:t>".repeat(200), "</m:t>".repeat(200));
let d = parse(x.as_bytes()).unwrap();
let part = Part { name: "ppt/slides/slide1.xml".into(), rels: Default::default(), ns: vec![], inner_ns: vec![] };
let mut para = Paragraph::default();
push_math_run(&mut para, &part, &d.root);
assert_eq!(para.runs[0].text, "word");
}
#[test]
fn colors_and_transforms() {
let d = parse(
+1 -2
View File
@@ -475,8 +475,7 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
.into_iter()
.map(|id| {
let rel = part.rels.get(&id).filter(|r| !r.external)?;
let pkg = imp.pkg;
if *imp.has_rels.entry(crate::opc::norm(&rel.target)).or_insert_with(|| !pkg.rels(&rel.target).items.is_empty()) {
if !imp.pkg.rels(&rel.target).items.is_empty() {
return None;
}
let (kind, target) = (rel.ty.clone(), rel.target.clone());
-8
View File
@@ -1206,14 +1206,6 @@ fn pres_props_xml(p: &Presentation) -> Vec<u8> {
w.open0("p:penClr");
w.val("a:srgbClr", s.pen_color.hex());
w.close("p:penClr");
if !s.show_media_controls {
// "Show media controls" is PowerPoint's p14 extension; absent means shown.
w.open0("p:extLst");
w.open("p:ext", A::new().a("uri", "{2FDB2607-1784-4EEB-B798-7EB5836EED8A}"));
w.empty("p14:showMediaCtrls", A::new().a("xmlns:p14", NS_P14).a("val", 0));
w.close("p:ext");
w.close("p:extLst");
}
w.close("p:showPr");
w.close("p:presentationPr");
w.finish()
+2 -12
View File
@@ -762,27 +762,17 @@ pub fn text_body(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, tb: &TextBody,
}
fn run_text(w: &mut W, x: &mut Exp, o: &mut Out, text: &str, props: &RunProps) {
// The run's properties are the same for every piece and line break: serialize them once.
let mut rpr_xml: Option<String> = None;
let mut put_rpr = |w: &mut W| match &rpr_xml {
Some(xml) => w.s.push_str(xml),
None => {
let start = w.s.len();
rpr(w, x, o, "a:rPr", props);
rpr_xml = Some(w.s[start..].to_string());
}
};
for (i, piece) in text.split(['\n', '\u{b}']).enumerate() {
if i > 0 {
w.open0("a:br");
put_rpr(w);
rpr(w, x, o, "a:rPr", props);
w.close("a:br");
}
if piece.is_empty() && i > 0 {
continue;
}
w.open0("a:r");
put_rpr(w);
rpr(w, x, o, "a:rPr", props);
w.elt("a:t", piece);
w.close("a:r");
}
+3 -41
View File
@@ -1,7 +1,7 @@
//! Transitions and the animation timing tree.
use deckcraft_model::anim::{AnimClass, AnimStart, Animation, BuildRole, TextBuild, Transition, build_filled, paragraph_build_roles};
use deckcraft_model::{Shape, ShapeId, ShapeKind};
use deckcraft_model::{Shape, ShapeId, ShapeKind, find_shape};
use super::shapes::IdMap;
use super::{Exp, Out};
@@ -577,21 +577,6 @@ fn click_pars(w: &mut W, x: &mut Exp, ids: &mut Ids, clicks: &Groups, interactiv
}
}
/// Most effects one slide's timing writes; animations past it are left out.
const MAX_EFFECTS: usize = 100_000;
/// The shapes of the tree by id, the first in document order for a repeated id (as `find_shape`
/// finds it), so each animation resolves its shape without a tree walk.
fn shape_index<'a>(shapes: &'a [Shape], depth: usize, out: &mut std::collections::HashMap<ShapeId, &'a Shape>) {
if depth > 64 {
return;
}
for s in shapes {
out.entry(s.id).or_insert(s);
shape_index(s.children(), depth + 1, out);
}
}
/// Write `p:timing` for a slide's animations.
pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idmap: &IdMap) {
let mut grp_count: std::collections::HashMap<u32, u32> = Default::default();
@@ -600,31 +585,16 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
let mut bld: Vec<(u32, u32, bool)> = vec![];
// The last paragraph effect written: (shape, class, trigger, grpId).
let mut prev_para: Option<(ShapeId, AnimClass, Option<ShapeId>, u32)> = None;
let mut by_id = std::collections::HashMap::new();
shape_index(shapes, 0, &mut by_id);
let mut roles_of: std::collections::HashMap<ShapeId, Vec<BuildRole>> = Default::default();
let mut effect_count = 0usize;
let mut warned = false;
for a in anims.iter().take(10_000) {
if effect_count >= MAX_EFFECTS {
if !warned {
log::warn!("pptx: more than {MAX_EFFECTS} animation effects; the rest are left out");
}
break;
}
let Some(spid) = idmap.get(a.shape) else {
log::warn!("pptx: animation on missing shape {} skipped", a.shape);
continue;
};
let Some(&shape) = by_id.get(&a.shape) else { continue };
let Some(shape) = find_shape(shapes, a.shape) else { continue };
let text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.is_some();
let is_text_sp = matches!(shape.kind, ShapeKind::Shape) && shape.text.as_ref().is_some_and(|t| !t.is_empty());
let text = shape.text.as_ref().filter(|_| a.text_build == TextBuild::ByParagraph && is_text_sp);
let no_roles = Vec::new();
let roles = match text {
Some(t) => roles_of.entry(a.shape).or_insert_with(|| paragraph_build_roles(t)),
None => &no_roles,
};
let roles = text.map(paragraph_build_roles).unwrap_or_default();
let by_para = roles.contains(&BuildRole::Top);
// PowerPoint keeps a shape's consecutive paragraph effects in one build (`grpId`) marked
// `build="p"`; the reader turns them back into a by-paragraph build only if that plays them
@@ -662,19 +632,11 @@ pub fn timing(w: &mut W, x: &mut Exp, anims: &[Animation], shapes: &[Shape], idm
_ => continue,
};
let Ok(pi) = u32::try_from(i) else { break };
if effect_count + effs.len() >= MAX_EFFECTS {
if !warned {
log::warn!("pptx: more than {MAX_EFFECTS} animation effects; the rest are left out");
warned = true;
}
break;
}
effs.push(Eff { a, spid, start, paragraph: Some(pi), grp });
}
} else {
effs.push(Eff { a, spid, start: a.start, paragraph: a.paragraph, grp });
}
effect_count += effs.len();
if text_sp && !joins {
bld.push((spid, grp, by_para || para_effect));
}
-27
View File
@@ -146,33 +146,6 @@ fn comments_on_several_slides_round_trip() {
assert_eq!(q.slides[2].comments[0].text, "Slide three");
}
/// A slide that names a modern part another slide already owns, plus its own legacy part, must
/// not import the legacy copy: modern precedence holds even when the modern part is not re-read.
#[test]
fn owned_modern_part_still_suppresses_later_legacy() {
let mut p = deck();
let layout = p.slides[0].layout;
let s2 = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s2));
Arc::make_mut(&mut p.slides[1]).comments = vec![Comment { author: "Cara".into(), text: "Slide two".into(), ..Default::default() }];
let bytes = deckcraft_pptx::export(&p).expect("export");
let rels2 = part(&bytes, "ppt/slides/_rels/slide2.xml.rels").replace("modernComment2.xml", "modernComment1.xml");
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).expect("zip");
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");
if f.name() == "ppt/slides/_rels/slide2.xml.rels" {
std::io::Write::write_all(&mut out, rels2.as_bytes()).expect("write");
} else {
std::io::copy(&mut f, &mut out).expect("copy");
}
}
let q = deckcraft_pptx::import(&out.finish().expect("finish").into_inner()).expect("import");
assert_eq!(q.slides[0].comments.len(), 2);
assert!(q.slides[1].comments.is_empty(), "{:?}", q.slides[1].comments);
}
/// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions,
/// flattened replies.
#[test]
-31
View File
@@ -287,34 +287,3 @@ fn unreadable_slide_is_reported() {
assert_eq!(lost, vec!["ppt/slides/slide2.xml".to_string()]);
assert_eq!(deckcraft_pptx::import(&broken).expect("import").slides.len(), 1);
}
/// Many slides naming one notes part: it is read for the first slide only, not copied onto every
/// slide that names it.
#[test]
fn a_shared_notes_part_is_read_once() {
use std::sync::Arc;
let mut p = defaults::new_presentation(None);
let layout = p.slides[0].layout;
for _ in 0..3 {
let s = defaults::new_slide(&mut p, layout).expect("slide");
p.slides.push(Arc::new(s));
}
for (i, s) in p.slides.iter_mut().enumerate() {
Arc::make_mut(s).notes = deckcraft_model::text::TextBody::from_text(&format!("notes {i}"));
}
let mut es = entries(&deckcraft_pptx::export(&p).expect("export"));
for (n, d) in es.iter_mut() {
if n.starts_with("ppt/slides/_rels/") {
let x = String::from_utf8(d.clone()).expect("utf8");
let x = x
.replace("notesSlide2.xml", "notesSlide1.xml")
.replace("notesSlide3.xml", "notesSlide1.xml")
.replace("notesSlide4.xml", "notesSlide1.xml");
*d = x.into_bytes();
}
}
let q = deckcraft_pptx::import(&rezip(&es)).expect("import");
assert_eq!(q.slides.len(), 4);
assert_eq!(q.slides[0].notes.text(), "notes 0");
assert!(q.slides[1..].iter().all(|s| s.notes.text().is_empty()), "a shared notes part is not copied");
}
-28
View File
@@ -2258,34 +2258,6 @@ fn default_durations_match_powerpoint() {
}
}
/// Show media controls is PowerPoint's `p14:showMediaCtrls` extension inside `p:showPr`: off is
/// written there, under its extension uri and with the p14 namespace declared on the element;
/// on (the default) writes nothing.
#[test]
fn hidden_media_controls_survive_save_and_reopen() {
let props = |p: &Presentation| {
let bytes = deckcraft_pptx::export(p).expect("export");
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut x = String::new();
std::io::Read::read_to_string(&mut zip.by_name("ppt/presProps.xml").expect("presProps"), &mut x).expect("read");
x
};
let mut p = Presentation::default();
p.show.show_media_controls = false;
let xml = props(&p);
let show = xml.split("<p:showPr").nth(1).and_then(|r| r.split("</p:showPr>").next()).expect("p:showPr");
assert!(
show.contains(
"<p:ext uri=\"{2FDB2607-1784-4EEB-B798-7EB5836EED8A}\"><p14:showMediaCtrls xmlns:p14=\"http://schemas.microsoft.com/office/powerpoint/2010/main\" val=\"0\"/></p:ext>"
),
"{xml}"
);
assert!(!round(&p).show.show_media_controls);
let default = Presentation::default();
assert!(!props(&default).contains("showMediaCtrls"));
assert!(round(&default).show.show_media_controls);
}
#[test]
fn reflection_angles_and_skews_out_of_range_are_written_inside_the_schema() {
let mut p = Presentation::default();
+8 -25
View File
@@ -1,7 +1,5 @@
//! Charts: title, plot area with axes and gridlines, series marks, legend.
use std::collections::HashMap;
use deckcraft_color::{ColorTransform, Rgba, SchemeSlot};
use deckcraft_model::Shape;
use deckcraft_model::chart::{Chart, ChartType, TrendlineType, axis_scale};
@@ -87,9 +85,9 @@ fn bar_outline(ctx: &Ctx, s: &deckcraft_model::chart::Series) -> Option<(Rgba, f
/// Paint of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
/// (not waterfalls, which are never read from a `<c:barChart>`). Pattern fills draw as patterns and
/// `a:noFill` leaves the bar empty, as in PowerPoint.
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series, fills: Option<&HashMap<usize, &Fill>>) -> Paint {
if let Some(f) = fills.and_then(|m| m.get(&pi)) {
return paint_of(ctx, Some(*f), series_fill(ctx, c, si, Some(s)));
fn bar_paint(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Paint {
if let Some((_, f)) = s.point_fills.iter().find(|(k, _)| *k == pi) {
return paint_of(ctx, Some(f), series_fill(ctx, c, si, Some(s)));
}
if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall {
return Paint::Color(series_color(ctx, c, pi));
@@ -885,8 +883,6 @@ fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) {
let col = if area_kind { series_fill(rctx, c, si, Some(s)) } else { series_line(rctx, c, si, s) };
let series_stats = s.error_bars.as_ref().map(|_| deckcraft_model::chart::SeriesStats::of(&s.values));
let mut pts = vec![];
// Whether the series has any x value, found once rather than per point.
let any_x = s.x_values.iter().flatten().next().is_some();
for i in 0..ncat {
let Some(v) = s.values.get(i).copied().flatten() else { continue };
let v = if stacked {
@@ -900,11 +896,11 @@ fn draw_lines(ctx: &mut RenderContext, draw: &mut LineDrawing<'_, '_>) {
};
let x = match kind {
ChartType::Scatter | ChartType::Bubble => {
match (s.x_values.get(i).copied().flatten().and_then(to_x), any_x) {
match (s.x_values.get(i).copied().flatten().and_then(to_x), s.x_values.iter().flatten().next()) {
(Some(x), _) => x,
// No x values at all: the points are numbered 1, 2, 3...
(None, false) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)),
(None, true) => continue,
(None, None) => to_x(i as f64 + 1.0).unwrap_or(plot.x0 + band * (slot(i) + 0.5)),
(None, Some(_)) => continue,
}
}
_ => plot.x0 + band * (slot(i) + 0.5),
@@ -974,19 +970,6 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
let group = bar + step * (n - 1.0);
// Error bars need each series' mean and spread: worked out once, not per point.
let stats: Vec<deckcraft_model::chart::SeriesStats> = bars.iter().map(|(_, s)| deckcraft_model::chart::SeriesStats::of(&s.values)).collect();
// Each series' per-point fills by point (the first for a point wins), not searched per bar.
let point_fills: Vec<HashMap<usize, &Fill>> = bars
.iter()
.map(|(_, s)| {
let mut m = HashMap::new();
for (k, f) in &s.point_fills {
m.entry(*k).or_insert(f);
}
m
})
.collect();
// A waterfall's running bars, worked out once for all its categories.
let waterfall_bars = if waterfall { c.waterfall_bars() } else { Vec::new() };
for i in 0..ncat {
let mut pos_acc = 0.0;
let mut neg_acc = 0.0;
@@ -1000,7 +983,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
let v = s.values.get(i).copied().flatten().unwrap_or(0.0) / total;
let start = band * slot(i) + (band - group) / 2.0 + if stacked { 0.0 } else { step * bi as f64 };
let (a, b) = if waterfall {
waterfall_bars.get(i).copied().unwrap_or_default()
c.waterfall_bars().get(i).copied().unwrap_or_default()
} else if stacked {
if v >= 0.0 {
let r = (pos_acc, pos_acc + v);
@@ -1032,7 +1015,7 @@ fn draw_bars(ctx: &mut RenderContext, draw: &BarDrawing<'_, '_>) -> Vec<(f64, f6
};
Paint::Color(series_color(rctx, c, slot))
} else {
bar_paint(rctx, c, *si, i, s, point_fills.get(bi))
bar_paint(rctx, c, *si, i, s)
};
fill_with(ctx, &paint, r.abs(), m);
if !waterfall && let Some((oc, ow)) = bar_outline(rctx, s).filter(|o| o.1 > 0.0) {
+6 -44
View File
@@ -37,8 +37,7 @@ struct Cache {
bad_order: VecDeque<usize>,
bad_bytes: usize,
/// GIFs by bytes address and adjustments (`None`: not animated); the bytes stay alive with them.
/// Decoded animations by (bytes, adjustments), each with the clock of its last use.
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>, u64)>,
anims: HashMap<(usize, u64), (Arc<Vec<u8>>, Option<Arc<Anim>>)>,
}
/// Longest side, total decoded size and frame count kept for one animated GIF; the largest canvas
@@ -433,33 +432,16 @@ fn anim(bytes: &Arc<Vec<u8>>, adj: &PictureAdjust) -> Option<Arc<Anim>> {
return None;
}
let key = (Arc::as_ptr(bytes) as usize, adjust_key(adj));
let hit = with(|c| {
c.clock += 1;
let clock = c.clock;
c.anims.get_mut(&key).map(|e| {
e.2 = clock;
e.1.clone()
})
});
if let Some(a) = hit {
if let Some(a) = with(|c| c.anims.get(&key).map(|e| e.1.clone())) {
return a;
}
let a = decode_anim(bytes, adj).map(Arc::new);
with(|c| {
// Make room by dropping the least recently used animations, not every one of them, so a
// slide whose animations overflow the cache does not decode all of them again each frame.
let new = a.as_ref().map_or(0, |a| a.bytes());
loop {
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
if held + new <= GIF_CACHE_BUDGET && c.anims.len() < 64 {
break;
}
let Some(oldest) = c.anims.iter().min_by_key(|(_, e)| e.2).map(|(k, _)| *k) else { break };
c.anims.remove(&oldest);
let held: usize = c.anims.values().filter_map(|e| e.1.as_ref()).map(|a| a.bytes()).sum();
if held + a.as_ref().map_or(0, |a| a.bytes()) > GIF_CACHE_BUDGET || c.anims.len() >= 64 {
c.anims.clear();
}
c.clock += 1;
let clock = c.clock;
c.anims.insert(key, (bytes.clone(), a.clone(), clock));
c.anims.insert(key, (bytes.clone(), a.clone()));
});
a
}
@@ -553,26 +535,6 @@ mod tests {
out.into_inner()
}
/// A two-frame GIF whose first pixel is `n`, so each one has its own bytes.
fn gif(n: u8) -> Arc<Vec<u8>> {
use image::codecs::gif::GifEncoder;
let frame = |c: u8| image::Frame::new(image::RgbaImage::from_pixel(2, 2, image::Rgba([n, c, 0, 255])));
let mut out = Vec::new();
GifEncoder::new(&mut out).encode_frames([frame(0), frame(255)]).expect("encode");
Arc::new(out)
}
#[test]
fn a_full_animation_cache_drops_the_oldest_entry_not_all_of_them() {
let adj = PictureAdjust::default();
let gifs: Vec<_> = (0..70).map(gif).collect();
for g in &gifs {
assert!(anim(g, &adj).is_some());
}
let (len, newest) = with(|c| (c.anims.len(), c.anims.contains_key(&(Arc::as_ptr(&gifs[69]) as usize, adjust_key(&adj)))));
assert!(newest && len >= 32, "{len} cached");
}
#[test]
fn rgba_conversion_is_refused_past_the_cap() {
let png = grey_png(100, 50);
+1 -1
View File
@@ -54,7 +54,7 @@ pub use images::{decode as decode_image, gif_frame};
pub use morph_path::PathMorph;
pub use morph_text::TextMorph;
pub use pattern::{is_fg as pattern_is_fg, presets as pattern_presets, tile as pattern_tile};
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, place_slide_with, text_outline};
pub use placed::{Placed, PlacedLink, PlacedShape, PlacedText, place_master_texts, place_slide, text_outline};
pub use table::cell_body as table_cell_body;
pub use table::cell_rects as table_cell_rects;
pub use table::row_heights as table_row_heights;
+10 -22
View File
@@ -76,12 +76,16 @@ fn tokens(l: &TextLayout, chars: bool) -> (Vec<Token>, Vec<(usize, usize)>) {
/// For each token of `b`, the token of `a` with the same text, in reading order, each used once.
fn matches(a: &[Token], b: &[Token]) -> Vec<Option<usize>> {
// Unused tokens of `a` by text, in reading order, so each lookup is a pop rather than a scan.
let mut free: std::collections::HashMap<&str, std::collections::VecDeque<usize>> = std::collections::HashMap::new();
for (i, ta) in a.iter().enumerate() {
free.entry(ta.key.as_str()).or_default().push_back(i);
}
b.iter().map(|tb| free.get_mut(tb.key.as_str())?.pop_front()).collect()
let mut used = vec![false; a.len()];
b.iter()
.map(|tb| {
let i = a.iter().enumerate().position(|(i, ta)| !used.get(i).copied().unwrap_or(true) && ta.key == tb.key)?;
if let Some(u) = used.get_mut(i) {
*u = true;
}
Some(i)
})
.collect()
}
fn layout(ctx: &Ctx, s: &Shape, x: Xfrm, fields: &dyn Fields) -> Option<TextLayout> {
@@ -224,19 +228,3 @@ pub(crate) fn draw(
pub(crate) fn token_keys(l: &TextLayout, chars: bool) -> Vec<String> {
tokens(l, chars).0.into_iter().map(|t| t.key).collect()
}
#[cfg(test)]
mod match_tests {
use super::*;
fn tok(k: &str) -> Token {
Token { key: k.into(), glyphs: Vec::new(), origin: Point::ZERO, run: 0 }
}
#[test]
fn each_source_token_is_used_once_in_reading_order() {
let a: Vec<Token> = ["a", "b", "a"].map(tok).into();
let b: Vec<Token> = ["a", "a", "a", "b", "c"].map(tok).into();
assert_eq!(matches(&a, &b), vec![Some(0), Some(2), None, Some(1), None]);
}
}
+6 -29
View File
@@ -2,9 +2,9 @@
//! that put real text over a raster (PDF text layer, links) and for text extraction.
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::text::{Hyperlink, RunKind, TextBody};
use deckcraft_model::text::{Hyperlink, TextBody};
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
use deckcraft_text::{Fields, Opts, TextLayout};
use deckcraft_text::{Opts, TextLayout};
use kurbo::{Affine, Rect};
use crate::{SlideFields, shape_geometry, table};
@@ -52,16 +52,10 @@ pub struct Placed {
/// Text and links of slide `index` (master and layout graphics first, as rendered).
pub fn place_slide(pres: &Presentation, index: usize) -> Placed {
place_slide_with(pres, index, &crate::RenderOpts::default())
}
/// [`place_slide`] with the render options the slide was drawn with, so date fields take the same
/// date and time as the raster (`opts.now`).
pub fn place_slide_with(pres: &Presentation, index: usize, opts: &crate::RenderOpts) -> Placed {
let mut out = Placed::default();
let Some(slide) = pres.slides.get(index) else { return out };
let Some(rctx) = Ctx::for_slide(pres, slide) else { return out };
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), opts);
let fields = SlideFields::new(pres.slide_number(index), pres.header_footer.footer_text.clone(), &crate::RenderOpts::default());
let (show_layout, show_master) = resolve::show_master_shapes(slide, rctx.layout);
if show_master {
let mctx = Ctx::for_master(pres, rctx.master);
@@ -137,11 +131,9 @@ fn place(out: &mut Placed, rctx: &Ctx, fields: &SlideFields, s: &Shape, parent:
}
ShapeKind::Table(t) => {
// Cell text, laid out exactly as the table draws it.
// The table's style is looked up once, not once per cell.
let def = table::resolve_style(rctx, t);
for (r, c, rect) in table::cell_rects(t, &table::row_heights(rctx, t, fields)) {
let Some(cell) = t.cell(r, c).filter(|cell| !cell.text.is_empty()) else { continue };
let body = table::cell_body_with_def(rctx, def, t, r, c, cell);
let body = table::cell_body(rctx, t, r, c, cell);
let tmp = Shape::table_cell(body.clone());
place_text(out, rctx, fields, s.id, &tmp, &body, rect, m);
}
@@ -160,24 +152,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 +162,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 -27
View File
@@ -33,10 +33,6 @@ pub struct Renderer {
/// Visits spent on outer-shadow silhouette passes, kept apart so they cannot use up the budget
/// of the normal drawing that follows.
shadow_work: usize,
/// `RenderOpts::skip` as a set, with the slice (address, length) it was built from: the
/// slice can be as long as the slide, and is tested for every shape drawn. Forgotten when a
/// frame starts, so another frame's slice at the same address is never mistaken for it.
skip: Option<(usize, usize, std::collections::HashSet<ShapeId>)>,
}
impl Default for Renderer {
@@ -52,7 +48,6 @@ impl Default for Renderer {
in_shadow_pass: false,
render_work: 0,
shadow_work: 0,
skip: None,
}
}
}
@@ -168,7 +163,6 @@ impl Renderer {
pub(crate) fn slide(&mut self, pres: &Presentation, slide: &Slide, index: usize, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
@@ -184,7 +178,6 @@ impl Renderer {
pub(crate) fn slide_part(&mut self, pres: &Presentation, slide: &Slide, index: usize, part: SlidePart, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, slide));
let f = Frame { pres, opts, fields: Self::fields(pres, index, opts) };
@@ -219,7 +212,6 @@ impl Renderer {
let mix = if mix.is_finite() { mix.clamp(0.0, 1.0) } else { 1.0 };
self.render_work = 0;
self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false;
let (mut ctx, w, h, view) = Self::canvas(pres, opts, needs_filters(pres, old.0) || needs_filters(pres, new.0));
let (Some(co), Some(cn)) = (Ctx::for_slide(pres, old.0), Ctx::for_slide(pres, new.0)) else { return self.finish(ctx, w, h) };
@@ -268,7 +260,6 @@ impl Renderer {
pub(crate) fn layout(&mut self, pres: &Presentation, mi: usize, li: Option<usize>, opts: &RenderOpts) -> Image {
self.render_work = 0;
self.shadow_work = 0;
self.skip = None;
self.in_shadow_pass = false;
let (w, h) = output_size(pres, opts);
let mut ctx = RenderContext::new_with(w, h, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
@@ -402,25 +393,8 @@ impl Renderer {
geo
}
/// Whether `id` is in `skip`, looked up in a set built once per frame and slice.
fn skipped(&mut self, skip: &[ShapeId], id: ShapeId) -> bool {
if skip.is_empty() {
return false;
}
let key = (skip.as_ptr() as usize, skip.len());
match &self.skip {
Some((p, n, set)) if (*p, *n) == key => set.contains(&id),
_ => {
let set: std::collections::HashSet<ShapeId> = skip.iter().copied().collect();
let hit = set.contains(&id);
self.skip = Some((key.0, key.1, set));
hit
}
}
}
fn shape_at(&mut self, ctx: &mut RenderContext, f: &Frame, rctx: &Ctx, s: &Shape, parent: Affine, depth: usize) {
if depth > 32 || (s.hidden && !f.opts.show_hidden) || self.skipped(f.opts.skip, s.id) {
if depth > 32 || (s.hidden && !f.opts.show_hidden) || f.opts.skip.contains(&s.id) {
return;
}
let work = if self.in_shadow_pass { &mut self.shadow_work } else { &mut self.render_work };
+1 -2
View File
@@ -191,8 +191,7 @@ pub(crate) fn limit_dash_count(path: &BezPath, pat: Vec<f64>) -> Vec<f64> {
return pat;
}
let len: f64 = path.segments().map(|s| s.arclen(1e-2)).sum();
// Every dash of the pattern counts, not each repeat of it: a custom pattern can hold many.
let dashes = len / period * pat.len().div_ceil(2) as f64;
let dashes = len / period;
if !len.is_finite() {
return vec![];
}
+1 -22
View File
@@ -1,7 +1,7 @@
use super::{
Ctx, Fields, Fill, Image, Opts, PathMorph, Point, RENDERER, Rect, Region, RenderOpts, Renderer, Rgba, ShapeState, SlideFields, TextMorph,
crop_dest, dpi_for_scale, gif_frame, image_dpi, images, morph_path, morph_text, needs_filters, parse_path, pasteboard_region, place_slide,
place_slide_with, render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
render_blend, render_shapes, render_slide, render_slide_drawn, slide_extent, text_transform,
};
use deckcraft_model::chart::{ChartType, Trendline, TrendlineType};
use deckcraft_model::style::ColorRef;
@@ -2971,27 +2971,6 @@ fn empty_bullet_is_gray_only_while_editing_and_never_in_exports() {
assert!(changed.iter().all(|(px, _)| px[0] >= 179 && px[0] == px[1] && px[1] == px[2]));
}
#[test]
fn placed_text_holds_the_field_value_not_the_saved_placeholder() {
let mut p = deck_with(vec![field_shape(Some("slidenum"), "<#>")]);
let second = p.slides[0].clone();
p.slides.push(second);
let placed = place_slide(&p, 1);
assert_eq!(placed.texts.len(), 1);
assert_eq!(placed.texts[0].body.text(), "2");
// The layout was drawn from the same value, so the offsets agree with the body.
assert_eq!(placed.texts[0].layout.runs.iter().map(|r| r.chars.1).max(), Some(1));
}
#[test]
fn placed_text_takes_its_date_from_the_given_render_options() {
use deckcraft_text::datetime::DateTime;
let p = deck_with(vec![field_shape(Some("datetime1"), "1/1/2000")]);
let at = |now: DateTime| place_slide_with(&p, 0, &RenderOpts { now: Some(now), ..Default::default() });
assert_eq!(at(DateTime::new(2026, 10, 9, 20, 5, 7)).texts[0].body.text(), "10/9/2026");
assert_eq!(at(DateTime::new(2027, 3, 4, 0, 0, 0)).texts[0].body.text(), "3/4/2027");
}
/// A chart that has no text colour of its own takes PowerPoint's default, the theme's text colour
/// at 65 % plus 35 %: light on Nocturne's dark slides (it used to be a fixed grey, near-invisible
/// there), 595959 grey on Harbor's light ones.
-33
View File
@@ -990,37 +990,4 @@ fn a_tiny_dash_on_a_huge_shape_draws_without_running_out_of_memory() {
p.line_to((4.0e6, 0.0));
let pat = limit_dash_count(&p, vec![1.0, 1.0]);
assert!((4.0e6 / (pat[0] + pat[1]) - MAX_DASHES).abs() < 1.0, "{pat:?}");
// A custom pattern of 1,000 dashes counts each of them: the limit is on dashes drawn.
let pat = limit_dash_count(&p, vec![1.0; 2000]);
let period: f64 = pat.iter().sum();
assert!((4.0e6 / period * 1000.0 - MAX_DASHES).abs() < 1.0, "{}", period);
}
/// A bar chart with 20,000 points, each with its own fill, and a sparse scatter of the same size:
/// the per-point fills and the scatter's "has any x" are looked up, not searched per point.
#[test]
fn many_point_fills_and_sparse_scatter_x_render() {
use deckcraft_model::ShapeKind;
use deckcraft_model::chart::{Chart, ChartType};
use deckcraft_model::style::Fill;
let n = 20_000;
let chart = |kind: ChartType| {
let mut c = Chart::sample(kind);
c.series.truncate(1);
c.categories = (0..n).map(|i| i.to_string()).collect();
c.series[0].values = (0..n).map(|i| Some(i as f64)).collect();
c
};
let mut bars = chart(ChartType::Column);
bars.series[0].point_fills = (0..n).rev().map(|i| (i, Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0))))).collect();
let mut scatter = chart(ChartType::Scatter);
scatter.series[0].x_values = (0..n).map(|i| (i + 1 == n).then_some(1.0)).collect();
let shape = |id: u32, c: Chart| Shape {
id: ShapeId(id),
xfrm: Some(Xfrm::new(10.0, 10.0, 400.0, 300.0)),
kind: ShapeKind::Chart(Box::new(c)),
..Default::default()
};
let img = render(vec![shape(2, bars), shape(3, scatter)]);
assert!(img.width > 0);
}
+8 -122
View File
@@ -35,30 +35,22 @@ pub(crate) fn prompt_body(rctx: &Ctx, s: &Shape, kind: PhType) -> TextBody {
t
}
/// Most path elements this thread's glyph cache retains. Its entries are shared with the font
/// cache, but they stay alive here after that cache evicts them, so they are bounded by elements.
const GLYPH_CACHE_MAX_ELEMENTS: usize = 1 << 22;
thread_local! {
/// The cached outlines, and the path elements they hold.
static GLYPH_CACHE: std::cell::RefCell<(HashMap<(u32, u32), Arc<BezPath>>, usize)> = std::cell::RefCell::new((HashMap::new(), 0));
static GLYPH_CACHE: std::cell::RefCell<HashMap<(u32, u32), Arc<BezPath>>> = std::cell::RefCell::new(HashMap::new());
}
pub(crate) fn glyph(face: &deckcraft_fonts::FontFace, gid: u32) -> Arc<BezPath> {
let key = (face.id(), gid);
if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().0.get(&key).cloned()) {
if let Some(p) = GLYPH_CACHE.with(|c| c.borrow().get(&key).cloned()) {
return p;
}
let p = FontDb::global().outline(face, gid);
GLYPH_CACHE.with(|c| {
let mut c = c.borrow_mut();
let n = p.elements().len();
if c.0.len() > 200_000 || c.1.saturating_add(n) > GLYPH_CACHE_MAX_ELEMENTS {
c.0.clear();
c.1 = 0;
if c.len() > 200_000 {
c.clear();
}
c.1 += n;
c.0.insert(key, p.clone());
c.insert(key, p.clone());
});
p
}
@@ -113,7 +105,6 @@ pub fn draw_layout(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr:
pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout, tr: Rect, m: Affine, paras: &[(usize, ParaState)]) {
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
let m = text_transform(m, tr, l.rotation);
let lines_of = LineIndex::new(l);
let deco = |ctx: &mut RenderContext, d: &deckcraft_text::Deco| {
let ps = para(d.para);
if !ps.visible || ps.opacity <= 0.001 {
@@ -153,7 +144,7 @@ pub fn draw_layout_with(ctx: &mut RenderContext, l: &deckcraft_text::TextLayout,
draw_glyph(ctx, run, *gid, at, ps.opacity);
continue;
};
let li = lines_of.line_at(run.para, *y);
let li = line_at(l, run.para, *y);
if !grads.iter().any(|(i, _)| *i == li) {
let scheme = deckcraft_color::ColorScheme { name: String::new(), colors: [Rgba::BLACK; 12] };
let stops = g.stops.iter().map(|s| (s.pos, s.color.resolve(&scheme, None))).collect();
@@ -200,78 +191,8 @@ pub(crate) fn fill_deco(ctx: &mut RenderContext, d: &deckcraft_text::Deco) {
}
}
/// Finds the line of a paragraph holding a glyph on baseline `y` without scanning the paragraph's
/// lines for every glyph. A paragraph whose lines are ordered top to bottom (every layout the text
/// crate produces) is searched by bisection; any other falls back to a scan with the same answer.
pub(crate) struct LineIndex<'a> {
l: &'a deckcraft_text::TextLayout,
/// Per paragraph: its lines' tops, bottoms and baselines all rise with the line index.
ordered: Vec<bool>,
}
impl<'a> LineIndex<'a> {
pub(crate) fn new(l: &'a deckcraft_text::TextLayout) -> Self {
let ordered = l
.para_lines
.iter()
.map(|&(first, count)| {
let end = first.saturating_add(count).min(l.lines.len());
l.lines.get(first..end).is_some_and(|ls| {
ls.iter().all(|a| a.top <= a.bottom)
&& ls.windows(2).all(|w| w[0].top <= w[1].top && w[0].bottom <= w[1].bottom && w[0].baseline <= w[1].baseline)
})
})
.collect();
LineIndex { l, ordered }
}
/// The nearest line baseline to `y` among `lines` (the first of equals), as an index into `lines`.
fn nearest(lines: &[deckcraft_text::LineInfo], y: f64) -> Option<usize> {
let i = lines.partition_point(|li| li.baseline < y);
let best = match (i.checked_sub(1), lines.get(i)) {
(Some(p), Some(n)) => {
if (lines[p].baseline - y).abs() <= (n.baseline - y).abs() {
p
} else {
i
}
}
(Some(p), None) => p,
(None, Some(_)) => i,
(None, None) => return None,
};
let b = lines[best].baseline;
Some(lines.partition_point(|li| li.baseline < b))
}
/// The paragraph's lines when they are ordered, with the index of the first.
fn ordered_lines(&self, para: usize) -> Option<(usize, &[deckcraft_text::LineInfo])> {
let &(first, count) = self.l.para_lines.get(para)?;
let end = first.saturating_add(count).min(self.l.lines.len());
(self.ordered[para] && first < end).then(|| (first, &self.l.lines[first..end]))
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
pub(crate) fn line_at(&self, para: usize, y: f64) -> usize {
if let Some((first, ls)) = self.ordered_lines(para) {
return Self::nearest(ls, y).map_or(0, |i| first + i);
}
line_at_scan(self.l, para, y)
}
/// The first line of paragraph `para` whose box holds `y`, else the nearest baseline's line.
pub(crate) fn line_of(&self, para: usize, y: f64) -> Option<usize> {
let Some((first, ls)) = self.ordered_lines(para) else { return crate::warp::line_of_scan(self.l, para, y) };
let i = ls.partition_point(|li| li.bottom + 0.01 < y);
if ls.get(i).is_some_and(|li| y >= li.top - 0.01) {
return Some(first + i);
}
Self::nearest(ls, y).map(|i| first + i)
}
}
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline), by scanning.
fn line_at_scan(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
/// The line of paragraph `para` holding a glyph on baseline `y` (the nearest baseline).
fn line_at(l: &deckcraft_text::TextLayout, para: usize, y: f64) -> usize {
if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 {
return 0;
@@ -397,38 +318,3 @@ fn draw_glyph_with(
ctx.stroke_path(&path);
}
}
#[cfg(test)]
mod line_index_tests {
use super::*;
use deckcraft_text::{LineInfo, TextLayout};
fn layout(ordered: bool) -> TextLayout {
let line = |para, top: f64| LineInfo { para, top, baseline: top + 8.0, bottom: top + 10.0, ..Default::default() };
let mut lines: Vec<LineInfo> = (0..40).map(|i| line(usize::from(i >= 25), f64::from(i) * 10.0)).collect();
if !ordered {
lines.swap(3, 7);
}
TextLayout { lines, para_lines: vec![(0, 25), (25, 15)], ..Default::default() }
}
#[test]
fn bisection_matches_the_scan() {
for ordered in [true, false] {
let l = layout(ordered);
let idx = LineIndex::new(l_ref(&l));
assert_eq!(idx.ordered, vec![ordered, true]);
for para in 0..2 {
for k in -20..450 {
let y = f64::from(k) * 0.9;
assert_eq!(idx.line_at(para, y), line_at_scan(&l, para, y), "line_at {para} {y}");
assert_eq!(idx.line_of(para, y), crate::warp::line_of_scan(&l, para, y), "line_of {para} {y}");
}
}
}
}
fn l_ref(l: &TextLayout) -> &TextLayout {
l
}
}
+4 -6
View File
@@ -9,14 +9,13 @@ use deckcraft_text::{GlyphRun, TextLayout};
use kurbo::{Affine, BezPath, PathEl, Point, Rect, Shape as _};
use vello_cpu::{RenderContext, peniko};
use crate::text_draw::LineIndex;
use crate::{ParaState, color, glyph, scale_of, text_transform};
/// Most points one warped glyph may have (a hostile size or zoom can't explode the work).
const MAX_POINTS: usize = 40_000;
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to, by scanning.
pub(crate) fn line_of_scan(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
/// The line of `l` a glyph of paragraph `para` on baseline `y` belongs to.
fn line_of(l: &TextLayout, para: usize, y: f64) -> Option<usize> {
if let Some(&(first, count)) = l.para_lines.get(para) {
if count == 0 {
return None;
@@ -111,7 +110,6 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
let para = |i: usize| paras.iter().find(|(p, _)| *p == i).map(|(_, s)| *s).unwrap_or_default();
let m = text_transform(m, tr, l.rotation);
let n = l.lines.len();
let lines_of = LineIndex::new(l);
let mut ink: Vec<Option<Rect>> = vec![None; n];
let mut off: Vec<Option<f64>> = vec![None; n];
let mut items: Vec<Item> = Vec::new();
@@ -119,7 +117,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
let k = run.size / run.face.upem.max(1.0);
let skew = if run.fake_italic { Affine::new([1.0, 0.0, -0.2, 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
for (gid, x, y) in &run.glyphs {
let Some(li) = lines_of.line_of(run.para, *y) else { continue };
let Some(li) = line_of(l, run.para, *y) else { continue };
if let Some(o) = off.get_mut(li) {
*o = Some(o.map_or(ref_offset(run), |v| v.max(ref_offset(run))));
}
@@ -158,7 +156,7 @@ pub(crate) fn draw(ctx: &mut RenderContext, l: &TextLayout, tr: Rect, m: Affine,
return;
}
let y = (d.rect.y0 + d.rect.y1) / 2.0;
let Some(li) = lines_of.line_of(d.para, y) else { return };
let Some(li) = line_of(l, d.para, y) else { return };
ctx.set_transform(m * Affine::translate(ps.offset));
ctx.set_paint(color(d.color, ps.opacity));
for r in d.pieces() {
+12 -29
View File
@@ -260,15 +260,12 @@ pub(crate) struct Tab {
align: TabAlign,
}
/// The paragraph's tab stops in position order (a stop at no finite position is left out), so
/// [`tab_advance`] finds the next one by binary search instead of a scan per tab.
pub(crate) fn tabs_of(props: &ParaProps) -> Vec<Tab> {
let mut tabs: Vec<Tab> = props
props
.tabs
.as_ref()
.map(|t| {
t.iter()
.filter(|s| s.pos.is_finite())
.map(|s| Tab {
pos: s.pos,
align: match s.align.as_str() {
@@ -280,9 +277,7 @@ pub(crate) fn tabs_of(props: &ParaProps) -> Vec<Tab> {
})
.collect()
})
.unwrap_or_default();
tabs.sort_by(|a, b| a.pos.total_cmp(&b.pos));
tabs
.unwrap_or_default()
}
/// Width of the text after the tab at cell `i` up to the next tab or line break, and the width of
@@ -306,30 +301,18 @@ pub(crate) fn tab_segment(cells: &[Cell], i: usize) -> (f64, f64) {
/// (`seg`: its width, and its width before the decimal point) as the stop aligns it but never
/// moving back, as PowerPoint does; past the last stop, to the next default stop.
pub(crate) fn tab_advance(x: f64, stops: &[Tab], default_tab: f64, seg: (f64, f64)) -> f64 {
// `stops` is in position order (`tabs_of`): the next stop is the first past x.
// A NaN `x` (a NaN spacing upstream) skips the lookup and takes the finite default-stop path.
if let Some(s) = stops.get(stops.partition_point(|s| s.pos <= x + 0.5)).filter(|_| !x.is_nan()) {
let to = s.pos - x;
return match s.align {
TabAlign::Left => to,
TabAlign::Right => (to - seg.0).max(0.0),
TabAlign::Center => (to - seg.0 / 2.0).max(0.0),
TabAlign::Decimal => (to - seg.1).max(0.0),
};
for s in stops {
if s.pos > x + 0.5 {
let to = s.pos - x;
return match s.align {
TabAlign::Left => to,
TabAlign::Right => (to - seg.0).max(0.0),
TabAlign::Center => (to - seg.0 / 2.0).max(0.0),
TabAlign::Decimal => (to - seg.1).max(0.0),
};
}
}
let d = if default_tab > 1.0 { default_tab } else { 72.0 };
let next = ((x / d).floor() + 1.0) * d;
(next - x).max(1.0)
}
#[cfg(test)]
mod tab_tests {
use super::*;
#[test]
fn a_nan_position_takes_the_finite_default_stop() {
let stops = [Tab { pos: 100.0, align: TabAlign::Left }];
assert!(tab_advance(f64::NAN, &stops, 72.0, (0.0, 0.0)).is_finite());
assert_eq!(tab_advance(10.0, &stops, 72.0, (0.0, 0.0)), 90.0);
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ pub struct GlyphRun {
pub link: bool,
/// Opacity of the fill on top of `color`'s own alpha (0 for no fill).
pub alpha: f64,
pub gradient: Option<std::sync::Arc<deckcraft_model::style::Gradient>>,
pub gradient: Option<deckcraft_model::style::Gradient>,
/// Shadow and glow behind the glyphs (`a:rPr/a:effectLst`).
pub effects: Option<RunEffects>,
/// East Asian vertical text (`eaVert`): each glyph stands upright, turned a quarter turn
+2 -2
View File
@@ -46,7 +46,7 @@ pub(crate) struct Style {
pub(crate) highlight: Option<Rgba>,
pub(crate) outline: Option<(Rgba, f64)>,
pub(crate) link: bool,
pub(crate) gradient: Option<std::sync::Arc<deckcraft_model::style::Gradient>>,
pub(crate) gradient: Option<deckcraft_model::style::Gradient>,
pub(crate) effects: Option<RunEffects>,
/// "Regular", "Bold"… for the slot and fallback faces.
pub(crate) style_name: &'static str,
@@ -136,7 +136,7 @@ pub(crate) fn make_style(ctx: &Ctx, r: &RunProps, scale: f64, prompt: Option<Rgb
for s in &mut g.stops {
s.color = deckcraft_model::ColorRef::rgb(ctx.color(&s.color, None));
}
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(std::sync::Arc::new(g)))
(g.stops.first().map(|s| ctx.color(&s.color, None)).unwrap_or(Rgba::BLACK), 1.0, Some(g))
}
Some(Fill::None) => (Rgba::TRANSPARENT, 0.0, None),
_ => (resolve::text_color(ctx, r), 1.0, None),
-10
View File
@@ -1416,16 +1416,6 @@ fn tab_stops_align_the_text_after_them() {
assert!((l.lines[0].edges[4].0 - x0(&l) - 72.0).abs() < 0.01);
}
#[test]
fn many_tabs_over_many_stops_lay_out_by_position() {
// 20,000 stops given out of order, and 2,000 tabs: each tab finds the next stop by position.
let mut stops: Vec<(f64, &str)> = (1..=20_000).rev().map(|k| (k as f64 * 0.05 + 30.0, "l")).collect();
stops.push((f64::NAN, "l"));
let text = "a\t".repeat(2_000);
let l = tabbed(&text, &stops, 1.0e6);
assert!((l.lines[0].edges[2].0 - l.inner.x0 - 30.05).abs() < 0.01, "{:?}", &l.lines[0].edges[..3]);
}
#[test]
fn a_tab_past_the_end_moves_to_the_next_line() {
// PowerPoint: "abcdefghij\tk\tl\tm…" in a narrow box puts the tab that doesn't fit on the next
-1
View File
@@ -106,7 +106,6 @@ impl SlideApp {
twisting: false,
next_file_id: 1,
pending_files: Vec::new(),
host_start: None,
ime_tracker: egui::ime_context::ImeContextTracker::new(),
ime_pending: None,
ime_frame_viewport: None,
-46
View File
@@ -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("&nbsp;", " ")
}
impl SlideApp {
/// Offer the copy or cut on the system clipboard.
///
-5
View File
@@ -20,11 +20,6 @@ impl SlideApp {
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
let r = if UI_COMMANDS.iter().any(|c| c.0 == id) {
self.run_ui(id, &params)
} else if id == "file.save" && self.services.host_save.is_some() && params.get("path").is_none() {
// A page that keeps the files (`?host=parent`): Save by id (the command palette) goes
// to the page like Ctrl+S. The engine would write to the presentation's path, which a
// browser can't.
self.save_to_host(None).map(|name| json!({"path": name}))
} else {
self.session.execute(id, &params).map_err(|e| e.to_string())
};
-85
View File
@@ -462,8 +462,6 @@ impl SlideApp {
Err(e) => self.set_status(e.to_string()),
}
}
} else if self.services.host_save.is_some() {
let _ = self.save_to_host(Some(ext));
} else if let Some(dl) = self.services.download.as_mut()
&& let Some(d) = self.session.active()
{
@@ -489,10 +487,6 @@ impl SlideApp {
}
pub fn save(&mut self) {
if self.services.host_save.is_some() {
let _ = self.save_to_host(None);
return;
}
if self.session.active().and_then(|d| d.path.clone()).is_some() {
match self.session.execute("file.save", &json!({})) {
Ok(_) => self.set_status("Saved"),
@@ -503,85 +497,6 @@ impl SlideApp {
}
}
/// Saves to the page that keeps the files ([`Services::host_save`](crate::Services::host_save)).
/// Save (`ext` `None`) writes the presentation's own file (the one the page sent, or the
/// last one saved there) in that file's format, as the desktop app does; a presentation with
/// no file of its own is a new `.deckcraft` file, the name Save suggests on the desktop. Save
/// As (`pptx`, `deckcraft`) is a new file named after the presentation. Once the page has the
/// bytes, the presentation is saved and that name is its file. Returns the name, or why it
/// wasn't saved (also shown in the status bar).
pub(super) fn save_to_host(&mut self, ext: Option<&str>) -> Result<String, String> {
use deckcraft_engine::cmd::file::{format_for_path, mark_saved, save_bytes};
let Some(d) = self.session.active() else { return Err("no presentation is open".into()) };
let own = d.path.as_deref().and_then(|p| std::path::Path::new(p).file_name()).map(|n| n.to_string_lossy().to_string());
let (name, save_as) = match (ext, own) {
(None, Some(own)) => (own, false),
(ext, _) => (format!("{}.{}", d.title(), ext.unwrap_or("deckcraft")), true),
};
let saved = save_bytes(&d.doc, format_for_path(&name)).map_err(|e| e.to_string()).and_then(|bytes| {
let host = self.services.host_save.as_mut().ok_or("no page to save to")?;
host(&name, &bytes, save_as).map_err(|e| format!("Couldn't save {name}: {e}"))?;
mark_saved(&mut self.session, Some(name.clone())).map_err(|e| e.to_string())
});
match saved {
Ok(()) => {
self.set_status(format!("Saved {name}"));
Ok(name)
}
Err(e) => {
self.set_status(e.clone());
Err(e)
}
}
}
/// A file the page that keeps the files sent (`?host=parent`), with the name it sent, if any.
/// It opens as File › Open opens it on the desktop: a presentation as itself, an outline
/// (.txt, .md, .rtf) as a new untitled presentation. The name is the presentation's file only
/// when the page sent one with a presentation extension and the file is an intact
/// presentation, so Save hands it back under that name and format; anything else (no name, an
/// outline, a damaged file opened as a repaired copy) is untitled, and its first Save is a new
/// file. The first presentation that opens takes the place of the empty one host mode started
/// with ([`Self::start_for_host`]), if that one is still open, untitled and unchanged; later
/// files open beside the others.
pub fn open_from_host(&mut self, name: Option<&str>, bytes: &[u8]) {
let blank = self.host_start.filter(|&uid| self.session.documents().iter().any(|d| d.uid == uid && d.path.is_none() && !d.is_dirty()));
let before = self.session.documents().len();
let shown = name.unwrap_or("Presentation");
let named = name.is_some_and(|n| is_presentation_name(&n.to_ascii_lowercase()));
let opened = deckcraft_engine::cmd::file::loaded_file(shown, bytes).and_then(|mut f| {
// `file.adopt` keeps the path only for a native file; a name the page didn't send
// for a presentation must not become one.
f.native &= named;
let token = self.session.stage_loaded(f);
self.session.execute("file.adopt", &json!({"path": shown, "token": token}))
});
if let Err(e) = opened {
self.notify(e.to_string());
}
self.drain_requests();
if self.session.documents().len() <= before {
return;
}
self.host_start = None;
if let Some(opened) = self.session.active().map(|d| d.uid)
&& let Some(i) = self.session.documents().iter().position(|d| Some(d.uid) == blank)
{
self.session.close_document(i);
if let Some(i) = self.session.documents().iter().position(|d| d.uid == opened) {
self.session.set_active(i);
}
}
}
/// Host mode (`?host=parent`) starts with an empty presentation instead of the sample, for
/// the first file the page sends to take its place ([`Self::open_from_host`]).
pub fn start_for_host(&mut self) -> Result<(), String> {
self.run("file.new", json!({}))?;
self.host_start = self.session.active().map(|d| d.uid);
Ok(())
}
/// Act on requests commands left for the UI.
pub(super) fn drain_requests(&mut self) {
for r in std::mem::take(&mut self.session.ui_requests) {
+2 -4
View File
@@ -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
-4
View File
@@ -144,10 +144,6 @@ impl SlideApp {
let visible = show::viewport_visible(ctx);
let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return };
let Some(show) = self.show.as_mut() else { return };
if show.presenter_grid && self.services.presenter.as_ref().is_some_and(|host| !host.is_open()) {
// A web popup closed with its grid open must not hold auto-advance while this tab is hidden (#1866).
show.presenter_grid = false;
}
if visible {
// A visible logic-only pass (`run_logic`) carries the last full frame's input time.
// Anchoring that stale time to `now` would move the hidden clock backwards if the
+1 -2
View File
@@ -249,8 +249,7 @@ fn act(app: &mut SlideApp, nav: Nav, current: Page) -> Option<Page> {
match nav {
Nav::Page(p) => Some(p),
Nav::Save => {
// A page that keeps the files (`host_save`) saves as the desktop does.
let web = app.services.pick_save.is_none() && app.services.download.is_some() && app.services.host_save.is_none();
let web = app.services.pick_save.is_none() && app.services.download.is_some();
let has_path = app.session.active().is_some_and(|d| d.path.is_some());
if !has_path && !web {
// A presentation that was never saved goes to Save As first, as in PowerPoint.
+4 -17
View File
@@ -166,28 +166,15 @@ pub(super) fn save_as(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, out:
})
.response
.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, "Save as type"));
// A page that keeps the files (`host_save`) saves; plain web downloads.
let web = app.services.pick_save.is_none() && app.services.host_save.is_none();
let web = app.services.pick_save.is_none();
let label = if web { "Download" } else { "Save" };
let save = ui.add(egui::Button::new(RichText::new(label).color(t.accent_text)).fill(t.accent).min_size(vec2(84.0, 26.0)));
if save
.on_hover_text(if web {
"Download the file"
} else if app.services.host_save.is_some() {
"Save to the page"
} else {
"Choose where to save"
})
.clicked()
{
if save.on_hover_text(if web { "Download the file" } else { "Choose where to save" }).clicked() {
let fmt = bs.file_type;
let done = if matches!(fmt, "pptx" | "deckcraft") {
// A save bumps the revision, also one under the same name that leaves the path
// and the dirty flag as they were; a cancelled dialog or a failed save doesn't.
let snapshot = |app: &SlideApp| app.session.active().map(|d| (d.path.clone(), d.is_dirty(), d.revision));
let before = snapshot(app);
let before = app.session.active().map(|d| (d.path.clone(), d.is_dirty()));
app.save_as_dialog(fmt);
web || snapshot(app) != before
web || app.session.active().map(|d| (d.path.clone(), d.is_dirty())) != before
} else {
app.export_as(fmt, json!({}))
};
+1 -2
View File
@@ -207,8 +207,7 @@ fn preview(app: &mut SlideApp, ui: &mut Ui, bs: &mut Backstage, doc: &deckcraft_
let mut budget = 4u32;
let mut draw_slide = |ui: &mut Ui, i: usize, r: Rect| {
let px = (r.width() * ui.ctx().pixels_per_point()).round().clamp(16.0, 1600.0) as u32;
// Print preview matches the actual exported PDF output (always full color).
match app.textures.thumb(ui.ctx(), doc, i, px, false, &mut budget) {
match app.textures.thumb(ui.ctx(), doc, i, px, &mut budget) {
Some(tex) => {
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
-58
View File
@@ -393,64 +393,6 @@ fn ctrl_w_asks_before_closing_unsaved_changes() {
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
}
/// Framed by a page that keeps the files (`?host=parent`), the Close prompt's Save is a real save:
/// when the page doesn't get the file the presentation stays open with its changes, and when it
/// does the presentation closes. (A plain browser download closes either way.)
#[test]
fn host_close_prompt_keeps_the_presentation_when_the_save_fails() {
for fail in [true, false] {
let services = Services {
host_save: Some(Box::new(move |_: &str, _: &[u8], _: bool| if fail { Err("the page is gone".into()) } else { Ok(()) })),
download: Some(Box::new(|_: &str, _: &[u8]| {})),
..Default::default()
};
let mut h = harness(SlideApp::new(Session::with_new(), services));
h.state_mut().run("slide.new", json!({})).unwrap();
h.run_steps(2);
h.key_press_modifiers(Modifiers::COMMAND, Key::W);
h.run_steps(3);
assert_eq!(h.state().dialog.as_ref().map(|d| d.id.as_str()), Some("close"));
// Enter is the prompt's Save.
h.key_press(Key::Enter);
h.run_steps(3);
assert!(h.state().dialog.is_none(), "fail={fail}");
let open = h.state().session.documents().len();
assert_eq!(open, usize::from(fail), "fail={fail}: open presentations");
if fail {
assert!(h.state().session.active().unwrap().is_dirty());
}
}
}
/// Framed by a page that keeps the files, Save As › Save under the presentation's own name goes
/// back to the slide once the page has the file. The bug this prevents: that save leaves the
/// file and the dirty flag as they were, so the Save As page stayed open and a second click sent
/// the file again.
#[test]
fn host_save_as_under_the_same_name_returns_to_the_slide() {
let got: Rc<RefCell<Vec<(String, bool)>>> = Default::default();
let saves = got.clone();
let services = Services {
host_save: Some(Box::new(move |name: &str, _: &[u8], save_as: bool| {
saves.borrow_mut().push((name.to_string(), save_as));
Ok(())
})),
download: Some(Box::new(|_: &str, _: &[u8]| {})),
..Default::default()
};
let mut app = SlideApp::new(Session::new(), services);
app.start_for_host().unwrap();
let deck = deckcraft_engine::cmd::file::save_bytes(&Session::with_new().doc().unwrap().doc, "pptx").unwrap();
app.open_from_host(Some("deck.pptx"), &deck);
let mut h = harness(app);
h.state_mut().run("app.backstage", json!({"page": "saveAs"})).unwrap();
h.run_steps(3);
click_on_page(&h, "Save");
h.run_steps(3);
assert_eq!(*got.borrow(), [("deck.pptx".to_string(), true)]);
assert!(h.state().backstage.is_none(), "the save returns to the slide");
}
/// Info shows the presentation's counts and properties, and edits them through file.properties.
#[test]
fn info_shows_and_edits_the_properties() {
+1 -1
View File
@@ -299,7 +299,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
vec2(w as f32 * scale, h as f32 * scale),
);
let mut budget = 2;
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, app.ui.grayscale, &mut budget) {
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (r.width() * ppp).round().max(1.0) as u32, &mut budget) {
painter.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
}
+2 -3
View File
@@ -339,9 +339,8 @@ fn dlg_close(app: &mut SlideApp, ui: &mut Ui, _d: &mut Dialog) -> bool {
let save = ui.add(egui::Button::new(egui::RichText::new("Save").color(t.accent_text)).fill(t.accent).min_size(vec2(72.0, 24.0)));
if save.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
app.save();
// In the browser a save is a download: the presentation stays "unsaved". A page
// that keeps the files saves it for real, so a failed save keeps it open.
let web = app.services.pick_save.is_none() && app.services.download.is_some() && app.services.host_save.is_none();
// In the browser a save is a download: the presentation stays "unsaved".
let web = app.services.pick_save.is_none() && app.services.download.is_some();
if web || app.session.active().is_some_and(|d| !d.is_dirty()) {
run(app, "file.close", json!({}));
}
-213
View File
@@ -1,213 +0,0 @@
//! The web build framed by a page that keeps the files (`?host=parent`): what Save, Save As,
//! the exports and Print hand that page, under which name, and whether it is a new file.
#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::json;
use super::{Services, SlideApp};
use crate::Session;
/// What the page was handed: (name, Save As, bytes).
type Got = Rc<RefCell<Vec<(String, bool, Vec<u8>)>>>;
/// The web app inside such a page, with `host_save` and `download` pointed at it as the bridge
/// does; `fail` makes every save miss the page.
fn host_app(fail: bool) -> (SlideApp, Got) {
let got: Got = Rc::default();
let (saves, downloads) = (got.clone(), got.clone());
let services = Services {
host_save: Some(Box::new(move |name: &str, bytes: &[u8], save_as: bool| {
if fail {
return Err("the page is gone".into());
}
saves.borrow_mut().push((name.to_string(), save_as, bytes.to_vec()));
Ok(())
})),
download: Some(Box::new(move |name: &str, bytes: &[u8]| downloads.borrow_mut().push((name.to_string(), false, bytes.to_vec())))),
..Default::default()
};
// Host mode starts as the web shell starts it: an empty presentation for the page's file.
let mut app = SlideApp::new(Session::new(), services);
app.start_for_host().unwrap();
(app, got)
}
/// A .pptx with `slides` slides, as the page would send it.
fn pptx(slides: usize) -> Vec<u8> {
let mut s = Session::with_new();
while s.doc().unwrap().doc.slides.len() < slides {
s.execute("slide.new", &json!({})).unwrap();
}
deckcraft_engine::cmd::file::save_bytes(&s.doc().unwrap().doc, "pptx").unwrap()
}
fn edit(app: &mut SlideApp) {
app.session.execute("slide.new", &json!({})).unwrap();
assert!(app.session.active().unwrap().is_dirty());
}
fn calls(got: &Got) -> Vec<(String, bool)> {
got.borrow().iter().map(|(n, s, _)| (n.clone(), *s)).collect()
}
fn path(app: &SlideApp) -> Option<String> {
app.session.active().and_then(|d| d.path.clone())
}
/// The bug this prevents: Save of a .pptx the page sent came back as `<name>.deckcraft`. Save
/// now hands the page the opened file under its own name and format, not as a new file; Save As
/// says it is a new file and becomes the file the next Save writes.
#[test]
fn host_saves_go_back_to_the_opened_file() {
let (mut app, got) = host_app(false);
app.open_from_host(Some("Lecture 3.pptx"), &pptx(2));
assert_eq!(app.session.documents().len(), 1, "the untouched blank presentation gave way");
edit(&mut app);
app.save();
assert!(!app.session.active().unwrap().is_dirty());
edit(&mut app);
app.save_as_dialog("deckcraft");
assert!(!app.session.active().unwrap().is_dirty());
edit(&mut app);
app.save();
let names = [("Lecture 3.pptx", false), ("Lecture 3.deckcraft", true), ("Lecture 3.deckcraft", false)];
assert_eq!(calls(&got), names.map(|(n, s)| (n.to_string(), s)));
// The first save is a .pptx that reopens with the edit in it.
let first = got.borrow()[0].2.clone();
assert!(first.windows(20).any(|w| w == b"ppt/presentation.xml"), "Save writes PowerPoint, not .deckcraft");
let reopened = deckcraft_engine::cmd::file::loaded_file("Lecture 3.pptx", &first).unwrap();
assert_eq!(reopened.doc.slides.len(), 3);
}
/// A presentation the page never sent is a new file, as Save As: the page must not overwrite a
/// file of the same name. The next Save goes back to it.
#[test]
fn host_save_of_an_untitled_presentation_is_save_as() {
let (mut app, got) = host_app(false);
let name = format!("{}.deckcraft", app.session.active().unwrap().title());
edit(&mut app);
app.save();
edit(&mut app);
app.save();
assert_eq!(calls(&got), [(name.clone(), true), (name, false)]);
}
/// A presentation the user dropped (or picked with File › Open) didn't come from the page, so
/// its name isn't one the page holds: Save makes a new file.
#[test]
fn a_file_the_page_never_sent_saves_as_a_new_file() {
let (mut app, got) = host_app(false);
app.open_or_insert_bytes("dropped.pptx", &pptx(1));
app.save();
assert_eq!(calls(&got), [("dropped.deckcraft".to_string(), true)]);
}
/// Exports and Print are copies: named after the presentation, not a new file, and the
/// presentation keeps its own file and its unsaved changes.
#[test]
fn host_exports_and_print_are_copies() {
let (mut app, got) = host_app(false);
app.open_from_host(Some("deck.pptx"), &pptx(1));
edit(&mut app);
assert!(app.export_as("pdf", json!({})));
assert!(app.print_pdf(json!({})));
assert_eq!(calls(&got), [("deck.pdf".to_string(), false), ("deck.pdf".to_string(), false)]);
assert!(app.session.active().unwrap().is_dirty());
assert_eq!(path(&app).as_deref(), Some("deck.pptx"));
}
/// The page not getting the file is a failed save: the presentation keeps its changes.
#[test]
fn a_host_save_that_fails_leaves_the_presentation_unsaved() {
let (mut app, _) = host_app(true);
app.open_from_host(Some("deck.pptx"), &pptx(1));
edit(&mut app);
app.save();
assert!(app.session.active().unwrap().is_dirty());
assert!(app.status.as_ref().is_some_and(|(m, _)| m.contains("Couldn't save deck.pptx")));
}
/// Only an untouched start presentation gives way: one with changes stays open beside the file.
#[test]
fn an_edited_start_presentation_stays_open() {
let (mut app, _) = host_app(false);
edit(&mut app);
app.open_from_host(Some("deck.pptx"), &pptx(1));
assert_eq!(app.session.documents().len(), 2);
assert_eq!(path(&app).as_deref(), Some("deck.pptx"));
}
/// Only the first file from the page takes the start presentation's place. The bug this
/// prevents: a file sent without a name opens untitled and unchanged, as the start presentation
/// is, so the page's next file closed it.
#[test]
fn only_the_first_file_from_the_page_replaces_the_start_presentation() {
let (mut app, _) = host_app(false);
app.open_from_host(None, &pptx(2));
app.open_from_host(Some("deck.pptx"), &pptx(3));
let slides: Vec<usize> = app.session.documents().iter().map(|d| d.doc.slides.len()).collect();
assert_eq!(slides, [2, 3], "the file sent without a name stays open beside the next one");
assert_eq!(path(&app).as_deref(), Some("deck.pptx"));
}
/// The command palette runs `file.save` by id. With a file of its own the engine would write to
/// that path, which a browser can't; in host mode it goes to the page like Ctrl+S.
#[test]
fn palette_save_goes_to_the_page() {
let (mut app, got) = host_app(false);
app.open_from_host(Some("deck.pptx"), &pptx(1));
edit(&mut app);
let r = app.run("file.save", json!({})).unwrap();
assert_eq!(r["path"], "deck.pptx");
assert_eq!(calls(&got), [("deck.pptx".to_string(), false)]);
assert!(!app.session.active().unwrap().is_dirty());
}
/// A file the page sent without a name, or with a name that isn't a presentation's, has no file
/// of its own: Save is a new `.deckcraft`, never an overwrite of an invented `Presentation.pptx`.
#[test]
fn a_file_sent_without_a_presentation_name_saves_as_a_new_file() {
for (name, saved) in [(None, "Presentation.deckcraft"), (Some("Talk"), "Talk.deckcraft")] {
let (mut app, got) = host_app(false);
app.open_from_host(name, &pptx(2));
assert_eq!(app.session.documents().len(), 1, "{name:?}");
assert_eq!(app.session.active().unwrap().doc.slides.len(), 2, "{name:?}");
assert_eq!(path(&app), None, "{name:?}");
app.save();
assert_eq!(calls(&got), [(saved.to_string(), true)], "{name:?}");
}
}
/// An outline from the page opens as a new untitled presentation, as File › Open does on the
/// desktop: the blank start presentation gives way, and Save is a new file.
#[test]
fn an_outline_from_the_page_opens_as_a_new_presentation() {
let (mut app, got) = host_app(false);
app.open_from_host(Some("notes.md"), b"# One\n\n# Two\n\n# Three\n");
assert_eq!(app.session.documents().len(), 1);
assert_eq!(app.session.active().unwrap().doc.slides.len(), 3);
assert_eq!(path(&app), None);
app.save();
assert_eq!(calls(&got), [("notes.deckcraft".to_string(), true)]);
}
/// A damaged file from the page opens as an untitled "(Repaired)" copy, so Save is a new file
/// and can't overwrite the page's original with the emptied media.
#[test]
fn a_damaged_file_from_the_page_saves_as_a_new_repaired_file() {
let mut p = deckcraft_model::Presentation::default();
p.add_media("a.png", "image/png", b"PICTURE-BYTES-0123456789".to_vec());
let mut bad = deckcraft_engine::cmd::file::save_bytes(&p, "deckcraft").unwrap();
// Pictures are stored as they are, so flipping a byte breaks the entry's CRC.
let at = bad.windows(13).position(|w| w == b"PICTURE-BYTES").unwrap();
bad[at] ^= 0xff;
let (mut app, got) = host_app(false);
app.open_from_host(Some("talk.deckcraft"), &bad);
assert_eq!(path(&app), None);
assert!(app.session.active().unwrap().title().ends_with("(Repaired)"));
app.save();
assert_eq!(calls(&got), [("talk (Repaired).deckcraft".to_string(), true)]);
}
+1 -7
View File
@@ -72,7 +72,7 @@ pub use panels::notes;
pub use panels::selpane;
pub use panels::thumbs;
pub use ribbon::qat;
pub use services::{DownloadFn, HostSaveFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn};
pub use services::{DownloadFn, Inbox, InboxEvent, InboxFile, OpenAsyncFn, PickFn, ReadFn, Services, WriteFn};
use state::{FilePlace, FilePurpose, ImeDescriptorKey, ImeDestination, ImeTerminalStamp};
pub use state::{RECENT_MAX, UiState, ViewMode, ZOOM_RANGE};
@@ -335,9 +335,6 @@ pub struct SlideApp {
next_file_id: u64,
/// Picks and drops whose bytes have not arrived yet.
pending_files: Vec<PendingFile>,
/// The empty presentation host mode starts with ([`SlideApp::start_for_host`]), until the
/// first file the page sends takes its place.
host_start: Option<u64>,
/// Owner/generation/cause bookkeeping for the canvas text IME producer.
ime_tracker: egui::ime_context::ImeContextTracker,
/// Canvas IME geometry for this pass: the caret while editing, or the selected shape.
@@ -490,8 +487,5 @@ mod datetime_tests;
#[cfg(test)]
mod inbox_tests;
#[cfg(test)]
mod host_save_tests;
#[cfg(test)]
mod tests;
+4 -38
View File
@@ -112,21 +112,6 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
}
}
/// Slide index -> the first section starting there, built once instead of searched per slide.
fn section_starts(doc: &deckcraft_model::Presentation) -> std::collections::HashMap<usize, usize> {
let mut index_of = std::collections::HashMap::new();
for (i, s) in doc.slides.iter().enumerate() {
index_of.entry(s.id).or_insert(i);
}
let mut starts = std::collections::HashMap::new();
for (si, s) in doc.sections.iter().enumerate() {
if let Some(&i) = s.slides.first().and_then(|id| index_of.get(id)) {
starts.entry(i).or_insert(si);
}
}
starts
}
/// Render the scrollable thumbnail list, scrolling the current slide into view when it changes.
fn list(app: &mut SlideApp, ui: &mut Ui) {
let t = Tokens::get(ui.ctx());
@@ -150,10 +135,11 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
let ppp = ui.ctx().pixels_per_point();
let mut budget = 3u32;
let mut rects = vec![];
let section_starts = section_starts(&doc);
let section_starts: Vec<(usize, usize)> =
doc.sections.iter().enumerate().filter_map(|(si, s)| s.slides.first().and_then(|id| doc.slide_index(*id)).map(|i| (i, si))).collect();
let mut need_more = false;
for i in 0..doc.slides.len() {
if let Some(si) = section_starts.get(&i)
if let Some((_, si)) = section_starts.iter().find(|(start, _)| *start == i)
&& let Some(sec) = doc.sections.get(*si)
{
ui.add_space(4.0);
@@ -219,7 +205,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(
@@ -448,26 +434,6 @@ mod tests {
h
}
#[test]
fn section_starts_map_each_first_slide_to_its_first_section() {
let mut s = Session::with_new();
for _ in 0..3 {
s.execute("slide.new", &json!({})).unwrap();
}
let mut doc = (*s.active().unwrap().doc).clone();
let ids: Vec<_> = doc.slides.iter().map(|sl| sl.id).collect();
doc.sections = vec![
deckcraft_model::Section { name: "a".into(), slides: vec![ids[0], ids[1]] },
deckcraft_model::Section { name: "dup".into(), slides: vec![ids[0]] },
deckcraft_model::Section { name: "c".into(), slides: vec![ids[2]] },
deckcraft_model::Section { name: "empty".into(), slides: vec![] },
];
let starts = section_starts(&doc);
assert_eq!(starts.get(&0), Some(&0), "the first section starting at a slide wins");
assert_eq!(starts.get(&2), Some(&2));
assert_eq!(starts.len(), 2);
}
/// Vertical scroll offset of the thumbnail pane, as recorded each frame by `show`.
fn thumbs_scroll(h: &Harness<'_, SlideApp>) -> f32 {
h.state().ui.thumbs_scroll
+5 -102
View File
@@ -49,15 +49,10 @@ pub struct Snapshot {
pub paused: bool,
pub notes_px: f32,
pub blank: Option<String>,
/// All slides is open in the presenter window (#1866); the audience keeps the current slide.
pub grid: bool,
pub zoom_armed: bool,
pub current: Option<Preview>,
pub next: Option<Preview>,
/// The All slides thumbnails, in slide order, while `grid` is on. `None` in a sent delta
/// means "unchanged since the last snapshot the window received".
#[serde(default)]
pub slides: Option<Vec<Preview>>,
}
/// Images acknowledged by a successful transport send, never merely attempted images.
@@ -65,7 +60,6 @@ pub struct Snapshot {
pub struct DeltaCache {
current: Option<Preview>,
next: Option<Preview>,
slides: Option<Vec<Preview>>,
token: String,
}
impl DeltaCache {
@@ -81,12 +75,6 @@ impl DeltaCache {
let next_changed = fresh || self.next != snapshot.next;
let current = snapshot.current.clone();
let next = snapshot.next.clone();
let slides = snapshot.slides.clone();
if !fresh && self.slides == slides {
snapshot.slides = None;
} else if snapshot.slides.is_none() {
snapshot.slides = Some(Vec::new());
}
if !current_changed {
snapshot.current = None;
}
@@ -96,28 +84,14 @@ impl DeltaCache {
send(snapshot, current_changed, next_changed)?;
self.current = current;
self.next = next;
self.slides = slides;
self.token = token.to_owned();
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Command {
Key {
name: String,
command: bool,
shift: bool,
alt: bool,
ctrl: bool,
},
Zoom {
x: f32,
y: f32,
},
/// A slide chosen in the presenter window's All slides grid (#1866).
Goto {
slide: usize,
},
Key { name: String, command: bool, shift: bool, alt: bool, ctrl: bool },
Zoom { x: f32, y: f32 },
End,
}
/// Implemented by the browser host. Native hosts retain the existing egui viewport.
@@ -138,8 +112,6 @@ pub struct View {
next: Option<egui::TextureHandle>,
previous_current: Option<Preview>,
previous_next: Option<Preview>,
grid_source: Option<Vec<Preview>>,
grid: Vec<Option<egui::TextureHandle>>,
}
impl View {
/// Install the same interface and dynamic fallback fonts as SlideApp before layout.
@@ -175,10 +147,6 @@ impl View {
.collect::<Vec<_>>()
});
for (key, modifiers) in keys {
if snapshot.grid && matches!(key, egui::Key::Space | egui::Key::Enter) {
// A focused thumbnail takes these to choose its slide; the show ignores them while the grid is open.
continue;
}
// Consume once before widgets see Space/Enter, avoiding a second button activation.
ui.input_mut(|i| i.consume_key(modifiers, key));
commands.push(Command::Key {
@@ -232,11 +200,6 @@ impl View {
}
});
ui.separator();
if snapshot.grid {
// All slides opens here, as in the desktop Presenter View; the audience keeps the current slide.
self.grid_ui(ui, snapshot, &mut commands);
return commands;
}
ui.columns(2, |columns| {
let left = &mut columns[0];
if snapshot.ended {
@@ -264,6 +227,9 @@ impl View {
if snapshot.zoom_armed {
left.label("Click the preview to zoom.");
}
if snapshot.grid {
left.label("Choose a slide in the audience window, or press G to return.");
}
let right = &mut columns[1];
right.label("Next");
if let Some(texture) = &self.next {
@@ -294,43 +260,6 @@ impl View {
commands
}
}
impl View {
fn grid_ui(&mut self, ui: &mut egui::Ui, snapshot: &Snapshot, commands: &mut Vec<Command>) {
let slides = snapshot.slides.as_deref().unwrap_or_default();
if self.grid_source.as_deref() != Some(slides) {
self.grid_source = Some(slides.to_vec());
self.grid = slides
.iter()
.enumerate()
.map(|(i, p)| p.image().map(|image| ui.ctx().load_texture(format!("remote-grid-{i}"), image, egui::TextureOptions::LINEAR)))
.collect();
}
ui.heading("All slides");
ui.label("Choose a slide to show it, or press G to return.");
if snapshot.total > crate::show::GRID_CAP {
ui.label(format!("First {} slides", crate::show::GRID_CAP));
}
egui::ScrollArea::vertical().show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (i, texture) in self.grid.iter().enumerate() {
let size = egui::vec2(160.0, 90.0);
let label = format!("{}", i + 1);
let label = if i == snapshot.slide { egui::RichText::new(label).strong() } else { egui::RichText::new(label) };
let response = match texture {
Some(t) => ui.add(egui::Button::image_and_text(
egui::Image::new(t).fit_to_exact_size(fit_preview(t.size_vec2(), size.x, size.y)),
label,
)),
None => ui.add_sized(size, egui::Button::new(label)),
};
if response.clicked() {
commands.push(Command::Goto { slide: i });
}
}
});
});
}
}
fn fit_preview(size: egui::Vec2, width: f32, height: f32) -> egui::Vec2 {
size * (width.max(1.0) / size.x.max(1.0)).min(height.max(1.0) / size.y.max(1.0))
}
@@ -353,7 +282,6 @@ mod tests {
zoom_armed: false,
current: Some(Preview { width: 1, height: 1, rgba: vec![255; 4] }),
next: Some(Preview { width: 1, height: 1, rgba: vec![0; 4] }),
slides: None,
}
}
@@ -490,7 +418,6 @@ mod tests {
zoom_armed: false,
current: None,
next: None,
slides: None,
};
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
@@ -510,30 +437,6 @@ mod tests {
assert!(matches!(h.state().2.last(), Some(Command::Key { name, .. }) if name == egui::Key::Space.name()));
}
#[test]
fn all_slides_opens_in_the_popup_and_picks_a_slide() {
use egui_kittest::kittest::Queryable;
let thumb = Preview { width: 16, height: 9, rgba: vec![200; 16 * 9 * 4] };
let mut snapshot = snapshot();
snapshot.total = 3;
snapshot.grid = true;
snapshot.slides = Some(vec![thumb.clone(), thumb.clone(), thumb]);
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).build_ui_state(
|ui, (view, snapshot, sent): &mut (View, Snapshot, Vec<Command>)| {
sent.extend(view.ui(ui, snapshot));
},
(View::default(), snapshot, Vec::new()),
);
h.run_steps(2);
assert!(h.query_by_label("Notes").is_none(), "the grid replaces the presenter panes, as on desktop (#1866)");
h.get_by_label("3").click();
h.run_steps(2);
assert_eq!(h.state().2, vec![Command::Goto { slide: 2 }]);
h.key_press(egui::Key::Enter);
h.run_steps(2);
assert!(!h.state().2.iter().any(|c| matches!(c, Command::Key { .. })), "Enter is left to the focused thumbnail");
}
#[test]
fn previews_are_bounded_and_bad_dimensions_are_rejected() {
let image = egui::ColorImage::filled([1280, 720], egui::Color32::RED);
-13
View File
@@ -11,10 +11,6 @@ pub type OpenAsyncFn = Box<dyn FnMut(&str, u64)>;
pub type DownloadFn = Box<dyn FnMut(&str, &[u8])>;
/// [`Services::host_save`]: `(file name, bytes, save_as)`. An error means the page didn't get
/// the file, and the save failed.
pub type HostSaveFn = Box<dyn FnMut(&str, &[u8], bool) -> Result<(), String>>;
/// A file the host finished reading for the request `id` from [`SlideApp::begin_external_file`].
#[derive(Clone, Debug)]
pub struct InboxFile {
@@ -53,15 +49,6 @@ pub struct Services {
pub open_async: Option<OpenAsyncFn>,
/// Hand bytes to the user as a named file (browser download).
pub download: Option<DownloadFn>,
/// Web, framed by a page that keeps the files (`?host=parent`): Save and Save As hand the
/// presentation to that page instead of downloading it, with whether it is a new file
/// ([`HostSaveFn`]). Save writes a presentation the page sent back under its own name and
/// format, as the desktop app writes back to the file it opened; a presentation the page
/// never sent (an untitled one, a repaired copy) goes as a new `.deckcraft` file, as Save
/// suggests on the desktop. Save As (.pptx or .deckcraft) is always a new file. Either way the
/// presentation is then that file, and the next Save goes to it. Exports and Print still go
/// through `download`, which the host also points at the page: they are copies.
pub host_save: Option<HostSaveFn>,
pub inbox: Option<Inbox>,
/// Image on the system clipboard (PNG bytes), when the host can read one.
pub clipboard_image: Option<Box<dyn FnMut() -> Option<Vec<u8>>>>,
-4
View File
@@ -18,7 +18,6 @@ mod presenter;
mod render;
mod ui;
mod zoom;
pub(crate) use zoom::GRID_CAP;
#[cfg(test)]
use media::{on_screen, pointer_on_media_bar, shown_slide_rect, slide_media_bars};
@@ -66,8 +65,6 @@ pub struct Show {
preview_spawns: usize,
remote_preview: Option<crate::presenter::Preview>,
remote_next: Option<(usize, crate::presenter::Preview)>,
/// All slides thumbnails sent to the web presenter window while its grid is open (#1866).
remote_grid: Vec<crate::presenter::Preview>,
remote_sent: f64,
remote_opened: bool,
/// The next slide's first frame, rendered in the background while this one is shown so
@@ -240,7 +237,6 @@ impl Show {
preview_spawns: 0,
remote_preview: None,
remote_next: None,
remote_grid: vec![],
remote_sent: f64::NEG_INFINITY,
remote_opened: false,
prefetch: None,
+2 -39
View File
@@ -1132,8 +1132,6 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
opens: usize,
closes: usize,
commands: Vec<Command>,
last: Option<Snapshot>,
gone: bool,
}
struct Mock(Rc<RefCell<Bridge>>);
impl Host for Mock {
@@ -1142,14 +1140,12 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
Ok(())
}
fn is_open(&self) -> bool {
!self.0.borrow().gone
true
}
fn commands(&mut self) -> Vec<Command> {
std::mem::take(&mut self.0.borrow_mut().commands)
}
fn update(&mut self, snapshot: Snapshot) {
self.0.borrow_mut().last = Some(snapshot);
}
fn update(&mut self, _: Snapshot) {}
fn close(&mut self) {
self.0.borrow_mut().closes += 1;
}
@@ -1177,39 +1173,6 @@ fn remote_presenter_uses_the_audience_state_and_closes_with_the_show() {
h.run_steps(2);
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide);
}
// #1864: the web popup's Previous/Next buttons send the DOM key names.
for (name, slide) in [("ArrowRight", 1), ("ArrowLeft", 0)] {
bridge.borrow_mut().commands.push(Command::Key { name: name.into(), command: false, shift: false, alt: false, ctrl: false });
h.run_steps(2);
assert_eq!(h.state().show.as_ref().unwrap().state.slide, slide, "{name}");
}
// #1866: All slides opens in the web Presenter View, as on desktop; the audience keeps its slide.
bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false });
h.run_steps(2);
let show = h.state().show.as_ref().unwrap();
assert!(show.presenter_grid && !show.grid, "G from the popup opens its own grid, not the audience's");
for _ in 0..20 {
h.step();
std::thread::sleep(std::time::Duration::from_millis(15));
}
let last = bridge.borrow().last.clone().unwrap();
assert!(last.grid);
assert_eq!(last.slides.as_ref().map(Vec::len), Some(3), "the popup gets a thumbnail per slide");
assert!(last.slides.iter().flatten().all(|p| p.width.max(p.height) <= 96), "thumbnails stay small enough for one message");
assert!(last.current.is_none() && last.next.is_none(), "the grid replaces both previews");
bridge.borrow_mut().commands.push(Command::Goto { slide: 2 });
h.run_steps(2);
let show = h.state().show.as_ref().unwrap();
assert!(!show.presenter_grid && !show.grid);
assert_eq!(show.state.slide, 2);
// Closing the popup with its grid open must not hold auto-advance for the rest of the show.
bridge.borrow_mut().commands.push(Command::Key { name: "G".into(), command: false, shift: false, alt: false, ctrl: false });
h.run_steps(2);
assert!(h.state().show.as_ref().unwrap().presenter_grid);
bridge.borrow_mut().gone = true;
h.run_steps(2);
assert!(!h.state().show.as_ref().unwrap().presenter_grid);
bridge.borrow_mut().gone = false;
bridge.borrow_mut().commands.push(Command::End);
h.run_steps(2);
assert!(h.state().show.is_none());
+5 -37
View File
@@ -80,10 +80,6 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
&& !show.reading
&& let Some(host) = app.services.presenter.as_mut()
{
if show.remote_opened && show.presenter_grid && !host.is_open() {
// A grid left open in the closed popup would hold auto-advance for the rest of the show.
show.presenter_grid = false;
}
if !show.remote_opened {
show.remote_opened = true;
if let Err(message) = host.open() {
@@ -98,20 +94,11 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
show.zoom_arm = false;
}
crate::presenter::Command::Key { name, command, shift, alt, ctrl } => {
if let Some(key) = egui::Key::from_name(&name) {
if let Some(key) = egui::Key::ALL.iter().copied().find(|key| key.name() == name) {
let modifiers = egui::Modifiers { command, shift, alt, ctrl, mac_cmd: command && !ctrl };
// The popup is the Presenter View: G opens All slides there, not on the audience screen (#1866).
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], true).0;
out.keep &= handle_keys(&mut show, &doc, now, vec![(key, modifiers)], false).0;
}
}
crate::presenter::Command::Goto { slide } if show.presenter_grid && slide < doc.slides.len() => {
show.presenter_grid = false;
show.blank = None;
show.zoom = None;
show.zoom_arm = false;
let action = show.state.goto(&doc, slide);
out.keep &= show.apply(action, &doc, now);
}
_ => {}
}
}
@@ -156,20 +143,6 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
);
show.remote_next = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false)).map(|image| (n, image));
}
if !show.presenter_grid {
show.remote_grid.clear();
} else if show.remote_grid.len() < grid_indices(doc.slides.len()).len() {
// A few thumbnails per update keep the audience window responsive on the web.
for i in grid_indices(doc.slides.len()).into_iter().skip(show.remote_grid.len()).take(4) {
// At most 96 px on the long side: 48 thumbnails stay well under the popup's message limit.
let (w, h) = (doc.slide_size.width.max(1.0), doc.slide_size.height.max(1.0));
let scale = REMOTE_THUMB / w.max(h);
let size = (((w * scale).round() as u32).max(1), ((h * scale).round() as u32).max(1));
let image = deckcraft_render::render_slide(&doc, i, &RenderOpts { scale, size: Some(size), ..Default::default() });
let preview = crate::presenter::Preview::from_image(&crate::textures::to_color_image(&image, false));
show.remote_grid.push(preview.unwrap_or(crate::presenter::Preview { width: 1, height: 1, rgba: vec![0; 4] }));
}
}
host.update(crate::presenter::Snapshot {
slide: show.state.slide,
total: doc.slides.len(),
@@ -179,12 +152,10 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
paused: show.timer.paused(),
notes_px: show.notes_px,
blank: show.blank_name().map(str::to_owned),
grid: show.presenter_grid,
grid: show.grid,
zoom_armed: show.zoom_arm,
// The grid replaces both previews in the popup; leaving them out keeps one message small.
current: show.remote_preview.clone().filter(|_| !show.presenter_grid),
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next && !show.presenter_grid).map(|(_, image)| image.clone()),
slides: show.presenter_grid.then(|| show.remote_grid.clone()),
current: show.remote_preview.clone(),
next: show.remote_next.as_ref().filter(|(n, _)| Some(*n) == next).map(|(_, image)| image.clone()),
});
show.remote_sent = now;
}
@@ -193,9 +164,6 @@ pub fn ui(app: &mut SlideApp, ui: &mut Ui) {
finish(app, show, &f, out.keep);
}
/// Longest side of an All slides thumbnail sent to the web presenter window (#1866).
const REMOTE_THUMB: f64 = 96.0;
fn paint_hidden(ui: &Ui, doc: &Presentation, show: &Show) {
if show.started {
let full = ui.max_rect();
+1 -1
View File
@@ -182,7 +182,7 @@ const WHEEL_NOTCHES_PER_DOUBLING: f32 = 4.0;
/// PowerPoint's slide show magnifier goes up to 400%.
const WHEEL_ZOOM_MAX: f32 = 4.0;
pub(crate) const GRID_CAP: usize = 48;
pub(super) const GRID_CAP: usize = 48;
const TOOL_BAR_W: f32 = 520.0;
+4 -6
View File
@@ -209,8 +209,7 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
// Only cells in view (plus a little margin) ask for a thumbnail, so a deck bigger
// than the thumbnail cache does not evict its own thumbnails forever (#969).
let near = ui.clip_rect().expand2(vec2(0.0, 2.0 * cell.height())).intersects(cell);
let tex =
if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, app.ui.grayscale, budget) } else { None };
let tex = if near { app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) } else { None };
match tex {
Some(tex) => {
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
@@ -392,7 +391,7 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
}
let ppp = ui.ctx().pixels_per_point();
let mut budget = 2;
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, app.ui.grayscale, &mut budget) {
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, &mut budget) {
ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
}
ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside);
@@ -477,7 +476,7 @@ fn outline_estimate(s: &deckcraft_model::Slide) -> f32 {
let lines: usize = s
.shapes
.iter()
.filter(|x| !x.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind()))
.filter(|x| !x.ph_type().is_some_and(|k| k.is_title()))
.filter_map(|x| x.text.as_ref())
.filter(|tb| !tb.is_empty())
.map(|tb| tb.paragraphs.len().max(1))
@@ -513,8 +512,7 @@ fn outline_slide(app: &mut SlideApp, ui: &mut Ui, t: &Tokens, i: usize, s: &deck
ui.data_mut(|d| d.insert_temp(id, v));
}
});
// Footer, date and slide-number placeholders are not outline text, as in PowerPoint.
for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title() || k.is_footer_kind())) {
for sh in s.shapes.iter().filter(|x| !x.ph_type().is_some_and(|k| k.is_title())) {
let Some(tb) = &sh.text else { continue };
if tb.is_empty() {
continue;
-21
View File
@@ -259,24 +259,3 @@ fn shift_and_ctrl_shift_clicks_select_ranges() {
click(&mut h, 4, Modifiers::COMMAND | Modifiers::SHIFT);
assert_eq!(selected(&h), vec![0, 2, 3]);
}
/// The outline view lists a slide's title and body text, not its footer, date and slide-number
/// placeholders, as PowerPoint's does: turning them on leaves the slide's outline block as it was.
#[test]
fn the_outline_view_leaves_footer_date_and_slide_number_out() {
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "First point\nSecond point"})).unwrap();
let slide = |s: &Session| {
let st = s.doc().unwrap();
st.doc.slides[st.selection.slide].clone()
};
let plain = super::outline_estimate(&slide(&s));
s.execute(
"design.headerFooter",
&json!({"footer": true, "footerText": "Company Confidential", "slideNumber": true, "date": true, "dateText": "1 May 2026"}),
)
.unwrap();
let with_footer = slide(&s);
assert!(with_footer.shapes.iter().any(|x| x.ph_type().is_some_and(|k| k.is_footer_kind())), "the footers are on the slide");
assert_eq!(super::outline_estimate(&with_footer), plain);
}
-48
View File
@@ -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]
+6 -31
View File
@@ -361,19 +361,9 @@ impl Textures {
self.thumbs.clear();
}
/// A thumbnail texture; renders at most `budget` new thumbnails per frame. Over budget it
/// returns the slide's previous thumbnail if there is one (and asks for another frame), else
/// None, so the caller draws a placeholder.
/// When `grayscale` is true, the thumbnail renders in black-and-white (matching Grayscale view).
pub fn thumb(
&mut self,
ctx: &egui::Context,
p: &Presentation,
index: usize,
width_px: u32,
grayscale: bool,
budget: &mut u32,
) -> Option<TextureHandle> {
/// A thumbnail texture; renders at most `budget` new thumbnails per frame (returns None when
/// it is over budget, so the caller draws a placeholder and asks for another frame).
pub fn thumb(&mut self, ctx: &egui::Context, p: &Presentation, index: usize, width_px: u32, budget: &mut u32) -> Option<TextureHandle> {
let slide = p.slides.get(index)?.clone();
let master = master_of(p, &slide);
let h = (width_px as f64 * p.slide_size.height / p.slide_size.width.max(1.0)).round().max(1.0) as u32;
@@ -382,7 +372,7 @@ impl Textures {
master: master.as_ref().map(|m| Arc::as_ptr(m) as usize).unwrap_or(0),
size: (width_px, h),
edit: false,
extra: grayscale as u64,
extra: 0,
want: Region::slide(width_px, h),
editing_text: None,
};
@@ -395,8 +385,7 @@ impl Textures {
return Some(e.tex.clone());
}
if *budget == 0 {
// Keep showing the stale thumbnail while the new one waits; request a repaint to finish rendering.
ctx.request_repaint();
// Keep showing the stale thumbnail while the new one waits.
return self.thumbs.get(&key.slide).map(|e| e.tex.clone());
}
*budget -= 1;
@@ -404,7 +393,7 @@ impl Textures {
let scale = tw as f64 / p.slide_size.width.max(1.0);
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() });
self.thumb_renders += 1;
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, grayscale));
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, false));
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
let region = key.want;
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
@@ -444,20 +433,6 @@ mod tests {
assert_eq!(device_pixel_rect(aligned, ppp), aligned);
}
#[test]
fn to_color_image_turns_colors_to_grayscale_when_flag_is_set() {
let img = deckcraft_render::Image { width: 2, height: 1, pixels: vec![0, 0, 255, 255, 255, 0, 0, 255] };
let color = super::to_color_image(&img, false);
assert_eq!(color.pixels[0], egui::Color32::from_rgba_premultiplied(0, 0, 255, 255));
assert_eq!(color.pixels[1], egui::Color32::from_rgba_premultiplied(255, 0, 0, 255));
let gray = super::to_color_image(&img, true);
assert_eq!(gray.pixels[0].r(), gray.pixels[0].g());
assert_eq!(gray.pixels[0].g(), gray.pixels[0].b());
assert_eq!(gray.pixels[1].r(), gray.pixels[1].g());
assert_eq!(gray.pixels[1].g(), gray.pixels[1].b());
}
#[test]
fn a_bad_scale_does_not_move_the_rect() {
let r = Rect::from_min_size(pos2(3.0, 4.0), vec2(10.0, 12.0));
+2 -3
View File
@@ -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 |
+2 -2
View File
@@ -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
View File
@@ -65,75 +65,12 @@ Local test: `python3 -m http.server 8765` inside the folder, then open http://lo
- The app fills the iframe and follows its size, so size the iframe and not the app.
- Keyboard shortcuts go to the iframe after the user clicks into it, as with any embedded app.
- **Cross-origin embeds** work. The web build keeps no preferences between visits; it starts
with the sample deck (or `?blank`) every time. Opening and saving through the page
(`?host=parent`, below) needs the same origin.
with the sample deck (or `?blank`) every time.
- **Sandboxed iframes** need at least
`sandbox="allow-scripts allow-same-origin allow-downloads allow-popups"`. Without `allow-downloads`, Save and Export (browser
downloads) are blocked. With `?host=parent`, `allow-scripts allow-same-origin` is enough for
opening and saving (without `allow-same-origin` the frame's origin is `null` and there is no
bridge), and `allow-downloads` isn't needed.
downloads) are blocked.
- Don't send `X-Frame-Options: DENY` or a `frame-ancestors` CSP that excludes the embedding page.
## Opening and saving through the page (`?host=parent`)
A page that keeps the files itself (a Nextcloud app, a file server) can frame DeckCraft with
`?host=parent` on the iframe `src`, hand it a presentation and take back what it saves, instead of
the user downloading. GridCraft, WordCraft and PdfCraft have the same bridge with their own prefix.
The page and DeckCraft must be on the **same origin**: DeckCraft only hears and answers its parent
window on its own origin, and without one it downloads as usual.
Messages are plain objects with a `type`:
| Message | Direction | Fields |
|---|---|---|
| `deckcraft:ready` | to the page | `version`. The app runs and listens: send the file now. |
| `deckcraft:open` | from the page | `name` (a bare file name, such as `Talk.pptx`), `bytes` (`ArrayBuffer` or `Uint8Array`). Opens like File › Open: a presentation (.pptx, .potx, .ppsx, .deckcraft) has that name as its file; an outline (.txt, .md, .rtf), a file sent without a name and a damaged file open untitled. |
| `deckcraft:save` | to the page | `name`, `saveAs`, `bytes` (a transferred `ArrayBuffer`), for every file DeckCraft would otherwise download. The page stores it. |
| `deckcraft:dirty` | to the page | `dirty`, whenever unsaved changes appear or go away. |
| `deckcraft:failed` | to the page | `error`, when the app couldn't start (no WebGPU or WebGL2). |
`saveAs` says whether the file becomes the presentation's file, as on the desktop:
- **Save** sends the file the page sent, under its own name and format (`Talk.pptx` comes back
as `Talk.pptx`), with `saveAs: false`. DeckCraft keeps only the part after the last `/`, so
`docs/Talk.pptx` also comes back as `Talk.pptx`. A presentation that has no file the page sent
(a new one, an outline, a file sent without a name, a file the user dropped, a repaired copy of
a damaged file) is a new file with `saveAs: true`: Ctrl+S names it `<title>.deckcraft` as Save
suggests on the desktop, and File › Save opens the Save As page first, as on the desktop.
- **Save As** (.pptx or .deckcraft) sends `saveAs: true`. The presentation is now that file; the
next Save sends its name with `saveAs: false`.
- **Exports and Print** (PDF, handouts, pictures, outline, GIF) send `saveAs: false` and are
copies: the presentation keeps its file and its unsaved changes.
`saveAs: false` doesn't mean "safe to overwrite": an export is `false` under a name the page may
already hold (`Talk.pdf`, or `Talk.pptx` exported from a presentation that is now
`Talk.deckcraft`). Replace a file without asking only when the name is the one the page sent, or
the name of the last `saveAs: true` file it stored for this presentation; ask for anything else.
DeckCraft doesn't learn the name the page finally stores a Save As under, so a page that renames
on a conflict should map later Saves of the old name itself.
In host mode DeckCraft starts with an empty presentation instead of the sample, and the first
presentation the page sends takes its place unless the user has changed it; later ones open
beside it. The page guards leaving itself (DeckCraft adds no guard of its own), and
`&author=Name` signs the user's comments.
```html
<iframe id="deck" src="/deckcraft/?host=parent&author=Ada" style="width: 100%; height: 720px; border: 0;"></iframe>
<script>
const frame = document.getElementById("deck");
addEventListener("message", async (e) => {
if (e.origin !== location.origin || e.source !== frame.contentWindow) return;
if (e.data.type === "deckcraft:ready") {
const bytes = await (await fetch("/files/Talk.pptx")).arrayBuffer();
frame.contentWindow.postMessage({ type: "deckcraft:open", name: "Talk.pptx", bytes }, location.origin, [bytes]);
} else if (e.data.type === "deckcraft:save") {
// Store e.data.bytes as e.data.name. Replace silently only when !e.data.saveAs and the name
// is the one sent (or the last saveAs file stored); ask before replacing anything else.
}
});
</script>
```
## Renderer selection and fallback flags
DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls back to
@@ -145,7 +82,6 @@ DeckCraft renders with wgpu. It uses **WebGPU** when the browser has it and fall
| `?webgl` | Force the WebGL2 backend (useful when a WebGPU driver misbehaves) |
| `?blank` | Start with an empty presentation instead of the sample deck |
| `?show` | Start the slide show immediately (kiosk/embedded decks) |
| `?host=parent` | Open and save through the framing page ([above](#opening-and-saving-through-the-page-hostparent)); `&author=` names the user |
For example: `<iframe src="https://example.com/deckcraft/?webgl" ...>`.
+1 -7
View File
@@ -18,13 +18,7 @@ fn walk(dir: &Path, c: &mut Counts) {
let p = e.path();
let name = e.file_name();
let name = name.to_string_lossy();
// `file_type` does not follow links, so a directory link is not walked (and aliases are
// not counted twice).
let Ok(kind) = e.file_type() else { continue };
if kind.is_symlink() {
continue;
}
if kind.is_dir() {
if p.is_dir() {
// Nested fuzz workspaces and build output are not part of the crate.
if name != "target" && name != "fuzz" && !name.starts_with('.') {
walk(&p, c);