diff --git a/Cargo.lock b/Cargo.lock
index 4c7e15203..c86df3540 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -984,7 +984,7 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "deckcraft"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"arboard",
"chrono",
@@ -1006,7 +1006,7 @@ dependencies = [
[[package]]
name = "deckcraft-anim"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-geom",
"deckcraft-model",
@@ -1015,7 +1015,7 @@ dependencies = [
[[package]]
name = "deckcraft-av1"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1024,14 +1024,14 @@ dependencies = [
[[package]]
name = "deckcraft-bitstream"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-cli"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-mcp",
@@ -1041,14 +1041,14 @@ dependencies = [
[[package]]
name = "deckcraft-color"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"serde",
]
[[package]]
name = "deckcraft-engine"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"chrono",
"deckcraft-anim",
@@ -1074,7 +1074,7 @@ dependencies = [
[[package]]
name = "deckcraft-fonts"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"epaint_default_fonts",
"harfrust 0.13.3",
@@ -1086,7 +1086,7 @@ dependencies = [
[[package]]
name = "deckcraft-format"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-model",
"serde",
@@ -1097,7 +1097,7 @@ dependencies = [
[[package]]
name = "deckcraft-geom"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"kurbo",
"serde",
@@ -1105,7 +1105,7 @@ dependencies = [
[[package]]
name = "deckcraft-h264"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1114,7 +1114,7 @@ dependencies = [
[[package]]
name = "deckcraft-hevc"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1123,21 +1123,21 @@ dependencies = [
[[package]]
name = "deckcraft-isobmff"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-matroska"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"craft-media",
]
[[package]]
name = "deckcraft-mcp"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-render",
@@ -1147,7 +1147,7 @@ dependencies = [
[[package]]
name = "deckcraft-media"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-av1",
"deckcraft-h264",
@@ -1165,7 +1165,7 @@ dependencies = [
[[package]]
name = "deckcraft-metafile"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"image",
"kurbo",
@@ -1174,7 +1174,7 @@ dependencies = [
[[package]]
name = "deckcraft-model"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-color",
"deckcraft-geom",
@@ -1186,15 +1186,15 @@ dependencies = [
[[package]]
name = "deckcraft-ogg"
-version = "0.13.1"
+version = "0.15.0"
[[package]]
name = "deckcraft-opus"
-version = "0.13.1"
+version = "0.15.0"
[[package]]
name = "deckcraft-pdf"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"craft-pdf",
"deckcraft-fonts",
@@ -1212,7 +1212,7 @@ dependencies = [
[[package]]
name = "deckcraft-pptx"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-color",
"deckcraft-engine",
@@ -1228,7 +1228,7 @@ dependencies = [
[[package]]
name = "deckcraft-render"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-anim",
"deckcraft-color",
@@ -1247,7 +1247,7 @@ dependencies = [
[[package]]
name = "deckcraft-text"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"chrono",
"deckcraft-color",
@@ -1261,7 +1261,7 @@ dependencies = [
[[package]]
name = "deckcraft-ui-egui"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"craft-ui",
"craft-update",
@@ -1286,7 +1286,7 @@ dependencies = [
[[package]]
name = "deckcraft-vp9"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-bitstream",
"rayon",
@@ -1295,7 +1295,7 @@ dependencies = [
[[package]]
name = "deckcraft-web"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"deckcraft-engine",
"deckcraft-ui-egui",
@@ -6177,7 +6177,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
[[package]]
name = "xtask"
-version = "0.13.1"
+version = "0.15.0"
dependencies = [
"serde_json",
]
diff --git a/Cargo.toml b/Cargo.toml
index 83d5d06bd..ab2b74291 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/deckcraft", "apps/deckcraft-cli"]
[workspace.package]
-version = "0.13.1"
+version = "0.15.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
diff --git a/README.md b/README.md
index 0cdfc5842..730165a8e 100644
--- a/README.md
+++ b/README.md
@@ -286,7 +286,15 @@ stand on its own.
|
| **PdfCraft** | Reading, organizing and protecting PDFs | [GitHub](https://github.com/storytold/pdfcraft) | [Website](https://getartcraft.com/apps/pdfcraft) |
|
| **EffectCraft** | Motion graphics and visual effects | [GitHub](https://github.com/storytold/effectcraft) | [Website](https://getartcraft.com/apps/effectcraft) |
|
| **DesignCraft** | Page layout and publishing | [GitHub](https://github.com/storytold/designcraft) | [Website](https://getartcraft.com/apps/designcraft) |
-|
| **DeckCraft** | **Presentations and slide shows · you are here** | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
+|
| **FigCraft** | Interface design and prototyping | [GitHub](https://github.com/storytold/figcraft) | [Website](https://getartcraft.com/apps/figcraft) |
+|
| **WordCraft** | Word processing and documents | [GitHub](https://github.com/storytold/wordcraft) | [Website](https://getartcraft.com/apps/wordcraft) |
+|
| **SoundCraft** | Audio recording and editing | [GitHub](https://github.com/storytold/soundcraft) | [Website](https://getartcraft.com/apps/soundcraft) |
+|
| **DeckCraft** | Presentations and slide decks | [GitHub](https://github.com/storytold/deckcraft) | [Website](https://getartcraft.com/apps/deckcraft) |
+|
| **CADCraft** | Computer-aided design | [GitHub](https://github.com/storytold/cadcraft) | [Website](https://getartcraft.com/apps/cadcraft) |
+|
| **GridCraft** | Spreadsheets and data | [GitHub](https://github.com/storytold/gridcraft) | [Website](https://getartcraft.com/apps/gridcraft) |
+|
| **TextureCraft** | 3D texture painting | [GitHub](https://github.com/storytold/texturecraft) | [Website](https://getartcraft.com/apps/texturecraft) |
+|
| **Craft Anything** | See how any software works, then build your own | [GitHub](https://github.com/storytold/craft-anything) | [Website](https://getartcraft.com/apps/craft-anything) |
+|
| **SolidCraft** | Parametric 3D CAD by Tangency Labs (community app) | [GitHub](https://github.com/tangency-labs/solidcraft) | |
And [**ArtCraft**](https://getartcraft.com/) itself, our AI image and video studio for artists who want real control.
diff --git a/ROADMAP.md b/ROADMAP.md
index f92554c14..9315be8a6 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -2,7 +2,9 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
-> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (print-preview tests use egui's emitted URL, and macOS removes its previews before exit; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (community PR wave 10: PPTX and native saves keep table cell centred anchoring, text overflow, compatible line spacing, normalised character height, Thai distributed alignment, RTL table direction, alternate run language and reflection transforms; imported effects placed by parent time-node delays; Reuse Slides fits the destination size and brings custom table styles; deleted slides leave custom shows; merged shapes drop their animations; Compress Pictures measures grouped pictures at displayed size; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **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)
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-26 · **Change:** trivial (progress log: dragging several selected slides moves them all; previous: trivial, progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option set the point type; previous: trivial, progress log: scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -148,6 +150,16 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
+| 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 |
+| 2026-11-02 | Deleting slides removes their IDs from every custom show; the cleanup is undoable and survives native save/reopen |
+| 2026-11-02 | Reused slides scale their geometry and content dimensions to the destination slide, including kept source masters and layouts; the reuse picker still lacks thumbnails |
+| 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences |
+| 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-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 |
| 2026-10-29 | Slide Show: going back to the previous slide plays the transition of the slide being left, reversed, as PowerPoint does, instead of the previous slide's own entrance transition (#1531); a jump (Go to Slide, links) still plays the target slide's transition |
@@ -318,6 +330,11 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
+| 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-02 | trivial | File › About replaces Account; product links, credits and appearance retained |
+| 2026-11-02 | trivial | Progress log: tiled picture fills keep their size in scaled groups. Scores and milestone names unchanged |
+| 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged |
| 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 |
| 2026-10-29 | trivial | Progress log: the Picture Styles row leaves room for Crop and Size (#1232). Scores and milestone names unchanged |
diff --git a/apps/deckcraft-web/Cargo.toml b/apps/deckcraft-web/Cargo.toml
index 7014421d8..541985e5b 100644
--- a/apps/deckcraft-web/Cargo.toml
+++ b/apps/deckcraft-web/Cargo.toml
@@ -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"] }
+web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "MessageEvent", "Event", "EventTarget"] }
diff --git a/apps/deckcraft-web/src/main.rs b/apps/deckcraft-web/src/main.rs
index 6d2b7e943..2ce622e9a 100644
--- a/apps/deckcraft-web/src/main.rs
+++ b/apps/deckcraft-web/src/main.rs
@@ -18,6 +18,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
+#[cfg(target_arch = "wasm32")]
+mod presenter;
#[cfg(target_arch = "wasm32")]
mod web;
diff --git a/apps/deckcraft-web/src/presenter.rs b/apps/deckcraft-web/src/presenter.rs
new file mode 100644
index 000000000..1ec9a298c
--- /dev/null
+++ b/apps/deckcraft-web/src/presenter.rs
@@ -0,0 +1,303 @@
+//! Same-origin popup transport. Each window starts its own eframe/WASM runner.
+use deckcraft_ui_egui::presenter::{Command, DeltaCache, Host, Snapshot, View};
+use std::{
+ cell::RefCell,
+ collections::VecDeque,
+ rc::{Rc, Weak},
+};
+use wasm_bindgen::{JsCast, closure::Closure};
+
+const MAX_MESSAGE: usize = 16 * 1024 * 1024;
+const MAX_COMMANDS: usize = 64;
+const PREFIX: &str = "#deckcraft-presenter=";
+fn error(e: wasm_bindgen::JsValue) -> String {
+ format!("{e:?}")
+}
+fn origin() -> Result {
+ web_sys::window().ok_or("No browser window")?.location().origin().map_err(error)
+}
+pub fn token() -> Option {
+ let hash = web_sys::window()?.location().hash().ok()?;
+ let token = hash.strip_prefix(PREFIX)?;
+ (!token.is_empty() && token.len() < 128).then(|| token.to_owned())
+}
+fn same_source(event: &web_sys::MessageEvent, window: &web_sys::Window, expected_origin: &str) -> bool {
+ event.origin() == expected_origin && event.source().is_some_and(|source| js_sys::Object::is(source.as_ref(), window.as_ref()))
+}
+fn send(window: &web_sys::Window, origin: &str, value: serde_json::Value) -> Result<(), String> {
+ let text = serde_json::to_string(&value).map_err(|e| e.to_string())?;
+ if text.len() > MAX_MESSAGE {
+ return Err("Presenter message is too large".into());
+ }
+ window.post_message(&wasm_bindgen::JsValue::from_str(&text), origin).map_err(error)
+}
+struct State {
+ popup: Option,
+ token: String,
+ commands: VecDeque,
+ ready: bool,
+ refresh_images: bool,
+}
+pub struct BrowserHost {
+ state: Rc>,
+ origin: String,
+ listener: Closure,
+ button: Option<(web_sys::HtmlElement, Closure)>,
+ images: DeltaCache,
+}
+impl BrowserHost {
+ pub fn new(ctx: egui::Context) -> Result {
+ let window = web_sys::window().ok_or("No browser window")?;
+ let origin = origin()?;
+ let state = Rc::new(RefCell::new(State { popup: None, token: String::new(), commands: VecDeque::new(), ready: false, refresh_images: true }));
+ let weak = Rc::downgrade(&state);
+ let expected = origin.clone();
+ let listener = Closure::new(move |event: web_sys::MessageEvent| {
+ let Some(state) = weak.upgrade() else { return };
+ let Ok(mut state) = state.try_borrow_mut() else { return };
+ let Some(popup) = state.popup.as_ref() else { return };
+ if !same_source(&event, popup, &expected) {
+ return;
+ }
+ let Some(text) = event.data().as_string().filter(|s| s.len() <= 8192) else { return };
+ let Ok(value) = serde_json::from_str::(&text) else { return };
+ if value.get("token").and_then(|v| v.as_str()) != Some(state.token.as_str()) {
+ return;
+ }
+ match value.get("kind").and_then(|v| v.as_str()) {
+ Some("ready") => {
+ state.ready = true;
+ state.refresh_images = true;
+ }
+ Some("command") if state.commands.len() < MAX_COMMANDS => {
+ if let Some(command) = value.get("command").and_then(|v| serde_json::from_value(v.clone()).ok()) {
+ state.commands.push_back(command);
+ }
+ }
+ _ => {}
+ }
+ ctx.request_repaint();
+ });
+ window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
+ Ok(Self { state, origin, listener, button: None, images: DeltaCache::default() })
+ }
+ fn remove_button(&mut self) {
+ if let Some((button, callback)) = self.button.take() {
+ let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
+ button.remove();
+ }
+ }
+ fn recovery_button(&mut self) {
+ if self.button.is_some() {
+ return;
+ }
+ let Some(document) = web_sys::window().and_then(|w| w.document()) else { return };
+ let Some(body) = document.body() else { return };
+ let Ok(element) = document.create_element("button") else { return };
+ let Ok(button) = element.dyn_into::() else { return };
+ button.set_text_content(Some("Open presenter window"));
+ let _ = button.set_attribute("style", "position:fixed;top:12px;right:12px;z-index:9999;padding:12px;color:white;background:#333;border:1px solid #aaa;border-radius:4px;cursor:pointer");
+ let weak: Weak> = Rc::downgrade(&self.state);
+ let button_copy = button.clone();
+ let callback = Closure::new(move |_event: web_sys::Event| {
+ if let Some(state) = weak.upgrade() {
+ match open_popup(&state) {
+ Ok(()) => {
+ button_copy.set_text_content(Some("Opening presenter window…"));
+ }
+ Err(_) => {
+ button_copy.set_text_content(Some("Popup blocked. Allow popups, then click to retry."));
+ }
+ }
+ }
+ });
+ if button.add_event_listener_with_callback("click", callback.as_ref().unchecked_ref()).is_ok() && body.append_child(&button).is_ok() {
+ self.button = Some((button, callback));
+ } else {
+ let _ = button.remove_event_listener_with_callback("click", callback.as_ref().unchecked_ref());
+ button.remove();
+ }
+ }
+}
+fn open_popup(state: &Rc>) -> Result<(), String> {
+ let window = web_sys::window().ok_or("No browser window")?;
+ let mut state = state.try_borrow_mut().map_err(|_| "Presenter is busy")?;
+ if let Some(popup) = &state.popup
+ && !popup.closed().unwrap_or(true)
+ {
+ let _ = popup.focus();
+ return Ok(());
+ }
+ let token = format!("{}-{}", js_sys::Date::now(), js_sys::Math::random());
+ let url = web_sys::Url::new(&window.location().href().map_err(error)?).map_err(error)?;
+ url.set_hash(&format!("{PREFIX}{token}"));
+ let popup = window
+ .open_with_url_and_target_and_features(&url.href(), "_blank", "popup,width=1100,height=700")
+ .map_err(error)?
+ .ok_or("Presenter popup was blocked. Use Open presenter window to retry.")?;
+ if popup.closed().unwrap_or(true) {
+ return Err("Browser isolation prevented connecting the presenter window".into());
+ }
+ state.popup = Some(popup);
+ state.token = token;
+ state.commands.clear();
+ state.ready = false;
+ Ok(())
+}
+impl Host for BrowserHost {
+ fn open(&mut self) -> Result<(), String> {
+ self.close();
+ let result = open_popup(&self.state);
+ if result.is_err() {
+ self.recovery_button();
+ }
+ result
+ }
+ fn is_open(&self) -> bool {
+ self.state.borrow().popup.as_ref().is_some_and(|w| !w.closed().unwrap_or(true))
+ }
+ fn commands(&mut self) -> Vec {
+ self.state.borrow_mut().commands.drain(..).collect()
+ }
+ fn update(&mut self, snapshot: Snapshot) {
+ if !self.is_open() {
+ self.recovery_button();
+ return;
+ }
+ self.remove_button();
+ let mut state = self.state.borrow_mut();
+ if !state.ready {
+ return;
+ }
+ if let Some(popup) = state.popup.clone() {
+ let token = state.token.clone();
+ let sent = self.images.send(snapshot, &token, state.refresh_images, |delta, current_changed, next_changed| {
+ send(
+ &popup,
+ &self.origin,
+ serde_json::json!({"kind":"snapshot", "token":token, "snapshot":delta,
+ "currentChanged":current_changed, "nextChanged":next_changed}),
+ )
+ });
+ if sent.is_ok() {
+ state.refresh_images = false;
+ }
+ }
+ }
+ fn close(&mut self) {
+ self.remove_button();
+ let mut state = self.state.borrow_mut();
+ if let Some(window) = state.popup.take() {
+ let _ = window.close();
+ }
+ state.commands.clear();
+ state.ready = false;
+ state.token.clear();
+ }
+}
+impl Drop for BrowserHost {
+ fn drop(&mut self) {
+ self.close();
+ if let Some(window) = web_sys::window() {
+ let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
+ }
+ }
+}
+
+pub struct Popup {
+ token: String,
+ origin: String,
+ opener: web_sys::Window,
+ snapshot: Rc>>,
+ last_received: Rc>,
+ listener: Closure,
+ view: View,
+}
+impl Popup {
+ pub fn new(token: String, ctx: egui::Context) -> Result {
+ let window = web_sys::window().ok_or("No browser window")?;
+ // `opener` is a WindowProxy from another realm; instanceof Window can fail across
+ // realms. The browser API supplies a WindowProxy or null, and messages still verify
+ // exact source identity and origin before any data is accepted.
+ let opener = window.opener().map_err(error)?;
+ if opener.is_null() || opener.is_undefined() {
+ return Err("The audience window is unavailable".into());
+ }
+ let opener: web_sys::Window = opener.unchecked_into();
+ let origin = origin()?;
+ let snapshot: Rc>> = Rc::new(RefCell::new(None));
+ let last_received = Rc::new(RefCell::new(js_sys::Date::now()));
+ let received = last_received.clone();
+ let target = snapshot.clone();
+ let expected_origin = origin.clone();
+ let expected_opener = opener.clone();
+ let expected_token = token.clone();
+ let listener = Closure::new(move |event: web_sys::MessageEvent| {
+ if !same_source(&event, &expected_opener, &expected_origin) {
+ return;
+ }
+ let Some(text) = event.data().as_string().filter(|s| s.len() <= MAX_MESSAGE) else { return };
+ let Ok(value) = serde_json::from_str::(&text) else { return };
+ if value.get("token").and_then(|v| v.as_str()) != Some(expected_token.as_str())
+ || value.get("kind").and_then(|v| v.as_str()) != Some("snapshot")
+ {
+ return;
+ }
+ if let Some(mut data) = value.get("snapshot").and_then(|v| serde_json::from_value::(v.clone()).ok()) {
+ if let Some(previous) = target.borrow().as_ref() {
+ if value.get("currentChanged").and_then(|v| v.as_bool()) == Some(false) {
+ data.current = previous.current.clone();
+ }
+ if value.get("nextChanged").and_then(|v| v.as_bool()) == Some(false) {
+ data.next = previous.next.clone();
+ }
+ }
+ *target.borrow_mut() = Some(data);
+ *received.borrow_mut() = js_sys::Date::now();
+ ctx.request_repaint();
+ }
+ });
+ window.add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()).map_err(error)?;
+ if let Some(document) = window.document() {
+ document.set_title("Presenter View — DeckCraft");
+ }
+ let popup = Self { token, origin, opener, snapshot, last_received, listener, view: View::default() };
+ send(&popup.opener, &popup.origin, serde_json::json!({"kind":"ready","token":popup.token}))?;
+ Ok(popup)
+ }
+}
+impl eframe::App for Popup {
+ fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
+ let snapshot = self.snapshot.borrow();
+ let notes = snapshot.as_ref().map_or("", |snapshot| snapshot.notes.as_str());
+ if !self.view.prepare(ui.ctx(), notes) {
+ return;
+ }
+ ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
+ if self.opener.closed().unwrap_or(true) {
+ ui.heading("The audience window has closed.");
+ return;
+ }
+ if js_sys::Date::now() - *self.last_received.borrow() > 10_000.0 {
+ ui.heading("The audience window is not responding.");
+ ui.label("Return to the audience window to resume the connection.");
+ return;
+ }
+ egui::CentralPanel::default().show(ui, |ui| {
+ if let Some(snapshot) = snapshot.as_ref() {
+ for command in self.view.ui(ui, snapshot) {
+ let _ = send(&self.opener, &self.origin, serde_json::json!({"kind":"command","token":self.token,"command":command}));
+ }
+ } else {
+ ui.label("Waiting for the audience window…");
+ }
+ });
+ }
+}
+impl Drop for Popup {
+ fn drop(&mut self) {
+ if let Some(window) = web_sys::window() {
+ let _ = window.remove_event_listener_with_callback("message", self.listener.as_ref().unchecked_ref());
+ }
+ }
+}
diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs
index f8e68a35c..a385d44c8 100644
--- a/apps/deckcraft-web/src/web.rs
+++ b/apps/deckcraft-web/src/web.rs
@@ -39,6 +39,10 @@ pub fn start() {
log::info!("deckcraft-web {}: wgpu backend {:?}", env!("CARGO_PKG_VERSION"), rs.adapter.get_info().backend);
}
deckcraft_ui_egui::install_ime_pass_hooks(&cc.egui_ctx);
+ if let Some(token) = crate::presenter::token() {
+ let presenter = crate::presenter::Popup::new(token, cc.egui_ctx.clone()).map_err(std::io::Error::other)?;
+ return Ok(Box::new(presenter) as Box);
+ }
let inbox: Inbox = Inbox::default();
let mut app = SlideApp::new(Session::new(), services(inbox.clone(), cc.egui_ctx.clone()));
let q = query();
@@ -80,6 +84,11 @@ impl eframe::App for WebShell {
/// Delegate one eframe logic pass to the wrapped application.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.app.logic(ctx);
+ if self.app.show.is_none()
+ && let Some(host) = self.app.services.presenter.as_mut()
+ {
+ host.close();
+ }
}
/// Consume browser drops with fresh input, capture the identity before the asynchronous read,
@@ -118,7 +127,15 @@ impl eframe::App for WebShell {
/// back to `open_async` (browser picker → inbox) and `download`.
fn services(inbox: Inbox, ctx: egui::Context) -> Services {
let open_inbox = inbox.clone();
+ let presenter = match crate::presenter::BrowserHost::new(ctx.clone()) {
+ Ok(host) => Some(Box::new(host) as Box),
+ Err(error) => {
+ log::error!("Presenter window: {error}");
+ None
+ }
+ };
Services {
+ presenter,
open_async: Some(Box::new(move |purpose: &str, request: u64| {
let inbox = open_inbox.clone();
let ctx = ctx.clone();
diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs
index a869ebdb3..be41aeacb 100644
--- a/crates/anim/src/morph.rs
+++ b/crates/anim/src/morph.rs
@@ -1,5 +1,7 @@
//! Morph: matching shapes between two slides, interpolating their boxes and building the frames.
+use std::collections::{HashMap, HashSet, VecDeque};
+
use deckcraft_model::resolve::{self, Ctx};
use deckcraft_model::{ColorRef, Fill, Geom, Presentation, Rgba, Shape, ShapeId, ShapeKind, Slide, Xfrm, walk};
@@ -15,48 +17,66 @@ fn text_of(s: &Shape) -> String {
/// kind and preset geometry if neither carries text. A text box with neither a name nor its text on
/// the other slide stays unpaired and fades in place, as in PowerPoint (#119). Each shape is used
/// once; top-level shapes only (groups move whole).
+///
+/// Each pass compares a key of the two shapes, so the shapes of `b` are indexed by key once per
+/// pass: matching costs the sum of the two shape lists, not their product.
pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
let mut pairs: Vec<(ShapeId, ShapeId)> = Vec::new();
let mut used_a = vec![false; a.shapes.len()];
let mut used_b = vec![false; b.shapes.len()];
- let passes: [&dyn Fn(&Shape, &Shape) -> bool; 4] = [
- &|x, y| x.name.starts_with("!!") && x.name == y.name,
- &|x, y| !x.name.is_empty() && x.name == y.name,
- &|x, y| {
- if x.kind_name() != y.kind_name() {
- return false;
+ // Each shape's text, worked out once rather than inside every comparison.
+ let text_a: Vec = a.shapes.iter().map(text_of).collect();
+ let text_b: Vec = b.shapes.iter().map(text_of).collect();
+ let forced = |s: &Shape, _: &str| (s.name.starts_with("!!")).then(|| MorphKey::Name(s.name.clone()));
+ let named = |s: &Shape, _: &str| (!s.name.is_empty()).then(|| MorphKey::Name(s.name.clone()));
+ let same_text = |s: &Shape, t: &str| (!t.trim().is_empty()).then(|| MorphKey::Text(s.kind_name(), t.to_string()));
+ let same_geom = |s: &Shape, t: &str| match s.geom.preset_name() {
+ Some(g) if t.trim().is_empty() => Some(MorphKey::Geom(s.kind_name(), g.to_string())),
+ _ => None,
+ };
+ let passes: [&dyn Fn(&Shape, &str) -> Option; 4] = [&forced, &named, &same_text, &same_geom];
+ for key in passes {
+ // Unused shapes of `b` by key, in slide order: the front of each queue is the first unused
+ // match, which is the one a search through `b` would find.
+ let mut index: HashMap> = HashMap::new();
+ for (j, sb) in b.shapes.iter().enumerate() {
+ if used_b.get(j).copied().unwrap_or(true) {
+ continue;
}
- let tx = text_of(x);
- !tx.trim().is_empty() && tx == text_of(y)
- },
- &|x, y| {
- x.kind_name() == y.kind_name()
- && x.geom.preset_name().is_some()
- && x.geom.preset_name() == y.geom.preset_name()
- && text_of(x).trim().is_empty()
- && text_of(y).trim().is_empty()
- },
- ];
- for pass in passes {
+ if let Some(k) = key(sb, text_b.get(j).map_or("", String::as_str)) {
+ index.entry(k).or_default().push_back(j);
+ }
+ }
+ if index.is_empty() {
+ continue;
+ }
for (i, sa) in a.shapes.iter().enumerate() {
if used_a.get(i).copied().unwrap_or(true) {
continue;
}
- let found = b.shapes.iter().enumerate().find(|(j, sb)| !used_b.get(*j).copied().unwrap_or(true) && pass(sa, sb));
- if let Some((j, sb)) = found {
- if let Some(u) = used_a.get_mut(i) {
- *u = true;
- }
- if let Some(u) = used_b.get_mut(j) {
- *u = true;
- }
- pairs.push((sa.id, sb.id));
+ let Some(k) = key(sa, text_a.get(i).map_or("", String::as_str)) else { continue };
+ let Some(j) = index.get_mut(&k).and_then(VecDeque::pop_front) else { continue };
+ let Some(sb) = b.shapes.get(j) else { continue };
+ if let Some(u) = used_a.get_mut(i) {
+ *u = true;
}
+ if let Some(u) = used_b.get_mut(j) {
+ *u = true;
+ }
+ pairs.push((sa.id, sb.id));
}
}
pairs
}
+/// What one [`morph_pairs`] pass matches shapes on.
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+enum MorphKey {
+ Name(String),
+ Text(&'static str, String),
+ Geom(&'static str, String),
+}
+
fn lerp(a: f64, b: f64, t: f64) -> f64 {
let v = a + (b - a) * t;
if v.is_finite() {
@@ -256,13 +276,23 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
f.shapes.push((sh, old));
id
};
+ // Lookups by id, built once (the first shape or pair with an id wins, as a search would).
+ let paired_a: HashSet = pairs.iter().map(|(ia, _)| *ia).collect();
+ let mut partner: HashMap = HashMap::with_capacity(pairs.len());
+ for (ia, ib) in &pairs {
+ partner.entry(*ib).or_insert(*ia);
+ }
+ let mut a_by_id: HashMap = HashMap::with_capacity(a.shapes.len());
+ for x in &a.shapes {
+ a_by_id.entry(x.id).or_insert(x);
+ }
for sa in &a.shapes {
- if !pairs.iter().any(|(ia, _)| *ia == sa.id) {
+ if !paired_a.contains(&sa.id) {
push(sa.clone(), true, 1.0 - s);
}
}
for sb in &b.shapes {
- let pair = pairs.iter().find(|(_, ib)| *ib == sb.id).and_then(|(ia, _)| a.shapes.iter().find(|x| x.id == *ia));
+ let pair = partner.get(&sb.id).and_then(|ia| a_by_id.get(ia).copied());
let Some(sa) = pair else {
if !hidden_b(sb.id) {
push(sb.clone(), false, s);
diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs
index c98ac22b8..385520b6d 100644
--- a/crates/anim/src/tests.rs
+++ b/crates/anim/src/tests.rs
@@ -1155,3 +1155,58 @@ fn many_by_paragraph_animations_on_one_shape_share_paragraph_targets() {
assert_eq!(tl.effects().len(), 6_000);
assert_eq!(tl.paragraph_targets(ShapeId(1)), vec![0, 1, 2]);
}
+
+#[test]
+fn morph_pairs_take_the_first_unused_match_in_slide_order() {
+ let mut a = Slide::default();
+ let mut b = Slide::default();
+ a.shapes = vec![shape(1, "Same"), shape(2, "Same"), shape(3, "Other")];
+ b.shapes = vec![shape(11, "Same"), shape(12, "Same"), shape(13, "Same")];
+ // By name first, in slide order; the shape of `a` left over pairs by kind and geometry with
+ // the shape of `b` left over.
+ assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(11)), (ShapeId(2), ShapeId(12)), (ShapeId(3), ShapeId(13))]);
+}
+
+#[test]
+fn morph_of_large_slides_stays_linear() {
+ let n = 20_000u32;
+ let text = |i: u32, side: &str| Some(TextBody::from_text(&format!("{side} {i}")));
+ let mut a = Slide::default();
+ let mut b = Slide::default();
+ // Text no shape on the other slide shares: every pass looks for a partner and finds none.
+ a.shapes = (0..n).map(|i| Shape { text: text(i, "a"), ..shape(i + 1, "") }).collect();
+ b.shapes = (0..n).map(|i| Shape { text: text(i, "b"), ..shape(n + i + 1, "") }).collect();
+ // One named pair at opposite ends.
+ a.shapes[0].name = "Logo".into();
+ b.shapes[n as usize - 1].name = "Logo".into();
+ // No wall-clock bound (it would flake on a loaded runner): at this size a quadratic pairing
+ // takes minutes, so a regression shows as a test that hangs rather than a failed assertion.
+ assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2 * n))]);
+ let p = deckcraft_model::defaults::new_presentation(None);
+ let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
+ // Every shape fades in place; the named pair differs in text, so it cross-fades as two.
+ assert_eq!(f.shapes.len(), 2 * n as usize);
+}
+
+#[test]
+fn paragraph_states_of_a_large_build_stay_linear() {
+ let n = 20_000usize;
+ let mut s = slide_with(vec![]);
+ let mut body = TextBody::from_text(&vec!["line"; n].join("\n"));
+ // One first-level paragraph owning every other paragraph.
+ for p in body.paragraphs.iter_mut().skip(1) {
+ p.level = 1;
+ }
+ s.shapes[0].text = Some(body);
+ let mut a = anim(1, AnimClass::Entrance, "fade", AnimStart::OnClick);
+ a.text_build = TextBuild::ByParagraph;
+ s.animations = vec![a];
+ let tl = Timeline::new(&s, W, H);
+ // No wall-clock bound, as above: a per-paragraph scan of the slide would take minutes here.
+ assert_eq!(tl.paragraph_targets(ShapeId(1)).len(), n);
+ for p in 0..n {
+ assert!(!tl.state(ShapeId(1), Some(p), 0, 0.0).visible);
+ assert!(tl.state(ShapeId(1), Some(p), 1, 0.0).visible);
+ }
+ assert_eq!(tl.animated_shapes(), [ShapeId(1)]);
+}
diff --git a/crates/anim/src/timeline.rs b/crates/anim/src/timeline.rs
index c995f2eaa..5784f4ea8 100644
--- a/crates/anim/src/timeline.rs
+++ b/crates/anim/src/timeline.rs
@@ -103,6 +103,41 @@ pub struct Timeline {
/// By-paragraph builds animate first-level paragraphs; deeper paragraphs follow their parent:
/// (shape, member paragraph, owner paragraph).
groups: Vec<(ShapeId, usize, usize)>,
+ /// Lookups built with the timeline, so evaluating a shape or paragraph costs its own effects
+ /// rather than a scan of every effect, group and colour on the slide.
+ index: TimelineIndex,
+}
+
+#[derive(Clone, Debug, Default)]
+struct TimelineIndex {
+ /// Effects (indices into `effects`, in order) by target shape and paragraph.
+ by_target: HashMap<(ShapeId, Option), Vec>,
+ /// Effects (indices into `effects`, in order) by target shape.
+ by_shape: HashMap>,
+ /// Owner paragraph of each member paragraph (the first group recorded wins).
+ owner: HashMap<(ShapeId, usize), usize>,
+ /// Groups (member, owner) by shape, in order.
+ groups_by_shape: HashMap>,
+ /// Base colour by shape (the first recorded wins).
+ color: HashMap,
+}
+
+impl TimelineIndex {
+ fn build(effects: &[Effect], groups: &[(ShapeId, usize, usize)], colors: &[(ShapeId, Rgba)]) -> TimelineIndex {
+ let mut ix = TimelineIndex::default();
+ for (i, e) in effects.iter().enumerate() {
+ ix.by_target.entry((e.timing.shape, e.timing.paragraph)).or_default().push(i);
+ ix.by_shape.entry(e.timing.shape).or_default().push(i);
+ }
+ for &(shape, member, owner) in groups {
+ ix.owner.entry((shape, member)).or_insert(owner);
+ ix.groups_by_shape.entry(shape).or_default().push((member, owner));
+ }
+ for &(shape, c) in colors {
+ ix.color.entry(shape).or_insert(c);
+ }
+ ix
+ }
}
enum Status {
@@ -324,6 +359,7 @@ impl Timeline {
};
tl.colors.push((id, c));
}
+ tl.index = TimelineIndex::build(&tl.effects, &tl.groups, &tl.colors);
tl
}
@@ -413,8 +449,12 @@ impl Timeline {
/// Paragraph indices of `shape` animated separately (by-paragraph builds).
pub fn paragraph_targets(&self, shape: ShapeId) -> Vec {
- let mut v: Vec = self.effects.iter().filter(|e| e.timing.shape == shape).filter_map(|e| e.timing.paragraph).collect();
- v.extend(self.groups.iter().filter(|g| g.0 == shape && v.contains(&g.2)).map(|g| g.1).collect::>());
+ let mine = self.index.by_shape.get(&shape).map_or(&[][..], Vec::as_slice);
+ let mut v: Vec = mine.iter().filter_map(|&i| self.effects.get(i)).filter_map(|e| e.timing.paragraph).collect();
+ let owners: HashSet = v.iter().copied().collect();
+ if let Some(groups) = self.index.groups_by_shape.get(&shape) {
+ v.extend(groups.iter().filter(|(_, owner)| owners.contains(owner)).map(|(member, _)| *member));
+ }
v.sort_unstable();
v.dedup();
v
@@ -422,13 +462,8 @@ impl Timeline {
/// Shapes with any effect.
pub fn animated_shapes(&self) -> Vec {
- let mut v: Vec = Vec::new();
- for e in &self.effects {
- if !v.contains(&e.timing.shape) {
- v.push(e.timing.shape);
- }
- }
- v
+ let mut seen: HashSet = HashSet::new();
+ self.effects.iter().map(|e| e.timing.shape).filter(|s| seen.insert(*s)).collect()
}
/// State of `shape` (or of one of its paragraphs, `para`) when `step` main steps have been
@@ -456,9 +491,9 @@ impl Timeline {
triggers: &[TriggerPlay],
stamps: &[StepStamp],
) -> AnimState {
- let para = para.map(|p| self.groups.iter().find(|g| g.0 == shape && g.1 == p).map(|g| g.2).unwrap_or(p));
- let mine = self.effects.iter().filter(|e| e.timing.shape == shape && e.timing.paragraph == para);
- let base_color = self.colors.iter().find(|(s, _)| *s == shape).map(|(_, c)| *c).unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
+ let para = para.map(|p| self.index.owner.get(&(shape, p)).copied().unwrap_or(p));
+ let mine = self.index.by_target.get(&(shape, para)).map_or(&[][..], Vec::as_slice).iter().filter_map(|&i| self.effects.get(i));
+ let base_color = self.index.color.get(&shape).copied().unwrap_or(Rgba::rgb(0x15, 0x60, 0x82));
let mut st = AnimState::default();
// Initial visibility comes from the first effect in the Animation Pane order.
let mut ordered: Vec<&Effect> = mine.collect();
diff --git a/crates/av1/src/decoder.rs b/crates/av1/src/decoder.rs
index 43abce22b..d463493a1 100644
--- a/crates/av1/src/decoder.rs
+++ b/crates/av1/src/decoder.rs
@@ -425,6 +425,8 @@ impl Decoder {
fn parse_obus(&mut self, data: &[u8], out: &mut Vec<(Output, usize, u32)>) -> Result<()> {
let mut tu = 0usize;
+ // Repeated-frame outputs in `out`, counted as they are added rather than recounted per OBU.
+ let mut existing = out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count();
let mut pos = 0;
while pos < data.len() {
let obu = parse_obu_unit(data, pos)?;
@@ -444,9 +446,10 @@ impl Decoder {
}
OBU_FRAME_HEADER_T | OBU_REDUNDANT_FRAME_HEADER_T | OBU_FRAME_T => {
if let Some(o) = self.frame_obu(&obu)? {
- if out.iter().filter(|(o, _, _)| matches!(o, Output::Existing { .. })).count() >= 32 {
+ if existing >= 32 {
return Err(Error::Invalid("too many frame outputs in chunk"));
}
+ existing += usize::from(matches!(o, Output::Existing { .. }));
out.push((o, tu, spatial_id));
}
}
diff --git a/crates/engine/src/cmd/compress.rs b/crates/engine/src/cmd/compress.rs
index 5708efbb3..1f795967f 100644
--- a/crates/engine/src/cmd/compress.rs
+++ b/crates/engine/src/cmd/compress.rs
@@ -116,16 +116,31 @@ fn pack(data: &[u8], crop: [f64; 4], w_pt: f64, h_pt: f64, ppi: f64, delete_crop
Some(Packed { bytes, content_type, crop: new_crop })
}
-/// Picture shapes in `shapes` (groups included) that `want` accepts.
-fn pictures_mut<'a>(shapes: &'a mut [Shape], want: &dyn Fn(ShapeId) -> bool, out: &mut Vec<&'a mut Shape>, depth: usize) {
+/// Pictures accepted by `want`, with width and height measured in slide space through group transforms.
+fn pictures_mut<'a>(
+ shapes: &'a mut [Shape],
+ want: &dyn Fn(ShapeId) -> bool,
+ out: &mut Vec<(&'a mut Shape, f64, f64)>,
+ to_slide: deckcraft_geom::Affine,
+ depth: usize,
+) {
if depth > 64 {
return;
}
for sh in shapes {
- if matches!(sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
- out.push(sh);
- } else if let Some(ch) = sh.children_mut() {
- pictures_mut(ch, want, out, depth + 1);
+ let Some(xfrm) = sh.xfrm else { continue };
+ let local_to_slide = to_slide * xfrm.affine();
+ if matches!(&sh.kind, ShapeKind::Picture { .. }) && want(sh.id) {
+ let origin = local_to_slide * deckcraft_geom::Point::ZERO;
+ let across = local_to_slide * deckcraft_geom::Point::new(xfrm.w, 0.0);
+ let down = local_to_slide * deckcraft_geom::Point::new(0.0, xfrm.h);
+ out.push((sh, (across - origin).hypot(), (down - origin).hypot()));
+ continue;
+ }
+ if let ShapeKind::Group { children, child } = &mut sh.kind {
+ let child_rect = deckcraft_geom::Rect::new(child.x, child.y, child.x + child.w, child.y + child.h);
+ let child_to_slide = to_slide * deckcraft_geom::group_child_affine(&xfrm, child_rect);
+ pictures_mut(children, want, out, child_to_slide, depth + 1);
}
}
}
@@ -155,10 +170,9 @@ fn compress(s: &mut Session, p: &Value) -> Result {
let Some(slide) = doc.slides.get(i).cloned() else { continue };
let mut slide = slide.as_ref().clone();
let mut pics = vec![];
- pictures_mut(&mut slide.shapes, &want, &mut pics, 0);
+ pictures_mut(&mut slide.shapes, &want, &mut pics, deckcraft_geom::Affine::IDENTITY, 0);
let mut touched = false;
- for sh in pics {
- let (w, h) = sh.xfrm.map(|x| (x.w, x.h)).unwrap_or((0.0, 0.0));
+ for (sh, w, h) in pics {
let ShapeKind::Picture { fill } = &mut sh.kind else { continue };
let PictureFill { media, crop, .. } = fill.clone();
let key = (media.0, crop.map(f64::to_bits), w.to_bits(), h.to_bits());
@@ -232,6 +246,114 @@ mod tests {
assert!(pack(b"", [0.0; 4], 72.0, 72.0, 150.0, true).is_none());
}
+ #[test]
+ fn grouped_pictures_use_their_displayed_size() {
+ let mut s = Session::with_new();
+ let original = png(600, 600);
+ s.edit(|doc, _| {
+ let media = doc.add_media("grouped.png", "image/png", original.clone()).ok_or_else(|| bad("test", "media"))?;
+ let picture = Shape {
+ id: ShapeId(70_101),
+ xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
+ kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
+ ..Default::default()
+ };
+ let nested = Shape {
+ id: ShapeId(70_102),
+ xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
+ kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
+ ..Default::default()
+ };
+ let group = Shape {
+ id: ShapeId(70_103),
+ xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 720.0)),
+ kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
+ ..Default::default()
+ };
+ let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
+ Arc::make_mut(slide).shapes.push(group);
+ Ok(())
+ })
+ .unwrap();
+
+ let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
+ assert_eq!(result["pictures"], 0, "a 600 px image displayed at 720×720 pt is already below the requested sampling density");
+ assert!(
+ s.doc().unwrap().doc.media.iter().any(|m| m.data.as_slice() == original.as_slice()),
+ "the under-target original media must remain untouched"
+ );
+ }
+
+ #[test]
+ fn anisotropic_nested_group_scaling_controls_sampling_dimensions() {
+ let mut s = Session::with_new();
+ let original = png(2000, 1000);
+ s.edit(|doc, _| {
+ let media = doc.add_media("scaled-group.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
+ let picture = Shape {
+ id: ShapeId(70_111),
+ xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
+ kind: ShapeKind::Picture { fill: PictureFill { media, ..Default::default() } },
+ ..Default::default()
+ };
+ let nested = Shape {
+ id: ShapeId(70_112),
+ xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
+ kind: ShapeKind::Group { children: vec![picture], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
+ ..Default::default()
+ };
+ let group = Shape {
+ id: ShapeId(70_113),
+ xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 720.0, 360.0)),
+ kind: ShapeKind::Group { children: vec![nested], child: deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0) },
+ ..Default::default()
+ };
+ let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
+ Arc::make_mut(slide).shapes.push(group);
+ Ok(())
+ })
+ .unwrap();
+
+ let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": false})).unwrap();
+ assert_eq!(result["pictures"], 1);
+ let slide = s.doc().unwrap().doc.slides.first().unwrap();
+ let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_113)).unwrap();
+ let ShapeKind::Group { children, .. } = &picture.kind else { panic!("outer group expected") };
+ let ShapeKind::Group { children, .. } = &children[0].kind else { panic!("nested group expected") };
+ let ShapeKind::Picture { fill } = &children[0].kind else { panic!("picture expected") };
+ let media = s.doc().unwrap().doc.media(fill.media).unwrap();
+ let image = image::load_from_memory(&media.data).unwrap();
+ assert_eq!((image.width(), image.height()), (960, 480));
+ }
+
+ #[test]
+ fn ungrouped_picture_still_compresses_and_deletes_its_crop() {
+ let mut s = Session::with_new();
+ let original = png(800, 800);
+ s.edit(|doc, _| {
+ let media = doc.add_media("cropped.png", "image/png", original).ok_or_else(|| bad("test", "media"))?;
+ let slide = doc.slides.first_mut().ok_or_else(|| bad("test", "slide"))?;
+ Arc::make_mut(slide).shapes.push(Shape {
+ id: ShapeId(70_104),
+ xfrm: Some(deckcraft_geom::Xfrm::new(0.0, 0.0, 72.0, 72.0)),
+ kind: ShapeKind::Picture { fill: PictureFill { media, crop: [0.5, 0.0, 0.0, 0.0], ..Default::default() } },
+ ..Default::default()
+ });
+ Ok(())
+ })
+ .unwrap();
+
+ let result = s.execute("picture.compress", &json!({"all": true, "ppi": 96, "deleteCropped": true})).unwrap();
+ assert_eq!(result["pictures"], 1);
+ let slide = s.doc().unwrap().doc.slides.first().unwrap();
+ let picture = slide.shapes.iter().find(|sh| sh.id == ShapeId(70_104)).unwrap();
+ let ShapeKind::Picture { fill } = &picture.kind else { panic!("picture expected") };
+ assert_eq!(fill.crop, [0.0; 4]);
+ let media = s.doc().unwrap().doc.media(fill.media).unwrap();
+ let image = image::load_from_memory(&media.data).unwrap();
+ assert_eq!((image.width(), image.height()), (96, 192));
+ }
+
#[test]
fn pack_deletes_cropped_areas_or_keeps_them() {
let src = png(400, 400);
diff --git a/crates/engine/src/cmd/design/master_view.rs b/crates/engine/src/cmd/design/master_view.rs
index 5dfe04964..c03bfa574 100644
--- a/crates/engine/src/cmd/design/master_view.rs
+++ b/crates/engine/src/cmd/design/master_view.rs
@@ -1,17 +1,17 @@
use super::{
Arc, Fill, NO_ID_SPACE, PhType, Result, Run, RunKind, Session, Shape, ShapeId, Size, Target, TextBody, Value, bad, bool_param, json,
- master_index, ok, opt_usize_param, rect_param, str_param, usize_param,
+ master_index, ok, opt_usize_param, rect_param, str_param,
};
pub(super) fn master_view(s: &mut Session, p: &Value) -> Result {
let st = s.doc()?;
- let mi = usize_param(p, "master").unwrap_or_else(|| master_index(&st.doc, &st.selection));
+ let mi = opt_usize_param(p, "master", "view.slideMaster")?.unwrap_or_else(|| master_index(&st.doc, &st.selection));
// A master thumbnail sends `{master}` and means the master itself. `{master, layout}` selects
// that layout. With neither key, follow the current slide's layout.
let layout = if p.get("master").is_some() && p.get("layout").is_none() {
None
} else {
- usize_param(p, "layout")
+ opt_usize_param(p, "layout", "view.slideMaster")?
.or_else(|| st.current_slide().and_then(|sl| st.doc.masters.get(mi).and_then(|m| m.layouts.iter().position(|l| l.id == sl.layout))))
};
let target = match layout {
diff --git a/crates/engine/src/cmd/design/slide_background.rs b/crates/engine/src/cmd/design/slide_background.rs
index 0de78e7e3..494b53be8 100644
--- a/crates/engine/src/cmd/design/slide_background.rs
+++ b/crates/engine/src/cmd/design/slide_background.rs
@@ -1,12 +1,14 @@
use super::{
Arc, BTreeSet, Background, Fill, NO_ID_SPACE, Presentation, Result, Session, Target, Value, bad, bool_or, bool_param, color_param, color_value,
- f64_param, ok, usize_param, with_param,
+ f64_param, ok, opt_usize_param, usize_param, with_param,
};
pub(super) fn background(s: &mut Session, p: &Value) -> Result {
if bool_or(p, "copy", false) {
return copy_slide_background(s);
}
+ // Check the index before anything is edited: a picture is added to the media first.
+ let index = opt_usize_param(p, "index", "design.background")?;
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
let transparency = f64_param(p, "transparency");
let reset = bool_or(p, "reset", false);
@@ -62,7 +64,6 @@ pub(super) fn background(s: &mut Session, p: &Value) -> Result {
));
};
let all = bool_or(p, "all", false);
- let index = usize_param(p, "index");
if matches!(op, BgOp::Adjust) {
let next = {
let st = s.doc()?;
diff --git a/crates/engine/src/cmd/design/tests.rs b/crates/engine/src/cmd/design/tests.rs
index 9765fcc8f..82eed6feb 100644
--- a/crates/engine/src/cmd/design/tests.rs
+++ b/crates/engine/src/cmd/design/tests.rs
@@ -485,3 +485,16 @@ fn background_all_clears_layout_overrides_even_if_slides_and_master_match() {
s.execute("design.background", &json!({"color": "#AA0000", "all": true})).unwrap();
assert_eq!(undo_len(&s), n + 1, "now it is truly a no-op");
}
+
+#[test]
+fn background_rejects_invalid_index() {
+ let mut s = Session::with_new();
+ s.execute("slide.new", &json!({})).unwrap();
+ s.execute("slide.go", &json!({"index": 0})).unwrap();
+ let before = s.doc().unwrap().doc.slides[0].background.clone();
+ for bad in [json!(-1), json!(1.5)] {
+ assert!(s.execute("design.background", &json!({"color": "#0000C8", "index": bad})).is_err());
+ assert!(s.execute("design.background", &json!({"transparency": 0.5, "index": bad})).is_err());
+ assert_eq!(s.doc().unwrap().doc.slides[0].background, before);
+ }
+}
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index 0d52e6358..4f8580c86 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -345,6 +345,7 @@ pub fn save_bytes(doc: &Presentation, format: &str) -> Result> {
"rtf" => Ok(deckcraft_format::rtf::slides_to_rtf(doc).into_bytes()),
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
"gif" => gif_bytes(doc, &Value::Null),
+ "png" | "jpeg" | "jpg" => Err(EngineError::Other("saving a presentation as a single image isn't supported; use file.export".into())),
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
}
}
@@ -359,7 +360,13 @@ pub fn pdf_options(p: &Value, count: usize) -> Result
let mut o = deckcraft_pdf::PdfOptions { layout: deckcraft_pdf::PageLayout::Slides, ..Default::default() };
o.layout = match str_param(p, "layout").unwrap_or("slides") {
"notes" => deckcraft_pdf::PageLayout::Notes,
- "handouts" => deckcraft_pdf::PageLayout::Handouts { per_page: usize_param(p, "perPage").unwrap_or(6).clamp(1, 9) as u8 },
+ "handouts" => {
+ let per_page = opt_usize_param(p, "perPage", "file.export")?.unwrap_or(6);
+ if ![1, 2, 3, 4, 6, 9].contains(&per_page) {
+ return Err(bad("file.export", format!("perPage {per_page} isn't one of 1, 2, 3, 4, 6, 9")));
+ }
+ deckcraft_pdf::PageLayout::Handouts { per_page: per_page as u8 }
+ }
_ => deckcraft_pdf::PageLayout::Slides,
};
o.dpi = f64_or(p, "dpi", o.dpi).clamp(36.0, 600.0);
@@ -390,6 +397,7 @@ pub fn pdf_options(p: &Value, count: usize) -> Result
pub fn parse_slide_range(text: &str, count: usize) -> Result> {
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 = vec![];
+ let mut seen: std::collections::HashSet = 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()),
@@ -405,7 +413,7 @@ pub fn parse_slide_range(text: &str, count: usize) -> Result> {
return Err(bad("file.export", format!("the presentation has {count} slides; “{part}” is outside them")));
}
for i in lo - 1..hi {
- if !out.contains(&i) {
+ if seen.insert(i) {
out.push(i);
}
}
@@ -769,6 +777,16 @@ 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());
diff --git a/crates/engine/src/cmd/format/paragraph.rs b/crates/engine/src/cmd/format/paragraph.rs
index f036068f6..e4b02a4b9 100644
--- a/crates/engine/src/cmd/format/paragraph.rs
+++ b/crates/engine/src/cmd/format/paragraph.rs
@@ -14,6 +14,7 @@ pub(super) fn align(s: &mut Session, p: &Value) -> Result {
"justify" | "just" => Align::Justify,
"justifyLow" | "justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
+ "thaiDist" => Align::ThaiDistributed,
_ => Align::Left,
};
align_to(s, a)
@@ -55,6 +56,7 @@ pub(super) fn bullets(s: &mut Session, p: &Value) -> Result {
}
if size.is_some() {
para.props.bullet_size = size;
+ para.props.bullet_size_points = None;
para.props.bullet_size_follow_text = None;
}
if para.props.indent.is_none_or(|i| i >= 0.0) && para.props.margin_left.is_none_or(|m| m == 0.0) {
@@ -85,6 +87,7 @@ pub(super) fn numbering(s: &mut Session, p: &Value) -> Result {
}
if size.is_some() {
para.props.bullet_size = size;
+ para.props.bullet_size_points = None;
para.props.bullet_size_follow_text = None;
}
para.props.margin_left = Some(27.0 + 36.0 * para.level as f64);
@@ -184,7 +187,8 @@ fn parse_align(cmd: &str, s: &str) -> Result {
"justify" | "just" => Align::Justify,
"justLow" => Align::JustLow,
"distributed" | "dist" => Align::Distributed,
- _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow or distributed")),
+ "thaiDist" => Align::ThaiDistributed,
+ _ => return Err(bad(cmd, "`align` must be left, center, right, justify, justLow, distributed or thaiDist")),
})
}
@@ -196,6 +200,7 @@ fn align_name(a: Align) -> &'static str {
Align::Justify => "justify",
Align::JustLow => "justLow",
Align::Distributed => "distributed",
+ Align::ThaiDistributed => "thaiDist",
}
}
diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs
index 7db4a9e33..f67a2ae49 100644
--- a/crates/engine/src/cmd/merge.rs
+++ b/crates/engine/src/cmd/merge.rs
@@ -246,8 +246,17 @@ fn merge(s: &mut Session, p: &Value) -> Result {
list.insert(at + k, sh);
}
list.retain(|x| !ids.contains(&x.id));
- sel.shapes = new_ids.clone();
let _ = list;
+ if sel.target == crate::Target::Slides
+ && let Some(sl) = doc.slides.get_mut(sel.slide)
+ {
+ let sl = std::sync::Arc::make_mut(sl);
+ sl.animations.retain(|a| !ids.contains(&a.shape));
+ for a in sl.animations.iter_mut().filter(|a| a.trigger.is_some_and(|t| ids.contains(&t))) {
+ a.trigger = None;
+ }
+ }
+ sel.shapes = new_ids.clone();
Ok(())
})?;
Ok(json!({"ids": new_ids.iter().map(|i| i.0).collect::>()}))
@@ -338,4 +347,36 @@ mod tests {
assert!(matches!(out.kind, ShapeKind::Shape) && out.text.is_none());
assert!(matches!(out.fill, Some(deckcraft_model::style::Fill::Solid { .. })), "{:?}", out.fill);
}
+
+ #[test]
+ fn merge_cleans_animation_targets_and_triggers_for_every_operation_and_undo_restores_them() {
+ for op in ["union", "combine", "fragment", "intersect", "subtract"] {
+ let mut s = Session::with_new();
+ let first = s.execute("shape.insert", &json!({"preset": "rect", "rect": [0, 0, 100, 100]})).expect("first shape")["id"]
+ .as_u64()
+ .expect("id") as u32;
+ let second = s.execute("shape.insert", &json!({"preset": "rect", "rect": [50, 0, 100, 100]})).expect("second shape")["id"]
+ .as_u64()
+ .expect("id") as u32;
+ let unrelated = s.execute("shape.insert", &json!({"preset": "rect", "rect": [200, 0, 100, 100]})).expect("unrelated shape")["id"]
+ .as_u64()
+ .expect("id") as u32;
+ s.execute("animation.add", &json!({"effect": "fade", "ids": [second]})).expect("operand animation");
+ s.execute("animation.add", &json!({"effect": "spin", "ids": [unrelated]})).expect("unrelated animation");
+ s.execute("animation.timing", &json!({"index": 1, "trigger": first})).expect("trigger animation");
+
+ s.execute("shape.merge", &json!({"op": op, "ids": [first, second]})).expect("merge");
+ let animations = &s.doc().expect("doc").doc.slides[0].animations;
+ assert_eq!(animations.len(), 1, "{op} removed animations for merged operands");
+ assert_eq!(animations[0].shape.0, unrelated, "{op} preserved the unrelated effect");
+ assert_eq!(animations[0].trigger, None, "{op} cleared the removed shape trigger");
+
+ s.execute("edit.undo", &json!({})).expect("undo merge");
+ let animations = &s.doc().expect("doc").doc.slides[0].animations;
+ assert_eq!(animations.len(), 2, "{op} undo restored removed effects");
+ assert_eq!(animations[0].shape.0, second);
+ assert_eq!(animations[1].shape.0, unrelated);
+ assert_eq!(animations[1].trigger.map(|id| id.0), Some(first));
+ }
+ }
}
diff --git a/crates/engine/src/cmd/picture_style.rs b/crates/engine/src/cmd/picture_style.rs
index 3a7572172..690191f71 100644
--- a/crates/engine/src/cmd/picture_style.rs
+++ b/crates/engine/src/cmd/picture_style.rs
@@ -135,7 +135,14 @@ pub fn apply(sh: &mut Shape, id: &str) -> bool {
let e = Effects {
outer_shadow: lk.shadow.map(shadow),
soft_edge: lk.soft_edge,
- reflection: lk.reflection.then_some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
+ reflection: lk.reflection.then_some(Reflection {
+ blur: 0.5,
+ start_alpha: 0.5,
+ end_alpha: 0.0,
+ end_pos: 0.35,
+ dist: 0.0,
+ ..Default::default()
+ }),
bevel: lk.bevel.map(str::to_string),
..Default::default()
};
diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs
index 91cf7c930..c85d5dc0f 100644
--- a/crates/engine/src/cmd/reuse.rs
+++ b/crates/engine/src/cmd/reuse.rs
@@ -6,16 +6,155 @@
//! renumbered with animations, triggers and connector ends following them. Links to another
//! reused slide follow its copy.
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use std::sync::Arc;
-use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
+use deckcraft_model::table::TableStyleDef;
+use deckcraft_model::text::{Action, Hyperlink, ListStyle, RunProps, Spacing, TextBody};
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use serde_json::{Value, json};
use super::{CommandSpec, NO_ID_SPACE, bad, bool_or, cmd, has_doc, usize_param};
use crate::{Result, Session, media_refs};
+/// Scale physical measurements in imported content to the destination slide's coordinate space.
+/// Text has one size, so it uses the geometric mean of the horizontal and vertical scale.
+fn scale_text(t: &mut TextBody, sx: f64, sy: f64) {
+ let unit = (sx * sy).sqrt();
+ for v in [&mut t.body.inset_l, &mut t.body.inset_r, &mut t.body.col_spacing].into_iter().flatten() {
+ *v *= sx;
+ }
+ for v in [&mut t.body.inset_t, &mut t.body.inset_b].into_iter().flatten() {
+ *v *= sy;
+ }
+ scale_list(&mut t.list_style, sx, unit);
+ for p in &mut t.paragraphs {
+ for v in [&mut p.props.margin_left, &mut p.props.margin_right, &mut p.props.indent, &mut p.props.default_tab].into_iter().flatten() {
+ *v *= sx;
+ }
+ if let Some(stops) = &mut p.props.tabs {
+ for stop in stops {
+ stop.pos *= sx;
+ }
+ }
+ for spacing in [&mut p.props.line_spacing, &mut p.props.space_before, &mut p.props.space_after] {
+ if let Some(Spacing::Pts(v)) = spacing {
+ *v *= unit;
+ }
+ }
+ if let Some(v) = &mut p.props.bullet_size_points {
+ *v *= unit;
+ }
+ scale_run(&mut p.def_props, unit);
+ scale_run(&mut p.end_props, unit);
+ for r in &mut p.runs {
+ scale_run(&mut r.props, unit);
+ }
+ }
+}
+
+fn scale_list(list: &mut ListStyle, sx: f64, unit: f64) {
+ for level in list.levels.iter_mut().flatten() {
+ for v in
+ [&mut level.para.margin_left, &mut level.para.margin_right, &mut level.para.indent, &mut level.para.default_tab].into_iter().flatten()
+ {
+ *v *= sx;
+ }
+ for spacing in [&mut level.para.line_spacing, &mut level.para.space_before, &mut level.para.space_after] {
+ if let Some(Spacing::Pts(v)) = spacing {
+ *v *= unit;
+ }
+ }
+ if let Some(v) = &mut level.para.bullet_size_points {
+ *v *= unit;
+ }
+ scale_run(&mut level.run, unit);
+ }
+}
+
+fn scale_run(run: &mut RunProps, factor: f64) {
+ for v in [&mut run.size, &mut run.spacing, &mut run.kern, &mut run.soft_edge].into_iter().flatten() {
+ *v *= factor;
+ }
+}
+
+fn scale_shapes(shapes: &mut [deckcraft_model::Shape], sx: f64, sy: f64, depth: usize) {
+ if depth > 64 {
+ return;
+ }
+ let unit = (sx * sy).sqrt();
+ for shape in shapes {
+ if let Some(x) = &mut shape.xfrm {
+ x.x *= sx;
+ x.w *= sx;
+ x.y *= sy;
+ x.h *= sy;
+ }
+ if let Some(text) = &mut shape.text {
+ scale_text(text, sx, sy);
+ }
+ match &mut shape.kind {
+ ShapeKind::Table(table) => {
+ for col in &mut table.cols {
+ *col *= sx;
+ }
+ for row in &mut table.rows {
+ row.height *= sy;
+ for cell in &mut row.cells {
+ if let Some(margins) = &mut cell.margins {
+ if let Some(v) = margins.get_mut(0) {
+ *v *= sx;
+ }
+ if let Some(v) = margins.get_mut(1) {
+ *v *= sx;
+ }
+ if let Some(v) = margins.get_mut(2) {
+ *v *= sy;
+ }
+ if let Some(v) = margins.get_mut(3) {
+ *v *= sy;
+ }
+ }
+ scale_text(&mut cell.text, sx, sy);
+ }
+ }
+ }
+ ShapeKind::Group { children, child } => {
+ child.x *= sx;
+ child.w *= sx;
+ child.y *= sy;
+ child.h *= sy;
+ scale_shapes(children, sx, sy, depth + 1);
+ }
+ _ => {}
+ }
+ if let Some(effects) = &mut shape.effects {
+ for shadow in [&mut effects.outer_shadow, &mut effects.inner_shadow].into_iter().flatten() {
+ shadow.blur *= unit;
+ shadow.dist *= unit;
+ }
+ if let Some(glow) = &mut effects.glow {
+ glow.radius *= unit;
+ }
+ if let Some(reflection) = &mut effects.reflection {
+ reflection.blur *= unit;
+ reflection.dist *= unit;
+ }
+ }
+ }
+}
+
+fn scale_master(master: &mut Master, sx: f64, sy: f64) {
+ scale_shapes(&mut master.shapes, sx, sy, 0);
+ let unit = (sx * sy).sqrt();
+ scale_list(&mut master.title_style, sx, unit);
+ scale_list(&mut master.body_style, sx, unit);
+ scale_list(&mut master.other_style, sx, unit);
+ for layout in &mut master.layouts {
+ scale_shapes(&mut layout.shapes, sx, sy, 0);
+ }
+}
+
pub fn specs() -> Vec {
vec![
cmd!(
@@ -152,6 +291,90 @@ fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::Layout
.unwrap_or_default()
}
+fn collect_table_styles(shapes: &[deckcraft_model::Shape], styles: &mut Vec, seen: &mut HashSet, depth: usize) {
+ if depth > 64 {
+ return;
+ }
+ for shape in shapes {
+ if let ShapeKind::Table(table) = &shape.kind
+ && seen.insert(table.style.to_ascii_lowercase())
+ {
+ styles.push(table.style.clone());
+ }
+ collect_table_styles(shape.children(), styles, seen, depth + 1);
+ }
+}
+
+fn remap_table_styles(shapes: &mut [deckcraft_model::Shape], styles: &HashMap, depth: usize) {
+ if depth > 64 {
+ return;
+ }
+ for shape in shapes {
+ if let ShapeKind::Table(table) = &mut shape.kind
+ && let Some(style) = styles.get(&table.style.to_ascii_lowercase())
+ {
+ table.style = style.clone();
+ }
+ if let Some(children) = shape.children_mut() {
+ remap_table_styles(children, styles, depth + 1);
+ }
+ }
+}
+
+fn same_table_style(a: &TableStyleDef, b: &TableStyleDef) -> bool {
+ a.name == b.name && a.parts == b.parts && a.background == b.background && a.raw == b.raw
+}
+
+/// Copy only definitions used on selected slides. Conflicting GUIDs receive a fresh GUID so
+/// existing destination tables continue to resolve to their original definitions.
+fn import_table_styles(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[String]) -> Result> {
+ let mut map = HashMap::new();
+ let mut source_defs = HashMap::new();
+ let mut source_ids = HashSet::new();
+ for (index, style) in src.table_styles.iter().enumerate() {
+ let key = style.id.to_ascii_lowercase();
+ source_defs.entry(key.clone()).or_insert(index);
+ source_ids.insert(key);
+ }
+ let mut destination_defs = HashMap::new();
+ for (index, style) in doc.table_styles.iter().enumerate() {
+ destination_defs.entry(style.id.to_ascii_lowercase()).or_insert(index);
+ }
+ for id in used {
+ let key = id.to_ascii_lowercase();
+ if map.contains_key(&key) {
+ continue;
+ }
+ let Some(source_index) = source_defs.get(&key).copied() else { continue };
+ let Some(source) = src.table_styles.get(source_index) else { continue };
+ let destination = destination_defs.get(&key).and_then(|index| doc.table_styles.get(*index));
+ let mut imported = source.clone();
+ if let Some(existing) = destination {
+ if same_table_style(existing, source) {
+ map.insert(key, existing.id.clone());
+ continue;
+ }
+ let candidate = loop {
+ let n = doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?;
+ let candidate = format!("{{00000000-0000-4000-8000-{n:012X}}}");
+ let key = candidate.to_ascii_lowercase();
+ if !destination_defs.contains_key(&key) && !source_ids.contains(&key) {
+ break candidate;
+ }
+ };
+ // The definition's own XML names it too, and PPTX export writes that XML as is.
+ imported.raw = imported.raw.replacen(&format!("styleId=\"{}\"", source.id), &format!("styleId=\"{candidate}\""), 1);
+ imported.id.clone_from(&candidate);
+ map.insert(key, candidate);
+ } else {
+ map.insert(key, imported.id.clone());
+ }
+ destination_defs.insert(imported.id.to_ascii_lowercase(), doc.table_styles.len());
+ doc.table_styles.push(imported);
+ }
+ Ok(map)
+}
+
fn reuse(s: &mut Session, p: &Value) -> Result {
let (name, bytes) = super::insert::media_bytes(p, "slide.reuse")?;
let src = super::file::open_presentation(&name, &bytes)?;
@@ -176,8 +399,23 @@ fn reuse(s: &mut Session, p: &Value) -> Result {
}
let keep = bool_or(p, "keepFormatting", false);
let at = usize_param(p, "at");
+ let source_size = src.slide_size;
s.edit(|doc, sel| {
let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len());
+ let mut used_table_styles = Vec::new();
+ let mut seen_table_styles = HashSet::new();
+ for &i in &picked {
+ if let Some(slide) = src.slides.get(i) {
+ collect_table_styles(&slide.shapes, &mut used_table_styles, &mut seen_table_styles, 0);
+ }
+ }
+ let table_style_map = import_table_styles("slide.reuse", doc, &src, &used_table_styles)?;
+ let scale = |source: f64, destination: f64| {
+ let ratio = destination / source;
+ if source > 0.0 && destination > 0.0 && ratio.is_finite() && ratio > 0.0 { ratio } else { 1.0 }
+ };
+ let sx = scale(source_size.width, doc.slide_size.width);
+ let sy = scale(source_size.height, doc.slide_size.height);
// Media: what the slides use is copied under fresh ids.
let mut media_map: HashMap = HashMap::new();
// Keep Source Formatting: bring each source master used, once, with fresh ids.
@@ -190,7 +428,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result {
continue;
}
masters_done.push(m.id);
- let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
+ let mut m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
+ scale_master(&mut m, sx, sy);
doc.masters.push(Arc::new(m));
}
}
@@ -201,6 +440,8 @@ fn reuse(s: &mut Session, p: &Value) -> Result {
let Some(from) = src.slides.get(i) else { continue };
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
let mut sl: Slide = from.as_ref().clone();
+ remap_table_styles(&mut sl.shapes, &table_style_map, 0);
+ scale_shapes(&mut sl.shapes, sx, sy, 0);
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
@@ -284,13 +525,205 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med
#[cfg(test)]
mod tests {
+ use deckcraft_geom::{Size, Xfrm};
use deckcraft_model::style::{Artistic, Fill, PictureFill};
+ use deckcraft_model::table::TableStyleDef;
use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody};
- use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults};
+ use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, Table, defaults};
use serde_json::json;
use super::*;
+ const CUSTOM_STYLE: &str = "{11111111-2222-4333-8444-555555555555}";
+
+ fn styled_table_presentation(id: &str, color: &str) -> Presentation {
+ let mut p = deckcraft_model::defaults::new_presentation(None);
+ let raw = format!(
+ r#""#
+ );
+ p.table_styles.push(TableStyleDef { id: id.into(), name: "Owned".into(), raw, ..Default::default() });
+ let table = deckcraft_model::Table { style: id.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
+ let child = deckcraft_model::Shape { id: ShapeId(p.alloc_id().expect("shape id")), kind: ShapeKind::Table(table), ..Default::default() };
+ let group = deckcraft_model::Shape {
+ id: ShapeId(p.alloc_id().expect("group id")),
+ kind: ShapeKind::Group { children: vec![child], child: Default::default() },
+ ..Default::default()
+ };
+ let slide = p.slides.first_mut().expect("default slide");
+ Arc::make_mut(slide).shapes.push(group);
+ p
+ }
+
+ fn first_table_style(shapes: &[deckcraft_model::Shape]) -> Option {
+ for shape in shapes {
+ if let ShapeKind::Table(table) = &shape.kind {
+ return Some(table.style.clone());
+ }
+ if let Some(style) = first_table_style(shape.children()) {
+ return Some(style);
+ }
+ }
+ None
+ }
+
+ #[test]
+ fn reused_grouped_table_styles_survive_native_and_pptx_round_trips_and_undo() {
+ for (name, bytes) in [
+ ("source.deckcraft", deckcraft_format::save(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("native source")),
+ ("source.pptx", deckcraft_pptx::export(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("pptx source")),
+ ] {
+ let data = super::super::base64_encode(&bytes);
+ let mut session = Session::with_new();
+ session.execute("slide.reuse", &json!({"data": data, "name": name, "slides": [0], "keepFormatting": true})).expect("reuse");
+ let doc = &session.doc().expect("doc").doc;
+ assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
+ assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("FF0000")));
+ assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
+
+ session.execute("edit.undo", &json!({})).expect("undo reuse");
+ let doc = &session.doc().expect("doc").doc;
+ assert!(doc.table_styles.iter().all(|style| style.id != CUSTOM_STYLE));
+ session.execute("edit.redo", &json!({})).expect("redo reuse");
+ let bytes = crate::cmd::file::save_bytes(&session.doc().expect("doc").doc, "deckcraft").expect("save destination");
+ let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &bytes).expect("reopen destination");
+ assert!(reopened.table_styles.iter().any(|style| style.id == CUSTOM_STYLE));
+ assert_eq!(first_table_style(&reopened.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
+ }
+ }
+
+ #[test]
+ fn reused_table_style_definitions_are_deduplicated_or_remapped_on_collision() {
+ let source = styled_table_presentation(CUSTOM_STYLE, "FF0000");
+ let source_bytes = deckcraft_format::save(&source).expect("save source");
+
+ let mut identical = Session::with_new();
+ identical
+ .edit(|doc, _| {
+ doc.table_styles.push(source.table_styles.first().cloned().unwrap_or_default());
+ Ok(())
+ })
+ .expect("seed identical definition");
+ identical
+ .execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
+ .expect("reuse identical definition");
+ let doc = &identical.doc().expect("doc").doc;
+ assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1);
+ assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE));
+
+ let mut conflicting = Session::with_new();
+ conflicting
+ .edit(|doc, _| {
+ let mut own = source.table_styles.first().cloned().unwrap_or_default();
+ own.raw = own.raw.replace("FF0000", "0000FF");
+ doc.table_styles.push(own);
+ let table = deckcraft_model::Table { style: CUSTOM_STYLE.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) };
+ let id = ShapeId(doc.alloc_id().expect("destination table id"));
+ if let Some(slide) = doc.slides.first_mut() {
+ Arc::make_mut(slide).shapes.push(Shape { id, kind: ShapeKind::Table(table), ..Default::default() });
+ }
+ Ok(())
+ })
+ .expect("seed conflicting definition");
+ conflicting
+ .execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]}))
+ .expect("reuse conflicting definition");
+ let doc = &conflicting.doc().expect("doc").doc;
+ assert_eq!(first_table_style(&doc.slides.first().expect("destination slide").shapes).as_deref(), Some(CUSTOM_STYLE));
+ let imported_style = first_table_style(&doc.slides.get(1).expect("reused slide").shapes).expect("grouped table style");
+ assert_ne!(imported_style, CUSTOM_STYLE);
+ assert!(
+ doc.table_styles.iter().any(|style| style.id == imported_style
+ && style.raw.contains("FF0000")
+ && style.raw.contains(&format!("styleId=\"{imported_style}\"")))
+ );
+ assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("0000FF")));
+ }
+
+ #[test]
+ fn reused_slide_content_fits_destination_size() {
+ let mut src = defaults::new_presentation(None);
+ src.slide_size = Size::new(960.0, 540.0);
+ let slide_id = src.slides.first().map(|s| s.id).unwrap_or_default();
+ if let Some(slide) = src.slides.first_mut().map(Arc::make_mut) {
+ let mut text = TextBody::from_text("scaled");
+ if let Some(run) = text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
+ run.props.size = Some(24.0);
+ }
+ slide.shapes = vec![
+ Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 200.0, 100.0)), ..Default::default() },
+ Shape {
+ xfrm: Some(Xfrm::new(40.0, 50.0, 400.0, 200.0)),
+ text: Some(text),
+ kind: ShapeKind::Table({
+ let mut table = Table::new(2, 2, 400.0, 40.0);
+ table.rows[0].cells[0].text = TextBody::from_text("cell");
+ if let Some(run) = table.rows[0].cells[0].text.paragraphs.first_mut().and_then(|p| p.runs.first_mut()) {
+ run.props.size = Some(18.0);
+ }
+ table
+ }),
+ ..Default::default()
+ },
+ Shape {
+ xfrm: Some(Xfrm::new(100.0, 80.0, 300.0, 150.0)),
+ kind: ShapeKind::Group {
+ child: Xfrm::new(0.0, 0.0, 300.0, 150.0),
+ children: vec![Shape { xfrm: Some(Xfrm::new(200.0, 100.0, 80.0, 40.0)), ..Default::default() }],
+ },
+ ..Default::default()
+ },
+ ];
+ }
+ if let Some(master) = src.masters.first_mut().map(Arc::make_mut) {
+ master.shapes = vec![Shape { xfrm: Some(Xfrm::new(800.0, 400.0, 100.0, 50.0)), ..Default::default() }];
+ if let Some(layout) = master.layouts.first_mut() {
+ layout.shapes = vec![Shape { xfrm: Some(Xfrm::new(700.0, 300.0, 100.0, 50.0)), ..Default::default() }];
+ }
+ }
+ let bytes = deckcraft_format::save(&src).expect("source save");
+ let data = super::super::base64_encode(&bytes);
+ let mut session = Session::with_new();
+ session
+ .edit(|doc, _| {
+ doc.slide_size = Size::new(480.0, 270.0);
+ Ok(())
+ })
+ .expect("destination size");
+ session.execute("slide.reuse", &json!({"data": data, "name": "source.deckcraft", "slides": [0], "keepFormatting": true})).expect("reuse");
+
+ let doc = &session.doc().expect("doc").doc;
+ let reused = doc.slides.get(1).expect("reused slide");
+ let first = reused.shapes.first().and_then(|s| s.xfrm).expect("shape transform");
+ assert_eq!(first, Xfrm::new(400.0, 200.0, 100.0, 50.0));
+ let table_shape = reused.shapes.get(1).expect("table");
+ let table_box = table_shape.xfrm.expect("table transform");
+ assert_eq!(table_box, Xfrm::new(20.0, 25.0, 200.0, 100.0));
+ assert!(matches!(&table_shape.kind, ShapeKind::Table(_)));
+ let ShapeKind::Table(table) = &table_shape.kind else { return };
+ assert_eq!(table.cols, vec![100.0, 100.0]);
+ assert_eq!(table.rows.first().map(|r| r.height), Some(20.0));
+ let font_size = table_shape.text.as_ref().and_then(|t| t.paragraphs.first()).and_then(|p| p.runs.first()).and_then(|r| r.props.size);
+ assert_eq!(font_size, Some(12.0));
+ let cell_size = table.rows[0].cells[0].text.paragraphs.first().and_then(|p| p.runs.first()).and_then(|r| r.props.size);
+ assert_eq!(cell_size, Some(9.0));
+ let group = reused.shapes.get(2).expect("group");
+ assert!(matches!(&group.kind, ShapeKind::Group { .. }));
+ let ShapeKind::Group { children, child } = &group.kind else { return };
+ assert_eq!(group.xfrm, Some(Xfrm::new(50.0, 40.0, 150.0, 75.0)));
+ assert_eq!(*child, Xfrm::new(0.0, 0.0, 150.0, 75.0));
+ assert_eq!(children.first().and_then(|s| s.xfrm), Some(Xfrm::new(100.0, 50.0, 40.0, 20.0)));
+
+ let imported_master = doc.masters.last().expect("imported master");
+ assert_eq!(imported_master.shapes.first().and_then(|s| s.xfrm), Some(Xfrm::new(400.0, 200.0, 50.0, 25.0)));
+ assert_eq!(imported_master.layouts.first().and_then(|l| l.shapes.first()).and_then(|s| s.xfrm), Some(Xfrm::new(350.0, 150.0, 50.0, 25.0)));
+ assert_ne!(reused.id, slide_id);
+
+ let saved = deckcraft_format::save(&doc.clone()).expect("destination save");
+ let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &saved).expect("destination reopen");
+ let reopened_shape = reopened.slides.get(1).and_then(|s| s.shapes.first()).and_then(|s| s.xfrm);
+ assert_eq!(reopened_shape, Some(Xfrm::new(400.0, 200.0, 100.0, 50.0)));
+ }
+
/// Every kind of media a slide can hold comes along, each pointing at its own bytes: the
/// artistic-effect original, a background picture, poster, sounds and picture bullets too.
#[test]
diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs
index 6a18db56f..47cf1ff24 100644
--- a/crates/engine/src/cmd/review.rs
+++ b/crates/engine/src/cmd/review.rs
@@ -367,7 +367,7 @@ fn spelling(s: &mut Session, p: &Value) -> Result {
}
out.push(v);
}
- let rep: Vec = crate::spell::repeated(para, &default).iter().map(|w| w.start).collect();
+ let rep: std::collections::HashSet = 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,23 +421,30 @@ fn add_to_dictionary(_s: &mut Session, p: &Value) -> Result {
/// 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 == '’';
- let mut n = 0;
- let mut at = 0;
+ // 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);
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);
+ // 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 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);
+ out.push_str(text.get(copied..i).unwrap_or_default());
+ out.push_str(to);
n += 1;
- at = i + to.len();
+ (at, copied) = (end, end);
if !all {
break;
}
}
+ if n > 0 {
+ out.push_str(text.get(copied..).unwrap_or_default());
+ *text = out;
+ }
n
}
@@ -764,6 +771,49 @@ fn end_compare(s: &mut Session, _: &Value) -> Result {
#[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;
diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs
index 9565cdf7b..08ed9be64 100644
--- a/crates/engine/src/cmd/shape.rs
+++ b/crates/engine/src/cmd/shape.rs
@@ -103,7 +103,7 @@ pub fn specs() -> Vec {
"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, color]], angle?, kind?: linear|radial} | 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
),
diff --git a/crates/engine/src/cmd/shape/appearance.rs b/crates/engine/src/cmd/shape/appearance.rs
index 24bfbabf8..4189deaa1 100644
--- a/crates/engine/src/cmd/shape/appearance.rs
+++ b/crates/engine/src/cmd/shape/appearance.rs
@@ -371,9 +371,9 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result {
let soft = p.get("softEdges").map(|v| v.as_f64().filter(|x| *x > 0.0).map(|x| x.min(100.0)));
let refl = str_param(p, "reflection").map(|r| match r {
"none" => None,
- "half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0 }),
- "full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0 }),
- _ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 }),
+ "half" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.55, dist: 4.0, ..Default::default() }),
+ "full" => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.9, dist: 4.0, ..Default::default() }),
+ _ => Some(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0, ..Default::default() }),
});
let three_d = p.get("threeD").filter(|v| v.is_object());
let bevel = str_param(p, "bevel").map(|b| if b == "none" { None } else { Some(b.to_string()) });
@@ -467,7 +467,14 @@ pub(super) fn effects(s: &mut Session, p: &Value) -> Result {
e.glow = (g.radius > 0.0).then_some(g);
}
if let Some(v) = &refl_props {
- let mut r = e.reflection.take().unwrap_or(Reflection { blur: 0.5, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.35, dist: 0.0 });
+ let mut r = e.reflection.take().unwrap_or(Reflection {
+ blur: 0.5,
+ start_alpha: 0.5,
+ end_alpha: 0.0,
+ end_pos: 0.35,
+ dist: 0.0,
+ ..Default::default()
+ });
if let Some(t) = num(v, "transparency") {
r.start_alpha = 1.0 - t.clamp(0.0, 1.0);
}
diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs
index e150a2659..f15cf32fa 100644
--- a/crates/engine/src/cmd/slide.rs
+++ b/crates/engine/src/cmd/slide.rs
@@ -267,6 +267,9 @@ fn delete(s: &mut Session, p: &Value) -> Result {
for sec in &mut doc.sections {
sec.slides.retain(|x| !ids.contains(x));
}
+ for show in &mut doc.custom_shows {
+ show.slides.retain(|x| !ids.contains(x));
+ }
sel.slide = first.min(doc.slides.len().saturating_sub(1));
sel.slides = doc.slides.get(sel.slide).map(|x| vec![x.id]).unwrap_or_default();
sel.shapes.clear();
@@ -405,8 +408,9 @@ fn select_slides(s: &mut Session, p: &Value) -> Result {
};
s.select(|doc, sel| {
if add {
+ let mut have: std::collections::HashSet<_> = sel.slides.iter().copied().collect();
for i in &ids {
- if !sel.slides.contains(i) {
+ if have.insert(*i) {
sel.slides.push(*i);
}
}
@@ -777,6 +781,45 @@ mod tests {
(sel.slide, sel.slides.iter().map(|x| x.0).collect())
}
+ #[test]
+ fn deleting_slide_prunes_every_custom_show_in_one_undo_step() {
+ let (mut s, ids) = deck(3);
+ let shows = vec![
+ deckcraft_model::CustomShow {
+ name: "First".into(),
+ slides: vec![deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])],
+ },
+ deckcraft_model::CustomShow { name: "Second".into(), slides: vec![deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])] },
+ deckcraft_model::CustomShow { name: "Empty after delete".into(), slides: vec![deckcraft_model::SlideId(ids[1])] },
+ ];
+ s.edit(|doc, _| {
+ doc.custom_shows = shows;
+ Ok(())
+ })
+ .expect("set custom shows");
+
+ s.execute("slide.delete", &json!({"index": 1})).expect("delete slide");
+ let shows = &s.doc().expect("doc").doc.custom_shows;
+ assert_eq!(shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
+ assert_eq!(shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
+ assert!(shows[2].slides.is_empty());
+
+ s.execute("edit.undo", &json!({})).expect("undo deletion");
+ assert_eq!(order(&s), ids);
+ assert_eq!(
+ s.doc().expect("doc").doc.custom_shows[0].slides,
+ [deckcraft_model::SlideId(ids[0]), deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[1])]
+ );
+ assert_eq!(s.doc().expect("doc").doc.custom_shows[1].slides, [deckcraft_model::SlideId(ids[1]), deckcraft_model::SlideId(ids[2])]);
+
+ s.execute("edit.redo", &json!({})).expect("redo deletion");
+ let bytes = deckcraft_format::save(&s.doc().expect("doc").doc).expect("save deck");
+ let reopened = deckcraft_format::load(&bytes).expect("reopen deck");
+ assert_eq!(reopened.custom_shows[0].slides, [deckcraft_model::SlideId(ids[0])]);
+ assert_eq!(reopened.custom_shows[1].slides, [deckcraft_model::SlideId(ids[2])]);
+ assert!(reopened.custom_shows[2].slides.is_empty());
+ }
+
#[test]
fn move_from_to_moves_one_slide() {
let (mut s, ids) = deck(4);
diff --git a/crates/engine/src/compare.rs b/crates/engine/src/compare.rs
index bf8ccf4ac..cb89d08f5 100644
--- a/crates/engine/src/compare.rs
+++ b/crates/engine/src/compare.rs
@@ -121,9 +121,10 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec = pairs.iter().map(|p| p.0).collect();
let rev_matched: HashSet = 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);
+ let details = slide_details(cur, a, rev, b, &mut media);
if !details.is_empty() {
out.push(Change { kind: ChangeKind::Changed, slide: *i, number: i.saturating_add(1), only_in: None, revised: Some(*j), details });
}
@@ -140,10 +141,18 @@ pub fn diff(cur: &Presentation, rev: &Presentation, by_position: bool) -> Vec = 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,
@@ -174,8 +183,34 @@ fn shape_label(s: &Shape) -> String {
if s.name.trim().is_empty() { format!("Shape {}", s.id.0) } else { s.name.clone() }
}
-fn media_bytes(p: &Presentation, id: MediaId) -> Option<&[u8]> {
- p.media(id).map(|m| m.data.as_slice())
+/// 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,
+ rev: HashMap,
+ 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, b: Option) -> 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(),
+ }
+ }
}
/// A shape without its ids (shape and media), for comparing across decks.
@@ -204,7 +239,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(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentation, cb: Option<&Ctx>, b: &Shape, out: &mut Vec) {
+fn shape_details(ca: Option<&Ctx>, a: &Shape, cb: Option<&Ctx>, b: &Shape, media: &mut Media, out: &mut Vec) {
let label = shape_label(b);
let text = |s: &Shape| s.text.as_ref().map(|t| t.text()).unwrap_or_default();
let mut what = vec![];
@@ -234,11 +269,11 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
if a.fill != b.fill || a.line != b.line || a.style != b.style || a.effects != b.effects {
what.push("formatting");
}
- let pic = |p: &Presentation, s: &Shape| match &s.kind {
- ShapeKind::Picture { fill } => media_bytes(p, fill.media).map(|b| b.to_vec()),
+ let pic = |s: &Shape| match &s.kind {
+ ShapeKind::Picture { fill } => Some(fill.media),
_ => None,
};
- if pic(cp, a) != pic(rp, b) {
+ if media.differ(pic(a), pic(b)) {
what.push("picture");
}
// Anything the checks above do not cover (alt text, hidden, a link…) is reported too, also
@@ -265,21 +300,32 @@ fn shape_details(cp: &Presentation, ca: Option<&Ctx>, a: &Shape, rp: &Presentati
}
}
-fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide) -> Vec {
+fn slide_details(cp: &Presentation, a: &Slide, rp: &Presentation, b: &Slide, media: &mut Media) -> Vec {
let mut out = vec![];
let mut used: HashSet = 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 = HashMap::new();
+ let mut by_name: HashMap<&str, (Vec, 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 = 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()));
+ 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()
+ });
match k {
Some(k) if !used.contains(&k) => {
used.insert(k);
if let Some(sb) = b.shapes.get(k) {
- shape_details(cp, ca.as_ref(), sa, rp, cb.as_ref(), sb, &mut out);
+ shape_details(ca.as_ref(), sa, cb.as_ref(), sb, media, &mut out);
}
}
_ => out.push(format!("{} deleted (only in the deck)", shape_label(sa))),
@@ -334,19 +380,34 @@ pub fn adopt(doc: &mut Presentation, rev: &Presentation, slide: &Slide, replacin
let mut sl = slide.clone();
// Media.
let mut media_map: HashMap = 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 = HashMap::new();
+ for (i, m) in rev.media.iter().enumerate() {
+ rev_at.entry(m.id).or_insert(i);
+ }
+ let mut doc_at: HashMap = 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.media(id) else { continue };
- match doc.media(id) {
+ 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)) {
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);
}
}
@@ -519,6 +580,39 @@ 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 {
diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs
index 7d21bb4b8..9a98f62a0 100644
--- a/crates/engine/src/io.rs
+++ b/crates/engine/src/io.rs
@@ -41,12 +41,15 @@ pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
Ok(canonical) => canonical,
Err(_) => std::path::PathBuf::from(path),
};
- let target_str = target_path.to_str().unwrap_or(path);
let existing_perms = std::fs::metadata(&target_path).ok().map(|m| m.permissions());
let mut attempts = 0;
let (tmp, mut file) = loop {
- let tmp = format!("{target_str}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
+ // 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,
@@ -120,4 +123,30 @@ mod tests {
let _ = std::fs::remove_file(&test_file);
let _ = std::fs::remove_dir(&tmp_dir);
}
+
+ #[test]
+ fn write_file_overwrites_file_with_name_near_the_length_limit() {
+ let dir = std::env::temp_dir().join("deckcraft_io_long_name_test");
+ let _ = std::fs::create_dir_all(&dir);
+ let file = dir.join(format!("{}.pptx", "a".repeat(250)));
+ std::fs::write(&file, b"old").unwrap();
+ let path_str = file.to_str().unwrap();
+
+ write_file(path_str, b"new").unwrap();
+ assert_eq!(read_file(path_str).unwrap(), b"new");
+
+ 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);
+ }
}
diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs
index 6c5c02308..d8c9f23ef 100644
--- a/crates/engine/src/recovery.rs
+++ b/crates/engine/src/recovery.rs
@@ -259,6 +259,16 @@ mod tests {
dir
}
+ #[test]
+ fn instance_identity_is_stable_nonzero_and_json_safe() {
+ let id = instance_id();
+ assert_eq!(instance_id(), id);
+ assert!(id != 0 && id <= UID_MASK);
+ assert_eq!(id & 0xFF_FFFF, 0);
+ let encoded = serde_json::to_value(id).unwrap();
+ assert_eq!(encoded.as_f64().unwrap() as u64, id);
+ }
+
#[test]
fn recovers_unsaved_presentations() {
let dir = temp("recovery");
diff --git a/crates/engine/src/spell.rs b/crates/engine/src/spell.rs
index 93852e3f8..0eeaa6757 100644
--- a/crates/engine/src/spell.rs
+++ b/crates/engine/src/spell.rs
@@ -700,16 +700,31 @@ pub struct Word {
pub repeated: Option,
}
+/// 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.
+/// part by part. A language over [`MAX_LANG_LEN`] counts as none.
pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec {
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 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());
- let inside = |c: char| c.is_alphanumeric() || c == '\'' || c == '’' || c == '-';
+ 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()
+ };
+ // 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![];
let mut i = 0;
while i < chars.len() {
@@ -725,8 +740,14 @@ pub fn paragraph_words(p: &deckcraft_model::Paragraph, default: &str) -> Vec a && !chars[b - 1].0.is_alphanumeric() {
- b -= 1;
+ // Trim back to the last letter or digit, keeping only the marks that sit on it
+ // (a mark on a trailing apostrophe goes with the apostrophe).
+ while b > a {
+ let k = (a..b).rev().find(|&k| !deckcraft_model::words::is_combining_mark(chars[k].0)).unwrap_or(a);
+ if chars[k].0.is_alphanumeric() {
+ break;
+ }
+ b = k;
}
let text: String = chars[a..b].iter().map(|c| c.0).collect();
if text.is_empty() || text.chars().any(|c| c.is_ascii_digit()) || !text.chars().any(char::is_alphabetic) {
@@ -784,15 +805,18 @@ pub fn misspelt(p: &deckcraft_model::Paragraph, default: &str, ignore: &[String]
.chain(repeated(p, default))
.collect::>()
.into_iter()
- .fold(vec![], |mut v: Vec, w| {
+ .fold((vec![], HashMap::new()), |(mut v, mut at): (Vec, HashMap), w| {
// A repeated misspelt word is reported once, as repeated.
- if let Some(i) = v.iter().position(|x| x.start == w.start) {
- v[i] = w;
- } else {
- v.push(w);
+ 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);
+ }
}
- v
+ (v, at)
})
+ .0
}
/// Use `d` for `lang` from now on in this process (a dictionary the app loaded itself).
@@ -819,6 +843,20 @@ SFX D Y 1\nSFX D 0 ed/S .\n";
Dictionary::parse(AFF.as_bytes(), DIC.as_bytes())
}
+ #[test]
+ fn nfd_word_not_split() {
+ let p = deckcraft_model::Paragraph::new("Das na\u{ef}ve Caf\u{e9} und cafe\u{301} na\u{308}ive");
+ let w: Vec = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect();
+ assert_eq!(w, ["Das", "na\u{ef}ve", "Caf\u{e9}", "und", "cafe\u{301}", "na\u{308}ive"]);
+ }
+
+ #[test]
+ fn a_mark_on_trailing_punctuation_is_trimmed_with_it() {
+ let p = deckcraft_model::Paragraph::new("word'\u{301} cafe\u{301}' x");
+ let w: Vec = paragraph_words(&p, "en-US").into_iter().map(|w| w.text).collect();
+ assert_eq!(w, ["word", "cafe\u{301}", "x"]);
+ }
+
#[test]
fn curly_apostrophe_matches_ascii_entry() {
let d = Dictionary::parse(AFF.as_bytes(), "2\ndon't\nwork\n".as_bytes());
@@ -932,6 +970,23 @@ 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");
diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs
index a687de95c..e37f9d135 100644
--- a/crates/engine/src/tests.rs
+++ b/crates/engine/src/tests.rs
@@ -2410,6 +2410,23 @@ fn pdf_options_take_paper_and_orientation() {
assert!(cmd::file::pdf_options(&json!({"orientation": "sideways"}), 1).is_err());
}
+/// Handout `perPage` accepts only 1, 2, 3, 4, 6 and 9; anything else is an error, not a 9-up page.
+#[test]
+fn pdf_options_reject_unsupported_per_page() {
+ for n in [1, 2, 3, 4, 6, 9] {
+ let o = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap();
+ assert!(matches!(o.layout, deckcraft_pdf::PageLayout::Handouts { per_page } if per_page as usize == n));
+ }
+ for n in [0, 5, 7, 8, 10] {
+ let e = cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": n}), 1).unwrap_err();
+ assert!(e.to_string().contains("1, 2, 3, 4, 6, 9"), "perPage {n}: {e}");
+ }
+ // Not a count at all: refused too, instead of falling back to 6.
+ for v in [json!(-1), json!(5.5), json!("6")] {
+ assert!(cmd::file::pdf_options(&json!({"layout": "handouts", "perPage": v}), 1).is_err(), "perPage {v}");
+ }
+}
+
#[test]
fn shape_line_sets_compound_and_arrow_sizes() {
// The Format Shape pane's Line page: compound type, arrowheads and their sizes.
@@ -3206,6 +3223,7 @@ fn numbering_takes_color_and_size() {
props.bullet_font_follow_text = Some(true);
props.bullet_color_follow_text = Some(true);
props.bullet_size_follow_text = Some(true);
+ props.bullet_size_points = Some(12.0);
}
s.execute("format.numbering", &json!({"on": true, "scheme": "romanUcPeriod", "start": 3, "color": "accent2", "size": 150})).unwrap();
let para = |s: &Session| {
@@ -3219,9 +3237,11 @@ fn numbering_takes_color_and_size() {
assert_eq!(pp.bullet_font_follow_text, Some(true));
assert_eq!(pp.bullet_color_follow_text, None);
assert_eq!(pp.bullet_size_follow_text, None);
+ assert_eq!(pp.bullet_size_points, None);
assert_eq!(pp.bullet_color, Some(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent2)));
s.execute("format.bullets", &json!({"on": true, "char": "★", "size": 9000})).unwrap();
assert_eq!(para(&s).bullet_size, Some(4.0));
+ assert_eq!(para(&s).bullet_size_points, None);
assert_eq!(para(&s).bullet_font_follow_text, None);
}
@@ -4294,6 +4314,38 @@ fn a_params_doc_spells_out_each_parameter() {
);
}
+/// `shape.fill`'s params doc names each key and kind its `gradient` takes.
+#[test]
+fn shape_fill_documents_each_gradient_key_and_kind() {
+ use deckcraft_model::style::GradientShape;
+ 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"]);
+ for kind in ["linear", "radial", "rectangular", "path"] {
+ assert!(about.contains(kind), "{kind}: {about}");
+ let g = cmd::shape::gradient_param(&json!({
+ "stops": [[0, "#ffffff"], [1, "#000000"]],
+ "kind": kind,
+ "angle": 30,
+ "focus": [0, 0, 1, 1],
+ "rotateWithShape": false
+ }))
+ .unwrap();
+ assert!(!g.rotate_with_shape, "{kind}");
+ match g.shape {
+ GradientShape::Linear { angle, .. } => assert_eq!((kind, angle), ("linear", 30.0)),
+ GradientShape::Path { path, focus } => {
+ let expected = match kind {
+ "radial" => "circle",
+ "rectangular" => "rect",
+ _ => "shape",
+ };
+ assert_eq!((path.as_str(), focus), (expected, [0.0, 0.0, 1.0, 1.0]), "{kind}");
+ }
+ }
+ }
+}
+
#[test]
fn what_a_command_acts_on_names_only_keys_it_takes() {
for c in command_specs() {
@@ -5310,7 +5362,8 @@ fn opening_a_file_caps_absurd_effect_sizes() {
e.soft_edge = Some(f64::NAN);
let sw = e.outer_shadow.as_mut().unwrap();
(sw.blur, sw.dist, sw.sx) = (huge, huge, huge);
- *e.reflection.as_mut().unwrap() = Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge };
+ *e.reflection.as_mut().unwrap() =
+ Reflection { blur: huge, start_alpha: 0.5, end_alpha: 0.0, end_pos: 0.5, dist: huge, ..Default::default() };
}
let glow = Some(Glow { color: deckcraft_model::style::ColorRef::rgb(deckcraft_color::Rgba::BLACK), radius: huge });
let runs: Vec<&mut deckcraft_model::text::Run> = match &mut sh.kind {
@@ -5572,6 +5625,170 @@ fn edit_points_conversion_is_one_undo_step() {
assert!(preset(&s), "the only step was the conversion");
}
+/// #1621: build overlapping objects with an ordinary quick style on the foreground shape.
+fn no_fill_overlap(s: &mut Session, text: bool) -> (ShapeId, ShapeId) {
+ s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
+ let under = s
+ .execute(
+ if text { "insert.textBox" } else { "shape.insert" },
+ &json!({"preset": "rect", "rect": [100, 300, 300, 60], "text": if text { "UNDERLYING TEXT" } else { "" }}),
+ )
+ .unwrap()["id"]
+ .as_u64()
+ .unwrap();
+ let over = s.execute("shape.insert", &json!({"preset": "rect", "rect": [80, 270, 350, 140]})).unwrap()["id"].as_u64().unwrap();
+ (ShapeId(under as u32), ShapeId(over as u32))
+}
+
+/// No Fill passes interior clicks through while preserving outline and actual-fill targets.
+#[test]
+fn no_fill_shape_allows_selecting_and_editing_underlying_text() {
+ let mut s = session();
+ let (under, over) = no_fill_overlap(&mut s, true);
+ s.execute("shape.fill", &json!({"none": true})).unwrap();
+ assert!(s.doc().unwrap().shape(over).unwrap().style.is_some(), "the ordinary quick style remains");
+ s.execute("edit.deselect", &json!({})).unwrap();
+ let p = deckcraft_geom::Point::new(200.0, 310.0);
+ assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
+ for kind in [PointerKind::Down, PointerKind::Up] {
+ s.pointer(tev(kind, p.x, p.y)).unwrap();
+ }
+ assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
+ assert_eq!(text_sel(&s).unwrap().shape, under);
+ // The transparent foreground also leaves an already-active text editor's caret alone.
+ for kind in [PointerKind::Down, PointerKind::Up] {
+ s.pointer(tev(kind, p.x + 20.0, p.y)).unwrap();
+ }
+ assert_eq!(text_sel(&s).unwrap().shape, under);
+ s.execute("edit.deselect", &json!({})).unwrap();
+ // The rectangle's outline is still a target, and selecting it does not remove its style.
+ for kind in [PointerKind::Down, PointerKind::Up] {
+ s.pointer(tev(kind, 80.0, 330.0)).unwrap();
+ }
+ assert_eq!(s.doc().unwrap().selection.shapes, vec![over]);
+ assert!(s.doc().unwrap().shape(over).unwrap().style.is_some());
+ // A real fill still catches clicks, even with 100% transparency (distinct from No Fill).
+ for transparency in [0.0, 1.0] {
+ s.execute("edit.select", &json!({"ids": [over.0]})).unwrap();
+ s.execute("shape.fill", &json!({"color": "#336699", "transparency": transparency})).unwrap();
+ s.execute("edit.deselect", &json!({})).unwrap();
+ assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over));
+ }
+}
+
+/// Rotation and pointer tolerance must preserve both pass-through interiors and outline hits.
+#[test]
+fn no_fill_shape_hit_testing_respects_rotation_and_pointer_tolerance() {
+ let mut s = session();
+ let (under, over) = no_fill_overlap(&mut s, false);
+ s.execute("shape.fill", &json!({"none": true})).unwrap();
+ s.execute("shape.rotate", &json!({"deg": 30})).unwrap();
+ let x = s.doc().unwrap().shape(over).unwrap().xfrm.unwrap();
+ let p = x.affine() * deckcraft_geom::Point::new(x.w / 2.0, x.h / 2.0);
+ let edge = x.affine() * deckcraft_geom::Point::new(0.0, x.h / 4.0);
+ for tol in [0.5, 3.0, 8.0] {
+ s.execute("edit.deselect", &json!({})).unwrap();
+ assert!(matches!(s.hit_test(p, tol), Some(tools::Hit::Shape { id, .. }) if id == under));
+ assert!(matches!(s.hit_test(edge, tol), Some(tools::Hit::Shape { id, .. }) if id == over));
+ for kind in [PointerKind::Down, PointerKind::Up] {
+ s.pointer(PointerEvent { tol, ..tev(kind, p.x, p.y) }).unwrap();
+ }
+ assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
+ }
+}
+
+/// Style, theme and absent fills follow rendering, with explicit fill taking precedence.
+#[test]
+fn no_fill_hit_testing_resolves_style_and_theme_fills() {
+ use std::sync::Arc;
+ for source in ["style", "theme", "absent"] {
+ let mut s = session();
+ let (under, over) = no_fill_overlap(&mut s, true);
+ {
+ let st = s.doc_mut().unwrap();
+ let doc = Arc::make_mut(&mut st.doc);
+ if source == "theme" {
+ Arc::make_mut(&mut doc.masters[0]).theme.format.fills[0] = model::Fill::None;
+ }
+ let sh = Arc::make_mut(&mut doc.slides[st.selection.slide]).shapes.iter_mut().find(|sh| sh.id == over).unwrap();
+ assert!(sh.fill.is_none(), "the shape has no explicit fill override");
+ if source == "absent" {
+ sh.style = None;
+ } else {
+ sh.style.as_mut().unwrap().fill_ref.0 = if source == "style" { 0 } else { 1 };
+ }
+ }
+ s.execute("edit.deselect", &json!({})).unwrap();
+ let p = deckcraft_geom::Point::new(200.0, 310.0);
+ assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under), "{source}");
+ // An explicit fill takes precedence over the no-fill style/theme.
+ s.execute("edit.select", &json!({"ids": [over.0]})).unwrap();
+ s.execute("shape.fill", &json!({"color": "#336699"})).unwrap();
+ s.execute("edit.deselect", &json!({})).unwrap();
+ assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over), "{source}");
+ }
+}
+
+/// Text on a foreground No Fill shape retains its selection and editing target.
+#[test]
+fn no_fill_text_shapes_keep_their_interior_text_target() {
+ let mut s = session();
+ let (_, over) = no_fill_overlap(&mut s, true);
+ s.execute("text.set", &json!({"id": over.0, "text": "FOREGROUND TEXT"})).unwrap();
+ s.execute("shape.fill", &json!({"none": true})).unwrap();
+ s.execute("edit.deselect", &json!({})).unwrap();
+ let p = deckcraft_geom::Point::new(200.0, 310.0);
+ assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, text: true }) if id == over));
+ s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
+ assert_eq!(text_sel(&s).unwrap().shape, over);
+}
+
+/// A locked No Fill shape remains selectable on its outline, but dragging cannot move it.
+#[test]
+fn no_fill_locked_shape_keeps_outline_selection_without_moving() {
+ let mut s = session();
+ let (under, over) = no_fill_overlap(&mut s, true);
+ s.execute("shape.fill", &json!({"none": true})).unwrap();
+ s.execute("shape.lock", &json!({"locked": true})).unwrap();
+ let before = s.doc().unwrap().shape(over).unwrap().xfrm;
+ s.execute("edit.deselect", &json!({})).unwrap();
+ let inside = deckcraft_geom::Point::new(200.0, 310.0);
+ assert!(matches!(s.hit_test(inside, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
+ for kind in [PointerKind::Down, PointerKind::Up] {
+ s.pointer(tev(kind, inside.x, inside.y)).unwrap();
+ }
+ assert_eq!(text_sel(&s).unwrap().shape, under);
+ s.execute("edit.deselect", &json!({})).unwrap();
+ let edge = deckcraft_geom::Point::new(80.0, 330.0);
+ assert!(matches!(s.hit_test(edge, 3.0), Some(tools::Hit::Shape { id, .. }) if id == over));
+ s.pointer(tev(PointerKind::Down, edge.x, edge.y)).unwrap();
+ s.pointer(tev(PointerKind::Drag, edge.x + 40.0, edge.y + 20.0)).unwrap();
+ s.pointer(tev(PointerKind::Up, edge.x + 40.0, edge.y + 20.0)).unwrap();
+ let st = s.doc().unwrap();
+ assert_eq!(st.selection.shapes, vec![over]);
+ assert_eq!(st.shape(over).unwrap().xfrm, before);
+ assert!(st.shape(over).unwrap().locked);
+}
+
+/// Removing both fill and outline leaves the empty interior transparent to clicks, locked or not.
+#[test]
+fn no_fill_shape_without_text_or_outline_passes_interior_clicks() {
+ for locked in [false, true] {
+ let mut s = session();
+ let (under, _) = no_fill_overlap(&mut s, false);
+ s.execute("shape.fill", &json!({"none": true})).unwrap();
+ s.execute("shape.line", &json!({"none": true})).unwrap();
+ s.execute("shape.lock", &json!({"locked": locked})).unwrap();
+ s.execute("edit.deselect", &json!({})).unwrap();
+ let p = deckcraft_geom::Point::new(200.0, 310.0);
+ assert!(matches!(s.hit_test(p, 3.0), Some(tools::Hit::Shape { id, .. }) if id == under));
+ for kind in [PointerKind::Down, PointerKind::Up] {
+ s.pointer(tev(kind, p.x, p.y)).unwrap();
+ }
+ assert_eq!(s.doc().unwrap().selection.shapes, vec![under]);
+ }
+}
+
/// #931: while the text of a placeholder is being edited, a shape in front of it still takes a
/// click (as in PowerPoint); a click on the text where nothing is in front still moves the caret.
#[test]
@@ -5645,6 +5862,204 @@ fn box_of(s: &mut Session, id: u64) -> serde_json::Value {
s.execute("shape.inspect", &json!({"id": id})).unwrap()["box"].clone()
}
+/// Two selected boxes with different sizes and aligned right edges, on a blank slide.
+fn resize_selection_scene() -> (Session, [u64; 2]) {
+ let mut s = session();
+ s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
+ s.prefs.smart_guides = false;
+ s.prefs.snap_to_grid = false;
+ let ids = [[100, 100, 100, 80], [120, 300, 80, 100]]
+ .map(|rect| s.execute("shape.insert", &json!({"preset": "rect", "rect": rect, "text": "Resize test"})).unwrap()["id"].as_u64().unwrap());
+ s.execute("edit.select", &json!({"ids": ids})).unwrap();
+ (s, ids)
+}
+
+/// Simulate file-loaded shapes whose stored boxes have not yet been fitted to their text.
+fn pending_resize_autofit(s: &mut Session, ids: &[u64]) {
+ s.edit(|doc, sel| {
+ let shapes = crate::shapes_mut(doc, sel).unwrap();
+ for id in ids {
+ let sh = deckcraft_model::find_shape_mut(shapes, ShapeId(*id as u32)).unwrap();
+ sh.text.as_mut().unwrap().body.autofit = Some(deckcraft_model::text::AutoFit::Shape);
+ }
+ Ok(())
+ })
+ .unwrap();
+}
+
+/// Resizing multiple selected shapes scales them proportionally around their anchors in one undo step.
+#[test]
+fn resizing_selected_shapes_is_one_undo_step() {
+ let (mut s, [a, b]) = resize_selection_scene();
+ let before = s.doc().unwrap().doc.clone();
+ let steps = s.doc().unwrap().history.undo.len();
+ let down = drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0));
+ assert_eq!(down["hit"]["kind"], "resize");
+ assert_eq!(down["hit"]["id"], a);
+ for (id, expected) in [(a, [100.0, 100.0, 150.0, 110.0]), (b, [120.0, 300.0, 120.0, 137.5])] {
+ let actual = box_of(&mut s, id);
+ for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) {
+ assert_eq!(actual[key], value, "{id}: {key}");
+ }
+ }
+ assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
+ let after = s.doc().unwrap().doc.clone();
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(s.doc().unwrap().doc, before, "one undo restores both shapes");
+ s.execute("edit.redo", &json!({})).unwrap();
+ assert_eq!(s.doc().unwrap().doc, after, "one redo resizes both shapes again");
+}
+
+/// Resizing selected shapes refits their text boxes inside the same undo transaction.
+#[test]
+fn resizing_selected_shapes_refits_text_in_the_same_undo_step() {
+ let (mut s, ids) = resize_selection_scene();
+ pending_resize_autofit(&mut s, &ids);
+ let before = s.doc().unwrap().doc.clone();
+ let steps = s.doc().unwrap().history.undo.len();
+ // An east-handle drag changes width only; a height change must come from text fitting.
+ drag_gesture(&mut s, (200.0, 140.0), (250.0, 140.0));
+ for (id, old_height) in ids.into_iter().zip([80.0, 100.0]) {
+ assert_ne!(box_of(&mut s, id)["h"], old_height, "both shapes' text is refitted");
+ }
+ assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
+ let after = s.doc().unwrap().doc.clone();
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(s.doc().unwrap().doc, before);
+ s.execute("edit.redo", &json!({})).unwrap();
+ assert_eq!(s.doc().unwrap().doc, after, "redo includes the final text fit");
+}
+
+/// Clicking a resize handle without dragging neither modifies shapes nor triggers text autofit.
+#[test]
+fn clicking_a_resize_handle_does_not_change_or_refit_selected_shapes() {
+ let (mut s, ids) = resize_selection_scene();
+ pending_resize_autofit(&mut s, &ids);
+ let before = s.doc().unwrap().doc.clone();
+ let steps = s.doc().unwrap().history.undo.len();
+ let revision = s.doc().unwrap().revision;
+ for kind in [PointerKind::Down, PointerKind::Up] {
+ s.pointer(PointerEvent { kind, x: 200.0, y: 180.0, tol: 3.0, ..Default::default() }).unwrap();
+ }
+ assert_eq!(s.doc().unwrap().doc, before);
+ assert_eq!(s.doc().unwrap().history.undo.len(), steps, "a click creates no undo entry");
+ assert_eq!(s.doc().unwrap().revision, revision);
+ assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().doc, &before), "a click does not dirty the document");
+ // Ensure this fixture would catch an accidental refit on release.
+ for id in ids {
+ cmd::text::fit_text_box(&mut s, ShapeId(id as u32)).unwrap();
+ }
+ assert_ne!(s.doc().unwrap().doc, before);
+}
+
+/// Locked shapes in a multi-selection are excluded from both resizing and post-resize text fitting.
+#[test]
+fn resizing_selected_shapes_skips_locked_shapes_and_their_text_fit() {
+ let (mut s, [a, b]) = resize_selection_scene();
+ pending_resize_autofit(&mut s, &[b]);
+ s.execute("shape.lock", &json!({"ids": [b], "locked": true})).unwrap();
+ s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
+ let locked = s.doc().unwrap().shape(ShapeId(b as u32)).unwrap().clone();
+ let before = s.doc().unwrap().doc.clone();
+ let steps = s.doc().unwrap().history.undo.len();
+ drag_gesture(&mut s, (200.0, 180.0), (250.0, 210.0));
+ assert_eq!(box_of(&mut s, a)["w"], 150.0);
+ assert_eq!(box_of(&mut s, a)["h"], 110.0);
+ assert_eq!(s.doc().unwrap().shape(ShapeId(b as u32)).unwrap(), &locked, "the locked shape is neither resized nor refitted");
+ assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(s.doc().unwrap().doc, before);
+}
+
+/// Resizing selected shapes excludes other selected resize targets from smart-guide snapping.
+#[test]
+fn resizing_selected_shapes_does_not_snap_to_other_resize_targets() {
+ for reverse in [false, true] {
+ let (mut s, [a, b]) = resize_selection_scene();
+ s.prefs.smart_guides = true;
+ let ids = if reverse { [b, a] } else { [a, b] };
+ s.execute("edit.select", &json!({"ids": ids})).unwrap();
+ // Within snap tolerance of the other selected shape's original right edge.
+ drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0));
+ assert_eq!(box_of(&mut s, a)["w"], 103.0, "first shape must not snap back");
+ assert_eq!(box_of(&mut s, b)["w"], 82.4, "selection order must not affect snapping");
+ }
+}
+
+/// Resizing selected shapes snaps the active shape against unselected stationary shapes and scales others proportionally.
+#[test]
+fn resizing_selected_shapes_still_snaps_to_stationary_shapes() {
+ let (mut s, [a, b]) = resize_selection_scene();
+ let fixed = s.execute("shape.insert", &json!({"preset": "rect", "rect": [207, 430, 100, 80]})).unwrap()["id"].as_u64().unwrap();
+ let fixed_before = box_of(&mut s, fixed);
+ s.execute("edit.select", &json!({"ids": [a, b]})).unwrap();
+ s.prefs.smart_guides = true;
+ drag_gesture(&mut s, (200.0, 140.0), (203.0, 140.0));
+ assert_eq!(box_of(&mut s, a)["w"], 107.0);
+ assert!((box_of(&mut s, b)["w"].as_f64().unwrap() - 85.6).abs() < 1e-9);
+ assert_eq!(box_of(&mut s, fixed), fixed_before);
+}
+
+/// Secondary pictures resize uniformly around their anchor, preserving independent flips
+/// and keeping the complete multi-selection resize in one undo step.
+#[test]
+fn resizing_selected_shapes_resizes_secondary_pictures() {
+ // Pointer destination, Alt, expected rectangle, expected picture, expected flips.
+ let cases = [
+ ((180.0, 200.0), false, [100.0, 100.0, 80.0, 100.0], [300.0, 300.0, 80.0, 80.0], [false, false]),
+ ((200.0, 180.0), false, [100.0, 100.0, 100.0, 80.0], [300.0, 300.0, 80.0, 80.0], [false, false]),
+ ((220.0, 200.0), false, [100.0, 100.0, 120.0, 100.0], [300.0, 300.0, 120.0, 120.0], [false, false]),
+ ((200.0, 220.0), false, [100.0, 100.0, 100.0, 120.0], [300.0, 300.0, 120.0, 120.0], [false, false]),
+ ((180.0, 200.0), true, [120.0, 100.0, 60.0, 100.0], [320.0, 320.0, 60.0, 60.0], [false, false]),
+ ((200.0, 180.0), true, [100.0, 120.0, 100.0, 60.0], [320.0, 320.0, 60.0, 60.0], [false, false]),
+ ((80.0, 200.0), false, [80.0, 100.0, 20.0, 100.0], [280.0, 300.0, 20.0, 20.0], [true, false]),
+ ((200.0, 80.0), false, [100.0, 80.0, 100.0, 20.0], [300.0, 280.0, 20.0, 20.0], [false, true]),
+ ];
+
+ for (to, alt, expected_rect, expected_picture, flips) in cases {
+ let mut s = session();
+ s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
+ s.prefs.smart_guides = false;
+ s.prefs.snap_to_grid = false;
+
+ let rect = s.execute("shape.insert", &json!({"preset": "rect", "rect": [100, 100, 100, 100]})).unwrap()["id"].as_u64().unwrap();
+ let picture = ramp_picture(&mut s, 1, [300, 300, 100, 100]).as_u64().unwrap();
+ s.execute("edit.select", &json!({"ids": [rect, picture]})).unwrap();
+
+ let before = s.doc().unwrap().doc.clone();
+ let steps = s.doc().unwrap().history.undo.len();
+ let mods = Mods { alt, ..Default::default() };
+ let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods, tol: 3.0, pressure: None };
+
+ let down = s.pointer(ev(PointerKind::Down, (200.0, 200.0))).unwrap();
+ assert_eq!(down["hit"]["kind"], "resize");
+ assert_eq!(down["hit"]["id"], rect);
+ assert_eq!(down["hit"]["handle"], 4);
+
+ s.pointer(ev(PointerKind::Drag, ((200.0 + to.0) / 2.0, (200.0 + to.1) / 2.0))).unwrap();
+ s.pointer(ev(PointerKind::Drag, to)).unwrap();
+ s.pointer(ev(PointerKind::Up, to)).unwrap();
+
+ for (id, expected) in [(rect, expected_rect), (picture, expected_picture)] {
+ let actual = box_of(&mut s, id);
+ for (key, value) in ["x", "y", "w", "h"].into_iter().zip(expected) {
+ let got = actual[key].as_f64().unwrap();
+ assert!((got - value).abs() < 1e-9, "{to:?}, alt={alt}, shape={id}, {key}: expected {value}, got {got}");
+ }
+
+ let xfrm = s.doc().unwrap().shape(ShapeId(id as u32)).unwrap().xfrm.unwrap();
+ assert_eq!([xfrm.flip_h, xfrm.flip_v], flips, "{to:?}, alt={alt}, shape={id}");
+ }
+
+ assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
+ let after = s.doc().unwrap().doc.clone();
+ s.execute("edit.undo", &json!({})).unwrap();
+ assert_eq!(s.doc().unwrap().doc, before);
+ s.execute("edit.redo", &json!({})).unwrap();
+ assert_eq!(s.doc().unwrap().doc, after);
+ }
+}
+
/// #961: dragging the east handle snaps the dragged (right) edge, even when the fixed left edge
/// already lines up with another shape. The nearest line used to be the fixed edge's, at distance 0.
#[test]
@@ -6064,3 +6479,30 @@ fn master_body_placeholder_format_keeps_per_level_formatting() {
assert_eq!((level(&s, 0).run.size, level(&s, 1).run.size), (Some(28.0), Some(24.0)));
assert_eq!(shown(&s), vec![Some(28.0), Some(24.0)]);
}
+
+#[test]
+fn slide_master_view_rejects_invalid_indices() {
+ let mut s = Session::with_new();
+ for p in [json!({"master": -1}), json!({"master": 0.5}), json!({"master": 0, "layout": -1}), json!({"master": 0, "layout": 1.5})] {
+ let r = s.execute("view.slideMaster", &p);
+ assert!(r.is_err(), "invalid index accepted for {p}: {r:?}");
+ }
+ assert!(s.execute("view.slideMaster", &json!({})).is_ok());
+ assert!(s.execute("view.slideMaster", &json!({"master": 0})).is_ok());
+}
+
+#[test]
+fn save_to_image_path_is_an_error_not_zip_bytes() {
+ let dir = std::env::temp_dir().join(format!("deckcraft-save-img-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ for (cmd, name) in [("file.save", "a.png"), ("file.saveAs", "a.jpg"), ("file.save", "a.jpeg")] {
+ let mut s = Session::new();
+ sample::open_sample(&mut s).unwrap();
+ let path = dir.join(name).to_string_lossy().to_string();
+ let before = s.doc().unwrap().path.clone();
+ assert!(s.execute(cmd, &json!({"path": path})).is_err(), "{cmd} {name} should fail");
+ assert!(!std::path::Path::new(&path).exists(), "{name} must not be written");
+ assert_eq!(s.doc().unwrap().path, before);
+ }
+ let _ = std::fs::remove_dir_all(&dir);
+}
diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs
index 78c6ed35c..82f53dfc3 100644
--- a/crates/engine/src/tools.rs
+++ b/crates/engine/src/tools.rs
@@ -150,8 +150,10 @@ enum Drag {
},
Resize {
id: ShapeId,
- handle: u8,
orig: Xfrm,
+ ids: Vec,
+ handle: u8,
+ origs: Vec<(ShapeId, Xfrm)>,
start: Point,
},
LineEnd {
diff --git a/crates/engine/src/tools/drag.rs b/crates/engine/src/tools/drag.rs
index 21aac4f38..dcee9cf7d 100644
--- a/crates/engine/src/tools/drag.rs
+++ b/crates/engine/src/tools/drag.rs
@@ -13,6 +13,7 @@ struct MoveDrag {
}
impl Session {
+ /// Update the active drag interaction using the pointer position and modifiers.
pub(super) fn drag(&mut self, p: Point, mods: Mods, tol: f64) -> Result {
let Some(drag) = self.tool.drag.clone() else { return Ok(Value::Null) };
match drag {
@@ -26,10 +27,48 @@ impl Session {
Drag::Move { ids, start, origs, moved, duplicate, enter_text } => {
return self.drag_move(MoveDrag { ids, start, origs, moved, duplicate, enter_text }, p, mods, tol);
}
- Drag::Resize { id, handle, orig, start } => {
- let nx = resize_box(&orig, handle, p - start, mods.shift || self.is_picture(id), mods.alt);
- let nx = self.snap_resize(nx, &orig, handle, id, tol);
- self.set_xfrm(id, nx)?;
+ Drag::Resize { id, orig, ids, handle, origs, start } => {
+ let keep_aspect = mods.shift || self.is_picture(id);
+ let nx = resize_box(&orig, handle, p - start, keep_aspect, mods.alt);
+ let nx = self.snap_resize(nx, handle, &ids, tol);
+ let sx = if orig.w > 1e-9 { (nx.w / orig.w) * if nx.flip_h != orig.flip_h { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
+ let sy = if orig.h > 1e-9 { (nx.h / orig.h) * if nx.flip_v != orig.flip_v { -1.0 } else { 1.0 } - 1.0 } else { 0.0 };
+ let div = if mods.alt { 2.0 } else { 1.0 };
+ let hx = match handle {
+ 0 | 6 | 7 => -1.0,
+ 2..=4 => 1.0,
+ _ => 0.0,
+ };
+ let hy = match handle {
+ 0..=2 => -1.0,
+ 4..=6 => 1.0,
+ _ => 0.0,
+ };
+ for (tid, o) in &origs {
+ let target_nx = if *tid == id {
+ nx
+ } else {
+ let picture = self.is_picture(*tid);
+ let (sx, sy) = if picture && matches!(handle, 0 | 2 | 4 | 6) {
+ // Use the axis with the larger relative change so an unchanged
+ // dimension cannot cancel shrinking. Preserve each axis's flip.
+ let scale_x = 1.0 + sx;
+ let scale_y = 1.0 + sy;
+ let scale = if sx.abs() >= sy.abs() { scale_x.abs() } else { scale_y.abs() };
+ (scale.copysign(scale_x) - 1.0, scale.copysign(scale_y) - 1.0)
+ } else {
+ (sx, sy)
+ };
+
+ let lp = Affine::rotate(o.rot.to_radians())
+ * Point::new(
+ hx * sx * o.w / div * if o.flip_h { -1.0 } else { 1.0 },
+ hy * sy * o.h / div * if o.flip_v { -1.0 } else { 1.0 },
+ );
+ resize_box(o, handle, Vec2::new(lp.x, lp.y), picture, mods.alt)
+ };
+ self.set_xfrm(*tid, target_nx)?;
+ }
}
Drag::LineEnd { id, end, orig } => {
let a = orig.affine();
diff --git a/crates/engine/src/tools/geometry.rs b/crates/engine/src/tools/geometry.rs
index 536a4ead2..cbbd01275 100644
--- a/crates/engine/src/tools/geometry.rs
+++ b/crates/engine/src/tools/geometry.rs
@@ -228,7 +228,7 @@ impl Session {
}
/// Snap the edges that resize handle `handle` drags in the resized box `x` to the smart guides.
- pub(super) fn snap_resize(&mut self, x: Xfrm, _orig: &Xfrm, handle: u8, id: ShapeId, tol: f64) -> Xfrm {
+ pub(super) fn snap_resize(&mut self, x: Xfrm, handle: u8, exclude: &[ShapeId], tol: f64) -> Xfrm {
if x.rot != 0.0 {
self.tool.guides.clear();
return x;
@@ -241,7 +241,7 @@ impl Session {
let (top, bottom) = (matches!(handle, 0..=2), matches!(handle, 4..=6));
let (moves_left, moves_right) = if x.flip_h { (right, left) } else { (left, right) };
let (moves_top, moves_bottom) = if x.flip_v { (bottom, top) } else { (top, bottom) };
- let (off, guides) = self.snap_edges(r, &[id], tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
+ let (off, guides) = self.snap_edges(r, exclude, tol * 1.5, true, [moves_left, false, moves_right], [moves_top, false, moves_bottom]);
let mut nx = x;
if moves_left {
nx.x += off.x;
diff --git a/crates/engine/src/tools/hit.rs b/crates/engine/src/tools/hit.rs
index 6a0da529f..247ab1e79 100644
--- a/crates/engine/src/tools/hit.rs
+++ b/crates/engine/src/tools/hit.rs
@@ -216,6 +216,7 @@ pub(super) fn in_table_cell(st: &crate::DocState, id: ShapeId, p: Point) -> bool
})
}
+/// Resolve a shape's target at slide point `p`, using its effective fill for interior hits.
pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option {
let x = xfrm_of(&st.doc, &st.selection, sh);
let inv = x.affine().inverse();
@@ -267,8 +268,11 @@ pub(super) fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) ->
return None;
}
let has_text = matches!(sh.kind, ShapeKind::Shape) && (sh.text.as_ref().is_some_and(|t| !t.is_empty()) || sh.ph.is_some() || sh.text_box);
- // Empty interiors of unfilled, text-less shapes don't catch clicks.
- let filled = !matches!(sh.fill, Some(deckcraft_model::Fill::None)) || sh.style.is_some();
+ // Match rendering: an explicit No Fill overrides the style, and style/theme fills
+ // can themselves be No Fill. Empty, text-less interiors then let clicks through.
+ let fill =
+ cmd::text::ctx_for(&st.doc, &st.selection).map(|ctx| deckcraft_model::resolve::fill(&ctx, sh).0).unwrap_or_else(|| sh.fill.clone());
+ let filled = !matches!(fill, None | Some(deckcraft_model::Fill::None));
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
let in_geo = {
use deckcraft_geom::KurboShape;
diff --git a/crates/engine/src/tools/pointer.rs b/crates/engine/src/tools/pointer.rs
index cb71cfed0..d20ae9315 100644
--- a/crates/engine/src/tools/pointer.rs
+++ b/crates/engine/src/tools/pointer.rs
@@ -220,6 +220,7 @@ impl Session {
}
}
+ /// Handle a pointer press and start the applicable tool interaction.
fn down(&mut self, p: Point, mods: Mods, tol: f64) -> Result {
if let Some(Drag::Freeform { mode, mut points, .. }) = self.tool.drag.clone() {
// Next click of a freeform: close on the start point, else add a vertex.
@@ -303,8 +304,12 @@ impl Session {
Some(Hit::TableRow { id, row }) => self.begin_table_border(id, false, row, p)?,
Some(Hit::Resize { id, handle }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
+ let ids: Vec = if st.selection.shapes.contains(&id) { st.selection.shapes.clone() } else { vec![id] };
+ let origs: Vec<(ShapeId, Xfrm)> =
+ ids.iter().filter_map(|i| st.shape(*i).filter(|s| !s.locked).map(|s| (*i, xfrm_of(&st.doc, &st.selection, s)))).collect();
+ let ids: Vec = origs.iter().map(|(id, _)| *id).collect();
self.begin("Size");
- self.tool.drag = Some(Drag::Resize { id, handle, orig, start: p });
+ self.tool.drag = Some(Drag::Resize { id, orig, ids, handle, origs, start: p });
}
Some(Hit::LineEnd { id, end }) => {
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
@@ -436,6 +441,7 @@ impl Session {
Ok(json!({"hit": serde_json::to_value(&hit).unwrap_or_default()}))
}
+ /// Handle a pointer release and finalize the active tool interaction.
fn up(&mut self, p: Point, mods: Mods) -> Result {
let drag = self.tool.drag.take();
self.tool.guides.clear();
@@ -558,10 +564,22 @@ impl Session {
out = self.add_ink(points)?;
}
}
- Some(Drag::Resize { id, .. }) => {
+ Some(Drag::Resize { origs, .. }) => {
+ let changed: Vec = origs
+ .iter()
+ .filter_map(|(id, orig)| {
+ let current = self.active().and_then(|st| st.shape(*id).map(|s| xfrm_of(&st.doc, &st.selection, s)))?;
+
+ (current != *orig).then_some(*id)
+ })
+ .collect();
+ if !changed.is_empty() {
+ for id in changed {
+ let _ = cmd::text::fit_text_box(self, id);
+ self.refit_after_resize(id)?;
+ }
+ }
self.commit();
- let _ = cmd::text::fit_text_box(self, id);
- self.refit_after_resize(id)?;
}
Some(Drag::TextSelect { before, .. }) => {
out = self.paint_if_selection_changed(before, out)?;
diff --git a/crates/fonts/src/fallback.rs b/crates/fonts/src/fallback.rs
index de4b2b0a6..8d54453f8 100644
--- a/crates/fonts/src/fallback.rs
+++ b/crates/fonts/src/fallback.rs
@@ -227,6 +227,9 @@ 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 {
@@ -236,9 +239,12 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec {
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 = 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),
@@ -246,7 +252,6 @@ pub fn ligature_carets(face: &FontFace, gid: u32) -> Vec {
// 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
diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs
index 043b8f2dd..c1db0b356 100644
--- a/crates/fonts/src/fontdb.rs
+++ b/crates/fonts/src/fontdb.rs
@@ -30,12 +30,15 @@ pub fn bundled() -> Vec<&'static [u8]> {
/// Open substitutes for families documents often ask for but we can't ship (metric-compatible
/// where one exists), tried in order after the family itself.
pub fn substitutes(family: &str) -> &'static [&'static str] {
- match family.to_ascii_lowercase().as_str() {
+ let f = family.to_ascii_lowercase();
+ if is_monospace_family(&f) {
+ return &["Liberation Mono", "Courier New", "Menlo", "Courier", "DejaVu Sans Mono"];
+ }
+ match f.as_str() {
"calibri" | "calibri light" => &["Carlito", "Liberation Sans", "Arial", "Inter"],
"cambria" | "cambria math" => &["Caladea", "Liberation Serif", "Times New Roman", "Merriweather"],
"arial" | "helvetica" | "helvetica neue" | "arial nova" => &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"],
"times new roman" | "times" => &["Liberation Serif", "Times New Roman", "Times", "Merriweather"],
- "courier new" | "courier" | "consolas" | "menlo" => &["Liberation Mono", "Courier New", "Menlo", "Courier"],
"aptos" | "aptos display" | "aptos narrow" | "segoe ui" | "segoe ui light" | "segoe ui semibold" | "tahoma" | "verdana" => {
&["Inter", "Helvetica Neue", "Arial", "Liberation Sans"]
}
@@ -52,12 +55,77 @@ pub fn substitutes(family: &str) -> &'static [&'static str] {
}
}
+/// Families that are monospaced: when not installed, documents keep a monospaced substitute
+/// ([`substitutes`]) so code blocks and columns stay aligned instead of falling back to
+/// proportional Calibri.
+pub fn is_monospace_family(family: &str) -> bool {
+ let f = family.to_ascii_lowercase();
+ let s = f.trim();
+ if matches!(
+ s,
+ "courier new"
+ | "courier"
+ | "cousine"
+ | "consolas"
+ | "menlo"
+ | "monaco"
+ | "source code pro"
+ | "jetbrains mono"
+ | "cascadia code"
+ | "cascadia code pl"
+ | "cascadia mono"
+ | "cascadia mono pl"
+ | "fira code"
+ | "fira mono"
+ | "inconsolata"
+ | "dejavu sans mono"
+ | "sf mono"
+ | "roboto mono"
+ | "hack"
+ | "ubuntu mono"
+ | "pt mono"
+ | "liberation mono"
+ | "andale mono"
+ | "lucida console"
+ | "lucida sans typewriter"
+ | "fixedsys"
+ | "terminal"
+ | "monospace"
+ | "iosevka"
+ | "monolisa"
+ | "envy code r"
+ | "hasklig"
+ ) {
+ return true;
+ }
+ s.starts_with("courier")
+ || s.starts_with("consolas")
+ || s.starts_with("inconsolata")
+ || s.starts_with("iosevka")
+ || s.ends_with(" mono")
+ || s.ends_with(" code")
+ || s.ends_with(" console")
+ || s.ends_with(" typewriter")
+ || s.ends_with(" monospace")
+ || s.split_whitespace().any(|w| w == "mono" || w == "terminal" || w == "monospace")
+}
+
/// Families whose open substitutes ([`substitutes`]) keep their metrics, so text drawn with
/// them breaks into the same lines.
fn has_metric_twin(family: &str) -> bool {
matches!(
family.to_ascii_lowercase().as_str(),
- "calibri" | "calibri light" | "cambria" | "cambria math" | "arial" | "helvetica" | "times new roman" | "times" | "courier new" | "courier"
+ "calibri"
+ | "calibri light"
+ | "cambria"
+ | "cambria math"
+ | "arial"
+ | "helvetica"
+ | "times new roman"
+ | "times"
+ | "courier new"
+ | "courier"
+ | "cousine"
)
}
@@ -300,7 +368,7 @@ struct CatalogEntry {
/// Process-wide font database.
pub struct FontDb {
faces: RwLock>>,
- outlines: Mutex>>,
+ outlines: Mutex,
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock>,
/// The folders the system font scan reads (the platform's font folders for [`FontDb::global`]).
@@ -371,6 +439,38 @@ 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>,
+ elements: usize,
+}
+
+impl OutlineCache {
+ fn get(&self, key: &(u32, u32)) -> Option> {
+ self.paths.get(key).cloned()
+ }
+
+ fn insert(&mut self, key: (u32, u32), path: Arc) {
+ 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 {
ids.iter().find_map(|id| font.localized_strings(*id).english_or_first().map(|s| s.to_string()).filter(|s| !s.is_empty()))
@@ -626,6 +726,10 @@ 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- ) -> Vec {
let mut offsets = std::collections::HashSet::new();
@@ -662,21 +766,30 @@ fn enumerate_faces(data: &[u8]) -> Vec {
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 named.iter().any(|(_, s, _): &(u32, String, _)| s.eq_ignore_ascii_case(&style)) {
+ if !seen.insert(style.to_ascii_lowercase()) {
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)));
@@ -837,7 +950,7 @@ impl FontDb {
}
Self {
faces: RwLock::new(faces),
- outlines: Mutex::new(HashMap::new()),
+ outlines: Mutex::new(OutlineCache::default()),
#[cfg(not(target_arch = "wasm32"))]
catalog: RwLock::new(Vec::new()),
font_dirs,
@@ -1080,6 +1193,7 @@ impl FontDb {
/// when it is installed or bundled, or a metric-compatible stand-in (Carlito for Calibri,
/// Liberation Sans for Arial…), or the family PowerPoint maps it to (Liberation Sans as Arial,
/// see `office_twin`); any other missing family is drawn in Calibri (or Carlito).
+ /// Monospaced families keep a monospaced substitute instead of falling back to Calibri.
/// DeckCraft's own default family keeps [`FontDb::face`]'s fallbacks.
pub fn document_face(&self, family: &str, style: &str) -> Arc {
if family.eq_ignore_ascii_case(FALLBACK_FAMILY) || has_metric_twin(family) || is_office_cloud_font(family) || self.has_family(family) {
@@ -1088,6 +1202,9 @@ impl FontDb {
if let Some(twin) = office_twin(family) {
return self.face(twin, style);
}
+ if is_monospace_family(family) {
+ return self.face(family, style);
+ }
self.face(MISSING_FONT_FAMILY, style)
}
@@ -1441,41 +1558,72 @@ impl FontDb {
pub fn outline(&self, face: &FontFace, gid: u32) -> Arc {
let key = (face.id, gid);
if let Some(p) = self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&key) {
- return p.clone();
+ return p;
}
- let mut pen = FlipPen(BezPath::new());
+ let mut pen = FlipPen::new(MAX_OUTLINE_ELEMENTS);
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.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());
+ let p = Arc::new(pen.finish());
+ self.outlines.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(key, p.clone());
p
}
}
-struct FlipPen(BezPath);
+/// 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 }
+ }
+}
impl OutlinePen for FlipPen {
fn move_to(&mut self, x: f32, y: f32) {
- self.0.move_to((x as f64, -y as f64));
+ if self.room() {
+ self.path.move_to((x as f64, -y as f64));
+ }
}
fn line_to(&mut self, x: f32, y: f32) {
- self.0.line_to((x as f64, -y as f64));
+ if self.room() {
+ self.path.line_to((x as f64, -y as f64));
+ }
}
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
- self.0.quad_to((cx0 as f64, -cy0 as f64), (x as f64, -y as f64));
+ if self.room() {
+ self.path.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) {
- self.0.curve_to((cx0 as f64, -cy0 as f64), (cx1 as f64, -cy1 as f64), (x as f64, -y as f64));
+ 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));
+ }
}
fn close(&mut self) {
- self.0.close_path();
+ if self.room() {
+ self.path.close_path();
+ }
}
}
@@ -1503,6 +1651,55 @@ 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::());
+ }
+ 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");
diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
index 92d2cb44d..0bb0edb50 100644
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -15,7 +15,8 @@ mod shape;
pub use fallback::{bundled_families, covers_cluster, is_emoji_cluster, ligature_carets};
pub use fontdb::{
- FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, substitutes, system_font_dirs,
+ FALLBACK_FAMILY, FaceRef, FontDb, FontFace, LAST_RESORT_FAMILY, MISSING_FONT_FAMILY, base_style, bundled, is_monospace_family, substitutes,
+ system_font_dirs,
};
pub use harfrust::Feature;
use harfrust::Tag;
diff --git a/crates/fonts/src/shape.rs b/crates/fonts/src/shape.rs
index 5c88055ac..91ceef3dc 100644
--- a/crates/fonts/src/shape.rs
+++ b/crates/fonts/src/shape.rs
@@ -37,56 +37,146 @@ pub struct ShapeParams<'a> {
pub language: Option<&'a str>,
}
+/// Most bytes of text given to the shaper at once. The shaper lets one call grow its buffers to
+/// 256 glyphs per character (a font's substitutions decide how many), so a long item is shaped in
+/// pieces of this size, each with the rest of the paragraph as context.
+const MAX_SHAPE_CHUNK: usize = 1024;
+/// Most glyphs kept per character of a piece: a piece the font expands further is laid out one
+/// glyph per character from the cmap instead.
+const MAX_GLYPHS_PER_CHAR: usize = 32;
+
/// Shape `para[range]` with `face`, the rest of `para` as context. Glyphs are in visual order
/// (left to right) and clusters are byte offsets into `para`. `map` substitutes characters (All
/// Caps) without moving clusters. An empty or out-of-bounds range shapes nothing.
pub fn shape_range(face: &FontFace, para: &str, range: Range, params: &ShapeParams, map: impl Fn(char) -> char) -> Vec {
- let (Some(pre), Some(text), Some(post)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
+ let (Some(_), Some(text), Some(_)) = (para.get(..range.start), para.get(range.clone()), para.get(range.end..)) else {
return Vec::new();
};
- let mut out = Vec::with_capacity(text.len());
+ let chunks = chunks(text, MAX_SHAPE_CHUNK);
+ let mut pieces: Vec> = Vec::with_capacity(chunks.len());
let shaped = face.hb().map(|hb| {
let shaper = face.shaper.shaper(&hb).instance(face.instance.as_ref()).build();
- let mut buf = UnicodeBuffer::new();
- buf.set_pre_context(pre);
- buf.set_post_context(post);
- for (i, c) in text.char_indices() {
- buf.add(map(c), (range.start + i) as u32);
- }
- buf.guess_segment_properties();
- if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
- buf.set_script(s);
- }
- if let Some(l) = params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::().ok())
- {
- buf.set_language(l);
- }
- buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
- let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
- for (info, pos) in gb.glyph_infos().iter().zip(gb.glyph_positions()) {
- out.push(ShapedGlyph {
- gid: info.glyph_id,
- cluster: info.cluster as usize,
- x_advance: pos.x_advance,
- x_offset: pos.x_offset,
- y_offset: pos.y_offset,
- });
+ // Pieces of one item share the script the whole item would be guessed to have.
+ let item_script = (chunks.len() > 1 && params.script.is_none()).then(|| {
+ let mut probe = UnicodeBuffer::new();
+ for c in text.chars() {
+ probe.add(map(c), 0);
+ }
+ probe.guess_segment_properties();
+ probe.script()
+ });
+ for c in &chunks {
+ let r = (range.start + c.start)..(range.start + c.end);
+ let (Some(pre), Some(piece), Some(post)) = (para.get(..r.start), para.get(r.clone()), para.get(r.end..)) else { continue };
+ let mut buf = UnicodeBuffer::new();
+ buf.set_pre_context(pre);
+ buf.set_post_context(post);
+ for (i, c) in piece.char_indices() {
+ buf.add(map(c), (r.start + i) as u32);
+ }
+ buf.guess_segment_properties();
+ if let Some(s) = params.script.and_then(|t| Script::from_iso15924_tag(Tag::new(&t))) {
+ buf.set_script(s);
+ } else if let Some(s) = item_script {
+ buf.set_script(s);
+ }
+ if let Some(l) =
+ params.language.or_else(|| params.script.and_then(crate::script::default_language)).and_then(|l| l.parse::().ok())
+ {
+ buf.set_language(l);
+ }
+ buf.set_direction(if params.rtl { Direction::RightToLeft } else { Direction::LeftToRight });
+ let gb = shaper.shape(buf, ShapeOptions::new().features(params.features));
+ let budget = piece.chars().count().saturating_mul(MAX_GLYPHS_PER_CHAR);
+ if gb.len() > budget {
+ // Font-controlled expansion past anything real text needs: keep it bounded.
+ pieces.push(cmap_glyphs(face, para, r, params.rtl, &map));
+ continue;
+ }
+ pieces.push(
+ gb.glyph_infos()
+ .iter()
+ .zip(gb.glyph_positions())
+ .map(|(info, pos)| ShapedGlyph {
+ gid: info.glyph_id,
+ cluster: info.cluster as usize,
+ x_advance: pos.x_advance,
+ x_offset: pos.x_offset,
+ y_offset: pos.y_offset,
+ })
+ .collect(),
+ );
}
});
- if shaped.is_none()
- && let Some(f) = face.skrifa()
- {
+ if shaped.is_none() {
// No shaper for this face: one glyph per character from the cmap, in visual order.
- let cmap = f.charmap();
- let gm = f.glyph_metrics(Size::unscaled(), face.location());
- for (i, c) in text.char_indices() {
- let g = cmap.map(map(c)).unwrap_or_default();
- let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
- out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
- }
- if params.rtl {
- out.reverse();
+ return cmap_glyphs(face, para, range, params.rtl, &map);
+ }
+ // Each piece is in visual order; right to left, the later pieces come first.
+ if params.rtl {
+ pieces.reverse();
+ }
+ let mut out = Vec::with_capacity(pieces.iter().map(Vec::len).sum());
+ for p in pieces {
+ out.extend(p);
+ }
+ out
+}
+
+/// One glyph per character of `para[range]` from the cmap, in visual order.
+fn cmap_glyphs(face: &FontFace, para: &str, range: Range, rtl: bool, map: &impl Fn(char) -> char) -> Vec {
+ let (Some(text), Some(f)) = (para.get(range.clone()), face.skrifa()) else { return Vec::new() };
+ let cmap = f.charmap();
+ let gm = f.glyph_metrics(Size::unscaled(), face.location());
+ let mut out = Vec::with_capacity(text.len());
+ for (i, c) in text.char_indices() {
+ let g = cmap.map(map(c)).unwrap_or_default();
+ let adv = gm.advance_width(g).unwrap_or(face.upem as f32 * 0.5);
+ out.push(ShapedGlyph { gid: g.to_u32(), cluster: range.start + i, x_advance: adv.round() as i32, x_offset: 0, y_offset: 0 });
+ }
+ if rtl {
+ out.reverse();
+ }
+ out
+}
+
+/// `text` cut into byte ranges of at most `max` bytes (or one character, if longer), at the last
+/// space of a piece where there is one, else before a character that starts a cluster (not a
+/// combining mark or joiner, not after a joiner). Text that fits is one piece.
+fn chunks(text: &str, max: usize) -> Vec> {
+ use unicode_bidi::BidiClass::{BN, NSM};
+ let mut out = Vec::new();
+ let mut start = 0;
+ while text.len() - start > max {
+ let window = text.get(start..).unwrap_or_default();
+ let mut space = None;
+ let mut boundary = None;
+ let mut any = None;
+ let mut prev: Option = None;
+ for (i, c) in window.char_indices() {
+ if i > max {
+ break;
+ }
+ if i > 0 {
+ any = Some(i);
+ let joined = prev.is_some_and(|p| matches!(unicode_bidi::bidi_class(p), BN));
+ if !joined && !matches!(unicode_bidi::bidi_class(c), NSM | BN) {
+ boundary = Some(i);
+ if prev.is_some_and(char::is_whitespace) {
+ space = Some(i);
+ }
+ }
+ }
+ prev = Some(c);
}
+ // A piece always moves forward by at least one character.
+ let first = window.chars().next().map_or(window.len(), char::len_utf8);
+ let cut = space.filter(|&i| i >= max / 2).or(boundary).or(any).unwrap_or(first).max(first);
+ out.push(start..start + cut);
+ start += cut;
+ }
+ if start < text.len() || out.is_empty() {
+ out.push(start..text.len());
}
out
}
@@ -121,4 +211,37 @@ mod tests {
// Not a char boundary.
assert!(shape_range(&face, "سنة", 1..2, &ShapeParams::default(), |c| c).is_empty());
}
+
+ #[test]
+ fn long_items_are_shaped_in_bounded_pieces() {
+ for text in ["word ".repeat(2_000), "e\u{301}".repeat(2_000), "x".repeat(5_000), "\u{1f469}\u{200d}\u{1f4bb} ".repeat(500)] {
+ let pieces = chunks(&text, MAX_SHAPE_CHUNK);
+ assert_eq!(pieces.first().map(|r| r.start), Some(0));
+ assert_eq!(pieces.last().map(|r| r.end), Some(text.len()));
+ for w in pieces.windows(2) {
+ assert_eq!(w[0].end, w[1].start);
+ }
+ for r in &pieces {
+ assert!(!r.is_empty() && r.len() <= MAX_SHAPE_CHUNK);
+ // Never before a combining mark, never after a joiner.
+ let rest = &text[r.end..];
+ assert!(!rest.starts_with('\u{301}'));
+ assert!(!text[..r.end].ends_with('\u{200d}') || rest.is_empty());
+ }
+ }
+ assert_eq!(chunks("short", MAX_SHAPE_CHUNK), vec![0..5]);
+ assert_eq!(chunks("", MAX_SHAPE_CHUNK), vec![0..0]);
+ }
+
+ #[test]
+ fn long_items_keep_every_cluster_in_visual_order() {
+ let face = FontDb::global().face(DEFAULT_FAMILY, "Regular");
+ let text = "ab cd ".repeat(1_000);
+ let g = shape_range(&face, &text, 0..text.len(), &ShapeParams::default(), |c| c);
+ assert_eq!(g.len(), text.len());
+ assert!(g.windows(2).all(|w| w[0].cluster < w[1].cluster));
+ let rtl = shape_range(&face, &text, 0..text.len(), &ShapeParams { rtl: true, ..Default::default() }, |c| c);
+ assert_eq!(rtl.len(), text.len());
+ assert!(rtl.windows(2).all(|w| w[0].cluster > w[1].cluster));
+ }
}
diff --git a/crates/fonts/src/tests.rs b/crates/fonts/src/tests.rs
index a6fca4c4d..596415d51 100644
--- a/crates/fonts/src/tests.rs
+++ b/crates/fonts/src/tests.rs
@@ -216,9 +216,13 @@ fn missing_document_fonts_are_drawn_in_calibri() {
assert_eq!(f.family, db.face(MISSING_FONT_FAMILY, "Bold").family, "{missing:?}");
}
// PowerPoint's own twins and Windows' substitutes, when the font itself is missing.
- for (missing, twin) in
- [("Liberation Sans", "Arial"), ("Liberation Serif", "Times New Roman"), ("Arial CE", "Arial"), ("Times New Roman CYR", "Times New Roman")]
- {
+ for (missing, twin) in [
+ ("Liberation Sans", "Arial"),
+ ("Liberation Serif", "Times New Roman"),
+ ("Arial CE", "Arial"),
+ ("Times New Roman CYR", "Times New Roman"),
+ ("Courier New CE", "Courier New"),
+ ] {
if !db.has_family(missing) {
assert_eq!(db.document_face(missing, "Regular").family, db.face(twin, "Regular").family, "{missing}");
}
@@ -237,6 +241,73 @@ fn office_fonts_have_substitutes() {
assert!(substitutes("Times New Roman").contains(&"Liberation Serif"));
assert!(substitutes("Aptos").contains(&"Inter"));
assert!(substitutes("zzz").is_empty());
+
+ for mono in [
+ "Courier New",
+ "Cousine",
+ "Consolas",
+ "Menlo",
+ "Monaco",
+ "Source Code Pro",
+ "JetBrains Mono",
+ "Cascadia Code",
+ "Cascadia Code PL",
+ "Cascadia Mono",
+ "Fira Code",
+ "Inconsolata",
+ "DejaVu Sans Mono",
+ "Roboto Mono",
+ "Hack",
+ "Ubuntu Mono",
+ "Custom Monospace",
+ "Fantasque Sans Mono",
+ ] {
+ assert!(substitutes(mono).contains(&"Liberation Mono"), "{mono} should have Liberation Mono substitute");
+ assert!(is_monospace_family(mono), "{mono} should be recognized as monospace");
+ }
+
+ for prop in ["Calibri", "Arial", "Times New Roman", "Inter", "Monotype Corsiva", "Monoton", "Monotype Baskerville", "Code Pro", "zzz"] {
+ assert!(!is_monospace_family(prop), "{prop} should not be recognized as monospace");
+ }
+
+ // Explicit checks requested in review: Consolas, Courier New, Fira Code, Monotype Corsiva, Monoton,
+ // and ensuring proportional families maintain their unchanged substitutes.
+ assert!(is_monospace_family("Consolas"));
+ assert!(is_monospace_family("Courier New"));
+ assert!(is_monospace_family("Fira Code"));
+ assert!(!is_monospace_family("Monotype Corsiva"));
+ assert!(!is_monospace_family("Monoton"));
+
+ assert_eq!(substitutes("Calibri"), &["Carlito", "Liberation Sans", "Arial", "Inter"]);
+ assert_eq!(substitutes("Arial"), &["Liberation Sans", "Arial", "Helvetica Neue", "Helvetica", "Inter"]);
+ assert_eq!(substitutes("Times New Roman"), &["Liberation Serif", "Times New Roman", "Times", "Merriweather"]);
+ assert_eq!(substitutes("Georgia"), &["Merriweather", "Liberation Serif", "Georgia"]);
+ assert!(substitutes("Monotype Corsiva").is_empty());
+ assert!(substitutes("Monoton").is_empty());
+}
+
+#[test]
+fn missing_monospace_fonts_keep_monospace_fallback() {
+ let db = FontDb::global();
+ let mono_sub = db.face("Unavailable JetBrains Mono", "Regular").family.clone();
+ let calibri_target = db.face(MISSING_FONT_FAMILY, "Regular").family.clone();
+
+ assert!(is_monospace_family(&mono_sub), "substitute {mono_sub} must be monospace");
+
+ // Cousine is metric-compatible with Courier New / Liberation Mono and must not fall back to Calibri.
+ if !db.has_family("Cousine") {
+ assert_eq!(db.document_face("Cousine", "Regular").family, mono_sub);
+ }
+
+ // Monospace fonts missing from the system must resolve to a monospace substitute.
+ for mono in ["Unavailable JetBrains Mono", "Custom App Code", "Unknown Space Mono", "Mock Terminal Font"] {
+ let f = db.document_face(mono, "Regular");
+ assert_eq!(f.family, mono_sub, "{mono}");
+ assert!(is_monospace_family(&f.family), "{mono} must resolve to a monospace family");
+ if mono_sub != calibri_target {
+ assert_ne!(f.family, calibri_target, "{mono} must not fall back to Calibri");
+ }
+ }
}
#[test]
diff --git a/crates/format/src/chart_parts_tests.rs b/crates/format/src/chart_parts_tests.rs
index df74a183c..89b20e8b6 100644
--- a/crates/format/src/chart_parts_tests.rs
+++ b/crates/format/src/chart_parts_tests.rs
@@ -85,14 +85,14 @@ fn chart_parts_share_media_byte_and_item_budgets() {
let mut p = fixture(vec![7; 600]);
p.add_media("a.bin", "application/octet-stream", vec![8; 600]).unwrap();
let bytes = save(&p).unwrap();
- assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
- assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err(), "chart payloads share the cumulative media budget");
- assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err(), "chart parts share the media item budget");
+ assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
+ assert!(load_within(&bytes, Budget::media(1199, 2)).is_err(), "chart payloads share the cumulative media budget");
+ assert!(load_within(&bytes, Budget::media(1200, 1)).is_err(), "chart parts share the media item budget");
let duplicated = rewrite_manifest(&bytes, |manifest| {
let media = manifest["presentation"]["media"].as_array_mut().unwrap();
media.push(media[0].clone());
});
- assert!(load_within(&duplicated, Budget { total: 1200, items: 2 }).is_err(), "duplicate media IDs still count as listed items");
+ assert!(load_within(&duplicated, Budget::media(1200, 2)).is_err(), "duplicate media IDs still count as listed items");
}
#[test]
@@ -104,9 +104,9 @@ fn legacy_inline_chart_bytes_are_budgeted_and_preserved() {
part.remove("nativeEntry");
part.insert("bytes".into(), json!(vec![9_u8; 600]));
});
- assert_eq!(load_within(&legacy, Budget { total: 1200, items: 2 }).unwrap().0, p);
- assert!(load_within(&legacy, Budget { total: 1199, items: 2 }).is_err());
- assert!(load_within(&legacy, Budget { total: 1200, items: 1 }).is_err());
+ assert_eq!(load_within(&legacy, Budget::media(1200, 2)).unwrap().0, p);
+ assert!(load_within(&legacy, Budget::media(1199, 2)).is_err());
+ assert!(load_within(&legacy, Budget::media(1200, 1)).is_err());
}
#[test]
@@ -207,7 +207,7 @@ fn corrupt_chart_members_stop_before_reusing_the_inflation_budget() {
}
// A failed CRC may already have inflated bytes. Continuing with the same remaining
// budget would let each corrupt member consume that work budget again.
- let result = load_within(&bytes, Budget { total: 10, items: 2 });
+ let result = load_within(&bytes, Budget::media(10, 2));
assert!(result.is_err(), "two corrupt 10-byte members must not reuse a 10-byte budget");
assert!(result.unwrap_err().to_string().contains("chart-parts/0"));
}
@@ -228,7 +228,7 @@ fn duplicate_physical_zip_names_are_indexed_and_inflated_once() {
assert_eq!(replaced, 2);
let z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
assert_eq!(z.file_names().filter(|n| n.starts_with("chart-parts/")).count(), 1);
- let (q, damaged) = load_within(&bytes, Budget { total: 10, items: 2 }).unwrap();
+ let (q, damaged) = load_within(&bytes, Budget::media(10, 2)).unwrap();
assert_eq!(damaged.len(), 1, "the second reference is missing, not inflated again");
let ShapeKind::Group { children, .. } = &q.slides[0].shapes[0].kind else { panic!("group") };
let ShapeKind::Chart(chart) = &children[0].kind else { panic!("chart") };
diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs
index 7610398e6..6f345e11c 100644
--- a/crates/format/src/lib.rs
+++ b/crates/format/src/lib.rs
@@ -29,12 +29,24 @@ const MAX_ENTRY: u64 = 2 << 30;
const MAX_TOTAL_MEDIA: u64 = 4 << 30;
/// Media items and chart sidecar parts one file may list.
const MAX_MEDIA_ITEMS: usize = 100_000;
+/// Largest `presentation.json` we will inflate. The manifest holds no media bytes, so it is far
+/// smaller than a media entry may be; inflating and parsing one is what dominates memory.
+const MAX_MANIFEST: u64 = 256 << 20;
/// What one load may inflate.
#[derive(Clone, Copy)]
struct Budget {
total: u64,
items: usize,
+ manifest: u64,
+}
+
+#[cfg(test)]
+impl Budget {
+ /// A media budget with the default manifest limit.
+ fn media(total: u64, items: usize) -> Self {
+ Budget { total, items, manifest: MAX_MANIFEST }
+ }
}
#[derive(Debug, thiserror::Error)]
@@ -107,6 +119,7 @@ pub fn sniff(bytes: &[u8]) -> bool {
bytes.starts_with(b"PK") && [MIMETYPE, LEGACY_MIMETYPE].iter().any(|m| bytes.windows(m.len()).take(200).any(|w| w == m.as_bytes()))
}
+#[cfg(test)]
fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Result> {
read_entry_max(z, name, MAX_ENTRY)
}
@@ -114,13 +127,30 @@ fn read_entry(z: &mut zip::ZipArchive>, name: &str) -> Result>, name: &str, max: u64) -> Result> {
+ read_entry_counted(z, name, max, &mut 0)
+}
+
+/// [`read_entry_max`] within `left` (and [`MAX_ENTRY`]), charging `left` for every byte inflated,
+/// including those inflated before a decode or CRC error, so a member that fails late still
+/// spends the shared budget it used.
+fn read_entry_charged(z: &mut zip::ZipArchive>, name: &str, left: &mut u64) -> Result> {
+ let mut spent = 0;
+ let out = read_entry_counted(z, name, (*left).min(MAX_ENTRY), &mut spent);
+ *left = left.saturating_sub(spent);
+ out
+}
+
+/// [`read_entry_max`], adding the bytes inflated to `spent` whether or not the read succeeds.
+fn read_entry_counted(z: &mut zip::ZipArchive>, name: &str, max: u64, spent: &mut u64) -> Result> {
let f = z.by_name(name).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
if f.size() > max {
return Err(FormatError::Damaged(format!("{name} is too large")));
}
let mut out = Vec::with_capacity(f.size().min(64 << 20) as usize);
let mut f = f.take(max.saturating_add(1));
- f.read_to_end(&mut out).map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
+ let read = f.read_to_end(&mut out);
+ *spent = spent.saturating_add(out.len() as u64);
+ read.map_err(|e| FormatError::Damaged(format!("{name}: {e}")))?;
if out.len() as u64 > max {
return Err(FormatError::Damaged(format!("{name} is too large")));
}
@@ -137,12 +167,15 @@ pub fn load(bytes: &[u8]) -> Result {
/// A chart member that is present but fails decoding, CRC or its entry limit refuses the file.
/// Linked media saved without its bytes isn't damage.
pub fn load_checked(bytes: &[u8]) -> Result<(Presentation, Vec)> {
- load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS })
+ load_within(bytes, Budget { total: MAX_TOTAL_MEDIA, items: MAX_MEDIA_ITEMS, manifest: MAX_MANIFEST })
}
fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec)> {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| FormatError::NotOurs(e.to_string()))?;
- let json = read_entry(&mut z, "presentation.json").map_err(|_| FormatError::NotOurs("no presentation.json".into()))?;
+ if z.index_for_name("presentation.json").is_none() {
+ return Err(FormatError::NotOurs("no presentation.json".into()));
+ }
+ let json = read_entry_max(&mut z, "presentation.json", budget.manifest)?;
let m = parse_manifest(&json)?;
if m.format != "deckcraft" {
return Err(FormatError::NotOurs(m.format));
@@ -164,11 +197,9 @@ fn load_within(bytes: &[u8], budget: Budget) -> Result<(Presentation, Vec left) {
return Err(FormatError::Damaged("embedded media is larger than DeckCraft can open".into()));
}
- match read_entry_max(&mut z, &name, left.min(MAX_ENTRY)) {
- Ok(data) => {
- left -= data.len() as u64;
- item.data = Arc::new(data);
- }
+ // A failed read is charged for what it inflated, so later members can't reuse that budget.
+ match read_entry_charged(&mut z, &name, &mut left) {
+ Ok(data) => item.data = Arc::new(data),
Err(_) if linked_only => {}
Err(e) => damaged.push(e.to_string()),
}
@@ -188,9 +219,11 @@ pub fn repair(p: &mut Presentation) {
}
p.fix_next_id();
let first_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default();
+ // Indexed once: a per-slide scan of every layout is slides × layouts work.
+ let layouts: std::collections::HashSet<_> = p.masters.iter().flat_map(|m| m.layouts.iter().map(|l| l.id)).collect();
let mut seen = std::collections::HashSet::new();
for i in 0..p.slides.len() {
- let needs_layout = p.slides.get(i).is_some_and(|s| p.layout(s.layout).is_none());
+ let needs_layout = p.slides.get(i).is_some_and(|s| !layouts.contains(&s.layout));
let dup = p.slides.get(i).is_some_and(|s| !seen.insert(s.id));
if needs_layout || dup {
// An exhausted id space leaves a duplicate slide as it is: the engine refuses to
@@ -331,16 +364,20 @@ fn failure(src: &[u8]) -> std::result::Result Result {
- let mut src = json.to_vec();
- let mut last = match failure(&src) {
+ // Parse the inflated text as it is: only a file that fails on a `null` number is copied for
+ // patching (the copy is bounded by `MAX_MANIFEST`).
+ let mut last = match failure(json) {
Ok(m) => return Ok(m),
Err(f) => f,
};
+ if !is_null_number(&last.0) {
+ return Err(FormatError::Damaged(last.0));
+ }
+ let mut src = json.to_vec();
let mut budget = MAX_REPARSE_BYTES;
'fixes: for _ in 0..MAX_NULL_FIXES {
let (msg, pos) = &last;
- let number = msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number");
- if !number {
+ if !is_null_number(msg) {
break;
}
let end = offset(&src, pos.0, pos.1).unwrap_or(src.len());
@@ -374,6 +411,11 @@ fn parse_manifest(json: &[u8]) -> Result {
Err(FormatError::Damaged(last.0))
}
+/// Is this parse error a `null` where a number belongs (a NaN or infinity serde_json wrote)?
+fn is_null_number(msg: &str) -> bool {
+ msg.starts_with("invalid type: null, expected f") || msg.starts_with("invalid type: null, expected a number")
+}
+
/// Byte offset of a 1-based `line`:`column`.
fn offset(src: &[u8], line: usize, column: usize) -> Option {
let mut start = 0;
@@ -453,6 +495,9 @@ fn sane_shadow(s: &mut deckcraft_model::style::Shadow) {
fn sane_reflection(r: &mut deckcraft_model::style::Reflection) {
r.blur = effect_size(r.blur, MAX_REFLECTION);
r.dist = effect_size(r.dist, MAX_REFLECTION);
+ if r.align.as_deref().is_some_and(|v| !matches!(v, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br")) {
+ r.align = None;
+ }
}
/// Run properties a list style hands down to the text that inherits it.
diff --git a/crates/format/src/tests.rs b/crates/format/src/tests.rs
index dcd156367..aa692d03f 100644
--- a/crates/format/src/tests.rs
+++ b/crates/format/src/tests.rs
@@ -11,6 +11,31 @@ fn chart_sidecars_are_optional_for_older_native_files() {
assert_eq!(serde_json::from_value::(json).expect("empty sidecar"), chart);
}
+#[test]
+fn alternate_run_language_survives_two_native_saves_and_absence() {
+ use deckcraft_model::text::{Paragraph, Run, RunProps, TextBody};
+ let mut p = defaults::new_presentation(None);
+ Arc::make_mut(&mut p.slides[0]).shapes[0].text = Some(TextBody {
+ paragraphs: vec![Paragraph {
+ runs: vec![
+ Run::with("bonjour", RunProps { lang: Some("en-US".into()), alt_lang: Some("fr-FR".into()), ..Default::default() }),
+ Run::with(" plain", RunProps { lang: Some("en-US".into()), ..Default::default() }),
+ ],
+ ..Default::default()
+ }],
+ ..Default::default()
+ });
+ let mut q = load(&save(&p).expect("save")).expect("load");
+ for _ in 0..2 {
+ let runs = &q.slides[0].shapes[0].text.as_ref().expect("text").paragraphs[0].runs;
+ assert_eq!(runs[0].props.lang.as_deref(), Some("en-US"));
+ assert_eq!(runs[0].props.alt_lang.as_deref(), Some("fr-FR"));
+ assert_eq!(runs[1].props.lang.as_deref(), Some("en-US"));
+ assert_eq!(runs[1].props.alt_lang, None);
+ q = load(&save(&q).expect("save again")).expect("load again");
+ }
+}
+
#[test]
fn native_save_keeps_chart_relationships_and_payloads() {
use deckcraft_model::chart::{Chart, ChartKept, ChartPart, ChartRel, ChartType};
@@ -48,6 +73,38 @@ fn native_save_keeps_chart_relationships_and_payloads() {
}
}
+#[test]
+fn native_table_direction_and_column_text_survive_two_saves() {
+ use deckcraft_model::table::{Cell, Table};
+ use deckcraft_model::{Shape, ShapeId, ShapeKind};
+ use std::sync::Arc;
+
+ for rtl in [Some(true), Some(false), None] {
+ let mut p = defaults::new_presentation(None);
+ let mut table = Table::new(1, 2, 200.0, 30.0);
+ table.rtl = rtl;
+ table.rows[0].cells[0] = Cell::new("Left column");
+ table.rows[0].cells[1] = Cell::new("Right column");
+ let shape = Shape { id: ShapeId(p.alloc_id().expect("id")), kind: ShapeKind::Table(table), ..Default::default() };
+ Arc::make_mut(&mut p.slides[0]).shapes.push(shape);
+ let mut q = load(&save(&p).expect("save")).expect("load");
+ for _ in 0..2 {
+ let table = q.slides[0]
+ .shapes
+ .iter()
+ .find_map(|s| match &s.kind {
+ ShapeKind::Table(table) => Some(table),
+ _ => None,
+ })
+ .expect("table shape");
+ assert_eq!(table.rtl, rtl);
+ assert_eq!(table.rows[0].cells[0].text.paragraphs[0].runs[0].text, "Left column");
+ assert_eq!(table.rows[0].cells[1].text.paragraphs[0].runs[0].text, "Right column");
+ q = load(&save(&q).expect("save again")).expect("load again");
+ }
+ }
+}
+
/// The same package without the entries `drop` names.
fn without(bytes: &[u8], drop: &str) -> Vec {
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).unwrap();
@@ -93,9 +150,60 @@ fn media_budget_is_cumulative() {
p.add_media("b.bin", "application/octet-stream", vec![2; 600]);
let bytes = save(&p).unwrap();
// Exactly at the limit opens; one byte under is refused, though each item fits.
- assert!(load_within(&bytes, Budget { total: 1200, items: 2 }).is_ok());
- assert!(load_within(&bytes, Budget { total: 1199, items: 2 }).is_err());
- assert!(load_within(&bytes, Budget { total: 1200, items: 1 }).is_err());
+ assert!(load_within(&bytes, Budget::media(1200, 2)).is_ok());
+ assert!(load_within(&bytes, Budget::media(1199, 2)).is_err());
+ assert!(load_within(&bytes, Budget::media(1200, 1)).is_err());
+}
+
+#[test]
+fn corrupt_media_is_charged_for_what_it_inflated() {
+ let mut p = Presentation::default();
+ p.add_media("a.png", "image/png", b"PICTURE-AAAAAAAAAAAA".to_vec());
+ p.add_media("b.png", "image/png", b"PICTURE-BBBBBBBBBBBB".to_vec());
+ let mut bytes = save(&p).unwrap();
+ // Corrupt both stored members, so each fails its CRC only after inflating all its bytes.
+ for tag in [b"PICTURE-AAAA", b"PICTURE-BBBB"] {
+ let at = bytes.windows(tag.len()).position(|w| w == tag).unwrap();
+ bytes[at] ^= 0xff;
+ }
+ // Each member fits the budget on its own; the second must not reuse what the first spent.
+ let result = load_within(&bytes, Budget::media(20, 2));
+ assert!(result.is_err(), "two corrupt 20-byte members must not share a 20-byte budget");
+ // With room for both, the file opens and reports both.
+ let (_, damaged) = load_within(&bytes, Budget::media(40, 2)).unwrap();
+ assert_eq!(damaged.len(), 2, "{damaged:?}");
+}
+
+#[test]
+fn oversized_manifest_is_refused_before_inflating() {
+ let bytes = save(&Presentation::default()).unwrap();
+ let mut z = zip::ZipArchive::new(Cursor::new(bytes.as_slice())).unwrap();
+ let size = z.by_name("presentation.json").unwrap().size();
+ assert!(load_within(&bytes, Budget { total: 0, items: 0, manifest: size }).is_ok());
+ let err = load_within(&bytes, Budget { total: 0, items: 0, manifest: size - 1 }).unwrap_err();
+ assert!(err.to_string().contains("too large"), "{err}");
+}
+
+#[test]
+fn many_slides_on_missing_layouts_are_repaired() {
+ let mut p = Presentation::default();
+ let template = p.slides[0].clone();
+ let good = template.layout;
+ p.slides = (0..2000u32)
+ .map(|i| {
+ let mut s = (*template).clone();
+ s.id = deckcraft_model::SlideId(10_000 + i);
+ if i % 2 == 1 {
+ s.layout = deckcraft_model::LayoutId(900_000 + i);
+ }
+ Arc::new(s)
+ })
+ .collect();
+ p.next_id = 20_000;
+ repair(&mut p);
+ assert!(p.validate().is_empty(), "{:?}", p.validate().first());
+ assert!(p.slides.iter().all(|s| p.layout(s.layout).is_some()));
+ assert_eq!(p.slides[0].layout, good);
}
#[test]
@@ -124,7 +232,7 @@ fn repeated_media_ids_are_read_once() {
}
let bytes = out.finish().unwrap().into_inner();
// 51 references to one 1000-byte member fit a 1000-byte budget: it's inflated once.
- let q = load_within(&bytes, Budget { total: 1000, items: 51 }).unwrap().0;
+ let q = load_within(&bytes, Budget::media(1000, 51)).unwrap().0;
assert_eq!(q.media.len(), 1);
assert_eq!(q.media[0].data.len(), 1000);
// And the deck saves again.
@@ -208,6 +316,54 @@ fn a_file_saved_with_non_finite_numbers_reopens() {
assert_eq!(load(&save(&back).unwrap()).unwrap(), back);
}
+#[test]
+fn reflection_transform_survives_two_native_saves_and_absent_fields_stay_absent() {
+ use deckcraft_model::style::Reflection;
+ use deckcraft_model::{Effects, Shape};
+ let mut p = Presentation::default();
+ let reflection = Reflection {
+ blur: 1.0,
+ start_alpha: 0.5,
+ end_alpha: 0.0,
+ end_pos: 0.75,
+ dist: 2.0,
+ st_pos: Some(20_000),
+ fade_dir: Some(0),
+ sx: Some(75_000),
+ sy: Some(-50_000),
+ kx: Some(600_000),
+ align: Some("bl".into()),
+ rotate_with_shape: Some(false),
+ ..Default::default()
+ };
+ let shape = Shape { effects: Some(Effects { reflection: Some(reflection.clone()), ..Default::default() }), ..Default::default() };
+ Arc::make_mut(&mut p.slides[0]).shapes.push(shape);
+ let once = load(&save(&p).expect("save")).expect("load");
+ let twice = load(&save(&once).expect("save again")).expect("load again");
+ assert_eq!(twice.slides[0].shapes.last().and_then(|s| s.effects.as_ref()).and_then(|e| e.reflection.as_ref()), Some(&reflection));
+
+ let absent = Reflection {
+ blur: 1.0,
+ start_alpha: 1.0,
+ end_alpha: 0.0,
+ end_pos: 1.0,
+ dist: 0.0,
+ st_pos: None,
+ dir: None,
+ fade_dir: None,
+ sx: None,
+ sy: None,
+ kx: None,
+ ky: None,
+ align: None,
+ rotate_with_shape: None,
+ };
+ let shape = Shape { effects: Some(Effects { reflection: Some(absent.clone()), ..Default::default() }), ..Default::default() };
+ Arc::make_mut(&mut p.slides[0]).shapes = vec![shape];
+ let reopened = load(&save(&p).expect("save absent values")).expect("load absent values");
+ assert_eq!(reopened.slides[0].shapes.last().and_then(|s| s.effects.as_ref()).and_then(|e| e.reflection.as_ref()), Some(&absent));
+}
+
/// Files written before [`finite::Finite`] (plain serde_json: NaN as `null`) open too,
/// including a `null` inside a tagged enum, where serde reports only the enclosing object.
#[test]
diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs
index 3be72e03f..a08bed987 100644
--- a/crates/geom/src/lib.rs
+++ b/crates/geom/src/lib.rs
@@ -71,7 +71,10 @@ impl Unit {
/// `2.08"` style text for a length in points.
pub fn format(self, pt: f64) -> String {
let v = pt / self.points_per_unit();
- let s = format!("{v:.2}");
+ let mut s = format!("{v:.2}");
+ if s.starts_with('-') && s[1..].bytes().all(|b| b == b'0' || b == b'.') {
+ s.remove(0);
+ }
format!("{s}{}", self.suffix())
}
/// Parses `2.5`, `2.5"`, `2.5 in`, `6 cm`, `30 pt`, `40px`, `5mm` into points. A bare number uses `self`.
@@ -153,8 +156,8 @@ impl Xfrm {
}
/// Rotation normalised to 0..360.
pub fn rot_norm(&self) -> f64 {
- let r = self.rot % 360.0;
- if r < 0.0 { r + 360.0 } else { r }
+ let r = self.rot.rem_euclid(360.0);
+ if r >= 360.0 { 0.0 } else { r }
}
}
@@ -222,6 +225,14 @@ pub fn preset_flatten(path: &BezPath, f: &mut dyn FnMut(PathEl)) {
mod tests {
use super::*;
+ #[test]
+ fn format_has_no_negative_zero() {
+ assert_eq!(Unit::Points.format(-0.001), "0.00 pt");
+ assert_eq!(Unit::Inches.format(-0.001), "0.00\"");
+ assert!(!Unit::Centimeters.format(-0.01).starts_with('-'));
+ assert_eq!(Unit::Points.format(-1.5), "-1.50 pt");
+ }
+
#[test]
fn emu_roundtrip_is_exact() {
for emu in [0i64, 1, 12_699, 12_700, 914_400, 12_192_000, 6_858_000, -5, 123_456_789] {
@@ -266,6 +277,14 @@ mod tests {
assert!((p.x - 300.0).abs() < 1e-9 && (p.y - 200.0).abs() < 1e-9);
}
+ #[test]
+ fn rot_norm_stays_below_360() {
+ for rot in [-1e-15, -1e-13, -f64::EPSILON, -0.0, 360.0, 720.0, -360.0] {
+ let n = Xfrm { rot, ..Default::default() }.rot_norm();
+ assert!((0.0..360.0).contains(&n), "rot {rot} -> {n}");
+ }
+ }
+
#[test]
fn segment_distance() {
assert!((dist_to_segment(Point::new(5.0, 5.0), Point::new(0.0, 0.0), Point::new(10.0, 0.0)) - 5.0).abs() < 1e-9);
diff --git a/crates/geom/src/morph.rs b/crates/geom/src/morph.rs
index 411da5f93..154db5e87 100644
--- a/crates/geom/src/morph.rs
+++ b/crates/geom/src/morph.rs
@@ -126,6 +126,21 @@ fn split_curve(c: &[Point], n: usize, out: &mut Vec) {
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 {
+ 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 {
@@ -136,11 +151,15 @@ 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 = 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)> =
+ len.iter().enumerate().map(|(k, l)| (Share(*l), std::cmp::Reverse(k))).collect();
for _ in 0..n - have {
- 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 });
+ let Some((_, std::cmp::Reverse(best))) = heap.pop() else { break };
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);
@@ -420,6 +439,16 @@ 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)] };
diff --git a/crates/geom/src/warp.rs b/crates/geom/src/warp.rs
index d3035d512..81376d7d7 100644
--- a/crates/geom/src/warp.rs
+++ b/crates/geom/src/warp.rs
@@ -928,11 +928,25 @@ 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