diff --git a/README.md b/README.md index b73a317b..073fa482 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index f2c77ebf..afae9ac4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 | diff --git a/apps/deckcraft-cli/src/automation_args.rs b/apps/deckcraft-cli/src/automation_args.rs new file mode 100644 index 00000000..8f0cabf8 --- /dev/null +++ b/apps/deckcraft-cli/src/automation_args.rs @@ -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, + pub write_root: Option, + read_given: bool, + write_given: bool, +} + +impl AutomationArgs { + pub fn new(read_root: Option, write_root: Option) -> 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) -> Result { + 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}= 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())); + } +} diff --git a/apps/deckcraft-cli/src/help.rs b/apps/deckcraft-cli/src/help.rs new file mode 100644 index 00000000..535ce848 --- /dev/null +++ b/apps/deckcraft-cli/src/help.rs @@ -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 { + 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 = + SUBCOMMANDS.iter().map(|s| format!(" {: ExitCode { let args: Vec = 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 = 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, 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::(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}{: &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 = 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 = 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 { @@ -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 = ["--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)] diff --git a/apps/deckcraft-cli/tests/cli.rs b/apps/deckcraft-cli/tests/cli.rs new file mode 100644 index 00000000..d04e55c7 --- /dev/null +++ b/apps/deckcraft-cli/tests/cli.rs @@ -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::>().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"); +} diff --git a/apps/deckcraft-cli/tests/convert.rs b/apps/deckcraft-cli/tests/convert.rs index 5de25e51..4f162994 100644 --- a/apps/deckcraft-cli/tests/convert.rs +++ b/apps/deckcraft-cli/tests/convert.rs @@ -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"); diff --git a/apps/deckcraft-cli/tests/mcp_roots.rs b/apps/deckcraft-cli/tests/mcp_roots.rs new file mode 100644 index 00000000..e9e1f856 --- /dev/null +++ b/apps/deckcraft-cli/tests/mcp_roots.rs @@ -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 `; 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) { + 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); +} diff --git a/apps/deckcraft-web/src/web.rs b/apps/deckcraft-web/src/web.rs index 8fa95918..dabad8af 100644 --- a/apps/deckcraft-web/src/web.rs +++ b/apps/deckcraft-web/src/web.rs @@ -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", diff --git a/contributors/people.toml b/contributors/people.toml index 810a7d38..f70303a1 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -51,3 +51,6 @@ sync_github_name = true [people.NikitkaRa] real_name = "Nikita Rakitin" + +[people.RCShoemaker] +real_name = "Ryan C. Shoemaker" diff --git a/crates/anim/src/lib.rs b/crates/anim/src/lib.rs index 8522a774..cf8fb3ff 100644 --- a/crates/anim/src/lib.rs +++ b/crates/anim/src/lib.rs @@ -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}; diff --git a/crates/anim/src/morph.rs b/crates/anim/src/morph.rs index a05f2d6c..a869ebdb 100644 --- a/crates/anim/src/morph.rs +++ b/crates/anim/src/morph.rs @@ -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 { diff --git a/crates/anim/src/tests.rs b/crates/anim/src/tests.rs index ab4d5146..ae68fcd6 100644 --- a/crates/anim/src/tests.rs +++ b/crates/anim/src/tests.rs @@ -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) }; diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs index 4d969798..c3e27804 100644 --- a/crates/engine/src/cmd/animation.rs +++ b/crates/engine/src/cmd/animation.rs @@ -28,7 +28,7 @@ pub fn specs() -> Vec { 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", diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index ddae3f58..e2051041 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -43,7 +43,7 @@ pub fn specs() -> Vec { "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 { "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 { #[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 { } fn background(s: &mut Session, p: &Value) -> Result { - let bg: Option = 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), + Adjust, +} + +fn background_of(doc: &Presentation, sel: &crate::Selection, index: Option) -> Option { + 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, all: bool, bg: &Option) -> 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 = 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, all: bool, bg: Option) { + 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 = 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 { + 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) { + 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 = 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 { @@ -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)] diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 3a24f70a..4761dc10 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -33,7 +33,7 @@ pub fn specs() -> Vec { 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 { 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 { - 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> { 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> { } /// 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> { + 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) -> 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 { @@ -519,6 +519,23 @@ fn export(s: &mut Session, p: &Value) -> Result { 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 { 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 = None; Ok(json!({ @@ -644,3 +661,43 @@ fn recovery_discard(s: &mut Session, p: &Value) -> Result { #[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); + } +} diff --git a/crates/engine/src/cmd/format.rs b/crates/engine/src/cmd/format.rs index 515ae069..fd7a5232 100644 --- a/crates/engine/src/cmd/format.rs +++ b/crates/engine/src/cmd/format.rs @@ -41,15 +41,7 @@ pub fn specs() -> Vec { 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 { "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 { "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(), })) diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index ab9680b0..f2e54ed5 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -308,9 +308,8 @@ pub(crate) fn media_bytes(p: &Value, cmd: &str) -> Result<(String, Vec)> { 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)); } diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs index fb666abc..9da535e0 100644 --- a/crates/engine/src/cmd/inspect.rs +++ b/crates/engine/src/cmd/inspect.rs @@ -10,7 +10,7 @@ use crate::{Result, Session}; pub fn specs() -> Vec { 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 { "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, })) diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c798bcf1..83fb816a 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 { + 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, 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>, String> { + let doc = doc.split('→').next().unwrap_or_default(); + let mut params: Vec = vec![]; + let mut open: Vec = vec![]; + let mut expect_key = false; + let mut or = false; + let mut objects: usize = 0; + let mut about_from: Option = 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, 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::())), + }; + 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) -> EngineError { diff --git a/crates/engine/src/cmd/review.rs b/crates/engine/src/cmd/review.rs index 52045e99..50eb4c5c 100644 --- a/crates/engine/src/cmd/review.rs +++ b/crates/engine/src/cmd/review.rs @@ -46,7 +46,7 @@ pub fn specs() -> Vec { ), 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)> { } #[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`")) diff --git a/crates/engine/src/cmd/shape.rs b/crates/engine/src/cmd/shape.rs index e571dc33..1f0c7bd0 100644 --- a/crates/engine/src/cmd/shape.rs +++ b/crates/engine/src/cmd/shape.rs @@ -39,8 +39,16 @@ pub fn specs() -> Vec { 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 { 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", diff --git a/crates/engine/src/cmd/slide.rs b/crates/engine/src/cmd/slide.rs index b90c0abb..deb702c3 100644 --- a/crates/engine/src/cmd/slide.rs +++ b/crates/engine/src/cmd/slide.rs @@ -32,7 +32,15 @@ pub fn specs() -> Vec { 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 { fn notes(s: &mut Session, p: &Value) -> Result { 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::(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(()) })?; diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 373c94c1..1c5b080c 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -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 { vec![ @@ -27,7 +28,15 @@ pub fn specs() -> Vec { 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 { "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 { 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 { ] } +/// 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, + /// Row heights as drawn: a stored height is a minimum, rows grow to fit their text. + pub heights: Vec, + /// 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 { + 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 { + 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 { + 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 { fn column_width(s: &mut Session, p: &Value) -> Result { 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(()) }) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index d4fd26f3..8cb05ca3 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -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(s: &mut Session, f: impl FnOnce(&mut TextBody, &mut TextSel) -> T) -> Result { - 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 Option { - 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 { + 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 { 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) -> Result { + 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) 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 { @@ -598,12 +681,18 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result { } pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { + 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 { 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 { 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 { 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; diff --git a/crates/engine/src/cmd/transition.rs b/crates/engine/src/cmd/transition.rs index 4c798125..4047faf2 100644 --- a/crates/engine/src/cmd/transition.rs +++ b/crates/engine/src/cmd/transition.rs @@ -35,7 +35,7 @@ pub fn specs() -> Vec { "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 ), diff --git a/crates/engine/src/file_access.rs b/crates/engine/src/file_access.rs new file mode 100644 index 00000000..bcda8492 --- /dev/null +++ b/crates/engine/src/file_access.rs @@ -0,0 +1,483 @@ +//! The folders automation may read and write. +//! +//! `deckcraft-cli mcp --automation-read-root --automation-write-root ` 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, + write: Option, +} + +#[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 { + 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>, 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), + Unconfined, +} + +thread_local! { + /// This thread's scope; `None`: the process's roots ([`confine_process`]). + static SCOPE: RefCell> = const { RefCell::new(None) }; +} + +/// The roots in force in every thread outside [`confine`] and [`unconfined`]. +static PROCESS: OnceLock> = 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) -> bool { + PROCESS.set(roots).is_ok() +} + +/// Puts the scope it replaced back when dropped (also when `f` panics). +struct Restore(Option); + +impl Drop for Restore { + fn drop(&mut self) { + let previous = self.0.take(); + SCOPE.with(|s| *s.borrow_mut() = previous); + } +} + +fn with_scope(scope: Option, 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(roots: Option<&Arc>, 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(f: impl FnOnce() -> T) -> T { + with_scope(Some(Scope::Unconfined), f) +} + +/// The roots in force on this thread, if any. +pub fn current() -> Option> { + 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 { + 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 = 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) { + 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) { + 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:#?}"); + } +} diff --git a/crates/engine/src/gif_export.rs b/crates/engine/src/gif_export.rs new file mode 100644 index 00000000..da4f044f --- /dev/null +++ b/crates/engine/src/gif_export.rs @@ -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>, + /// 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, + 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 { + 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 { + 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 { + 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::>>() + .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> { + let n = doc.slides.len(); + let chosen: Vec = 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 = 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 { + 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 { + 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 { + 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) { + 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 { + 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); + } +} diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs new file mode 100644 index 00000000..c19efc0f --- /dev/null +++ b/crates/engine/src/io.rs @@ -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, 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, 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 { + 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 { + None +} diff --git a/crates/engine/src/keys.rs b/crates/engine/src/keys.rs index bc71b575..38b5aa71 100644 --- a/crates/engine/src/keys.rs +++ b/crates/engine/src/keys.rs @@ -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!({})) } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 29fae722..ac25d666 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e55127e0..926a8fd1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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 { + 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::(); + 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() { diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 15a51eb0..dbc72dcb 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -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> { + 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 { + 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 Option { + 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= -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 (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 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::*; diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index acd819e5..bd1faf09 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -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>, } impl Default for Headless { @@ -59,12 +63,19 @@ pub fn tool_kind(name: &str, preset: Option<&str>) -> Option { 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>) -> Self { + self.roots = roots; + self } fn exec(&mut self, id: &str, params: &Value) -> Result { @@ -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 { + 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 { 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() - } } diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 576e0b4a..710067cd 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -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"; diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index db5ca5bf..f34ee867 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -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); +} diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b5eab7f9..b964ea10 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -115,23 +115,32 @@ pub fn tool_definitions() -> Vec { 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"], ), diff --git a/crates/mcp/tests/automation_roots.rs b/crates/mcp/tests/automation_roots.rs new file mode 100644 index 00000000..ed67fb3e --- /dev/null +++ b/crates/mcp/tests/automation_roots.rs @@ -0,0 +1,225 @@ +//! `deckcraft-cli mcp --automation-read-root --automation-write-root `: 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); +} diff --git a/crates/model/src/bidi.rs b/crates/model/src/bidi.rs new file mode 100644 index 00000000..0b113522 --- /dev/null +++ b/crates/model/src/bidi.rs @@ -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}مرحبا")); + } +} diff --git a/crates/model/src/edit.rs b/crates/model/src/edit.rs index 2d6d69da..1c38bf74 100644 --- a/crates/model/src/edit.rs +++ b/crates/model/src/edit.rs @@ -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 = body.text().chars().collect(); + let new: Vec = 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"); + } } diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index d12680cf..c62a5adb 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -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, 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)>, }, } diff --git a/crates/pptx/src/read/chart.rs b/crates/pptx/src/read/chart.rs index 9fef9984..9055b2d9 100644 --- a/crates/pptx/src/read/chart.rs +++ b/crates/pptx/src/read/chart.rs @@ -195,9 +195,8 @@ fn chart_within(root: &El, mut budget: usize) -> Option { "surfaceChart" | "surface3DChart" => ChartType::Surface, _ => continue, }; - if let Some(v) = g.child_val("varyColors") { - c.vary_colors = v == "1" || v == "true"; - } + // A missing 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 { 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#"1"#; + let ser: String = (0..series).map(|i| format!(r#"S{i}{val}"#)).collect(); + let plot = format!(r#"{ser}"#); + let xml = format!(r#"{title}{auto_deleted}{plot}"#); + read_chart_xml(xml.as_bytes()).unwrap() + } + + #[test] + fn single_series_gets_automatic_title() { + assert_eq!(chart(r#""#, "", 1).title.as_deref(), Some("S0")); + } + + #[test] + fn no_automatic_title_when_deleted_or_multi_series() { + assert_eq!(chart(r#""#, "", 1).title, None); + assert_eq!(chart("", "", 1).title, None); + assert_eq!(chart(r#""#, "", 2).title, None); + } + + #[test] + fn explicit_title_wins() { + let t = "Mine"; + assert_eq!(chart(r#""#, 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#""#; + let bar = format!(r#"{vary}{ser}"#); + format!(r#"{bar}"#) + } + + #[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#""#).as_bytes()).is_some_and(|c| !c.vary_colors)); + assert!(read_chart_xml(vary_xml(r#""#).as_bytes()).is_some_and(|c| c.vary_colors)); + } } diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 5f2ad912..ac83dfc0 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -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 { + fn rec(e: &El, out: &mut Vec, 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)> { diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index 0623f4a6..3f7072f4 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -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::>>() + .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 { + 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!("\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 { let rel_ids = gd.child("relIds")?; diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index aff238b7..9a173445 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -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, 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, + 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::>>() { 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) { + 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#""# + ); + 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, 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 { diff --git a/crates/pptx/tests/corpus_media.rs b/crates/pptx/tests/corpus_media.rs index 26a9539d..c03f16d3 100644 --- a/crates/pptx/tests/corpus_media.rs +++ b/crates/pptx/tests/corpus_media.rs @@ -63,9 +63,9 @@ fn corpus() -> (Presentation, Vec, Vec, Vec) { 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); diff --git a/crates/pptx/tests/malformed.rs b/crates/pptx/tests/malformed.rs index e76e3201..633d339e 100644 --- a/crates/pptx/tests/malformed.rs +++ b/crates/pptx/tests/malformed.rs @@ -251,7 +251,7 @@ fn export_of_odd_models_is_valid() { } s.shapes.push(Shape { id: ShapeId(7), - kind: ShapeKind::Opaque { xml: " +Metodický prehľadŽiaci pracujú vo dvojiciach.Cieľ: vysvetliť cyklusReflexiaBez odrážkyPomôcky: počítač"#; + +/// A deck with one notes slide, its notes replaced by [`NOTES_XML`]. +fn deck() -> Vec { + 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#""), "{xml}"); +} diff --git a/crates/pptx/tests/roundtrip.rs b/crates/pptx/tests/roundtrip.rs index 5177281c..1a90aa9c 100644 --- a/crates/pptx/tests/roundtrip.rs +++ b/crates/pptx/tests/roundtrip.rs @@ -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#"Manager"#.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#""#).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#""#; + 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#""#; + 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("") && !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:?}"); +} diff --git a/crates/render/src/chart.rs b/crates/render/src/chart.rs index c373eaf4..ac185780 100644 --- a/crates/render/src/chart.rs +++ b/crates/render/src/chart.rs @@ -44,6 +44,18 @@ fn point_fill(ctx: &Ctx, c: &Chart, i: usize, s: &deckcraft_model::chart::Series } } +/// Fill of one bar: a `` 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 ``). +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)); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1618a89d..926bb762 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -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 { diff --git a/crates/render/src/morph_path.rs b/crates/render/src/morph_path.rs index 6579eb18..6947053f 100644 --- a/crates/render/src/morph_path.rs +++ b/crates/render/src/morph_path.rs @@ -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) { - 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) { + 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() + } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 2123cb44..1759855e 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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| { + 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::>(), 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)); +} diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index b9e28fb1..840b7834 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -1170,7 +1170,8 @@ pub fn symbol_char(face: &FontFace, font: &str, c: char) -> Option { 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") { diff --git a/crates/ui-egui/src/background_tests.rs b/crates/ui-egui/src/background_tests.rs new file mode 100644 index 00000000..0bb3d229 --- /dev/null +++ b/crates/ui-egui/src/background_tests.rs @@ -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); +} diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs index 59f06aa6..f7bfe253 100644 --- a/crates/ui-egui/src/backstage.rs +++ b/crates/ui-egui/src/backstage.rs @@ -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); } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index a786b5fb..bc4e080e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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 { }) } +/// 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 { + 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); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 4829e004..30fddc94 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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 { + 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::>()); + } + 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, &[]); diff --git a/crates/ui-egui/src/ime_tests.rs b/crates/ui-egui/src/ime_tests.rs index 911a2d1a..c9bc7dab 100644 --- a/crates/ui-egui/src/ime_tests.rs +++ b/crates/ui-egui/src/ime_tests.rs @@ -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); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d1caa59e..e1f29e7d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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, /// 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 { + 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 { @@ -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 `1.png`, +/// `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)> { + let slides: Vec = 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!("").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| { + 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::>(); + 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| 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| { diff --git a/crates/ui-egui/src/notes.rs b/crates/ui-egui/src/notes.rs new file mode 100644 index 00000000..7714637b --- /dev/null +++ b/crates/ui-egui/src/notes.rs @@ -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, +} + +/// 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 { + 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> { + 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 { + let whole = job(body, size, color, wrap); + if body.paragraphs.is_empty() { + return ui.fonts_mut(|f| f.layout_job(whole)); + } + let parts: Vec> = 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, 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::(), 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)); + } +} diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs index 27423257..39bab078 100644 --- a/crates/ui-egui/src/panes.rs +++ b/crates/ui-egui/src/panes.rs @@ -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)] diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 652a2e60..a53940e1 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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 { diff --git a/crates/ui-egui/src/sorter.rs b/crates/ui-egui/src/sorter.rs index 1300ff48..4c97605c 100644 --- a/crates/ui-egui/src/sorter.rs +++ b/crates/ui-egui/src/sorter.rs @@ -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!({})); diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs index 7bacd91a..d5d03cdf 100644 --- a/crates/ui-egui/src/status.rs +++ b/crates/ui-egui/src/status.rs @@ -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 { 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; }); } diff --git a/crates/ui-egui/src/textures.rs b/crates/ui-egui/src/textures.rs index e1f8b1f5..9730f4d6 100644 --- a/crates/ui-egui/src/textures.rs +++ b/crates/ui-egui/src/textures.rs @@ -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, } 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, 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> { 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, 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) { diff --git a/crates/ui-egui/src/thumbs.rs b/crates/ui-egui/src/thumbs.rs index 82e10d4a..d6c9c02c 100644 --- a/crates/ui-egui/src/thumbs.rs +++ b/crates/ui-egui/src/thumbs.rs @@ -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, diff --git a/docs/animation-parity.md b/docs/animation-parity.md index e27daccc..c8adf49b 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -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 | diff --git a/docs/control-protocol.md b/docs/control-protocol.md index aaca4535..b7b6f013 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 163a3dfa..1b926ce3 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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) | diff --git a/docs/gaps.md b/docs/gaps.md index 6cb3fd4e..fd8b0e05 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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 | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 1ba069ec..c803da55 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -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) | diff --git a/docs/mcp.md b/docs/mcp.md index 07284e70..b2f879b3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 ` or `--flag=`). + +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. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index eda1bc43..316cf715 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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 ` **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 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 815d06b3..1bcff00a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -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 | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b9196b86..0224f768 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -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 |