mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 07:38:16 +00:00
Merge the landing branch into land-r8
# Conflicts: # contributors/people.toml # crates/engine/src/cmd/file.rs # crates/pptx/src/opc.rs # crates/pptx/tests/roundtrip.rs # crates/ui-egui/src/backstage.rs # crates/ui-egui/src/lib.rs
This commit is contained in:
commit
bb15c0a033
76 files changed
+6113
-477
No files matched your search
@@ -75,7 +75,7 @@ Today it can:
|
||||
- **Present**: transitions (including Morph), entrance/emphasis/exit and motion-path animations,
|
||||
by-paragraph builds, full-screen slide show, presenter view, pen, rehearse timings, media playback.
|
||||
- **Exchange files**: open and save PowerPoint `.pptx`, export PDF (slides, notes pages, handouts
|
||||
with selectable text), PNG/JPEG and outlines. Unsaved work is kept by AutoRecover.
|
||||
with selectable text), PNG/JPEG, an animated GIF of the deck and outlines. Unsaved work is kept by AutoRecover.
|
||||
- **Automate**: 200+ commands with undo, all scriptable from the CLI, a JSON control channel and an
|
||||
MCP server for AI agents.
|
||||
|
||||
@@ -150,6 +150,7 @@ deckcraft-cli commands format. # list commands
|
||||
deckcraft-cli run --sample --cmd 'slide.new={"layout":"titleOnly","title":"Hello"}' --save hello.deckcraft
|
||||
deckcraft-cli mcp # MCP server over stdio (headless)
|
||||
deckcraft-cli mcp --connect 7990 # drive the running app (deckcraft --control 7990)
|
||||
deckcraft-cli mcp --automation-read-root DIR --automation-write-root DIR # confine the headless agent to folders
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
+12
@@ -33,6 +33,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Trim Media dialog follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: chart trendlines are drawn; previous: trivial, chart trendlines round-trip; hours to ~95% per audience added; ready ~55%; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Format Background picture options; percentages unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: copy and paste rich text and pictures with other apps; previous: hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (animation preview on the canvas; breadth 79% → 80%, Animations 79% → 85%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: Format Shape fill transparency follows the selection; breadth stays 79%; previous: minor, hours to ~95%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -222,6 +223,8 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | Design ▸ Effects: named effect schemes (DeckCraft, Soft, Crisp, Lift, Glow, Plain) replace the theme's fills, lines and effects and leave colours and fonts. `design.effects` |
|
||||
| 2026-10-14 | Slide Master: Preserve on the open master or layout, and Title and Footers placeholder checkboxes. Two commands (`master.preserve`, `master.placeholders`); dialogs stay 27; breadth stays 79% |
|
||||
| 2026-10-14 | Trim Media: start, end and fades open from the selected clip, so OK no longer resets them to zero |
|
||||
| 2026-10-14 | Format Background: a solid fill's transparency, and for a picture transparency, tile, offset, scale, alignment and mirror. Apply to All copies a picture background onto every slide. The same `design.background` command; dialogs stay 27 |
|
||||
| 2026-10-13 | Editor slide rasters sit on the physical pixel grid, so small text stays sharp on a Retina display instead of being blurred by a linear filter between pixels (#184) |
|
||||
| 2026-10-14 | Copy and paste with other apps: bold, italic and underline travel as HTML (Pages keeps them); a shape with no text still copies as DeckCraft objects so Ctrl+V pastes it (#182) |
|
||||
| 2026-10-13 | Animations ▸ Preview plays the current slide's animations on the editor canvas (Play All does the same); Escape or a click stops it. Breadth 79% → 80%, Animations 79% → 85% |
|
||||
| 2026-10-14 | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged |
|
||||
@@ -242,6 +245,9 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) |
|
||||
| 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) |
|
||||
| 2026-10-12 | Canvas: a right click selects the shape under the pointer before the context menu opens (`contextclick` pointer kind for the control channel and MCP); in edited text the menu offers text items |
|
||||
| 2026-10-12 | Canvas: dragging the frame of a text box being edited moves it (move cursor on the frame, one undo step); the caret zone follows rotated boxes |
|
||||
| 2026-10-13 | Start screen: a missing Recent file is marked and its click shows the error there; entries can be removed from the list (#172) |
|
||||
| 2026-10-12 | Tables: the caret and text selection show in the cell being edited; clicks and double-clicks place the caret in the clicked cell (and select the word); dragging inner column borders, the right edge and row borders resizes them in one undo step; Tab skips cells hidden under a merge |
|
||||
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
|
||||
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
|
||||
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
|
||||
@@ -250,6 +256,8 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-12 | File tab (Backstage) like PowerPoint's (#76): File or Alt+F opens Home, New, Open, Info, Save, Save As, Print, Export, Close, Account and Options over the window, Esc or Back returns, arrows walk the side menu; the start screen is File › Home. Pages run the existing file commands; new `file.info`, print slide ranges (`"1-3, 5"`), Print through a PDF, Ctrl+P, and Close/Ctrl+W ask about unsaved changes |
|
||||
| 2026-10-12 | WordArt text warps (`prstTxWarp`): all 40 presets drawn, curves fitted to PowerPoint by observation; warp adjusts round-trip |
|
||||
| 2026-10-12 | Render: line widths, dashes and arrowheads inside a group no longer scale with the group's child-coordinate mapping, so a group in a small legacy child space (scale about 1600) draws thin lines instead of covering the slide (#233) |
|
||||
| 2026-10-12 | `deckcraft-cli help` and each subcommand's `--help` explain the CLI to an agent; `describe` spells out a command's parameters and what it acts on; `commands` lists one command per line |
|
||||
| 2026-10-11 | Animated GIF export of the deck: the Export… dialog (Animated GIF), `file.export` format `gif`, CLI and MCP (#171) |
|
||||
| 2026-10-10 | Progress docs restructured to the craftrules standard; full re-measure (breadth 79%, ready ~55%, mainstream ~53%, essentials ~62%, alpha). Landed: Animation Painter (#54), Morph geometry outlines (#27), animated GIF playback (#39), hyperlink colour and following links in the show (#37, #45), date fields in their own format (#36), flipped shapes keep readable text (#35), show transition fix (#46), start screen from the Home button (#49), MCP export lists PDF (#65), OmaStore manifest (#57) |
|
||||
| 2026-10-09 | v0.4.0 released. UAX #9 bidi and Arabic shaping (#4), Morph with Words and Characters (#5), tables grow to fit and PDF cell text (#14), rotating log file (#7), DX12 default on Windows (#16), H.264/HEVC overflow hardening (#2), PPTX sniffing anywhere in the zip (#19) |
|
||||
| 2026-10-08 | v0.2.0 and v0.3.0 released: Windows arm64, Flatpak aarch64, AppImage zsync updates, branded DMG |
|
||||
@@ -300,6 +308,8 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-14 | trivial | Progress log: Design ▸ Effects |
|
||||
| 2026-10-14 | trivial | Progress log: Slide Master Preserve and Title/Footers placeholders. Percentages unchanged |
|
||||
| 2026-10-14 | trivial | Progress log: Trim Media dialog follows the selection |
|
||||
| 2026-10-14 | trivial | Progress log: Format Background picture and solid options |
|
||||
| 2026-10-13 | trivial | Progress log: editor rasters on the Retina pixel grid (#184) |
|
||||
| 2026-10-14 | trivial | Progress log: copy and paste rich text and pictures with other apps |
|
||||
| 2026-10-13 | trivial | Progress log: animation preview on the editor canvas; breadth 80%, Animations 85% |
|
||||
| 2026-10-14 | trivial | Progress log: Format Shape fill transparency follows the selection |
|
||||
@@ -311,8 +321,10 @@ app's JSON control channel (`deckcraft --control PORT`).
|
||||
| 2026-10-13 | trivial | Progress log: chart workbook round-trip (#335) |
|
||||
| 2026-10-13 | trivial | Progress log: wild text cases |
|
||||
| 2026-10-13 | trivial | Progress log: slide show for interactive decks |
|
||||
| 2026-10-12 | minor | Progress log: table cell editing and border resizing |
|
||||
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
|
||||
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
|
||||
| 2026-10-11 | minor | Progress log: animated GIF export (#171); P2 breadth 36% → 37%, overall breadth stays 79% |
|
||||
| 2026-10-10 | minor | Readiness-by-audience table with hours to ~95% for full, mainstream and essentials |
|
||||
| 2026-10-10 | minor | Added mainstream practitioner (~53%) and essentials user (~62%); features recomputed from written area weights, ready ~56% → ~55% |
|
||||
| 2026-10-10 | minor | Stage checked against the new core-workflow gate: passes, stays alpha |
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Parse `--automation-read-root` / `--automation-write-root` before the server starts. A malformed
|
||||
//! flag must not silently remove a root and leave the server unconfined.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct AutomationArgs {
|
||||
pub read_root: Option<PathBuf>,
|
||||
pub write_root: Option<PathBuf>,
|
||||
read_given: bool,
|
||||
write_given: bool,
|
||||
}
|
||||
|
||||
impl AutomationArgs {
|
||||
pub fn new(read_root: Option<PathBuf>, write_root: Option<PathBuf>) -> Self {
|
||||
Self { read_root, write_root, read_given: false, write_given: false }
|
||||
}
|
||||
|
||||
/// Returns whether this argument is a root flag. A second occurrence of a flag is an error,
|
||||
/// including when the two spellings (`--flag DIR`, `--flag=DIR`) are mixed.
|
||||
pub fn consume(&mut self, arg: &str, args: &mut impl Iterator<Item = String>) -> Result<bool, String> {
|
||||
let (flag, inline) = arg.split_once('=').map_or((arg, None), |(flag, value)| (flag, Some(value)));
|
||||
let (root, given) = match flag {
|
||||
"--automation-read-root" => (&mut self.read_root, &mut self.read_given),
|
||||
"--automation-write-root" => (&mut self.write_root, &mut self.write_given),
|
||||
_ => return Ok(false),
|
||||
};
|
||||
if *given {
|
||||
return Err(format!("{flag} given twice"));
|
||||
}
|
||||
let value = match inline {
|
||||
Some(value) => value.to_owned(),
|
||||
None => {
|
||||
let value = args.next().ok_or_else(|| format!("{flag} needs a folder"))?;
|
||||
if value.starts_with('-') {
|
||||
return Err(format!("{flag} needs a folder; use {flag}=<dir> for a folder starting with '-'"));
|
||||
}
|
||||
value
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Err(format!("{flag} needs a non-empty folder"));
|
||||
}
|
||||
*root = Some(value.into());
|
||||
*given = true;
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn roots_are_taken_once_and_other_arguments_are_left() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
let mut args = ["a folder".to_owned(), "deck.pptx".to_owned()].into_iter();
|
||||
assert!(roots.consume("--automation-read-root", &mut args).unwrap());
|
||||
assert!(roots.consume("--automation-write-root=out", &mut args).unwrap());
|
||||
assert!(!roots.consume("--sample", &mut args).unwrap());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
assert_eq!(roots.write_root, Some("out".into()));
|
||||
assert_eq!(args.next().as_deref(), Some("deck.pptx"));
|
||||
assert!(roots.consume("--automation-read-root=other", &mut args).is_err());
|
||||
assert_eq!(roots.read_root, Some("a folder".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_operands_leave_roots_unchanged() {
|
||||
for flag in ["--automation-read-root", "--automation-write-root"] {
|
||||
for operand in [None, Some(""), Some("--version"), Some("--connect"), Some("-folder")] {
|
||||
let mut roots = AutomationArgs::new(Some("read".into()), Some("write".into()));
|
||||
let mut args = operand.map(str::to_owned).into_iter();
|
||||
assert!(roots.consume(flag, &mut args).is_err(), "{flag} {operand:?}");
|
||||
assert_eq!(roots.read_root, Some("read".into()));
|
||||
assert_eq!(roots.write_root, Some("write".into()));
|
||||
}
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume(&format!("{flag}="), &mut std::iter::empty()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_dash_folder_and_relative_dash_folder_are_valid() {
|
||||
let mut roots = AutomationArgs::new(None, None);
|
||||
assert!(roots.consume("--automation-read-root=-folder", &mut std::iter::empty()).unwrap());
|
||||
assert!(roots.consume("--automation-write-root", &mut ["./-folder".to_owned()].into_iter()).unwrap());
|
||||
assert_eq!(roots.read_root, Some("-folder".into()));
|
||||
assert_eq!(roots.write_root, Some("./-folder".into()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
pub struct Subcommand {
|
||||
pub name: &'static str,
|
||||
pub usage: &'static str,
|
||||
pub summary: &'static str,
|
||||
pub details: &'static str,
|
||||
}
|
||||
|
||||
pub const SUBCOMMANDS: &[Subcommand] = &[
|
||||
Subcommand {
|
||||
name: "info",
|
||||
usage: "info FILE",
|
||||
summary: "print a presentation's slides, layouts and theme as JSON",
|
||||
details: "Prints FILE (.deckcraft or .pptx) as JSON: its slides (index, id, title, layout, how many shapes,
|
||||
notes, transition), its layouts, theme fonts, sections and slide size.
|
||||
|
||||
The shapes of one slide, with their ids, boxes and text, come from the slide.inspect command:
|
||||
|
||||
deckcraft-cli run --in deck.deckcraft --print --cmd 'slide.inspect={\"index\":0}'",
|
||||
},
|
||||
Subcommand {
|
||||
name: "render",
|
||||
usage: "render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT",
|
||||
summary: "draw slides as PNG images",
|
||||
details: "Draws slides of FILE, or of the sample presentation with --sample, as PNG images. FILE comes first;
|
||||
OUT is the image to write. With --all, OUT is a folder that gets one slide-01.png, slide-02.png… per
|
||||
slide. Prints each image's path and size.
|
||||
|
||||
--slide N the slide at index N; indices start at 0, and 0 is the default
|
||||
--scale S pixels per point, 2 by default: a 960×540 slide becomes 1920×1080
|
||||
--edit draw what the editor shows: placeholder prompts and their dashed outlines
|
||||
|
||||
deckcraft-cli render deck.deckcraft --slide 2 slide-3.png",
|
||||
},
|
||||
Subcommand {
|
||||
name: "render-transition",
|
||||
usage: "render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png",
|
||||
summary: "draw frames of the transition into a slide as PNG images",
|
||||
details: "Draws the transition into slide N of FILE at each time T, from 0 (the slide before) to 1 (slide N),
|
||||
as PNG images. With one T, OUT.png is the image; with several, each frame is written next to it as
|
||||
OUT-t0.250.png and so on. Prints each image's path, size, transition and time.
|
||||
|
||||
--slide N the slide the transition leads into; indices start at 0
|
||||
--t T,T... the times to draw, comma-separated
|
||||
--width PX the image width in pixels, twice the slide width in points by default
|
||||
|
||||
deckcraft-cli render-transition deck.deckcraft --slide 1 --t 0.25,0.5,0.75 fade.png",
|
||||
},
|
||||
Subcommand {
|
||||
name: "convert",
|
||||
usage: "convert IN OUT",
|
||||
summary: "save a presentation in the format OUT's extension names",
|
||||
details: "Opens IN (.deckcraft or .pptx) and writes it to OUT, in the format OUT's extension names:
|
||||
.deckcraft, .pptx, .pdf, .txt or .md for the outline, .png or .jpg for the first slide, .gif for an
|
||||
animated GIF with one frame per slide. Other extensions are refused. Prints OUT.
|
||||
|
||||
deckcraft-cli convert deck.deckcraft deck.pptx",
|
||||
},
|
||||
Subcommand {
|
||||
name: "run",
|
||||
usage: "run [--in FILE | --sample] [--cmd COMMAND[=PARAMS]]... [--save OUT] [--export OUT [--export-params JSON]] [--print]",
|
||||
summary: "run commands on a presentation, then save or export it",
|
||||
details: "Runs commands, in order, on one presentation: FILE with --in, the sample presentation with --sample,
|
||||
else a new one with an empty title slide. Then --save writes the presentation: .deckcraft keeps
|
||||
everything; .pptx, .pdf and .md (the outline) are written as convert writes them. --export writes the
|
||||
current slide as .png or .jpg, or the presentation as .pdf, .pptx or an animated .gif (one frame per
|
||||
slide). --export-params passes file.export options as one JSON object, such as
|
||||
'{\"width\": \"large\", \"secondsPerSlide\": 3, \"loop\": false, \"slides\": [0, 1, 2]}' for a GIF
|
||||
(`deckcraft-cli describe file.export` lists them).
|
||||
|
||||
Each --cmd is one command: COMMAND is a command id, then `=`, then the command's parameters as one
|
||||
JSON object. Quote it for the shell. A command that needs no parameters is just its id.
|
||||
|
||||
deckcraft-cli run \\
|
||||
--cmd 'slide.new={\"layout\":\"titleOnly\",\"title\":\"Q3 results\"}' \\
|
||||
--cmd 'shape.insert={\"preset\":\"roundRect\",\"rect\":[80,160,320,120],\"text\":\"Revenue\"}' \\
|
||||
--cmd 'format.size={\"size\":28}' \\
|
||||
--save q3.deckcraft
|
||||
|
||||
`deckcraft-cli commands` lists the command ids; `deckcraft-cli describe COMMAND` explains one and its
|
||||
parameters.
|
||||
|
||||
What the ids don't say:
|
||||
- Positions and sizes are in points on a 960×540 slide, x to the right and y down; a rect is
|
||||
[x, y, width, height].
|
||||
- Slide indices start at 0. slide.new inserts after the current slide and makes the new one current;
|
||||
slide.go {\"index\": N} makes slide N current.
|
||||
- Shape ids are looked up on the current slide only.
|
||||
- A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it. format.* acts
|
||||
on the selected text or shapes; edit.select {\"ids\": [...]} selects others. Many shape commands
|
||||
take the ids directly; `deckcraft-cli describe COMMAND` says what a command acts on.
|
||||
|
||||
If a command fails, run stops there and saves nothing. --print shows each command's answer, such as
|
||||
the id of an inserted shape.",
|
||||
},
|
||||
Subcommand {
|
||||
name: "commands",
|
||||
usage: "commands [FILTER]",
|
||||
summary: "list the commands run can use, one per line",
|
||||
details: "Lists the commands that run --cmd, app and the MCP server take, one per line: the id, its name in
|
||||
the app and its parameters. FILTER keeps the commands whose id or name contains it, ignoring case.
|
||||
|
||||
deckcraft-cli commands format.
|
||||
|
||||
In the parameters, `?` marks an optional one, a spaced ` | ` separates alternatives and `→` shows what
|
||||
the command answers. `deckcraft-cli describe COMMAND` spells one command out.",
|
||||
},
|
||||
Subcommand {
|
||||
name: "describe",
|
||||
usage: "describe COMMAND",
|
||||
summary: "explain one command and its parameters",
|
||||
details: "Explains one command: what it is in the app, each of its parameters, what it answers, and how run
|
||||
calls it.
|
||||
|
||||
deckcraft-cli describe shape.insert",
|
||||
},
|
||||
Subcommand {
|
||||
name: "app",
|
||||
usage: "app [--port PORT] COMMAND [PARAMS] | --method METHOD [PARAMS]",
|
||||
summary: "run a command in the DeckCraft app started with --control",
|
||||
details: "Sends one request to the DeckCraft app started with `deckcraft --control PORT` (port 7979 by default)
|
||||
and prints its answer as JSON. COMMAND [PARAMS] runs a command, as run --cmd does; --method METHOD
|
||||
[PARAMS] calls a control-channel method such as ui.screenshot. PARAMS is a JSON object.
|
||||
|
||||
deckcraft-cli app shape.insert '{\"preset\":\"star5\",\"rect\":[100,100,200,200]}'
|
||||
deckcraft-cli app --method ui.screenshot '{\"path\":\"/tmp/shot.png\"}'",
|
||||
},
|
||||
Subcommand {
|
||||
name: "mcp",
|
||||
usage: "mcp [--connect PORT] [--sample] [--automation-read-root DIR] [--automation-write-root DIR]",
|
||||
summary: "serve the commands to an agent over MCP on stdio",
|
||||
details: "Runs a Model Context Protocol server on stdin and stdout. It works on its own presentation without a
|
||||
window, or with --connect PORT (or host:port) on the DeckCraft app started with --control PORT.
|
||||
--sample opens the sample presentation.
|
||||
|
||||
--automation-read-root DIR and --automation-write-root DIR confine every file the agent's commands read
|
||||
(open, pictures, audio, video) and write (save, export, PDF) to those folders, links followed; a root left
|
||||
out grants none of its access. They run the in-process server, so they conflict with --connect (a running
|
||||
app is not confined by them).",
|
||||
},
|
||||
Subcommand {
|
||||
name: "bench",
|
||||
usage: "bench (FILE | --sample) [--runs N] [--json]",
|
||||
summary: "time opening, drawing, saving and exporting a presentation",
|
||||
details: "Times how long FILE, or the sample presentation with --sample, takes to open, to draw each slide, to
|
||||
save as .deckcraft and .pptx, to reopen as .pptx and to export as .pdf, and the peak memory. Prints the
|
||||
median and slowest of N runs (5 by default, 1 to 1000) as a table, or as JSON with --json.
|
||||
|
||||
deckcraft-cli bench deck.pptx --runs 10",
|
||||
},
|
||||
Subcommand {
|
||||
name: "links",
|
||||
usage: "links",
|
||||
summary: "print the DeckCraft community and project links",
|
||||
details: "Prints the DeckCraft community and project links.",
|
||||
},
|
||||
Subcommand { name: "version", usage: "--version", summary: "print the version", details: "Prints the version of deckcraft-cli." },
|
||||
];
|
||||
|
||||
pub fn page(name: &str) -> Option<String> {
|
||||
let name = match name {
|
||||
"help" | "--help" | "-h" => return Some(overview()),
|
||||
"--version" | "-V" => "version",
|
||||
name => name,
|
||||
};
|
||||
SUBCOMMANDS.iter().find(|s| s.name == name).map(|s| format!("usage: deckcraft-cli {}\n\n{}", s.usage, s.details))
|
||||
}
|
||||
|
||||
pub fn overview() -> String {
|
||||
let width = SUBCOMMANDS.iter().map(|s| s.usage.split(' ').next().unwrap_or_default().len()).max().unwrap_or_default();
|
||||
let list: Vec<String> =
|
||||
SUBCOMMANDS.iter().map(|s| format!(" {:<width$} {}", s.usage.split(' ').next().unwrap_or_default(), s.summary)).collect();
|
||||
format!(
|
||||
"deckcraft-cli works on DeckCraft presentations (.deckcraft, .pptx) without a window.
|
||||
|
||||
{}
|
||||
|
||||
A presentation is edited by running commands on it:
|
||||
|
||||
deckcraft-cli run --cmd 'slide.new={{\"title\":\"Q3 results\"}}' --save q3.deckcraft
|
||||
|
||||
`deckcraft-cli help SUBCOMMAND`, or `deckcraft-cli SUBCOMMAND --help`, explains one subcommand;
|
||||
`deckcraft-cli help run` explains how to write a command.",
|
||||
list.join("\n")
|
||||
)
|
||||
}
|
||||
+187
-62
@@ -1,22 +1,11 @@
|
||||
//! Headless DeckCraft.
|
||||
//!
|
||||
//! ```text
|
||||
//! deckcraft-cli info FILE # slides, titles, layouts (JSON)
|
||||
//! deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] OUT.png|OUT_DIR
|
||||
//! N starts at 0. --all writes slide-01.png, slide-02.png, …
|
||||
//! deckcraft-cli render-transition FILE --slide N --t T[,T…] [--width PX] OUT.png # transition frame(s)
|
||||
//! deckcraft-cli convert IN OUT # .deckcraft ⇄ .pptx, outline .txt, .png (first slide)
|
||||
//! deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||||
//! deckcraft-cli commands [FILTER] # list commands (JSON)
|
||||
//! deckcraft-cli describe ID # one command
|
||||
//! deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON] # drive the running app
|
||||
//! deckcraft-cli mcp [--connect PORT] [--sample] # MCP server over stdio
|
||||
//! deckcraft-cli bench [FILE | --sample] [--runs N] [--json] # time open, render, save, export
|
||||
//! deckcraft-cli links | --version
|
||||
//! ```
|
||||
//! Headless DeckCraft. `deckcraft-cli help` explains each subcommand (`help.rs`).
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod automation_args;
|
||||
|
||||
mod help;
|
||||
|
||||
use std::process::ExitCode;
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
@@ -35,23 +24,28 @@ macro_rules! outln {
|
||||
}};
|
||||
}
|
||||
|
||||
const USAGE: &str = "usage: deckcraft-cli info FILE
|
||||
deckcraft-cli render (FILE | --sample) [--slide N | --all] [--scale S] [--edit] OUT
|
||||
N starts at 0 (the first slide). --all writes slide-01.png, slide-02.png, …
|
||||
deckcraft-cli render-transition FILE --slide N --t T[,T...] [--width PX] OUT.png
|
||||
deckcraft-cli convert IN OUT
|
||||
deckcraft-cli run [--in FILE | --sample] [--cmd ID[=JSON]]... [--save OUT] [--export OUT] [--print]
|
||||
deckcraft-cli commands [FILTER]
|
||||
deckcraft-cli describe COMMAND
|
||||
deckcraft-cli app [--port PORT] COMMAND [JSON] | --method METHOD [JSON]
|
||||
deckcraft-cli mcp [--connect PORT] [--sample]
|
||||
deckcraft-cli bench [FILE | --sample] [--runs N] [--json]
|
||||
deckcraft-cli links | --version";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let rest = args.get(1..).unwrap_or(&[]);
|
||||
let asks_help = rest.iter().any(|a| a == "--help" || a == "-h");
|
||||
if let Some(page) = args.first().filter(|_| asks_help).and_then(|n| help::page(n)) {
|
||||
outln!("{page}");
|
||||
return ExitCode::SUCCESS;
|
||||
}
|
||||
match args.first().map(String::as_str) {
|
||||
Some("help" | "--help" | "-h") => match rest.first() {
|
||||
None => {
|
||||
outln!("{}", help::overview());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some(name) => match help::page(name) {
|
||||
Some(page) => {
|
||||
outln!("{page}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
None => unknown(name),
|
||||
},
|
||||
},
|
||||
Some("--version" | "-V" | "version") => {
|
||||
outln!("deckcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
ExitCode::SUCCESS
|
||||
@@ -62,24 +56,17 @@ fn main() -> ExitCode {
|
||||
Some("render-transition") => report(render_transition(rest)),
|
||||
Some("convert") => report(convert(rest)),
|
||||
Some("run") => report(run(rest)),
|
||||
Some("--help" | "-h" | "help") => {
|
||||
outln!("{USAGE}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("commands") if rest.first().is_some_and(|a| is_help(a)) => {
|
||||
outln!("{USAGE}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("commands") => {
|
||||
let s = Session::new();
|
||||
let f = rest.first().map(|x| x.to_lowercase());
|
||||
let list: Vec<Value> = s
|
||||
.commands()
|
||||
.into_iter()
|
||||
for c in deckcraft_engine::command_specs()
|
||||
.iter()
|
||||
.filter(|c| f.as_ref().is_none_or(|f| c.id.to_lowercase().contains(f.as_str()) || c.label.to_lowercase().contains(f.as_str())))
|
||||
.map(|c| serde_json::to_value(c).unwrap_or_default())
|
||||
.collect();
|
||||
outln!("{}", serde_json::to_string_pretty(&list).unwrap_or_default());
|
||||
{
|
||||
match c.params.strip_prefix("{}") {
|
||||
Some(answer) => outln!("{} — {}{answer}", c.id, c.label),
|
||||
None => outln!("{} — {}: {}", c.id, c.label, c.params),
|
||||
}
|
||||
}
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Some("describe") => report(describe(rest.first().map(String::as_str))),
|
||||
@@ -90,11 +77,8 @@ fn main() -> ExitCode {
|
||||
outln!("Discord {DISCORD}\nWebsite {WEBSITE}\nApp page {APP_PAGE}\nGitHub {GITHUB}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
_ => {
|
||||
eprintln!("{USAGE}");
|
||||
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
Some(other) => unknown(other),
|
||||
None => usage(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,6 +86,22 @@ fn is_help(arg: &str) -> bool {
|
||||
matches!(arg, "--help" | "-h")
|
||||
}
|
||||
|
||||
/// The help page of subcommand `name`, else the overview.
|
||||
fn help_page(name: &str) -> String {
|
||||
help::page(name).unwrap_or_else(help::overview)
|
||||
}
|
||||
|
||||
fn unknown(sub: &str) -> ExitCode {
|
||||
eprintln!("deckcraft-cli: unknown subcommand `{sub}`\n");
|
||||
usage()
|
||||
}
|
||||
|
||||
fn usage() -> ExitCode {
|
||||
eprintln!("{}", help::overview());
|
||||
eprintln!("\nCommunity: {} · {}", deckcraft_engine::links::DISCORD, deckcraft_engine::links::APP_PAGE);
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
|
||||
fn report(r: Result<(), String>) -> ExitCode {
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
@@ -226,7 +226,7 @@ fn bench(args: &[String]) -> Result<(), String> {
|
||||
|
||||
fn info(args: &[String]) -> Result<(), String> {
|
||||
if args.first().is_some_and(|a| is_help(a)) {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("info"));
|
||||
return Ok(());
|
||||
}
|
||||
let path = args.first().ok_or("info needs a FILE")?;
|
||||
@@ -254,7 +254,7 @@ fn render(args: &[String]) -> Result<(), String> {
|
||||
"--edit" => edit = true,
|
||||
"--scale" => scale = it.next().and_then(|v| v.parse().ok()).ok_or("--scale needs a number")?,
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("render"));
|
||||
return Ok(());
|
||||
}
|
||||
other if other.starts_with('-') => return Err(format!("unknown option `{other}`")),
|
||||
@@ -403,7 +403,7 @@ fn frame_paths(out: &str, ts: &[f64]) -> Result<Vec<String>, String> {
|
||||
|
||||
fn convert(args: &[String]) -> Result<(), String> {
|
||||
if args.first().is_some_and(|a| is_help(a)) {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("convert"));
|
||||
return Ok(());
|
||||
}
|
||||
let (Some(input), Some(out)) = (args.first(), args.get(1)) else { return Err("convert needs IN and OUT".into()) };
|
||||
@@ -412,6 +412,8 @@ fn convert(args: &[String]) -> Result<(), String> {
|
||||
open(&mut s, Some(input), false)?;
|
||||
let r = if matches!(fmt, "png" | "jpeg") {
|
||||
s.execute("file.export", &json!({"path": out, "slide": 0}))
|
||||
} else if fmt == "gif" {
|
||||
s.execute("file.export", &json!({"path": out}))
|
||||
} else {
|
||||
s.execute("file.saveAs", &json!({"path": out}))
|
||||
};
|
||||
@@ -436,18 +438,26 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut cmds = vec![];
|
||||
let mut save = None;
|
||||
let mut export = None;
|
||||
let mut export_params = json!({});
|
||||
let mut print = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--in" => input = Some(it.next().cloned().ok_or("--in needs a file")?),
|
||||
"--sample" => sample = true,
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs ID[=JSON]")?),
|
||||
"--cmd" => cmds.push(it.next().cloned().ok_or("--cmd needs COMMAND[=PARAMS]")?),
|
||||
"--save" => save = Some(it.next().cloned().ok_or("--save needs a path")?),
|
||||
"--export" => export = Some(it.next().cloned().ok_or("--export needs a path")?),
|
||||
"--export-params" => {
|
||||
let j = it.next().ok_or("--export-params needs a JSON object")?;
|
||||
export_params = serde_json::from_str::<Value>(j).map_err(|e| format!("--export-params: bad JSON: {e}"))?;
|
||||
if !export_params.is_object() {
|
||||
return Err("--export-params must be a JSON object".into());
|
||||
}
|
||||
}
|
||||
"--print" => print = true,
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("run"));
|
||||
return Ok(());
|
||||
}
|
||||
other => return Err(format!("unknown option `{other}`")),
|
||||
@@ -472,22 +482,76 @@ fn run(args: &[String]) -> Result<(), String> {
|
||||
s.execute("file.saveAs", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(p) = export {
|
||||
s.execute("file.export", &json!({"path": p})).map_err(|e| e.to_string())?;
|
||||
export_params["path"] = json!(p);
|
||||
s.execute("file.export", &export_params).map_err(|e| e.to_string())?;
|
||||
} else if export_params != json!({}) {
|
||||
return Err("--export-params needs --export OUT".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn describe(id: Option<&str>) -> Result<(), String> {
|
||||
if id.is_some_and(is_help) {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("describe"));
|
||||
return Ok(());
|
||||
}
|
||||
let id = id.ok_or("describe needs a command id")?;
|
||||
let c = deckcraft_engine::find_command(id).ok_or_else(|| format!("unknown command `{id}`"))?;
|
||||
outln!("{} — {}\n where: {}\n shortcut: {}\n params: {}", c.id, c.label, c.menu.join(" ▸ "), c.shortcut.unwrap_or("—"), c.params);
|
||||
let c = deckcraft_engine::find_command(id).ok_or_else(|| deckcraft_engine::EngineError::UnknownCommand(id.into()).to_string())?;
|
||||
let mut head = format!("{}: {}", c.id, c.label);
|
||||
if !c.menu.is_empty() {
|
||||
head.push_str(&format!(", in {}", c.menu.join(" ▸ ")));
|
||||
}
|
||||
if let Some(k) = c.shortcut {
|
||||
head.push_str(&format!(", shortcut {k}"));
|
||||
}
|
||||
outln!("{head}.\n");
|
||||
let (args, answer) = c.params.split_once('→').unwrap_or((c.params, ""));
|
||||
let parsed = deckcraft_engine::cmd::parse_params(c.params);
|
||||
match &parsed {
|
||||
Err(_) => outln!("Parameters: {}", args.trim()),
|
||||
Ok(params) if params.is_empty() => outln!("Takes no parameters."),
|
||||
Ok(params) => print_params(params),
|
||||
}
|
||||
let params = parsed.unwrap_or_default();
|
||||
if let Some(acts) = c.acts_on() {
|
||||
outln!("{acts}");
|
||||
}
|
||||
let remark = remark(args);
|
||||
if !remark.is_empty() {
|
||||
outln!("Note: {remark}");
|
||||
}
|
||||
if !answer.trim().is_empty() {
|
||||
outln!("Answers with {}.", answer.trim());
|
||||
}
|
||||
let bare = params.is_empty() || params.iter().any(|p| params.iter().filter(|q| q.object == p.object).all(|q| q.optional));
|
||||
let call = if bare { c.id.to_string() } else { format!("'{}={{…}}'", c.id) };
|
||||
outln!("\nRun it with: deckcraft-cli run --cmd {call}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_params(params: &[deckcraft_engine::cmd::Param]) {
|
||||
let name = |p: &deckcraft_engine::cmd::Param| {
|
||||
let or = if p.or { "or " } else { "" };
|
||||
let optional = if p.optional { ", optional" } else { "" };
|
||||
format!("{or}{}{optional}", p.name)
|
||||
};
|
||||
let width = params.iter().map(|p| name(p).chars().count()).max().unwrap_or_default();
|
||||
let sets = params.iter().map(|p| p.object).max().unwrap_or_default() > 0;
|
||||
outln!("Parameters:");
|
||||
for (k, p) in params.iter().enumerate() {
|
||||
if sets && params.get(k.wrapping_sub(1)).is_none_or(|before| before.object != p.object) {
|
||||
outln!(" {}", if p.object == 0 { "either:" } else { "or:" });
|
||||
}
|
||||
let indent = if sets { " " } else { " " };
|
||||
outln!("{}", format!("{indent}{:<width$} {}", name(p), p.about).trim_end());
|
||||
}
|
||||
}
|
||||
|
||||
fn remark(args: &str) -> &str {
|
||||
let after = args.rfind('}').and_then(|i| args.get(i + 1..)).unwrap_or_default().trim().trim_start_matches('—').trim();
|
||||
after.strip_prefix('(').and_then(|n| n.strip_suffix(')')).unwrap_or(after)
|
||||
}
|
||||
|
||||
fn app(args: &[String]) -> Result<(), String> {
|
||||
use deckcraft_mcp::{Backend, Remote, control_addr};
|
||||
let mut port = deckcraft_mcp::DEFAULT_PORT.to_string();
|
||||
@@ -499,7 +563,7 @@ fn app(args: &[String]) -> Result<(), String> {
|
||||
"--port" => port = it.next().cloned().ok_or("--port needs a value")?,
|
||||
"--method" => method = Some(it.next().cloned().ok_or("--method needs a name")?),
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("app"));
|
||||
return Ok(());
|
||||
}
|
||||
other => rest.push(other.to_string()),
|
||||
@@ -522,28 +586,47 @@ fn app(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
use deckcraft_engine::file_access::{self, AutomationRoots};
|
||||
use deckcraft_mcp::{Backend, Headless, Remote, Server, control_addr};
|
||||
let mut connect: Option<String> = None;
|
||||
let mut sample = false;
|
||||
let mut it = args.iter();
|
||||
let mut roots_args = automation_args::AutomationArgs::new(None, None);
|
||||
let mut it = args.iter().cloned();
|
||||
while let Some(a) = it.next() {
|
||||
if roots_args.consume(&a, &mut it)? {
|
||||
continue;
|
||||
}
|
||||
match a.as_str() {
|
||||
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs a port or host:port")?),
|
||||
"--connect" => connect = Some(it.next().ok_or("--connect needs a port or host:port")?),
|
||||
"--sample" => sample = true,
|
||||
"--help" | "-h" => {
|
||||
outln!("{USAGE}");
|
||||
outln!("{}", help_page("mcp"));
|
||||
return Ok(());
|
||||
}
|
||||
other => return Err(format!("unknown mcp option `{other}`")),
|
||||
}
|
||||
}
|
||||
let roots = AutomationRoots::new(roots_args.read_root.as_deref(), roots_args.write_root.as_deref())?;
|
||||
if roots.is_some() && connect.is_some() {
|
||||
return Err("--automation-read-root and --automation-write-root confine the in-process server; a running app is not confined by them, \
|
||||
so drop --connect (or use --connect without the roots)"
|
||||
.into());
|
||||
}
|
||||
let backend: Box<dyn Backend> = match connect {
|
||||
Some(c) => Box::new(Remote::connect(&control_addr(&c)).map_err(|e| format!("can't reach the app at {c}: {e}"))?),
|
||||
None => {
|
||||
let mut h = Headless::new();
|
||||
// `--sample` is the operator's own choice, made before the roots come into force.
|
||||
if sample {
|
||||
deckcraft_engine::sample::open_sample(&mut h.session).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if let Some(roots) = roots {
|
||||
// Every thread of this process, not only the server's: nothing reaches past the roots.
|
||||
file_access::confine_process(roots.clone());
|
||||
let show = |root: Option<&std::path::Path>| root.map_or_else(|| "none".to_string(), |r| r.display().to_string());
|
||||
eprintln!("deckcraft-cli: files confined: read root {}, write root {}", show(roots.read_root()), show(roots.write_root()));
|
||||
h = h.with_automation_roots(Some(roots));
|
||||
}
|
||||
Box::new(h)
|
||||
}
|
||||
};
|
||||
@@ -554,6 +637,8 @@ fn mcp(args: &[String]) -> Result<(), String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn args(a: &[&str]) -> Vec<String> {
|
||||
@@ -582,6 +667,46 @@ mod tests {
|
||||
assert!(describe(Some("--help")).is_ok());
|
||||
assert!(mcp(&args(&["-h"])).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_refuses_an_unknown_option_instead_of_writing_it_as_a_file() {
|
||||
let out = std::env::temp_dir().join("deckcraft-cli-unknown-option.png");
|
||||
let _ = std::fs::remove_file(&out);
|
||||
for flag in ["--bogus", "-x"] {
|
||||
let args: Vec<String> = ["--sample", flag, &out.to_string_lossy()].iter().map(|a| a.to_string()).collect();
|
||||
let e = render(&args).unwrap_err();
|
||||
assert!(e.contains(&format!("unknown option `{flag}`")), "{flag}: {e}");
|
||||
assert!(!out.exists(), "{flag}: rendered anyway");
|
||||
}
|
||||
}
|
||||
|
||||
/// `run --export OUT.gif --export-params JSON` and `convert IN OUT.gif` write animated GIFs.
|
||||
#[test]
|
||||
fn exports_an_animated_gif() {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-cli-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let gif = dir.join("sample.gif").to_string_lossy().to_string();
|
||||
let deck = dir.join("sample.deckcraft").to_string_lossy().to_string();
|
||||
let params = r#"{"width": "small", "secondsPerSlide": 2, "loop": false, "slides": [0, 1]}"#;
|
||||
run(&args(&["--sample", "--save", &deck, "--export", &gif, "--export-params", params])).unwrap();
|
||||
let bytes = std::fs::read(&gif).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 640, "small = 640 px wide");
|
||||
assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "play once: no loop block");
|
||||
|
||||
let gif2 = dir.join("converted.gif").to_string_lossy().to_string();
|
||||
convert(&args(&[&deck, &gif2])).unwrap();
|
||||
let bytes = std::fs::read(&gif2).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a") && bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "loops forever by default");
|
||||
|
||||
// Bad options are errors, not panics.
|
||||
let bad = dir.join("bad.gif").to_string_lossy().to_string();
|
||||
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", r#"{"width": 1e9}"#])).is_err());
|
||||
assert!(run(&args(&["--sample", "--export", &bad, "--export-params", "[1]"])).is_err());
|
||||
assert!(run(&args(&["--sample", "--export-params", "{}"])).is_ok());
|
||||
assert!(run(&args(&["--sample", "--export-params", r#"{"width": 64}"#])).is_err());
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
use std::process::{Command, Output};
|
||||
|
||||
fn cli(args: &[&str]) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn stdout(o: &Output) -> String {
|
||||
assert!(o.status.success(), "exit {:?}\nstderr: {}", o.status, String::from_utf8_lossy(&o.stderr));
|
||||
String::from_utf8_lossy(&o.stdout).into_owned()
|
||||
}
|
||||
|
||||
fn stderr(o: &Output) -> String {
|
||||
assert!(!o.status.success(), "exit {:?}\nstdout: {}", o.status, String::from_utf8_lossy(&o.stdout));
|
||||
String::from_utf8_lossy(&o.stderr).into_owned()
|
||||
}
|
||||
|
||||
const SUBCOMMANDS: [&str; 11] = ["info", "render", "render-transition", "convert", "run", "commands", "describe", "app", "mcp", "bench", "links"];
|
||||
|
||||
fn words(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_subcommand_is_named_before_the_overview() {
|
||||
for args in [vec!["frobnicate"], vec!["help", "frobnicate"], vec!["--info", "--help"]] {
|
||||
let err = stderr(&cli(&args));
|
||||
assert!(err.contains("unknown subcommand `") && err.contains("\n\ndeckcraft-cli works on"), "{args:?}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_subcommand_shows_the_overview_and_fails() {
|
||||
assert!(stderr(&cli(&[])).starts_with("deckcraft-cli works on"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_lists_every_subcommand_with_a_run_example() {
|
||||
for flag in ["help", "--help", "-h"] {
|
||||
let out = stdout(&cli(&[flag]));
|
||||
for sub in SUBCOMMANDS {
|
||||
assert!(out.contains(&format!("\n {sub} ")), "{flag} lacks {sub}: {out}");
|
||||
}
|
||||
assert!(out.contains("deckcraft-cli run --cmd 'slide.new={\"title\":\"Q3 results\"}' --save q3.deckcraft"), "{out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_subcommand_answers_help_and_does_nothing_else() {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-help-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
for sub in SUBCOMMANDS {
|
||||
for args in [vec![sub, "--help"], vec![sub, "-h", "out.png"], vec!["help", sub]] {
|
||||
let o = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).current_dir(&dir).args(&args).output().unwrap();
|
||||
assert!(stdout(&o).starts_with(&format!("usage: deckcraft-cli {sub}")), "{args:?}");
|
||||
}
|
||||
}
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 0, "help wrote nothing");
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
for args in [["help", "version"], ["--version", "--help"]] {
|
||||
assert!(stdout(&cli(&args)).starts_with("usage: deckcraft-cli --version"), "{args:?}");
|
||||
}
|
||||
for args in [["help", "help"], ["help", "--help"]] {
|
||||
assert!(stdout(&cli(&args)).starts_with("deckcraft-cli works on"), "{args:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_help_says_what_the_ids_do_not() {
|
||||
let out = words(&stdout(&cli(&["run", "--help"])));
|
||||
for fact in [
|
||||
"COMMAND is a command id, then `=`, then the command's parameters as one JSON object.",
|
||||
"points on a 960×540 slide",
|
||||
"Slide indices start at 0.",
|
||||
"Shape ids are looked up on the current slide only.",
|
||||
"A command that adds a shape (shape.insert, insert.textBox, insert.table…) selects it.",
|
||||
"format.* acts on the selected text or shapes",
|
||||
] {
|
||||
assert!(out.contains(fact), "{fact}: {out}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_spells_out_a_command() {
|
||||
assert_eq!(
|
||||
stdout(&cli(&["describe", "shape.insert"])),
|
||||
"shape.insert: Shapes, in Insert ▸ Illustrations.
|
||||
|
||||
Parameters:
|
||||
preset rect|roundRect|ellipse|triangle|rightArrow|star5|… (see shape.presets)
|
||||
rect [x,y,w,h] pt
|
||||
text, optional string
|
||||
|
||||
Run it with: deckcraft-cli run --cmd 'shape.insert={…}'
|
||||
"
|
||||
);
|
||||
assert_eq!(
|
||||
stdout(&cli(&["describe", "format.size"])),
|
||||
"format.size: Font Size, in Home ▸ Font.
|
||||
|
||||
Parameters:
|
||||
size pt
|
||||
Acts on the selected text or shapes.
|
||||
|
||||
Run it with: deckcraft-cli run --cmd 'format.size={…}'
|
||||
"
|
||||
);
|
||||
assert!(stdout(&cli(&["describe", "edit.copy"])).contains("Answers with {text, html?}.\n\nRun it with: deckcraft-cli run --cmd edit.copy\n"));
|
||||
assert!(stdout(&cli(&["describe", "text.set"])).contains("Note: replaces the whole text, keeping the first run's formatting\n"));
|
||||
assert_eq!(
|
||||
stdout(&cli(&["describe", "shape.setDefault"])),
|
||||
"shape.setDefault: Set as Default Shape.\n\nTakes no parameters.\nActs on the selected shapes.\n\nRun it with: deckcraft-cli run --cmd shape.setDefault\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn describe_shows_alternative_sets_of_parameters() {
|
||||
assert!(stdout(&cli(&["describe", "slide.go"])).contains("Parameters:\n either:\n index\n or:\n id\n"));
|
||||
let remove = stdout(&cli(&["describe", "animation.remove"]));
|
||||
assert!(remove.contains(" or:\n ids, optional (all of the shapes' animations)\n"), "{remove}");
|
||||
assert!(remove.ends_with("Run it with: deckcraft-cli run --cmd animation.remove\n") && !remove.contains("Note:"), "{remove}");
|
||||
assert!(stdout(&cli(&["describe", "shape.move"])).contains(
|
||||
" either:
|
||||
dx, optional pt (relative)
|
||||
dy, optional pt (relative)
|
||||
ids, optional
|
||||
or:
|
||||
x, optional pt (absolute)
|
||||
y, optional pt (absolute)
|
||||
ids, optional
|
||||
"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_lists_one_line_per_command() {
|
||||
assert_eq!(stdout(&cli(&["commands", "format.size"])), "format.size — Font Size: {size: pt}\n");
|
||||
assert_eq!(stdout(&cli(&["commands", "slide.next"])), "slide.next — Next Slide\n");
|
||||
assert_eq!(stdout(&cli(&["commands", "shape.presets"])), "shape.presets — Shape Gallery → [{name, label, category}]\n");
|
||||
}
|
||||
@@ -21,7 +21,7 @@ fn convert_rejects_output_formats_it_cannot_write() {
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_deckcraft-cli")).args(["run", "--sample", "--export"]).arg(&input).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
for name in ["out.odp", "out.gif", "out.svg", "out.outline", "out"] {
|
||||
for name in ["out.odp", "out.bmp", "out.svg", "out.outline", "out"] {
|
||||
let target = dir.join(name);
|
||||
let out = cli(&[Path::new("convert"), &input, &target]);
|
||||
assert!(!out.status.success(), "{name}: convert should fail");
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! `deckcraft-cli mcp` with the automation root flags, run as the real binary.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Output, Stdio};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn bin() -> Command {
|
||||
Command::new(env!("CARGO_BIN_EXE_deckcraft-cli"))
|
||||
}
|
||||
|
||||
fn run(args: &[&str]) -> Output {
|
||||
bin().args(args).output().unwrap()
|
||||
}
|
||||
|
||||
fn tmpdir(tag: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("dc-cli-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
/// Send `calls` (tool name, arguments) to `deckcraft-cli mcp <args>`; returns whether it exited
|
||||
/// cleanly, its stderr, and the stdout lines after `initialize`, one per call.
|
||||
fn mcp_session(args: &[&str], calls: &[(&str, Value)]) -> (bool, String, Vec<Value>) {
|
||||
let mut child = bin().arg("mcp").args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped()).spawn().unwrap();
|
||||
{
|
||||
let stdin = child.stdin.as_mut().unwrap();
|
||||
writeln!(stdin, r#"{{"jsonrpc":"2.0","id":1,"method":"initialize","params":{{"protocolVersion":"2025-06-18"}}}}"#).unwrap();
|
||||
for (i, (name, arguments)) in calls.iter().enumerate() {
|
||||
let call = serde_json::json!({"jsonrpc": "2.0", "id": i + 2, "method": "tools/call", "params": {"name": name, "arguments": arguments}});
|
||||
writeln!(stdin, "{call}").unwrap();
|
||||
}
|
||||
}
|
||||
let out = child.wait_with_output().unwrap();
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let replies = stdout.lines().skip(1).map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
(out.status.success(), String::from_utf8_lossy(&out.stderr).into_owned(), replies)
|
||||
}
|
||||
|
||||
fn text(reply: &Value) -> &str {
|
||||
reply["result"]["content"][0]["text"].as_str().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_roots_confine_the_server() {
|
||||
let base = tmpdir("roots");
|
||||
let (work, elsewhere) = (base.join("work"), base.join("elsewhere"));
|
||||
std::fs::create_dir_all(&work).unwrap();
|
||||
std::fs::create_dir_all(&elsewhere).unwrap();
|
||||
std::fs::write(work.join("in.txt"), "Inside text").unwrap();
|
||||
std::fs::write(elsewhere.join("secret.txt"), "TOP SECRET").unwrap();
|
||||
let work_s = work.to_str().unwrap();
|
||||
let secret = elsewhere.join("secret.txt").to_str().unwrap().to_string();
|
||||
|
||||
// Both spellings of the flags; inside works, outside is refused with a readable message.
|
||||
let read_flag = format!("--automation-read-root={work_s}");
|
||||
let calls = [
|
||||
("open_presentation", serde_json::json!({"path": work.join("in.txt").to_str().unwrap()})),
|
||||
("open_presentation", serde_json::json!({"path": secret})),
|
||||
("save_presentation", serde_json::json!({"path": work.join("saved.deckcraft").to_str().unwrap()})),
|
||||
("save_presentation", serde_json::json!({"path": elsewhere.join("saved.deckcraft").to_str().unwrap()})),
|
||||
];
|
||||
let (success, stderr, replies) = mcp_session(&[&read_flag, "--automation-write-root", work_s], &calls);
|
||||
assert!(success, "{stderr}");
|
||||
assert!(stderr.contains("files confined"), "{stderr}");
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
|
||||
assert_eq!(replies[1]["result"]["isError"], true);
|
||||
assert!(text(&replies[1]).contains("outside the read root"), "{}", text(&replies[1]));
|
||||
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
|
||||
assert!(work.join("saved.deckcraft").exists());
|
||||
assert_eq!(replies[3]["result"]["isError"], true);
|
||||
assert!(text(&replies[3]).contains("outside the write root"), "{}", text(&replies[3]));
|
||||
assert!(!elsewhere.join("saved.deckcraft").exists());
|
||||
|
||||
// A root left out grants none of its access.
|
||||
let save = [("new_presentation", serde_json::json!({})), calls[2].clone()];
|
||||
let (_, _, replies) = mcp_session(&[&read_flag], &save);
|
||||
assert_eq!(replies[1]["result"]["isError"], true);
|
||||
assert!(text(&replies[1]).contains("write authority is absent"), "{}", text(&replies[1]));
|
||||
|
||||
// No flags: nothing is confined.
|
||||
let (success, _, replies) = mcp_session(&[], &calls[1..2]);
|
||||
assert!(success);
|
||||
assert_eq!(replies[0]["result"]["isError"], false, "{}", replies[0]);
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_automation_root_flags_are_checked_up_front() {
|
||||
let base = tmpdir("roots-args");
|
||||
let dir = base.to_str().unwrap();
|
||||
let missing = base.join("missing");
|
||||
let fails = |args: &[&str], needle: &str| {
|
||||
let mut full = vec!["mcp"];
|
||||
full.extend_from_slice(args);
|
||||
let o = run(&full);
|
||||
assert!(!o.status.success(), "{args:?}");
|
||||
let err = String::from_utf8_lossy(&o.stderr).into_owned();
|
||||
assert!(err.contains(needle), "{args:?}: {err}");
|
||||
};
|
||||
// The flags confine the in-process server, so they cannot be combined with --connect.
|
||||
fails(&["--connect", "7979", "--automation-read-root", dir], "--connect");
|
||||
fails(&["--automation-write-root", dir, "--connect", "7979"], "--connect");
|
||||
fails(&["--automation-read-root", dir, "--automation-read-root", dir], "given twice");
|
||||
fails(&["--automation-write-root", dir, "--automation-write-root", dir], "given twice");
|
||||
fails(&["--automation-read-root"], "needs a folder");
|
||||
fails(&["--automation-write-root", "--sample"], "needs a folder");
|
||||
fails(&["--automation-read-root", missing.to_str().unwrap()], "cannot open the automation read root");
|
||||
fails(&["--automation-write-root="], "needs a non-empty folder");
|
||||
// A file is not a folder.
|
||||
std::fs::write(base.join("f.txt"), "x").unwrap();
|
||||
fails(&["--automation-write-root", base.join("f.txt").to_str().unwrap()], "cannot open the automation write root");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
@@ -181,6 +181,7 @@ fn mime_for(name: &str) -> &'static str {
|
||||
Some("png") => "image/png",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("gif") => "image/gif",
|
||||
Some("txt") => "text/plain",
|
||||
Some("pptx") => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
Some("deckcraft") => "application/zip",
|
||||
|
||||
@@ -51,3 +51,6 @@ sync_github_name = true
|
||||
|
||||
[people.NikitkaRa]
|
||||
real_name = "Nikita Rakitin"
|
||||
|
||||
[people.RCShoemaker]
|
||||
real_name = "Ryan C. Shoemaker"
|
||||
@@ -38,7 +38,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub use easing::ease;
|
||||
pub use interact::{Click, HitShape, Target};
|
||||
pub use mesh::{MeshVertex, layer_mesh};
|
||||
pub use morph::{MorphFrame, MorphPath, MorphText, morph_frame, morph_pairs, morph_xfrm};
|
||||
pub use morph::{MorphFrame, MorphPath, MorphText, morph_crop, morph_frame, morph_pairs, morph_xfrm};
|
||||
pub use path::{MotionPath, default_path};
|
||||
pub use show::{LinkJump, ShowAction, ShowState, safe_url, show_order};
|
||||
pub use timeline::{EffectTiming, Sequence, StepStamp, Timeline, TriggerPlay};
|
||||
|
||||
@@ -12,7 +12,9 @@ fn text_of(s: &Shape) -> String {
|
||||
|
||||
/// Pairs of (shape on `a`, shape on `b`) that Morph animates from one to the other: first by
|
||||
/// `!!name` (forced match), then by exact name, then by the same kind and text, then by the same
|
||||
/// kind and preset geometry. Each shape is used once; top-level shapes only (groups move whole).
|
||||
/// 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).
|
||||
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()];
|
||||
@@ -31,7 +33,8 @@ pub fn morph_pairs(a: &Slide, b: &Slide) -> Vec<(ShapeId, ShapeId)> {
|
||||
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()
|
||||
&& text_of(x).trim().is_empty()
|
||||
&& text_of(y).trim().is_empty()
|
||||
},
|
||||
];
|
||||
for pass in passes {
|
||||
@@ -173,9 +176,58 @@ fn reshaped(a: &Shape, b: &Shape) -> bool {
|
||||
a.geom != b.geom && outlined(a) && outlined(b) && Shape { geom: Geom::default(), ..content(a) } == Shape { geom: Geom::default(), ..content(b) }
|
||||
}
|
||||
|
||||
/// The content of a shape apart from what Morph interpolates, with a picture's crop left out.
|
||||
fn uncropped(s: &Shape) -> Shape {
|
||||
let mut c = content(s);
|
||||
if let ShapeKind::Picture { fill } = &mut c.kind {
|
||||
fill.crop = [0.0; 4];
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn crop_of(s: &Shape) -> [f64; 4] {
|
||||
match &s.kind {
|
||||
ShapeKind::Picture { fill } => fill.crop,
|
||||
_ => [0.0; 4],
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether two pictures differ in their crop only (apart from box and fill), so the crop can morph.
|
||||
fn recropped(a: &Shape, b: &Shape) -> bool {
|
||||
matches!(a.kind, ShapeKind::Picture { .. }) && crop_of(a) != crop_of(b) && uncropped(a) == uncropped(b)
|
||||
}
|
||||
|
||||
/// The crop (left, top, right, bottom) of a picture morphing from box `xa` cropped by `ca` to box
|
||||
/// `xb` cropped by `cb`, when its box is `x` at progress `t`: the whole image moves and scales
|
||||
/// linearly between where it lies on either slide, and the crop is what of it `x` shows. Falls back
|
||||
/// to blending the crops when a crop leaves nothing visible.
|
||||
pub fn morph_crop(ca: [f64; 4], xa: Xfrm, cb: [f64; 4], xb: Xfrm, x: Xfrm, t: f64) -> [f64; 4] {
|
||||
// Along one axis: (box start, box size, crop before, crop after) → the crops at `t`.
|
||||
let axis = |(pa, sa, la, ha): (f64, f64, f64, f64), (pb, sb, lb, hb): (f64, f64, f64, f64), (p, s): (f64, f64)| {
|
||||
let (va, vb) = (1.0 - la - ha, 1.0 - lb - hb);
|
||||
if va <= 1e-6 || vb <= 1e-6 {
|
||||
return (lerp(la, lb, t), lerp(ha, hb, t));
|
||||
}
|
||||
// The whole image: its extent and start on either slide.
|
||||
let (ea, eb) = (sa / va, sb / vb);
|
||||
let e = lerp(ea, eb, t);
|
||||
if !e.is_finite() || e.abs() <= 1e-9 {
|
||||
return (lerp(la, lb, t), lerp(ha, hb, t));
|
||||
}
|
||||
let o = lerp(pa - la * ea, pb - lb * eb, t);
|
||||
let lo = (p - o) / e;
|
||||
(lo, 1.0 - lo - s / e)
|
||||
};
|
||||
let (l, r) = axis((xa.x, xa.w, ca[0], ca[2]), (xb.x, xb.w, cb[0], cb[2]), (x.x, x.w));
|
||||
let (tp, bt) = axis((xa.y, xa.h, ca[1], ca[3]), (xb.y, xb.h, cb[1], cb[3]), (x.y, x.h));
|
||||
let c = [l, tp, r, bt];
|
||||
if c.iter().all(|v| v.is_finite()) { c } else { cb }
|
||||
}
|
||||
|
||||
/// Morph frame from slide `a` to slide `b` at progress `t` (0..1, eased here). Paired shapes
|
||||
/// ([`morph_pairs`]) move along [`morph_xfrm`]; when they differ only in solid fill colour the
|
||||
/// colour blends, when only in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]),
|
||||
/// colour blends, when pictures differ only in crop the crop morphs ([`morph_crop`]), when only
|
||||
/// in geometry (and fill colour) the outline morphs ([`MorphFrame::paths`]),
|
||||
/// otherwise the new shape fades in under the fading old one on the same moving
|
||||
/// box; with the transition option `words` or `characters` their texts are listed in
|
||||
/// [`MorphFrame::text`] to morph word by word or character by character. Unpaired shapes of `a` fade out, unpaired shapes of `b` fade in, and shapes of `b` for
|
||||
@@ -223,8 +275,18 @@ pub fn morph_frame(pres: &Presentation, a: &Slide, b: &Slide, t: f64, hidden_b:
|
||||
(_, xb) => xb,
|
||||
};
|
||||
let reshape = reshaped(sa, sb);
|
||||
if reshape || content(sa) == content(sb) {
|
||||
let recrop = recropped(sa, sb);
|
||||
if reshape || recrop || content(sa) == content(sb) {
|
||||
let mut m = Shape { xfrm: x, ..sb.clone() };
|
||||
if recrop && let ShapeKind::Picture { fill } = &mut m.kind {
|
||||
fill.crop = match (xa, xb, x) {
|
||||
(Some(xa), Some(xb), Some(x)) => morph_crop(crop_of(sa), xa, crop_of(sb), xb, x, s),
|
||||
_ => {
|
||||
let (ca, cb) = (crop_of(sa), crop_of(sb));
|
||||
[lerp(ca[0], cb[0], s), lerp(ca[1], cb[1], s), lerp(ca[2], cb[2], s), lerp(ca[3], cb[3], s)]
|
||||
}
|
||||
};
|
||||
}
|
||||
if let (Some(fa), Some(fb)) = (solid_color(ca.as_ref(), sa), solid_color(cb.as_ref(), sb))
|
||||
&& fa != fb
|
||||
{
|
||||
|
||||
@@ -529,6 +529,61 @@ fn morph_pairs_by_name_text_and_geometry() {
|
||||
assert!(morph_pairs(&Slide::default(), &b).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_pairs_leaves_unrelated_text_boxes_alone() {
|
||||
// A text box that leaves and an unrelated one that arrives fade in place (#119).
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
let tb = |id, name, x, text| Shape { xfrm: Some(Xfrm::new(x, 100.0, 200.0, 100.0)), text: Some(TextBody::from_text(text)), ..shape(id, name) };
|
||||
a.shapes = vec![tb(1, "TextBox 1", 0.0, "Gone")];
|
||||
b.shapes = vec![tb(2, "TextBox 2", 400.0, "New")];
|
||||
assert!(morph_pairs(&a, &b).is_empty());
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
let xs: Vec<(&str, f64)> = f.shapes.iter().map(|(s, _)| (s.name.as_str(), s.xfrm.map_or(f64::NAN, |x| x.x))).collect();
|
||||
assert_eq!(xs, [("TextBox 1", 0.0), ("TextBox 2", 400.0)]);
|
||||
// Empty shapes of one kind and geometry still pair, and a named text box keeps its partner.
|
||||
a.shapes.push(shape(3, "Rect A"));
|
||||
b.shapes.push(shape(4, "Rect B"));
|
||||
b.shapes[0].name = "TextBox 1".into();
|
||||
assert_eq!(morph_pairs(&a, &b), [(ShapeId(1), ShapeId(2)), (ShapeId(3), ShapeId(4))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_crop_moves_the_image_linearly() {
|
||||
let bx = |x, w| Xfrm::new(x, 0.0, w, 100.0);
|
||||
// Same box, the crop pans from the left half to the right half of a 200-wide image.
|
||||
let c = morph_crop([0.0, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.5, 0.0, 0.0, 0.0], bx(0.0, 100.0), bx(0.0, 100.0), 0.5);
|
||||
assert!(c.iter().zip([0.25, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}");
|
||||
// The box shrinks over an image that stays put: only the right crop grows.
|
||||
let c = morph_crop([0.0; 4], bx(0.0, 100.0), [0.0, 0.0, 0.5, 0.0], bx(0.0, 50.0), bx(0.0, 75.0), 0.5);
|
||||
assert!(c.iter().zip([0.0, 0.0, 0.25, 0.0]).all(|(a, b)| (a - b).abs() < 1e-9), "{c:?}");
|
||||
// Nothing visible on one side: the crops blend.
|
||||
let c = morph_crop([0.5, 0.0, 0.5, 0.0], bx(0.0, 100.0), [0.0; 4], bx(0.0, 100.0), bx(0.0, 100.0), 0.5);
|
||||
assert!(c.iter().all(|v| v.is_finite()) && (c[0] - 0.25).abs() < 1e-9, "{c:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_frame_morphs_a_picture_crop() {
|
||||
// A picture recropped on the next slide stays one picture whose crop morphs (#118).
|
||||
let p = deckcraft_model::defaults::new_presentation(None);
|
||||
let pic = |id, crop| Shape {
|
||||
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { crop, ..Default::default() } },
|
||||
..shape(id, "Picture 1")
|
||||
};
|
||||
let mut a = Slide::default();
|
||||
let mut b = Slide::default();
|
||||
a.shapes = vec![pic(1, [0.0, 0.0, 0.5, 0.0])];
|
||||
b.shapes = vec![pic(2, [0.5, 0.0, 0.0, 0.0])];
|
||||
let f = morph_frame(&p, &a, &b, 0.5, &|_| false);
|
||||
assert_eq!(f.shapes.len(), 1);
|
||||
assert!(f.opacity.is_empty());
|
||||
let deckcraft_model::ShapeKind::Picture { fill } = &f.shapes[0].0.kind else { panic!("not a picture") };
|
||||
let want = 0.5 * f.mix;
|
||||
assert!((fill.crop[0] - want).abs() < 1e-9 && (fill.crop[2] - (0.5 - want)).abs() < 1e-9, "{:?}", fill.crop);
|
||||
assert_eq!(morph_frame(&p, &a, &b, 1.0, &|_| false).shapes[0].0.kind, b.shapes[0].kind);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_xfrm_interpolates() {
|
||||
let a = Xfrm { rot: 350.0, ..Xfrm::new(0.0, 0.0, 100.0, 100.0) };
|
||||
|
||||
@@ -28,7 +28,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_selection,
|
||||
add
|
||||
),
|
||||
cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids?} (all of the shapes' animations)", has_slide, remove),
|
||||
cmd!("animation.remove", "Remove Animation", ["Animation Pane"], None, "{index} | {ids? (all of the shapes' animations)}", has_slide, remove),
|
||||
cmd!("animation.move", "Reorder Animation", ["Animations", "Timing"], None, "{index, to}", has_slide, move_anim),
|
||||
cmd!(
|
||||
"animation.timing",
|
||||
|
||||
+304
-35
@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Format Background",
|
||||
["Design", "Customize"],
|
||||
None,
|
||||
"{color? | gradient? | picture?: base64 | style?: 1..12 | reset?, all?: bool (apply to all), index?}",
|
||||
"{color? | gradient? | picture?: base64 | pattern? | style?: 1..12 | reset?, all?: bool, index?, transparency?: 0..1, pictureProps?: {tile?, offsetX?, offsetY?: pt, scaleX?, scaleY?: 0.01..10, align?, mirror?, stretch?: [l,t,r,b], rotateWithShape?}, copy?: bool}",
|
||||
has_slide,
|
||||
background
|
||||
),
|
||||
@@ -53,7 +53,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Slide Size",
|
||||
["Design", "Customize"],
|
||||
None,
|
||||
"{preset?: widescreen|standard|… | w, h: pt, orientation?: portrait|landscape, scale?: maximize|ensureFit|none}",
|
||||
"{preset: widescreen|standard|…, orientation?: portrait|landscape, scale?: maximize|ensureFit|none} | {w: pt, h: pt, orientation?: portrait|landscape, scale?: maximize|ensureFit|none}",
|
||||
has_doc,
|
||||
slide_size
|
||||
),
|
||||
@@ -152,7 +152,7 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("design.browseTheme", "give `path` or `data`"))?;
|
||||
std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?
|
||||
crate::io::read_file(path).map_err(crate::EngineError::Other)?
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
return Err(bad("design.browseTheme", "give `data`"));
|
||||
@@ -480,70 +480,256 @@ fn effects(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let bg: Option<Background> = if bool_or(p, "reset", false) {
|
||||
None
|
||||
} else if let Some(c) = color_param(p, "color") {
|
||||
Some(Background::Fill { fill: Fill::solid(c) })
|
||||
} else if let Some(i) = usize_param(p, "style") {
|
||||
if bool_or(p, "copy", false) {
|
||||
return copy_slide_background(s);
|
||||
}
|
||||
let props = p.get("pictureProps").filter(|v| v.is_object()).cloned();
|
||||
let transparency = f64_param(p, "transparency");
|
||||
let reset = bool_or(p, "reset", false);
|
||||
let color = color_param(p, "color");
|
||||
let style = usize_param(p, "style");
|
||||
let op = if reset {
|
||||
BgOp::Set(None)
|
||||
} else if let Some(mut c) = color {
|
||||
if let Some(t) = transparency {
|
||||
set_solid_transparency(&mut c, t);
|
||||
}
|
||||
BgOp::Set(Some(Background::Fill { fill: Fill::solid(c) }))
|
||||
} else if let Some(i) = style {
|
||||
// Background styles: 3 theme bg fills × 4 colours (bg1, bg2, tx1, tx2).
|
||||
let slots =
|
||||
[deckcraft_color::SchemeSlot::Bg1, deckcraft_color::SchemeSlot::Bg2, deckcraft_color::SchemeSlot::Tx2, deckcraft_color::SchemeSlot::Tx1];
|
||||
let i = i.clamp(1, 12) - 1;
|
||||
Some(Background::Ref {
|
||||
BgOp::Set(Some(Background::Ref {
|
||||
idx: 1001 + (i / 4) as u32,
|
||||
color: deckcraft_model::ColorRef::scheme(slots.get(i % 4).copied().unwrap_or(deckcraft_color::SchemeSlot::Bg1)),
|
||||
})
|
||||
}))
|
||||
} else if let Some(g) = p.get("gradient") {
|
||||
let mut g = super::shape::gradient_param(g).ok_or_else(|| bad("design.background", "gradient needs two or more `stops`"))?;
|
||||
g.rotate_with_shape = false;
|
||||
Some(Background::Fill { fill: Fill::Gradient(g) })
|
||||
BgOp::Set(Some(Background::Fill { fill: Fill::Gradient(g) }))
|
||||
} else if let Some(pt) = p.get("pattern") {
|
||||
Some(Background::Fill {
|
||||
BgOp::Set(Some(Background::Fill {
|
||||
fill: Fill::Pattern(deckcraft_model::style::PatternFill {
|
||||
preset: pt.get("preset").and_then(Value::as_str).unwrap_or("pct50").to_string(),
|
||||
fg: pt.get("fg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Accent1)),
|
||||
bg: pt.get("bg").and_then(color_value).unwrap_or(deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1)),
|
||||
}),
|
||||
})
|
||||
}))
|
||||
} else if p.get("picture").is_some() {
|
||||
let (name, bytes) = super::insert::media_bytes(&with_param(p, "data", p.get("picture").cloned().unwrap_or_default()), "design.background")?;
|
||||
let ct = super::insert::content_type(&name, &bytes);
|
||||
let media = s.edit(|doc, _| Ok(doc.add_media(&name, ct, bytes)))?;
|
||||
Some(Background::Fill { fill: Fill::Picture(deckcraft_model::style::PictureFill { media, ..Default::default() }) })
|
||||
let mut pf = deckcraft_model::style::PictureFill { media, ..Default::default() };
|
||||
if let Some(pp) = &props {
|
||||
apply_picture_props(&mut pf, pp);
|
||||
}
|
||||
if let Some(t) = transparency {
|
||||
set_picture_transparency(&mut pf, t);
|
||||
}
|
||||
BgOp::Set(Some(Background::Fill { fill: Fill::Picture(pf) }))
|
||||
} else if props.is_some() || transparency.is_some() {
|
||||
BgOp::Adjust
|
||||
} else {
|
||||
return Err(bad("design.background", "give `color`, `gradient`, `pattern`, `picture`, `style` or `reset`"));
|
||||
return Err(bad(
|
||||
"design.background",
|
||||
"give `color`, `gradient`, `pattern`, `picture`, `style`, `reset`, `pictureProps`, `transparency` or `copy`",
|
||||
));
|
||||
};
|
||||
let all = bool_or(p, "all", false);
|
||||
let index = usize_param(p, "index");
|
||||
if matches!(op, BgOp::Adjust) {
|
||||
let next = {
|
||||
let st = s.doc()?;
|
||||
let current = background_of(&st.doc, &st.selection, index);
|
||||
let picture = matches!(current, Some(Background::Fill { fill: Fill::Picture(_) }));
|
||||
let solid = matches!(current, Some(Background::Fill { fill: Fill::Solid { .. } }));
|
||||
if props.is_some() && !picture {
|
||||
return Err(bad("design.background", "picture options need a picture background"));
|
||||
}
|
||||
if transparency.is_some() && !picture && !solid {
|
||||
return Err(bad("design.background", "transparency needs a solid or picture background"));
|
||||
}
|
||||
let mut next = current.clone();
|
||||
if let Some(bg) = next.as_mut() {
|
||||
adjust_background(bg, props.as_ref(), transparency);
|
||||
}
|
||||
if next == current { None } else { Some(next) }
|
||||
};
|
||||
let Some(next) = next else { return ok() };
|
||||
s.edit(|doc, sel| {
|
||||
write_background(doc, sel, index, false, next);
|
||||
Ok(())
|
||||
})?;
|
||||
return ok();
|
||||
}
|
||||
let BgOp::Set(bg) = op else { return ok() };
|
||||
let unchanged = {
|
||||
let st = s.doc()?;
|
||||
backgrounds_already(&st.doc, &st.selection, index, all, &bg)
|
||||
};
|
||||
if unchanged {
|
||||
return ok();
|
||||
}
|
||||
s.edit(|doc, sel| {
|
||||
match sel.target {
|
||||
Target::Master { master } => {
|
||||
if let Some(m) = doc.masters.get_mut(master) {
|
||||
Arc::make_mut(m).background = bg.clone();
|
||||
write_background(doc, sel, index, all, bg);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
enum BgOp {
|
||||
Set(Option<Background>),
|
||||
Adjust,
|
||||
}
|
||||
|
||||
fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option<usize>) -> Option<Background> {
|
||||
match sel.target {
|
||||
Target::Master { master } => doc.masters.get(master).and_then(|m| m.background.clone()),
|
||||
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).and_then(|l| l.background.clone()),
|
||||
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).and_then(|m| m.background.clone()),
|
||||
Target::Slides => doc.slides.get(index.unwrap_or(sel.slide)).and_then(|sl| sl.background.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn backgrounds_already(doc: &Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: &Option<Background>) -> bool {
|
||||
match sel.target {
|
||||
Target::Master { master } => doc.masters.get(master).is_some_and(|m| m.background == *bg),
|
||||
Target::Layout { master, layout } => doc.masters.get(master).and_then(|m| m.layouts.get(layout)).is_some_and(|l| l.background == *bg),
|
||||
t @ (Target::Handout | Target::NotesMaster) => crate::page_master(doc, t).is_some_and(|m| m.background == *bg),
|
||||
Target::Slides => {
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
!list.is_empty() && list.iter().all(|i| doc.slides.get(*i).is_some_and(|sl| sl.background == *bg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_background(doc: &mut Presentation, sel: &crate::Selection, index: Option<usize>, all: bool, bg: Option<Background>) {
|
||||
match sel.target {
|
||||
Target::Master { master } => {
|
||||
if let Some(m) = doc.masters.get_mut(master) {
|
||||
Arc::make_mut(m).background = bg;
|
||||
}
|
||||
}
|
||||
Target::Layout { master, layout } => {
|
||||
if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) {
|
||||
l.background = bg;
|
||||
}
|
||||
}
|
||||
t @ (Target::Handout | Target::NotesMaster) => {
|
||||
if let Some(m) = crate::page_master_mut(doc, t) {
|
||||
Arc::make_mut(m).background = bg;
|
||||
}
|
||||
}
|
||||
Target::Slides => {
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).background = bg.clone();
|
||||
}
|
||||
}
|
||||
Target::Layout { master, layout } => {
|
||||
if let Some(l) = doc.masters.get_mut(master).and_then(|m| Arc::make_mut(m).layouts.get_mut(layout)) {
|
||||
l.background = bg.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_slide_background(s: &mut Session) -> Result<Value> {
|
||||
let bg = {
|
||||
let st = s.doc()?;
|
||||
if !matches!(st.selection.target, Target::Slides) {
|
||||
return Err(bad("design.background", "copy applies to slides"));
|
||||
}
|
||||
let bg = st.doc.slides.get(st.selection.slide).and_then(|sl| sl.background.clone());
|
||||
if st.doc.slides.iter().all(|sl| sl.background == bg) {
|
||||
return ok();
|
||||
}
|
||||
bg
|
||||
};
|
||||
s.edit(|doc, sel| {
|
||||
write_background(doc, sel, None, true, bg);
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
|
||||
fn adjust_background(bg: &mut Background, props: Option<&Value>, transparency: Option<f64>) {
|
||||
match bg {
|
||||
Background::Fill { fill: Fill::Picture(pf) } => {
|
||||
if let Some(pp) = props {
|
||||
apply_picture_props(pf, pp);
|
||||
}
|
||||
t @ (Target::Handout | Target::NotesMaster) => {
|
||||
if let Some(m) = crate::page_master_mut(doc, t) {
|
||||
Arc::make_mut(m).background = bg.clone();
|
||||
}
|
||||
if let Some(t) = transparency {
|
||||
set_picture_transparency(pf, t);
|
||||
}
|
||||
Target::Slides => {
|
||||
let list: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![index.unwrap_or(sel.slide)] };
|
||||
for i in list {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).background = bg.clone();
|
||||
}
|
||||
Background::Fill { fill: Fill::Solid { color } } => {
|
||||
if let Some(t) = transparency {
|
||||
set_solid_transparency(color, t);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_picture_transparency(pf: &mut deckcraft_model::style::PictureFill, t: f64) {
|
||||
let a = 1.0 - t.clamp(0.0, 1.0);
|
||||
pf.alpha = (a < 1.0).then_some(a);
|
||||
}
|
||||
|
||||
fn set_solid_transparency(color: &mut deckcraft_model::ColorRef, t: f64) {
|
||||
color.mods.retain(|m| !matches!(m, deckcraft_color::ColorTransform::Alpha(_)));
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
if t > 0.0 {
|
||||
color.mods.push(deckcraft_color::ColorTransform::Alpha(((1.0 - t) * 100_000.0).round() as i32));
|
||||
}
|
||||
}
|
||||
|
||||
/// Picture-fill options for Format Background (tile, offsets, scale, alignment, mirror, stretch).
|
||||
fn apply_picture_props(pf: &mut deckcraft_model::style::PictureFill, pp: &Value) {
|
||||
use deckcraft_model::style::PictureMode;
|
||||
let f = |k: &str| pp.get(k).and_then(Value::as_f64).filter(|v| v.is_finite());
|
||||
if let Some(up) = pp.get("rotateWithShape").and_then(Value::as_bool) {
|
||||
pf.upright = !up;
|
||||
}
|
||||
match pp.get("tile").and_then(Value::as_bool) {
|
||||
Some(true) if !matches!(pf.mode, PictureMode::Tile { .. }) => {
|
||||
pf.mode = PictureMode::Tile { tx: 0.0, ty: 0.0, sx: 1.0, sy: 1.0, flip: "none".into(), align: "tl".into() };
|
||||
}
|
||||
Some(false) if !matches!(pf.mode, PictureMode::Stretch { .. }) => pf.mode = PictureMode::default(),
|
||||
_ => {}
|
||||
}
|
||||
match &mut pf.mode {
|
||||
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
|
||||
if let Some(v) = f("offsetX") {
|
||||
*tx = v.clamp(-4000.0, 4000.0);
|
||||
}
|
||||
if let Some(v) = f("offsetY") {
|
||||
*ty = v.clamp(-4000.0, 4000.0);
|
||||
}
|
||||
if let Some(v) = f("scaleX") {
|
||||
*sx = v.clamp(0.01, 10.0);
|
||||
}
|
||||
if let Some(v) = f("scaleY") {
|
||||
*sy = v.clamp(0.01, 10.0);
|
||||
}
|
||||
if let Some(m) = pp.get("mirror").and_then(Value::as_str).filter(|m| matches!(*m, "none" | "x" | "y" | "xy")) {
|
||||
*flip = m.to_string();
|
||||
}
|
||||
if let Some(a) =
|
||||
pp.get("align").and_then(Value::as_str).filter(|a| matches!(*a, "tl" | "t" | "tr" | "l" | "ctr" | "r" | "bl" | "b" | "br"))
|
||||
{
|
||||
*align = a.to_string();
|
||||
}
|
||||
}
|
||||
PictureMode::Stretch { fill_rect } => {
|
||||
if let Some(arr) = pp.get("stretch").and_then(Value::as_array) {
|
||||
for (slot, v) in fill_rect.iter_mut().zip(arr) {
|
||||
if let Some(v) = v.as_f64().filter(|v| v.is_finite()) {
|
||||
*slot = v.clamp(-10.0, 0.99);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn hide_bg_graphics(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -1266,7 +1452,9 @@ fn slide_number_paragraph() -> deckcraft_model::Paragraph {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use deckcraft_color::SchemeSlot;
|
||||
use deckcraft_model::style::PictureMode;
|
||||
use deckcraft_model::theme::{FontScheme, ScriptFont, builtin_color_schemes, builtin_font_schemes};
|
||||
use deckcraft_model::{Background, Fill};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Session, UiRequest};
|
||||
@@ -1283,6 +1471,21 @@ mod tests {
|
||||
s.doc().unwrap().history.undo.len()
|
||||
}
|
||||
|
||||
fn png() -> String {
|
||||
let mut img = image::RgbaImage::new(2, 2);
|
||||
img.put_pixel(0, 0, image::Rgba([10, 20, 30, 255]));
|
||||
let mut bytes = Vec::new();
|
||||
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut bytes), image::ImageFormat::Png).unwrap();
|
||||
crate::cmd::base64_encode(&bytes)
|
||||
}
|
||||
|
||||
fn picture(s: &Session) -> deckcraft_model::style::PictureFill {
|
||||
match &s.doc().unwrap().doc.slides[0].background {
|
||||
Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.clone(),
|
||||
other => panic!("expected a picture background, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_dates() {
|
||||
assert_eq!(super::civil(0), (1970, 1, 1));
|
||||
@@ -1453,6 +1656,72 @@ mod tests {
|
||||
assert_eq!(font_scheme(&s).major.ea, "Kept");
|
||||
assert_eq!(font_scheme(&s).minor.latin, "Open Sans");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_picture_options_tile_transparency_and_copy() {
|
||||
let mut s = Session::with_new();
|
||||
let n = undo_len(&s);
|
||||
let err = s.execute("design.background", &json!({"pictureProps": {"tile": true}})).unwrap_err().to_string();
|
||||
assert!(err.contains("picture background"), "{err}");
|
||||
assert!(s.execute("design.background", &json!({"transparency": 0.2})).is_err());
|
||||
assert_eq!(undo_len(&s), n);
|
||||
|
||||
s.execute("design.background", &json!({"color": "accent1", "transparency": 0.25})).unwrap();
|
||||
match &s.doc().unwrap().doc.slides[0].background {
|
||||
Some(Background::Fill { fill: Fill::Solid { color } }) => assert!((color.alpha() - 0.75).abs() < 1e-4, "{}", color.alpha()),
|
||||
other => panic!("expected a solid background, got {other:?}"),
|
||||
}
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"transparency": 0.25})).unwrap();
|
||||
assert_eq!(undo_len(&s), n, "the same transparency is not an undo step");
|
||||
|
||||
s.execute("design.background", &json!({"picture": png(), "name": "dot.png", "transparency": 0.4, "pictureProps": {"tile": true}})).unwrap();
|
||||
let pf = picture(&s);
|
||||
assert!((pf.alpha.unwrap_or(1.0) - 0.6).abs() < 1e-6);
|
||||
assert!(matches!(pf.mode, PictureMode::Tile { .. }));
|
||||
let n = undo_len(&s);
|
||||
|
||||
s.execute(
|
||||
"design.background",
|
||||
&json!({"pictureProps": {"offsetX": 12.0, "offsetY": -3.0, "scaleX": 0.5, "scaleY": 50.0, "align": "ctr", "mirror": "xy", "rotateWithShape": false}}),
|
||||
)
|
||||
.unwrap();
|
||||
match picture(&s).mode {
|
||||
PictureMode::Tile { tx, ty, sx, sy, flip, align } => {
|
||||
assert_eq!((tx, ty, sx, sy), (12.0, -3.0, 0.5, 10.0));
|
||||
assert_eq!(flip, "xy");
|
||||
assert_eq!(align, "ctr");
|
||||
}
|
||||
other => panic!("expected a tile, got {other:?}"),
|
||||
}
|
||||
assert!(picture(&s).upright);
|
||||
s.execute("design.background", &json!({"pictureProps": {"align": "bogus", "offsetX": "nope"}})).unwrap();
|
||||
match picture(&s).mode {
|
||||
PictureMode::Tile { tx, align, .. } => {
|
||||
assert_eq!(tx, 12.0);
|
||||
assert_eq!(align, "ctr");
|
||||
}
|
||||
other => panic!("expected the tile to stay, got {other:?}"),
|
||||
}
|
||||
assert_eq!(undo_len(&s), n + 1, "ignored options are not an undo step");
|
||||
|
||||
s.execute("design.background", &json!({"pictureProps": {"tile": false, "stretch": [0.1, 0.2, 0.0, 0.0]}})).unwrap();
|
||||
match picture(&s).mode {
|
||||
PictureMode::Stretch { fill_rect } => assert_eq!(fill_rect, [0.1, 0.2, 0.0, 0.0]),
|
||||
other => panic!("expected a stretch, got {other:?}"),
|
||||
}
|
||||
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
assert!(s.doc().unwrap().doc.slides[1].background.is_none());
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let n = undo_len(&s);
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.slides[1].background, s.doc().unwrap().doc.slides[0].background);
|
||||
assert_eq!(undo_len(&s), n + 1);
|
||||
assert_eq!(s.doc().unwrap().history.undo.last().unwrap().label, "Format Background");
|
||||
s.execute("design.background", &json!({"copy": true})).unwrap();
|
||||
assert_eq!(undo_len(&s), n + 1, "copying an already shared background is not an undo step");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -33,7 +33,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.saveAs", "Save As…", ["File"], Some("Cmd+Shift+S"), "{path, format?}", has_doc, save_as),
|
||||
cmd!(noundo "file.saveTemplate", "Save as Template…", ["File"], None, "{path}", has_doc, save_as),
|
||||
cmd!(query "file.saveBytes", "Save to Data", [], None, "{format?: deckcraft|pptx} → {data: base64}", has_doc, save_bytes_cmd),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format: png|jpeg|pptx|deckcraft|outline|pdf, slide?: index, all?: bool, scale?: px per pt; pdf: layout?: slides|notes|handouts, perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3, orientation?: portrait|landscape, dpi?, slides?: [index] | \"1-3,5\", includeHidden?, textLayer?, frame?}", has_doc, export),
|
||||
cmd!(noundo "file.export", "Export…", ["File"], None, "{path, format?: png|jpeg|pptx|deckcraft|outline|pdf|gif (else by path), slide?: index, all?: bool, scale?: px per pt, layout?: slides|notes|handouts (pdf), perPage?: 1|2|3|4|6|9, paper?: letter|a4|legal|a3 (pdf), orientation?: portrait|landscape (pdf), dpi?, slides?: [index]|range (\"1-3,5\"), includeHidden?, textLayer?, frame?, width?: px 16-3840|small|medium|large|extraLarge (gif), secondsPerSlide?: 0.1-600 (gif; 5), loop?: bool (gif; true: forever)} → {path, bytes} or {files} (png, jpeg); gif adds frames, width, height, secondsPerSlide", has_doc, export),
|
||||
cmd!(
|
||||
query "file.info",
|
||||
"Info",
|
||||
@@ -66,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!(noundo "file.recovery.save", "Save AutoRecover Information", [], None, "{} → {written}", has_doc, recovery_save),
|
||||
cmd!(query "file.recovery.list", "Recovered Presentations", [], None, "{} → [{uid, path, title, saved}]", always, recovery_list),
|
||||
cmd!(noundo "file.recovery.open", "Open Recovered Presentations", [], None, "{} → {opened: [document index]}", always, recovery_open),
|
||||
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry, else all}", always, recovery_discard),
|
||||
cmd!(noundo "file.recovery.discard", "Discard Recovered Presentations", [], None, "{uid?: one entry (else all)}", always, recovery_discard),
|
||||
cmd!(noundo "file.revert", "Revert", [], None, "{}", has_doc, revert),
|
||||
cmd!(noundo "window.next", "Next Window", ["Window"], Some("Cmd+`"), "{index?}", has_doc, next_window),
|
||||
]
|
||||
@@ -187,7 +187,7 @@ pub struct LoadedFile {
|
||||
/// Read and parse the presentation at `path`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_presentation_file(path: &str) -> Result<LoadedFile> {
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let (doc, damaged) = open_checked(path, &bytes)?;
|
||||
let native = deckcraft_format::sniff(&bytes) || deckcraft_pptx::sniff(&bytes);
|
||||
Ok(LoadedFile { path: path.to_string(), doc, native, damaged })
|
||||
@@ -257,12 +257,13 @@ fn report_damage(s: &mut Session, damaged: &[String]) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `potx`, `ppsx`, `outline`, `pdf`).
|
||||
/// Encode the presentation in `format` (`deckcraft`, `pptx`, `potx`, `ppsx`, `outline`, `pdf`, `gif`).
|
||||
pub fn save_bytes(doc: &Presentation, format: &str) -> Result<Vec<u8>> {
|
||||
match format {
|
||||
f @ ("pptx" | "potx" | "ppsx") => deckcraft_pptx::export_as(doc, f).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"outline" | "txt" => Ok(deckcraft_format::slides_to_outline(doc).into_bytes()),
|
||||
"pdf" => deckcraft_pdf::export(doc, &Default::default()).map_err(|e| EngineError::Other(e.to_string())),
|
||||
"gif" => gif_bytes(doc, &Value::Null),
|
||||
_ => deckcraft_format::save(doc).map_err(|e| EngineError::Other(e.to_string())),
|
||||
}
|
||||
}
|
||||
@@ -336,16 +337,21 @@ pub fn pdf_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
}
|
||||
|
||||
/// The output extensions [`format_for_path`] maps to a writer of that format.
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "txt", "md"];
|
||||
pub const WRITABLE_EXTENSIONS: &[&str] = &["deckcraft", "pptx", "potx", "ppsx", "pdf", "png", "jpg", "jpeg", "gif", "txt", "md"];
|
||||
|
||||
/// The format a file named `path` is written in, or `None` when its extension names no format
|
||||
/// DeckCraft writes (`.odp`, `.gif`, `.svg`, no extension …): [`format_for_path`] would save
|
||||
/// DeckCraft writes (`.odp`, `.svg`, no extension …): [`format_for_path`] would save
|
||||
/// those as native `.deckcraft` bytes under the wrong name.
|
||||
pub fn writable_format_for_path(path: &str) -> Option<&'static str> {
|
||||
let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase();
|
||||
WRITABLE_EXTENSIONS.contains(&ext.as_str()).then(|| format_for_path(path))
|
||||
}
|
||||
|
||||
/// Animated GIF bytes for `doc` with the options in `p` (see [`crate::gif_export::GifOptions::from_params`]).
|
||||
pub fn gif_bytes(doc: &Presentation, p: &Value) -> Result<Vec<u8>> {
|
||||
Ok(crate::gif_export::encode(doc, &crate::gif_export::GifOptions::from_params(p)?)?.bytes)
|
||||
}
|
||||
|
||||
pub fn format_for_path(path: &str) -> &'static str {
|
||||
let l = path.to_ascii_lowercase();
|
||||
if l.ends_with(".pptx") {
|
||||
@@ -360,6 +366,8 @@ pub fn format_for_path(path: &str) -> &'static str {
|
||||
"jpeg"
|
||||
} else if l.ends_with(".pdf") {
|
||||
"pdf"
|
||||
} else if l.ends_with(".gif") {
|
||||
"gif"
|
||||
} else if l.ends_with(".txt") || l.ends_with(".md") {
|
||||
"outline"
|
||||
} else {
|
||||
@@ -381,16 +389,8 @@ fn mark_saved(s: &mut Session, path: Option<String>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn write_file(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
// Write to a temporary sibling then rename, so a failed save never truncates the old file.
|
||||
let tmp = format!("{path}.saving");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn write_file(_path: &str, _bytes: &[u8]) -> Result<()> {
|
||||
Err(EngineError::Other("saving to a path is not available in the browser".into()))
|
||||
crate::io::write_file(path, bytes).map_err(EngineError::Other)
|
||||
}
|
||||
|
||||
fn save(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -519,6 +519,23 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
write_file(&path, &bytes)?;
|
||||
Ok(json!({"path": path, "bytes": bytes.len()}))
|
||||
}
|
||||
"gif" => {
|
||||
// `slides` may be a range, as for PDF ("1-3,5").
|
||||
let mut gp = p.clone();
|
||||
if let Some(text) = p.get("slides").and_then(Value::as_str) {
|
||||
gp["slides"] = json!(parse_slide_range(text, st.doc.slides.len())?);
|
||||
}
|
||||
let out = crate::gif_export::encode(&st.doc, &crate::gif_export::GifOptions::from_params(&gp)?)?;
|
||||
write_file(&path, &out.bytes)?;
|
||||
Ok(json!({
|
||||
"path": path,
|
||||
"bytes": out.bytes.len(),
|
||||
"frames": out.frames,
|
||||
"width": out.width,
|
||||
"height": out.height,
|
||||
"secondsPerSlide": f64::from(out.delay_cs) / 100.0,
|
||||
}))
|
||||
}
|
||||
"pptx" | "potx" | "ppsx" | "deckcraft" | "outline" | "txt" => {
|
||||
let bytes = save_bytes(&st.doc, &format)?;
|
||||
write_file(&path, &bytes)?;
|
||||
@@ -533,7 +550,7 @@ fn info(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let st = s.doc()?;
|
||||
let doc = &st.doc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let bytes = st.path.as_ref().and_then(|p| std::fs::metadata(p).ok()).map(|m| m.len());
|
||||
let bytes = st.path.as_deref().and_then(crate::io::file_len);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let bytes: Option<u64> = None;
|
||||
Ok(json!({
|
||||
@@ -644,3 +661,43 @@ fn recovery_discard(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
#[cfg(test)]
|
||||
#[path = "color_at_tests.rs"]
|
||||
mod color_at_tests;
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::write_file;
|
||||
|
||||
fn scratch(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-write-file-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_does_not_touch_a_predictable_saving_sibling() {
|
||||
let dir = scratch("sibling");
|
||||
let dest = dir.join("deck.deckcraft");
|
||||
let dest_str = dest.to_str().unwrap();
|
||||
std::fs::write(&dest, b"old").unwrap();
|
||||
std::fs::write(dir.join("deck.deckcraft.saving"), b"precious").unwrap();
|
||||
write_file(dest_str, b"new").unwrap();
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"new");
|
||||
assert_eq!(std::fs::read(dir.join("deck.deckcraft.saving")).unwrap(), b"precious");
|
||||
let names: Vec<_> = std::fs::read_dir(&dir).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(names.len(), 2, "no temporary file may be left behind: {names:?}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_file_failed_rename_keeps_destination_and_cleans_up() {
|
||||
let dir = scratch("failed");
|
||||
let dest = dir.join("target");
|
||||
std::fs::create_dir(&dest).unwrap();
|
||||
assert!(write_file(dest.to_str().unwrap(), b"new").is_err());
|
||||
assert!(dest.is_dir());
|
||||
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -41,15 +41,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("format.size", "Font Size", ["Home", "Font"], None, "{size: pt}", has_text_or_shapes, size),
|
||||
cmd!("format.grow", "Increase Font Size", ["Home", "Font"], Some("Cmd+Shift+."), "{}", has_text_or_shapes, grow),
|
||||
cmd!("format.shrink", "Decrease Font Size", ["Home", "Font"], Some("Cmd+Shift+,"), "{}", has_text_or_shapes, shrink),
|
||||
cmd!(
|
||||
"format.color",
|
||||
"Font Color",
|
||||
["Home", "Font"],
|
||||
None,
|
||||
"{color: #RRGGBB | accent1..6 | tx1… | {scheme, lumMod…}}",
|
||||
has_text_or_shapes,
|
||||
color
|
||||
),
|
||||
cmd!("format.color", "Font Color", ["Home", "Font"], None, "{color: #RRGGBB|accent1..6|tx1…|{scheme, lumMod…}}", has_text_or_shapes, color),
|
||||
cmd!(
|
||||
"format.textTransparency",
|
||||
"Text Transparency",
|
||||
@@ -214,7 +206,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Text Box Margins",
|
||||
["Format Shape", "Text Box"],
|
||||
None,
|
||||
"{left?, top?, right?, bottom?: pt}",
|
||||
"{left?: pt, top?: pt, right?: pt, bottom?: pt}",
|
||||
has_text_or_shapes,
|
||||
margins
|
||||
),
|
||||
@@ -1206,6 +1198,7 @@ fn state(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
"align": pp.align.map(|a| a.xml()),
|
||||
"bullets": matches!(pp.bullet, Some(Bullet::Char { .. })),
|
||||
"numbering": matches!(pp.bullet, Some(Bullet::AutoNum { .. })),
|
||||
"lang": r.lang,
|
||||
"lineSpacing": serde_json::to_value(pp.line_spacing).unwrap_or_default(),
|
||||
"painter": s.painter.is_some(),
|
||||
}))
|
||||
|
||||
@@ -308,9 +308,8 @@ pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec<u8>)> {
|
||||
let bytes = base64_decode(d).ok_or_else(|| bad(cmd, "invalid base64 `data`"))?;
|
||||
return Ok((str_param(p, "name").unwrap_or("media").to_string(), bytes));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = crate::io::read_file(path).map_err(EngineError::Other)?;
|
||||
let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "media".into());
|
||||
return Ok((name, bytes));
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::{Result, Session};
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(query "document.inspect", "Inspect Presentation", [], None, "{} → slides with titles, layouts, shape counts; selection; size; theme", has_doc, document),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes", has_doc, slide),
|
||||
cmd!(query "slide.inspect", "Inspect Slide", [], None, "{index?} → the slide's shapes with ids, kinds, boxes, text, fills, animations, transition, notes (text, and notesBody with its formatting)", has_doc, slide),
|
||||
cmd!(query "shape.inspect", "Inspect Shape", [], None, "{id} → full shape JSON plus its effective box", has_doc, shape),
|
||||
cmd!(query "commands.list", "List Commands", [], None, "{filter?: substring} → [{id, label, params, enabled}]", always, commands),
|
||||
]
|
||||
@@ -129,6 +129,7 @@ fn slide(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
"transition": serde_json::to_value(&sl.transition).unwrap_or_default(),
|
||||
"animations": serde_json::to_value(&sl.animations).unwrap_or_default(),
|
||||
"notes": sl.notes_text(),
|
||||
"notesBody": sl.notes,
|
||||
"comments": sl.comments.len(),
|
||||
"hidden": sl.hidden,
|
||||
}))
|
||||
|
||||
@@ -46,6 +46,9 @@ pub struct CommandSpec {
|
||||
/// Where it lives: `["Home", "Font"]` (ribbon tab and group) or `["Arrange"]` (menu).
|
||||
pub menu: &'static [&'static str],
|
||||
pub shortcut: Option<&'static str>,
|
||||
/// The parameters as `describe` and `commands` show them, read by [`parse_params`]: `?` marks
|
||||
/// an optional key, a spaced ` | ` separates alternative sets of keys (so alternative values
|
||||
/// are written unspaced, `ms|null`), `(…)` is a remark and `→` starts the answer.
|
||||
pub params: &'static str,
|
||||
pub enabled: Enabled,
|
||||
pub run: Run,
|
||||
@@ -66,6 +69,28 @@ pub struct CommandInfo {
|
||||
}
|
||||
|
||||
impl CommandSpec {
|
||||
/// Holds because `Session::execute` skips the selection check when `id` or `ids` is given.
|
||||
pub fn acts_on(&self) -> Option<String> {
|
||||
let keys = param_keys(self.params).unwrap_or_default();
|
||||
let named = if keys.contains(&"ids") {
|
||||
Some("the shapes in `ids`")
|
||||
} else if keys.contains(&"id") {
|
||||
Some("the shape in `id`")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let reason = (self.enabled)(&Session::with_new()).err()?;
|
||||
Some(match (reason.as_str(), named) {
|
||||
(SELECT_TEXT_OR_SHAPE, _) => "Acts on the selected text or shapes.".into(),
|
||||
(NOTHING_SELECTED, Some(n)) => format!("Acts on {n}, else on the selected shapes."),
|
||||
(NOTHING_SELECTED, None) => "Acts on the selected shapes.".into(),
|
||||
(SELECT_TWO, Some(n)) => format!("Acts on two or more shapes: {n}, else the selected ones."),
|
||||
(SELECT_TWO, None) => "Acts on two or more selected shapes.".into(),
|
||||
(NO_TEXT_POINT, _) => "Acts on the text being edited; text.edit starts editing a shape's text.".into(),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn info(&self, s: &Session) -> CommandInfo {
|
||||
let e = (self.enabled)(s);
|
||||
CommandInfo {
|
||||
@@ -92,6 +117,8 @@ pub fn has_slide(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
pub const NOTHING_SELECTED: &str = "nothing selected";
|
||||
pub const SELECT_TWO: &str = "select two or more objects";
|
||||
pub const NO_TEXT_POINT: &str = "no text insertion point";
|
||||
pub const SELECT_TEXT_OR_SHAPE: &str = "select text or a shape";
|
||||
pub fn has_selection(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| !d.selection.shapes.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) }
|
||||
@@ -102,17 +129,14 @@ pub fn has_two(s: &Session) -> std::result::Result<(), String> {
|
||||
}
|
||||
pub fn has_text(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err("no text insertion point".into()) }
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some()) { Ok(()) } else { Err(NO_TEXT_POINT.into()) }
|
||||
}
|
||||
/// Text editing or shapes with text selected (formatting commands apply to either).
|
||||
/// Text editing or selected shapes (formatting commands apply to either).
|
||||
pub fn has_text_or_shapes(s: &Session) -> std::result::Result<(), String> {
|
||||
has_doc(s)?;
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("select text or a shape".into())
|
||||
}
|
||||
if s.active().is_some_and(|d| d.selection.text.is_some() || !d.selection.shapes.is_empty()) { Ok(()) } else { Err(SELECT_TEXT_OR_SHAPE.into()) }
|
||||
}
|
||||
|
||||
pub fn can_undo(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.active().is_some_and(|d| !d.history.undo.is_empty()) { Ok(()) } else { Err("nothing to undo".into()) }
|
||||
}
|
||||
@@ -172,6 +196,99 @@ pub fn find_command(id: &str) -> Option<&'static CommandSpec> {
|
||||
command_specs().iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
pub(crate) fn param_keys(doc: &str) -> std::result::Result<Vec<&str>, String> {
|
||||
let mut keys = vec![];
|
||||
for p in parse_params(doc)? {
|
||||
if !keys.contains(&p.name) {
|
||||
keys.push(p.name);
|
||||
}
|
||||
}
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Param<'a> {
|
||||
pub name: &'a str,
|
||||
pub optional: bool,
|
||||
/// What the doc says of it: its values, unit or remark (`rect|roundRect|…`, `[x,y,w,h] pt`).
|
||||
pub about: &'a str,
|
||||
/// An alternative to the parameter before it in the same object (`color? | none?`).
|
||||
pub or: bool,
|
||||
/// Which of the doc's alternative objects it belongs to: `{index} | {id}` puts id in object 1.
|
||||
pub object: usize,
|
||||
}
|
||||
|
||||
/// The top-level parameters of a params doc: `{preset, rect?: [x,y,w,h] pt} | {id}` gives preset,
|
||||
/// rect (optional, `[x,y,w,h] pt`) and id (in the second object). Text after `→` is the result
|
||||
/// and is skipped. Errs where the doc strays from that grammar.
|
||||
pub fn parse_params(doc: &str) -> std::result::Result<Vec<Param<'_>>, String> {
|
||||
let doc = doc.split('→').next().unwrap_or_default();
|
||||
let mut params: Vec<Param> = vec![];
|
||||
let mut open: Vec<char> = vec![];
|
||||
let mut expect_key = false;
|
||||
let mut or = false;
|
||||
let mut objects: usize = 0;
|
||||
let mut about_from: Option<usize> = None;
|
||||
let mut chars = doc.char_indices().peekable();
|
||||
let mut prev = ' ';
|
||||
fn end_about<'a>(doc: &'a str, params: &mut [Param<'a>], from: &mut Option<usize>, to: usize) {
|
||||
if let (Some(f), Some(p)) = (from.take(), params.last_mut()) {
|
||||
p.about = doc.get(f..to).unwrap_or_default().trim();
|
||||
}
|
||||
}
|
||||
while let Some((i, c)) = chars.next() {
|
||||
let in_object = open == ['{'];
|
||||
match c {
|
||||
'{' | '[' | '(' => {
|
||||
open.push(c);
|
||||
if open == ['{'] {
|
||||
expect_key = true;
|
||||
objects += 1;
|
||||
or = false;
|
||||
}
|
||||
}
|
||||
'}' | ']' | ')' => {
|
||||
if in_object {
|
||||
end_about(doc, &mut params, &mut about_from, i);
|
||||
}
|
||||
open.pop();
|
||||
}
|
||||
',' if in_object => {
|
||||
end_about(doc, &mut params, &mut about_from, i);
|
||||
expect_key = true;
|
||||
}
|
||||
'|' if in_object && prev == ' ' && chars.peek().is_some_and(|(_, n)| *n == ' ') => {
|
||||
end_about(doc, &mut params, &mut about_from, i);
|
||||
expect_key = true;
|
||||
or = true;
|
||||
}
|
||||
c if in_object && expect_key && (c.is_ascii_alphabetic() || c == '_') => {
|
||||
let mut end = i + c.len_utf8();
|
||||
while let Some((j, n)) = chars.peek().copied().filter(|(_, n)| n.is_ascii_alphanumeric() || *n == '_') {
|
||||
end = j + n.len_utf8();
|
||||
chars.next();
|
||||
}
|
||||
let after = doc.get(end..).unwrap_or_default();
|
||||
let rest = after.trim_start_matches('?').trim_start();
|
||||
let at = doc.len() - rest.len();
|
||||
about_from = match rest.chars().next() {
|
||||
Some(':') => Some(at + 1),
|
||||
Some('(') => Some(at),
|
||||
Some(',' | '}' | '|') => None,
|
||||
_ => return Err(format!("`{}` is followed by `{}`", &doc[i..end], rest.chars().take(12).collect::<String>())),
|
||||
};
|
||||
params.push(Param { name: &doc[i..end], optional: after.starts_with('?'), about: "", or, object: objects.saturating_sub(1) });
|
||||
or = false;
|
||||
expect_key = false;
|
||||
}
|
||||
c if in_object && expect_key && !c.is_whitespace() => return Err(format!("expected a key at `{}`", &doc[i..])),
|
||||
_ => {}
|
||||
}
|
||||
prev = c;
|
||||
}
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
// ---------- param helpers ----------
|
||||
|
||||
pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||||
|
||||
@@ -46,7 +46,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
),
|
||||
cmd!(noundo "review.addToDictionary", "Add to Dictionary", ["Review", "Proofing"], None, "{word} → {word, file} (append to the user dictionary file; not an undo step)", always, add_to_dictionary),
|
||||
cmd!(query "review.dictionary", "Spelling Dictionary", ["Review", "Proofing"], None, "{lang?} → {lang, available, found, folders} (whether review.spelling has a dictionary, and which file)", always, dictionary_info),
|
||||
cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word?: default the selected word, or the word at the caret} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus),
|
||||
cmd!(query "review.thesaurus", "Thesaurus", ["Review", "Proofing"], None, "{word? (default: the selected word, or the word at the caret)} → {word, senses: [{word, synonyms}]}", has_doc, thesaurus),
|
||||
cmd!(
|
||||
"review.replaceWord",
|
||||
"Insert Synonym",
|
||||
@@ -639,7 +639,7 @@ fn revised_bytes(p: &Value) -> Result<(String, Vec<u8>)> {
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = str_param(p, "path") {
|
||||
let bytes = std::fs::read(path).map_err(|e| crate::EngineError::Other(format!("{path}: {e}")))?;
|
||||
let bytes = crate::io::read_file(path).map_err(crate::EngineError::Other)?;
|
||||
return Ok((path.to_string(), bytes));
|
||||
}
|
||||
Err(bad("review.compare", "give `path` or `data`"))
|
||||
|
||||
@@ -39,8 +39,16 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
ink_to_shape
|
||||
),
|
||||
cmd!(query "shape.sites", "Connection Sites", [], None, "{id} → [[x, y]] connection sites in slide points", has_slide, sites),
|
||||
cmd!("shape.move", "Move", [], None, "{dx?, dy?: pt (relative) | x?, y?: pt (absolute), ids?}", has_selection, move_by),
|
||||
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
|
||||
cmd!(
|
||||
"shape.move",
|
||||
"Move",
|
||||
[],
|
||||
None,
|
||||
"{dx?: pt (relative), dy?: pt (relative), ids?} | {x?: pt (absolute), y?: pt (absolute), ids?}",
|
||||
has_selection,
|
||||
move_by
|
||||
),
|
||||
cmd!("shape.resize", "Size", ["Shape Format", "Size"], None, "{w?: pt, h?: pt, lockAspect?: bool, ids?}", has_selection, resize),
|
||||
cmd!("shape.setBounds", "Position and Size", [], None, "{x, y, w, h, ids?}", has_selection, set_bounds),
|
||||
cmd!(
|
||||
"shape.rotate",
|
||||
@@ -121,7 +129,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
lock
|
||||
),
|
||||
cmd!("shape.setDefault", "Set as Default Shape", [], None, "{}", has_selection, set_default),
|
||||
cmd!("picture.crop", "Crop", ["Picture Format", "Size"], None, "{left?, top?, right?, bottom?: fraction 0..1, ids?}", has_selection, crop),
|
||||
cmd!(
|
||||
"picture.crop",
|
||||
"Crop",
|
||||
["Picture Format", "Size"],
|
||||
None,
|
||||
"{left?: fraction 0..1, top?: fraction 0..1, right?: fraction 0..1, bottom?: fraction 0..1, ids?}",
|
||||
has_selection,
|
||||
crop
|
||||
),
|
||||
cmd!(
|
||||
"picture.adjust",
|
||||
"Corrections",
|
||||
|
||||
@@ -32,7 +32,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("slide.hide", "Hide Slide", ["Slide Show", "Set Up"], None, "{index?, hidden?: bool}", has_slide, hide),
|
||||
cmd!("slide.layout", "Layout", ["Home", "Slides"], None, "{layout: name or kind, index?}", has_slide, set_layout),
|
||||
cmd!("slide.reset", "Reset", ["Home", "Slides"], None, "{index?}", has_slide, reset),
|
||||
cmd!("slide.notes", "Notes", ["View"], None, "{text, index?}", has_slide, notes),
|
||||
cmd!(
|
||||
"slide.notes",
|
||||
"Notes",
|
||||
["View"],
|
||||
None,
|
||||
"{text | body: formatted notes (as in slide.inspect's notesBody), index?} (text as typed: untouched notes keep their formatting)",
|
||||
has_slide,
|
||||
notes
|
||||
),
|
||||
cmd!("slide.rename", "Rename Slide", [], None, "{name, index?}", has_slide, rename),
|
||||
cmd!("section.add", "Add Section", ["Home", "Slides", "Section"], None, "{name?, at?: slide index}", has_slide, section_add),
|
||||
cmd!("section.rename", "Rename Section", ["Home", "Slides", "Section"], None, "{index, name}", has_slide, section_rename),
|
||||
@@ -468,10 +476,20 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn notes(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let i = index_of(s, p)?;
|
||||
// Formatted notes as a whole (the notes editors), or text: then only what was retyped
|
||||
// changes, so bold, levels and bullets around it survive.
|
||||
let body = match p.get("body") {
|
||||
Some(b) => Some(serde_json::from_value::<TextBody>(b.clone()).map_err(|e| bad("slide.notes", format!("invalid `body`: {e}")))?),
|
||||
None => None,
|
||||
};
|
||||
let text = str_param(p, "text").unwrap_or("").to_string();
|
||||
s.edit(|doc, _| {
|
||||
if let Some(sl) = doc.slides.get_mut(i) {
|
||||
Arc::make_mut(sl).notes = TextBody::from_text(&text);
|
||||
let notes = &mut Arc::make_mut(sl).notes;
|
||||
match body {
|
||||
Some(b) => *notes = b,
|
||||
None => deckcraft_model::edit::replace_text(notes, &text),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
//! Table Design and Table Layout tabs.
|
||||
|
||||
use deckcraft_model::{ShapeKind, Table};
|
||||
use deckcraft_geom::{Affine, Point, Rect};
|
||||
use deckcraft_model::{Shape, ShapeKind, Table};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
use crate::{Result, Session};
|
||||
use crate::{DocState, Result, Session};
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
@@ -27,7 +28,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
s, p, "delCol"
|
||||
)),
|
||||
cmd!("table.merge", "Merge Cells", ["Table Layout", "Merge"], None, "{from: [r, c], to: [r, c], id?}", has_selection, merge),
|
||||
cmd!("table.split", "Split Cells", ["Table Layout", "Merge"], None, "{cell: [r, c], id?}", has_selection, split),
|
||||
cmd!(
|
||||
"table.split",
|
||||
"Split Cells",
|
||||
["Table Layout", "Merge"],
|
||||
None,
|
||||
"{cell?: [r, c] (else the edited cell), id?} | {row?: index, col?: index, id?}",
|
||||
has_selection,
|
||||
split
|
||||
),
|
||||
cmd!(
|
||||
"table.style",
|
||||
"Table Styles",
|
||||
@@ -42,7 +51,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Table Style Options",
|
||||
["Table Design", "Table Style Options"],
|
||||
None,
|
||||
"{headerRow?, totalRow?, bandedRows?, firstColumn?, lastColumn?, bandedColumns?: bool, id?}",
|
||||
"{headerRow?: bool, totalRow?: bool, bandedRows?: bool, firstColumn?: bool, lastColumn?: bool, bandedColumns?: bool, id?}",
|
||||
has_selection,
|
||||
options
|
||||
),
|
||||
@@ -86,7 +95,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("table.distributeColumns", "Distribute Columns", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute(
|
||||
s, p, false
|
||||
)),
|
||||
cmd!("table.columnWidth", "Width", ["Table Layout", "Cell Size"], None, "{col, width: pt, id?}", has_selection, column_width),
|
||||
cmd!(
|
||||
"table.columnWidth",
|
||||
"Width",
|
||||
["Table Layout", "Cell Size"],
|
||||
None,
|
||||
"{col, width: pt, keepTotal?: bool (the next column absorbs the change), id?}",
|
||||
has_selection,
|
||||
column_width
|
||||
),
|
||||
cmd!("table.rowHeight", "Height", ["Table Layout", "Cell Size"], None, "{row, height: pt, id?}", has_selection, row_height),
|
||||
cmd!(
|
||||
"table.drawLine",
|
||||
@@ -110,6 +127,104 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Narrowest a column gets when its border is dragged (points).
|
||||
pub const MIN_COLUMN_WIDTH: f64 = 12.0;
|
||||
|
||||
/// A table as drawn on the slide: where it is, its drawn row heights and its visible cells.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TableFrame {
|
||||
/// Table-local (points from the table's top-left corner) → slide space, as the table is drawn.
|
||||
pub affine: Affine,
|
||||
pub cols: Vec<f64>,
|
||||
/// Row heights as drawn: a stored height is a minimum, rows grow to fit their text.
|
||||
pub heights: Vec<f64>,
|
||||
/// Visible cells (merges honoured): (row, col, table-local rect).
|
||||
pub cells: Vec<(usize, usize, Rect)>,
|
||||
}
|
||||
|
||||
/// A border between table rows or columns that can be dragged.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TableBorder {
|
||||
/// The right edge of this column (the last one is the table's right edge).
|
||||
Column(usize),
|
||||
/// The bottom edge of this row (the last one is the table's bottom edge).
|
||||
Row(usize),
|
||||
}
|
||||
|
||||
/// The drawn geometry of table shape `sh` on the edited slide (`None` if it isn't a table).
|
||||
pub fn table_frame(st: &DocState, sh: &Shape) -> Option<TableFrame> {
|
||||
let ShapeKind::Table(t) = &sh.kind else { return None };
|
||||
// The table is drawn in its own box; rows that grow past it don't move it.
|
||||
let x = sh.xfrm.unwrap_or_else(|| xfrm_of(&st.doc, &st.selection, sh));
|
||||
let heights = table_drawn_heights(&st.doc, &st.selection, t);
|
||||
let cells = deckcraft_render::table_cell_rects(t, &heights);
|
||||
Some(TableFrame { affine: x.affine(), cols: t.cols.clone(), heights, cells })
|
||||
}
|
||||
|
||||
impl TableFrame {
|
||||
pub fn width(&self) -> f64 {
|
||||
self.cols.iter().map(|w| w.max(0.0)).sum()
|
||||
}
|
||||
|
||||
pub fn height(&self) -> f64 {
|
||||
self.heights.iter().map(|h| h.max(0.0)).sum()
|
||||
}
|
||||
|
||||
/// Table-local point for slide point `p`.
|
||||
pub fn local(&self, p: Point) -> Point {
|
||||
self.affine.inverse() * p
|
||||
}
|
||||
|
||||
/// Table-local rect of visible cell (`r`, `c`).
|
||||
pub fn cell_rect(&self, r: usize, c: usize) -> Option<Rect> {
|
||||
self.cells.iter().find(|(cr, cc, _)| (*cr, *cc) == (r, c)).map(|x| x.2)
|
||||
}
|
||||
|
||||
/// The visible cell at table-local point `p` (points outside the table go to the nearest cell).
|
||||
pub fn cell_at(&self, p: Point) -> Option<(usize, usize)> {
|
||||
let q = Point::new(p.x.clamp(0.0, self.width().max(0.0)), p.y.clamp(0.0, self.height().max(0.0)));
|
||||
let inside = |r: &Rect| q.x >= r.x0 && q.x <= r.x1 && q.y >= r.y0 && q.y <= r.y1;
|
||||
// Edges are shared: prefer the cell the point is strictly inside.
|
||||
self.cells
|
||||
.iter()
|
||||
.find(|(_, _, r)| q.x >= r.x0 && q.x < r.x1 && q.y >= r.y0 && q.y < r.y1)
|
||||
.or_else(|| self.cells.iter().rev().find(|(_, _, r)| inside(r)))
|
||||
.map(|(r, c, _)| (*r, *c))
|
||||
}
|
||||
|
||||
/// The draggable border within `tol` of table-local point `p`: the inner borders and the right
|
||||
/// and bottom edges (the left and top edges move the table). Borders hidden inside merged
|
||||
/// cells don't count.
|
||||
pub fn border_at(&self, p: Point, tol: f64) -> Option<TableBorder> {
|
||||
let (w, h) = (self.width(), self.height());
|
||||
if !(p.x >= -tol && p.y >= -tol && p.x <= w + tol && p.y <= h + tol) {
|
||||
return None;
|
||||
}
|
||||
let q = Point::new(p.x.clamp(0.0, w), p.y.clamp(0.0, h));
|
||||
let mut best: Option<(f64, TableBorder)> = None;
|
||||
let mut x = 0.0;
|
||||
for (i, cw) in self.cols.iter().enumerate() {
|
||||
x += cw.max(0.0);
|
||||
let d = (p.x - x).abs();
|
||||
// A cell ending at this edge at this height shows the border.
|
||||
let shown = self.cells.iter().any(|(_, _, r)| (r.x1 - x).abs() < 1e-6 && q.y >= r.y0 && q.y <= r.y1);
|
||||
if d <= tol && shown && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, TableBorder::Column(i)));
|
||||
}
|
||||
}
|
||||
let mut y = 0.0;
|
||||
for (i, rh) in self.heights.iter().enumerate() {
|
||||
y += rh.max(0.0);
|
||||
let d = (p.y - y).abs();
|
||||
let shown = self.cells.iter().any(|(_, _, r)| (r.y1 - y).abs() < 1e-6 && q.x >= r.x0 && q.x <= r.x1);
|
||||
if d <= tol && shown && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, TableBorder::Row(i)));
|
||||
}
|
||||
}
|
||||
best.map(|b| b.1)
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_param(p: &Value, key: &str) -> Option<(usize, usize)> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
||||
@@ -418,9 +533,27 @@ fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
|
||||
fn column_width(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (_, c) = at(s, p);
|
||||
let w = f64_param(p, "width").ok_or_else(|| bad("table.columnWidth", "missing `width`"))?.clamp(4.0, 4000.0);
|
||||
let keep_total = bool_or(p, "keepTotal", false);
|
||||
with_table(s, p, "table.columnWidth", move |t| {
|
||||
if let Some(x) = t.cols.get_mut(c) {
|
||||
*x = w;
|
||||
// Keeping the table's width, the column to the right gives or takes the difference (dragging
|
||||
// an inner border). The last column has none: the table gets wider or narrower.
|
||||
let pair = t.cols.get(c).zip(t.cols.get(c + 1)).map(|(a, b)| a.max(0.0) + b.max(0.0)).filter(|x| x.is_finite());
|
||||
match pair {
|
||||
Some(pair) if keep_total => {
|
||||
let min = MIN_COLUMN_WIDTH.min(pair / 2.0);
|
||||
let w = w.max(min).min(pair - min);
|
||||
if let Some(x) = t.cols.get_mut(c) {
|
||||
*x = w;
|
||||
}
|
||||
if let Some(x) = t.cols.get_mut(c + 1) {
|
||||
*x = pair - w;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(x) = t.cols.get_mut(c) {
|
||||
*x = w;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
|
||||
+129
-14
@@ -2,10 +2,11 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_geom::{Affine, Point, Rect};
|
||||
use deckcraft_model::edit::{self as ed, Pos};
|
||||
use deckcraft_model::resolve::{self, Ctx};
|
||||
use deckcraft_model::text::{AutoFit, Hyperlink, Run, RunKind, RunProps, TextBody};
|
||||
use deckcraft_model::{Presentation, ShapeId, ShapeKind};
|
||||
use deckcraft_model::text::{Align, AutoFit, Hyperlink, Paragraph, Run, RunKind, RunProps, TextBody};
|
||||
use deckcraft_model::{Presentation, Shape, ShapeId, ShapeKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
@@ -114,7 +115,7 @@ fn body_mut<'a>(doc: &'a mut Presentation, sel: &Selection, t: &TextSel) -> Opti
|
||||
|
||||
/// Edit the active text body and selection; refits auto-sizing text boxes afterwards.
|
||||
pub(crate) fn edit_text<T>(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result<T> {
|
||||
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", "no text insertion point"))?;
|
||||
let t0 = s.doc()?.selection.text.clone().ok_or_else(|| bad("text", NO_TEXT_POINT))?;
|
||||
// Materialise an inherited placeholder box before editing (so autofit can change it).
|
||||
let r = s.edit(|doc, sel| {
|
||||
let mut t = t0.clone();
|
||||
@@ -183,20 +184,44 @@ pub(crate) fn ctx_for<'a>(doc: &'a Presentation, sel: &Selection) -> Option<Ctx<
|
||||
|
||||
/// Lay out the text being edited (for vertical caret movement and hit testing).
|
||||
pub fn layout_for(st: &DocState, t: &TextSel) -> Option<deckcraft_text::TextLayout> {
|
||||
if t.notes || t.cell.is_some() {
|
||||
text_frame(st, t).map(|f| f.0)
|
||||
}
|
||||
|
||||
/// The text being edited as it is drawn: its layout, and the transform from layout space to the
|
||||
/// slide (flips don't mirror text, vertical text is turned). Shapes and table cells alike; the
|
||||
/// caret, the text selection, hit testing and caret movement all use it. `None` for notes.
|
||||
pub fn text_frame(st: &DocState, t: &TextSel) -> Option<(deckcraft_text::TextLayout, Affine)> {
|
||||
if t.notes {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(t.shape)?;
|
||||
let body = sh.text.as_ref()?;
|
||||
let ctx = ctx_for(&st.doc, &st.selection)?;
|
||||
let lay = |s: &Shape, body: &TextBody, rect: Rect| {
|
||||
deckcraft_text::layout(&ctx, s, body, &deckcraft_text::Opts { rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false })
|
||||
};
|
||||
if let Some((r, c)) = t.cell {
|
||||
// A cell's text is laid out in the cell's rect with the cell's margins, anchor and the
|
||||
// table style's text look, exactly as the table draws it.
|
||||
let placed = cell_on_slide(st, t.shape, r, c)?;
|
||||
return Some((placed.layout, placed.to_slide));
|
||||
}
|
||||
let body = sh.text.as_ref()?;
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let geo = deckcraft_render::shape_geometry(sh, x.w, x.h);
|
||||
Some(deckcraft_text::layout(
|
||||
&ctx,
|
||||
sh,
|
||||
body,
|
||||
&deckcraft_text::Opts { rect: geo.text_rect, fields: &deckcraft_text::NoFields, prompt_color: None, no_shrink: false },
|
||||
))
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let l = lay(sh, body, tr);
|
||||
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
||||
Some((l, m))
|
||||
}
|
||||
|
||||
/// The text position under slide point `p` in the text being edited (its shape or cell).
|
||||
pub fn text_pos_at(st: &DocState, t: &TextSel, p: Point) -> Pos {
|
||||
match text_frame(st, t) {
|
||||
Some((l, m)) => {
|
||||
let h = l.hit(m.inverse() * p);
|
||||
(h.para, h.ch)
|
||||
}
|
||||
None => (0, 0),
|
||||
}
|
||||
}
|
||||
|
||||
/// The insertion point on the slide: its line from top to bottom, in slide coordinates (flips and
|
||||
@@ -234,6 +259,22 @@ pub fn caret_line(st: &DocState, t: &TextSel) -> Option<(deckcraft_geom::Point,
|
||||
Some((m * deckcraft_geom::Point::new(cx, top), m * deckcraft_geom::Point::new(cx, bot)))
|
||||
}
|
||||
|
||||
/// The colour of text typed at the caret. The canvas draws the caret in it, so the caret shows
|
||||
/// wherever the text does: light on a dark slide, dark on a light one. `None` when that text
|
||||
/// has no fill to take a colour from.
|
||||
pub fn caret_color(st: &DocState, t: &TextSel) -> Option<deckcraft_color::Rgba> {
|
||||
if t.notes || t.cell.is_some() {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(t.shape)?;
|
||||
let body = sh.text.as_ref()?;
|
||||
let ctx = ctx_for(&st.doc, &st.selection)?;
|
||||
let blank = Paragraph::default();
|
||||
let para = body.paragraphs.get(t.caret.0).unwrap_or(&blank);
|
||||
let props = resolve::run(&ctx, sh, para, &ed::props_at(body, t.caret));
|
||||
Some(resolve::text_color(&ctx, &props)).filter(|c| c.a > 0)
|
||||
}
|
||||
|
||||
fn pos_param(p: &Value, key: &str) -> Option<Pos> {
|
||||
let a = p.get(key)?.as_array()?;
|
||||
Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize))
|
||||
@@ -305,6 +346,7 @@ fn typed(s: &Session, body: &TextBody, at: Pos, text: &str) -> String {
|
||||
}
|
||||
|
||||
pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>) -> Result<Value> {
|
||||
let inherited = inherited_aligns_for_edit(s);
|
||||
let text = {
|
||||
let st = s.doc()?;
|
||||
match &st.selection.text {
|
||||
@@ -322,12 +364,53 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
|
||||
let c = ed::insert(body, at, &text, props);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
for pi in at.0..=c.0 {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
}
|
||||
}
|
||||
c
|
||||
})?;
|
||||
autocorrect(s, text.chars().count() == 1)?;
|
||||
Ok(json!({"caret": [pos.0, pos.1]}))
|
||||
}
|
||||
|
||||
/// Per outline level (0..=8), whether shape `id` on the current slide already resolves an
|
||||
/// alignment for its paragraphs (its own list style, the layout's or the master's). All `false`
|
||||
/// when the shape can't be resolved (notes, a shape that is gone).
|
||||
fn inherited_aligns(s: &Session, id: ShapeId) -> [bool; 9] {
|
||||
let mut out = [false; 9];
|
||||
let Ok(st) = s.doc() else { return out };
|
||||
let Some(sh) = st.shape(id) else { return out };
|
||||
let Some(ctx) = ctx_for(&st.doc, &st.selection) else { return out };
|
||||
for (lvl, set) in out.iter_mut().enumerate() {
|
||||
let para = deckcraft_model::text::Paragraph { level: lvl as u8, ..Default::default() };
|
||||
*set = resolve::para(&ctx, sh, ¶).align.is_some();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The aligns [`inherited_aligns`] gives for the text being edited (none for notes).
|
||||
fn inherited_aligns_for_edit(s: &Session) -> [bool; 9] {
|
||||
match s.doc().ok().and_then(|st| st.selection.text.clone()) {
|
||||
Some(t) if !t.notes => inherited_aligns(s, t.shape),
|
||||
_ => [false; 9],
|
||||
}
|
||||
}
|
||||
|
||||
/// A paragraph with no direction of its own whose first strong character is right-to-left
|
||||
/// (Arabic, Hebrew, Kurdish…) becomes right-to-left, and right-aligned when nothing (the
|
||||
/// paragraph or what it inherits, `inherited`) sets its alignment.
|
||||
fn promote_rtl_if_needed(para: &mut deckcraft_model::text::Paragraph, inherited: &[bool; 9]) {
|
||||
if para.props.rtl.is_none() && deckcraft_model::bidi::first_strong_is_rtl(¶.text()) {
|
||||
para.props.rtl = Some(true);
|
||||
let inherits_align = inherited.get(usize::from(para.level)).copied().unwrap_or(false);
|
||||
if para.props.align.is_none() && !inherits_align {
|
||||
para.props.align = Some(Align::Right);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Typing right after a hyperlink doesn't extend it (as in PowerPoint): at the end of a linked
|
||||
/// run, the props to type with are the run's without the link. `None` elsewhere.
|
||||
fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option<RunProps> {
|
||||
@@ -598,12 +681,18 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result<Value> {
|
||||
let inherited = inherited_aligns_for_edit(s);
|
||||
let pos = edit_text(s, |body, t| {
|
||||
let (a, b) = t.ordered();
|
||||
let at = ed::delete(body, a, b);
|
||||
let c = ed::insert_body(body, at, src);
|
||||
t.anchor = c;
|
||||
t.caret = c;
|
||||
for pi in at.0..=c.0 {
|
||||
if let Some(para) = body.paragraphs.get_mut(pi) {
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
}
|
||||
}
|
||||
c
|
||||
})?;
|
||||
Ok(json!({"caret": [pos.0, pos.1]}))
|
||||
@@ -1037,6 +1126,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
None => s.doc()?.selection.shapes.first().copied().ok_or_else(|| bad("text.set", "missing `id` (or select a shape)"))?,
|
||||
};
|
||||
let cell = p.get("cell").and_then(Value::as_array).and_then(|a| Some((a.first()?.as_u64()? as usize, a.get(1)?.as_u64()? as usize)));
|
||||
let inherited = inherited_aligns(s, id);
|
||||
edit_shapes(s, &json!({"id": id}), "text.set", |sh| {
|
||||
let target = match (&mut sh.kind, cell) {
|
||||
(ShapeKind::Table(t), Some((r, c))) => t.cell_mut(r, c).map(|x| &mut x.text),
|
||||
@@ -1055,6 +1145,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
para.level = *lv;
|
||||
para.props = pp.clone();
|
||||
}
|
||||
promote_rtl_if_needed(para, &inherited);
|
||||
for r in &mut para.runs {
|
||||
r.props = keep.clone();
|
||||
}
|
||||
@@ -1104,7 +1195,7 @@ pub(crate) fn format_run(s: &mut Session, f: &(dyn Fn(&mut RunProps) + Send + Sy
|
||||
}
|
||||
let ids = st.selection.shapes.clone();
|
||||
if ids.is_empty() {
|
||||
return Err(bad("format", "select text or a shape"));
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
match &mut sh.kind {
|
||||
@@ -1194,7 +1285,7 @@ pub(crate) fn format_para(s: &mut Session, f: &(dyn Fn(&mut deckcraft_model::tex
|
||||
}
|
||||
let ids = st.selection.shapes.clone();
|
||||
if ids.is_empty() {
|
||||
return Err(bad("format", "select text or a shape"));
|
||||
return Err(bad("format", SELECT_TEXT_OR_SHAPE));
|
||||
}
|
||||
edit_shapes(s, &json!({"ids": ids}), "format", |sh| {
|
||||
match &mut sh.kind {
|
||||
@@ -1264,6 +1355,30 @@ fn replace_font(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"replaced": n}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod rtl_promote_tests {
|
||||
use deckcraft_model::text::{Align, Paragraph, Run};
|
||||
|
||||
use super::promote_rtl_if_needed;
|
||||
|
||||
fn para(text: &str) -> Paragraph {
|
||||
Paragraph { runs: vec![Run::new(text)], ..Default::default() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_aligns_only_when_no_alignment_is_set_or_inherited() {
|
||||
let mut p = para("مرحبا");
|
||||
promote_rtl_if_needed(&mut p, &[false; 9]);
|
||||
assert_eq!((p.props.rtl, p.props.align), (Some(true), Some(Align::Right)));
|
||||
let mut p = para("مرحبا");
|
||||
promote_rtl_if_needed(&mut p, &[true; 9]);
|
||||
assert_eq!((p.props.rtl, p.props.align), (Some(true), None));
|
||||
let mut p = para("Hello مرحبا");
|
||||
promote_rtl_if_needed(&mut p, &[false; 9]);
|
||||
assert_eq!((p.props.rtl, p.props.align), (None, None));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod replace_font_tests {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -35,7 +35,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Sound",
|
||||
["Transitions", "Timing"],
|
||||
None,
|
||||
"{builtin?: chime|click|camera|whoosh|drumRoll|laser | path? | data: base64, name?; none?: true (No Sound); stopPrevious?: true; loop?: bool (Loop Until Next Sound); index?} — the sound plays as the slide comes in",
|
||||
"{builtin?: chime|click|camera|whoosh|drumRoll|laser | path? | data: base64, name?, none?: true (No Sound), stopPrevious?: true, loop?: bool (Loop Until Next Sound), index?} — the sound plays as the slide comes in",
|
||||
has_slide,
|
||||
sound
|
||||
),
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
//! The folders automation may read and write.
|
||||
//!
|
||||
//! `deckcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>` confines the
|
||||
//! files an agent's commands reach. [`AutomationRoots`] holds the two folders. While they are in force, every path a command
|
||||
//! reads goes through [`check_read`] and every path it writes through [`check_write`]:
|
||||
//!
|
||||
//! - The path is made absolute against the working directory, as the file system reads it. Its
|
||||
//! links are followed: for a file that doesn't exist yet, the links of its nearest existing
|
||||
//! folder, the rest of the path being plain names (`..` below a missing folder is refused, and so
|
||||
//! is a link that leads nowhere).
|
||||
//! - The result must lie inside the root, compared by whole components (`/work2` isn't inside
|
||||
//! `/work`), without regard to case on Windows.
|
||||
//! - Read and write access are separate: a root given alone grants only its own kind of access.
|
||||
//!
|
||||
//! Roots are in force on a thread inside [`confine`], and in the whole process after
|
||||
//! [`confine_process`] (`deckcraft-cli mcp`), except inside [`unconfined`]. Without roots
|
||||
//! nothing is checked.
|
||||
//!
|
||||
//! A path is checked, then opened: a process that can change the folders in between (swapping in
|
||||
//! a link) can still win that race. The roots keep an agent to the files it was given; they don't
|
||||
//! guard against other programs on the computer.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::ffi::OsString;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Read access or write access.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Access {
|
||||
Read,
|
||||
Write,
|
||||
}
|
||||
|
||||
/// The folders automation may read and write; a root left out grants no access of its kind.
|
||||
#[derive(Debug)]
|
||||
pub struct AutomationRoots {
|
||||
read: Option<Root>,
|
||||
write: Option<Root>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Root {
|
||||
/// The folder made absolute, links followed: what paths are compared with.
|
||||
resolved: PathBuf,
|
||||
/// The same without Windows' `\\?\` prefix, for people.
|
||||
shown: PathBuf,
|
||||
}
|
||||
|
||||
impl Root {
|
||||
/// The folder at `path`, which must exist.
|
||||
fn open(path: &Path, access: Access) -> Result<Self, String> {
|
||||
let kind = match access {
|
||||
Access::Read => "read",
|
||||
Access::Write => "write",
|
||||
};
|
||||
let resolved = std::fs::canonicalize(path)
|
||||
.and_then(|p| if p.is_dir() { Ok(p) } else { Err(std::io::ErrorKind::NotADirectory.into()) })
|
||||
.map_err(|e| format!("cannot open the automation {kind} root `{}`: {e}", path.display()))?;
|
||||
Ok(Self { shown: plain(resolved.clone()), resolved })
|
||||
}
|
||||
}
|
||||
|
||||
impl AutomationRoots {
|
||||
/// The roots `read` and `write`, which must be existing folders; `None` when neither is given
|
||||
/// (nothing is confined then).
|
||||
pub fn new(read: Option<&Path>, write: Option<&Path>) -> Result<Option<Arc<Self>>, String> {
|
||||
if read.is_none() && write.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let read = read.map(|p| Root::open(p, Access::Read)).transpose()?;
|
||||
let write = write.map(|p| Root::open(p, Access::Write)).transpose()?;
|
||||
Ok(Some(Arc::new(Self { read, write })))
|
||||
}
|
||||
|
||||
/// The read root, absolute with its links followed.
|
||||
pub fn read_root(&self) -> Option<&Path> {
|
||||
self.read.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// The write root, absolute with its links followed.
|
||||
pub fn write_root(&self) -> Option<&Path> {
|
||||
self.write.as_ref().map(|r| r.shown.as_path())
|
||||
}
|
||||
|
||||
/// May the file or folder at `path` be read (or written)? `Err` says why not.
|
||||
pub fn check(&self, path: &str, access: Access) -> Result<(), String> {
|
||||
let root = match access {
|
||||
Access::Read => self.read.as_ref(),
|
||||
Access::Write => self.write.as_ref(),
|
||||
};
|
||||
let Some(Root { resolved: folder, shown }) = root else {
|
||||
return match access {
|
||||
Access::Read => Err(format!("automation filesystem access is not granted: read authority is absent: {path}")),
|
||||
Access::Write => Err(format!("automation filesystem access is not granted: write authority is absent: {path}")),
|
||||
};
|
||||
};
|
||||
if inside(&resolve(path)?, folder) {
|
||||
return Ok(());
|
||||
}
|
||||
let root = shown.display();
|
||||
match access {
|
||||
Access::Read => Err(format!("automation path rejected: outside the read root {root}: {path}")),
|
||||
Access::Write => Err(format!("automation path rejected: outside the write root {root}: {path}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the roots are in force on a thread ([`confine`], [`unconfined`]).
|
||||
#[derive(Clone, Debug)]
|
||||
enum Scope {
|
||||
Confined(Arc<AutomationRoots>),
|
||||
Unconfined,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// This thread's scope; `None`: the process's roots ([`confine_process`]).
|
||||
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The roots in force in every thread outside [`confine`] and [`unconfined`].
|
||||
static PROCESS: OnceLock<Arc<AutomationRoots>> = OnceLock::new();
|
||||
|
||||
/// Put `roots` in force in the whole process, for good. `false` when roots were in force already
|
||||
/// (they stay as they were).
|
||||
pub fn confine_process(roots: Arc<AutomationRoots>) -> bool {
|
||||
PROCESS.set(roots).is_ok()
|
||||
}
|
||||
|
||||
/// Puts the scope it replaced back when dropped (also when `f` panics).
|
||||
struct Restore(Option<Scope>);
|
||||
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
let previous = self.0.take();
|
||||
SCOPE.with(|s| *s.borrow_mut() = previous);
|
||||
}
|
||||
}
|
||||
|
||||
fn with_scope<T>(scope: Option<Scope>, f: impl FnOnce() -> T) -> T {
|
||||
let _restore = Restore(SCOPE.with(|s| s.replace(scope)));
|
||||
f()
|
||||
}
|
||||
|
||||
/// Run `f` with `roots` in force on this thread (`None`: as things stand).
|
||||
pub fn confine<T>(roots: Option<&Arc<AutomationRoots>>, f: impl FnOnce() -> T) -> T {
|
||||
match roots {
|
||||
Some(r) => with_scope(Some(Scope::Confined(r.clone())), f),
|
||||
None => f(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `f` with no roots in force on this thread: what the person at the keyboard does, and the
|
||||
/// app's own folders.
|
||||
pub fn unconfined<T>(f: impl FnOnce() -> T) -> T {
|
||||
with_scope(Some(Scope::Unconfined), f)
|
||||
}
|
||||
|
||||
/// The roots in force on this thread, if any.
|
||||
pub fn current() -> Option<Arc<AutomationRoots>> {
|
||||
match SCOPE.with(|s| s.borrow().clone()) {
|
||||
Some(Scope::Confined(r)) => Some(r),
|
||||
Some(Scope::Unconfined) => None,
|
||||
None => PROCESS.get().cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `path` without Windows' `\\?\` prefix (which `canonicalize` adds), for people to read.
|
||||
fn plain(path: PathBuf) -> PathBuf {
|
||||
if !cfg!(windows) {
|
||||
return path;
|
||||
}
|
||||
let text = path.to_string_lossy();
|
||||
if let Some(rest) = text.strip_prefix(r"\\?\UNC\") {
|
||||
return PathBuf::from(format!(r"\\{rest}"));
|
||||
}
|
||||
match text.strip_prefix(r"\\?\") {
|
||||
Some(rest) => PathBuf::from(rest),
|
||||
None => path,
|
||||
}
|
||||
}
|
||||
|
||||
/// May `path` be read here? Always, without roots in force.
|
||||
pub fn check_read(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Read))
|
||||
}
|
||||
|
||||
/// May `path` be written here? Always, without roots in force.
|
||||
pub fn check_write(path: &str) -> Result<(), String> {
|
||||
current().map_or(Ok(()), |r| r.check(path, Access::Write))
|
||||
}
|
||||
|
||||
/// `path` made absolute with its links followed; for a path that doesn't exist (yet), its nearest
|
||||
/// existing folder's links followed and the rest appended.
|
||||
fn resolve(path: &str) -> Result<PathBuf, String> {
|
||||
if path.is_empty() {
|
||||
return Err("automation path rejected: path is empty".into());
|
||||
}
|
||||
// Checked as given: making `…\NUL` absolute gives the device path `\\.\NUL` on Windows.
|
||||
if cfg!(windows) && Path::new(path).components().any(|c| matches!(c, Component::Normal(n) if is_device_name(&n.to_string_lossy()))) {
|
||||
return Err(format!("automation path rejected: reserved device names are not allowed: {path}"));
|
||||
}
|
||||
let absolute = std::path::absolute(path).map_err(|e| format!("automation path rejected: cannot resolve {path}: {e}"))?;
|
||||
// The names below the nearest existing folder, last first.
|
||||
let mut missing: Vec<OsString> = vec![];
|
||||
let mut at = absolute.as_path();
|
||||
loop {
|
||||
match std::fs::canonicalize(at) {
|
||||
Ok(mut found) => {
|
||||
found.extend(missing.iter().rev());
|
||||
return Ok(found);
|
||||
}
|
||||
Err(e) => match std::fs::symlink_metadata(at) {
|
||||
Ok(m) if m.file_type().is_symlink() => return Err(format!("automation path rejected: a link that leads nowhere: {path}")),
|
||||
// There, yet its real path can't be had (no permission): nothing to compare.
|
||||
Ok(_) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(format!("automation path rejected: cannot resolve {path}: {e}")),
|
||||
},
|
||||
}
|
||||
let parent = at.parent();
|
||||
match (at.components().next_back(), parent) {
|
||||
(Some(Component::Normal(name)), Some(parent)) => {
|
||||
missing.push(name.to_os_string());
|
||||
at = parent;
|
||||
}
|
||||
(Some(Component::ParentDir), _) => {
|
||||
return Err(format!("automation path rejected: parent traversal below a folder that doesn't exist: {path}"));
|
||||
}
|
||||
// Down to the root (or drive) and it doesn't exist either.
|
||||
_ => {
|
||||
let e = std::io::Error::from(std::io::ErrorKind::NotFound);
|
||||
return Err(format!("automation path rejected: cannot resolve {path}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Is `path` (resolved) `root` or inside it, component by component (without regard to case on
|
||||
/// Windows, whose file systems ignore it)?
|
||||
fn inside(path: &Path, root: &Path) -> bool {
|
||||
let mut path = path.components();
|
||||
root.components().all(|r| path.next().is_some_and(|p| same_component(p, r)))
|
||||
}
|
||||
|
||||
fn same_component(a: Component, b: Component) -> bool {
|
||||
if a == b {
|
||||
return true;
|
||||
}
|
||||
if !cfg!(windows) {
|
||||
return false;
|
||||
}
|
||||
match (a.as_os_str().to_str(), b.as_os_str().to_str()) {
|
||||
(Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// A Windows device name (`CON`, `NUL`, `COM1`… also with an extension), which names no file.
|
||||
fn is_device_name(component: &str) -> bool {
|
||||
let stem = component.split('.').next().unwrap_or(component).trim_end_matches(' ').to_ascii_uppercase();
|
||||
let numbered = |prefix: &str| stem.strip_prefix(prefix).is_some_and(|n| matches!(n, "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9"));
|
||||
matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL" | "CLOCK$" | "CONIN$" | "CONOUT$") || numbered("COM") || numbered("LPT")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A fresh folder holding `in` (read root), `out` (write root) and `outside`.
|
||||
fn folders(name: &str) -> (PathBuf, Arc<AutomationRoots>) {
|
||||
let base = std::env::temp_dir().join(format!("dc-file-access-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in/sub", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(base.join("in/a.deckcraft"), "x").unwrap();
|
||||
std::fs::write(base.join("outside/secret.deckcraft"), "x").unwrap();
|
||||
let roots = AutomationRoots::new(Some(&base.join("in")), Some(&base.join("out"))).unwrap().unwrap();
|
||||
(base, roots)
|
||||
}
|
||||
|
||||
fn s(p: PathBuf) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_inside_the_roots_pass_and_others_dont() {
|
||||
let (base, roots) = folders("inside");
|
||||
assert_eq!(roots.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(()));
|
||||
assert_eq!(roots.check(&s(base.join("in")), Access::Read), Ok(()), "the root itself");
|
||||
assert_eq!(roots.check(&s(base.join("in/sub/new.deckcraft")), Access::Read), Ok(()), "missing files are checked by their folder");
|
||||
assert_eq!(roots.check(&s(base.join("out/new/deeper/x.png")), Access::Write), Ok(()), "and by their nearest folder");
|
||||
let e = roots.check(&s(base.join("outside/secret.deckcraft")), Access::Read).unwrap_err();
|
||||
assert!(e.starts_with("automation path rejected: outside the read root"), "{e}");
|
||||
// Read and write access are separate.
|
||||
let e = roots.check(&s(base.join("in/a.deckcraft")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "{e}");
|
||||
assert!(roots.check(&s(base.join("out/x.png")), Access::Read).unwrap_err().contains("outside the read root"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sibling_with_the_same_prefix_is_outside() {
|
||||
let (base, roots) = folders("prefix");
|
||||
std::fs::write(base.join("in2/b.deckcraft"), "x").unwrap();
|
||||
assert!(roots.check(&s(base.join("in2/b.deckcraft")), Access::Read).is_err(), "in2 is not inside in");
|
||||
assert!(roots.check(&format!("{}2/c.deckcraft", s(base.join("in"))), Access::Read).is_err());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parent_traversal_is_resolved_or_refused() {
|
||||
let (base, roots) = folders("dotdot");
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
let up = format!("{}{sep}..{sep}outside{sep}secret.deckcraft", s(base.join("in")));
|
||||
assert!(roots.check(&up, Access::Read).unwrap_err().contains("outside the read root"), "`..` out of the root");
|
||||
let back = format!("{}{sep}..{sep}a.deckcraft", s(base.join("in/sub")));
|
||||
assert_eq!(roots.check(&back, Access::Read), Ok(()), "`..` that stays inside");
|
||||
let missing = format!("{}{sep}nope{sep}..{sep}..{sep}outside{sep}x.deckcraft", s(base.join("out")));
|
||||
let e = roots.check(&missing, Access::Write).unwrap_err();
|
||||
// Windows resolves `..` by name first (so does opening the file there): then it's outside.
|
||||
assert!(e.contains("parent traversal below a folder that doesn't exist") || e.contains("outside the write root"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_paths_resolve_against_the_working_directory() {
|
||||
let (base, roots) = folders("relative");
|
||||
// The tests' working directory is the crate's folder: not inside the roots.
|
||||
assert!(roots.check("a.deckcraft", Access::Read).unwrap_err().contains("outside the read root"));
|
||||
assert!(roots.check("", Access::Read).unwrap_err().contains("path is empty"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_missing_root_is_an_error_and_none_is_no_confinement() {
|
||||
let (base, _) = folders("missing");
|
||||
let e = AutomationRoots::new(Some(&base.join("nope")), None).unwrap_err();
|
||||
assert!(e.starts_with("cannot open the automation read root"), "{e}");
|
||||
let e = AutomationRoots::new(None, Some(&base.join("in/a.deckcraft"))).unwrap_err();
|
||||
assert!(
|
||||
e.starts_with("cannot open the automation write root")
|
||||
&& e.ends_with(&std::io::Error::from(std::io::ErrorKind::NotADirectory).to_string()),
|
||||
"{e}"
|
||||
);
|
||||
assert!(AutomationRoots::new(Some(Path::new("")), None).unwrap_err().starts_with("cannot open the automation read root ``"));
|
||||
assert!(AutomationRoots::new(None, None).unwrap().is_none());
|
||||
// One root alone grants only its own access.
|
||||
let read_only = AutomationRoots::new(Some(&base.join("in")), None).unwrap().unwrap();
|
||||
let e = read_only.check(&s(base.join("in/x.deckcraft")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("write authority is absent"), "{e}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_are_followed_before_comparing() {
|
||||
let (base, roots) = folders("links");
|
||||
#[cfg(unix)]
|
||||
let made = |target: &Path, link: &Path| std::os::unix::fs::symlink(target, link).is_ok();
|
||||
#[cfg(windows)]
|
||||
let made = |target: &Path, link: &Path| {
|
||||
let made =
|
||||
if target.is_dir() { std::os::windows::fs::symlink_dir(target, link) } else { std::os::windows::fs::symlink_file(target, link) };
|
||||
made.is_ok()
|
||||
};
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let made = |_: &Path, _: &Path| false;
|
||||
// Windows needs Developer Mode or elevation to make links: nothing to test without them.
|
||||
if !made(&base.join("outside/secret.deckcraft"), &base.join("in/leak.deckcraft")) {
|
||||
eprintln!("skipped: symbolic links can't be made here");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
return;
|
||||
}
|
||||
assert!(roots.check(&s(base.join("in/leak.deckcraft")), Access::Read).unwrap_err().contains("outside the read root"), "a file link out");
|
||||
assert!(made(&base.join("outside"), &base.join("out/away")));
|
||||
let e = roots.check(&s(base.join("out/away/new.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("outside the write root"), "a folder link out: {e}");
|
||||
assert!(made(&base.join("outside/gone.deckcraft"), &base.join("out/dangling.png")));
|
||||
let e = roots.check(&s(base.join("out/dangling.png")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("a link that leads nowhere"), "writing would create the file outside: {e}");
|
||||
// A link to a folder inside the root stays inside.
|
||||
assert!(made(&base.join("in/sub"), &base.join("in/alias")));
|
||||
assert_eq!(roots.check(&s(base.join("in/alias/x.deckcraft")), Access::Read), Ok(()));
|
||||
// A root given through a link is the folder it leads to.
|
||||
assert!(made(&base.join("in"), &base.join("in-link")));
|
||||
let linked = AutomationRoots::new(Some(&base.join("in-link")), None).unwrap().unwrap();
|
||||
assert_eq!(linked.check(&s(base.join("in/a.deckcraft")), Access::Read), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_ignores_case_and_refuses_devices() {
|
||||
let (base, roots) = folders("case");
|
||||
let upper = s(base.join("IN/a.deckcraft"));
|
||||
assert_eq!(roots.check(&upper, Access::Read), Ok(()), "{upper}");
|
||||
assert_eq!(roots.check(&s(base.join("In/Sub/New.deckcraft")), Access::Read), Ok(()));
|
||||
let e = roots.check(&s(base.join("out/NUL")), Access::Write).unwrap_err();
|
||||
assert!(e.contains("reserved device names"), "{e}");
|
||||
assert!(roots.check(&s(base.join("out/com1.txt")), Access::Write).is_err());
|
||||
assert_eq!(roots.check(&s(base.join("out/console.txt")), Access::Write), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scopes_nest_and_stay_on_their_thread() {
|
||||
let (base, roots) = folders("scopes");
|
||||
let outside = s(base.join("outside/secret.deckcraft"));
|
||||
assert_eq!(check_read(&outside), Ok(()), "nothing is confined by default");
|
||||
confine(Some(&roots), || {
|
||||
assert!(check_read(&outside).is_err());
|
||||
assert_eq!(unconfined(|| check_read(&outside)), Ok(()));
|
||||
assert!(check_read(&outside).is_err(), "restored after unconfined");
|
||||
let o = outside.clone();
|
||||
let there = std::thread::spawn(move || check_read(&o).is_ok()).join().unwrap();
|
||||
assert!(there, "another thread is not confined by this thread's scope");
|
||||
});
|
||||
assert_eq!(check_read(&outside), Ok(()), "restored after confine");
|
||||
let r = std::panic::catch_unwind(|| confine(Some(&roots), || panic!("boom")));
|
||||
assert!(r.is_err());
|
||||
assert_eq!(check_read(&outside), Ok(()), "restored after a panic");
|
||||
assert_eq!(confine(None, || check_read(&outside)), Ok(()));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_names() {
|
||||
for d in ["CON", "nul", "Com1", "LPT9.txt", "aux.deckcraft", "NUL "] {
|
||||
assert!(is_device_name(d), "{d}");
|
||||
}
|
||||
for f in ["console", "com10", "COM0", "lpt", "a.deckcraft", "nul-x"] {
|
||||
assert!(!is_device_name(f), "{f}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Every file a command reads or writes by path goes through `io` (`read_file`,
|
||||
/// `write_file`), which check the roots. Engine code that reaches for `std::fs` itself would
|
||||
/// bypass them, so none may: every file under `src/` (commands, `lib.rs`, `guard.rs`...)
|
||||
/// except `io.rs`, this module, `recovery.rs` (the recovery folder is set by the host
|
||||
/// application, never by a command's path, and a headless session has none) and the test
|
||||
/// files.
|
||||
#[test]
|
||||
fn commands_do_not_touch_the_file_system_themselves() {
|
||||
fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
walk(&path, files);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut files = vec![];
|
||||
walk(&src, &mut files);
|
||||
assert!(files.iter().any(|f| f.ends_with("lib.rs")) && files.iter().any(|f| f.ends_with("cmd/file.rs")), "{files:?}");
|
||||
let mut offenders = vec![];
|
||||
for path in files {
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
// `io` and this module are the checked gateway; `tests*.rs` are test-only modules.
|
||||
// `recovery` and `spell` touch only the app's own folders (AutoRecover files, the
|
||||
// system and user spelling dictionaries), never a path a command names.
|
||||
if path.parent() == Some(src.as_path()) && matches!(name, "io.rs" | "file_access.rs" | "recovery.rs" | "spell.rs")
|
||||
|| name.starts_with("tests")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
// Test modules (which build their own fixtures) come last in each file.
|
||||
let code = text.split("#[cfg(test)]").next().unwrap_or("");
|
||||
for (n, line) in code.lines().enumerate() {
|
||||
let line = line.trim_start();
|
||||
let touches = ["std::fs", "File::open", "File::create", "OpenOptions"].iter().any(|w| line.contains(w));
|
||||
if !line.starts_with("//") && touches {
|
||||
offenders.push(format!("{}:{}: {line}", path.display(), n + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(offenders.is_empty(), "use crate::io::{{read_file, write_file}}: {offenders:#?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
//! Animated GIF of the deck (the Export… dialog; `file.export` with format `gif`).
|
||||
//!
|
||||
//! One frame per slide, drawn by the slide renderer and shown for `secondsPerSlide`. Every frame
|
||||
//! gets its own 256-colour palette (exact when the slide has 256 colours or fewer, NeuQuant
|
||||
//! otherwise), so a photo on one slide doesn't steal colours from the next. Transitions and
|
||||
//! build animations are not animated: a slide appears as it looks when its builds have finished.
|
||||
//!
|
||||
//! All parameters are hostile: sizes, delays and slide lists are checked against fixed bounds, and
|
||||
//! the total number of pixels encoded is capped, so a request can't exhaust memory or run for
|
||||
//! hours.
|
||||
|
||||
use deckcraft_model::Presentation;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::cmd::bad;
|
||||
use crate::{EngineError, Result};
|
||||
|
||||
const CMD: &str = "file.export";
|
||||
|
||||
/// Size presets, by frame width in pixels (16:9 slides: 640×360, 960×540, 1280×720, 1920×1080).
|
||||
pub const SIZE_PRESETS: [(&str, u32); 4] = [("small", 640), ("medium", 960), ("large", 1280), ("extraLarge", 1920)];
|
||||
/// Frame width when none is given (the `medium` preset).
|
||||
pub const DEFAULT_WIDTH: u32 = 960;
|
||||
pub const MIN_WIDTH: u32 = 16;
|
||||
pub const MAX_WIDTH: u32 = 3840;
|
||||
/// Longest frame side: a very tall slide is scaled down to fit.
|
||||
pub const MAX_SIDE: u32 = 3840;
|
||||
pub const DEFAULT_SECONDS: f64 = 5.0;
|
||||
/// GIF delays are in hundredths of a second; viewers treat delays under 0.02 s as 0.1 s anyway.
|
||||
pub const MIN_SECONDS: f64 = 0.1;
|
||||
pub const MAX_SECONDS: f64 = 600.0;
|
||||
pub const MAX_FRAMES: usize = 500;
|
||||
/// Cap on frames × width × height (≈ 96 Full HD frames or 480 frames at 960×540).
|
||||
pub const MAX_TOTAL_PIXELS: u64 = 200_000_000;
|
||||
/// NeuQuant sampling: 1 is slowest and best, 30 fastest; 10 is the usual balance.
|
||||
const QUANTIZER_SPEED: i32 = 10;
|
||||
|
||||
/// What to export.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct GifOptions {
|
||||
/// Frame width in pixels; the height follows the slide's aspect ratio.
|
||||
pub width: u32,
|
||||
pub seconds_per_slide: f64,
|
||||
/// Loop forever (`true`) or play once.
|
||||
pub loop_forever: bool,
|
||||
/// Slide indices in play order (default: all).
|
||||
pub slides: Option<Vec<usize>>,
|
||||
/// Hidden slides are left out unless this is set.
|
||||
pub include_hidden: bool,
|
||||
}
|
||||
|
||||
impl Default for GifOptions {
|
||||
fn default() -> Self {
|
||||
GifOptions { width: DEFAULT_WIDTH, seconds_per_slide: DEFAULT_SECONDS, loop_forever: true, slides: None, include_hidden: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// An encoded GIF and what is in it.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GifOutput {
|
||||
pub bytes: Vec<u8>,
|
||||
pub frames: usize,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Per-frame delay in hundredths of a second, as written.
|
||||
pub delay_cs: u16,
|
||||
}
|
||||
|
||||
fn width_value(v: &Value) -> Result<u32> {
|
||||
let err =
|
||||
|| bad(CMD, format!("`width` must be a whole number of pixels from {MIN_WIDTH} to {MAX_WIDTH}, or one of small, medium, large, extraLarge"));
|
||||
match v {
|
||||
Value::String(s) => SIZE_PRESETS.iter().find(|(k, _)| k.eq_ignore_ascii_case(s.trim())).map(|(_, w)| *w).ok_or_else(err),
|
||||
Value::Number(n) => n.as_u64().and_then(|w| u32::try_from(w).ok()).filter(|w| (MIN_WIDTH..=MAX_WIDTH).contains(w)).ok_or_else(err),
|
||||
_ => Err(err()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check a per-slide duration (NaN, infinities and out-of-range values are errors).
|
||||
pub fn check_seconds(s: f64) -> Result<f64> {
|
||||
if (MIN_SECONDS..=MAX_SECONDS).contains(&s) {
|
||||
Ok(s)
|
||||
} else {
|
||||
Err(bad(CMD, format!("`secondsPerSlide` must be from {MIN_SECONDS} to {MAX_SECONDS}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Present and not `null`.
|
||||
fn given<'a>(p: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
p.get(key).filter(|v| !v.is_null())
|
||||
}
|
||||
|
||||
impl GifOptions {
|
||||
/// Read `{width?: px | small|medium|large|extraLarge, secondsPerSlide?, loop?: bool |
|
||||
/// forever|once, slides?: [index], includeHidden?}`. Absent or `null` values take their
|
||||
/// defaults; anything else that is out of range or of the wrong type is an error.
|
||||
pub fn from_params(p: &Value) -> Result<Self> {
|
||||
let mut o = GifOptions::default();
|
||||
if let Some(v) = given(p, "width") {
|
||||
o.width = width_value(v)?;
|
||||
}
|
||||
if let Some(v) = given(p, "secondsPerSlide") {
|
||||
let s = v.as_f64().ok_or_else(|| bad(CMD, "`secondsPerSlide` must be a number"))?;
|
||||
o.seconds_per_slide = check_seconds(s)?;
|
||||
}
|
||||
if let Some(v) = given(p, "loop") {
|
||||
o.loop_forever = match v {
|
||||
Value::Bool(b) => *b,
|
||||
Value::String(s) if s.eq_ignore_ascii_case("forever") => true,
|
||||
Value::String(s) if s.eq_ignore_ascii_case("once") => false,
|
||||
_ => return Err(bad(CMD, "`loop` must be true (forever), false (once), \"forever\" or \"once\"")),
|
||||
};
|
||||
}
|
||||
if let Some(v) = given(p, "slides") {
|
||||
let a = v.as_array().ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices"))?;
|
||||
if a.is_empty() {
|
||||
return Err(bad(CMD, "`slides` is empty"));
|
||||
}
|
||||
if a.len() > MAX_FRAMES {
|
||||
return Err(bad(CMD, format!("a GIF can have at most {MAX_FRAMES} slides")));
|
||||
}
|
||||
let list = a
|
||||
.iter()
|
||||
.map(|x| x.as_u64().and_then(|i| usize::try_from(i).ok()))
|
||||
.collect::<Option<Vec<usize>>>()
|
||||
.ok_or_else(|| bad(CMD, "`slides` must be a list of slide indices (0, 1, 2…)"))?;
|
||||
o.slides = Some(list);
|
||||
}
|
||||
if let Some(v) = given(p, "includeHidden") {
|
||||
o.include_hidden = v.as_bool().ok_or_else(|| bad(CMD, "`includeHidden` must be true or false"))?;
|
||||
}
|
||||
Ok(o)
|
||||
}
|
||||
|
||||
/// The slides that become frames, in order: the chosen ones (or all), without hidden slides
|
||||
/// unless `include_hidden`.
|
||||
pub fn frames(&self, doc: &Presentation) -> Result<Vec<usize>> {
|
||||
let n = doc.slides.len();
|
||||
let chosen: Vec<usize> = match &self.slides {
|
||||
Some(list) => {
|
||||
if let Some(i) = list.iter().find(|i| **i >= n) {
|
||||
return Err(bad(CMD, format!("no slide {i} (the presentation has {n})")));
|
||||
}
|
||||
list.clone()
|
||||
}
|
||||
None => (0..n).collect(),
|
||||
};
|
||||
let frames: Vec<usize> = chosen.into_iter().filter(|i| self.include_hidden || doc.slides.get(*i).is_some_and(|s| !s.hidden)).collect();
|
||||
if frames.is_empty() {
|
||||
return Err(bad(CMD, "no slides to export (they are all hidden, or the presentation is empty)"));
|
||||
}
|
||||
if frames.len() > MAX_FRAMES {
|
||||
return Err(bad(CMD, format!("a GIF can have at most {MAX_FRAMES} slides; choose a range")));
|
||||
}
|
||||
Ok(frames)
|
||||
}
|
||||
|
||||
/// Delay per frame in hundredths of a second.
|
||||
pub fn delay_cs(&self) -> Result<u16> {
|
||||
let s = check_seconds(self.seconds_per_slide)?;
|
||||
// In range by `check_seconds`: 10..=60_000.
|
||||
Ok((s * 100.0).round().clamp(1.0, f64::from(u16::MAX)) as u16)
|
||||
}
|
||||
}
|
||||
|
||||
/// Frame size for a slide of `doc`'s size at `width` px: the height follows the aspect ratio and
|
||||
/// no side exceeds [`MAX_SIDE`].
|
||||
pub fn frame_size(doc: &Presentation, width: u32) -> (u32, u32) {
|
||||
let (sw, sh) = (doc.slide_size.width, doc.slide_size.height);
|
||||
let aspect = if sw.is_finite() && sh.is_finite() && sw > 0.0 && sh > 0.0 { sh / sw } else { 9.0 / 16.0 };
|
||||
let w = f64::from(width.clamp(MIN_WIDTH, MAX_WIDTH));
|
||||
let mut h = w * aspect;
|
||||
let mut w2 = w;
|
||||
if h > f64::from(MAX_SIDE) {
|
||||
w2 = w * f64::from(MAX_SIDE) / h;
|
||||
h = f64::from(MAX_SIDE);
|
||||
}
|
||||
let side = |v: f64| if v.is_finite() { v.round().clamp(1.0, f64::from(MAX_SIDE)) as u32 } else { 1 };
|
||||
(side(w2), side(h))
|
||||
}
|
||||
|
||||
/// Straight RGBA of a premultiplied render composited onto white (GIF has no partial alpha, and
|
||||
/// fully transparent pixels would otherwise become see-through).
|
||||
fn on_white(img: &deckcraft_render::Image, w: u32, h: u32) -> Vec<u8> {
|
||||
let len = (w as usize).saturating_mul(h as usize).saturating_mul(4);
|
||||
if img.width != w || img.height != h || img.pixels.len() != len {
|
||||
log::error!("GIF export: a slide rendered at {}×{} instead of {w}×{h}; writing a blank frame", img.width, img.height);
|
||||
return vec![255; len];
|
||||
}
|
||||
let mut px = img.pixels.clone();
|
||||
for p in px.as_chunks_mut::<4>().0 {
|
||||
let back = 255 - p[3];
|
||||
p[0] = p[0].saturating_add(back);
|
||||
p[1] = p[1].saturating_add(back);
|
||||
p[2] = p[2].saturating_add(back);
|
||||
p[3] = 255;
|
||||
}
|
||||
px
|
||||
}
|
||||
|
||||
/// Render and encode the GIF.
|
||||
pub fn encode(doc: &Presentation, opts: &GifOptions) -> Result<GifOutput> {
|
||||
use image::codecs::gif::{GifEncoder, Repeat};
|
||||
|
||||
let frames = opts.frames(doc)?;
|
||||
let delay_cs = opts.delay_cs()?;
|
||||
let (w, h) = frame_size(doc, opts.width);
|
||||
let total = (frames.len() as u64).saturating_mul(u64::from(w)).saturating_mul(u64::from(h));
|
||||
if total > MAX_TOTAL_PIXELS {
|
||||
return Err(bad(CMD, format!("{} slides at {w}×{h} is too large for a GIF; choose a smaller size or fewer slides", frames.len())));
|
||||
}
|
||||
let scale = f64::from(w) / doc.slide_size.width;
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let enc_err = |e: image::ImageError| EngineError::Other(format!("GIF encoding failed: {e}"));
|
||||
let mut bytes = Vec::new();
|
||||
{
|
||||
let mut enc = GifEncoder::new_with_speed(&mut bytes, QUANTIZER_SPEED);
|
||||
// No repeat block at all means "play once"; a repeat count of 0 means "forever".
|
||||
if opts.loop_forever {
|
||||
enc.set_repeat(Repeat::Infinite).map_err(enc_err)?;
|
||||
}
|
||||
for i in &frames {
|
||||
let img = deckcraft_render::render_slide(doc, *i, &deckcraft_render::RenderOpts { scale, size: Some((w, h)), ..Default::default() });
|
||||
let buffer = image::RgbaImage::from_raw(w, h, on_white(&img, w, h))
|
||||
.ok_or_else(|| EngineError::Other(format!("GIF export: slide {i} has no pixels")))?;
|
||||
let delay = image::Delay::from_numer_denom_ms(u32::from(delay_cs) * 10, 1);
|
||||
enc.encode_frame(image::Frame::from_parts(buffer, 0, 0, delay)).map_err(enc_err)?;
|
||||
}
|
||||
}
|
||||
Ok(GifOutput { bytes, frames: frames.len(), width: w, height: h, delay_cs })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use image::AnimationDecoder;
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::Session;
|
||||
|
||||
const COLOURS: [(&str, [u8; 3]); 4] =
|
||||
[("#E02020", [0xE0, 0x20, 0x20]), ("#20A040", [0x20, 0xA0, 0x40]), ("#2040E0", [0x20, 0x40, 0xE0]), ("#F0C000", [0xF0, 0xC0, 0x00])];
|
||||
|
||||
/// Four blank slides with solid backgrounds (red, green, blue, yellow); slide 2 (blue) hidden.
|
||||
fn deck() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"blank": true})).unwrap();
|
||||
while s.doc().unwrap().doc.slides.len() < COLOURS.len() {
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
}
|
||||
for (i, (hex, _)) in COLOURS.iter().enumerate() {
|
||||
s.execute("design.background", &json!({"color": hex, "index": i})).unwrap();
|
||||
}
|
||||
s.execute("slide.hide", &json!({"index": 2, "hidden": true})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn temp(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> (image::metadata::LoopCount, Vec<image::Frame>) {
|
||||
let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let count = dec.loop_count();
|
||||
(count, dec.into_frames().collect_frames().unwrap())
|
||||
}
|
||||
|
||||
/// The NETSCAPE2.0 application block's loop count, if the GIF has one.
|
||||
fn netscape_loops(bytes: &[u8]) -> Option<u16> {
|
||||
let tag = b"NETSCAPE2.0";
|
||||
let at = bytes.windows(tag.len()).position(|w| w == tag)?;
|
||||
// NETSCAPE2.0, sub-block size 3, id 1, loop count (little endian).
|
||||
let b = bytes.get(at + tag.len()..at + tag.len() + 4)?;
|
||||
Some(u16::from_le_bytes([b[2], b[3]]))
|
||||
}
|
||||
|
||||
fn close(a: [u8; 4], b: [u8; 3]) -> bool {
|
||||
(0..3).all(|k| (i32::from(a[k]) - i32::from(b[k])).abs() <= 12) && a[3] == 255
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_one_frame_per_visible_slide() {
|
||||
let mut s = deck();
|
||||
let path = temp("deck.gif");
|
||||
let r = s.execute("file.export", &json!({"path": path.to_string_lossy(), "width": 320, "secondsPerSlide": 1.5})).unwrap();
|
||||
assert_eq!(r["frames"], 3, "{r}");
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(netscape_loops(&bytes), Some(0), "loops forever by default");
|
||||
let (count, frames) = decode(&bytes);
|
||||
assert!(matches!(count, image::metadata::LoopCount::Infinite));
|
||||
assert_eq!(frames.len(), 3, "the hidden slide is skipped");
|
||||
// Slides are 16:9: 320×180.
|
||||
let expected = [COLOURS[0].1, COLOURS[1].1, COLOURS[3].1];
|
||||
for (f, want) in frames.iter().zip(expected) {
|
||||
assert_eq!((f.buffer().width(), f.buffer().height()), (320, 180));
|
||||
assert_eq!(f.delay().numer_denom_ms(), (1500, 1));
|
||||
let px = f.buffer().get_pixel(160, 90).0;
|
||||
assert!(close(px, want), "frame colour {px:?}, expected {want:?}");
|
||||
}
|
||||
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(320), Some(180)));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presets_ranges_hidden_slides_and_play_once() {
|
||||
let s = deck();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let o = GifOptions::from_params(&json!({"width": "small", "loop": false, "slides": [3, 2, 1], "includeHidden": true})).unwrap();
|
||||
let out = encode(doc, &o).unwrap();
|
||||
assert_eq!((out.frames, out.width, out.height, out.delay_cs), (3, 640, 360, 500));
|
||||
assert_eq!(netscape_loops(&out.bytes), None, "play once: no repeat block");
|
||||
let (_, frames) = decode(&out.bytes);
|
||||
let want = [COLOURS[3].1, COLOURS[2].1, COLOURS[1].1];
|
||||
for (f, w) in frames.iter().zip(want) {
|
||||
assert!(close(f.buffer().get_pixel(320, 180).0, w));
|
||||
assert_eq!(f.delay().numer_denom_ms(), (5000, 1), "5 seconds by default");
|
||||
}
|
||||
// A range that holds only the hidden slide has nothing to export.
|
||||
let o = GifOptions::from_params(&json!({"slides": [2]})).unwrap();
|
||||
assert!(encode(doc, &o).is_err());
|
||||
for (preset, w) in SIZE_PRESETS {
|
||||
assert_eq!(GifOptions::from_params(&json!({"width": preset})).unwrap().width, w);
|
||||
}
|
||||
assert_eq!(
|
||||
GifOptions::from_params(&json!({"width": "Large", "loop": "once"})).unwrap(),
|
||||
GifOptions { width: 1280, loop_forever: false, ..Default::default() }
|
||||
);
|
||||
assert_eq!(GifOptions::from_params(&json!({"width": null, "secondsPerSlide": null})).unwrap(), GifOptions::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_params_are_errors_not_panics() {
|
||||
for p in [
|
||||
json!({"width": 0}),
|
||||
json!({"width": 15}),
|
||||
json!({"width": 3841}),
|
||||
json!({"width": -5}),
|
||||
json!({"width": 1e308}),
|
||||
json!({"width": 640.5}),
|
||||
json!({"width": "huge"}),
|
||||
json!({"width": [640]}),
|
||||
json!({"secondsPerSlide": 0}),
|
||||
json!({"secondsPerSlide": 0.05}),
|
||||
json!({"secondsPerSlide": -1}),
|
||||
json!({"secondsPerSlide": 601}),
|
||||
json!({"secondsPerSlide": 1e308}),
|
||||
json!({"secondsPerSlide": "5"}),
|
||||
json!({"loop": 1}),
|
||||
json!({"loop": "twice"}),
|
||||
json!({"slides": []}),
|
||||
json!({"slides": [-1]}),
|
||||
json!({"slides": [0.5]}),
|
||||
json!({"slides": "1-3"}),
|
||||
json!({"slides": vec![0; MAX_FRAMES + 1]}),
|
||||
json!({"includeHidden": "yes"}),
|
||||
] {
|
||||
assert!(GifOptions::from_params(&p).is_err(), "{p}");
|
||||
}
|
||||
for v in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MIN_POSITIVE, 1e-300] {
|
||||
assert!(check_seconds(v).is_err(), "{v}");
|
||||
let o = GifOptions { seconds_per_slide: v, ..Default::default() };
|
||||
assert!(o.delay_cs().is_err(), "{v}");
|
||||
}
|
||||
assert_eq!(GifOptions { seconds_per_slide: 600.0, ..Default::default() }.delay_cs().unwrap(), 60_000);
|
||||
assert_eq!(GifOptions { seconds_per_slide: 0.1, ..Default::default() }.delay_cs().unwrap(), 10);
|
||||
|
||||
// Through the command too: errors, the presentation untouched, no file written.
|
||||
let mut s = deck();
|
||||
let path = temp("hostile.gif");
|
||||
for p in [
|
||||
json!({"width": 99999}),
|
||||
json!({"secondsPerSlide": -3}),
|
||||
json!({"slides": [0, 99]}),
|
||||
json!({"slides": [18446744073709551615u64]}),
|
||||
json!({"slides": [2]}),
|
||||
] {
|
||||
let mut p = p;
|
||||
p["path"] = json!(path.to_string_lossy());
|
||||
assert!(s.execute("file.export", &p).is_err(), "{p}");
|
||||
}
|
||||
assert!(!path.exists());
|
||||
assert_eq!(s.doc().unwrap().doc.slides.len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_are_bounded() {
|
||||
let mut s = deck();
|
||||
// A very tall slide is scaled down so no side exceeds MAX_SIDE.
|
||||
s.execute("design.slideSize", &json!({"w": 100.0, "h": 4000.0, "scale": "none"})).unwrap();
|
||||
let doc = &s.doc().unwrap().doc;
|
||||
let (w, h) = frame_size(doc, MAX_WIDTH);
|
||||
assert!(w >= 1 && h <= MAX_SIDE && w <= MAX_WIDTH, "{w}×{h}");
|
||||
// The total pixel budget stops huge requests before anything is rendered.
|
||||
let mut big = Presentation::clone(doc);
|
||||
big.slide_size = deckcraft_geom::Size::new(960.0, 540.0);
|
||||
let slide = big.slides[0].clone();
|
||||
big.slides = vec![slide; 200];
|
||||
let o = GifOptions { width: MAX_WIDTH, ..Default::default() };
|
||||
let e = encode(&big, &o).unwrap_err().to_string();
|
||||
assert!(e.contains("too large"), "{e}");
|
||||
// A degenerate slide size falls back to 16:9 rather than dividing by zero.
|
||||
let mut odd = Presentation::clone(doc);
|
||||
odd.slide_size = deckcraft_geom::Size::new(0.0, f64::NAN);
|
||||
assert_eq!(frame_size(&odd, 640), (640, 360));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gif_is_also_a_save_format_and_inferred_from_the_path() {
|
||||
let s = deck();
|
||||
let bytes = crate::cmd::file::save_bytes(&s.doc().unwrap().doc, "gif").unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(crate::cmd::file::format_for_path("Talk.GIF"), "gif");
|
||||
let bytes = crate::cmd::file::gif_bytes(&s.doc().unwrap().doc, &json!({"width": 64, "slides": [0]})).unwrap();
|
||||
assert_eq!(decode(&bytes).1.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Reading and writing the files a command names by path.
|
||||
//!
|
||||
//! Every such read and write goes through [`read_file`] and [`write_file`], so the automation
|
||||
//! roots ([`crate::file_access`]) are checked in one place. A command module that called
|
||||
//! `std::fs` itself would bypass them; a test in `file_access` keeps that from happening.
|
||||
|
||||
/// The bytes of the file at `path`, inside the automation read root when there is one.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
crate::file_access::check_read(path)?;
|
||||
std::fs::read(path).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn read_file(path: &str) -> Result<Vec<u8>, String> {
|
||||
Err(format!("{path}: files are opened through the browser on the web"))
|
||||
}
|
||||
|
||||
/// Write `bytes` to the file at `path`, inside the automation write root when there is one.
|
||||
///
|
||||
/// The bytes go to a temporary sibling first and are renamed over `path`, so a failed save never
|
||||
/// truncates the old file. The sibling is in the same folder, so it is inside the root whenever
|
||||
/// `path` is.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
use std::io::Write;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
crate::file_access::check_write(path)?;
|
||||
let err = |e: std::io::Error| format!("{path}: {e}");
|
||||
// A uniquely named temporary sibling, created exclusively (never following or reusing an
|
||||
// existing file or symlink), is renamed over the destination: concurrent saves never share
|
||||
// temporary bytes.
|
||||
let mut attempts = 0;
|
||||
let (tmp, mut file) = loop {
|
||||
let tmp = format!("{path}.{}.{}.saving", std::process::id(), COUNTER.fetch_add(1, Ordering::Relaxed));
|
||||
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,
|
||||
Err(e) => return Err(err(e)),
|
||||
}
|
||||
};
|
||||
let written = file.write_all(bytes).and_then(|()| file.sync_all());
|
||||
drop(file);
|
||||
let result = written.and_then(|()| std::fs::rename(&tmp, path));
|
||||
if let Err(e) = result {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(err(e));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn write_file(path: &str, _bytes: &[u8]) -> Result<(), String> {
|
||||
Err(format!("{path}: saving to a path is not available in the browser"))
|
||||
}
|
||||
|
||||
/// The size in bytes of the file at `path`; `None` when it can't be read (or is outside the
|
||||
/// automation read root, so File › Info doesn't reveal files the agent may not read).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn file_len(path: &str) -> Option<u64> {
|
||||
crate::file_access::check_read(path).ok()?;
|
||||
std::fs::metadata(path).ok().map(|m| m.len())
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn file_len(_path: &str) -> Option<u64> {
|
||||
None
|
||||
}
|
||||
@@ -290,15 +290,22 @@ impl Session {
|
||||
let Some(t) = st.selection.text.clone() else { return Ok(Value::Null) };
|
||||
let Some((r, c)) = t.cell else { return Ok(Value::Null) };
|
||||
let Some(deckcraft_model::ShapeKind::Table(tb)) = st.shape(t.shape).map(|s| s.kind.clone()) else { return Ok(Value::Null) };
|
||||
let (nr, nc) = (tb.n_rows(), tb.n_cols());
|
||||
let (nr, nc) = (tb.n_rows(), tb.n_cols().max(1));
|
||||
let idx = r * nc + c;
|
||||
if !back && idx + 1 >= nr * nc {
|
||||
// Tab in the last cell adds a row.
|
||||
self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": r}))?;
|
||||
return self.execute("text.edit", &json!({"id": t.shape, "cell": [r + 1, 0]}));
|
||||
}
|
||||
let ni = if back { idx.saturating_sub(1) } else { idx + 1 };
|
||||
self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc.max(1), ni % nc.max(1)]}))?;
|
||||
// Cells hidden under a merged cell are skipped.
|
||||
let visible = |i: &usize| tb.cell(i / nc, i % nc).is_some_and(|x| !x.is_covered());
|
||||
let next = if back { (0..idx).rev().find(visible) } else { (idx + 1..nr * nc).find(visible) };
|
||||
let ni = match next {
|
||||
Some(ni) => ni,
|
||||
None if !back => {
|
||||
// Tab in the last cell adds a row.
|
||||
let last = nr.saturating_sub(1);
|
||||
self.execute("table.insertRowBelow", &json!({"id": t.shape, "row": last}))?;
|
||||
return self.execute("text.edit", &json!({"id": t.shape, "cell": [last + 1, 0]}));
|
||||
}
|
||||
None => idx,
|
||||
};
|
||||
self.execute("text.edit", &json!({"id": t.shape, "cell": [ni / nc, ni % nc]}))?;
|
||||
self.execute("text.selectAll", &json!({}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,13 @@ pub mod cmd;
|
||||
pub mod compare;
|
||||
pub mod connect;
|
||||
pub mod drawtable;
|
||||
pub mod file_access;
|
||||
pub mod fonts;
|
||||
pub mod gif_export;
|
||||
pub mod guard;
|
||||
pub mod icons;
|
||||
pub mod inkshape;
|
||||
pub mod io;
|
||||
pub mod keys;
|
||||
pub mod links;
|
||||
pub mod media_refs;
|
||||
|
||||
+684
-1
@@ -293,7 +293,8 @@ fn writable_format_for_path_rejects_formats_deckcraft_does_not_write() {
|
||||
assert_eq!(f("a.pdf"), Some("pdf"));
|
||||
assert_eq!(f("a.jpg"), Some("jpeg"));
|
||||
assert_eq!(f("a.txt"), Some("outline"));
|
||||
for path in ["a.odp", "a.gif", "a.svg", "a.outline", "a", "dir.v2/a"] {
|
||||
assert_eq!(f("a.gif"), Some("gif"));
|
||||
for path in ["a.odp", "a.bmp", "a.svg", "a.outline", "a", "dir.v2/a"] {
|
||||
assert_eq!(f(path), None, "{path}");
|
||||
}
|
||||
}
|
||||
@@ -567,11 +568,307 @@ fn grown_table_frame_covers_its_drawn_rows() {
|
||||
assert!(matches!(s.hit_test(below_stored, 1.0), Some(tools::Hit::Shape { .. })));
|
||||
}
|
||||
|
||||
/// A table at (100, 100): columns `cols` wide, rows 40 pt tall, cells holding `data`.
|
||||
fn table_at(s: &mut Session, cols: &[f64], data: serde_json::Value) -> deckcraft_model::ShapeId {
|
||||
let rows = data.as_array().map_or(1, Vec::len);
|
||||
let w: f64 = cols.iter().sum();
|
||||
let r =
|
||||
s.execute("insert.table", &json!({"rows": rows, "cols": cols.len(), "rect": [100.0, 100.0, w, 40.0 * rows as f64], "data": data})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
for (c, cw) in cols.iter().enumerate() {
|
||||
s.execute("table.columnWidth", &json!({"id": id, "col": c, "width": cw})).unwrap();
|
||||
}
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
id
|
||||
}
|
||||
|
||||
fn table_of(s: &Session, id: deckcraft_model::ShapeId) -> deckcraft_model::Table {
|
||||
match &s.doc().unwrap().shape(id).unwrap().kind {
|
||||
deckcraft_model::ShapeKind::Table(t) => t.clone(),
|
||||
k => panic!("not a table: {k:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn tev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
|
||||
PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None }
|
||||
}
|
||||
|
||||
fn text_sel(s: &Session) -> Option<TextSel> {
|
||||
s.doc().unwrap().selection.text.clone()
|
||||
}
|
||||
|
||||
/// Slide point of the caret before character `ch` of paragraph `para` in the text being edited.
|
||||
fn caret_point(s: &Session, para: usize, ch: usize) -> deckcraft_geom::Point {
|
||||
let st = s.doc().unwrap();
|
||||
text_point(st, st.selection.text.as_ref().unwrap(), para, ch)
|
||||
}
|
||||
|
||||
/// Slide point of the caret before character `ch` of paragraph `para` of text `t`.
|
||||
fn text_point(st: &DocState, t: &TextSel, para: usize, ch: usize) -> deckcraft_geom::Point {
|
||||
let (l, m) = cmd::text::text_frame(st, t).unwrap();
|
||||
let (x, top, bot) = l.caret(deckcraft_text::Pos { para, ch }).unwrap();
|
||||
m * deckcraft_geom::Point::new(x, (top + bot) / 2.0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cell_text_is_laid_out_where_the_cell_draws_it() {
|
||||
// The caret and hit testing of a cell's text use the cell's rect, not the table's.
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", ""]]));
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 1], "at": [0, 0]})).unwrap();
|
||||
let c = caret_point(&s, 0, 0);
|
||||
assert!(c.x > 300.0 && c.x < 320.0 && c.y > 100.0 && c.y < 140.0, "caret of cell (0, 1) at {c:?}");
|
||||
// An empty cell still has a caret.
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [1, 1]})).unwrap();
|
||||
let c = caret_point(&s, 0, 0);
|
||||
assert!(c.x > 300.0 && c.y > 140.0 && c.y < 180.0, "caret of empty cell (1, 1) at {c:?}");
|
||||
// Hit testing: the start and the end of "Hello world".
|
||||
let st = s.doc().unwrap();
|
||||
let t = TextSel { shape: id, cell: Some((0, 0)), ..Default::default() };
|
||||
assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(101.0, 115.0)), (0, 0));
|
||||
assert_eq!(cmd::text::text_pos_at(st, &t, deckcraft_geom::Point::new(295.0, 115.0)), (0, 11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_click_in_a_cell_selects_the_word_clicked() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]]));
|
||||
// Where "world" is drawn.
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
|
||||
let p = caret_point(&s, 0, 8);
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
// As the UI sends it: press, release, press, double-click (on release), then the release.
|
||||
s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap();
|
||||
assert!(text_sel(&s).is_none(), "a click on an unselected table selects it");
|
||||
s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap();
|
||||
let t = text_sel(&s).unwrap();
|
||||
assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11))), "the word under the double-click");
|
||||
// The control channel sends the double-click alone.
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
|
||||
assert_eq!(text_sel(&s).map(|t| t.ordered()), Some(((0, 6), (0, 11))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicks_while_editing_a_cell_move_the_caret_between_cells() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "Second cell"], ["c", "d"]]));
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 1], "at": [0, 0]})).unwrap();
|
||||
let p6 = caret_point(&s, 0, 6);
|
||||
let p11 = caret_point(&s, 0, 11);
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [1, 0]})).unwrap();
|
||||
// A click in another cell of the table moves the caret there, at the click.
|
||||
s.pointer(tev(PointerKind::Down, p6.x, p6.y)).unwrap();
|
||||
let t = text_sel(&s).unwrap();
|
||||
assert_eq!((t.shape, t.cell, t.caret, t.anchor), (id, Some((0, 1)), (0, 6), (0, 6)));
|
||||
// Dragging selects within that cell, even past its edge.
|
||||
s.pointer(tev(PointerKind::Drag, p11.x, p11.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 600.0, p11.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 600.0, p11.y)).unwrap();
|
||||
let t = text_sel(&s).unwrap();
|
||||
assert_eq!((t.cell, t.ordered()), (Some((0, 1)), ((0, 6), (0, 11))));
|
||||
// Shift+click in another cell starts a new caret there instead of extending across cells.
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
s.pointer(PointerEvent { mods: shift, ..tev(PointerKind::Down, 150.0, 165.0) }).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 150.0, 165.0)).unwrap();
|
||||
let t = text_sel(&s).unwrap();
|
||||
assert_eq!((t.cell, t.is_range()), (Some((1, 0)), false));
|
||||
// Typing goes into the clicked cell.
|
||||
s.execute("text.insert", &json!({"text": "X"})).unwrap();
|
||||
assert_eq!(table_of(&s, id).cell(1, 0).unwrap().text.text(), "cX");
|
||||
// A click outside the table stops editing.
|
||||
s.pointer(tev(PointerKind::Down, 50.0, 400.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 50.0, 400.0)).unwrap();
|
||||
assert!(text_sel(&s).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_click_in_a_selected_table_enters_the_clicked_cell() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["a", "b"], ["c", "Hello world"]]));
|
||||
s.pointer(tev(PointerKind::Down, 350.0, 160.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 350.0, 160.0)).unwrap();
|
||||
assert_eq!((s.doc().unwrap().selection.shapes.clone(), text_sel(&s)), (vec![id], None), "the first click selects the table");
|
||||
let st = s.doc().unwrap();
|
||||
let p = text_point(st, &TextSel { shape: id, cell: Some((1, 1)), ..Default::default() }, 0, 6);
|
||||
s.pointer(tev(PointerKind::Down, p.x, p.y)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, p.x, p.y)).unwrap();
|
||||
let t = text_sel(&s).unwrap();
|
||||
assert_eq!((t.cell, t.caret), (Some((1, 1)), (0, 6)), "caret at the click");
|
||||
// Dragging a selected table still moves it.
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.pointer(tev(PointerKind::Down, 150.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 170.0, 130.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 170.0, 130.0)).unwrap();
|
||||
assert!(text_sel(&s).is_none());
|
||||
assert_ne!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().x, 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_moves_by_line_in_a_wrapped_cell() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[90.0, 200.0], json!([["one two three four five six seven", "b"]]));
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0], "at": [0, 0]})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "down"})).unwrap();
|
||||
let down = text_sel(&s).unwrap().caret;
|
||||
assert!(down.0 == 0 && down.1 > 0, "down goes to the next line of the paragraph: {down:?}");
|
||||
s.execute("text.move", &json!({"to": "lineEnd"})).unwrap();
|
||||
let end = text_sel(&s).unwrap().caret;
|
||||
assert!(end.1 > down.1 && end.1 < 33, "end of the second line: {end:?}");
|
||||
s.execute("text.move", &json!({"to": "up"})).unwrap();
|
||||
assert!(text_sel(&s).unwrap().caret.1 < down.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_borders_are_hit_where_they_are_drawn() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]]));
|
||||
let hit = |s: &Session, x: f64, y: f64| s.hit_test(deckcraft_geom::Point::new(x, y), 3.0);
|
||||
assert_eq!(hit(&s, 201.0, 125.0), Some(tools::Hit::TableColumn { id, col: 0 }));
|
||||
assert_eq!(hit(&s, 400.0, 125.0), Some(tools::Hit::TableColumn { id, col: 2 }), "the right edge");
|
||||
assert_eq!(hit(&s, 150.0, 139.0), Some(tools::Hit::TableRow { id, row: 0 }));
|
||||
assert_eq!(hit(&s, 150.0, 181.0), Some(tools::Hit::TableRow { id, row: 1 }), "the bottom edge");
|
||||
assert!(matches!(hit(&s, 100.0, 125.0), Some(tools::Hit::Shape { .. })), "the left edge moves the table");
|
||||
assert!(matches!(hit(&s, 150.0, 125.0), Some(tools::Hit::Shape { .. })));
|
||||
// Hovering a border shows it (the UI picks the resize cursor from the hover).
|
||||
s.pointer(tev(PointerKind::Move, 300.0, 170.0)).unwrap();
|
||||
assert_eq!(s.tool.hover, Some(tools::Hit::TableColumn { id, col: 1 }));
|
||||
// A border inside a merged cell isn't there.
|
||||
s.execute("table.merge", &json!({"id": id, "from": [0, 0], "to": [0, 1]})).unwrap();
|
||||
assert!(matches!(hit(&s, 200.0, 120.0), Some(tools::Hit::Shape { .. })));
|
||||
assert_eq!(hit(&s, 200.0, 160.0), Some(tools::Hit::TableColumn { id, col: 0 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_column_borders_resizes_columns() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]]));
|
||||
let undo_depth = s.doc().unwrap().history.undo.len();
|
||||
// An inner border: the two columns share the change, the table keeps its width.
|
||||
s.pointer(tev(PointerKind::Down, 200.0, 125.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 215.0, 125.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 230.0, 128.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 230.0, 128.0)).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![130.0, 70.0, 100.0]);
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 300.0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), undo_depth + 1, "one undo step per drag");
|
||||
// Columns don't collapse.
|
||||
s.pointer(tev(PointerKind::Down, 230.0, 125.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 500.0, 125.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 500.0, 125.0)).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![200.0 - cmd::table::MIN_COLUMN_WIDTH, cmd::table::MIN_COLUMN_WIDTH, 100.0]);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0, 100.0]);
|
||||
// The right edge: the last column and the table get wider.
|
||||
s.pointer(tev(PointerKind::Down, 400.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 450.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 450.0, 120.0)).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![100.0, 100.0, 150.0]);
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 350.0);
|
||||
// A click on a border without dragging changes nothing.
|
||||
let before = s.doc().unwrap().doc.clone();
|
||||
s.pointer(tev(PointerKind::Down, 200.0, 125.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 200.0, 125.0)).unwrap();
|
||||
assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_row_border_sets_the_row_height() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["a", "b"], ["c", "d"]]));
|
||||
let drawn = |s: &Session| {
|
||||
let st = s.doc().unwrap();
|
||||
tools::table_rows(st, &table_of(s, id))
|
||||
};
|
||||
s.pointer(tev(PointerKind::Down, 150.0, 140.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 150.0, 170.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 150.0, 170.0)).unwrap();
|
||||
assert_eq!(drawn(&s), vec![70.0, 40.0]);
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(cmd::xfrm_of(&st.doc, &st.selection, st.shape(id).unwrap()).h, 110.0, "the table grows");
|
||||
// Rows still fit their text: dragging the border up stops at the text.
|
||||
s.pointer(tev(PointerKind::Down, 150.0, 170.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 150.0, 60.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 150.0, 60.0)).unwrap();
|
||||
let h = drawn(&s)[0];
|
||||
assert!(h > 10.0 && h < 40.0, "row 0 shrinks to its text: {h}");
|
||||
// The bottom edge sizes the last row.
|
||||
let bottom = 100.0 + drawn(&s).iter().sum::<f64>();
|
||||
s.pointer(tev(PointerKind::Down, 150.0, bottom)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 150.0, bottom + 20.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 150.0, bottom + 20.0)).unwrap();
|
||||
assert_eq!(drawn(&s)[1], 60.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_border_while_editing_a_cell_keeps_editing() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"]]));
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 1]})).unwrap();
|
||||
s.pointer(tev(PointerKind::Down, 200.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 180.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 180.0, 120.0)).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![80.0, 120.0]);
|
||||
assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((0, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn column_width_can_keep_the_table_width() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"]]));
|
||||
s.execute("table.columnWidth", &json!({"id": id, "col": 1, "width": 150.0, "keepTotal": true})).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![100.0, 150.0, 50.0]);
|
||||
s.execute("table.columnWidth", &json!({"id": id, "col": 1, "width": 1000.0, "keepTotal": true})).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![100.0, 200.0 - cmd::table::MIN_COLUMN_WIDTH, cmd::table::MIN_COLUMN_WIDTH]);
|
||||
// The last column has no neighbour: the table changes width.
|
||||
s.execute("table.columnWidth", &json!({"id": id, "col": 2, "width": 80.0, "keepTotal": true})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().shape(id).unwrap().xfrm.unwrap().w, 100.0 + (200.0 - cmd::table::MIN_COLUMN_WIDTH) + 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_tables_hit_the_cell_under_the_pointer() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0], json!([["a", "b"], ["c", "d"]]));
|
||||
s.execute("shape.rotate", &json!({"ids": [id], "deg": 90})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let f = cmd::table::table_frame(st, st.shape(id).unwrap()).unwrap();
|
||||
let p = f.affine * deckcraft_geom::Point::new(150.0, 60.0);
|
||||
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
|
||||
assert_eq!(text_sel(&s).and_then(|t| t.cell), Some((1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tab_moves_between_cells_skipping_merged_ones() {
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "c"], ["d", "e", "f"]]));
|
||||
s.execute("table.merge", &json!({"id": id, "from": [0, 0], "to": [0, 1]})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
|
||||
let cell = |s: &Session| text_sel(s).and_then(|t| t.cell);
|
||||
let shift = Mods { shift: true, ..Default::default() };
|
||||
s.key("Tab", Mods::default()).unwrap();
|
||||
assert_eq!(cell(&s), Some((0, 2)), "the cell under the merge is skipped");
|
||||
s.key("Tab", shift).unwrap();
|
||||
assert_eq!(cell(&s), Some((0, 0)));
|
||||
s.key("Tab", shift).unwrap();
|
||||
assert_eq!(cell(&s), Some((0, 0)), "Shift+Tab in the first cell stays");
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [1, 2]})).unwrap();
|
||||
s.key("Tab", Mods::default()).unwrap();
|
||||
assert_eq!((cell(&s), table_of(&s, id).n_rows()), (Some((2, 0)), 3), "Tab in the last cell adds a row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_to_left_paragraphs() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
// Explicitly left-to-right first, so typing Arabic doesn't already turn it right-to-left (#169).
|
||||
s.execute("format.rtl", &json!({"on": false})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "سنة 2024 (Retrait) فقط"})).unwrap();
|
||||
s.execute("format.rtl", &json!({})).unwrap();
|
||||
let para = |s: &Session| s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone();
|
||||
@@ -592,6 +889,61 @@ fn right_to_left_paragraphs() {
|
||||
assert_eq!(para(&s).rtl, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_kurdish_or_arabic_auto_detects_rtl_and_advances_caret_smoothly() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
|
||||
let mut first_x = None;
|
||||
let mut last_x = 0.0;
|
||||
|
||||
// Type Kurdish sentence character-by-character: "من ناوم هێڤاره"
|
||||
for ch in ["م", "ن", " ", "ن", "ا", "و", "م", " ", "ه", "ێ", "ڤ", "ا", "ر", "ه"] {
|
||||
s.execute("text.insert", &json!({"text": ch})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.as_ref().unwrap();
|
||||
let l = super::cmd::text::layout_for(st, t).unwrap();
|
||||
let (cx, ..) = l.caret(deckcraft_text::Pos { para: t.caret.0, ch: t.caret.1 }).unwrap();
|
||||
|
||||
if first_x.is_none() {
|
||||
first_x = Some(cx);
|
||||
}
|
||||
last_x = cx;
|
||||
|
||||
// The title is centred: the caret must NOT jump back to the left margin (~7.2 pt).
|
||||
assert!(cx > 100.0, "caret should not jump to left margin: {cx}");
|
||||
}
|
||||
|
||||
// Caret advances leftwards overall as text is appended
|
||||
assert!(last_x < first_x.unwrap(), "caret should advance leftwards: last={last_x} first={:?}", first_x);
|
||||
let para = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone();
|
||||
assert_eq!(para.rtl, Some(true), "auto-detected as RTL");
|
||||
assert_eq!(para.align, None, "the title keeps the alignment it inherits");
|
||||
|
||||
// A text box inherits the deck's default (left) alignment, which it keeps too.
|
||||
s.execute("text.exit", &json!({})).ok();
|
||||
let id = s.execute("insert.textBox", &json!({"rect": [10, 10, 300, 100], "text": ""})).unwrap()["id"].as_u64().unwrap();
|
||||
s.execute("text.edit", &json!({"id": id})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "مرحبا"})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let sh = st.shape(deckcraft_model::ShapeId(id as u32)).unwrap();
|
||||
let para = sh.text.as_ref().unwrap().paragraphs[0].props.clone();
|
||||
assert_eq!(para.rtl, Some(true));
|
||||
assert_eq!(para.align, None, "the text box keeps the alignment it inherits");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_ltr_override_is_preserved_when_typing_rtl() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("format.rtl", &json!({"on": false})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "مرحبا"})).unwrap();
|
||||
let para = s.doc().unwrap().current_slide().unwrap().shapes[0].text.as_ref().unwrap().paragraphs[0].props.clone();
|
||||
assert_eq!(para.rtl, Some(false), "user explicit LTR is preserved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_footer_fixed_date_is_the_slides_own_text() {
|
||||
use deckcraft_model::text::RunKind;
|
||||
@@ -2362,6 +2714,306 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
|
||||
Some((u32::from_be_bytes(w), u32::from_be_bytes(h)))
|
||||
}
|
||||
|
||||
/// A blank slide holding one text box being edited, with smart guides and grid snapping off so
|
||||
/// drags move by exactly the pointer delta. Returns the session, the box id and its box.
|
||||
fn editing_text_box(rot: f64) -> (Session, u64, deckcraft_geom::Xfrm) {
|
||||
let mut s = session();
|
||||
s.prefs.snap_to_grid = false;
|
||||
s.prefs.smart_guides = false;
|
||||
s.execute("slide.new", &json!({"layout": "blank"})).unwrap();
|
||||
let id = s.execute("insert.textBox", &json!({"rect": [200.0, 150.0, 240.0, 60.0], "text": "Hello world"})).unwrap()["id"].as_u64().unwrap();
|
||||
if rot != 0.0 {
|
||||
s.execute("shape.rotate", &json!({"ids": [id], "deg": rot})).unwrap();
|
||||
}
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||
let x = text_box_xfrm(&s, id);
|
||||
(s, id, x)
|
||||
}
|
||||
|
||||
/// Slide point for a point in the box's own (rotated) frame.
|
||||
fn on_box(x: &deckcraft_geom::Xfrm, lx: f64, ly: f64) -> (f64, f64) {
|
||||
let p = x.affine() * deckcraft_geom::Point::new(lx, ly);
|
||||
(p.x, p.y)
|
||||
}
|
||||
|
||||
fn drag_gesture(s: &mut Session, from: (f64, f64), to: (f64, f64)) -> serde_json::Value {
|
||||
let ev = |kind, (x, y): (f64, f64)| PointerEvent { kind, x, y, mods: Mods::default(), tol: 3.0, pressure: None };
|
||||
let down = s.pointer(ev(PointerKind::Down, from)).unwrap();
|
||||
let mid = ((from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0);
|
||||
s.pointer(ev(PointerKind::Drag, mid)).unwrap();
|
||||
s.pointer(ev(PointerKind::Drag, to)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up, to)).unwrap();
|
||||
down
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_the_frame_of_edited_text_moves_the_box() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
assert!(x.h > 12.0, "{x:?}");
|
||||
let sid = deckcraft_model::ShapeId(id as u32);
|
||||
// Hovering the band on either side of the edge says "move"; the inside says "text".
|
||||
for ly in [-2.0, 2.0, x.h + 2.0] {
|
||||
let (px, py) = on_box(&x, x.w / 4.0, ly);
|
||||
s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
|
||||
assert_eq!(s.tool.hover, Some(tools::Hit::Frame { id: sid }), "ly {ly}");
|
||||
}
|
||||
let (px, py) = on_box(&x, x.w / 4.0, x.h / 2.0);
|
||||
s.pointer(PointerEvent { kind: PointerKind::Move, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
|
||||
assert_eq!(s.tool.hover, Some(tools::Hit::Shape { id: sid, text: true }));
|
||||
|
||||
// Press on the top edge, just outside, and drag: the box moves, text editing ends.
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let from = on_box(&x, x.w / 4.0, -1.0);
|
||||
let down = drag_gesture(&mut s, from, (from.0 + 30.0, from.1 + 20.0));
|
||||
assert_eq!(down["hit"], json!({"kind": "frame", "id": id}));
|
||||
let moved = text_box_xfrm(&s, id);
|
||||
assert!((moved.x - (x.x + 30.0)).abs() < 1e-9 && (moved.y - (x.y + 20.0)).abs() < 1e-9, "{moved:?}");
|
||||
assert_eq!((moved.w, moved.h), (x.w, x.h));
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.selection.text.is_none());
|
||||
assert_eq!(st.selection.shapes, vec![sid]);
|
||||
// One undo step, which puts the box back.
|
||||
assert_eq!(st.history.undo.len(), undo_before + 1);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(text_box_xfrm(&s, id), x);
|
||||
|
||||
// The inner side of the band works too; a click there without a drag selects the box whole
|
||||
// and changes nothing.
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let (px, py) = on_box(&x, x.w * 0.75, x.h - 1.0);
|
||||
let ev = |kind| PointerEvent { kind, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None };
|
||||
s.pointer(ev(PointerKind::Down)).unwrap();
|
||||
s.pointer(ev(PointerKind::Up)).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert!(st.selection.text.is_none());
|
||||
assert_eq!(st.selection.shapes, vec![sid]);
|
||||
assert_eq!(st.history.undo.len(), undo_before);
|
||||
assert_eq!(text_box_xfrm(&s, id), x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_inside_edited_text_selects_text() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
let undo_before = s.doc().unwrap().history.undo.len();
|
||||
let down = drag_gesture(&mut s, on_box(&x, 8.0, x.h / 2.0), on_box(&x, x.w - 8.0, x.h / 2.0));
|
||||
assert!(down.get("caret").is_some(), "{down}");
|
||||
assert_eq!(text_box_xfrm(&s, id), x);
|
||||
let st = s.doc().unwrap();
|
||||
let t = st.selection.text.as_ref().expect("still editing");
|
||||
assert!(t.is_range(), "{t:?}");
|
||||
assert_eq!(t.ordered(), ((0, 0), (0, 11)));
|
||||
assert_eq!(st.history.undo.len(), undo_before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_of_rotated_edited_text_follows_the_rotation() {
|
||||
let (mut s, id, x) = editing_text_box(30.0);
|
||||
assert_eq!(x.rot, 30.0);
|
||||
let sid = deckcraft_model::ShapeId(id as u32);
|
||||
// The rotated bottom edge is the frame; dragging it moves the box and keeps its rotation.
|
||||
let from = on_box(&x, x.w / 4.0, x.h + 1.0);
|
||||
assert_eq!(s.hit_test(deckcraft_geom::Point::new(from.0, from.1), 3.0), Some(tools::Hit::Frame { id: sid }));
|
||||
let down = drag_gesture(&mut s, from, (from.0 + 25.0, from.1 - 10.0));
|
||||
assert_eq!(down["hit"]["kind"], "frame");
|
||||
let moved = text_box_xfrm(&s, id);
|
||||
assert!((moved.x - (x.x + 25.0)).abs() < 1e-9 && (moved.y - (x.y - 10.0)).abs() < 1e-9, "{moved:?}");
|
||||
assert_eq!(moved.rot, 30.0);
|
||||
// Inside the rotated box, pressing still places the caret.
|
||||
s.execute("text.edit", &json!({"id": id, "at": [0, 0]})).unwrap();
|
||||
let mx = text_box_xfrm(&s, id);
|
||||
let (px, py) = on_box(&mx, mx.w / 2.0, mx.h / 2.0);
|
||||
let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
|
||||
s.pointer(PointerEvent { kind: PointerKind::Up, x: px, y: py, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
|
||||
assert!(r.get("caret").is_some(), "{r}");
|
||||
assert_eq!(text_box_xfrm(&s, id), mx);
|
||||
// Inside the axis-aligned bounds but off the rotated box: neither text nor frame, so a press
|
||||
// there leaves editing instead of moving the caret.
|
||||
let off = on_box(&mx, mx.w / 2.0, -mx.h);
|
||||
let off_pt = deckcraft_geom::Point::new(off.0, off.1);
|
||||
assert!(mx.bounds().contains(off_pt));
|
||||
assert_ne!(s.hit_test(off_pt, 3.0), Some(tools::Hit::Frame { id: sid }));
|
||||
let r = s.pointer(PointerEvent { kind: PointerKind::Down, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
|
||||
s.pointer(PointerEvent { kind: PointerKind::Up, x: off.0, y: off.1, mods: Mods::default(), tol: 3.0, pressure: None }).unwrap();
|
||||
assert!(r.get("caret").is_none(), "{r}");
|
||||
assert!(s.doc().unwrap().selection.text.is_none());
|
||||
assert_eq!(text_box_xfrm(&s, id), mx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resize_handles_beat_the_frame_of_edited_text() {
|
||||
let (mut s, id, x) = editing_text_box(0.0);
|
||||
// The SE corner lies in the frame band, but its handle wins.
|
||||
let from = on_box(&x, x.w, x.h);
|
||||
let down = drag_gesture(&mut s, from, (from.0 + 40.0, from.1));
|
||||
assert_eq!(down["hit"]["kind"], "resize");
|
||||
let sized = text_box_xfrm(&s, id);
|
||||
assert!((sized.w - (x.w + 40.0)).abs() < 1e-9, "{sized:?}");
|
||||
assert_eq!((sized.x, sized.y), (x.x, x.y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_language_marks_the_selected_text() {
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Hola mundo"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "wordLeft", "extend": true})).unwrap();
|
||||
s.execute("format.language", &json!({"lang": "es-ES"})).unwrap();
|
||||
assert_eq!(s.execute("format.state", &json!({})).unwrap()["lang"], "es-ES");
|
||||
let body = s.doc().unwrap().current_slide().unwrap().shapes[0].text.clone().unwrap();
|
||||
let langs: Vec<_> = body.paragraphs[0].runs.iter().map(|r| (r.text.as_str(), r.props.lang.as_deref())).collect();
|
||||
assert_eq!(langs, vec![("Hola ", None), ("mundo", Some("es-ES"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_params_doc_names_its_keys() {
|
||||
for c in command_specs() {
|
||||
assert!(cmd::param_keys(c.params).is_ok(), "{}: {:?}", c.id, cmd::param_keys(c.params));
|
||||
}
|
||||
assert_eq!(cmd::param_keys("{index} | {id}").unwrap(), ["index", "id"]);
|
||||
assert_eq!(cmd::param_keys("{color? | none?: true, kind?: a|b (x, y), ids?} → {id}").unwrap(), ["color", "none", "kind", "ids"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_params_doc_spells_out_each_parameter() {
|
||||
use cmd::Param;
|
||||
let p = |name, optional, about, or, object| Param { name, optional, about, or, object };
|
||||
assert_eq!(
|
||||
cmd::parse_params("{preset: rect|roundRect, rect?: [x,y,w,h] pt, index? (else selected shapes)} → {id}").unwrap(),
|
||||
[
|
||||
p("preset", false, "rect|roundRect", false, 0),
|
||||
p("rect", true, "[x,y,w,h] pt", false, 0),
|
||||
p("index", true, "(else selected shapes)", false, 0)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
cmd::parse_params("{color? | none?: true, gradient?: {stops, kind?: a|b}, ids?}").unwrap(),
|
||||
[
|
||||
p("color", true, "", false, 0),
|
||||
p("none", true, "true", true, 0),
|
||||
p("gradient", true, "{stops, kind?: a|b}", false, 0),
|
||||
p("ids", true, "", false, 0)
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
cmd::parse_params("{name} | {major, minor}").unwrap(),
|
||||
[p("name", false, "", false, 0), p("major", false, "", false, 1), p("minor", false, "", false, 1)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_a_command_acts_on_names_only_keys_it_takes() {
|
||||
for c in command_specs() {
|
||||
let keys = cmd::param_keys(c.params).unwrap();
|
||||
let said = c.acts_on().unwrap_or_default();
|
||||
assert!(!said.contains("`ids`") || keys.contains(&"ids"), "{}: {said}", c.id);
|
||||
assert!(!said.contains("`id`") || keys.contains(&"id"), "{}: {said}", c.id);
|
||||
}
|
||||
let acts = |id: &str| find_command(id).and_then(|c| c.acts_on());
|
||||
assert_eq!(acts("shape.resize").as_deref(), Some("Acts on the shapes in `ids`, else on the selected shapes."));
|
||||
assert_eq!(acts("table.style").as_deref(), Some("Acts on the shape in `id`, else on the selected shapes."));
|
||||
assert_eq!(acts("shape.setDefault").as_deref(), Some("Acts on the selected shapes."));
|
||||
assert_eq!(acts("format.size").as_deref(), Some("Acts on the selected text or shapes."));
|
||||
assert_eq!(acts("slide.new"), None);
|
||||
}
|
||||
|
||||
/// Tables whose style the file defines (#292, #294: its look and background drawn) edit like the
|
||||
/// others: the caret is laid out with the file style's lettering, clicks and double-clicks land
|
||||
/// in the cell where it is drawn, and borders still resize.
|
||||
#[test]
|
||||
fn tables_with_a_file_defined_style_edit_and_resize() {
|
||||
use deckcraft_model::style::{ColorRef, Fill};
|
||||
use deckcraft_model::table::{TablePart, TablePartStyle, TableStyleDef};
|
||||
let mut s = session();
|
||||
let id = table_at(&mut s, &[200.0, 200.0], json!([["Hello world", "b"], ["c", "d"]]));
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
|
||||
let built_in = caret_point(&s, 0, 8);
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
// The table's style, defined by the file: a plain (not bold) header, a filled body, a
|
||||
// background.
|
||||
{
|
||||
let style = table_of(&s, id).style;
|
||||
let d = s.doc_mut().unwrap();
|
||||
let doc = std::sync::Arc::make_mut(&mut d.doc);
|
||||
let solid = |r, g, b| Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(r, g, b))));
|
||||
doc.table_styles.push(TableStyleDef {
|
||||
id: style,
|
||||
name: "File style".into(),
|
||||
parts: vec![
|
||||
(TablePart::WholeTbl, TablePartStyle { fill: solid(230, 240, 250), ..Default::default() }),
|
||||
(TablePart::FirstRow, TablePartStyle { fill: solid(40, 60, 90), bold: Some(false), ..Default::default() }),
|
||||
],
|
||||
background: Some(TablePartStyle { fill: solid(200, 200, 200), ..Default::default() }),
|
||||
raw: String::new(),
|
||||
});
|
||||
}
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
s.execute("text.edit", &json!({"id": id, "cell": [0, 0]})).unwrap();
|
||||
let p = caret_point(&s, 0, 8);
|
||||
assert!((p.x - built_in.x).abs() > 0.5, "the header is laid out in the file style's (regular) weight: {p:?} vs {built_in:?}");
|
||||
s.execute("text.exit", &json!({})).unwrap();
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
// A double-click selects the word drawn there.
|
||||
s.pointer(tev(PointerKind::DoubleClick, p.x, p.y)).unwrap();
|
||||
let t = text_sel(&s).unwrap();
|
||||
assert_eq!((t.cell, t.ordered()), (Some((0, 0)), ((0, 6), (0, 11))));
|
||||
// A click in another cell moves the caret there.
|
||||
s.pointer(tev(PointerKind::Down, 400.0, 160.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 400.0, 160.0)).unwrap();
|
||||
assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1)));
|
||||
// Border drag, while editing: the columns resize, editing goes on.
|
||||
s.pointer(tev(PointerKind::Down, 300.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 320.0, 120.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Drag, 330.0, 121.0)).unwrap();
|
||||
s.pointer(tev(PointerKind::Up, 330.0, 121.0)).unwrap();
|
||||
assert_eq!(table_of(&s, id).cols, vec![230.0, 170.0]);
|
||||
assert_eq!(text_sel(&s).unwrap().cell, Some((1, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_notes_keeps_their_formatting_through_pptx() {
|
||||
use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody};
|
||||
// A deck whose notes have a bold heading over indented bullet points, as saved by PowerPoint.
|
||||
let mut p = deckcraft_model::defaults::new_presentation(None);
|
||||
let bullet = ParaProps { margin_left: Some(22.5), indent: Some(-22.5), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() };
|
||||
let point = |t: &str| Paragraph { level: 1, props: bullet.clone(), runs: vec![Run::new(t)], ..Default::default() };
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).notes = TextBody {
|
||||
paragraphs: vec![
|
||||
Paragraph { runs: vec![Run::with("Overview", RunProps { bold: Some(true), ..Default::default() })], ..Default::default() },
|
||||
point("First point"),
|
||||
point("Second point"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let bytes = deckcraft_pptx::export(&p).unwrap();
|
||||
let mut s = session();
|
||||
s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": crate::cmd::base64_encode(&bytes)})).unwrap();
|
||||
// Type a word into the second bullet, as the notes pane does.
|
||||
s.execute("slide.notes", &json!({"index": 0, "text": "Overview\nFirst point\nSecond main point"})).unwrap();
|
||||
let check = |notes: &TextBody| {
|
||||
assert_eq!(notes.text(), "Overview\nFirst point\nSecond main point");
|
||||
assert_eq!(notes.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
for para in ¬es.paragraphs[1..] {
|
||||
assert_eq!(para.level, 1);
|
||||
assert_eq!(para.props.margin_left, Some(22.5));
|
||||
assert_eq!(para.props.indent, Some(-22.5));
|
||||
assert_eq!(para.props.bullet, Some(Bullet::Char { char: "•".into() }));
|
||||
}
|
||||
};
|
||||
check(&s.doc().unwrap().doc.slides[0].notes);
|
||||
let saved = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].as_str().unwrap().to_string();
|
||||
let reopened = deckcraft_pptx::import(&crate::cmd::base64_decode(&saved).unwrap()).unwrap();
|
||||
check(&reopened.slides[0].notes);
|
||||
// slide.inspect hands out the formatted notes, and slide.notes {body} takes them back as they are.
|
||||
let body = s.execute("slide.inspect", &json!({"index": 0})).unwrap()["notesBody"].clone();
|
||||
s.execute("slide.notes", &json!({"index": 0, "text": "Plain"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.slides[0].notes, TextBody::from_text("Plain"), "new text shares nothing: plain");
|
||||
s.execute("slide.notes", &json!({"index": 0, "body": body})).unwrap();
|
||||
check(&s.doc().unwrap().doc.slides[0].notes);
|
||||
assert!(s.execute("slide.notes", &json!({"index": 0, "body": 7})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn autocorrect_options_as_you_type() {
|
||||
let mut s = session();
|
||||
@@ -2522,6 +3174,37 @@ fn language_marks_text_and_no_proof_and_repeats_are_reported() {
|
||||
assert!(s.execute("format.language", &json!({"lang": "en US!"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_caret_takes_the_colour_of_the_text() {
|
||||
let caret = |theme: &str| {
|
||||
let mut s = session();
|
||||
s.execute("design.theme", &json!({"name": theme})).unwrap();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let empty = cmd::text::caret_color(st, &st.selection.text.clone().unwrap()).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Typed"})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
assert_eq!(cmd::text::caret_color(st, &st.selection.text.clone().unwrap()), Some(empty));
|
||||
empty
|
||||
};
|
||||
// Dark text on the light theme, light text on the dark one: never black on a dark slide.
|
||||
let (light, dark) = (caret("DeckCraft"), caret("Nocturne"));
|
||||
assert!(light.r < 0x60 && light.g < 0x60 && light.b < 0x60, "{light:?}");
|
||||
assert!(dark.r > 0xC0 && dark.g > 0xC0 && dark.b > 0xC0, "{dark:?}");
|
||||
// Text set to red gets a red caret.
|
||||
let mut s = session();
|
||||
let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0;
|
||||
s.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
s.execute("text.insert", &json!({"text": "Red"})).unwrap();
|
||||
s.execute("text.selectAll", &json!({})).unwrap();
|
||||
s.execute("format.color", &json!({"color": "#FF0000"})).unwrap();
|
||||
s.execute("text.move", &json!({"to": "end"})).unwrap();
|
||||
let st = s.doc().unwrap();
|
||||
let red = cmd::text::caret_color(st, &st.selection.text.clone().unwrap()).unwrap();
|
||||
assert_eq!((red.r, red.g, red.b), (0xFF, 0, 0));
|
||||
}
|
||||
|
||||
/// Smooth Corners (#286): a rounded rectangle becomes a squircle outline that survives PPTX.
|
||||
#[test]
|
||||
fn smooth_corners_makes_a_squircle_outline() {
|
||||
|
||||
+233
-80
@@ -110,6 +110,21 @@ pub enum Hit {
|
||||
id: ShapeId,
|
||||
text: bool,
|
||||
},
|
||||
/// The frame of the box whose text is being edited (a band on either side of its edge):
|
||||
/// pressing there leaves text editing and drags the box, like PowerPoint.
|
||||
Frame {
|
||||
id: ShapeId,
|
||||
},
|
||||
/// The right edge of table column `col` (dragging it resizes the column).
|
||||
TableColumn {
|
||||
id: ShapeId,
|
||||
col: usize,
|
||||
},
|
||||
/// The bottom edge of table row `row` (dragging it resizes the row).
|
||||
TableRow {
|
||||
id: ShapeId,
|
||||
row: usize,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -177,6 +192,18 @@ enum Drag {
|
||||
TextSelect {
|
||||
shape: ShapeId,
|
||||
},
|
||||
/// Dragging a table border: the right edge of column `index`, or the bottom edge of row
|
||||
/// `index`. `start` is the table-local press point, `size` the column's width or the row's
|
||||
/// drawn height then, `frame` where the table was drawn.
|
||||
TableBorder {
|
||||
id: ShapeId,
|
||||
column: bool,
|
||||
index: usize,
|
||||
start: Point,
|
||||
size: f64,
|
||||
frame: Affine,
|
||||
moved: bool,
|
||||
},
|
||||
Ink {
|
||||
points: Vec<(f64, f64, f32)>,
|
||||
},
|
||||
@@ -382,6 +409,13 @@ impl Session {
|
||||
}
|
||||
}
|
||||
}
|
||||
// The frame of the text being edited: grabbing it moves the box.
|
||||
if let Some(e) = edited_box(st)
|
||||
&& e.movable
|
||||
&& edit_zone(&e.xfrm, p, tol) == EditZone::Frame
|
||||
{
|
||||
return Some(Hit::Frame { id: e.id });
|
||||
}
|
||||
// Shapes, topmost first.
|
||||
// Locked objects are still picked (to format them or type in them); they just don't move.
|
||||
for sh in st.shapes().iter().rev() {
|
||||
@@ -510,30 +544,10 @@ impl Session {
|
||||
}
|
||||
ToolKind::Select => {}
|
||||
}
|
||||
let st = self.doc()?;
|
||||
// Clicking inside the text being edited moves the caret / starts a text selection.
|
||||
if let Some(t) = st.selection.text.clone()
|
||||
&& !t.notes
|
||||
&& t.cell.is_none()
|
||||
&& let Some(sh) = st.shape(t.shape)
|
||||
{
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
if x.bounds().inflate(tol, tol).contains(p)
|
||||
&& !matches!(self.hit_test(p, tol), Some(Hit::Resize { .. } | Hit::Rotate { .. } | Hit::Adjust { .. } | Hit::Warp { .. }))
|
||||
{
|
||||
let pos = text_pos(st, sh, p);
|
||||
let shift = mods.shift;
|
||||
self.select(|_, sel| {
|
||||
if let Some(tt) = sel.text.as_mut() {
|
||||
tt.caret = pos;
|
||||
if !shift {
|
||||
tt.anchor = pos;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
self.tool.drag = Some(Drag::TextSelect { shape: t.shape });
|
||||
return Ok(json!({"caret": [pos.0, pos.1]}));
|
||||
}
|
||||
if let Some((shape, pos)) = self.place_caret(p, tol, mods.shift)? {
|
||||
self.tool.drag = Some(Drag::TextSelect { shape });
|
||||
return Ok(json!({"caret": [pos.0, pos.1]}));
|
||||
}
|
||||
let hit = self.hit_test(p, tol);
|
||||
// Edit Points: drag a vertex, Ctrl-click one to delete it or the outline to add one;
|
||||
@@ -567,6 +581,8 @@ impl Session {
|
||||
}
|
||||
let st = self.doc()?;
|
||||
match hit {
|
||||
Some(Hit::TableColumn { id, col }) => self.begin_table_border(id, true, col, p)?,
|
||||
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();
|
||||
self.begin("Size");
|
||||
@@ -592,6 +608,17 @@ impl Session {
|
||||
self.begin("Adjust Shape");
|
||||
self.tool.drag = Some(Drag::Adjust { id, index, orig });
|
||||
}
|
||||
Some(Hit::Frame { id }) => {
|
||||
// Like PowerPoint: the box leaves text editing and is selected whole (a click
|
||||
// without a drag stops there), and dragging moves it as one undo step.
|
||||
let origs: Vec<(ShapeId, Xfrm)> = st.shape(id).map(|s| (id, xfrm_of(&st.doc, &st.selection, s))).into_iter().collect();
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
})?;
|
||||
self.tool.drag = Some(Drag::Move { ids: vec![id], start: p, origs, moved: false, duplicate: mods.alt, enter_text: None });
|
||||
}
|
||||
Some(Hit::Warp { id, index }) => {
|
||||
let orig = st.shape(id).map(|s| xfrm_of(&st.doc, &st.selection, s)).unwrap_or_default();
|
||||
let found = st.shape(id).and_then(|sh| {
|
||||
@@ -624,8 +651,10 @@ impl Session {
|
||||
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();
|
||||
// A click (no drag) on text puts the caret there; unselected text boxes and
|
||||
// placeholders take the caret straight away like PowerPoint.
|
||||
let enter_text = (text && !toggle && (selected || is_text_first(st, id))).then_some((id, p));
|
||||
// placeholders take the caret straight away like PowerPoint. A click in a selected
|
||||
// table puts the caret in the clicked cell.
|
||||
let table = st.shape(id).is_some_and(|s| matches!(s.kind, ShapeKind::Table(_)));
|
||||
let enter_text = (!toggle && ((text && (selected || is_text_first(st, id))) || (table && selected))).then_some((id, p));
|
||||
let ids2 = ids.clone();
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = ids2;
|
||||
@@ -874,9 +903,10 @@ impl Session {
|
||||
self.tool.preview = Some(x);
|
||||
}
|
||||
Drag::TextSelect { shape } => {
|
||||
// The selection stays in the shape or table cell where it started.
|
||||
let st = self.doc()?;
|
||||
if let Some(sh) = st.shape(shape) {
|
||||
let pos = text_pos(st, sh, p);
|
||||
if let Some(t) = st.selection.text.as_ref().filter(|t| t.shape == shape) {
|
||||
let pos = cmd::text::text_pos_at(st, t, p);
|
||||
self.select(|_, sel| {
|
||||
if let Some(t) = sel.text.as_mut() {
|
||||
t.caret = pos;
|
||||
@@ -884,6 +914,26 @@ impl Session {
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Drag::TableBorder { id, column, index, start, size, frame, moved } => {
|
||||
let local = frame.inverse() * p;
|
||||
let d = if column { local.x - start.x } else { local.y - start.y };
|
||||
if !moved && d.abs() < tol.max(1.0) {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
if !moved {
|
||||
self.begin(if column { "Column Width" } else { "Row Height" });
|
||||
}
|
||||
// An inner column border moves between its two columns (the table keeps its
|
||||
// width); the right edge widens the last column and the table. A row's height is
|
||||
// a minimum: rows still grow to fit their text.
|
||||
if column {
|
||||
let width = (size + d).max(cmd::table::MIN_COLUMN_WIDTH);
|
||||
self.execute("table.columnWidth", &json!({"id": id, "col": index, "width": width, "keepTotal": true}))?;
|
||||
} else {
|
||||
self.execute("table.rowHeight", &json!({"id": id, "row": index, "height": (size + d).max(1.0)}))?;
|
||||
}
|
||||
self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: true });
|
||||
}
|
||||
Drag::Freeform { mode, mut points, pressed } => {
|
||||
// Dragging scribbles (except for curves, which only take clicked points).
|
||||
if pressed && mode != "curve" && points.last().is_none_or(|l| (*l - p).hypot() >= 1.5) {
|
||||
@@ -939,11 +989,8 @@ impl Session {
|
||||
};
|
||||
}
|
||||
Some(Drag::Move { moved: false, enter_text: Some((id, at)), .. }) => {
|
||||
// Plain click on text: caret at the click.
|
||||
let st = self.doc()?;
|
||||
if let Some(sh) = st.shape(id) {
|
||||
let pos = text_pos(st, sh, at);
|
||||
self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?;
|
||||
// Plain click on text: caret at the click (in a table, in the clicked cell).
|
||||
if self.edit_text_at(id, at)? {
|
||||
out = json!({"editing": id});
|
||||
}
|
||||
}
|
||||
@@ -1073,8 +1120,12 @@ impl Session {
|
||||
self.tool.sticky = false;
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let st = self.doc()?;
|
||||
if st.selection.text.is_some() {
|
||||
// The double-click's second press started a click (caret on release): this replaces it.
|
||||
if matches!(self.tool.drag, Some(Drag::Move { moved: false, .. })) {
|
||||
self.tool.drag = None;
|
||||
}
|
||||
// In the text being edited: select the word double-clicked.
|
||||
if self.place_caret(p, tol, false)?.is_some() {
|
||||
return self.execute("text.selectWord", &json!({}));
|
||||
}
|
||||
match self.hit_test(p, tol) {
|
||||
@@ -1082,18 +1133,12 @@ impl Session {
|
||||
let st = self.doc()?;
|
||||
let Some(sh) = st.shape(id).cloned() else { return Ok(Value::Null) };
|
||||
match &sh.kind {
|
||||
ShapeKind::Table(_) => {
|
||||
let x = xfrm_of(&st.doc, &st.selection, &sh);
|
||||
let local = x.affine().inverse() * p;
|
||||
if let ShapeKind::Table(t) = &sh.kind {
|
||||
let cell = deckcraft_render_cell(t, &table_rows(st, t), local);
|
||||
return self.execute("text.edit", &json!({"id": id, "cell": [cell.0, cell.1]}));
|
||||
ShapeKind::Table(_) | ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => {
|
||||
// Caret at the double-click (in a table, in the clicked cell), then select
|
||||
// the word there.
|
||||
if !self.edit_text_at(id, p)? {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
Ok(Value::Null)
|
||||
}
|
||||
ShapeKind::Shape | ShapeKind::Connector { .. } if !sh.is_line() => {
|
||||
let pos = text_pos(st, &sh, p);
|
||||
self.execute("text.edit", &json!({"id": id, "at": [pos.0, pos.1]}))?;
|
||||
self.execute("text.selectWord", &json!({}))
|
||||
}
|
||||
ShapeKind::Group { .. } => {
|
||||
@@ -1147,6 +1192,9 @@ impl Session {
|
||||
| Hit::LineEnd { id, .. }
|
||||
| Hit::Handle { id, .. }
|
||||
| Hit::Vertex { id, .. }
|
||||
| Hit::Frame { id }
|
||||
| Hit::TableColumn { id, .. }
|
||||
| Hit::TableRow { id, .. }
|
||||
| Hit::Warp { id, .. },
|
||||
) => Some(*id),
|
||||
None => None,
|
||||
@@ -1166,6 +1214,96 @@ impl Session {
|
||||
}))
|
||||
}
|
||||
|
||||
/// A click at `p` inside the text being edited: the caret goes there (`extend`: the selection
|
||||
/// grows to it). In a table being edited, a click in another cell moves the caret into that
|
||||
/// cell. Returns the edited shape and the caret, or `None` when `p` isn't in the edited text
|
||||
/// (or is on a handle or a table border).
|
||||
fn place_caret(&mut self, p: Point, tol: f64, extend: bool) -> Result<Option<(ShapeId, (usize, usize))>> {
|
||||
let st = self.doc()?;
|
||||
let Some(mut t) = st.selection.text.clone().filter(|t| !t.notes) else { return Ok(None) };
|
||||
let Some(sh) = st.shape(t.shape) else { return Ok(None) };
|
||||
// A text box: in its own (rotated) frame, short of the band that moves it (`Hit::Frame`).
|
||||
// A table cell: anywhere over the table.
|
||||
let inside = match t.cell {
|
||||
None => edited_box(st).is_some_and(|e| edit_zone(&e.xfrm, p, tol) != EditZone::Outside),
|
||||
Some(_) => xfrm_of(&st.doc, &st.selection, sh).bounds().inflate(tol, tol).contains(p),
|
||||
};
|
||||
if !inside
|
||||
|| matches!(
|
||||
self.hit_test(p, tol),
|
||||
Some(
|
||||
Hit::Resize { .. }
|
||||
| Hit::Rotate { .. }
|
||||
| Hit::Adjust { .. }
|
||||
| Hit::Warp { .. }
|
||||
| Hit::Frame { .. }
|
||||
| Hit::TableColumn { .. }
|
||||
| Hit::TableRow { .. }
|
||||
)
|
||||
)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let old_cell = t.cell;
|
||||
if old_cell.is_some()
|
||||
&& let Some(f) = cmd::table::table_frame(st, sh)
|
||||
{
|
||||
t.cell = f.cell_at(f.local(p)).or(old_cell);
|
||||
}
|
||||
let pos = cmd::text::text_pos_at(st, &t, p);
|
||||
// A selection can't span cells: a click in another cell starts afresh.
|
||||
let extend = extend && t.cell == old_cell;
|
||||
let cell = t.cell;
|
||||
self.select(|_, sel| {
|
||||
if let Some(tt) = sel.text.as_mut() {
|
||||
tt.cell = cell;
|
||||
tt.caret = pos;
|
||||
if !extend {
|
||||
tt.anchor = pos;
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Some((t.shape, pos)))
|
||||
}
|
||||
|
||||
/// Start editing shape `id`'s text with the caret at slide point `p`; for a table, in the cell
|
||||
/// at `p`. Returns whether editing started.
|
||||
fn edit_text_at(&mut self, id: ShapeId, p: Point) -> Result<bool> {
|
||||
let st = self.doc()?;
|
||||
let Some(sh) = st.shape(id) else { return Ok(false) };
|
||||
let params = match cmd::table::table_frame(st, sh) {
|
||||
Some(f) => {
|
||||
let Some((r, c)) = f.cell_at(f.local(p)) else { return Ok(false) };
|
||||
let pos = cmd::text::text_pos_at(st, &TextSel { shape: id, cell: Some((r, c)), ..Default::default() }, p);
|
||||
json!({"id": id, "cell": [r, c], "at": [pos.0, pos.1]})
|
||||
}
|
||||
None => {
|
||||
let pos = text_pos(st, sh, p);
|
||||
json!({"id": id, "at": [pos.0, pos.1]})
|
||||
}
|
||||
};
|
||||
self.execute("text.edit", ¶ms)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Press on a table border: dragging it resizes the column or row. Editing a cell of the
|
||||
/// table goes on; otherwise the table becomes the selection.
|
||||
fn begin_table_border(&mut self, id: ShapeId, column: bool, index: usize, p: Point) -> Result<()> {
|
||||
let st = self.doc()?;
|
||||
let Some(f) = st.shape(id).and_then(|sh| cmd::table::table_frame(st, sh)) else { return Ok(()) };
|
||||
let size = if column { f.cols.get(index) } else { f.heights.get(index) }.copied().unwrap_or(0.0);
|
||||
let (start, frame) = (f.local(p), f.affine);
|
||||
if !st.selection.text.as_ref().is_some_and(|t| t.shape == id) {
|
||||
self.select(|_, sel| {
|
||||
sel.shapes = vec![id];
|
||||
sel.text = None;
|
||||
sel.cells = None;
|
||||
})?;
|
||||
}
|
||||
self.tool.drag = Some(Drag::TableBorder { id, column, index, start, size, frame, moved: false });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_picture(&self, id: ShapeId) -> bool {
|
||||
self.active().and_then(|d| d.shape(id)).is_some_and(|s| matches!(s.kind, ShapeKind::Picture { .. } | ShapeKind::Media(_)))
|
||||
}
|
||||
@@ -1504,6 +1642,46 @@ fn member_at(children: &[Shape], to_abs: Affine, p: Point, tol: f64) -> Option<S
|
||||
None
|
||||
}
|
||||
|
||||
/// The box whose text is being edited on the slide (not speaker notes, not a table cell).
|
||||
struct EditedBox {
|
||||
id: ShapeId,
|
||||
xfrm: Xfrm,
|
||||
/// Its frame can be grabbed to move it: not a line (no frame to grab) and not locked.
|
||||
movable: bool,
|
||||
}
|
||||
|
||||
fn edited_box(st: &crate::DocState) -> Option<EditedBox> {
|
||||
let t = st.selection.text.as_ref()?;
|
||||
if t.notes || t.cell.is_some() {
|
||||
return None;
|
||||
}
|
||||
let sh = st.shape(t.shape)?;
|
||||
Some(EditedBox { id: t.shape, xfrm: xfrm_of(&st.doc, &st.selection, sh), movable: !sh.is_line() && !sh.locked })
|
||||
}
|
||||
|
||||
/// Where a point falls against the box of the text being edited.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum EditZone {
|
||||
/// Well inside: the caret goes there.
|
||||
Inside,
|
||||
/// On the frame: a band `tol` wide on either side of the box edge.
|
||||
Frame,
|
||||
Outside,
|
||||
}
|
||||
|
||||
/// Classify slide point `p` against box `x`, in the box's own (rotated, flipped) frame. Inside
|
||||
/// small boxes the band narrows to a quarter of the box so their text stays reachable.
|
||||
fn edit_zone(x: &Xfrm, p: Point, tol: f64) -> EditZone {
|
||||
let l = x.affine().inverse() * p;
|
||||
let (w, h) = (x.w.max(0.0), x.h.max(0.0));
|
||||
let tol = if tol.is_finite() { tol.max(0.0) } else { 0.0 };
|
||||
if !(l.x.is_finite() && l.y.is_finite()) || l.x < -tol || l.y < -tol || l.x > w + tol || l.y > h + tol {
|
||||
return EditZone::Outside;
|
||||
}
|
||||
let inner = tol.min(w / 4.0).min(h / 4.0);
|
||||
if l.x > inner && l.y > inner && l.x < w - inner && l.y < h - inner { EditZone::Inside } else { EditZone::Frame }
|
||||
}
|
||||
|
||||
fn contains_rect(outer: Rect, inner: Rect) -> bool {
|
||||
inner.x0 >= outer.x0 && inner.y0 >= outer.y0 && inner.x1 <= outer.x1 && inner.y1 <= outer.y1
|
||||
}
|
||||
@@ -1617,6 +1795,15 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let inv = x.affine().inverse();
|
||||
let local = inv * p;
|
||||
// Table borders resize rows and columns.
|
||||
let near = local.x >= -tol && local.y >= -tol && local.x <= x.w + tol && local.y <= x.h + tol;
|
||||
if near && let Some(f) = cmd::table::table_frame(st, sh) {
|
||||
match f.border_at(f.local(p), tol) {
|
||||
Some(cmd::table::TableBorder::Column(col)) => return Some(Hit::TableColumn { id: sh.id, col }),
|
||||
Some(cmd::table::TableBorder::Row(row)) => return Some(Hit::TableRow { id: sh.id, row }),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
match &sh.kind {
|
||||
ShapeKind::Group { children, child } => {
|
||||
// A group is hit when any child is hit; the group is what gets selected.
|
||||
@@ -1685,18 +1872,7 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option<Hit
|
||||
|
||||
/// Text position under slide point `p` in shape `sh`.
|
||||
pub fn text_pos(st: &crate::DocState, sh: &Shape, p: Point) -> (usize, usize) {
|
||||
let x = xfrm_of(&st.doc, &st.selection, sh);
|
||||
let t = TextSel { shape: sh.id, ..Default::default() };
|
||||
match cmd::text::layout_for(st, &t) {
|
||||
Some(l) => {
|
||||
// The text's own transform: flips don't mirror text, vertical text is turned.
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let local = deckcraft_render::text_transform(x.affine(), tr, l.rotation).inverse() * p;
|
||||
let h = l.hit(local);
|
||||
(h.para, h.ch)
|
||||
}
|
||||
None => (0, 0),
|
||||
}
|
||||
cmd::text::text_pos_at(st, &TextSel { shape: sh.id, ..Default::default() }, p)
|
||||
}
|
||||
|
||||
/// Row heights of table `t` as drawn: rows grow to fit their text.
|
||||
@@ -1704,29 +1880,6 @@ pub fn table_rows(st: &crate::DocState, t: &deckcraft_model::Table) -> Vec<f64>
|
||||
cmd::table_drawn_heights(&st.doc, &st.selection, t)
|
||||
}
|
||||
|
||||
/// Which table cell contains a table-local point (`heights` from [`table_rows`]).
|
||||
fn deckcraft_render_cell(t: &deckcraft_model::Table, heights: &[f64], p: Point) -> (usize, usize) {
|
||||
let mut y = 0.0;
|
||||
let mut row = heights.len().saturating_sub(1);
|
||||
for (i, h) in heights.iter().enumerate() {
|
||||
if p.y < y + h {
|
||||
row = i;
|
||||
break;
|
||||
}
|
||||
y += h;
|
||||
}
|
||||
let mut x = 0.0;
|
||||
let mut col = t.cols.len().saturating_sub(1);
|
||||
for (i, w) in t.cols.iter().enumerate() {
|
||||
if p.x < x + w {
|
||||
col = i;
|
||||
break;
|
||||
}
|
||||
x += w;
|
||||
}
|
||||
(row, col)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! An in-process engine session that answers the engine-level control-channel methods itself.
|
||||
|
||||
use deckcraft_engine::{Mods, PointerEvent, PointerKind, Session, ToolKind};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_engine::file_access::{self, AutomationRoots};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::backend::Backend;
|
||||
@@ -8,6 +11,7 @@ use crate::backend::Backend;
|
||||
/// Headless backend: a [`Session`] rendering slides on the CPU.
|
||||
pub struct Headless {
|
||||
pub session: Session,
|
||||
roots: Option<Arc<AutomationRoots>>,
|
||||
}
|
||||
|
||||
impl Default for Headless {
|
||||
@@ -59,12 +63,19 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option<ToolKind> {
|
||||
|
||||
impl Headless {
|
||||
pub fn new() -> Self {
|
||||
Self { session: Session::new() }
|
||||
Self { session: Session::new(), roots: None }
|
||||
}
|
||||
|
||||
/// A session with a fresh presentation.
|
||||
pub fn with_document() -> Self {
|
||||
Self { session: Session::with_new() }
|
||||
Self { session: Session::with_new(), roots: None }
|
||||
}
|
||||
|
||||
/// Confine every file the session's commands read and write to `roots`
|
||||
/// (`deckcraft-cli mcp --automation-read-root … --automation-write-root …`).
|
||||
pub fn with_automation_roots(mut self, roots: Option<Arc<AutomationRoots>>) -> Self {
|
||||
self.roots = roots;
|
||||
self
|
||||
}
|
||||
|
||||
fn exec(&mut self, id: &str, params: &Value) -> Result<Value, String> {
|
||||
@@ -111,7 +122,7 @@ impl Headless {
|
||||
);
|
||||
let png = img.to_png_at(deckcraft_engine::render::dpi_for_scale(scale));
|
||||
if let Some(path) = s(p, "path") {
|
||||
std::fs::write(path, &png).map_err(|e| format!("{path}: {e}"))?;
|
||||
deckcraft_engine::io::write_file(path, &png)?;
|
||||
return Ok(json!({"path": path, "width": img.width, "height": img.height}));
|
||||
}
|
||||
Ok(json!({"png": deckcraft_engine::cmd::base64_encode(&png), "width": img.width, "height": img.height, "slide": i}))
|
||||
@@ -120,6 +131,19 @@ impl Headless {
|
||||
|
||||
impl Backend for Headless {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let roots = self.roots.clone();
|
||||
file_access::confine(roots.as_ref(), || self.answer(method, params))
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"headless (in-process engine; no window)".into()
|
||||
}
|
||||
}
|
||||
|
||||
impl Headless {
|
||||
fn answer(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let p = ¶ms;
|
||||
match method {
|
||||
"engine.execute" | "command" | "ui.menu.invoke" => {
|
||||
@@ -153,10 +177,4 @@ impl Backend for Headless {
|
||||
other => Err(format!("unknown method `{other}`")),
|
||||
}
|
||||
}
|
||||
fn has_ui(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn describe(&self) -> String {
|
||||
"headless (in-process engine; no window)".into()
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,10 @@ const INSTRUCTIONS: &str = "DeckCraft is a presentation app (a PowerPoint clone)
|
||||
current slide (y down; the default 16:9 slide is 960×540). Every action is a command: find ids and parameters with \
|
||||
list_commands and run them with run_command (or several with batch). Typical flow: new_presentation → add_slide \
|
||||
{layout:\"titleAndContent\", title, body} → add_shape / add_text_box / insert_picture / insert_chart → \
|
||||
inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shapes are selected with \
|
||||
run_command edit.select {ids:[…]}; formatting commands (format.*, shape.fill, shape.line…) act on the selection or \
|
||||
on `ids`. Save with save_presentation (.deckcraft or .pptx).";
|
||||
inspect_slide to read back ids, boxes and text → render_slide to look at the result. Shape ids are looked up on the \
|
||||
current slide. An inserted shape is selected; run_command edit.select {ids:[…]} selects others. format.* acts on \
|
||||
the selected text or shapes; shape.fill, shape.line and the other shape commands whose parameters list `ids` act on \
|
||||
those shapes, else on the selected ones. Save with save_presentation (.deckcraft or .pptx).";
|
||||
|
||||
/// Resource URIs.
|
||||
pub const DOC_URI: &str = "deckcraft://document";
|
||||
|
||||
@@ -216,3 +216,29 @@ fn export_rejects_unknown_format() {
|
||||
assert!(r["result"]["content"][0]["text"].as_str().unwrap().contains("docx"), "{r}");
|
||||
assert!(!path.exists(), "nothing may be written for an unknown format");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_tool_lists_gif_and_its_options() {
|
||||
let mut s = Server::new(Box::new(Headless::new()));
|
||||
let tools = call(&mut s, 1, "tools/list", json!({}));
|
||||
let export = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == "export").unwrap().clone();
|
||||
assert!(export["description"].as_str().unwrap().contains("gif"), "{export}");
|
||||
let props = &export["inputSchema"]["properties"];
|
||||
assert!(props["format"]["enum"].as_array().unwrap().iter().any(|x| x == "gif"), "{export}");
|
||||
assert_eq!(props["width"]["oneOf"][1]["enum"], json!(["small", "medium", "large", "extraLarge"]));
|
||||
for p in ["secondsPerSlide", "loop", "slides", "includeHidden"] {
|
||||
assert!(props.get(p).is_some(), "{p}: {export}");
|
||||
}
|
||||
// What the schema describes is accepted, and bad values are tool errors, not crashes.
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-mcp-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
tool(&mut s, "new_presentation", json!({}));
|
||||
for width in [json!("small"), json!(200)] {
|
||||
let path = dir.join("deck.gif");
|
||||
tool(&mut s, "export", json!({"path": path.to_string_lossy(), "width": width, "secondsPerSlide": 2, "loop": false, "slides": [0]}));
|
||||
assert!(std::fs::read(&path).unwrap().starts_with(b"GIF89a"), "{width}");
|
||||
}
|
||||
let r = call(&mut s, 9, "tools/call", json!({"name": "export", "arguments": {"path": dir.join("bad.gif").to_string_lossy(), "width": 0}}));
|
||||
assert_eq!(r["result"]["isError"], true, "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
+14
-5
@@ -115,23 +115,32 @@ pub fn tool_definitions() -> Vec<Value> {
|
||||
tool(
|
||||
"export",
|
||||
"Export",
|
||||
"Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, or pdf (slides, notes pages or handouts).",
|
||||
"Export slides: png/jpeg (one slide, or all with all:true), pptx, deckcraft, outline text, pdf (slides, notes pages or handouts), or an animated gif of the deck (one frame per slide).",
|
||||
obj(
|
||||
json!({
|
||||
"path": string("Output path"),
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf"], "description": "Output format (default: from the path's extension)"},
|
||||
"format": {"type": "string", "enum": ["png", "jpeg", "pptx", "deckcraft", "outline", "pdf", "gif"], "description": "Output format (default: from the path's extension)"},
|
||||
"slide": int("Slide index for images"),
|
||||
"all": boolean("All slides (images)"),
|
||||
"scale": num("Pixels per point for images (default 2)"),
|
||||
"layout": {"type": "string", "enum": ["slides", "notes", "handouts"], "description": "PDF page layout (default slides)"},
|
||||
"perPage": {"type": "integer", "enum": [1, 2, 3, 4, 6, 9], "description": "PDF handouts: slides per page (default 6)"},
|
||||
"dpi": num("PDF: raster resolution of slide artwork, 36-600 (default 200)"),
|
||||
"slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF: slide indices to export (default all)"},
|
||||
"includeHidden": boolean("PDF: include hidden slides (default false)"),
|
||||
"slides": {"type": "array", "items": {"type": "integer", "minimum": 0}, "description": "PDF and GIF: slide indices to export, in order (default all)"},
|
||||
"includeHidden": boolean("PDF and GIF: include hidden slides (default false)"),
|
||||
"textLayer": boolean("PDF: invisible text layer for search, select and copy (default true)"),
|
||||
"frame": boolean("PDF notes/handouts: thin frame around each slide (default true)"),
|
||||
"paper": {"type": "string", "enum": ["letter", "a4", "legal", "a3"], "description": "PDF notes/handouts: paper size (default letter)"},
|
||||
"orientation": {"type": "string", "enum": ["portrait", "landscape"], "description": "PDF notes/handouts: page orientation (default portrait)"}
|
||||
"orientation": {"type": "string", "enum": ["portrait", "landscape"], "description": "PDF notes/handouts: page orientation (default portrait)"},
|
||||
"width": {
|
||||
"oneOf": [
|
||||
{"type": "integer", "minimum": 16, "maximum": 3840},
|
||||
{"type": "string", "enum": ["small", "medium", "large", "extraLarge"]}
|
||||
],
|
||||
"description": "GIF: frame width in pixels, or a size: small 640, medium 960 (default), large 1280, extraLarge 1920; the height follows the slide's shape"
|
||||
},
|
||||
"secondsPerSlide": {"type": "number", "minimum": 0.1, "maximum": 600, "description": "GIF: seconds each slide shows (default 5)"},
|
||||
"loop": boolean("GIF: loop forever (true, default) or play once (false)")
|
||||
}),
|
||||
&["path"],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
//! `deckcraft-cli mcp --automation-read-root <dir> --automation-write-root <dir>`: through the MCP
|
||||
//! protocol, files open and load only from inside the read root and are saved and exported only
|
||||
//! inside the write root. Paths that leave a root (`..`, another folder, a sibling with the same
|
||||
//! prefix, a link) are refused, and nothing is written outside.
|
||||
// Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md).
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use deckcraft_engine::file_access::AutomationRoots;
|
||||
use deckcraft_mcp::{Headless, Server};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// A 1x1 PNG, so `insert_picture` has something real to read.
|
||||
const PNG: &[u8] = &[
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0xf8, 0xcf, 0xc0, 0xf0,
|
||||
0x1f, 0x00, 0x05, 0x00, 0x01, 0xff, 0x89, 0x99, 0x3d, 0x1d, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
|
||||
];
|
||||
|
||||
/// A fresh folder holding `in` (the read root, with `a.txt` and `pic.png`), `out` (the write root),
|
||||
/// `outside` (with `secret.txt` and `pic.png`) and `in2`, and a server confined to `in` and `out`
|
||||
/// (either may be left out). The server starts with a new presentation.
|
||||
fn confined(tag: &str, read: bool, write: bool) -> (Server, PathBuf) {
|
||||
let base = std::env::temp_dir().join(format!("dc-mcp-roots-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
for d in ["in", "out", "outside", "in2"] {
|
||||
std::fs::create_dir_all(base.join(d)).unwrap();
|
||||
}
|
||||
std::fs::write(base.join("in/a.txt"), "Inside text").unwrap();
|
||||
std::fs::write(base.join("in/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("outside/secret.txt"), "TOP SECRET").unwrap();
|
||||
std::fs::write(base.join("outside/pic.png"), PNG).unwrap();
|
||||
std::fs::write(base.join("in2/c.txt"), "Sibling").unwrap();
|
||||
let (in_dir, out_dir) = (base.join("in"), base.join("out"));
|
||||
let roots = AutomationRoots::new(read.then_some(in_dir.as_path()), write.then_some(out_dir.as_path())).unwrap();
|
||||
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(roots)));
|
||||
ok(&mut server, "new_presentation", json!({}));
|
||||
(server, base)
|
||||
}
|
||||
|
||||
fn s(p: &Path) -> String {
|
||||
p.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// `tools/call` -> (isError, the first text block).
|
||||
fn call(server: &mut Server, name: &str, args: Value) -> (bool, String) {
|
||||
let line = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": name, "arguments": args}}).to_string();
|
||||
let reply: Value = serde_json::from_str(&server.handle_line(&line).unwrap()).unwrap();
|
||||
let result = &reply["result"];
|
||||
let text = result["content"].as_array().unwrap().iter().find_map(|c| c["text"].as_str()).unwrap_or_default().to_string();
|
||||
(result["isError"] == true, text)
|
||||
}
|
||||
|
||||
fn ok(server: &mut Server, name: &str, args: Value) -> String {
|
||||
let (error, text) = call(server, name, args.clone());
|
||||
assert!(!error, "{name} {args}: {text}");
|
||||
text
|
||||
}
|
||||
|
||||
fn refused(server: &mut Server, name: &str, args: Value, why: &str) {
|
||||
let (error, text) = call(server, name, args.clone());
|
||||
assert!(error && text.contains(why), "{name} {args} should be refused ({why}): {text}");
|
||||
}
|
||||
|
||||
fn run(server: &mut Server, command: &str, params: Value) -> (bool, String) {
|
||||
call(server, "run_command", json!({"command": command, "params": params}))
|
||||
}
|
||||
|
||||
/// Everything the open presentation says, as one string.
|
||||
fn deck_text(server: &mut Server) -> String {
|
||||
format!("{} {}", ok(server, "inspect_document", json!({})), ok(server, "inspect_slide", json!({})))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presentations_open_only_from_inside_the_read_root() {
|
||||
let (mut server, base) = confined("open", true, true);
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
ok(&mut server, "open_presentation", json!({"path": s(&base.join("in/a.txt"))}));
|
||||
assert!(deck_text(&mut server).contains("Inside text"));
|
||||
|
||||
refused(&mut server, "open_presentation", json!({"path": s(&base.join("outside/secret.txt"))}), "outside the read root");
|
||||
refused(
|
||||
&mut server,
|
||||
"open_presentation",
|
||||
json!({"path": format!("{}{sep}..{sep}outside{sep}secret.txt", s(&base.join("in")))}),
|
||||
"outside the read root",
|
||||
);
|
||||
refused(&mut server, "open_presentation", json!({"path": s(&base.join("in2/c.txt"))}), "outside the read root");
|
||||
// The write root isn't readable: the two kinds of access are separate.
|
||||
std::fs::write(base.join("out/d.txt"), "d").unwrap();
|
||||
refused(&mut server, "open_presentation", json!({"path": s(&base.join("out/d.txt"))}), "outside the read root");
|
||||
// The same through the engine command and the control-channel method.
|
||||
let (error, text) = run(&mut server, "file.open", json!({"path": s(&base.join("outside/secret.txt"))}));
|
||||
assert!(error && text.contains("outside the read root"), "{text}");
|
||||
refused(&mut server, "call_app", json!({"method": "app.open", "params": {"path": s(&base.join("outside/secret.txt"))}}), "outside the read root");
|
||||
// Nothing from the refused files reached the deck.
|
||||
let text = deck_text(&mut server);
|
||||
assert!(!text.contains("TOP SECRET") && !text.contains("Sibling"), "{text}");
|
||||
assert!(text.contains("Inside text"), "the refused opens left the deck it had: {text}");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pictures_media_and_other_path_reads_are_confined() {
|
||||
let (mut server, base) = confined("media", true, true);
|
||||
ok(&mut server, "insert_picture", json!({"path": s(&base.join("in/pic.png"))}));
|
||||
let before = ok(&mut server, "inspect_slide", json!({}));
|
||||
refused(&mut server, "insert_picture", json!({"path": s(&base.join("outside/pic.png"))}), "outside the read root");
|
||||
for command in ["insert.audio", "insert.video", "insert.picture", "picture.change"] {
|
||||
let (error, text) = run(&mut server, command, json!({"path": s(&base.join("outside/pic.png"))}));
|
||||
assert!(error && text.contains("outside the read root"), "{command}: {text}");
|
||||
}
|
||||
assert_eq!(ok(&mut server, "inspect_slide", json!({})), before, "refused reads change nothing");
|
||||
|
||||
// Media inside the root gets as far as being read: a refusal there would name the root.
|
||||
for command in ["insert.audio", "insert.video"] {
|
||||
let (_, text) = run(&mut server, command, json!({"path": s(&base.join("in/pic.png"))}));
|
||||
assert!(!text.contains("automation"), "{command}: {text}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_writing_command_is_refused_outside_the_write_root() {
|
||||
let (mut server, base) = confined("write", true, true);
|
||||
let outside = |name: &str| s(&base.join("outside").join(name));
|
||||
let inside = |name: &str| s(&base.join("out").join(name));
|
||||
ok(&mut server, "add_slide", json!({"title": "Second"}));
|
||||
|
||||
refused(&mut server, "save_presentation", json!({"path": outside("x.deckcraft")}), "outside the write root");
|
||||
refused(&mut server, "export", json!({"path": outside("x.pptx")}), "outside the write root");
|
||||
refused(&mut server, "export", json!({"path": outside("x.pdf")}), "outside the write root");
|
||||
refused(&mut server, "export", json!({"path": outside("x.png")}), "outside the write root");
|
||||
refused(&mut server, "export", json!({"path": outside("x.txt")}), "outside the write root");
|
||||
refused(&mut server, "export", json!({"path": outside("all.png"), "all": true}), "outside the write root");
|
||||
refused(&mut server, "call_app", json!({"method": "ui.render", "params": {"path": outside("r.png")}}), "outside the write root");
|
||||
refused(&mut server, "call_app", json!({"method": "app.save", "params": {"path": outside("y.deckcraft")}}), "outside the write root");
|
||||
refused(&mut server, "call_app", json!({"method": "app.export", "params": {"path": outside("y.pdf")}}), "outside the write root");
|
||||
for command in ["file.save", "file.saveAs", "file.export"] {
|
||||
let (error, text) = run(&mut server, command, json!({"path": outside("z.deckcraft")}));
|
||||
assert!(error && text.contains("outside the write root"), "{command}: {text}");
|
||||
}
|
||||
// A sibling folder with the same prefix, and `..` out of the root.
|
||||
refused(&mut server, "save_presentation", json!({"path": s(&base.join("out2/x.deckcraft"))}), "outside the write root");
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
refused(
|
||||
&mut server,
|
||||
"save_presentation",
|
||||
json!({"path": format!("{}{sep}..{sep}outside{sep}x.deckcraft", s(&base.join("out")))}),
|
||||
"outside the write root",
|
||||
);
|
||||
// The read root isn't writable.
|
||||
refused(&mut server, "save_presentation", json!({"path": s(&base.join("in/x.deckcraft"))}), "outside the write root");
|
||||
|
||||
let leftovers: Vec<_> = std::fs::read_dir(base.join("outside")).unwrap().map(|e| e.unwrap().file_name()).collect();
|
||||
assert_eq!(leftovers.len(), 2, "nothing was written beside secret.txt and pic.png: {leftovers:?}");
|
||||
assert!(!base.join("out2").exists() && !base.join("in/x.deckcraft").exists());
|
||||
|
||||
// Inside the write root all of it works.
|
||||
ok(&mut server, "save_presentation", json!({"path": inside("x.deckcraft")}));
|
||||
ok(&mut server, "export", json!({"path": inside("x.pptx")}));
|
||||
ok(&mut server, "export", json!({"path": inside("x.pdf")}));
|
||||
ok(&mut server, "export", json!({"path": inside("all.png"), "all": true}));
|
||||
ok(&mut server, "call_app", json!({"method": "ui.render", "params": {"path": inside("r.png")}}));
|
||||
for f in ["x.deckcraft", "x.pptx", "x.pdf", "all1.png", "all2.png", "r.png"] {
|
||||
assert!(base.join("out").join(f).exists(), "{f}");
|
||||
}
|
||||
let temporaries: Vec<_> = std::fs::read_dir(base.join("out"))
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().ends_with(".saving"))
|
||||
.collect();
|
||||
assert!(temporaries.is_empty(), "the temporary file is renamed away");
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_root_grants_only_its_own_access() {
|
||||
let (mut read_only, base) = confined("read-only", true, false);
|
||||
ok(&mut read_only, "open_presentation", json!({"path": s(&base.join("in/a.txt"))}));
|
||||
refused(&mut read_only, "save_presentation", json!({"path": s(&base.join("out/x.deckcraft"))}), "write authority is absent");
|
||||
refused(&mut read_only, "export", json!({"path": s(&base.join("out/x.pdf"))}), "write authority is absent");
|
||||
refused(&mut read_only, "call_app", json!({"method": "ui.render", "params": {"path": s(&base.join("out/r.png"))}}), "write authority is absent");
|
||||
// Calls that return bytes instead of writing a path keep working.
|
||||
let (error, _) = call(&mut read_only, "render_slide", json!({"scale": 0.25}));
|
||||
assert!(!error);
|
||||
assert!(std::fs::read_dir(base.join("out")).unwrap().next().is_none());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
|
||||
let (mut write_only, base) = confined("write-only", false, true);
|
||||
refused(&mut write_only, "open_presentation", json!({"path": s(&base.join("in/a.txt"))}), "read authority is absent");
|
||||
refused(&mut write_only, "insert_picture", json!({"path": s(&base.join("in/pic.png"))}), "read authority is absent");
|
||||
ok(&mut write_only, "save_presentation", json!({"path": s(&base.join("out/x.deckcraft"))}));
|
||||
assert!(base.join("out/x.deckcraft").exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_out_of_a_root_are_refused() {
|
||||
let (mut server, base) = confined("links", true, true);
|
||||
std::os::unix::fs::symlink(base.join("outside/secret.txt"), base.join("in/leak.txt")).unwrap();
|
||||
std::os::unix::fs::symlink(base.join("outside"), base.join("out/away")).unwrap();
|
||||
std::os::unix::fs::symlink(base.join("outside/gone.deckcraft"), base.join("out/dangling.deckcraft")).unwrap();
|
||||
refused(&mut server, "open_presentation", json!({"path": s(&base.join("in/leak.txt"))}), "outside the read root");
|
||||
refused(&mut server, "save_presentation", json!({"path": s(&base.join("out/away/x.deckcraft"))}), "outside the write root");
|
||||
refused(&mut server, "save_presentation", json!({"path": s(&base.join("out/dangling.deckcraft"))}), "a link that leads nowhere");
|
||||
assert!(!base.join("outside/x.deckcraft").exists() && !base.join("outside/gone.deckcraft").exists());
|
||||
assert!(!deck_text(&mut server).contains("TOP SECRET"));
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_roots_nothing_is_confined() {
|
||||
let base = std::env::temp_dir().join(format!("dc-mcp-roots-none-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
std::fs::write(base.join("a.txt"), "Anywhere").unwrap();
|
||||
let mut server = Server::new(Box::new(Headless::with_document().with_automation_roots(AutomationRoots::new(None, None).unwrap())));
|
||||
ok(&mut server, "open_presentation", json!({"path": s(&base.join("a.txt"))}));
|
||||
ok(&mut server, "save_presentation", json!({"path": s(&base.join("b.deckcraft"))}));
|
||||
assert!(base.join("b.deckcraft").exists());
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Bidirectional text helpers (UAX #9).
|
||||
//!
|
||||
//! Follows Unicode Bidirectional Algorithm (UAX #9 rules P2 and P3) for paragraph
|
||||
//! base direction auto-detection.
|
||||
|
||||
/// Does the first strong directional character in `text` belong to a right-to-left script?
|
||||
///
|
||||
/// Follows UAX #9 rules P2 and P3: characters with strong bidirectional class AL or R
|
||||
/// (Arabic, Hebrew, Syriac, Thaana…) give `true`; strong L characters (Latin, Greek,
|
||||
/// Cyrillic, CJK…) give `false`. Neutral, weak, and whitespace characters are skipped.
|
||||
pub fn first_strong_is_rtl(text: &str) -> bool {
|
||||
for c in text.chars() {
|
||||
if c == '\u{200F}' {
|
||||
// Right-to-Left Mark (RLM)
|
||||
return true;
|
||||
}
|
||||
if c == '\u{200E}' {
|
||||
// Left-to-Right Mark (LRM)
|
||||
return false;
|
||||
}
|
||||
if c.is_alphabetic() {
|
||||
return is_rtl_script(c);
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Helper checking if a char belongs to standard RTL Unicode blocks.
|
||||
pub fn is_rtl_script(c: char) -> bool {
|
||||
matches!(
|
||||
c,
|
||||
'\u{0590}'..='\u{05FF}' // Hebrew
|
||||
| '\u{0600}'..='\u{06FF}' // Arabic
|
||||
| '\u{0700}'..='\u{074F}' // Syriac
|
||||
| '\u{0750}'..='\u{077F}' // Arabic Supplement
|
||||
| '\u{0780}'..='\u{07BF}' // Thaana
|
||||
| '\u{0870}'..='\u{089F}' // Arabic Extended-B
|
||||
| '\u{08A0}'..='\u{08FF}' // Arabic Extended-A
|
||||
| '\u{FB1D}'..='\u{FB4F}' // Hebrew Presentation Forms
|
||||
| '\u{FB50}'..='\u{FDFF}' // Arabic Presentation Forms-A
|
||||
| '\u{FE70}'..='\u{FEFE}' // Arabic Presentation Forms-B
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_arabic_and_kurdish() {
|
||||
assert!(first_strong_is_rtl("مرحبا"));
|
||||
assert!(first_strong_is_rtl("من ناوم هێڤاره"));
|
||||
assert!(first_strong_is_rtl("سلام"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_hebrew() {
|
||||
assert!(first_strong_is_rtl("שלום"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_latin_as_not_rtl() {
|
||||
assert!(!first_strong_is_rtl("Hello"));
|
||||
assert!(!first_strong_is_rtl("DeckCraft"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_neutrals_numbers_and_whitespace() {
|
||||
assert!(first_strong_is_rtl(" 123 مرحبا"));
|
||||
assert!(first_strong_is_rtl("... (2024) سنة"));
|
||||
assert!(!first_strong_is_rtl(" 123 Hello"));
|
||||
assert!(!first_strong_is_rtl(""));
|
||||
assert!(!first_strong_is_rtl(" 12345 "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honours_directional_marks() {
|
||||
assert!(first_strong_is_rtl("\u{200F}Hello"));
|
||||
assert!(!first_strong_is_rtl("\u{200E}مرحبا"));
|
||||
}
|
||||
}
|
||||
@@ -401,6 +401,44 @@ pub fn char_move(body: &TextBody, pos: Pos, forward: bool) -> Pos {
|
||||
}
|
||||
}
|
||||
|
||||
/// The position of the `n`th character of the body's plain text (`\n` between paragraphs).
|
||||
fn pos_of_char(body: &TextBody, mut n: usize) -> Pos {
|
||||
for (pi, p) in body.paragraphs.iter().enumerate() {
|
||||
let len = p.char_len();
|
||||
if n <= len {
|
||||
return (pi, n);
|
||||
}
|
||||
n -= len + 1;
|
||||
}
|
||||
end(body)
|
||||
}
|
||||
|
||||
/// Make the body's plain text (paragraphs joined by `\n`) read `text`, as if it had been typed in
|
||||
/// place: only the span between the longest common prefix and suffix changes, so untouched text
|
||||
/// keeps its run and paragraph formatting and new text takes the formatting where it lands. Text
|
||||
/// that shares nothing with the old text replaces it as plain text.
|
||||
pub fn replace_text(body: &mut TextBody, text: &str) {
|
||||
let old: Vec<char> = body.text().chars().collect();
|
||||
let new: Vec<char> = text.chars().collect();
|
||||
if old == new {
|
||||
return;
|
||||
}
|
||||
let pre = old.iter().zip(&new).take_while(|(a, b)| a == b).count();
|
||||
let room = old.len().min(new.len()) - pre;
|
||||
let suf = old.iter().rev().zip(new.iter().rev()).take(room).take_while(|(a, b)| a == b).count();
|
||||
if pre == 0 && suf == 0 {
|
||||
*body = TextBody { body: body.body.clone(), list_style: body.list_style.clone(), ..TextBody::from_text(text) };
|
||||
return;
|
||||
}
|
||||
let a = pos_of_char(body, pre);
|
||||
let b = pos_of_char(body, old.len() - suf);
|
||||
let at = delete(body, a, b);
|
||||
let mid: String = new.get(pre..new.len() - suf).unwrap_or(&[]).iter().collect();
|
||||
if !mid.is_empty() {
|
||||
insert(body, at, &mid, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// End of the body.
|
||||
pub fn end(body: &TextBody) -> Pos {
|
||||
let pi = body.paragraphs.len().saturating_sub(1);
|
||||
@@ -676,4 +714,53 @@ mod tests {
|
||||
assert_eq!(next_case("hello. world is HERE"), "upper");
|
||||
assert_eq!(next_case("123"), "upper");
|
||||
}
|
||||
|
||||
/// Speaker notes: a bold heading over indented bullet points.
|
||||
fn notes() -> TextBody {
|
||||
use crate::text::{Bullet, ParaProps};
|
||||
let mut b = body("Overview\nFirst point\nSecond point");
|
||||
format(&mut b, (0, 0), (0, 8), &|p| p.bold = Some(true));
|
||||
for p in b.paragraphs.iter_mut().skip(1) {
|
||||
p.level = 1;
|
||||
p.props =
|
||||
ParaProps { margin_left: Some(36.0), indent: Some(-18.0), bullet: Some(Bullet::Char { char: "•".into() }), ..Default::default() };
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_text_keeps_formatting() {
|
||||
let before = notes();
|
||||
// Same text: nothing changes.
|
||||
let mut b = before.clone();
|
||||
replace_text(&mut b, "Overview\nFirst point\nSecond point");
|
||||
assert_eq!(b, before);
|
||||
// A word typed into a bullet: heading stays bold, bullets keep level, indent and bullet.
|
||||
replace_text(&mut b, "Overview\nFirst main point\nSecond point");
|
||||
assert_eq!(b.text(), "Overview\nFirst main point\nSecond point");
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(b.paragraphs[1].props, before.paragraphs[1].props);
|
||||
assert_eq!((b.paragraphs[1].level, b.paragraphs[2].level), (1, 1));
|
||||
// Typing at the end of the heading continues it in bold.
|
||||
replace_text(&mut b, "Overview (5 min)\nFirst main point\nSecond point");
|
||||
assert_eq!(b.paragraphs[0].runs.len(), 1);
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
// Enter after a bullet starts another bullet; deleting one joins nothing else.
|
||||
replace_text(&mut b, "Overview (5 min)\nFirst main point\nThird\nSecond point");
|
||||
assert_eq!(b.paragraphs.len(), 4);
|
||||
assert_eq!((b.paragraphs[2].level, &b.paragraphs[2].props), (1, &before.paragraphs[1].props));
|
||||
replace_text(&mut b, "Overview (5 min)\nSecond point");
|
||||
assert_eq!(b.text(), "Overview (5 min)\nSecond point");
|
||||
assert_eq!(b.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(b.paragraphs[1].level, 1);
|
||||
// Text with nothing in common is new plain text.
|
||||
replace_text(&mut b, "Plain");
|
||||
assert_eq!(b, body("Plain"));
|
||||
replace_text(&mut b, "");
|
||||
assert!(b.is_empty());
|
||||
// Into an empty body.
|
||||
let mut e = TextBody::default();
|
||||
replace_text(&mut e, "a\nb");
|
||||
assert_eq!(e.text(), "a\nb");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod anim;
|
||||
pub mod bidi;
|
||||
pub mod chart;
|
||||
pub mod defaults;
|
||||
pub mod edit;
|
||||
@@ -27,6 +28,7 @@ pub use deckcraft_geom::{Rect, Size, Xfrm};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use anim::{AnimClass, AnimStart, Animation, Transition};
|
||||
pub use bidi::{first_strong_is_rtl, is_rtl_script};
|
||||
pub use chart::Chart;
|
||||
pub use style::{ColorBase, ColorRef, Effects, Fill, Line, ShapeStyle};
|
||||
pub use table::{Cell, Table};
|
||||
@@ -315,6 +317,10 @@ pub enum ShapeKind {
|
||||
xml: String,
|
||||
preview: Option<MediaId>,
|
||||
label: String,
|
||||
/// The parts its XML points to (SmartArt data, an OLE embedding…), so a save writes them
|
||||
/// back: (relationship id used in `xml`, relationship type, the part as a media item).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
parts: Vec<(String, String, MediaId)>,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -195,9 +195,8 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
"surfaceChart" | "surface3DChart" => ChartType::Surface,
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(v) = g.child_val("varyColors") {
|
||||
c.vary_colors = v == "1" || v == "true";
|
||||
}
|
||||
// A missing <c:varyColors> means "vary by point" (the schema default is true), as PowerPoint draws it.
|
||||
c.vary_colors = g.child_val("varyColors").is_none_or(|v| v == "1" || v == "true");
|
||||
// A bar group without c:gapWidth / c:overlap takes the schema's defaults (150 %, 0 %),
|
||||
// not the 219 % / -27 % PowerPoint writes into its new charts.
|
||||
let bar_group = g.is("barChart") || g.is("bar3DChart");
|
||||
@@ -287,6 +286,10 @@ fn chart_within(root: &El, mut budget: usize) -> Option<Chart> {
|
||||
c.series.push(ser);
|
||||
}
|
||||
}
|
||||
// `autoTitleDeleted val="0"` without `c:title`: PowerPoint and LibreOffice draw the series name as the title of a single-series chart.
|
||||
if ch.child("title").is_none() && ch.child("autoTitleDeleted").and_then(|a| a.bool("val")) == Some(false) && c.series.len() == 1 {
|
||||
c.title = c.series.first().map(|s| s.name.clone()).filter(|n| !n.is_empty());
|
||||
}
|
||||
let first = *kinds.first()?;
|
||||
c.kind = if kinds.iter().any(|k| *k != first) { ChartType::Combo } else { first };
|
||||
if c.kind != ChartType::Combo {
|
||||
@@ -691,4 +694,52 @@ mod tests {
|
||||
assert_eq!((t[1].kind, t[1].order), (TrendlineType::Poly, Some(6)));
|
||||
assert_eq!((t[2].kind, t[2].period), (TrendlineType::MovingAvg, Some(3)));
|
||||
}
|
||||
|
||||
const NS_C: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
|
||||
const NS_A: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
|
||||
|
||||
fn chart(auto_deleted: &str, title: &str, series: usize) -> Chart {
|
||||
let val = r#"<c:val><c:numLit><c:ptCount val="1"/><c:pt idx="0"><c:v>1</c:v></c:pt></c:numLit></c:val>"#;
|
||||
let ser: String = (0..series).map(|i| format!(r#"<c:ser><c:idx val="{i}"/><c:tx><c:v>S{i}</c:v></c:tx>{val}</c:ser>"#)).collect();
|
||||
let plot = format!(r#"<c:plotArea><c:barChart><c:barDir val="col"/>{ser}</c:barChart></c:plotArea>"#);
|
||||
let xml = format!(r#"<c:chartSpace xmlns:c="{NS_C}" xmlns:a="{NS_A}"><c:chart>{title}{auto_deleted}{plot}</c:chart></c:chartSpace>"#);
|
||||
read_chart_xml(xml.as_bytes()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_series_gets_automatic_title() {
|
||||
assert_eq!(chart(r#"<c:autoTitleDeleted val="0"/>"#, "", 1).title.as_deref(), Some("S0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_automatic_title_when_deleted_or_multi_series() {
|
||||
assert_eq!(chart(r#"<c:autoTitleDeleted val="1"/>"#, "", 1).title, None);
|
||||
assert_eq!(chart("", "", 1).title, None);
|
||||
assert_eq!(chart(r#"<c:autoTitleDeleted val="0"/>"#, "", 2).title, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_title_wins() {
|
||||
let t = "<c:title><c:tx><c:rich><a:p><a:r><a:t>Mine</a:t></a:r></a:p></c:rich></c:tx></c:title>";
|
||||
assert_eq!(chart(r#"<c:autoTitleDeleted val="0"/>"#, t, 1).title.as_deref(), Some("Mine"));
|
||||
}
|
||||
|
||||
const NS_CHART: &str = "http://schemas.openxmlformats.org/drawingml/2006/chart";
|
||||
|
||||
fn vary_xml(vary: &str) -> String {
|
||||
let ser = r#"<c:ser><c:idx val="0"/><c:order val="0"/></c:ser>"#;
|
||||
let bar = format!(r#"<c:barChart><c:barDir val="col"/><c:grouping val="clustered"/>{vary}{ser}</c:barChart>"#);
|
||||
format!(r#"<c:chartSpace xmlns:c="{NS_CHART}"><c:chart><c:plotArea>{bar}</c:plotArea></c:chart></c:chartSpace>"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_vary_colors_means_vary_by_point() {
|
||||
assert!(read_chart_xml(vary_xml("").as_bytes()).is_some_and(|c| c.vary_colors));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_vary_colors_is_respected() {
|
||||
assert!(read_chart_xml(vary_xml(r#"<c:varyColors val="0"/>"#).as_bytes()).is_some_and(|c| !c.vary_colors));
|
||||
assert!(read_chart_xml(vary_xml(r#"<c:varyColors val="1"/>"#).as_bytes()).is_some_and(|c| c.vary_colors));
|
||||
}
|
||||
}
|
||||
+24
-10
@@ -740,19 +740,33 @@ fn sane_len(v: f64, dflt: f64) -> f64 {
|
||||
|
||||
/// Does the element (or a descendant) reference a relationship?
|
||||
pub fn has_rel_refs(el: &El) -> bool {
|
||||
fn rec(e: &El, depth: usize) -> bool {
|
||||
!rel_refs(el).is_empty()
|
||||
}
|
||||
|
||||
/// Is this attribute a relationship id (`r:id`, `r:embed`, `r:dm`…)?
|
||||
pub fn is_rel_attr(k: &str, v: &str) -> bool {
|
||||
!k.starts_with("xmlns")
|
||||
&& k.contains(':')
|
||||
&& matches!(k.rsplit(':').next(), Some("id" | "embed" | "link" | "dm" | "lo" | "qs" | "cs" | "pict" | "href"))
|
||||
&& !v.is_empty()
|
||||
}
|
||||
|
||||
/// Every relationship id `el` and its descendants use, sorted and without repeats.
|
||||
pub fn rel_refs(el: &El) -> Vec<String> {
|
||||
fn rec(e: &El, out: &mut Vec<String>, depth: usize) {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
out.extend(e.attrs.iter().filter(|(k, v)| is_rel_attr(k, v)).map(|(_, v)| v.clone()));
|
||||
for c in e.elements() {
|
||||
rec(c, out, depth + 1);
|
||||
}
|
||||
let own = e.attrs.iter().any(|(k, v)| {
|
||||
!k.starts_with("xmlns")
|
||||
&& k.contains(':')
|
||||
&& matches!(k.rsplit(':').next(), Some("id" | "embed" | "link" | "dm" | "lo" | "qs" | "cs" | "pict" | "href"))
|
||||
&& !v.is_empty()
|
||||
});
|
||||
own || e.elements().any(|c| rec(c, depth + 1))
|
||||
}
|
||||
rec(el, 0)
|
||||
let mut out = vec![];
|
||||
rec(el, &mut out, 0);
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
pub fn color_map(cm: &El) -> Vec<(String, String)> {
|
||||
|
||||
@@ -450,7 +450,22 @@ fn frame(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El, depth: usize) -> S
|
||||
} else {
|
||||
uri.rsplit('/').next().unwrap_or("Object").to_string()
|
||||
};
|
||||
s.kind = ShapeKind::Opaque { xml: part.keep(e), preview, label };
|
||||
// Keep the parts it points to so a save writes it back whole (#203). A part with relationships
|
||||
// of its own, or an external link, can't be carried: the save then falls back to the preview.
|
||||
let parts = super::rel_refs(e)
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let rel = part.rels.get(&id).filter(|r| !r.external)?;
|
||||
if !imp.pkg.rels(&rel.target).items.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (kind, target) = (rel.ty.clone(), rel.target.clone());
|
||||
let media = if rel.kind == "diagramData" { diagram_data(imp, &target) } else { imp.media_for(&target) };
|
||||
Some((id, kind, media?))
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
.unwrap_or_default();
|
||||
s.kind = ShapeKind::Opaque { xml: part.keep(e), preview, label, parts };
|
||||
s
|
||||
}
|
||||
|
||||
@@ -491,6 +506,34 @@ fn vml_index(pkg: &crate::opc::Package, vml: &str) -> std::collections::HashMap<
|
||||
out
|
||||
}
|
||||
|
||||
/// A diagram's data part, without the link to its cached drawing: that link is a slide
|
||||
/// relationship id, which a save renumbers. Only diagrams whose drawing is missing or empty get
|
||||
/// here, and those are drawn from their data.
|
||||
fn diagram_data(imp: &mut Imp, target: &str) -> Option<deckcraft_model::MediaId> {
|
||||
fn strip(e: &mut El, depth: usize) {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
e.children.retain(|n| !matches!(n, crate::xml::Node::El(c) if c.is("ext") && c.find("dataModelExt").is_some()));
|
||||
for c in e.elements_mut() {
|
||||
strip(c, depth + 1);
|
||||
}
|
||||
}
|
||||
let mut doc = imp.pkg.xml(target)?;
|
||||
strip(&mut doc.root, 0);
|
||||
let xml = format!("<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n{}", doc.root.to_xml());
|
||||
let id = deckcraft_model::MediaId(imp.alloc());
|
||||
let content_type = imp.pkg.content_type(target)?;
|
||||
imp.media.push(deckcraft_model::MediaItem {
|
||||
id,
|
||||
name: crate::opc::file_of(target).to_string(),
|
||||
content_type,
|
||||
data: std::sync::Arc::new(xml.into_bytes()),
|
||||
link: None,
|
||||
});
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// SmartArt: read the pre-drawn shapes of its drawing part as a group.
|
||||
fn smartart(imp: &mut Imp, part: &Part, gd: &El, frame: &Shape, depth: usize) -> Option<Shape> {
|
||||
let rel_ids = gd.child("relIds")?;
|
||||
|
||||
@@ -441,7 +441,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth
|
||||
}
|
||||
w.close("p:grpSp");
|
||||
}
|
||||
ShapeKind::Opaque { xml, preview, label } => opaque(w, x, o, cx, s, xml, *preview, label),
|
||||
ShapeKind::Opaque { xml, preview, label, parts } => opaque(w, x, o, cx, s, xml, *preview, label, parts),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,13 +836,32 @@ fn media(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, m: &deckcraft_
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn opaque(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, xml: &str, preview: Option<deckcraft_model::MediaId>, label: &str) {
|
||||
// Kept XML that references no relationships can be written back as it was.
|
||||
fn opaque(
|
||||
w: &mut W,
|
||||
x: &mut Exp,
|
||||
o: &mut Out,
|
||||
cx: &mut Ctx,
|
||||
s: &Shape,
|
||||
xml: &str,
|
||||
preview: Option<deckcraft_model::MediaId>,
|
||||
label: &str,
|
||||
parts: &[(String, String, deckcraft_model::MediaId)],
|
||||
) {
|
||||
// Kept XML is written back as it was when every part it references was kept too, with those
|
||||
// parts re-added under this slide's relationship ids.
|
||||
if let Ok(doc) = crate::xml::parse(xml.as_bytes())
|
||||
&& !crate::read::has_rel_refs(&doc.root)
|
||||
&& matches!(doc.root.local(), "graphicFrame" | "sp" | "grpSp" | "cxnSp")
|
||||
&& let Some(ids) = crate::read::rel_refs(&doc.root)
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let (_, kind, m) = parts.iter().find(|(id, ..)| id == r)?;
|
||||
Some((r.clone(), x.media_rel(o, *m, kind)?))
|
||||
})
|
||||
.collect::<Option<std::collections::HashMap<_, _>>>()
|
||||
{
|
||||
let mut root = doc.root;
|
||||
set_rel_ids(&mut root, &ids, 0);
|
||||
ole_fallback(&mut root, x, o, preview);
|
||||
let id = cx.id_for(s);
|
||||
set_cnvpr_id(&mut root, id);
|
||||
w.raw(&root.to_xml());
|
||||
@@ -892,6 +911,52 @@ fn shape_with_id(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, i
|
||||
}
|
||||
}
|
||||
|
||||
/// A legacy OLE object finds its preview in the slide's VML drawing through `spid`, and PowerPoint
|
||||
/// won't open the slide without it. We don't write that drawing, so write PowerPoint's own
|
||||
/// fallback form instead: no `spid`, and the preview as a `p:pic`.
|
||||
fn ole_fallback(root: &mut crate::xml::El, x: &mut Exp, o: &mut Out, preview: Option<deckcraft_model::MediaId>) {
|
||||
let xfrm = root.child("xfrm").map(|t| {
|
||||
let at = |e: &str, a: &str| t.child(e).and_then(|c| c.attr(a)).unwrap_or("0").to_string();
|
||||
(at("off", "x"), at("off", "y"), at("ext", "cx"), at("ext", "cy"))
|
||||
});
|
||||
let Some(ole) = root
|
||||
.elements_mut()
|
||||
.find(|e| e.is("graphic"))
|
||||
.and_then(|g| g.elements_mut().find(|e| e.is("graphicData")))
|
||||
.and_then(|d| d.elements_mut().find(|e| e.is("oleObj")))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if ole.child("pic").is_some() || ole.attr("spid").is_none() {
|
||||
return;
|
||||
}
|
||||
ole.attrs.retain(|(k, _)| k != "spid");
|
||||
let rel = preview.and_then(|m| x.media_rel(o, m, "image")).unwrap_or_else(|| x.placeholder_rel(o));
|
||||
let (ox, oy, cx, cy) = xfrm.unwrap_or_default();
|
||||
let pic = format!(
|
||||
r#"<p:pic><p:nvPicPr><p:cNvPr id="0" name=""/><p:cNvPicPr/><p:nvPr/></p:nvPicPr><p:blipFill><a:blip r:embed="{rel}"/><a:stretch><a:fillRect/></a:stretch></p:blipFill><p:spPr><a:xfrm><a:off x="{ox}" y="{oy}"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>"#
|
||||
);
|
||||
if let Ok(d) = crate::xml::parse(pic.as_bytes()) {
|
||||
ole.children.push(crate::xml::Node::El(d.root));
|
||||
}
|
||||
}
|
||||
|
||||
fn set_rel_ids(e: &mut crate::xml::El, ids: &std::collections::HashMap<String, String>, depth: usize) {
|
||||
if depth > crate::xml::MAX_DEPTH {
|
||||
return;
|
||||
}
|
||||
for (k, v) in &mut e.attrs {
|
||||
if crate::read::is_rel_attr(k, v)
|
||||
&& let Some(new) = ids.get(v.as_str())
|
||||
{
|
||||
v.clone_from(new);
|
||||
}
|
||||
}
|
||||
for c in e.elements_mut() {
|
||||
set_rel_ids(c, ids, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_cnvpr_id(el: &mut crate::xml::El, id: u32) {
|
||||
fn rec(e: &mut crate::xml::El, id: u32, depth: usize) -> bool {
|
||||
if depth > 8 {
|
||||
|
||||
@@ -63,9 +63,9 @@ fn corpus() -> (Presentation, Vec<u8>, Vec<u8>, Vec<u8>) {
|
||||
|
||||
let mut objects = new_slide(&mut p, "Objects");
|
||||
let mut diagram = shape(&mut p, "Diagram", 40.0, 80.0, 288.0, 144.0);
|
||||
diagram.kind = ShapeKind::Opaque { xml: SMARTART.into(), preview: None, label: "SmartArt".into() };
|
||||
diagram.kind = ShapeKind::Opaque { xml: SMARTART.into(), preview: None, label: "SmartArt".into(), parts: vec![] };
|
||||
let mut embedded = shape(&mut p, "Embedded", 340.0, 80.0, 144.0, 72.0);
|
||||
embedded.kind = ShapeKind::Opaque { xml: OLE.into(), preview: None, label: "DeckCraft.Note.1".into() };
|
||||
embedded.kind = ShapeKind::Opaque { xml: OLE.into(), preview: None, label: "DeckCraft.Note.1".into(), parts: vec![] };
|
||||
let mut block_shape = shape(&mut p, "Block", 40.0, 240.0, 120.0, 90.0);
|
||||
block_shape.kind = ShapeKind::Model3d { media: block_id, poster: None };
|
||||
let mut globe_shape = shape(&mut p, "Globe", 180.0, 240.0, 160.0, 120.0);
|
||||
|
||||
@@ -251,7 +251,7 @@ fn export_of_odd_models_is_valid() {
|
||||
}
|
||||
s.shapes.push(Shape {
|
||||
id: ShapeId(7),
|
||||
kind: ShapeKind::Opaque { xml: "<not xml".into(), preview: None, label: "Thing".into() },
|
||||
kind: ShapeKind::Opaque { xml: "<not xml".into(), preview: None, label: "Thing".into(), parts: vec![] },
|
||||
..Default::default()
|
||||
});
|
||||
p.slides.push(Arc::new(s));
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Speaker notes keep their run and paragraph formatting: a bold heading, indented points with
|
||||
//! custom bullets, plain lines.
|
||||
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::defaults;
|
||||
use deckcraft_model::text::{Bullet, ParaProps, TextBody};
|
||||
|
||||
const NOTES: &str = "ppt/notesSlides/notesSlide1.xml";
|
||||
|
||||
/// A notes slide written the way PowerPoint writes one (Slovak text): a bold heading, a plain
|
||||
/// line, two level-2 points with a hanging indent and a Wingdings square bullet (one mixing a bold
|
||||
/// label and plain text), a level-2 paragraph with buNone, and a closing plain line.
|
||||
const NOTES_XML: &str = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image Placeholder 1"/><p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr><p:spPr/></p:sp><p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes Placeholder 2"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="sk-SK" b="1" dirty="0"/><a:t>Metodický prehľad</a:t></a:r></a:p><a:p><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Žiaci pracujú vo dvojiciach.</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buFont typeface="Wingdings" panose="05000000000000000000" pitchFamily="2" charset="2"/><a:buChar char="§"/></a:pPr><a:r><a:rPr lang="sk-SK" b="1" dirty="0"/><a:t>Cieľ: </a:t></a:r><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>vysvetliť cyklus</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buFont typeface="Wingdings" panose="05000000000000000000" pitchFamily="2" charset="2"/><a:buChar char="§"/></a:pPr><a:r><a:rPr lang="sk-SK" i="1" dirty="0"/><a:t>Reflexia</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buNone/></a:pPr><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Bez odrážky</a:t></a:r></a:p><a:p><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Pomôcky: počítač</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>"#;
|
||||
|
||||
/// A deck with one notes slide, its notes replaced by [`NOTES_XML`].
|
||||
fn deck() -> Vec<u8> {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text("placeholder");
|
||||
let bytes = deckcraft_pptx::export(&p).expect("export");
|
||||
let mut src = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut out = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let mut found = false;
|
||||
for i in 0..src.len() {
|
||||
let mut f = src.by_index(i).expect("entry");
|
||||
let name = f.name().to_string();
|
||||
let mut data = vec![];
|
||||
f.read_to_end(&mut data).expect("read");
|
||||
if name == NOTES {
|
||||
data = NOTES_XML.as_bytes().to_vec();
|
||||
found = true;
|
||||
}
|
||||
out.start_file(name, zip::write::SimpleFileOptions::default()).expect("start");
|
||||
out.write_all(&data).expect("write");
|
||||
}
|
||||
assert!(found, "the export has no {NOTES}");
|
||||
out.finish().expect("finish").into_inner()
|
||||
}
|
||||
|
||||
fn assert_formatted(notes: &TextBody) {
|
||||
assert_eq!(notes.text(), "Metodický prehľad\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nReflexia\nBez odrážky\nPomôcky: počítač");
|
||||
let [head, plain, goal, reflect, unbulleted, closing] = notes.paragraphs.as_slice() else { panic!("six paragraphs: {notes:?}") };
|
||||
assert_eq!(head.runs[0].props.bold, Some(true), "bold heading");
|
||||
for para in [head, plain, closing] {
|
||||
assert_eq!((para.level, ¶.props), (0, &ParaProps::default()), "{:?}", para.text());
|
||||
}
|
||||
assert_eq!(plain.runs[0].props.bold, None);
|
||||
for para in [goal, reflect] {
|
||||
assert_eq!(para.level, 1, "level 2");
|
||||
assert_eq!(para.props.margin_left, Some(22.5), "marL 285750 EMU");
|
||||
assert_eq!(para.props.indent, Some(-22.5), "hanging indent");
|
||||
assert_eq!(para.props.bullet, Some(Bullet::Char { char: "§".into() }));
|
||||
assert_eq!(para.props.bullet_font.as_deref(), Some("Wingdings"));
|
||||
}
|
||||
assert_eq!((goal.runs[0].text.as_str(), goal.runs[0].props.bold), ("Cieľ: ", Some(true)), "bold label");
|
||||
assert_eq!((goal.runs[1].text.as_str(), goal.runs[1].props.bold), ("vysvetliť cyklus", None), "then plain");
|
||||
assert_eq!(reflect.runs[0].props.italic, Some(true));
|
||||
assert_eq!((unbulleted.level, unbulleted.props.bullet.clone(), unbulleted.props.margin_left), (1, Some(Bullet::None), Some(22.5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_import_keeps_bold_levels_and_indents() {
|
||||
let p = deckcraft_pptx::import(&deck()).expect("import");
|
||||
assert_formatted(&p.slides[0].notes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_formatting_survives_a_round_trip() {
|
||||
let p = deckcraft_pptx::import(&deck()).expect("import");
|
||||
let again = deckcraft_pptx::import(&deckcraft_pptx::export(&p).expect("export")).expect("reimport");
|
||||
assert_formatted(&again.slides[0].notes);
|
||||
// And the written XML says so, for PowerPoint.
|
||||
let bytes = deckcraft_pptx::export(&again).expect("export");
|
||||
let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip");
|
||||
let mut xml = String::new();
|
||||
z.by_name(NOTES).expect("notes part").read_to_string(&mut xml).expect("utf-8");
|
||||
assert!(xml.contains(r#"b="1""#), "{xml}");
|
||||
assert!(xml.contains(r#"lvl="1""#) && xml.contains(r#"marL="285750""#) && xml.contains(r#"indent="-285750""#), "{xml}");
|
||||
assert!(xml.contains(r#"<a:buFont typeface="Wingdings""#) && xml.contains("<a:buChar") && xml.contains("<a:buNone/>"), "{xml}");
|
||||
}
|
||||
@@ -1789,3 +1789,57 @@ fn compress_pictures_shrinks_the_file_and_keeps_shared_originals() {
|
||||
assert!(doc.media.iter().all(|m| m.data.len() < big.len() / 4));
|
||||
assert!(s.execute("picture.compress", &json!({"ppi": 5, "all": true})).is_err());
|
||||
}
|
||||
|
||||
/// #203: SmartArt we can't draw kept its XML but lost its diagram parts on save.
|
||||
#[test]
|
||||
fn smartart_without_a_drawing_keeps_its_parts() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let data = p.add_media(
|
||||
"data1.xml",
|
||||
"application/vnd.openxmlformats-officedocument.drawingml.diagramData+xml",
|
||||
br#"<dgm:dataModel xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><dgm:ptLst><dgm:pt modelId="1"><dgm:t><a:p><a:r><a:t>Manager</a:t></a:r></a:p></dgm:t></dgm:pt></dgm:ptLst><dgm:extLst><a:ext uri="http://schemas.microsoft.com/office/drawing/2008/diagram"><dsp:dataModelExt xmlns:dsp="http://schemas.microsoft.com/office/drawing/2008/diagram" relId="rId6"/></a:ext></dgm:extLst></dgm:dataModel>"#.to_vec(),
|
||||
);
|
||||
let mut parts = vec![("rId2".to_string(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagramData".to_string(), data)];
|
||||
for (i, kind) in ["Layout", "QuickStyle", "Colors"].into_iter().enumerate() {
|
||||
let xml = format!(r#"<dgm:{kind} xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram"/>"#).into_bytes();
|
||||
let m = p.add_media(&format!("{kind}1.xml"), &format!("application/vnd.openxmlformats-officedocument.drawingml.diagram{kind}+xml"), xml);
|
||||
parts.push((format!("rId{}", i + 3), format!("http://schemas.openxmlformats.org/officeDocument/2006/relationships/diagram{kind}"), m));
|
||||
}
|
||||
let xml = r#"<p:graphicFrame xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:nvGraphicFramePr><p:cNvPr id="4" name="Diagram 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/diagram"><dgm:relIds xmlns:dgm="http://schemas.openxmlformats.org/drawingml/2006/diagram" r:dm="rId2" r:lo="rId3" r:qs="rId4" r:cs="rId5"/></a:graphicData></a:graphic></p:graphicFrame>"#;
|
||||
let mut s = new_slide(&mut p, "SmartArt");
|
||||
let mut frame = shape(&mut p, "Diagram 3", 0.0, 0.0, 72.0, 72.0);
|
||||
frame.kind = ShapeKind::Opaque { xml: xml.into(), preview: None, label: "SmartArt".into(), parts };
|
||||
s.shapes.push(frame);
|
||||
p.slides.push(Arc::new(s));
|
||||
|
||||
// Twice: the second save starts from what the first one wrote.
|
||||
let q = round(&round(&p));
|
||||
let ShapeKind::Opaque { parts, .. } = &by_name(&q.slides[1], "Diagram 3").kind else { panic!("not kept as an object") };
|
||||
assert_eq!(parts.len(), 4, "data, layout, style and colours all kept");
|
||||
let data = q.media(parts.iter().find(|(_, k, _)| k.ends_with("/diagramData")).expect("data").2).expect("media");
|
||||
let data = String::from_utf8_lossy(&data.data);
|
||||
assert!(data.contains("Manager"), "{data}");
|
||||
assert!(!data.contains("dataModelExt"), "the link to a drawing that isn't written is dropped: {data}");
|
||||
}
|
||||
|
||||
/// #203: a legacy OLE object (preview in the VML drawing we don't write) is saved in PowerPoint's
|
||||
/// fallback form, without `spid` and with a `p:pic` preview, or PowerPoint won't open the file.
|
||||
#[test]
|
||||
fn legacy_ole_object_is_written_in_the_fallback_form() {
|
||||
let mut p = defaults::new_presentation(None);
|
||||
let bin = p.add_media("oleObject1.bin", "application/vnd.openxmlformats-officedocument.oleObject", b"\xD0\xCF\x11\xE0not-a-real-ole".to_vec());
|
||||
let prev = p.add_media("image1.png", "image/png", png(4, 3, [10, 20, 30]));
|
||||
let xml = r#"<p:graphicFrame xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><p:nvGraphicFramePr><p:cNvPr id="4" name="Object 3"/><p:cNvGraphicFramePr/><p:nvPr/></p:nvGraphicFramePr><p:xfrm><a:off x="0" y="0"/><a:ext cx="914400" cy="914400"/></p:xfrm><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/presentationml/2006/ole"><p:oleObj spid="_x0000_s1026" name="Slide" r:id="rId4" progId="PowerPoint.Slide.8"><p:embed/></p:oleObj></a:graphicData></a:graphic></p:graphicFrame>"#;
|
||||
let parts = vec![("rId4".to_string(), "http://schemas.openxmlformats.org/officeDocument/2006/relationships/oleObject".to_string(), bin)];
|
||||
let mut s = new_slide(&mut p, "OLE");
|
||||
let mut frame = shape(&mut p, "Object 3", 0.0, 0.0, 72.0, 72.0);
|
||||
frame.kind = ShapeKind::Opaque { xml: xml.into(), preview: Some(prev), label: "Slide".into(), parts };
|
||||
s.shapes.push(frame);
|
||||
p.slides.push(Arc::new(s));
|
||||
|
||||
let q = round(&round(&p));
|
||||
let ShapeKind::Opaque { xml, parts, preview, .. } = &by_name(&q.slides[1], "Object 3").kind else { panic!("not kept as an object") };
|
||||
assert!(xml.contains("<p:pic>") && !xml.contains("spid"), "{xml}");
|
||||
assert!(preview.is_some(), "the preview picture is kept");
|
||||
assert!(parts.iter().any(|(_, k, _)| k.ends_with("/oleObject")), "the embedding is kept: {parts:?}");
|
||||
}
|
||||
@@ -44,6 +44,18 @@ fn point_fill(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill of one bar: a `<c:dPt>` fill wins, and a single-series chart that varies colours by point uses one accent per point
|
||||
/// (not waterfalls, which are never read from a `<c:barChart>`).
|
||||
fn bar_fill(ctx: &Ctx, c: &Chart, si: usize, pi: usize, s: &deckcraft_model::chart::Series) -> Rgba {
|
||||
if s.point_fills.iter().any(|(k, _)| *k == pi) {
|
||||
return point_fill(ctx, c, si, s, pi);
|
||||
}
|
||||
if c.vary_colors && c.series.len() == 1 && s.fill.is_none() && c.kind != ChartType::Waterfall {
|
||||
return series_color(ctx, c, pi);
|
||||
}
|
||||
series_fill(ctx, c, si, Some(s))
|
||||
}
|
||||
|
||||
fn polyline(pts: &[Point]) -> BezPath {
|
||||
let mut p = BezPath::new();
|
||||
for (k, q) in pts.iter().enumerate() {
|
||||
@@ -623,7 +635,7 @@ fn draw_inner(ctx: &mut RenderContext, rctx: &Ctx, c: &Chart, m: Affine, w: f64,
|
||||
};
|
||||
series_color(rctx, c, slot)
|
||||
} else {
|
||||
point_fill(rctx, c, *si, s, i)
|
||||
bar_fill(rctx, c, *si, i, s)
|
||||
};
|
||||
ctx.set_transform(m);
|
||||
ctx.set_paint(color(fill, 1.0));
|
||||
|
||||
@@ -309,11 +309,21 @@ pub struct Renderer {
|
||||
/// The fill of the group being drawn, with the transform and box it is laid out in: children
|
||||
/// filled with the group's fill (`a:grpFill`) show it, as one fill across the whole group.
|
||||
group_fill: Option<(Fill, Affine, Rect)>,
|
||||
/// Their aligned outlines, kept from frame to frame.
|
||||
morphs: morph_path::Cache,
|
||||
}
|
||||
|
||||
impl Default for Renderer {
|
||||
fn default() -> Self {
|
||||
Renderer { resources: Resources::new(), paths: Vec::new(), bg: None, drawn: None, view: Affine::IDENTITY, group_fill: None }
|
||||
Renderer {
|
||||
resources: Resources::new(),
|
||||
paths: Vec::new(),
|
||||
bg: None,
|
||||
drawn: None,
|
||||
view: Affine::IDENTITY,
|
||||
group_fill: None,
|
||||
morphs: morph_path::Cache::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,6 +743,7 @@ impl Renderer {
|
||||
}
|
||||
}
|
||||
self.paths.clear();
|
||||
self.morphs.sweep();
|
||||
self.finish(ctx, w, h)
|
||||
}
|
||||
|
||||
@@ -982,7 +993,7 @@ impl Renderer {
|
||||
depth: usize,
|
||||
) {
|
||||
let (geo, fades) = match self.paths.iter().find(|p| p.id == s.id) {
|
||||
Some(pm) => morph_path::geometry(pm, s, w, h),
|
||||
Some(pm) => self.morphs.geometry(pm, s, w, h),
|
||||
None => (shape_geometry(s, w, h), Vec::new()),
|
||||
};
|
||||
let fade = |i: usize| fades.get(i).copied().unwrap_or(deckcraft_geom::morph::Fade { fill: 1.0, stroke: 1.0 });
|
||||
@@ -1410,7 +1421,10 @@ impl Renderer {
|
||||
ctx.set_paint(color(c.with_alpha(255), 1.0));
|
||||
ctx.set_stroke(kurbo::Stroke::new(g.radius.max(0.0) * 0.95).with_join(kurbo::Join::Round));
|
||||
ctx.stroke_path(&silhouette);
|
||||
ctx.fill_path(&silhouette);
|
||||
// A shape without a fill (an outline, a frame) glows along its line only (#225).
|
||||
if fill_alpha.is_some() || content.is_some() {
|
||||
ctx.fill_path(&silhouette);
|
||||
}
|
||||
ctx.pop_layer();
|
||||
}
|
||||
if let Some(rf) = &e.reflection {
|
||||
|
||||
@@ -29,12 +29,72 @@ fn ratio(now: f64, own: f64) -> f64 {
|
||||
if own.abs() > 1e-9 && r.is_finite() { r } else { 1.0 }
|
||||
}
|
||||
|
||||
/// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at their
|
||||
/// own sizes (so the alignment does not change while the box grows), then scaled into the box.
|
||||
/// With a [`Fade`] per sub-path.
|
||||
pub(crate) fn geometry(pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec<Fade>) {
|
||||
let a = shape_geometry(&Shape { geom: pm.from.clone(), ..Shape::default() }, pm.from_box.w, pm.from_box.h);
|
||||
let b = shape_geometry(s, pm.to_box.w, pm.to_box.h);
|
||||
let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h));
|
||||
GeometryMorph::new(&a, &b).at(pm.t, scale(pm.from_box), scale(pm.to_box))
|
||||
/// What a [`GeometryMorph`] is built from: both outlines at their own sizes. None of it changes
|
||||
/// while a transition plays, only `t` and the current box do.
|
||||
#[derive(Debug)]
|
||||
struct Source {
|
||||
from: Geom,
|
||||
from_wh: (f64, f64),
|
||||
to: Geom,
|
||||
to_wh: (f64, f64),
|
||||
}
|
||||
|
||||
impl Source {
|
||||
fn of(pm: &PathMorph, s: &Shape) -> Self {
|
||||
Source { from: pm.from.clone(), from_wh: (pm.from_box.w, pm.from_box.h), to: s.geom.clone(), to_wh: (pm.to_box.w, pm.to_box.h) }
|
||||
}
|
||||
|
||||
fn is(&self, pm: &PathMorph, s: &Shape) -> bool {
|
||||
self.from == pm.from && self.from_wh == (pm.from_box.w, pm.from_box.h) && self.to == s.geom && self.to_wh == (pm.to_box.w, pm.to_box.h)
|
||||
}
|
||||
|
||||
fn build(&self) -> GeometryMorph {
|
||||
let geo = |geom: &Geom, (w, h): (f64, f64)| shape_geometry(&Shape { geom: geom.clone(), ..Shape::default() }, w, h);
|
||||
GeometryMorph::new(&geo(&self.from, self.from_wh), &geo(&self.to, self.to_wh))
|
||||
}
|
||||
}
|
||||
|
||||
/// The aligned outlines of the morphs being played, kept across frames: aligning is the expensive
|
||||
/// part, blending them at `t` is cheap.
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Cache {
|
||||
/// With whether the current frame used it.
|
||||
morphs: Vec<(Source, GeometryMorph, bool)>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
/// The geometry of frame shape `s` at its current size `w` x `h`: both outlines are built at
|
||||
/// their own sizes (so the alignment does not change while the box grows), then scaled into the
|
||||
/// box. With a [`Fade`] per sub-path.
|
||||
pub(crate) fn geometry(&mut self, pm: &PathMorph, s: &Shape, w: f64, h: f64) -> (Geometry, Vec<Fade>) {
|
||||
let k = match self.morphs.iter().position(|(src, ..)| src.is(pm, s)) {
|
||||
Some(k) => k,
|
||||
None => {
|
||||
let src = Source::of(pm, s);
|
||||
let m = src.build();
|
||||
self.morphs.push((src, m, false));
|
||||
self.morphs.len() - 1
|
||||
}
|
||||
};
|
||||
let scale = |x: Xfrm| Vec2::new(ratio(w, x.w), ratio(h, x.h));
|
||||
let Some((_, m, used)) = self.morphs.get_mut(k) else {
|
||||
// Not reachable (k was just found or pushed); align afresh rather than panic.
|
||||
return Source::of(pm, s).build().at(pm.t, scale(pm.from_box), scale(pm.to_box));
|
||||
};
|
||||
*used = true;
|
||||
m.at(pm.t, scale(pm.from_box), scale(pm.to_box))
|
||||
}
|
||||
|
||||
/// Ends a frame: forgets the morphs it did not use (their transition is over).
|
||||
pub(crate) fn sweep(&mut self) {
|
||||
self.morphs.retain(|(.., used)| *used);
|
||||
for (.., used) in &mut self.morphs {
|
||||
*used = false;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.morphs.len()
|
||||
}
|
||||
}
|
||||
@@ -403,6 +403,34 @@ fn horizontal_bars_list_the_first_category_at_the_bottom_unless_reversed() {
|
||||
assert!(draw(true) < 180, "reversed: first category at the top: {}", draw(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glow_of_an_unfilled_shape_follows_its_line() {
|
||||
// A frame (no fill, thin line) with a strong glow: the glow lines its edge, the inside stays
|
||||
// the slide's white, as PowerPoint draws it.
|
||||
let glow = |fill: Option<Fill>| {
|
||||
let mut sh = Shape {
|
||||
id: ShapeId(610),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 300.0)),
|
||||
fill,
|
||||
line: Some(deckcraft_model::style::Line::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 176, 240)), 0.25)),
|
||||
..Default::default()
|
||||
};
|
||||
sh.effects = Some(deckcraft_model::Effects {
|
||||
glow: Some(deckcraft_model::style::Glow { color: ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 255)), radius: 8.0 }),
|
||||
..Default::default()
|
||||
});
|
||||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() })
|
||||
};
|
||||
let img = glow(Some(Fill::None));
|
||||
let inside = img.pixel(300, 250);
|
||||
assert!(inside[0] > 245 && inside[1] > 245, "{inside:?}");
|
||||
let edge = img.pixel(300, 97);
|
||||
assert!(edge[0] < 230, "{edge:?}");
|
||||
// A filled shape's glow is still the whole silhouette's (the fill covers its inside).
|
||||
let img = glow(Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 255, 255)))));
|
||||
assert!(img.pixel(300, 94)[0] < 240);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn placeholder_prompts_only_in_edit_view() {
|
||||
let p = Presentation::default();
|
||||
@@ -715,6 +743,29 @@ fn morph_path_fades_a_fill_only_one_side_has() {
|
||||
assert!(half[0] > full[0] + 40 && half[0] < 215, "{half:?} vs {full:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn morph_path_aligns_once_per_transition() {
|
||||
let (from, to) = (Xfrm::new(0.0, 0.0, 200.0, 200.0), Xfrm::new(0.0, 0.0, 300.0, 150.0));
|
||||
let star = Shape { geom: Geom::preset("star5"), ..Default::default() };
|
||||
let pm = |t: f64| PathMorph { id: ShapeId(10), from: Geom::preset("ellipse"), from_box: from, to_box: to, t };
|
||||
let els = |(g, f): (deckcraft_geom::preset::Geometry, Vec<_>)| (g.paths.iter().map(|p| p.path.elements().to_vec()).collect::<Vec<_>>(), f);
|
||||
let mut cache = morph_path::Cache::default();
|
||||
for t in [0.0, 0.3, 0.7, 1.0] {
|
||||
let got = els(cache.geometry(&pm(t), &star, 250.0, 175.0));
|
||||
// The same as aligning afresh.
|
||||
assert_eq!(got, els(morph_path::Cache::default().geometry(&pm(t), &star, 250.0, 175.0)));
|
||||
cache.sweep();
|
||||
}
|
||||
assert_eq!(cache.len(), 1);
|
||||
// Another target outline is another morph.
|
||||
let _ = cache.geometry(&pm(0.5), &Shape { geom: Geom::preset("rect"), ..Default::default() }, 250.0, 175.0);
|
||||
assert_eq!(cache.len(), 2);
|
||||
// A frame without morphs ends the transition: the cache empties.
|
||||
cache.sweep();
|
||||
cache.sweep();
|
||||
assert_eq!(cache.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_transform_unmirrors_flips() {
|
||||
// Asymmetric text rect in a 100×50 box: flips move the rect, never mirror the text.
|
||||
@@ -1472,7 +1523,7 @@ fn an_object_deckcraft_doesnt_model_shows_its_preview_picture() {
|
||||
let sh = Shape {
|
||||
id: ShapeId(600),
|
||||
xfrm: Some(Xfrm::new(100.0, 100.0, 400.0, 200.0)),
|
||||
kind: ShapeKind::Opaque { xml: String::new(), preview: with_preview.then_some(media), label: "Package".into() },
|
||||
kind: ShapeKind::Opaque { xml: String::new(), preview: with_preview.then_some(media), label: "Package".into(), parts: vec![] },
|
||||
..Default::default()
|
||||
};
|
||||
std::sync::Arc::make_mut(&mut p.slides[0]).shapes = vec![sh];
|
||||
@@ -2436,3 +2487,16 @@ fn render_an_emf_file_to_look_at() {
|
||||
let img = render_slide(&p, 0, &RenderOpts { scale: 1.0, ..Default::default() });
|
||||
let _ = std::fs::write(out, img.to_png());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_series_column_chart_varies_colours_by_point() {
|
||||
let render = |vary: bool| {
|
||||
let mut c = Chart::sample(ChartType::Column);
|
||||
c.series.truncate(1);
|
||||
c.vary_colors = vary;
|
||||
let sh =
|
||||
Shape { id: ShapeId(800), xfrm: Some(Xfrm::new(50.0, 50.0, 400.0, 300.0)), kind: ShapeKind::Chart(Box::new(c)), ..Default::default() };
|
||||
render_slide(&deck_with(vec![sh]), 0, &RenderOpts { scale: 1.0, ..Default::default() }).pixels
|
||||
};
|
||||
assert_ne!(render(true), render(false));
|
||||
}
|
||||
@@ -1170,7 +1170,8 @@ pub fn symbol_char(face: &FontFace, font: &str, c: char) -> Option<char> {
|
||||
Some(symbol_font_char(face, &s, Some(font)).unwrap_or_else(|| map_symbol_bullet(&s, Some(font)).chars().next().unwrap_or('•')))
|
||||
}
|
||||
|
||||
fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||||
/// The Unicode bullet for a symbol-font bullet character (Wingdings, Symbol), or the character.
|
||||
pub fn map_symbol_bullet(c: &str, font: Option<&str>) -> String {
|
||||
let f = font.unwrap_or("").to_ascii_lowercase();
|
||||
let first = c.chars().next().unwrap_or('•');
|
||||
if f.starts_with("webdings") {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
//! Format Background pane: picture transparency, tiling, and Apply to All.
|
||||
|
||||
use deckcraft_engine::Session;
|
||||
use deckcraft_model::style::PictureMode;
|
||||
use deckcraft_model::{Background, Fill};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
/// 1×1 PNG.
|
||||
const PNG: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
||||
|
||||
fn harness(mut app: SlideApp) -> Harness<'static, SlideApp> {
|
||||
app.ui.tab = "Design".into();
|
||||
app.ui.pane = Some("background".into());
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1800.0, 1100.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn picture_mode(h: &Harness<'_, SlideApp>) -> PictureMode {
|
||||
match &h.state().session.active().unwrap().doc.slides[0].background {
|
||||
Some(Background::Fill { fill: Fill::Picture(pf) }) => pf.mode.clone(),
|
||||
other => panic!("expected a picture background, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picture_fill_shows_transparency_and_tiling() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("design.background", &json!({"picture": PNG, "name": "dot.png"})).unwrap();
|
||||
let mut h = harness(SlideApp::new(s, Services::default()));
|
||||
assert!(h.query_all_by_label("Format Background").next().is_some(), "the pane is open");
|
||||
let _ = h.get_by_label("Picture or texture fill");
|
||||
assert!(h.query_all_by_label("Transparency").next().is_some());
|
||||
h.get_by_label("Tile picture as texture").click();
|
||||
h.run_steps(2);
|
||||
let _ = h.get_by_label("Offset X");
|
||||
let _ = h.get_by_label("Scale X");
|
||||
let _ = h.get_by_label("Alignment");
|
||||
let _ = h.get_by_label("Mirror");
|
||||
assert!(matches!(picture_mode(&h), PictureMode::Tile { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_fill_shows_transparency_without_tile_options() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("design.background", &json!({"color": "accent1"})).unwrap();
|
||||
let h = harness(SlideApp::new(s, Services::default()));
|
||||
assert!(h.query_all_by_label("Transparency").next().is_some());
|
||||
let _ = h.get_by_label("Solid fill");
|
||||
assert!(h.query_by_label("Tile picture as texture").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_to_all_copies_the_picture() {
|
||||
let mut s = Session::with_new();
|
||||
s.execute("design.background", &json!({"picture": PNG, "name": "dot.png"})).unwrap();
|
||||
s.execute("slide.new", &json!({})).unwrap();
|
||||
s.execute("slide.go", &json!({"index": 0})).unwrap();
|
||||
let mut h = harness(SlideApp::new(s, Services::default()));
|
||||
h.get_by_label("Apply to All").click();
|
||||
h.run_steps(2);
|
||||
let doc = &h.state().session.active().unwrap().doc;
|
||||
assert!(matches!(doc.slides[1].background, Some(Background::Fill { fill: Fill::Picture(_) })));
|
||||
assert_eq!(doc.slides[1].background, doc.slides[0].background);
|
||||
}
|
||||
@@ -398,6 +398,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
ui.label(RichText::new(bs.page.label()).font(theme::bold(22.0)).color(t.text));
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
// Errors raised on a backstage page (a missing recent file, a failed open) would
|
||||
// otherwise vanish: the status bar only draws once the backstage is closed (#172).
|
||||
// A real label, not painted text, so screen readers see it too.
|
||||
if let Some(msg) = app.fresh_status() {
|
||||
ui.label(RichText::new(&msg).font(theme::font(12.0)).color(t.accent));
|
||||
ui.add_space(8.0);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
match bs.page {
|
||||
Page::Home => home(app, ui, &mut bs, &mut out),
|
||||
Page::New => new_page(app, ui, &mut bs),
|
||||
@@ -583,6 +591,7 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
|
||||
}
|
||||
let width = ui.available_width().min(CONTENT_W);
|
||||
for path in list {
|
||||
let missing = !app.recent_exists(&path);
|
||||
let (name, folder) = split_path(&path);
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, 44.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
|
||||
@@ -590,10 +599,36 @@ fn recent_list(app: &mut SlideApp, ui: &mut Ui, query: &str, limit: usize) {
|
||||
if resp.hovered() {
|
||||
p.rect_filled(rect, CornerRadius::same(3), t.hover);
|
||||
}
|
||||
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, t.text, false);
|
||||
p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), t.text);
|
||||
p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim);
|
||||
if resp.on_hover_text(&path).clicked() {
|
||||
let ink = if missing { t.text_dim } else { t.text };
|
||||
// The row is as wide as the page's content, which a narrow window clips: keep the mark
|
||||
// and the remove button inside the visible part of the row.
|
||||
let visible_right = rect.right().min(ui.clip_rect().right() - 6.0);
|
||||
{
|
||||
let p = ui.painter();
|
||||
icons::paint(p, Rect::from_center_size(pos2(rect.left() + 20.0, rect.center().y), vec2(22.0, 22.0)), Icon::Slide, ink, false);
|
||||
p.text(pos2(rect.left() + 44.0, rect.center().y - 8.0), Align2::LEFT_CENTER, &name, theme::font(13.0), ink);
|
||||
p.text(pos2(rect.left() + 44.0, rect.center().y + 9.0), Align2::LEFT_CENTER, &folder, theme::font(11.0), t.text_dim);
|
||||
// A missing file says so before it is clicked (#172).
|
||||
if missing {
|
||||
p.text(pos2(visible_right - 82.0, rect.center().y), Align2::RIGHT_CENTER, "(missing)", theme::font(11.0), t.text_dim);
|
||||
}
|
||||
}
|
||||
let resp = if missing { resp.on_hover_text(format!("{path} — this file can't be found")) } else { resp.on_hover_text(&path) };
|
||||
// Remove from the list: a real labelled widget on top of the row, with a code-drawn icon
|
||||
// (a text glyph would need a font that has it).
|
||||
let x = ui.allocate_rect(
|
||||
Rect::from_min_max(pos2(visible_right - 32.0, rect.top() + 5.0), pos2(visible_right, rect.bottom() - 5.0)),
|
||||
Sense::click(),
|
||||
);
|
||||
x.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, "Remove from Recent"));
|
||||
if x.hovered() {
|
||||
ui.painter().rect_filled(x.rect, CornerRadius::same(3), t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(x.rect.center(), vec2(14.0, 14.0)), Icon::Close, t.text_dim, false);
|
||||
if x.on_hover_text("Remove from Recent").clicked() {
|
||||
app.forget_recent(&path);
|
||||
}
|
||||
if resp.clicked() {
|
||||
app.open_path_in_background(&path);
|
||||
}
|
||||
}
|
||||
|
||||
+142
-43
@@ -153,10 +153,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
offset.x = offset.x.clamp(0.0, overflow.x);
|
||||
offset.y = offset.y.clamp(0.0, overflow.y);
|
||||
let slide_rect = content_rect.translate(-offset);
|
||||
// On the physical pixel grid, so the slide texture is one texel per device pixel (#184). The
|
||||
// zoom anchor keeps the unsnapped rect, so snapping never accumulates into drift.
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let exact_rect = content_rect.translate(-offset);
|
||||
let slide_rect = crate::textures::device_pixel_rect(exact_rect, ppp);
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(scroll_id, offset);
|
||||
d.insert_temp(last_id, (slide_rect, scale));
|
||||
d.insert_temp(last_id, (exact_rect, scale));
|
||||
});
|
||||
app.slide_rect = Some(slide_rect);
|
||||
app.canvas_scale = scale;
|
||||
@@ -164,7 +168,6 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let painter = ui.painter_at(avail);
|
||||
// Shadow + slide.
|
||||
painter.rect_filled(slide_rect.translate(vec2(0.0, 1.5)).expand(1.0), CornerRadius::same(2), t.shadow);
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let px = ((slide_rect.width() * ppp).round().max(1.0) as u32, (slide_rect.height() * ppp).round().max(1.0) as u32);
|
||||
let now = ui.input(|i| i.time);
|
||||
let gifs = match app.session.active() {
|
||||
@@ -183,7 +186,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let idx = d.selection.slide;
|
||||
let doc = d.doc.clone();
|
||||
let frame = anim.as_ref().map(|(tl, t)| (tl, *t));
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×, frame)
|
||||
// A placeholder's prompt makes way for the caret once it is being typed into.
|
||||
let typing_into = d.selection.text.as_ref().filter(|t| !t.notes && t.cell.is_none()).map(|t| t.shape);
|
||||
let no_prompt = typing_into.filter(|id| d.shape(*id).and_then(|sh| sh.text.as_ref()).is_none_or(|body| body.is_empty()));
|
||||
app.textures.slide(ui.ctx(), &doc, idx, px, visible, true, crate::media::gif_key(&gifs), app.ui.grayscale, ×, no_prompt, frame)
|
||||
}
|
||||
Some((Target::Master { master }, d)) => master_tex(ui.ctx(), &d.doc, master, None, px, visible),
|
||||
Some((Target::Layout { master, layout }, d)) => master_tex(ui.ctx(), &d.doc, master, Some(layout), px, visible),
|
||||
@@ -425,7 +431,7 @@ fn master_tex(
|
||||
layout,
|
||||
&deckcraft_render::RenderOpts { scale, edit: true, offset: region.offset(), size: Some(region.size()), ..Default::default() },
|
||||
);
|
||||
Some((crate::textures::upload(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
|
||||
Some((crate::textures::upload_editor(ctx, "master-canvas", crate::textures::to_color_image(&img, false)), region))
|
||||
}
|
||||
|
||||
fn ruler(p: &egui::Painter, avail: Rect, slide: Rect, scale: f32, t: &Tokens) {
|
||||
@@ -760,6 +766,8 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
|
||||
_ => egui::CursorIcon::ResizeHorizontal,
|
||||
},
|
||||
(_, Some(Hit::Rotate { .. })) => egui::CursorIcon::Alias,
|
||||
(_, Some(Hit::TableColumn { .. })) => egui::CursorIcon::ResizeColumn,
|
||||
(_, Some(Hit::TableRow { .. })) => egui::CursorIcon::ResizeRow,
|
||||
(_, Some(Hit::Adjust { .. } | Hit::Warp { .. } | Hit::LineEnd { .. })) => egui::CursorIcon::PointingHand,
|
||||
(_, Some(Hit::Shape { text: true, .. })) => {
|
||||
if app.session.active().is_some_and(|d| d.selection.text.is_some()) {
|
||||
@@ -768,7 +776,7 @@ fn pointer(app: &mut SlideApp, ui: &Ui, resp: &egui::Response, xf: Xf) {
|
||||
egui::CursorIcon::Move
|
||||
}
|
||||
}
|
||||
(_, Some(Hit::Shape { .. })) => egui::CursorIcon::Move,
|
||||
(_, Some(Hit::Shape { .. } | Hit::Frame { .. })) => egui::CursorIcon::Move,
|
||||
_ => match app.session.tool.guide_drag.map(|g| g.0).or(app.session.tool.guide_hover) {
|
||||
Some(i) if app.session.active().and_then(|d| d.doc.guides.get(i)).is_some_and(|g| g.vertical) => egui::CursorIcon::ResizeHorizontal,
|
||||
Some(_) => egui::CursorIcon::ResizeVertical,
|
||||
@@ -887,21 +895,10 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
}
|
||||
}
|
||||
}
|
||||
// Caret / text selection.
|
||||
if let Some(ts) = &editing
|
||||
&& !ts.notes
|
||||
&& ts.cell.is_none()
|
||||
&& let Some(sh) = st.shape(ts.shape)
|
||||
&& let Some(l) = deckcraft_engine::cmd::text::layout_for(st, ts)
|
||||
{
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
// Where the text is drawn: flips don't mirror it, vertical text is turned.
|
||||
let tr = deckcraft_render::shape_geometry(sh, x.w, x.h).text_rect;
|
||||
let m = deckcraft_render::text_transform(x.affine(), tr, l.rotation);
|
||||
let (s0, s1) = ts.ordered();
|
||||
for r in l.selection_rects(deckcraft_text::Pos { para: s0.0, ch: s0.1 }, deckcraft_text::Pos { para: s1.0, ch: s1.1 }) {
|
||||
let pts = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| xf.to_screen(m * q));
|
||||
painter.add(egui::Shape::convex_polygon(pts.to_vec(), t.text_selection, Stroke::NONE));
|
||||
// Caret / text selection, in a shape or a table cell.
|
||||
if let Some(ts) = &editing {
|
||||
for quad in text_marks(st, ts).iter().flat_map(|m| &m.selection) {
|
||||
painter.add(egui::Shape::convex_polygon(quad.iter().map(|q| xf.to_screen(*q)).collect(), t.text_selection, Stroke::NONE));
|
||||
}
|
||||
// An input method's composition: underlined in place, thicker under the part being converted.
|
||||
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
|
||||
@@ -912,34 +909,21 @@ fn overlays(app: &mut SlideApp, ui: &Ui, painter: &egui::Painter, xf: Xf, t: &To
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
|
||||
// An empty placeholder shows the caret where typing begins too.
|
||||
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret));
|
||||
// In the colour of the text being typed: a black caret is lost on a dark slide.
|
||||
let color = deckcraft_engine::cmd::text::caret_color(st, ts).map_or(t.caret, theme::to_color32);
|
||||
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, color));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Table cell editing: outline the cell.
|
||||
// Table cell editing: outline the cell (merged cells as drawn).
|
||||
if let Some(ts) = &editing
|
||||
&& let (Some((r, c)), Some(sh)) = (ts.cell, st.shape(ts.shape))
|
||||
&& let deckcraft_model::ShapeKind::Table(tb) = &sh.kind
|
||||
&& let Some(f) = deckcraft_engine::cmd::table::table_frame(st, sh)
|
||||
&& let Some(cr) = f.cell_rect(r, c)
|
||||
{
|
||||
let x = deckcraft_engine::cmd::xfrm_of(&st.doc, &st.selection, sh);
|
||||
let heights = deckcraft_engine::tools::table_rows(st, tb);
|
||||
let y0: f64 = heights.iter().take(r).sum();
|
||||
let x0: f64 = tb.cols.iter().take(c).sum();
|
||||
let (w, h) = (tb.cols.get(c).copied().unwrap_or(0.0), heights.get(r).copied().unwrap_or(0.0));
|
||||
let a = x.affine();
|
||||
let pts = [Point::new(x0, y0), Point::new(x0 + w, y0), Point::new(x0 + w, y0 + h), Point::new(x0, y0 + h)].map(|q| xf.to_screen(a * q));
|
||||
let pts = [Point::new(cr.x0, cr.y0), Point::new(cr.x1, cr.y0), Point::new(cr.x1, cr.y1), Point::new(cr.x0, cr.y1)]
|
||||
.map(|q| xf.to_screen(f.affine * q));
|
||||
poly(painter, pts, Stroke::new(2.0, t.accent));
|
||||
// The composition is in the cell text; underline it where that text is drawn (#101).
|
||||
for (a, b, thick) in deckcraft_engine::cmd::text::composition_underlines(st) {
|
||||
painter.line_segment([xf.to_screen(a), xf.to_screen(b)], Stroke::new(if thick { 2.0 } else { 1.0 }, t.caret));
|
||||
}
|
||||
if !ts.is_range() {
|
||||
let blink = (ui.input(|i| i.time) * 1.6).fract() < 0.6;
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(300));
|
||||
if blink && let Some((top, bot)) = deckcraft_engine::cmd::text::caret_line(st, ts) {
|
||||
painter.line_segment([xf.to_screen(top), xf.to_screen(bot)], Stroke::new(1.5, t.caret));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Smart guides, marquee, previews.
|
||||
for g in &app.session.tool.guides {
|
||||
@@ -1029,6 +1013,24 @@ fn reported_pressure(ui: &Ui) -> Option<f32> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The text selection (quads) of the text being edited, in slide space: in a shape or a table
|
||||
/// cell, wherever its text is drawn. The caret is `cmd::text::caret_line`.
|
||||
pub(crate) struct TextMarks {
|
||||
pub selection: Vec<[Point; 4]>,
|
||||
}
|
||||
|
||||
pub(crate) fn text_marks(st: &deckcraft_engine::DocState, ts: &deckcraft_engine::TextSel) -> Option<TextMarks> {
|
||||
let (l, m) = deckcraft_engine::cmd::text::text_frame(st, ts)?;
|
||||
let pos = |p: (usize, usize)| deckcraft_text::Pos { para: p.0, ch: p.1 };
|
||||
let (s0, s1) = ts.ordered();
|
||||
let selection = l
|
||||
.selection_rects(pos(s0), pos(s1))
|
||||
.into_iter()
|
||||
.map(|r| [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x1, r.y1), Point::new(r.x0, r.y1)].map(|q| m * q))
|
||||
.collect();
|
||||
Some(TextMarks { selection })
|
||||
}
|
||||
|
||||
fn rotate_handle(p: &egui::Painter, c: Pos2, t: &Tokens) {
|
||||
p.circle_filled(c, 6.0, t.handle_fill);
|
||||
p.add(egui::Shape::Path(egui::epaint::PathShape {
|
||||
@@ -1239,6 +1241,14 @@ fn context_menu(app: &mut SlideApp, resp: &egui::Response) {
|
||||
let _ = app.run("shape.lock", json!({"locked": !locked}));
|
||||
ui.close();
|
||||
}
|
||||
let picture = app
|
||||
.session
|
||||
.active()
|
||||
.is_some_and(|d| d.selected_shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })));
|
||||
if picture && ui.button("Change Picture…").clicked() {
|
||||
let _ = app.run("app.changePictureDialog", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Link…").clicked() {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("hyperlink"));
|
||||
ui.close();
|
||||
@@ -1346,6 +1356,7 @@ mod tests {
|
||||
use egui::{Event, Modifiers, MouseWheelUnit, Pos2, TouchPhase, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
|
||||
use super::*;
|
||||
use crate::{Services, SlideApp};
|
||||
|
||||
fn harness() -> Harness<'static, SlideApp> {
|
||||
@@ -1373,8 +1384,10 @@ mod tests {
|
||||
h.run_steps(30);
|
||||
}
|
||||
|
||||
/// Within half a slide point, plus the half device pixel the slide moves to sit on the pixel
|
||||
/// grid (#184).
|
||||
fn assert_near(a: Pos2, b: Pos2, what: &str) {
|
||||
assert!((a - b).length() < 0.5, "{what}: {a:?} moved to {b:?}");
|
||||
assert!((a - b).length() < 1.25, "{what}: {a:?} moved to {b:?}");
|
||||
}
|
||||
|
||||
/// #106: wheel and pinch zoom zoomed toward the slide's corner instead of the pointer.
|
||||
@@ -1430,6 +1443,92 @@ mod tests {
|
||||
assert_near(mid, slide_point(&h, c), "zoom out");
|
||||
}
|
||||
|
||||
fn app_with_table() -> (egui_kittest::Harness<'static, SlideApp>, deckcraft_model::ShapeId) {
|
||||
let mut s = Session::with_new();
|
||||
let data = json!([["Hello world", "b"], ["c", ""]]);
|
||||
let r = s.execute("insert.table", &json!({"rows": 2, "cols": 2, "rect": [100.0, 100.0, 400.0, 80.0], "data": data})).unwrap();
|
||||
let id = deckcraft_model::ShapeId(r["id"].as_u64().unwrap() as u32);
|
||||
s.execute("edit.deselect", &json!({})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(s, Services::default()),
|
||||
);
|
||||
// The app keeps requesting repaints, so step frames instead of run().
|
||||
harness.run_steps(2);
|
||||
(harness, id)
|
||||
}
|
||||
|
||||
/// Screen position of slide point (x, y).
|
||||
fn screen(app: &SlideApp, x: f64, y: f64) -> Pos2 {
|
||||
Xf { origin: app.slide_rect.unwrap().min, scale: app.canvas_scale }.to_screen(Point::new(x, y))
|
||||
}
|
||||
|
||||
/// Press and release at `p`, 50 ms apart in input time (two clicks make a double-click).
|
||||
fn click(harness: &mut egui_kittest::Harness<'static, SlideApp>, p: Pos2) {
|
||||
for pressed in [true, false] {
|
||||
let t = harness.ctx.input(|i| i.time) + 0.05;
|
||||
harness.input_mut().time = Some(t);
|
||||
harness.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE });
|
||||
harness.step();
|
||||
}
|
||||
}
|
||||
|
||||
/// Double-clicking a table cell edits it with the caret drawn in that cell, where clicked.
|
||||
#[test]
|
||||
fn double_click_in_a_table_cell_shows_the_caret_there() {
|
||||
let (mut harness, id) = app_with_table();
|
||||
// The empty cell (1, 1) spans x 300..500, y 140..180.
|
||||
let p = screen(harness.state(), 400.0, 160.0);
|
||||
harness.hover_at(p);
|
||||
harness.step();
|
||||
click(&mut harness, p);
|
||||
click(&mut harness, p);
|
||||
harness.run_steps(2);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
let ts = st.selection.text.clone().expect("editing the cell");
|
||||
assert_eq!((ts.shape, ts.cell), (id, Some((1, 1))));
|
||||
assert!(text_marks(st, &ts).is_some_and(|m| m.selection.is_empty()), "no range");
|
||||
let (top, bottom) = deckcraft_engine::cmd::text::caret_line(st, &ts).expect("the cell's text has a caret");
|
||||
for q in [top, bottom] {
|
||||
assert!(q.x > 300.0 && q.x < 500.0 && q.y >= 140.0 && q.y <= 180.0, "caret {q:?} outside cell (1, 1)");
|
||||
}
|
||||
// A click in another cell moves the caret there.
|
||||
let p = screen(harness.state(), 110.0, 120.0);
|
||||
click(&mut harness, p);
|
||||
harness.run_steps(1);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
let ts = st.selection.text.clone().unwrap();
|
||||
assert_eq!((ts.cell, ts.caret), (Some((0, 0)), (0, 0)));
|
||||
let (top, _) = deckcraft_engine::cmd::text::caret_line(st, &ts).unwrap();
|
||||
assert!(top.x > 100.0 && top.x < 120.0 && top.y >= 100.0 && top.y < 140.0, "caret {top:?} at the start of cell (0, 0)");
|
||||
}
|
||||
|
||||
/// Table borders show a resize cursor and drag to resize columns.
|
||||
#[test]
|
||||
fn table_borders_resize_with_the_pointer() {
|
||||
let (mut harness, id) = app_with_table();
|
||||
let p = screen(harness.state(), 300.0, 120.0);
|
||||
harness.hover_at(p);
|
||||
harness.step();
|
||||
harness.step();
|
||||
assert_eq!(harness.output().platform_output.cursor_icon, egui::CursorIcon::ResizeColumn);
|
||||
let q = screen(harness.state(), 340.0, 120.0);
|
||||
harness.drag_at(p);
|
||||
harness.step();
|
||||
for k in 1..=4 {
|
||||
harness.hover_at(p + (q - p) * k as f32 / 4.0);
|
||||
harness.step();
|
||||
}
|
||||
harness.drop_at(q);
|
||||
harness.run_steps(2);
|
||||
let st = harness.state().session.active().unwrap();
|
||||
let deckcraft_model::ShapeKind::Table(t) = &st.shape(id).unwrap().kind else { panic!("not a table") };
|
||||
assert!((t.cols[0] - 240.0).abs() < 0.5 && (t.cols[1] - 160.0).abs() < 0.5, "{:?}", t.cols);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_step_follows_spacing_and_rejects_unusable_values() {
|
||||
let step = super::grid_line_step(72.0, 0.5).unwrap_or(0.0);
|
||||
|
||||
@@ -1521,6 +1521,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
("JPEG image (current slide)", "jpeg"),
|
||||
("PowerPoint Presentation (.pptx)", "pptx"),
|
||||
("Outline (.txt)", "outline"),
|
||||
("Animated GIF (.gif)", "gif"),
|
||||
] {
|
||||
if ui.radio(fmt == f, l).clicked() {
|
||||
d.fields.insert("fmt".into(), f.into());
|
||||
@@ -1564,6 +1565,10 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
d.fields.insert("pdfHidden".into(), hidden.to_string());
|
||||
}
|
||||
}
|
||||
let gif_params = if fmt == "gif" { gif_options(app, ui, d) } else { Ok(Value::Null) };
|
||||
if let Err(e) = &gif_params {
|
||||
ui.label(egui::RichText::new(e).color(Tokens::get(ui.ctx()).text_dim));
|
||||
}
|
||||
let pdf_params = {
|
||||
let (layout, per) = match pdf_layout.as_str() {
|
||||
"notes" => ("notes", 0),
|
||||
@@ -1574,7 +1579,19 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
};
|
||||
let (ok, cancel) = buttons(ui, "Export");
|
||||
if ok {
|
||||
let extra = if fmt == "pdf" { pdf_params } else { json!({}) };
|
||||
let gif_params = match gif_params {
|
||||
Ok(v) => v,
|
||||
// Keep the dialog open so the range or width can be corrected.
|
||||
Err(e) => {
|
||||
app.set_status(e);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let extra = match fmt.as_str() {
|
||||
"pdf" => pdf_params,
|
||||
"gif" => gif_params,
|
||||
_ => json!({}),
|
||||
};
|
||||
app.export_as(&fmt, extra);
|
||||
}
|
||||
ok || cancel
|
||||
@@ -3205,6 +3222,64 @@ mod size_position_tests;
|
||||
#[path = "photo_album_tests.rs"]
|
||||
mod photo_album_tests;
|
||||
|
||||
/// The export dialog's Animated GIF options (size, seconds per slide, loop, slide range, hidden
|
||||
/// slides) as `file.export` params; a slide range that doesn't fit the deck is an error message.
|
||||
fn gif_options(app: &SlideApp, ui: &mut Ui, d: &mut Dialog) -> Result<Value, String> {
|
||||
use deckcraft_engine::gif_export::{MAX_SECONDS, MAX_WIDTH, MIN_SECONDS, MIN_WIDTH, SIZE_PRESETS};
|
||||
let n = app.session.active().map_or(0, |s| s.doc.slides.len());
|
||||
let size = d.get("gifSize", "medium");
|
||||
let size_label = |k: &str| match k {
|
||||
"small" => "Small (640 px wide)",
|
||||
"large" => "Large (1280 px wide)",
|
||||
"extraLarge" => "Extra Large (1920 px wide)",
|
||||
"custom" => "Custom width",
|
||||
_ => "Medium (960 px wide)",
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(120.0, 20.0), egui::Label::new("Size:"));
|
||||
egui::ComboBox::from_id_salt("gif_size").selected_text(size_label(&size)).show_ui(ui, |ui| {
|
||||
for k in SIZE_PRESETS.iter().map(|(k, _)| *k).chain(["custom"]) {
|
||||
if ui.selectable_label(size == k, size_label(k)).clicked() {
|
||||
d.fields.insert("gifSize".into(), k.into());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if size == "custom" {
|
||||
field(ui, d, "Width (pixels):", "gifWidth", "960");
|
||||
}
|
||||
field(ui, d, "Seconds per slide:", "gifSeconds", "5");
|
||||
let n_text = n.max(1).to_string();
|
||||
field(ui, d, "From slide:", "gifFrom", "1");
|
||||
field(ui, d, "To slide:", "gifTo", &n_text);
|
||||
let forever = check(ui, d, "Loop forever", "gifLoop", true);
|
||||
let hidden = check(ui, d, "Include hidden slides", "gifHidden", false);
|
||||
|
||||
let width = if size == "custom" {
|
||||
let w = num(d, "gifWidth", 960.0).round();
|
||||
if !(f64::from(MIN_WIDTH)..=f64::from(MAX_WIDTH)).contains(&w) {
|
||||
return Err(format!("Width: enter {MIN_WIDTH} to {MAX_WIDTH} pixels"));
|
||||
}
|
||||
json!(w as u32)
|
||||
} else {
|
||||
json!(size)
|
||||
};
|
||||
let seconds = num(d, "gifSeconds", f64::NAN);
|
||||
if deckcraft_engine::gif_export::check_seconds(seconds).is_err() {
|
||||
return Err(format!("Seconds per slide: enter {MIN_SECONDS} to {MAX_SECONDS}"));
|
||||
}
|
||||
let mut p = json!({"width": width, "secondsPerSlide": seconds, "loop": forever, "includeHidden": hidden});
|
||||
let (from, to) = (num(d, "gifFrom", 1.0).round(), num(d, "gifTo", n as f64).round());
|
||||
if !(from >= 1.0 && from <= to && to <= n as f64) {
|
||||
return Err(format!("Slides: choose a range from 1 to {n}"));
|
||||
}
|
||||
if from > 1.0 || to < n as f64 {
|
||||
p["slides"] = json!(((from as usize - 1)..(to as usize)).collect::<Vec<_>>());
|
||||
}
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "theme_colors_tests.rs"]
|
||||
mod theme_colors_tests;
|
||||
@@ -3247,6 +3322,68 @@ mod tests {
|
||||
h
|
||||
}
|
||||
|
||||
/// The Export… dialog (Animated GIF) shows the GIF options and exports through `file.export` (#171).
|
||||
#[test]
|
||||
fn export_dialog_writes_an_animated_gif() {
|
||||
use std::cell::RefCell;
|
||||
|
||||
use egui::vec2;
|
||||
use egui_kittest::kittest::Queryable as _;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SlideApp;
|
||||
let dir = std::env::temp_dir().join(format!("deckcraft-ui-gif-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("deck.gif");
|
||||
let picked = path.to_string_lossy().to_string();
|
||||
let suggested = std::rc::Rc::new(RefCell::new(String::new()));
|
||||
let seen = suggested.clone();
|
||||
let services = crate::Services {
|
||||
pick_save: Some(Box::new(move |s: &str| {
|
||||
*seen.borrow_mut() = s.to_string();
|
||||
Some(picked.clone())
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = deckcraft_engine::Session::with_new();
|
||||
session.execute("slide.new", &json!({})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(vec2(1200.0, 900.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, services),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
harness.state_mut().run("app.exportDialog", json!({})).unwrap();
|
||||
harness.run_steps(2);
|
||||
harness.get_by_label("Animated GIF (.gif)").click_accesskit();
|
||||
harness.run_steps(2);
|
||||
assert_eq!(harness.state().dialog.as_ref().and_then(|d| d.fields.get("fmt")).map(String::as_str), Some("gif"));
|
||||
harness.get_by_label("Seconds per slide:");
|
||||
harness.get_by_label("Loop forever").click_accesskit();
|
||||
harness.run_steps(2);
|
||||
// A range outside the deck keeps the dialog open with a message, and nothing is written.
|
||||
harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "9".into());
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_some());
|
||||
assert!(harness.state().status.as_ref().is_some_and(|(m, _)| m.contains("Slides")), "{:?}", harness.state().status);
|
||||
assert!(!path.exists());
|
||||
harness.state_mut().dialog.as_mut().unwrap().fields.insert("gifTo".into(), "2".into());
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none(), "the dialog closes after exporting");
|
||||
assert!(suggested.borrow().ends_with(".gif"), "{}", suggested.borrow());
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(bytes.starts_with(b"GIF89a"));
|
||||
assert_eq!(u16::from_le_bytes([bytes[6], bytes[7]]), 960, "medium by default");
|
||||
assert!(!bytes.windows(11).any(|w| w == b"NETSCAPE2.0"), "Loop forever unticked: plays once");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_link_opens_as_a_new_web_address() {
|
||||
let seed = seed_from(CurrentLink::None, &[]);
|
||||
|
||||
@@ -132,14 +132,14 @@ fn korean_composes_in_the_notes_pane() {
|
||||
for pre in ["ㄴ", "노", "놑", "노트"] {
|
||||
h.event(preedit(pre));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().notes_buf.2, pre, "the preedit replaces the last one");
|
||||
assert_eq!(h.state().notes_buf.2.text, pre, "the preedit replaces the last one");
|
||||
}
|
||||
let ime = h.output().platform_output.ime.expect("the notes field turns the input method on");
|
||||
let slide = h.state().slide_rect.unwrap();
|
||||
assert!(ime.cursor_rect.min.y > slide.max.y, "candidate window at the notes caret, under the slide");
|
||||
h.event(Event::Ime(ImeEvent::Commit("노트".into())));
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state().notes_buf.2, "노트");
|
||||
assert_eq!(h.state().notes_buf.2.text, "노트");
|
||||
// Focus leaves the field (Escape): the notes are committed as one step.
|
||||
h.key_press(egui::Key::Escape);
|
||||
h.run_steps(2);
|
||||
|
||||
+460
-7
@@ -18,6 +18,7 @@ pub mod fillui;
|
||||
pub mod icons;
|
||||
pub mod media;
|
||||
pub mod menus;
|
||||
pub mod notes;
|
||||
pub mod opening;
|
||||
pub mod panes;
|
||||
mod preview;
|
||||
@@ -36,6 +37,7 @@ pub mod toast;
|
||||
pub mod uifonts;
|
||||
pub mod widgets;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use deckcraft_engine::{Mods, Session, Target, UiRequest};
|
||||
@@ -84,6 +86,8 @@ enum FilePurpose {
|
||||
Picture,
|
||||
Audio,
|
||||
Video,
|
||||
/// Change Picture: the picture replaces the selected one (`picture.change`).
|
||||
ChangePicture,
|
||||
/// A drop, or any other purpose: decide from the name and bytes.
|
||||
Infer,
|
||||
}
|
||||
@@ -183,12 +187,16 @@ fn command_for(purpose: FilePurpose, name: &str, bytes: &[u8]) -> Result<&'stati
|
||||
FilePurpose::Picture => Some("insert.picture"),
|
||||
FilePurpose::Audio => Some("insert.audio"),
|
||||
FilePurpose::Video => Some("insert.video"),
|
||||
FilePurpose::ChangePicture => deckcraft_engine::cmd::insert::content_type(name, bytes).starts_with("image/").then_some("picture.change"),
|
||||
FilePurpose::Open => open_command(name),
|
||||
FilePurpose::Infer => infer_command(name, bytes),
|
||||
};
|
||||
cmd.ok_or_else(|| format!("{name}: DeckCraft can't open this kind of file"))
|
||||
}
|
||||
|
||||
/// How long a transient status message stays visible.
|
||||
const STATUS_LINGER_MS: f64 = 6000.0;
|
||||
|
||||
/// Platform services injected by the host (desktop or web).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
@@ -390,6 +398,7 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
|
||||
("app.openDialog", "Open…", None, "{purpose?}"),
|
||||
("app.insertPictureDialog", "Picture from File…", None, "{}"),
|
||||
("app.smartArtPictureDialog", "Insert Picture in SmartArt…", None, "{id?, index}"),
|
||||
("app.changePictureDialog", "Change Picture…", None, "{}"),
|
||||
("app.insertAudioDialog", "Audio from File…", None, "{}"),
|
||||
("app.reuseSlidesDialog", "Reuse Slides…", None, "{keepFormatting?: bool}"),
|
||||
("app.insertVideoDialog", "Video from File…", None, "{}"),
|
||||
@@ -480,7 +489,7 @@ pub struct SlideApp {
|
||||
pub last_recovery: f64,
|
||||
/// Quit was confirmed (changes saved or discarded): let the window close.
|
||||
pub quit_confirmed: bool,
|
||||
pub(crate) notes_buf: (u64, usize, String),
|
||||
pub(crate) notes_buf: (u64, usize, notes::Draft),
|
||||
/// Faces borrowed from the font database for text the interface fonts can't draw (#101).
|
||||
pub ui_fonts: uifonts::UiFonts,
|
||||
/// Thumbnail pane drag (slide index, current drop index).
|
||||
@@ -493,6 +502,11 @@ pub struct SlideApp {
|
||||
pub sorter: sorter::State,
|
||||
/// The slide thumbnail pane was the last thing clicked, so ⌘A selects slides, not shapes.
|
||||
pub(crate) slides_focused: bool,
|
||||
/// A V key press or a Paste event arrived since the last V release (see `keyboard`).
|
||||
v_seen: bool,
|
||||
/// Which recent paths exist on disk, probed once per path per session (a dead network share
|
||||
/// must not stall every frame). Not persisted.
|
||||
recent_checked: HashMap<String, bool>,
|
||||
/// A trackpad twist is in progress (one undo step when it ends).
|
||||
twisting: bool,
|
||||
/// Next cancellation identity for a browser pick or a dropped file.
|
||||
@@ -554,13 +568,15 @@ impl SlideApp {
|
||||
last_time: 0.0,
|
||||
last_recovery: 0.0,
|
||||
quit_confirmed: false,
|
||||
notes_buf: (0, usize::MAX, String::new()),
|
||||
notes_buf: (0, usize::MAX, notes::Draft::default()),
|
||||
ui_fonts: uifonts::UiFonts::default(),
|
||||
thumb_drag: None,
|
||||
selpane: selpane::State::default(),
|
||||
doc_window: None,
|
||||
sorter: sorter::State::default(),
|
||||
slides_focused: false,
|
||||
v_seen: false,
|
||||
recent_checked: HashMap::new(),
|
||||
twisting: false,
|
||||
next_file_id: 1,
|
||||
pending_files: Vec::new(),
|
||||
@@ -582,6 +598,56 @@ impl SlideApp {
|
||||
self.toast = Some(toast::Toast { text: msg.into(), at: now_ms() });
|
||||
}
|
||||
|
||||
/// The transient status message while it is fresh; clears it once stale. Both the status bar
|
||||
/// and the start screen draw it — the start screen has no status bar, and errors raised there
|
||||
/// (a missing recent file, a failed sample) used to vanish (issue #172).
|
||||
pub fn fresh_status(&mut self) -> Option<String> {
|
||||
let (msg, at) = self.status.clone()?;
|
||||
if now_ms() - at < STATUS_LINGER_MS {
|
||||
Some(msg)
|
||||
} else {
|
||||
self.status = None;
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this recent path exist on disk? Probed once per path per session and only on
|
||||
/// desktop (the web build has no file system to ask): a stat on a dead network share can
|
||||
/// block for seconds, so it must not repeat on every frame. A stale answer is harmless —
|
||||
/// opening still tries and reports, and a successful open re-probes.
|
||||
pub fn recent_exists(&mut self, path: &str) -> bool {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = path;
|
||||
true
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Some(ok) = self.recent_checked.get(path) {
|
||||
return *ok;
|
||||
}
|
||||
// A stat on a dead network location (UNC share, URL) can block for the OS timeout,
|
||||
// so remote-looking paths are never probed: they stay unmarked and are judged when
|
||||
// opened. A disconnected mapped drive would still stall once — accepted.
|
||||
if path.starts_with("\\\\") || path.contains("://") {
|
||||
return true;
|
||||
}
|
||||
let ok = std::path::Path::new(path).is_file();
|
||||
if self.recent_checked.len() > 64 {
|
||||
let live: std::collections::HashSet<&String> = self.ui.recent.iter().collect();
|
||||
self.recent_checked.retain(|p, _| live.contains(p));
|
||||
}
|
||||
self.recent_checked.insert(path.to_string(), ok);
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop a path from the Recent list (its file is gone or the user asked for it).
|
||||
pub fn forget_recent(&mut self, path: &str) {
|
||||
self.ui.recent.retain(|p| p != path);
|
||||
self.recent_checked.remove(path);
|
||||
}
|
||||
|
||||
/// Run a command by id (UI commands here, everything else in the engine). Errors show in the
|
||||
/// status bar and are returned.
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
@@ -676,6 +742,7 @@ impl SlideApp {
|
||||
"pptx" => ("pptx", "pptx", false),
|
||||
"outline" | "txt" => ("txt", "outline", false),
|
||||
"deckcraft" => ("deckcraft", "deckcraft", false),
|
||||
"gif" => ("gif", "gif", false),
|
||||
other => {
|
||||
self.set_status(format!("DeckCraft can't export “{other}”"));
|
||||
return false;
|
||||
@@ -699,12 +766,16 @@ impl SlideApp {
|
||||
let Some(doc) = self.session.active().map(|d| d.doc.clone()) else { return false };
|
||||
let bytes = match format {
|
||||
"png" | "jpeg" => {
|
||||
let scale = 2.0;
|
||||
let img = deckcraft_render::render_slide(&doc, slide, &deckcraft_render::RenderOpts { scale, ..Default::default() });
|
||||
let dpi = deckcraft_render::dpi_for_scale(scale);
|
||||
Ok(if format == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) })
|
||||
// Every slide for "all slides", one download each, named like the desktop export (#64).
|
||||
let name = self.session.active().map(|d| d.title()).unwrap_or_else(|| "Presentation".into());
|
||||
let mut any = false;
|
||||
for (file, bytes) in web_image_files(&doc, slide, all, &name, ext) {
|
||||
any = self.download(&file, Ok(bytes)) || any;
|
||||
}
|
||||
return any;
|
||||
}
|
||||
"pdf" => deckcraft_engine::cmd::file::pdf_bytes(&doc, &extra),
|
||||
"gif" => deckcraft_engine::cmd::file::gif_bytes(&doc, &extra),
|
||||
other => deckcraft_engine::cmd::file::save_bytes(&doc, other),
|
||||
};
|
||||
self.download(&suggested, bytes)
|
||||
@@ -937,6 +1008,7 @@ impl SlideApp {
|
||||
}
|
||||
self.pick_and_run("picture", "smartArt.picture", p);
|
||||
}
|
||||
"app.changePictureDialog" => self.pick_and_insert("picture", "picture.change"),
|
||||
"app.insertAudioDialog" => self.pick_and_insert("audio", "insert.audio"),
|
||||
"app.reuseSlidesDialog" => self.pick_and_reuse(p.get("keepFormatting").and_then(Value::as_bool).unwrap_or(false)),
|
||||
"app.insertVideoDialog" => self.pick_and_insert("video", "insert.video"),
|
||||
@@ -1114,6 +1186,8 @@ impl SlideApp {
|
||||
|
||||
/// A presentation was opened from `path`: first in the recent list, shown in Normal view.
|
||||
pub(crate) fn note_recent(&mut self, path: &str) {
|
||||
self.status = None; // an older error (a missing file just before this one) must not linger
|
||||
self.recent_checked.remove(path);
|
||||
self.ui.recent.retain(|p| p != path);
|
||||
self.ui.recent.insert(0, path.to_string());
|
||||
self.ui.recent.truncate(RECENT_MAX);
|
||||
@@ -1168,6 +1242,12 @@ impl SlideApp {
|
||||
}
|
||||
} else if self.services.open_async.is_some() {
|
||||
let id = self.begin_external_file(purpose);
|
||||
// Change Picture: the picture that arrives replaces the selected one (#115).
|
||||
if cmd == "picture.change"
|
||||
&& let Some(r) = self.pending_files.iter_mut().find(|r| r.id == id)
|
||||
{
|
||||
r.purpose = FilePurpose::ChangePicture;
|
||||
}
|
||||
if let Some(f) = self.services.open_async.as_mut() {
|
||||
f(purpose, id);
|
||||
}
|
||||
@@ -1562,10 +1642,25 @@ impl SlideApp {
|
||||
|
||||
/// Keyboard and clipboard for the slide (when no text field has focus).
|
||||
fn keyboard(&mut self, ctx: &egui::Context) {
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
// Ctrl+V with only an image on the clipboard: egui-winit sends no Paste event and swallows the
|
||||
// key press, so the release of a V press never seen is the paste. Tracked even while a text
|
||||
// field has focus, so that a V typed there is not taken for one.
|
||||
let mut image_paste = false;
|
||||
for e in &events {
|
||||
match e {
|
||||
egui::Event::Paste(_) | egui::Event::Key { key: egui::Key::V, pressed: true, .. } => self.v_seen = true,
|
||||
egui::Event::Key { key: egui::Key::V, pressed: false, modifiers, .. } => {
|
||||
// Only a release with Command/Ctrl held is a paste: a bare V release is not.
|
||||
let seen = std::mem::take(&mut self.v_seen);
|
||||
image_paste = !seen && (modifiers.command || modifiers.ctrl);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if ctx.memory(|m| m.focused().is_some()) || self.dialog.as_ref().is_some_and(|d| d.modal()) || self.palette.is_some() {
|
||||
return;
|
||||
}
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
if self.backstage.is_some() {
|
||||
// The File tab: its own keys (Esc goes back, arrows walk the side menu) and app
|
||||
// shortcuts (New, Open, Command Palette…); typing, pasting or deleting would edit the
|
||||
@@ -1588,6 +1683,13 @@ impl SlideApp {
|
||||
self.drain_requests();
|
||||
return;
|
||||
}
|
||||
let img = if image_paste { self.services.clipboard_image.as_mut().and_then(|f| f()) } else { None };
|
||||
if let Some(png) = img {
|
||||
let data = deckcraft_engine::cmd::base64_encode(&png);
|
||||
if let Err(e) = self.session.execute("insert.picture", &json!({"name": "Pasted image.png", "data": data})) {
|
||||
self.set_status(e.to_string());
|
||||
}
|
||||
}
|
||||
for e in events {
|
||||
match e {
|
||||
egui::Event::Text(t) => {
|
||||
@@ -1624,6 +1726,12 @@ impl SlideApp {
|
||||
}
|
||||
}
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } => {
|
||||
if key == egui::Key::Tab {
|
||||
// The slide has the keyboard, so Tab is ours (indent, tab character, next
|
||||
// shape). Left to egui it would also focus the first ribbon widget, and
|
||||
// every key after it would go there instead of the slide (#78).
|
||||
ctx.memory_mut(|m| m.move_focus(egui::FocusDirection::None));
|
||||
}
|
||||
if key == egui::Key::Escape && self.anim_preview.is_some() && !modifiers.command && !modifiers.ctrl && !modifiers.alt {
|
||||
self.anim_preview = None;
|
||||
continue;
|
||||
@@ -1874,6 +1982,25 @@ pub fn now_ms() -> f64 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Images for the browser download: the current slide, or every slide (named `<name>1.png`,
|
||||
/// `<name>2.png`... like the native export) when `all`.
|
||||
fn web_image_files(doc: &deckcraft_model::Presentation, current: usize, all: bool, name: &str, ext: &str) -> Vec<(String, Vec<u8>)> {
|
||||
let slides: Vec<usize> = if all { (0..doc.slides.len()).collect() } else { vec![current] };
|
||||
let single = slides.len() == 1;
|
||||
let scale = 2.0;
|
||||
let dpi = deckcraft_render::dpi_for_scale(scale);
|
||||
slides
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(k, i)| {
|
||||
let img = deckcraft_render::render_slide(doc, i, &deckcraft_render::RenderOpts { scale, ..Default::default() });
|
||||
let bytes = if ext == "png" { img.to_png_at(dpi) } else { img.to_jpeg_at(92, Some(dpi)) };
|
||||
let file = if single { format!("{name}.{ext}") } else { format!("{name}{}.{ext}", k + 1) };
|
||||
(file, bytes)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn js_now() -> f64 {
|
||||
js_sys::Date::now()
|
||||
@@ -1912,6 +2039,7 @@ mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use super::*;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
|
||||
fn shape_rot(app: &SlideApp) -> f64 {
|
||||
let d = app.session.active().unwrap();
|
||||
@@ -1957,6 +2085,23 @@ mod tests {
|
||||
assert!((shape_rot(harness.state()) - 0.0).abs() < 0.01, "one undo, got {}", shape_rot(harness.state()));
|
||||
}
|
||||
|
||||
/// The browser "PNG images (all slides)" export yields one image per slide, not just the selected one (#64).
|
||||
#[test]
|
||||
fn web_export_all_slides_gives_one_file_per_slide() {
|
||||
let mut doc = deckcraft_model::Presentation::default();
|
||||
if let Some(first) = doc.slides.first().cloned() {
|
||||
doc.slides.push(first.clone());
|
||||
doc.slides.push(first);
|
||||
}
|
||||
assert_eq!(doc.slides.len(), 3);
|
||||
let all = web_image_files(&doc, 1, true, "Deck", "png");
|
||||
let names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert_eq!(names, ["Deck1.png", "Deck2.png", "Deck3.png"]);
|
||||
let one = web_image_files(&doc, 1, false, "Deck", "png");
|
||||
assert_eq!(one.len(), 1);
|
||||
assert_eq!(one[0].0, "Deck.png");
|
||||
}
|
||||
|
||||
fn title_text(app: &SlideApp) -> String {
|
||||
let d = app.session.active().unwrap();
|
||||
let sh = &d.doc.slides[0].shapes[0];
|
||||
@@ -2028,6 +2173,61 @@ mod tests {
|
||||
assert!(harness.state().toast.is_none());
|
||||
}
|
||||
|
||||
/// #115: Change Picture replaced nothing — it inserted a second picture.
|
||||
#[test]
|
||||
fn change_picture_replaces_the_selected_picture() {
|
||||
let svg = |c: &str| {
|
||||
format!("<svg xmlns='http://www.w3.org/2000/svg' width='4' height='4'><rect width='4' height='4' fill='{c}'/></svg>").into_bytes()
|
||||
};
|
||||
let media = |app: &SlideApp| {
|
||||
let d = app.session.active().unwrap();
|
||||
let pics: Vec<_> = d.doc.slides[0]
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|s| if let deckcraft_model::ShapeKind::Picture { fill } = &s.kind { Some(fill.media) } else { None })
|
||||
.collect();
|
||||
assert_eq!(pics.len(), 1, "one picture, replaced in place");
|
||||
pics[0]
|
||||
};
|
||||
let insert = |app: &mut SlideApp| {
|
||||
let id =
|
||||
app.session.execute("insert.picture", &json!({"name": "a.svg", "data": deckcraft_engine::cmd::base64_encode(&svg("red"))})).unwrap()
|
||||
["id"]
|
||||
.clone();
|
||||
app.session.execute("edit.select", &json!({"ids": [id]})).unwrap();
|
||||
media(app)
|
||||
};
|
||||
|
||||
// Desktop: the file dialog returns a path.
|
||||
let blue = svg("blue");
|
||||
let services = Services {
|
||||
pick_open: Some(Box::new(|_: &str| Some("b.svg".into()))),
|
||||
read: Some(Box::new(move |_: &str| Ok(blue.clone()))),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SlideApp::new(Session::with_new(), services);
|
||||
let before = insert(&mut app);
|
||||
app.run("app.changePictureDialog", json!({})).unwrap();
|
||||
assert_ne!(media(&app), before);
|
||||
|
||||
// Web: the browser picker delivers the file through the inbox later.
|
||||
let inbox = Inbox::default();
|
||||
let started = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let record = started.clone();
|
||||
let services = Services {
|
||||
open_async: Some(Box::new(move |_: &str, id| record.lock().unwrap().push(id))),
|
||||
inbox: Some(inbox.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let mut app = SlideApp::new(Session::with_new(), services);
|
||||
let before = insert(&mut app);
|
||||
app.run("app.changePictureDialog", json!({})).unwrap();
|
||||
let id = started.lock().unwrap()[0];
|
||||
inbox.lock().unwrap().push(InboxEvent::File(InboxFile { id, name: "b.svg".into(), bytes: svg("blue") }));
|
||||
app.drain_inbox();
|
||||
assert_ne!(media(&app), before);
|
||||
}
|
||||
|
||||
/// #17: Home created a new blank presentation instead of going to the start screen.
|
||||
#[test]
|
||||
fn home_shows_the_start_screen_over_the_open_presentation() {
|
||||
@@ -2176,6 +2376,88 @@ mod tests {
|
||||
assert_eq!(harness.state().dialog.as_ref().map(|d| d.id.as_str()), Some("export"));
|
||||
}
|
||||
|
||||
/// #53: Ctrl+V did nothing for an image-only clipboard: egui-winit sends no Paste event for it and
|
||||
/// swallows the key press, so only the V release reaches the app.
|
||||
#[test]
|
||||
fn ctrl_v_pastes_an_image_when_no_paste_event_arrives() {
|
||||
let session = Session::with_new();
|
||||
let png = {
|
||||
let d = session.active().unwrap();
|
||||
deckcraft_engine::cmd::file::render_png(&d.doc, 0, 0.1, false).0
|
||||
};
|
||||
assert!(!png.is_empty());
|
||||
let services = Services { clipboard_image: Some(Box::new(move || Some(png.clone()))), ..Default::default() };
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, services),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let pictures = |app: &SlideApp| {
|
||||
let d = app.session.active().unwrap();
|
||||
d.doc.slides[0].shapes.iter().filter(|s| matches!(s.kind, deckcraft_model::ShapeKind::Picture { .. })).count()
|
||||
};
|
||||
let v = |pressed| egui::Event::Key { key: egui::Key::V, physical_key: None, pressed, repeat: false, modifiers: egui::Modifiers::COMMAND };
|
||||
// A text paste (Paste event on the press) does not also paste the image on release.
|
||||
harness.event(egui::Event::Paste("hello".into()));
|
||||
harness.run_steps(2);
|
||||
harness.event(v(false));
|
||||
harness.run_steps(2);
|
||||
assert_eq!(pictures(harness.state()), 0);
|
||||
// A V release without Command/Ctrl is not a paste.
|
||||
harness.event(egui::Event::Key { key: egui::Key::V, physical_key: None, pressed: false, repeat: false, modifiers: egui::Modifiers::NONE });
|
||||
harness.run_steps(2);
|
||||
assert_eq!(pictures(harness.state()), 0);
|
||||
// What egui-winit sends for Ctrl+V with only an image on the clipboard: the release alone.
|
||||
harness.event(v(false));
|
||||
harness.run_steps(2);
|
||||
assert_eq!(pictures(harness.state()), 1);
|
||||
}
|
||||
|
||||
/// #78: after Tab in a text box, typing and deleting no longer reached the text.
|
||||
#[test]
|
||||
fn typing_goes_on_after_tab_in_a_text_box() {
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
{
|
||||
let mut session = Session::with_new();
|
||||
session.prefs.autocorrect = false;
|
||||
SlideApp::new(session, Services::default())
|
||||
},
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
harness.event(egui::Event::Text("ab".into()));
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Tab);
|
||||
harness.run_steps(2);
|
||||
harness.event(egui::Event::Text("c".into()));
|
||||
harness.run_steps(2);
|
||||
assert_eq!(title_text(harness.state()), "ab\tc");
|
||||
harness.key_press(egui::Key::Backspace);
|
||||
harness.run_steps(2);
|
||||
assert_eq!(title_text(harness.state()), "ab\t");
|
||||
assert!(harness.state().session.active().unwrap().selection.text.is_some(), "still editing");
|
||||
// Tab at the start of a paragraph indents it, and typing goes on there too (#93).
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.run_steps(2);
|
||||
harness.key_press(egui::Key::Tab);
|
||||
harness.run_steps(2);
|
||||
harness.event(egui::Event::Text("d".into()));
|
||||
harness.run_steps(2);
|
||||
assert_eq!(title_text(harness.state()), "ab\t\nd");
|
||||
let level = |h: &egui_kittest::Harness<SlideApp>| {
|
||||
h.state().session.active().unwrap().doc.slides[0].shapes[0].text.as_ref().unwrap().paragraphs[1].level
|
||||
};
|
||||
assert_eq!(level(&harness), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_needs_an_open_presentation() {
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
@@ -2504,6 +2786,177 @@ mod tests {
|
||||
assert!(!st.selection.shapes.is_empty(), "canvas select all picks the shapes");
|
||||
}
|
||||
|
||||
fn harness() -> egui_kittest::Harness<'static, SlideApp> {
|
||||
egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::new(), Services::default()),
|
||||
)
|
||||
}
|
||||
|
||||
/// #172: a recent file that no longer exists is marked on File › Home, and clicking it draws
|
||||
/// the open error right there (the status bar is hidden while the backstage is open, so the
|
||||
/// message used to vanish).
|
||||
#[test]
|
||||
fn missing_recent_is_marked_and_its_error_shows() {
|
||||
let mut harness = harness();
|
||||
let missing = "/deckcraft-tests/172/missing.deckcraft";
|
||||
harness.state_mut().ui.recent = vec![missing.to_string()];
|
||||
harness.run_steps(2);
|
||||
// The row is labelled with the file's name. Clicking it raises the open error, and the
|
||||
// drawn message is a second node that mentions the name (the OS text after it varies).
|
||||
harness.get_by_label("missing.deckcraft").click();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().toast.is_some(), "the open error was raised");
|
||||
assert!(harness.get_all_by_label_contains("missing.deckcraft").count() >= 2, "the open error is drawn on the page, next to the entry");
|
||||
}
|
||||
|
||||
/// #172: a Recent entry can be removed from the list.
|
||||
#[test]
|
||||
fn recent_entry_can_be_removed() {
|
||||
let mut harness = harness();
|
||||
harness.state_mut().ui.recent = vec!["/deckcraft-tests/172/a.deckcraft".into(), "/deckcraft-tests/172/b.deckcraft".into()];
|
||||
harness.run_steps(2);
|
||||
let removes = harness.get_all_by_label("Remove from Recent").collect::<Vec<_>>();
|
||||
assert_eq!(removes.len(), 2);
|
||||
// The remove button must sit inside the visible part of the row even when the page's
|
||||
// content is wider than the window.
|
||||
assert!(removes[1].rect().right() < 1200.0, "reachable in the 1200-wide window: {:?}", removes[1].rect());
|
||||
removes[1].click();
|
||||
harness.run_steps(1);
|
||||
assert_eq!(harness.state().ui.recent, vec!["/deckcraft-tests/172/a.deckcraft"]);
|
||||
}
|
||||
|
||||
/// The existence probe tells present files from missing ones and never touches remote paths.
|
||||
#[test]
|
||||
fn recent_exists_tells_missing_from_present() {
|
||||
let mut app = SlideApp::new(Session::new(), Services::default());
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!("deckcraft-test-172-{}.deckcraft", std::process::id()));
|
||||
std::fs::write(&path, b"").unwrap();
|
||||
assert!(app.recent_exists(&path.to_string_lossy()), "the file exists");
|
||||
assert!(!app.recent_exists("/deckcraft-tests/172/missing.deckcraft"));
|
||||
assert!(app.recent_exists("\\\\server/share/deck.deckcraft"), "UNC paths are never probed");
|
||||
std::fs::remove_file(&path).ok();
|
||||
}
|
||||
|
||||
/// #339: clicking a language in Review > Language did nothing, and OK didn't mark the text.
|
||||
#[test]
|
||||
fn the_language_dialog_marks_the_selected_text() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(Session::with_new(), Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let title = harness.state().session.active().unwrap().doc.slides[0].shapes[0].id.0;
|
||||
harness.state_mut().session.execute("text.edit", &json!({"id": title})).unwrap();
|
||||
harness.state_mut().session.execute("text.insert", &json!({"text": "Bonjour"})).unwrap();
|
||||
harness.state_mut().session.execute("text.selectAll", &json!({})).unwrap();
|
||||
harness.state_mut().run("app.dialog", json!({"id": "language"})).unwrap();
|
||||
// The anchored window settles over its first frames; click once it stays put.
|
||||
harness.run_steps(4);
|
||||
harness.get_by_label("Français (France)").click();
|
||||
harness.run_steps(2);
|
||||
harness.get_by_label("OK").click();
|
||||
harness.run_steps(2);
|
||||
assert!(harness.state().dialog.is_none());
|
||||
let d = harness.state().session.active().unwrap();
|
||||
let body = d.doc.slides[0].shapes[0].text.as_ref().unwrap();
|
||||
assert_eq!(body.paragraphs[0].runs[0].props.lang.as_deref(), Some("fr-FR"));
|
||||
}
|
||||
|
||||
/// Speaker notes keep their formatting when edited in the notes pane: typing into a bold
|
||||
/// heading, Enter in a bulleted point, then saving as .pptx and reopening.
|
||||
#[test]
|
||||
fn editing_notes_in_the_pane_keeps_their_formatting() {
|
||||
use deckcraft_model::text::{Bullet, ParaProps, Paragraph, Run, RunProps, TextBody};
|
||||
let point = |runs: Vec<Run>| Paragraph {
|
||||
level: 1,
|
||||
props: ParaProps {
|
||||
margin_left: Some(22.5),
|
||||
indent: Some(-22.5),
|
||||
bullet: Some(Bullet::Char { char: "§".into() }),
|
||||
bullet_font: Some("Wingdings".into()),
|
||||
..Default::default()
|
||||
},
|
||||
runs,
|
||||
..Default::default()
|
||||
};
|
||||
let bold = RunProps { bold: Some(true), ..Default::default() };
|
||||
let notes = TextBody {
|
||||
paragraphs: vec![
|
||||
Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() },
|
||||
Paragraph::new("Žiaci pracujú vo dvojiciach."),
|
||||
point(vec![Run::with("Cieľ: ", bold.clone()), Run::new("vysvetliť cyklus")]),
|
||||
point(vec![Run::new("Reflexia")]),
|
||||
Paragraph::new("Pomôcky: počítač"),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let mut session = Session::with_new();
|
||||
session.execute("slide.notes", &json!({"index": 0, "body": notes})).unwrap();
|
||||
let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
app.logic(ui.ctx());
|
||||
app.ui(ui);
|
||||
},
|
||||
SlideApp::new(session, Services::default()),
|
||||
);
|
||||
harness.run_steps(2);
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let caret = |h: &mut egui_kittest::Harness<'_, SlideApp>, at: usize| {
|
||||
let mut st = egui::TextEdit::load_state(&h.ctx, id).unwrap_or_default();
|
||||
st.cursor.set_char_range(Some(egui::text::CCursorRange::one(egui::text::CCursor::new(at))));
|
||||
egui::TextEdit::store_state(&h.ctx, id, st);
|
||||
};
|
||||
let chars = |s: &str, of: &str| s.find(of).map(|b| s[..b].chars().count() + of.chars().count()).unwrap();
|
||||
let text = notes.text();
|
||||
harness.ctx.memory_mut(|m| m.request_focus(id));
|
||||
harness.run_steps(1);
|
||||
// Type into the bold heading.
|
||||
caret(&mut harness, chars(&text, "prehľad"));
|
||||
harness.run_steps(1);
|
||||
harness.event(egui::Event::Text(" hodiny".into()));
|
||||
harness.run_steps(1);
|
||||
// Enter after a bulleted point, and type the next one.
|
||||
let text = text.replacen("prehľad", "prehľad hodiny", 1);
|
||||
caret(&mut harness, chars(&text, "cyklus"));
|
||||
harness.run_steps(1);
|
||||
harness.key_press(egui::Key::Enter);
|
||||
harness.event(egui::Event::Text("Nový bod".into()));
|
||||
harness.run_steps(1);
|
||||
// Leave the field: the notes are saved.
|
||||
harness.ctx.memory_mut(|m| m.surrender_focus(id));
|
||||
harness.run_steps(2);
|
||||
let check = |n: &TextBody| {
|
||||
assert_eq!(
|
||||
n.text(),
|
||||
"Metodický prehľad hodiny\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nNový bod\nReflexia\nPomôcky: počítač"
|
||||
);
|
||||
assert_eq!(n.paragraphs[0].runs.len(), 1, "{n:?}");
|
||||
assert_eq!(n.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
assert_eq!(n.paragraphs[1].props, ParaProps::default());
|
||||
assert_eq!(n.paragraphs[2].runs[0].props.bold, Some(true), "the bold label");
|
||||
assert_eq!(n.paragraphs[2].runs[1].props.bold, None);
|
||||
for i in [2, 3, 4] {
|
||||
assert_eq!((n.paragraphs[i].level, &n.paragraphs[i].props), (1, ¬es.paragraphs[2].props), "point {i}");
|
||||
}
|
||||
assert_eq!((n.paragraphs[5].level, &n.paragraphs[5].props), (0, &ParaProps::default()));
|
||||
};
|
||||
check(&harness.state().session.active().unwrap().doc.slides[0].notes);
|
||||
// Saved as .pptx and reopened.
|
||||
let s = &mut harness.state_mut().session;
|
||||
let data = s.execute("file.saveBytes", &json!({"format": "pptx"})).unwrap()["data"].clone();
|
||||
s.execute("file.openBytes", &json!({"name": "notes.pptx", "data": data})).unwrap();
|
||||
check(&s.active().unwrap().doc.slides[0].notes);
|
||||
}
|
||||
|
||||
fn step_app(services: Services) -> egui_kittest::Harness<'static, SlideApp> {
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_ui_state(
|
||||
|ui, app: &mut SlideApp| {
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Speaker notes editing (the notes pane and Notes Page view). The notes are edited as text and
|
||||
//! drawn with their formatting: bold, italic, underline, strikethrough and size from the runs,
|
||||
//! indents (wrapped lines included) and bullets or numbers from the paragraphs. Each edit is
|
||||
//! applied to a formatted draft, so typed text takes the formatting where it lands and the rest
|
||||
//! keeps its own; the draft is saved with `slide.notes {body}` once the editor loses focus.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use deckcraft_model::edit;
|
||||
use deckcraft_model::text::{Bullet, Paragraph, RunProps, Strike, TextBody};
|
||||
use egui::text::{CCursor, Galley, LayoutJob, TextFormat};
|
||||
use egui::{Align2, Color32, Stroke, Ui, vec2};
|
||||
|
||||
use crate::theme;
|
||||
|
||||
/// Size and colour of a notes editor's text.
|
||||
pub struct Look {
|
||||
/// Pixels for 12 pt text, the notes' default size; indents scale with it.
|
||||
pub size: f32,
|
||||
/// `None` = the widget text colour.
|
||||
pub color: Option<Color32>,
|
||||
}
|
||||
|
||||
/// Notes being edited: the text in the editor and the formatted notes it stands for.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Draft {
|
||||
pub text: String,
|
||||
pub body: TextBody,
|
||||
/// Edited since it was saved.
|
||||
pub edited: bool,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
pub fn new(body: &TextBody) -> Self {
|
||||
Draft { text: body.text(), body: body.clone(), edited: false }
|
||||
}
|
||||
}
|
||||
|
||||
/// The draft as it reads with `text` typed in (one edit since the draft): `None` when that can't
|
||||
/// be mapped onto it.
|
||||
fn typed(body: &TextBody, text: &str) -> Option<TextBody> {
|
||||
let mut b = body.clone();
|
||||
edit::replace_text(&mut b, text);
|
||||
(b.text() == text).then_some(b)
|
||||
}
|
||||
|
||||
/// A length in points from the file as pixels, bounded.
|
||||
fn px(pt: f64, k: f32) -> f32 {
|
||||
if pt.is_finite() { pt.clamp(0.0, 400.0) as f32 * k } else { 0.0 }
|
||||
}
|
||||
|
||||
/// Where a paragraph's first line starts and its other lines start (points): a level without its
|
||||
/// own margin is indented 36 pt per level, as in the default notes master.
|
||||
fn indents(p: &Paragraph) -> (f64, f64) {
|
||||
let margin = p.props.margin_left.unwrap_or(36.0 * f64::from(p.level.min(8)));
|
||||
let first = margin + p.props.indent.unwrap_or(0.0);
|
||||
(first, margin)
|
||||
}
|
||||
|
||||
/// Each paragraph's bullet or number, numbered as on a slide (per level, restarting after an
|
||||
/// unnumbered paragraph at the same or a lower level).
|
||||
fn bullets(body: &TextBody) -> Vec<Option<String>> {
|
||||
let mut numbering = [0u32; 9];
|
||||
body.paragraphs
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let lvl = usize::from(p.level.min(8));
|
||||
if p.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let auto = matches!(p.props.bullet, Some(Bullet::AutoNum { .. }));
|
||||
for (i, n) in numbering.iter_mut().enumerate().skip(lvl) {
|
||||
if i > lvl || !auto {
|
||||
*n = 0;
|
||||
}
|
||||
}
|
||||
match p.props.bullet.as_ref()? {
|
||||
Bullet::None => None,
|
||||
Bullet::Char { char } => Some(deckcraft_text::map_symbol_bullet(char, p.props.bullet_font.as_deref())),
|
||||
Bullet::AutoNum { scheme, start_at } => {
|
||||
let n = numbering.get_mut(lvl)?;
|
||||
*n = n.saturating_add(1);
|
||||
Some(deckcraft_text::autonum(scheme, start_at.saturating_sub(1).saturating_add(*n)))
|
||||
}
|
||||
Bullet::Picture { .. } => Some("•".into()),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format(props: &RunProps, size: f32, color: Color32) -> TextFormat {
|
||||
let size = match props.size {
|
||||
Some(pt) if pt.is_finite() && pt > 0.0 => (size * pt as f32 / 12.0).clamp(size * 0.75, size * 2.0),
|
||||
_ => size,
|
||||
};
|
||||
let font = if props.bold == Some(true) { theme::bold(size) } else { theme::font(size) };
|
||||
let mut f = TextFormat::simple(font, color);
|
||||
f.italics = props.italic == Some(true);
|
||||
if props.underline.as_deref().is_some_and(|u| u != "none") {
|
||||
f.underline = Stroke::new(1.0, color);
|
||||
}
|
||||
if matches!(props.strike, Some(Strike::Single | Strike::Double)) {
|
||||
f.strikethrough = Stroke::new(1.0, color);
|
||||
}
|
||||
f
|
||||
}
|
||||
|
||||
/// One paragraph to lay out: wrapped lines start at the margin (pixels), the first line's text at
|
||||
/// `text`, clear of a bullet.
|
||||
struct ParaLayout {
|
||||
job: LayoutJob,
|
||||
margin: f32,
|
||||
/// x of the first line's text.
|
||||
text: f32,
|
||||
}
|
||||
|
||||
fn para_layout(p: &Paragraph, bullet: Option<&str>, size: f32, color: Color32, wrap: f32) -> ParaLayout {
|
||||
let k = size / 12.0;
|
||||
let (first, margin) = indents(p);
|
||||
let (first, margin) = (px(first, k), px(margin, k));
|
||||
let text = match bullet {
|
||||
// The text starts at the margin, after the bullet if it's wider than the hanging indent.
|
||||
Some(b) => margin.max(first + size * (0.4 + 0.6 * b.chars().count() as f32)),
|
||||
None => first,
|
||||
};
|
||||
let mut job = LayoutJob::default();
|
||||
job.wrap.max_width = (wrap - margin).max(size * 4.0);
|
||||
let mut lead = (text - margin).max(0.0);
|
||||
for r in &p.runs {
|
||||
let t = r.content();
|
||||
if !t.is_empty() {
|
||||
job.append(t, lead, format(&r.props, size, color));
|
||||
lead = 0.0;
|
||||
}
|
||||
}
|
||||
if job.text.is_empty() {
|
||||
job.append("", 0.0, format(&p.end_props, size, color));
|
||||
}
|
||||
ParaLayout { job, margin, text }
|
||||
}
|
||||
|
||||
/// The whole notes as one job (the galley's text and formats).
|
||||
fn job(body: &TextBody, size: f32, color: Color32, wrap: f32) -> LayoutJob {
|
||||
let mut job = LayoutJob::default();
|
||||
job.wrap.max_width = wrap;
|
||||
for (i, (p, bullet)) in body.paragraphs.iter().zip(bullets(body)).enumerate() {
|
||||
if i > 0 {
|
||||
job.append("\n", 0.0, TextFormat::simple(theme::font(size), color));
|
||||
}
|
||||
let pl = para_layout(p, bullet.as_deref(), size, color, wrap);
|
||||
for s in pl.job.sections {
|
||||
if let Some(t) = pl.job.text.get(s.byte_range.start.0..s.byte_range.end.0) {
|
||||
job.append(t, s.leading_space, s.format);
|
||||
}
|
||||
}
|
||||
}
|
||||
job
|
||||
}
|
||||
|
||||
/// Lay the notes out paragraph by paragraph, each moved to its indent, so wrapped lines line up
|
||||
/// under the text and not under the bullet.
|
||||
fn galley(ui: &Ui, body: &TextBody, size: f32, color: Color32, wrap: f32) -> Arc<Galley> {
|
||||
let whole = job(body, size, color, wrap);
|
||||
if body.paragraphs.is_empty() {
|
||||
return ui.fonts_mut(|f| f.layout_job(whole));
|
||||
}
|
||||
let parts: Vec<Arc<Galley>> = ui.fonts_mut(|f| {
|
||||
body.paragraphs
|
||||
.iter()
|
||||
.zip(bullets(body))
|
||||
.map(|(p, bullet)| {
|
||||
let pl = para_layout(p, bullet.as_deref(), size, color, wrap);
|
||||
let mut g = (*f.layout_job(pl.job)).clone();
|
||||
for (i, row) in g.rows.iter_mut().enumerate() {
|
||||
// The first line already holds the gap after a bullet; a first line hanging
|
||||
// left of the margin without one starts there.
|
||||
row.pos.x += if i == 0 { pl.margin.min(pl.text) } else { pl.margin };
|
||||
}
|
||||
Arc::new(g)
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
Arc::new(Galley::concat(Arc::new(whole), &parts, ui.ctx().pixels_per_point()))
|
||||
}
|
||||
|
||||
/// A multiline editor for the draft that draws its formatting; `configure` sets the id, hint,
|
||||
/// size and frame. Edits update the draft's text and formatted body. Returns the field's output
|
||||
/// (galley and caret too, for spelling marks).
|
||||
pub fn editor(
|
||||
ui: &mut Ui,
|
||||
draft: &mut Draft,
|
||||
look: &Look,
|
||||
configure: impl for<'a> FnOnce(egui::TextEdit<'a>) -> egui::TextEdit<'a>,
|
||||
) -> egui::text_edit::TextEditOutput {
|
||||
let color = look.color.unwrap_or_else(|| ui.visuals().override_text_color.unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()));
|
||||
let size = look.size;
|
||||
let before = draft.body.clone();
|
||||
let mut layouter = |ui: &Ui, text: &dyn egui::TextBuffer, wrap: f32| match typed(&before, text.as_str()) {
|
||||
Some(b) => galley(ui, &b, size, color, wrap),
|
||||
None => ui.fonts_mut(|f| f.layout_job(LayoutJob::simple(text.as_str().to_string(), theme::font(size), color, wrap))),
|
||||
};
|
||||
let out = configure(egui::TextEdit::multiline(&mut draft.text)).layouter(&mut layouter).show(ui);
|
||||
if draft.body.text() != draft.text {
|
||||
edit::replace_text(&mut draft.body, &draft.text);
|
||||
draft.edited = true;
|
||||
}
|
||||
// Bullets and numbers sit at the start of each paragraph's first line.
|
||||
if draft.body.text() == draft.text {
|
||||
let painter = ui.painter().with_clip_rect(out.text_clip_rect);
|
||||
let k = size / 12.0;
|
||||
let mut at = 0usize;
|
||||
for (p, bullet) in draft.body.paragraphs.iter().zip(bullets(&draft.body)) {
|
||||
if let Some(b) = bullet {
|
||||
let row = out.galley.pos_from_cursor(CCursor::new(at));
|
||||
let pos = out.galley_pos + vec2(px(indents(p).0, k), row.center().y);
|
||||
let font = theme::font(row.height().clamp(size * 0.75, size * 2.0) * 0.8);
|
||||
painter.text(pos, Align2::LEFT_CENTER, b, font, color);
|
||||
}
|
||||
at += p.char_len() + 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use deckcraft_model::text::{ParaProps, Run};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Notes as PowerPoint users write them: a bold heading, a plain line, points with a
|
||||
/// Wingdings square bullet and a hanging indent (one long enough to wrap), numbered points,
|
||||
/// a point with an explicit buNone, closing plain lines; Slovak text throughout.
|
||||
fn notes() -> TextBody {
|
||||
let props = |bullet: Bullet| ParaProps {
|
||||
margin_left: Some(22.5),
|
||||
indent: Some(-22.5),
|
||||
bullet: Some(bullet),
|
||||
bullet_font: Some("Wingdings".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let point = |runs: Vec<Run>, bullet: Bullet| Paragraph { level: 1, props: props(bullet), runs, ..Default::default() };
|
||||
let square = || Bullet::Char { char: "§".into() };
|
||||
let num = || Bullet::AutoNum { scheme: "arabicPeriod".into(), start_at: 1 };
|
||||
let bold = RunProps { bold: Some(true), ..Default::default() };
|
||||
TextBody {
|
||||
paragraphs: vec![
|
||||
Paragraph { runs: vec![Run::with("Metodický prehľad", bold.clone())], ..Default::default() },
|
||||
Paragraph::new("Žiaci pracujú vo dvojiciach."),
|
||||
point(vec![Run::with("Cieľ: ", bold), Run::new("vysvetliť cyklus")], square()),
|
||||
point(vec![Run::new("Dlhý bod, ktorý sa zalomí na viac riadkov, lebo je naozaj veľmi dlhý a pokračuje ďalej.")], square()),
|
||||
point(vec![Run::new("Jeden")], num()),
|
||||
point(vec![Run::new("Dva")], num()),
|
||||
point(vec![Run::new("Bez odrážky")], Bullet::None),
|
||||
Paragraph::new("Pomôcky: počítač"),
|
||||
],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bullets_follow_the_paragraphs() {
|
||||
let b = bullets(¬es());
|
||||
assert_eq!(b, vec![None, None, Some("▪".into()), Some("▪".into()), Some("1.".into()), Some("2.".into()), None, None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notes_show_bold_runs_indents_and_wrap_under_the_text() {
|
||||
let body = notes();
|
||||
let ctx = egui::Context::default();
|
||||
theme::install_fonts(&ctx);
|
||||
let mut laid = None;
|
||||
let mut out = ctx.run_ui(Default::default(), |ui| laid = Some(galley(ui, &body, 12.0, Color32::BLACK, 300.0)));
|
||||
out.textures_delta.clear();
|
||||
let g = laid.unwrap();
|
||||
assert_eq!(g.job.text, body.text(), "the galley reads the notes");
|
||||
assert_eq!(g.rows.iter().map(|r| r.char_count_including_newline().0).sum::<usize>(), body.text().chars().count());
|
||||
// Bold where the runs are bold, regular elsewhere.
|
||||
let font_of = |s: &str| {
|
||||
let b = g.job.text.find(s).unwrap();
|
||||
g.job.sections.iter().find(|x| (x.byte_range.start.0..x.byte_range.end.0).contains(&b)).unwrap().format.font_id.clone()
|
||||
};
|
||||
assert_eq!(font_of("Metodický"), theme::bold(12.0));
|
||||
assert_eq!(font_of("Cieľ"), theme::bold(12.0));
|
||||
assert_eq!(font_of("vysvetliť"), theme::font(12.0));
|
||||
assert_eq!(font_of("Pomôcky"), theme::font(12.0));
|
||||
// Rows: x where each paragraph's text starts.
|
||||
let row_x = |s: &str| {
|
||||
let at = body.text().find(s).map(|b| body.text()[..b].chars().count()).unwrap();
|
||||
g.pos_from_cursor(CCursor::new(at)).min.x
|
||||
};
|
||||
assert!(row_x("Metodický") < 1.0 && row_x("Žiaci") < 1.0 && row_x("Pomôcky") < 1.0, "plain paragraphs aren't indented");
|
||||
assert!((row_x("Cieľ") - 22.5).abs() < 1.0, "bulleted text at the 22.5 pt margin: {}", row_x("Cieľ"));
|
||||
assert!((row_x("Bez odrážky") - 0.0).abs() < 1.0, "buNone: no bullet, the first line hangs to 0");
|
||||
// The long point wraps, and its wrapped lines start at the margin, not under the bullet.
|
||||
let long = body.text().find("Dlhý").map(|b| body.text()[..b].chars().count()).unwrap();
|
||||
let first_row = g.pos_from_cursor(CCursor::new(long));
|
||||
let wrapped: Vec<_> =
|
||||
g.rows.iter().filter(|r| r.pos.y > first_row.min.y + 1.0 && r.pos.y < g.pos_from_cursor(CCursor::new(long + 80)).max.y).collect();
|
||||
assert!(!wrapped.is_empty(), "the long point wraps");
|
||||
assert!(wrapped.iter().all(|r| (r.pos.x - 22.5).abs() < 1.0), "wrapped lines align with the text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_keeps_the_formatting_around_it() {
|
||||
let mut d = Draft::new(¬es());
|
||||
// Typing into the bold heading continues it in bold.
|
||||
let t = d.text.replacen("prehľad", "prehľad hodiny", 1);
|
||||
d.body = typed(&d.body, &t).unwrap();
|
||||
assert_eq!(d.body.paragraphs[0].runs.len(), 1);
|
||||
assert_eq!(d.body.paragraphs[0].runs[0].props.bold, Some(true));
|
||||
// Enter at the end of a bulleted point starts another one with the same bullet and indent.
|
||||
let t = d.body.text().replacen("cyklus", "cyklus\nnový bod", 1);
|
||||
d.body = typed(&d.body, &t).unwrap();
|
||||
let new = &d.body.paragraphs[3];
|
||||
assert_eq!(new.text(), "nový bod");
|
||||
assert_eq!((new.level, &new.props), (1, ¬es().paragraphs[2].props));
|
||||
assert_eq!(d.body.paragraphs[2].runs[0].props.bold, Some(true), "the bold label stays bold");
|
||||
// Hostile sizes and indents stay bounded.
|
||||
let mut wild = notes();
|
||||
wild.paragraphs[2].props.margin_left = Some(f64::NAN);
|
||||
wild.paragraphs[0].runs[0].props.size = Some(f64::INFINITY);
|
||||
let j = job(&wild, 12.0, Color32::BLACK, 300.0);
|
||||
assert!(j.sections.iter().all(|s| s.leading_space.is_finite() && s.format.font_id.size <= 24.0));
|
||||
}
|
||||
}
|
||||
+105
-10
@@ -859,6 +859,10 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
});
|
||||
let mut tr = ((1.0 - color.alpha()) * 100.0) as f32;
|
||||
if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() {
|
||||
run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)}));
|
||||
}
|
||||
}
|
||||
Some(Fill::Gradient(g)) => {
|
||||
current = Some(json!({"gradient": crate::fillui::gradient_json(g)}));
|
||||
@@ -888,7 +892,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(Fill::Picture(_)) => {
|
||||
Some(Fill::Picture(pf)) => {
|
||||
if ui.button("Insert picture from file…").clicked()
|
||||
&& let Some(pick) = app.services.pick_open.as_mut()
|
||||
&& let Some(path) = pick("picture")
|
||||
@@ -896,6 +900,7 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
{
|
||||
let _ = app.run("design.background", json!({"picture": deckcraft_engine::cmd::base64_encode(&bytes)}));
|
||||
}
|
||||
picture_background_options(app, ui, pf);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -906,12 +911,15 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
run(app, "design.hideBackgroundGraphics", json!({"hide": h}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let picture = matches!(fill, Some(Fill::Picture(_)));
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(current.is_some(), egui::Button::new("Apply to All")).clicked()
|
||||
&& let Some(Value::Object(mut o)) = current.clone()
|
||||
{
|
||||
o.insert("all".into(), json!(true));
|
||||
run(app, "design.background", Value::Object(o));
|
||||
if ui.add_enabled(current.is_some() || picture, egui::Button::new("Apply to All")).clicked() {
|
||||
if let Some(Value::Object(mut o)) = current.clone() {
|
||||
o.insert("all".into(), json!(true));
|
||||
run(app, "design.background", Value::Object(o));
|
||||
} else if picture {
|
||||
run(app, "design.background", json!({"copy": true}));
|
||||
}
|
||||
}
|
||||
if ui.button("Reset Background").clicked() {
|
||||
run(app, "design.background", json!({"reset": true}));
|
||||
@@ -919,6 +927,86 @@ fn background(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
|
||||
fn picture_background_options(app: &mut SlideApp, ui: &mut Ui, pf: &deckcraft_model::style::PictureFill) {
|
||||
use deckcraft_model::style::PictureMode;
|
||||
let alpha = pf.alpha.unwrap_or(1.0);
|
||||
let mut tr = ((1.0 - alpha) * 100.0) as f32;
|
||||
if ui.add(egui::Slider::new(&mut tr, 0.0..=100.0).text("Transparency").suffix("%")).drag_stopped() {
|
||||
run(app, "design.background", json!({"transparency": (f64::from(tr) / 100.0).clamp(0.0, 1.0)}));
|
||||
}
|
||||
let mut tile = matches!(pf.mode, PictureMode::Tile { .. });
|
||||
if ui.checkbox(&mut tile, "Tile picture as texture").changed() {
|
||||
run(app, "design.background", json!({"pictureProps": {"tile": tile}}));
|
||||
}
|
||||
let PictureMode::Tile { tx, ty, sx, sy, flip, align } = &pf.mode else { return };
|
||||
for (label, id, value, suffix, scale) in [
|
||||
("Offset X", "bg-ox", *tx, " pt", 1.0),
|
||||
("Offset Y", "bg-oy", *ty, " pt", 1.0),
|
||||
("Scale X", "bg-sx", *sx * 100.0, "%", 0.01),
|
||||
("Scale Y", "bg-sy", *sy * 100.0, "%", 0.01),
|
||||
] {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(72.0, 20.0), egui::Label::new(label));
|
||||
if let Some(v) = widgets::spinner(ui, id, value, if scale < 1.0 { 5.0 } else { 1.0 }, suffix, 64.0) {
|
||||
let key = match id {
|
||||
"bg-ox" => "offsetX",
|
||||
"bg-oy" => "offsetY",
|
||||
"bg-sx" => "scaleX",
|
||||
_ => "scaleY",
|
||||
};
|
||||
run(app, "design.background", json!({"pictureProps": {key: v * scale}}));
|
||||
}
|
||||
});
|
||||
}
|
||||
let align_name = match align.as_str() {
|
||||
"t" => "Top",
|
||||
"tr" => "Top Right",
|
||||
"l" => "Left",
|
||||
"ctr" => "Center",
|
||||
"r" => "Right",
|
||||
"bl" => "Bottom Left",
|
||||
"b" => "Bottom",
|
||||
"br" => "Bottom Right",
|
||||
_ => "Top Left",
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Alignment"));
|
||||
egui::ComboBox::from_id_salt("bg-align").selected_text(align_name).width(140.0).show_ui(ui, |ui| {
|
||||
for (k, l) in [
|
||||
("tl", "Top Left"),
|
||||
("t", "Top"),
|
||||
("tr", "Top Right"),
|
||||
("l", "Left"),
|
||||
("ctr", "Center"),
|
||||
("r", "Right"),
|
||||
("bl", "Bottom Left"),
|
||||
("b", "Bottom"),
|
||||
("br", "Bottom Right"),
|
||||
] {
|
||||
if ui.selectable_label(align == k, l).clicked() {
|
||||
run(app, "design.background", json!({"pictureProps": {"align": k}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
let mirror_name = match flip.as_str() {
|
||||
"x" => "Horizontal",
|
||||
"y" => "Vertical",
|
||||
"xy" => "Both",
|
||||
_ => "None",
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_sized(vec2(72.0, 20.0), egui::Label::new("Mirror"));
|
||||
egui::ComboBox::from_id_salt("bg-mirror").selected_text(mirror_name).width(140.0).show_ui(ui, |ui| {
|
||||
for (k, l) in [("none", "None"), ("x", "Horizontal"), ("y", "Vertical"), ("xy", "Both")] {
|
||||
if ui.selectable_label(flip == k, l).clicked() {
|
||||
run(app, "design.background", json!({"pictureProps": {"mirror": k}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn animation(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else { return };
|
||||
@@ -1251,7 +1339,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let key_doc = (st.uid, st.revision);
|
||||
let w = ui.available_width() - 8.0;
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let px = (w * ppp).max(16.0) as u32;
|
||||
let px = (w * ppp).round().max(16.0) as u32;
|
||||
let ideas = deckcraft_engine::cmd::ideas::ideas_for(&doc, idx);
|
||||
let themes = deckcraft_model::theme::builtin_themes();
|
||||
let cache_id = egui::Id::new("design-idea-thumbs");
|
||||
@@ -1269,7 +1357,7 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
|
||||
idx,
|
||||
&deckcraft_render::RenderOpts { scale: px as f64 / d.slide_size.width.max(1.0), size: Some((px, h)), ..Default::default() },
|
||||
);
|
||||
let tex = crate::textures::upload(ui.ctx(), format!("idea-{name}"), crate::textures::to_color_image(&img, false));
|
||||
let tex = crate::textures::upload_editor(ui.ctx(), format!("idea-{name}"), crate::textures::to_color_image(&img, false));
|
||||
cache.tex.push((name, tex, px, h));
|
||||
};
|
||||
for (id, _) in &ideas {
|
||||
@@ -1290,9 +1378,12 @@ fn designer(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let thumb = |ui: &mut Ui, name: &str, tip: &str| -> bool {
|
||||
let Some((_, tex, tw, th)) = cache.tex.iter().find(|e| e.0 == name) else { return false };
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, w * *th as f32 / (*tw).max(1) as f32), Sense::click());
|
||||
ui.painter().image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
// On the physical pixel grid, one texel per device pixel (#184).
|
||||
let at = crate::textures::device_pixel_rect(r, ppp);
|
||||
let at = Rect::from_min_size(at.min, vec2(*tw as f32 / ppp, *th as f32 / ppp));
|
||||
ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
ui.painter().rect_stroke(
|
||||
r,
|
||||
at,
|
||||
CornerRadius::same(2),
|
||||
Stroke::new(if resp.hovered() { 2.5 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border }),
|
||||
egui::StrokeKind::Outside,
|
||||
@@ -1350,6 +1441,10 @@ pub(crate) fn text_transparency_slider(app: &mut SlideApp, ui: &mut Ui, sh: &dec
|
||||
#[path = "smartart_tests.rs"]
|
||||
mod smartart_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "background_tests.rs"]
|
||||
mod background_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
@@ -395,8 +395,9 @@ fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) {
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let tex = crate::textures::upload(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false));
|
||||
ui.painter().image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let tex = crate::textures::upload_editor(ui.ctx(), format!("layout-{li}"), crate::textures::to_color_image(&img, false));
|
||||
let at = crate::textures::device_pixel_rect(rect, ui.ctx().pixels_per_point());
|
||||
ui.painter().image(tex.id(), at, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_stroke(
|
||||
@@ -2646,7 +2647,7 @@ fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
});
|
||||
rows(ui, |ui| {
|
||||
small(app, ui, Icon::ChangePicture, "Change Picture", "app.insertPictureDialog", json!({}));
|
||||
small(app, ui, Icon::ChangePicture, "Change Picture", "app.changePictureDialog", json!({}));
|
||||
small(app, ui, Icon::ResetPicture, "Reset Picture", "picture.reset", json!({}));
|
||||
let (_, r) = small_button_ex(ui, Icon::CompressPictures, "Compress Pictures", enabled(app, "picture.compress"), false, true);
|
||||
if let Some(r) = r {
|
||||
|
||||
@@ -6,9 +6,9 @@ use deckcraft_model::{Presentation, SlideId};
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, Ui, WidgetInfo, WidgetType, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::SlideApp;
|
||||
use crate::icons::{self, Icon};
|
||||
use crate::theme::{self, Tokens};
|
||||
use crate::{SlideApp, widgets};
|
||||
|
||||
/// Sorter-only state. Nothing here is part of the document.
|
||||
#[derive(Debug, Default)]
|
||||
@@ -200,10 +200,10 @@ fn place_slides(app: &mut SlideApp, ui: &mut Ui, g: &Grid<'_>, budget: &mut u32,
|
||||
let (cell, resp) = ui.allocate_exact_size(vec2(g.tw + 36.0, g.th + 36.0), Sense::click_and_drag());
|
||||
let label = format!("Slide {}", i + 1);
|
||||
resp.widget_info(|| WidgetInfo::labeled(WidgetType::Button, true, &label));
|
||||
let img = Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th));
|
||||
let img = crate::textures::device_pixel_rect(Rect::from_min_size(cell.min + vec2(18.0, 8.0), vec2(g.tw, g.th)), ppp);
|
||||
rects.push((i, cell));
|
||||
let p = ui.painter();
|
||||
match app.textures.thumb(ui.ctx(), g.doc, i, (g.tw * ppp) as u32, budget) {
|
||||
match app.textures.thumb(ui.ctx(), g.doc, i, (img.width() * ppp).round() as u32, budget) {
|
||||
Some(tex) => {
|
||||
p.image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
@@ -375,8 +375,8 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
|
||||
Rect::from_min_max(page.min + vec2(x0 as f32 * k, y0 as f32 * k), page.min + vec2(x1 as f32 * k, y1 as f32 * k))
|
||||
};
|
||||
let (slide_box, notes_box) = deckcraft_engine::pdf::notes_frames(&doc, paper);
|
||||
let srect = to_screen((slide_box.x0, slide_box.y0, slide_box.x1, slide_box.y1));
|
||||
let sw = srect.width();
|
||||
// On the physical pixel grid, so the thumbnail is one texel per device pixel (#184).
|
||||
let srect = crate::textures::device_pixel_rect(to_screen((slide_box.x0, slide_box.y0, slide_box.x1, slide_box.y1)), ui.ctx().pixels_per_point());
|
||||
if let Some(m) = doc.notes_master.as_deref() {
|
||||
// Laid out by the text engine as on the PDF page: wrapped, each run in its size and colour.
|
||||
for piece in deckcraft_engine::pdf::master_text_pieces(&doc, m, paper, idx + 1) {
|
||||
@@ -388,29 +388,41 @@ pub fn notes_page(app: &mut SlideApp, ui: &mut Ui) {
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let mut budget = 2;
|
||||
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (sw * ppp) as u32, &mut budget) {
|
||||
if let Some(tex) = app.textures.thumb(ui.ctx(), &doc, idx, (srect.width() * ppp).round() as u32, &mut budget) {
|
||||
ui.painter().image(tex.id(), srect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
ui.painter().rect_stroke(srect, CornerRadius::ZERO, Stroke::new(1.0, Color32::GRAY), egui::StrokeKind::Outside);
|
||||
let notes_rect = to_screen((notes_box.x0, notes_box.y0, notes_box.x1, notes_box.y1));
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(notes_rect));
|
||||
let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default();
|
||||
// A draft belongs to this document and to the notes it started from (#413): another document,
|
||||
// a reorder or an undo that changes them drops it.
|
||||
let id = egui::Id::new(("notespage", st.uid, idx));
|
||||
let stored: Option<(String, String)> = child.data_mut(|d| d.get_temp(id));
|
||||
let mut buf = widgets::text_draft(stored, ¤t);
|
||||
let r = child.add(
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.hint_text("Click to add text")
|
||||
.desired_width(notes_rect.width())
|
||||
.desired_rows(12)
|
||||
.frame(egui::Frame::NONE)
|
||||
.text_color(Color32::from_gray(30))
|
||||
.font(theme::font(14.0)),
|
||||
);
|
||||
if r.lost_focus() && buf != current {
|
||||
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
|
||||
let edit_id = egui::Id::new(("notespage-edit", idx));
|
||||
let focused = child.ctx().memory(|m| m.has_focus(edit_id));
|
||||
let notes_of = |app: &SlideApp| app.session.active().and_then(|st| st.doc.slides.get(idx)).map(|s| s.notes.clone()).unwrap_or_default();
|
||||
let notes = notes_of(app);
|
||||
let stored: Option<(deckcraft_model::text::TextBody, crate::notes::Draft)> = child.data_mut(|d| d.get_temp(id));
|
||||
let mut draft = stored.filter(|(base, _)| *base == notes).map(|(_, d)| d);
|
||||
// Save an edited draft once the field has lost focus, before anything reloads it.
|
||||
if !focused && let Some(d) = draft.as_mut().filter(|d| d.edited) {
|
||||
d.edited = false;
|
||||
let _ = app.run("slide.notes", json!({"body": d.body, "index": idx}));
|
||||
}
|
||||
child.data_mut(|d| d.insert_temp(id, (current, buf)));
|
||||
let notes = notes_of(app);
|
||||
let mut draft = draft.filter(|d| focused || d.body == notes).unwrap_or_else(|| crate::notes::Draft::new(¬es));
|
||||
let look = crate::notes::Look { size: 14.0, color: Some(Color32::from_gray(30)) };
|
||||
let width = notes_rect.width();
|
||||
let r = crate::notes::editor(&mut child, &mut draft, &look, |e| {
|
||||
e.id(edit_id).hint_text("Click to add text").desired_width(width).desired_rows(12).frame(egui::Frame::NONE)
|
||||
})
|
||||
.response
|
||||
.response;
|
||||
if r.lost_focus() && draft.edited {
|
||||
draft.edited = false;
|
||||
let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx}));
|
||||
}
|
||||
let base = notes_of(app);
|
||||
child.data_mut(|d| d.insert_temp(id, (base, draft)));
|
||||
let nav = ui.input(|i| (i.key_pressed(egui::Key::PageDown), i.key_pressed(egui::Key::PageUp)));
|
||||
if nav.0 {
|
||||
let _ = app.run("slide.next", json!({}));
|
||||
|
||||
@@ -28,13 +28,9 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
|
||||
app.dialog = Some(crate::dialogs::Dialog::new("language"));
|
||||
}
|
||||
// Transient status message.
|
||||
if let Some((msg, at)) = app.status.clone() {
|
||||
if crate::now_ms() - at < 6000.0 {
|
||||
p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
} else {
|
||||
app.status = None;
|
||||
}
|
||||
if let Some(msg) = app.fresh_status() {
|
||||
p.text(pos2(rect.min.x + 290.0, rect.center().y), Align2::LEFT_CENTER, &msg, theme::font(12.0), t.accent);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(500));
|
||||
}
|
||||
let mut right = ui.new_child(
|
||||
egui::UiBuilder::new()
|
||||
@@ -114,21 +110,30 @@ fn labeled(ui: &mut Ui, icon: Icon, label: &str, on: bool) -> egui::Response {
|
||||
resp
|
||||
}
|
||||
|
||||
/// The speaker notes editor under the slide.
|
||||
/// The language of a slide's notes: the first notes run that has one.
|
||||
fn notes_lang(s: &deckcraft_model::Slide) -> Option<String> {
|
||||
s.notes.paragraphs.iter().flat_map(|p| &p.runs).find_map(|r| r.props.lang.clone())
|
||||
}
|
||||
|
||||
/// The speaker notes editor under the slide.
|
||||
pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let Some(st) = app.session.active() else { return };
|
||||
if st.selection.target != deckcraft_engine::Target::Slides {
|
||||
return;
|
||||
}
|
||||
let idx = st.selection.slide;
|
||||
let uid = st.uid;
|
||||
let current = st.current_slide().map(|s| s.notes_text()).unwrap_or_default();
|
||||
let (idx, uid) = (st.selection.slide, st.uid);
|
||||
// Save an edited draft once the field has lost focus or another slide is shown (before
|
||||
// anything reloads it), to the slide it was written on.
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let focused = ui.ctx().memory(|m| m.has_focus(id));
|
||||
if app.notes_buf.2.edited && (!focused || app.notes_buf.0 != uid || app.notes_buf.1 != idx) {
|
||||
app.notes_buf.2.edited = false;
|
||||
if app.notes_buf.0 == uid {
|
||||
let _ = app.run("slide.notes", json!({"body": app.notes_buf.2.body, "index": app.notes_buf.1}));
|
||||
}
|
||||
}
|
||||
let notes = app.session.active().and_then(|st| st.current_slide()).map(|s| s.notes.clone()).unwrap_or_default();
|
||||
egui::Panel::bottom("notes")
|
||||
.resizable(true)
|
||||
.default_size(app.ui.notes_height)
|
||||
@@ -139,39 +144,24 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let rect = ui.max_rect();
|
||||
ui.painter()
|
||||
.line_segment([pos2(rect.min.x - 8.0, rect.min.y - 6.0), pos2(rect.max.x + 8.0, rect.min.y - 6.0)], Stroke::new(1.0, t.separator));
|
||||
// Keep a local buffer per slide; commit when it changes and the field loses focus.
|
||||
let id = egui::Id::new("notes_edit");
|
||||
let focused = ui.ctx().memory(|m| m.has_focus(id));
|
||||
// Focus can leave in the frame before the field is drawn (Escape, a click on a
|
||||
// thumbnail): what was typed is still committed, to the slide it was typed for.
|
||||
let was_focused = ui.ctx().memory(|m| m.had_focus_last_frame(id));
|
||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx {
|
||||
let (old_uid, old_idx, typed) = std::mem::replace(&mut app.notes_buf, (uid, idx, current.clone()));
|
||||
let old = app.session.active().and_then(|d| d.doc.slides.get(old_idx)).map(|s| s.notes_text());
|
||||
if was_focused && old_uid == uid && old.is_some_and(|o| o != typed) {
|
||||
let _ = app.run("slide.notes", json!({"text": typed, "index": old_idx}));
|
||||
}
|
||||
// Keep a local draft per slide, following the notes while the field isn't focused.
|
||||
if app.notes_buf.0 != uid || app.notes_buf.1 != idx || (!focused && app.notes_buf.2.body != notes) {
|
||||
app.notes_buf = (uid, idx, crate::notes::Draft::new(¬es));
|
||||
}
|
||||
if !focused && !was_focused && app.notes_buf.2 != current {
|
||||
app.notes_buf.2 = current.clone();
|
||||
}
|
||||
let mut buf = app.notes_buf.2.clone();
|
||||
let mut draft = app.notes_buf.2.clone();
|
||||
let look = crate::notes::Look { size: 13.0, color: None };
|
||||
// Long notes scroll inside the pane instead of spilling over the slide (#221).
|
||||
let out = egui::ScrollArea::vertical()
|
||||
.max_height(ui.available_height())
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
egui::TextEdit::multiline(&mut buf)
|
||||
.id(id)
|
||||
.hint_text("Click to add notes")
|
||||
.frame(egui::Frame::NONE)
|
||||
.desired_width(f32::INFINITY)
|
||||
.desired_rows(2)
|
||||
.font(theme::font(13.0))
|
||||
.show(ui)
|
||||
crate::notes::editor(ui, &mut draft, &look, |e| {
|
||||
e.id(id).hint_text("Click to add notes").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2)
|
||||
})
|
||||
})
|
||||
.inner;
|
||||
let r = out.response.clone();
|
||||
let r = out.response.response.clone();
|
||||
let mut buf = draft.text.clone();
|
||||
// Spelling squiggles under misspelt words of the notes.
|
||||
let lang = app.session.active().and_then(|d| d.doc.slides.get(idx)).and_then(|s| notes_lang(s)).unwrap_or_else(|| "en-US".into());
|
||||
let caret = out.cursor_range.map(|c| c.primary.index.0);
|
||||
@@ -243,13 +233,18 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
if let Some(new) = changed {
|
||||
// A correction is typed into the formatted draft, then saved.
|
||||
buf = new;
|
||||
app.squiggles.notes_menu = None;
|
||||
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
|
||||
draft.text = buf.clone();
|
||||
deckcraft_model::edit::replace_text(&mut draft.body, &buf);
|
||||
draft.edited = false;
|
||||
let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx}));
|
||||
}
|
||||
app.notes_buf.2 = buf.clone();
|
||||
if r.lost_focus() && buf != current {
|
||||
let _ = app.run("slide.notes", json!({"text": buf, "index": idx}));
|
||||
if r.lost_focus() && draft.edited {
|
||||
draft.edited = false;
|
||||
let _ = app.run("slide.notes", json!({"body": draft.body, "index": idx}));
|
||||
}
|
||||
app.notes_buf.2 = draft;
|
||||
});
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use std::sync::Arc;
|
||||
use deckcraft_geom::Rect;
|
||||
use deckcraft_model::{Master, Presentation, ShapeId, Slide};
|
||||
use deckcraft_render::{Image, Region, RenderOpts};
|
||||
use egui::{ColorImage, TextureHandle, TextureOptions};
|
||||
use egui::{ColorImage, TextureFilter, TextureHandle, TextureOptions, TextureWrapMode};
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
struct Key {
|
||||
@@ -21,6 +21,7 @@ struct Key {
|
||||
extra: u64,
|
||||
/// The pasteboard region the slide's drawing calls for (canvas only).
|
||||
want: Region,
|
||||
skip_text: Option<ShapeId>,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
@@ -98,6 +99,39 @@ impl Textures {
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap `rect` onto the physical pixel grid.
|
||||
///
|
||||
/// The editor draws the slide as a texture of device pixels. A rect that starts or ends
|
||||
/// between pixels is sampled with a linear filter, so every glyph edge is a blend of two
|
||||
/// texels and small text looks soft. On a Retina display that offset is half a point, which
|
||||
/// is the whole edge of an editing-size glyph. The slide show draws much larger glyphs, so
|
||||
/// the same blend is not visible there (#184).
|
||||
pub fn device_pixel_rect(rect: egui::Rect, ppp: f32) -> egui::Rect {
|
||||
if !ppp.is_finite() || ppp <= 0.0 || !rect.is_finite() {
|
||||
return rect;
|
||||
}
|
||||
let snap = |v: f32| {
|
||||
let p = v * ppp;
|
||||
if p.is_finite() { p.round() / ppp } else { v }
|
||||
};
|
||||
let min = egui::pos2(snap(rect.min.x), snap(rect.min.y));
|
||||
let step = 1.0 / ppp;
|
||||
let max = egui::pos2(snap(rect.max.x).max(min.x + step), snap(rect.max.y).max(min.y + step));
|
||||
egui::Rect::from_min_max(min, max)
|
||||
}
|
||||
|
||||
/// Editor slide textures. Nearest magnification keeps a texel that is stretched by a
|
||||
/// fraction of a pixel from blurring the glyph; minification still blends when many texels
|
||||
/// share a screen pixel.
|
||||
pub fn editor_texture_options() -> TextureOptions {
|
||||
TextureOptions {
|
||||
magnification: TextureFilter::Nearest,
|
||||
minification: TextureFilter::Linear,
|
||||
wrap_mode: TextureWrapMode::ClampToEdge,
|
||||
mipmap_mode: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_color_image(img: &Image, grayscale: bool) -> ColorImage {
|
||||
let w = img.width as usize;
|
||||
let h = img.height as usize;
|
||||
@@ -175,6 +209,17 @@ pub fn replace(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) {
|
||||
tex.set(shrink_to(img, max_side(ctx)), TextureOptions::LINEAR);
|
||||
}
|
||||
|
||||
/// [`upload`] for editor slide pictures, drawn on the device pixel grid with
|
||||
/// [`editor_texture_options`] (#184).
|
||||
pub fn upload_editor(ctx: &egui::Context, name: impl Into<String>, img: ColorImage) -> TextureHandle {
|
||||
ctx.load_texture(name, shrink_to(img, max_side(ctx)), editor_texture_options())
|
||||
}
|
||||
|
||||
/// [`replace`] for editor slide pictures (#184).
|
||||
pub fn replace_editor(ctx: &egui::Context, tex: &mut TextureHandle, img: ColorImage) {
|
||||
tex.set(shrink_to(img, max_side(ctx)), editor_texture_options());
|
||||
}
|
||||
|
||||
fn master_of(p: &Presentation, slide: &Slide) -> Option<Arc<Master>> {
|
||||
p.masters.iter().find(|m| m.layout(slide.layout).is_some()).cloned()
|
||||
}
|
||||
@@ -203,7 +248,8 @@ impl Textures {
|
||||
/// The canvas texture for slide `index`, the slide being `size` pixels, and the region it
|
||||
/// covers: the slide, grown over the pasteboard to show what reaches off the slide within
|
||||
/// `visible` (the canvas, in pixels from the slide's top-left corner). `extra` changes with the
|
||||
/// GIF frames.
|
||||
/// GIF frames. `skip_text` leaves out one shape's text (the prompt of a placeholder being typed
|
||||
/// into).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn slide(
|
||||
&mut self,
|
||||
@@ -216,6 +262,7 @@ impl Textures {
|
||||
extra: u64,
|
||||
grayscale: bool,
|
||||
gif_times: &[(ShapeId, f64)],
|
||||
skip_text: Option<ShapeId>,
|
||||
anim: Option<(&deckcraft_anim::Timeline, f64)>,
|
||||
) -> Option<(TextureHandle, Region)> {
|
||||
let slide = p.slides.get(index)?.clone();
|
||||
@@ -243,6 +290,7 @@ impl Textures {
|
||||
edit,
|
||||
extra: extra ^ grayscale as u64 ^ anim_key(anim),
|
||||
want,
|
||||
skip_text,
|
||||
};
|
||||
if let Some(e) = &self.canvas
|
||||
&& e.key == key
|
||||
@@ -261,6 +309,7 @@ impl Textures {
|
||||
offset: region.offset(),
|
||||
size: Some(region.size()),
|
||||
state,
|
||||
skip_text,
|
||||
..Default::default()
|
||||
};
|
||||
deckcraft_render::render_slide_drawn(p, index, &opts)
|
||||
@@ -283,10 +332,10 @@ impl Textures {
|
||||
let ci = to_color_image(&img, grayscale);
|
||||
let tex = match self.canvas.take() {
|
||||
Some(mut e) => {
|
||||
replace(ctx, &mut e.tex, ci);
|
||||
replace_editor(ctx, &mut e.tex, ci);
|
||||
e.tex
|
||||
}
|
||||
None => upload(ctx, "slide-canvas", ci),
|
||||
None => upload_editor(ctx, "slide-canvas", ci),
|
||||
};
|
||||
self.canvas = Some(Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: 0 });
|
||||
Some((tex, region))
|
||||
@@ -318,6 +367,7 @@ impl Textures {
|
||||
edit: false,
|
||||
extra: 0,
|
||||
want: Region::slide(width_px, h),
|
||||
skip_text: None,
|
||||
};
|
||||
self.clock += 1;
|
||||
let clock = self.clock;
|
||||
@@ -335,7 +385,7 @@ impl Textures {
|
||||
let (tw, th) = fit_size((width_px, h), max_side(ctx));
|
||||
let scale = tw as f64 / p.slide_size.width.max(1.0);
|
||||
let img = deckcraft_render::render_slide(p, index, &RenderOpts { scale, size: Some((tw, th)), threads: 0, ..Default::default() });
|
||||
let tex = upload(ctx, format!("thumb-{index}"), to_color_image(&img, false));
|
||||
let tex = upload_editor(ctx, format!("thumb-{index}"), to_color_image(&img, false));
|
||||
// Drop the old entry for this slide address; entries for slides that no longer exist age out.
|
||||
let region = key.want;
|
||||
self.thumbs.insert(key.slide, Entry { key, tex: tex.clone(), region, _slide: slide, _master: master, stamp: clock });
|
||||
@@ -350,6 +400,39 @@ impl Textures {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::device_pixel_rect;
|
||||
use egui::{Rect, pos2, vec2};
|
||||
|
||||
fn on_grid(v: f32, ppp: f32) -> bool {
|
||||
let px = v * ppp;
|
||||
(px - px.round()).abs() < 1e-3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retina_slide_rect_lands_on_device_pixels() {
|
||||
let ppp = 2.0;
|
||||
let r = Rect::from_min_size(pos2(10.3, 20.25), vec2(100.2, 50.4));
|
||||
let s = device_pixel_rect(r, ppp);
|
||||
assert!(on_grid(s.min.x, ppp) && on_grid(s.min.y, ppp) && on_grid(s.max.x, ppp) && on_grid(s.max.y, ppp));
|
||||
assert!(s.width() > 0.0 && s.height() > 0.0);
|
||||
// One texture pixel per device pixel: the snapped size is an integer pixel count.
|
||||
let px = ((s.width() * ppp).round() as u32, (s.height() * ppp).round() as u32);
|
||||
assert!((s.width() / px.0 as f32 * ppp - 1.0).abs() < 1e-3);
|
||||
assert!((s.height() / px.1 as f32 * ppp - 1.0).abs() < 1e-3);
|
||||
let aligned = Rect::from_min_max(pos2(1.0, 2.5), pos2(80.0, 40.0));
|
||||
assert_eq!(device_pixel_rect(aligned, ppp), aligned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bad_scale_does_not_move_the_rect() {
|
||||
let r = Rect::from_min_size(pos2(3.0, 4.0), vec2(10.0, 12.0));
|
||||
assert_eq!(device_pixel_rect(r, 0.0), r);
|
||||
assert_eq!(device_pixel_rect(r, f32::NAN), r);
|
||||
}
|
||||
}
|
||||
|
||||
/// `r` cut to at most `max` pixels a side, keeping as much of `visible` as fits.
|
||||
pub(crate) fn fit_texture(mut r: Region, visible: Region, max: u32) -> Region {
|
||||
fn axis(start: &mut i32, len: &mut u32, vis: i32, max: u32) {
|
||||
|
||||
@@ -94,7 +94,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
});
|
||||
}
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(width, th + 16.0), Sense::click_and_drag());
|
||||
let img = Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th));
|
||||
let img = crate::textures::device_pixel_rect(Rect::from_min_size(pos2(row.min.x + 30.0, row.min.y + 6.0), vec2(tw, th)), ppp);
|
||||
rects.push((i, row));
|
||||
let p = ui.painter();
|
||||
let id = doc.slides.get(i).map(|s| s.id);
|
||||
@@ -114,7 +114,7 @@ fn list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
if has_anim {
|
||||
p.text(pos2(row.min.x + 14.0, img.min.y + 20.0), Align2::CENTER_TOP, "★", theme::font(10.0), t.text_faint);
|
||||
}
|
||||
let wpx = (tw * ppp).round() as u32;
|
||||
let wpx = (img.width() * ppp).round() as u32;
|
||||
match app.textures.thumb(ui.ctx(), &doc, i, wpx, &mut budget) {
|
||||
Some(tex) => {
|
||||
p.image(
|
||||
@@ -276,10 +276,11 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
let tw = (width - indent - 14.0).max(40.0) * if li.is_some() { 0.8 } else { 1.0 };
|
||||
let th = tw * (doc.slide_size.height / doc.slide_size.width.max(1.0)) as f32;
|
||||
let (row, resp) = ui.allocate_exact_size(vec2(width, th + 12.0), Sense::click());
|
||||
let img = Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th));
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let img = crate::textures::device_pixel_rect(Rect::from_min_size(pos2(row.min.x + indent, row.min.y + 4.0), vec2(tw, th)), ppp);
|
||||
let wpx = ((img.width() * ppp).round() as u32).max(1);
|
||||
let px = crate::textures::fit_size(
|
||||
(((tw * ppp) as u32).max(1), ((tw * ppp) as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32),
|
||||
(wpx, (wpx as f64 * doc.slide_size.height / doc.slide_size.width.max(1.0)).clamp(1.0, 1e9) as u32),
|
||||
crate::textures::max_side(ui.ctx()),
|
||||
);
|
||||
let pic = deckcraft_render::render_layout(
|
||||
@@ -293,7 +294,7 @@ fn master_list(app: &mut SlideApp, ui: &mut Ui) {
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let tex = crate::textures::upload(ui.ctx(), format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false));
|
||||
let tex = crate::textures::upload_editor(ui.ctx(), format!("ml-{mi}-{li:?}"), crate::textures::to_color_image(&pic, false));
|
||||
ui.painter().image(tex.id(), img, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
let selected = match (target, li) {
|
||||
(Target::Master { master }, None) => master == mi,
|
||||
|
||||
@@ -43,7 +43,7 @@ perspective-correct subdivided meshes.
|
||||
| Duration, advance on click / after time, Apply To All | yes | yes |
|
||||
| Transition sounds | yes | yes: Transitions › Sound offers six built-in sounds (synthesised, no sound files shipped), Other Sound…, [Stop Previous Sound] and Loop Until Next Sound; they play in the show and round-trip |
|
||||
| Preview in the editor | yes | yes |
|
||||
| Morph: objects move, resize, recolour | yes | yes |
|
||||
| Morph: objects move, resize, recolour; unmatched text boxes fade in place; a picture's crop morphs | yes | yes (#119, #118) |
|
||||
| Morph: Words and Characters | yes | yes (#5) |
|
||||
| Morph: geometry (circle to star) | yes | yes (#27) |
|
||||
| Morph: `!!` name forcing, pictures, 3-D models, ink | yes | names partly; no 3-D or ink |
|
||||
@@ -105,4 +105,6 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85
|
||||
| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide's animations on the editor canvas; preview from the selected effect is still open |
|
||||
| 2026-10-13 | major | Transitions measured frame by frame against PowerPoint's video export and retuned (mean diff 35.7 → 15.0); PowerPoint's XML defaults and option meanings; new options |
|
||||
| 2026-10-13 | minor | Triggers now play in the slide show (they were read and written but never started), effects of different triggers apply in the order they were clicked |
|
||||
| 2026-10-11 | minor | Morph leaves unrelated text boxes unpaired: they fade in place (#119) |
|
||||
| 2026-10-11 | minor | Morph animates a changed picture crop (#118) |
|
||||
| 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates |
|
||||
@@ -17,7 +17,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
|
||||
| Method | Params | What it does |
|
||||
|---|---|---|
|
||||
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe ID` documents one) |
|
||||
| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `deckcraft-cli describe COMMAND` documents one) |
|
||||
| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason |
|
||||
| `document.inspect` | — | Slides, sections, layouts, shapes, selection |
|
||||
| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, `home` (the File tab open over a presentation), `backstage` (its page, also the start screen), slide show state (`grid`, `zoomArm`, `zoom`, `blank`, `presenterPaused`, `presenterSecs`, `notesPx`, `custom`, `goto`; `cells` while See All Slides is open), documents, perf (fps, render ms) |
|
||||
@@ -37,7 +37,7 @@ Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless note
|
||||
| `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly |
|
||||
| `ui.resize` / `ui.focus` | | Window control |
|
||||
| `show.start` / `show.end` | `{from?: slide index, custom?: name, reading?}` | Slide show. `custom` plays that custom show from its first slide and does not change Set Up. With no `from` and no `custom`, playback starts at the first slide of the show order |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) |
|
||||
| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}` or `{"path": "deck.gif", "width": "large", "secondsPerSlide": 3, "loop": false}`) |
|
||||
|
||||
The MCP server (`deckcraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude
|
||||
and other agents; `deckcraft-cli app METHOD [JSON]` sends one request from a shell.
|
||||
and other agents; `deckcraft-cli app --method METHOD [PARAMS]` sends one request from a shell.
|
||||
@@ -46,7 +46,7 @@ PowerPoint opens and saves; Viewer = opens only).
|
||||
| Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt`/`.md` outline (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests |
|
||||
| PNG / JPEG slide images | export | n/a | yes (`file.export`, `deckcraft-cli render`) | Matches the editor's renderer. PNG `pHYs` and JPEG JFIF density are `scale` × 72 dpi (default scale 2 is 144 dpi), so Preview shows the slide's real size | render and engine tests |
|
||||
| TIFF / BMP / GIF slide images | export | n/a | no | Easy (the `image` crate writes them) | none |
|
||||
| Animated GIF of the deck | export | n/a | no | | none |
|
||||
| Animated GIF of the deck | export | n/a | yes (`file.export` format `gif`, Export…, `deckcraft-cli`, MCP `export`) | One frame per slide from the slide renderer; size small/medium/large/extra large or 16–3840 px wide, 0.1–600 s per slide, loop forever or play once, slide range, hidden slides skipped; a 256-colour palette per frame (exact, or NeuQuant above 256 colours). Transitions and build animations are not animated | `crates/engine/src/gif_export.rs` tests (decoded frames, size, delay, loop, hidden slides, hostile params), MCP, CLI and export dialog tests |
|
||||
| MP4 / MOV video of the deck | export | n/a | no | Needs timeline render + H.264 encode | none |
|
||||
| Print | yes | n/a | no | PR #66 (browser) open | none |
|
||||
|
||||
@@ -81,7 +81,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| Presentation, slide size, sections (p14), custom shows | yes | yes | |
|
||||
| Slide masters, layouts, placeholders and inheritance | yes | yes | |
|
||||
| Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept |
|
||||
| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. Edited in View ▸ Notes Master |
|
||||
| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. Edited in View ▸ Notes Master. Notes keep their run and paragraph formatting (bold, italic, underline, size, levels, indents, bullets), shown and kept when edited in the notes pane and Notes Page (`crates/pptx/tests/notes.rs`). PDF notes pages print them as plain text; levels and bullets inherited from a custom notes master aren't shown |
|
||||
| Handout master | yes | yes | Header, footer, date and slide-number text is kept. Edited in View ▸ Handout Master |
|
||||
| Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). |
|
||||
| Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | |
|
||||
@@ -149,5 +149,7 @@ the round trip even though DeckCraft doesn't model it.
|
||||
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
|
||||
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
|
||||
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
|
||||
| 2026-10-11 | minor | Speaker notes: formatting shown in the notes pane and Notes Page and kept when they are edited |
|
||||
| 2026-10-11 | minor | Animated GIF of the deck: export yes (#171) |
|
||||
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
|
||||
| 2026-10-13 | minor | Comments: the writer emits the modern threaded part (`p188`) next to the flattened legacy one; the reader prefers the modern part and scales its EMU positions (#70) |
|
||||
+13
-10
@@ -25,6 +25,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; checklist row stays P so breadth stays 79%; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; checklist letters unchanged, breadth stays 79%, dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (chart trendlines are drawn; previous: minor, chart trendlines round-trip; 3-D bevel, camera and face lighting rendered; IME composition and CJK interface text landed, #101; core-workflow alpha gate added; stage stays alpha; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture options are pane controls, not a new dialog; dialogs stay 27; previous: 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (animation preview plays on the editor canvas; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a two-finger twist rotates the selection; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (a trackpad swipe turns the page in the editor and the show; pinch-zoom remains; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -200,11 +201,13 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
- **Impact:** handouts and speaker notes are printed in many workplaces.
|
||||
- **Estimate:** 5–8 h (print through the PDF path and each OS's print dialog). **Beta blocker.**
|
||||
|
||||
### No video or animated GIF export
|
||||
- **Evidence:** checklist row missing; PowerPoint exports MP4/MOV and animated GIF.
|
||||
### No video export; the animated GIF has no transitions or builds
|
||||
- **Evidence:** checklist row "Export video (MP4) / animated GIF" is partial: `file.export` writes
|
||||
an animated GIF with one frame per slide (#171), but there is no MP4/MOV export, and the GIF
|
||||
doesn't animate transitions or build animations.
|
||||
- **Impact:** sharing a talk as a video is a common request.
|
||||
- **Estimate:** 10–16 h (render the timeline frame by frame; an H.264 encoder is the hard part,
|
||||
FilmCraft has one to copy).
|
||||
- **Estimate:** 10–16 h (render the timeline frame by frame, which the GIF can then reuse for
|
||||
transitions and builds; an H.264 encoder is the hard part, FilmCraft has one to copy).
|
||||
|
||||
### Embedded MicroType Express font subsets aren't drawn
|
||||
- **Evidence:** `p:embeddedFontLst` is read and written. A raw TrueType/OpenType face, or one inside an Embedded OpenType wrapper, is loaded and used to draw. A MicroType Express subset (`application/x-fontdata` with neither form) is kept and written back, and the text still uses a stand-in.
|
||||
@@ -223,11 +226,6 @@ Detail in [file-format-parity.md](file-format-parity.md).
|
||||
- **Impact:** copy-paste of screenshots is a core workflow.
|
||||
- **Estimate:** 2–4 h. **Beta blocker.**
|
||||
|
||||
### Tab inside a text box ends text entry (#78)
|
||||
- **Evidence:** #78.
|
||||
- **Impact:** tab stops and indented lists can't be typed.
|
||||
- **Estimate:** 1–2 h.
|
||||
|
||||
### Charts: data grid, elements and the newer chart types
|
||||
- **Evidence:** checklist rows "Edit chart data" (P0, partial), "Chart elements" (partial), "Other
|
||||
chart types" (partial); `chartex` parts (waterfall, funnel, treemap, sunburst, histogram, Pareto,
|
||||
@@ -390,7 +388,9 @@ Detail in [ui-parity.md](ui-parity.md).
|
||||
- **Dialogs**: 28 dialogs vs PowerPoint's ~60 (Font, Bullets and Numbering, Find/Replace dialog,
|
||||
Action Settings, Print, Insert Object…). 8–14 h.
|
||||
- **Dialogs**: 30 dialogs vs PowerPoint's ~60 (Font, Find/Replace dialog, Replace Fonts, Print,
|
||||
Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in. 8–14 h.
|
||||
Insert Object…). Size and Position, Bullets and Numbering, Action Settings and Date and Time are in.
|
||||
Format Background picture options (transparency, tile, offset, scale, alignment, mirror, and Apply
|
||||
to All copying a picture) are pane controls on `design.background`, not another dialog. 8–14 h.
|
||||
- **Dialogs**: 28 dialogs vs PowerPoint's ~60. The Font dialog is in (family, size, style, underline,
|
||||
colour, strikethrough, baseline, caps, character spacing, one undo step). Still missing: Bullets
|
||||
and Numbering, Find/Replace, Replace Fonts, Action Settings, Print, Insert Object…. 6–12 h.
|
||||
@@ -494,6 +494,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-13 | trivial | Command palette skips commands that need a value, so Export opens the dialog (#79) |
|
||||
| 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme. The checklist row stays P, so breadth stays 79%. Dialogs stay 27 |
|
||||
| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Checklist letters unchanged (breadth stays 79%); dialogs stay 27 |
|
||||
| 2026-10-14 | minor | Format Background picture and solid options are in the existing pane. Dialogs stay 27 |
|
||||
| 2026-10-13 | minor | AutoCorrect options: case rules, fractions, ellipsis, a larger built-in list and your own entries (`text.autocorrect`, Tools ▸ AutoCorrect) |
|
||||
| 2026-10-14 | trivial | Notes spelling menu, Review › Language with Do Not Check Spelling, repeated words |
|
||||
| 2026-10-14 | trivial | Spelling squiggles in table cells and notes, and on the web |
|
||||
@@ -513,5 +514,7 @@ Detail in [localization-parity.md](localization-parity.md).
|
||||
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
|
||||
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
|
||||
| 2026-10-12 | minor | Text warps render (all 40 presets, adjusts round-trip); the warp gap is now the gallery, command and handles |
|
||||
| 2026-10-11 | minor | Removed "Tab inside a text box ends text entry" (#78, fixed) |
|
||||
| 2026-10-11 | minor | Animated GIF export landed (#171); the entry now covers video export and animating transitions in the GIF |
|
||||
| 2026-10-10 | minor | Noted that no gap blocks alpha (core-workflow gate passes) |
|
||||
| 2026-10-10 | major | Created: every gap from the 2026-10-10 re-measure and the 16 open GitHub issues, ranked, with beta blockers marked |
|
||||
@@ -16,8 +16,8 @@ because it mostly follows from hardware use.
|
||||
| Window compositing on the GPU | Metal / DirectX | wgpu (Metal) | wgpu, DX12 only by default (#16) | wgpu (Vulkan, GL) | WebGPU, WebGL2 fallback |
|
||||
| Slide rasterization | GPU | **CPU** (`vello_cpu`) | CPU | CPU | CPU (wasm) |
|
||||
| Transitions and Morph frames | GPU | CPU-rendered slide images composited as textured quads on the GPU | same | same | same |
|
||||
| High-DPI / Retina | yes | yes | yes | yes | yes |
|
||||
| Picture export resolution | 2 px per point, tagged so the file is the slide's size | PNG `pHYs` and JPEG density, 144 dpi at the default 2 px per point | same | same | same |
|
||||
| High-DPI / Retina | yes | yes; editor slide rasters sit on the physical pixel grid (#184) | yes | yes | yes |
|
||||
| Wide gamut / HDR display | partly (P3 images) | no (sRGB) | no | no | no |
|
||||
| Startup on all GPUs | yes | yes | **no**: crashes in some Intel/AMD drivers (#40, #59) | not reported | n/a |
|
||||
| Software fallback when no GPU | yes (WARP on Windows) | no | no | no | no |
|
||||
@@ -80,6 +80,7 @@ outlines every frame (#67, fix open), and the decoders run in software.
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-14 | minor | PNG and JPEG slide export record 144 dpi at the default 2 pixels per point, so a Retina picture opens at the slide's size |
|
||||
| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) |
|
||||
| 2026-10-14 | minor | A two-finger twist on the trackpad rotates the selected shapes, as one undo step. Pinch-zoom is unchanged |
|
||||
| 2026-10-14 | minor | A sideways trackpad swipe turns the page in Normal view (when the slide fits) and in the show. Pinch-zoom is unchanged |
|
||||
| 2026-10-14 | minor | Pen pressure scales ink width; tilt, pinch zoom and trackpad gestures remain (estimate 3–6 h → 2–5 h) |
|
||||
|
||||
+55
-2
@@ -13,6 +13,58 @@ deck.
|
||||
}
|
||||
```
|
||||
|
||||
## Confining file access
|
||||
|
||||
```sh
|
||||
deckcraft-cli mcp --automation-read-root /work/project --automation-write-root /work/project/out
|
||||
```
|
||||
|
||||
```json
|
||||
{"mcpServers": {"deckcraft": {"command": "deckcraft-cli",
|
||||
"args": ["mcp", "--automation-read-root", "/work/project", "--automation-write-root", "/work/project/out"]}}}
|
||||
```
|
||||
|
||||
The two flags (VectorCraft, PhotoCraft and the other Craft apps take the same ones, so one client
|
||||
config works across the suite) keep the agent to the folders you give it:
|
||||
|
||||
- Every file a command reads must lie inside the **read root**: `open_presentation` and
|
||||
`file.open`, and `insert_picture`, `insert.picture`, `picture.change`, `insert.audio` and
|
||||
`insert.video` with a `path`. Pictures, audio and video are copied into the deck when they are
|
||||
inserted, so playing them later reads nothing from disk.
|
||||
- Every file a command writes must lie inside the **write root**: `save_presentation`, `export`
|
||||
(every format, and every slide of an image export), `file.save`, `file.saveAs`, `file.export`,
|
||||
and `render_slide` with a `path`, plus the `.saving` temporary file a save goes through.
|
||||
- Read and write access are separate. Give the same folder twice for both; a root left out grants
|
||||
none of its access (with only `--automation-read-root`, nothing can be written to disk). Without
|
||||
either flag nothing changes.
|
||||
- A path is made absolute against the server's working directory, then its links are followed: a
|
||||
file (or folder) link that leads out of a root is outside it. For a file that doesn't exist yet,
|
||||
its nearest existing folder's links are followed and the rest must be plain names, so `..` below a
|
||||
folder that doesn't exist is refused, as is a link that leads nowhere (writing through it would
|
||||
create its target). The result is compared with the root by whole folder names: `/work2/x` is not
|
||||
inside `/work`. On Windows case is ignored, UNC paths (`\\server\share\...`) are resolved like
|
||||
drive-letter ones, and device names (`CON`, `NUL`, `COM1`...) are refused.
|
||||
- A refused path is an ordinary tool error the agent can read and correct, for example
|
||||
`automation path rejected: outside the write root /work/project/out: /etc/x.pptx` or
|
||||
`automation filesystem access is not granted: write authority is absent: /work/project/out/x.pptx`.
|
||||
- The roots must be existing folders; a missing one stops the server with an error. Each flag is
|
||||
given once (`--flag <dir>` or `--flag=<dir>`).
|
||||
|
||||
The flags confine the in-process server, so `--connect` together with them is an error: the server
|
||||
would otherwise talk to an app that isn't confined. DeckCraft's desktop app has no equivalent flags
|
||||
yet, so a running app (`deckcraft --control`) is not confined. `--sample` is the operator's own
|
||||
choice and is opened before the roots apply.
|
||||
|
||||
What the roots do not cover: commands that take no path still read and change the open deck, and
|
||||
`inspect_document` and `inspect_slide` return it. The system font scan and the app's own recovery
|
||||
folder are fixed paths, not agent input, and are not checked. Anything that reads or writes a path
|
||||
goes through one place in the engine (`io::read_file` and `io::write_file`); a test keeps the rest of the
|
||||
engine from touching the file system on its own.
|
||||
|
||||
The checks hold for every thread of the server process. A path is checked, then opened: another
|
||||
program that swaps a link in between can still win that race, so the roots keep an agent to the
|
||||
files it was given rather than guarding against other software on the computer.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | What it does |
|
||||
@@ -20,7 +72,7 @@ deck.
|
||||
| `list_commands` | Every command (id, label, menu, shortcut, params doc), optionally filtered |
|
||||
| `run_command` | Run one command: `{command, params}` — every menu item, ribbon button and gesture is a command |
|
||||
| `batch` | Run several commands in order and stop at the first error |
|
||||
| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.deckcraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline |
|
||||
| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.deckcraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline, animated GIF (size, seconds per slide, loop, slides) |
|
||||
| `inspect_document`, `inspect_slide` | Structure of the deck / one slide (shapes, text, placeholders, animations) |
|
||||
| `render_slide` | A slide as an image the model can look at |
|
||||
| `add_slide`, `go_to_slide` | Slides by layout |
|
||||
@@ -35,5 +87,6 @@ A tool refuses an argument that is not in its schema (`insert_chart: unknown arg
|
||||
Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset,
|
||||
rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`,
|
||||
`shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`,
|
||||
`chart.*`, `table.*`, `file.export {path, layout}`. `deckcraft-cli describe ID` prints any
|
||||
`chart.*`, `table.*`, `file.export {path, layout}` (PDF) or `{path, width, secondsPerSlide, loop}`
|
||||
(GIF). `deckcraft-cli describe COMMAND` prints any
|
||||
command's parameters.
|
||||
@@ -1,6 +1,6 @@
|
||||
# PowerPoint feature checklist
|
||||
|
||||
> **Generated:** 2026-10-13 (summary by `cargo xtask parity`; Preview in editor re-scored; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Generated:** 2026-10-13 (summary by `cargo xtask parity`; Preview in editor re-scored; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
|
||||
The summary between `<!-- SUMMARY -->
|
||||
**Weighted breadth parity: 82%** over 193 features.
|
||||
@@ -85,7 +85,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
|
||||
| Files | Export to PDF (slides, notes pages, handouts; text layer, links, bookmarks; vector artwork pending) | P0 | P |
|
||||
| Files | Export slides as PNG/JPEG | P0 | D |
|
||||
| Files | Export outline / RTF | P2 | P |
|
||||
| Files | Export video (MP4) / animated GIF | P2 | M |
|
||||
| Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P |
|
||||
| Files | Print (slides, handouts, notes, outline) | P1 | M |
|
||||
| Files | Password protection | P2 | M |
|
||||
| Files | Reuse slides from another file | P1 | P |
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (essentials discount no longer cites the palette Export failure, #79; percentages unchanged; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; percentages unchanged; previous: hours to ~95% per audience, ready ~55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers; automation counted at 236 commands; the menu measurement and the d556ff9 snapshot still count 226; percentages unchanged; previous: hours to ~95% per audience, ready 56% → 55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture and solid options; percentages unchanged; previous: hours to ~95% per audience, ready ~55%) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (Preview in editor re-scored done; breadth 79% → 80%, Animations 79% → 85%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (227 commands; previous: minor, hours to ~95% per audience added; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (229 commands; previous: trivial, 227 commands; ready stays ~55%; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -251,7 +252,9 @@ Carried from the 2026-10-07 ROADMAP status and updated with what has landed sinc
|
||||
latin faces (one scheme on that master, not a library of saved font schemes, and not East
|
||||
Asian or complex-script faces).
|
||||
effect schemes (Design ▸ Effects: DeckCraft, Soft, Crisp, Lift, Glow, Plain),
|
||||
backgrounds (solid, gradient, picture, pattern), slide size presets.
|
||||
backgrounds (solid, gradient, picture, pattern; a solid fill's transparency; picture
|
||||
transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture),
|
||||
slide size presets.
|
||||
- **Shapes:** ~180 preset geometries with adjustment handles, text boxes, pictures (crop,
|
||||
corrections, animated GIFs that play and pause), tables with styles, charts (column, bar, line,
|
||||
area, pie, doughnut, scatter and more), basic SmartArt, groups, connectors glued to sites,
|
||||
@@ -353,6 +356,8 @@ scored done (16 open issues).
|
||||
| 2026-10-13 | trivial | Essentials discoverability note: the command palette's Export failure (#79) is fixed; the ×0.95 discount stays for the menu bar and start screen |
|
||||
| 2026-10-14 | minor | Design ▸ Effects noted under what works today. Percentages stay 79% / ~60% / ~55%. The d556ff9 snapshot and the menu measurement stay at 226 commands; the live registry is 227 |
|
||||
| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Automation in "what works today" is 236 commands; the menu-bar measurement and the d556ff9 snapshot still count 226. Percentages unchanged |
|
||||
| 2026-10-11 | minor | Animated GIF export (#171) moves the checklist's video/GIF row from missing to partial: P2 breadth 36% → 37%; overall breadth (79%), dimension scores and stage unchanged |
|
||||
| 2026-10-14 | minor | Format Background picture and solid options noted under what works today. Percentages, the 226-command count and the d556ff9 dialog snapshot stay as they are |
|
||||
| 2026-10-13 | trivial | Preview in editor scored done: breadth 79% → 80%, Animations area 79% → 85% |
|
||||
| 2026-10-14 | trivial | Command count 227 → 229 (Paragraph dialog and Tabs). Breadth and ready percentages unchanged |
|
||||
| 2026-10-14 | trivial | Command count 226 → 227 (Font dialog). Breadth and ready percentages unchanged |
|
||||
|
||||
+18
-3
@@ -33,6 +33,7 @@
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Insert › Date and Time dialog; previous: text editing keys, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Design ▸ Effects; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Slide Master Preserve and Title/Footers placeholders; previous: text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Format Background picture and solid options; dialogs stay 27) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-14 · **Change:** minor (copy and paste rich text and pictures with other apps; previous: text editing keys, #268, and File tab, #76) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Font dialog; previous: minor, text editing keys observed in PowerPoint for Windows, #268) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** minor (Paragraph dialog; previous: minor, Font dialog) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
|
||||
@@ -96,7 +97,12 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
|---|---|---|
|
||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line (dash, compound, cap, join, begin/end arrows and sizes), shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, 3-D format and rotation (bevels, depth, contour, material, lighting, camera presets, X/Y/Z rotation), size (lock aspect ratio) |
|
||||
| Format Background pane | yes | yes |
|
||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line (dash, compound, cap, join, begin/end arrows and sizes), some effects, size (lock aspect ratio); no 3-D, thin picture/texture options |
|
||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, line, shadow/glow/reflection details (colour, transparency, blur, angle, distance, size), soft edges, size; no 3-D, thin picture/texture options |
|
||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill (solid transparency follows the selection), gradient editor, line, some effects, size; no 3-D, thin picture/texture options |
|
||||
| Format Shape / Picture / Text pane (Fill & Line, Effects, Size & Properties, Picture, Text Box) | full | partial: fill, gradient editor, picture/texture fill (source, tile, offsets, scale, alignment, mirror, transparency, rotate with shape), line, some effects, size; no 3-D |
|
||||
| Slide Master | Insert Layout, Rename, Delete, Preserve, Insert Placeholder, Title and Footers | Insert Layout, Rename, Delete, Insert Placeholder, and Preserve, Title and Footers checkboxes (`master.preserve`, `master.placeholders`). A master thumbnail selects the master |
|
||||
| Format Background pane | yes | yes: solid transparency; picture transparency, tile, offset, scale, alignment and mirror; Apply to All copies a picture. Stretch insets and rotate-with-shape are command parameters |
|
||||
| Selection Pane | front-most first, groups fold, drag to restack, Bring Forward / Send Backward arrows, Show All / Hide All, eye, padlock (Lock), rename in place | same: drag restacks among siblings (a child stays in its group), Ctrl+click toggles, Shift+click selects a run, arrows / Home / End / F2 / Esc in the list, right-click menu |
|
||||
| Animation Pane | list + advanced timeline | list, reorder, timing; no timeline |
|
||||
| Comments pane (threads) | yes | yes: every slide, with Previous and Next on the Review tab |
|
||||
@@ -137,8 +143,10 @@ menu colours taken from the theme tokens (menu `chrome`, page `card`).
|
||||
| Edit points on freeforms | yes | **no** |
|
||||
| WordArt adjust handles | yellow handles on transformed text | yes, one per adjust when the text has a warp; dragging sets that guide value |
|
||||
| Crop handles, crop to shape, aspect ratio | yes | partial |
|
||||
| Text caret, selection, double/triple click, drag-select | yes | yes; Tab in text boxes ends editing (#78) |
|
||||
| Text caret, selection, double/triple click, drag-select | yes | yes |
|
||||
| Exact size/position, lock aspect | yes | yes — Size and Position dialog (height, width, rotation, scale %, lock aspect, position from the top-left), one undo step; ribbon spinners still resize one side at a time |
|
||||
| Drag the frame of a text box being edited to move it | yes | yes |
|
||||
| Table cells: caret and selection in the cell, click into another cell, drag row and column borders | yes | yes; no cell-range selection by dragging across cells |
|
||||
| Rulers with indent markers and tab stops | yes | rulers yes; tab stops partial |
|
||||
| Context menus on shapes, slides, text, thumbnails | yes | yes, shorter; a right click selects the shape under the pointer first |
|
||||
| WordArt transform | Transform gallery | Shape Format ▸ Text Effects ▸ Transform (`format.textWarp`): No Transform, Follow Path and the other 36 presets. No adjust handles |
|
||||
@@ -192,8 +200,10 @@ Alt+Shift+arrows in text are handled elsewhere (#78, #93).
|
||||
Colours, spacing, ribbon card and tab geometry follow the observations in
|
||||
`plan/powerpoint/01-observed-ui.md`; the ribbon and menu icons are original (~240, drawn in code)
|
||||
and must not imitate Microsoft's. Insert › Icons places Lucide stroke icons (ISC, release 1.56.0)
|
||||
on the slide as shapes. No pixel comparison against PowerPoint screenshots has been run since
|
||||
2026-10-07. Cross-app icon consistency is open (#73, #56).
|
||||
on the slide as shapes. The editing canvas, thumbnails and layout previews are drawn on the
|
||||
physical pixel grid, so a Retina display does not blur them between pixels (#184). No pixel
|
||||
comparison against PowerPoint screenshots has been run since 2026-10-07. Cross-app icon
|
||||
consistency is open (#73, #56).
|
||||
|
||||
## Estimates
|
||||
|
||||
@@ -243,6 +253,10 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b
|
||||
| 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme (fills, lines and effects). Not a new dialog; dialogs stay 27 |
|
||||
| 2026-10-14 | minor | Slide Master: Preserve, and Title and Footers placeholders, on the open master or layout |
|
||||
| 2026-10-14 | trivial | Trim Media dialog: start, end and fades follow the selected clip |
|
||||
| 2026-10-12 | minor | Dragging the frame of a text box being edited moves it, as in PowerPoint |
|
||||
| 2026-10-12 | minor | Table cells: caret and selection shown in the edited cell, clicks place the caret in the clicked cell, row and column borders drag to resize |
|
||||
| 2026-10-14 | minor | Format Background pane: solid transparency, picture transparency, tile, offset, scale, alignment, mirror, and Apply to All copying a picture. Not a new dialog; the dialog count stays 27 |
|
||||
| 2026-10-13 | trivial | Editor slide rasters sit on the physical pixel grid (#184) |
|
||||
| 2026-10-14 | minor | Copy and paste rich text (HTML: bold, italic, underline) with other apps |
|
||||
| 2026-10-13 | minor | Animations ▸ Preview and Animation Pane ▸ Play All play the current slide on the editor canvas |
|
||||
| 2026-10-14 | trivial | Format Shape Fill transparency slider follows the selected solid fill and does not clear it on focus loss |
|
||||
@@ -254,4 +268,5 @@ on the slide as shapes. No pixel comparison against PowerPoint screenshots has b
|
||||
| 2026-10-12 | minor | Off-slide objects drawn and editable around the slide in Normal view (#270) |
|
||||
| 2026-10-12 | minor | Selection Pane matched to PowerPoint (#133): drag and drop, arrows, groups, eye, Lock |
|
||||
| 2026-10-12 | minor | Text editing keys: Ctrl+arrows, Ctrl+Backspace/Delete, Ctrl+Up/Down, Home/End on wrapped lines, Shift+F3, font size keys, as observed in PowerPoint for Windows (#268) |
|
||||
| 2026-10-11 | minor | Tab in text boxes keeps editing (#78) |
|
||||
| 2026-10-10 | major | Created: shell, panes and dialogs, direct manipulation, shortcuts and visual fidelity against PowerPoint 16.113 |
|
||||
Reference in new issue
Block a user