diff --git a/Cargo.lock b/Cargo.lock index 7451a1b3..ef008b65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2692,6 +2692,16 @@ version = "3.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d" +[[package]] +name = "deckcraft-anim" +version = "0.3.0" +source = "git+https://github.com/ymote/deckcraft.git?rev=d0e57d7e25f9852179cc66be12dd6188f1c05535#d0e57d7e25f9852179cc66be12dd6188f1c05535" +dependencies = [ + "deckcraft-geom", + "deckcraft-model", + "serde", +] + [[package]] name = "deckcraft-av1" version = "0.3.0" @@ -11555,15 +11565,21 @@ dependencies = [ name = "octosense-deck-service" version = "0.1.0" dependencies = [ + "deckcraft-anim", "deckcraft-engine", + "deckcraft-fonts", "deckcraft-format", "deckcraft-model", + "deckcraft-pptx", + "flate2", + "makepad-widgets", "octosense-app-policy", "octosense-appstore", "octosense-engine-area", "octosense-skill-gen", "serde_json", "tempfile", + "zip 3.0.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a9ee1a71..57ec9df3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -240,6 +240,9 @@ wordcraft-doc = { git = "https://github.com/ymote/wordcraft.git", rev = "7584b9b deckcraft-engine = { git = "https://github.com/ymote/deckcraft.git", rev = "d0e57d7e25f9852179cc66be12dd6188f1c05535" } deckcraft-format = { git = "https://github.com/ymote/deckcraft.git", rev = "d0e57d7e25f9852179cc66be12dd6188f1c05535" } deckcraft-model = { git = "https://github.com/ymote/deckcraft.git", rev = "d0e57d7e25f9852179cc66be12dd6188f1c05535" } +deckcraft-anim = { git = "https://github.com/ymote/deckcraft.git", rev = "d0e57d7e25f9852179cc66be12dd6188f1c05535" } +deckcraft-fonts = { git = "https://github.com/ymote/deckcraft.git", rev = "d0e57d7e25f9852179cc66be12dd6188f1c05535" } +deckcraft-pptx = { git = "https://github.com/ymote/deckcraft.git", rev = "d0e57d7e25f9852179cc66be12dd6188f1c05535" } # The cadcraft drafting engine (ADR 0013): the pinned ymote fork, one revision. cadcraft-engine = { git = "https://github.com/ymote/cadcraft.git", rev = "59631c8d4f9ffe6c08c5c2065ea17504e53bf821" } cadcraft-io = { git = "https://github.com/ymote/cadcraft.git", rev = "59631c8d4f9ffe6c08c5c2065ea17504e53bf821" } diff --git a/apps/AGENTS.md b/apps/AGENTS.md index bcb526aa..e1f96eea 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -60,6 +60,81 @@ app's own agent and `tools.json`, the system toolbox, `glance.publish` and Deck, `apps/quickdeck/bundle`, desktop only, whose own `deck.*` calls work in its storage; its dev fixture is in apps/README.md): `apps/deck/host-service` and `cargo test --locked -p octosense-deck-service`. + Besides the file methods (`info`, `text`, `render`, `new`, `convert`, + `run`; the system agent's two tools, `deck.info` and `deck.run`, stay as + they are), it serves Quick Deck v2's open documents to apps' own + requests, as `apps/quickdeck/design/SERVICE.md` specifies; change that + contract and the service together. `deck.open` and `deck.create` keep + the engine's session for a deck, its edits and undo history, under a + handle, on the pdf service's terms (`src/docs.rs`): a document belongs to + the caller app, its storage scope and the handle (anyone else's is + `unknown_doc:`), a caller keeps at most 8 open and the process 32, the + table is a `thread_local` on the UI thread, the listener on App Hub's + `services::on_isolate_closed` releases a closing isolate's documents and + their renders, every call sweeps for a changed storage scope, and a + caller with no isolate gets a 15-minute idle bound. Each method builds + every engine command and its parameters itself and refuses a key it does + not take, then runs them through one gate (`src/gate.rs`): the door + `deck.run` uses, with the same `REVIEWED` limits, held commands and run + ceilings, admits the whole call before any command runs + (`Door::admit_method` in `crates/engine-area`, with the method's own most + commands a call, its reason beside its constant), the ceilings on the + session hold after every command, and a refused command puts the + document back as it was. `shape.fill` and `design.background` (`file`) + run for a method only without `picture` or `path` (`UNREAD`, + `door::Unread`), so the service never has them read a file, and + `deck.run` still refuses them; `insert.picture` and `picture.change` + read a picture inside the storage only, checked first + (`src/pictures.rs`). A change method is one undo step, labelled, of at + most 100: the engine's own steps for its commands are folded through its + `History`, and a call that changes nothing adds none. `rev` and + `changed` come from the engine's copy-on-write slide pointers, compared + by content where the engine copied a slide it did not change. Text is + exact (the session's autocorrect and smart quotes are off), and + `deck.set_text` replaces only the span that changed, so the rest keeps + its formatting. Before the engine reads a byte of a file (`deck.open`, + `deck.cover`, the file methods, the agent's tools), the import guard + (`src/guard.rs`, #448) reads the zip's central directory within bounds + (10,000 entries, names of 1,024 bytes, 64 MiB an entry, 256 MiB in all, + 100:1 past 100 KiB) and only in a shape the engine's own zip reader + takes the same way (one end record, ending the file; entry counts that + agree; a directory filling exactly the space it declares; nothing before + the first entry; every extra field as that reader reads it, refusing + whatever it would refuse), then has that reader, the `zip` crate + deckcraft opens decks with, read the directory too and refuses any + difference (`damaged:`), so the guard checks exactly what the engine + will read. Its verdict (a native deck by deckcraft's own marker, an + Office package by its `[Content_Types].xml` entry, which LibreOffice + writes late, past where the engine's own sniff looks) picks the + engine's reader (`engine_read`, the steps of the pinned + `open_presentation` after its sniff, compared by a test). It inflates + every entry against its declared size and + refuses a nested zip or an audio or video part (`held:`), an encrypted + `.pptx` (`protected:`) and a legacy `.ppt` or any other file it does not + know (`unsupported:`); engine-side bounds stay with #448. The one nested + zip it lets through is an Office package's embedded object under + `ppt/embeddings/` (a chart's workbook, an embedded document), which + neither the service nor deckcraft ever unzips; it still counts against + every bound, and a zip anywhere else stays `held:`. Present's order + and build steps (`deck.show`, `deck.view {step}`) come from + deckcraft-anim at the engine's pin. Caps: drawing 48–3840 px wide, 4096 + a side and 16 MP, 500 shapes or slides named a call, 1,024 table cells + filled, 20,000 characters of notes, 50 MP a picture, a PDF ≤ 160 MP and + an image export ≤ 64 slides and 256 MP (checked before anything is + written), and a render cache of 16 MiB and 64 files: slide drawings in + `.cache/slides//`, and, from `deck.cover` (a file method for + apps, no agent tool), each deck's first slide in `.cache/covers/`, at + most 32, kept across closes and named by the file's name, size and + modification time (`src/covers.rs`). Slide drawings go before covers, + each oldest first, and the whole cache is cleared before a write would + fail with `storage_full:`. Errors start with a stable code + (`src/codes.rs`). Every call first waits for a warm-up that is still + running (`src/warm.rs`): deckcraft's font database cannot load one + family on two threads at once, and a slide drawn during that race kept + the last-resort face. `src/doc_tests.rs` holds its tests, and + `cargo test --release --locked -p octosense-deck-service --lib -- + --ignored timings` times a 30-slide deck's open, first drawing and + edits. The cad engine's (cadcraft behind `cad.*`, same ADR, no bundle yet): `apps/cad/host-service` and `cargo test --locked -p octosense-cad-service`. The light engine's (lightcraft behind `light.*`, same ADR, no bundle yet): diff --git a/apps/README.md b/apps/README.md index 7723a09e..5f94e5d5 100644 --- a/apps/README.md +++ b/apps/README.md @@ -38,7 +38,7 @@ them. They live in `apps/` of the [OctoSense repository](../README.md); until speech, embedding and asynchronous video requests. Apps declare `model` and exact host API versions; provider entitlement is checked when used. See the [media contract and validation limits](ai-providers/host-service/MEDIA.md). -- **The `deck` host service** (`deck/host-service`, ADR 0013) is the deckcraft presentation engine behind `deck.*`: decks inspected, read as outline text, rendered to PNG, created from titles and bullets, and converted (pptx, native, outline, PDF), confined to the caller's own folder. Its app is Quick Deck (`quickdeck/bundle`), whose calls work in Quick Deck's own storage. +- **The `deck` host service** (`deck/host-service`, ADR 0013) is the deckcraft presentation engine behind `deck.*`: decks inspected, read as outline text, rendered to PNG, created from titles and bullets, and converted (pptx, native, outline, PDF), confined to the caller's own folder. Its app is Quick Deck (`quickdeck/bundle`), whose calls work in Quick Deck's own storage. For Quick Deck v2 it also keeps open documents (`apps/quickdeck/design/SERVICE.md`): `deck.open` and `deck.create` return a handle, and the methods on it (slides, objects, text, formatting, design, notes, transitions, undo and redo, drawing a slide into a render cache, Present's order and build steps, save and export) each run reviewed engine commands through the same gate as the system agent's `deck.run`, as one undo step. Every file the service opens first passes its own import guard, which refuses a zip beyond its bounds, audio and video, an encrypted `.pptx` and a legacy `.ppt` before the engine reads it. - **The `cad` engine service** (`cad/host-service`): the cadcraft drafting engine behind `cad.*` (ADR 0013, no bundle yet) — inspect, query, measure, render and convert DXF/DWG drawings, every path inside the caller's own folder. - **The `light` engine service** (`light/host-service`) is lightcraft's RAW develop engine behind `light.*` (ADR 0013): EXIF/XMP metadata, the develop diff --git a/apps/README.zh-CN.md b/apps/README.zh-CN.md index b9b1ef55..6f03889d 100644 --- a/apps/README.zh-CN.md +++ b/apps/README.zh-CN.md @@ -33,7 +33,7 @@ OctoSense-System-Apps 仓库(已归档)。 - **`model` 媒体 API** 使用宿主持有的供应商配置,执行有上限的图片、语音、向量和异步 视频请求。应用声明 `model` 和精确宿主 API 版本;调用时检查供应商权益。见 [媒体契约与验证范围](ai-providers/host-service/MEDIA.zh-CN.md)。 -- **`deck` 宿主服务**(`deck/host-service`,ADR 0013)把 deckcraft 演示文稿引擎放在 `deck.*` 之后:检查幻灯片、提取大纲文本、渲染 PNG、按标题和要点新建演示文稿,以及格式转换(pptx、原生格式、大纲、PDF),全部限制在调用方自己的文件夹内。它的应用是 Quick Deck(`quickdeck/bundle`),其调用在 Quick Deck 自己的存储中运行。 +- **`deck` 宿主服务**(`deck/host-service`,ADR 0013)把 deckcraft 演示文稿引擎放在 `deck.*` 之后:检查幻灯片、提取大纲文本、渲染 PNG、按标题和要点新建演示文稿,以及格式转换(pptx、原生格式、大纲、PDF),全部限制在调用方自己的文件夹内。它的应用是 Quick Deck(`quickdeck/bundle`),其调用在 Quick Deck 自己的存储中运行。它还为 Quick Deck v2 保持打开的文档(`apps/quickdeck/design/SERVICE.md`):`deck.open` 和 `deck.create` 返回一个句柄,作用于它的方法(幻灯片、对象、文本、格式、设计、备注、切换效果、撤销与重做、把幻灯片绘制到渲染缓存、放映的顺序与动画步骤、保存与导出)都通过与系统代理的 `deck.run` 相同的入口运行经过审查的引擎命令,每次调用合为一个撤销步骤。服务打开的每个文件都先通过它自己的导入检查:在引擎读取之前,拒绝超出界限的 zip、音频和视频、加密的 `.pptx` 以及旧版 `.ppt`。 - **`cad` 引擎服务**(`cad/host-service`):cadcraft 制图引擎,提供 `cad.*` 方法(ADR 0013,暂无 bundle)——在调用方自己的文件夹内检查、查询、测量、渲染和转换 DXF/DWG 图纸。 - **`light` 引擎服务**(`light/host-service`)把 lightcraft 的 RAW 显影引擎 放在 `light.*` 方法之后(ADR 0013):EXIF/XMP 元数据、显影控制目录、单张与 diff --git a/apps/deck/host-service/Cargo.toml b/apps/deck/host-service/Cargo.toml index 24424c4e..63ac4478 100644 --- a/apps/deck/host-service/Cargo.toml +++ b/apps/deck/host-service/Cargo.toml @@ -7,8 +7,10 @@ # text and PDF — all file I/O done by the service itself, confined to # the call's area: the caller's own folder from the shell's resolver, # else the legacy `deck/` corner of the caller's host directory. -# Headless by design: agents build and read decks with no deck UI -# existing yet. +# Quick Deck v2's open documents keep the engine's session, its edits and +# undo history between calls (apps/quickdeck/design/SERVICE.md), and every +# file is checked by the service's own import guard before the engine +# reads a byte of it. [package] name = "octosense-deck-service" version = "0.1.0" @@ -17,7 +19,9 @@ license = "Apache-2.0" publish = false [dependencies] -# The same App Hub rev as Mail and News: one appstore, one service registry. +# The same App Hub rev as Mail and News: one appstore, one service registry +# (and, through it, Makepad's per-isolate storage scope an open document is +# bound to). octosense-appstore = { workspace = true } # Where a call works: the caller's area and its write rules (ADR 0013). octosense-engine-area = { workspace = true } @@ -26,10 +30,31 @@ serde_json = { workspace = true } deckcraft-engine = { workspace = true } deckcraft-format = { workspace = true } deckcraft-model = { workspace = true } +# Present: the show order and a slide's build steps (src/reading.rs). +deckcraft-anim = { workspace = true } +# The font families the engine can draw (deck.fonts, deck.open's +# missing_fonts). +deckcraft-fonts = { workspace = true } +# The import guard inflates every zip entry, bounded, to check it is no +# bigger than its directory says before the engine reads it (src/guard.rs). +flate2 = "1" +# The engine's own zip reader, which the import guard asks to read every +# directory it passes, so the two never read a zip differently +# (src/guard.rs `read_alike`): deckcraft's own version and features +# (its root Cargo.toml), which a test keeps the same crate in Cargo.lock. +zip = { version = "3", default-features = false, features = ["deflate"] } +# The engine's Office package reader, which the service calls itself for a +# package the import guard classified (src/lib.rs `engine_read`, the steps +# of the engine's `open_presentation`), since the engine's own sniff misses +# a package whose content-type list comes late, as LibreOffice writes them. +deckcraft-pptx = { workspace = true } [dev-dependencies] # Generates and checks the skill's references (tests/skill.rs). octosense-skill-gen = { workspace = true } # Its tool manifest is checked with App Hub's own loader (tools.json). octosense-app-policy = { workspace = true } +# A real app isolate with a storage scope, whose closing releases the +# documents it opened (src/doc_tests.rs). +makepad-widgets = { workspace = true } tempfile = "3" diff --git a/apps/deck/host-service/src/args.rs b/apps/deck/host-service/src/args.rs new file mode 100644 index 00000000..7038ded6 --- /dev/null +++ b/apps/deck/host-service/src/args.rs @@ -0,0 +1,229 @@ +//! Reading a call's arguments. Every helper refuses with `invalid:` (a cap +//! with `too_large:`) and names the key. Integers may arrive as whole +//! floats (`3.0`): a script's numbers are doubles. The values these helpers +//! return are what the service builds every engine argument from: a method +//! never hands the engine a caller's object. + +use serde_json::Value as Json; + +use crate::codes::{invalid, too_large}; + +/// Refuse any argument `method` does not take. The engine commands behind a +/// method only ever get the parameters reviewed for them, which the service +/// builds itself; a key it was not reviewed for (a `picture` on a fill, a +/// `path` on a background) is refused here, before any engine work. A key +/// set to `null` counts as absent. +pub(crate) fn only(args: &Json, keys: &[&str], method: &str) -> Result<(), String> { + match args { + Json::Null => Ok(()), + Json::Object(map) => match map.iter().find(|(k, v)| !v.is_null() && !keys.contains(&k.as_str())) { + Some((k, _)) => Err(invalid(format!("{method} takes {}; `{k}` is not one of them", keys.join(", ")))), + None => Ok(()), + }, + _ => Err(invalid(format!("{method}'s arguments are an object"))), + } +} + +pub(crate) fn present<'a>(args: &'a Json, key: &str) -> Option<&'a Json> { + args.get(key).filter(|v| !v.is_null()) +} + +/// A required, non-empty string. +pub(crate) fn need_str<'a>(args: &'a Json, key: &str, m: &str) -> Result<&'a str, String> { + present(args, key).and_then(Json::as_str).filter(|s| !s.is_empty()).ok_or_else(|| invalid(format!("{m} needs `{key}`, a string"))) +} + +/// An optional string of at most `max` characters (it may be empty). +pub(crate) fn opt_str<'a>(args: &'a Json, key: &str, m: &str, max: usize) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + let s = v.as_str().ok_or_else(|| invalid(format!("{m}: `{key}` is a string")))?; + if s.chars().count() > max { + return Err(too_large(format!("{m}: `{key}` is at most {max} characters"))); + } + Ok(Some(s)) +} + +/// A required string of at most `max` characters (it may be empty). +pub(crate) fn need_text<'a>(args: &'a Json, key: &str, m: &str, max: usize) -> Result<&'a str, String> { + opt_str(args, key, m, max)?.ok_or_else(|| invalid(format!("{m} needs `{key}`, a string"))) +} + +/// A whole number, from an integer or a whole float. +pub(crate) fn whole(v: &Json) -> Option { + v.as_i64().or_else(|| v.as_f64().filter(|f| f.fract() == 0.0 && f.abs() < 9.0e15).map(|f| f as i64)) +} + +/// An optional whole number. +pub(crate) fn opt_int(args: &Json, key: &str, m: &str) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + whole(v).map(Some).ok_or_else(|| invalid(format!("{m}: `{key}` is a whole number"))) +} + +/// An optional whole number in `lo..=hi`. +pub(crate) fn opt_int_in(args: &Json, key: &str, m: &str, lo: i64, hi: i64) -> Result, String> { + match opt_int(args, key, m)? { + Some(n) if !(lo..=hi).contains(&n) => Err(invalid(format!("{m}: `{key}` is {lo} to {hi}"))), + n => Ok(n), + } +} + +pub(crate) fn opt_bool(args: &Json, key: &str, m: &str) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + v.as_bool().map(Some).ok_or_else(|| invalid(format!("{m}: `{key}` is true or false"))) +} + +pub(crate) fn need_bool(args: &Json, key: &str, m: &str) -> Result { + opt_bool(args, key, m)?.ok_or_else(|| invalid(format!("{m} needs `{key}`, true or false"))) +} + +/// An optional finite number in `lo..=hi`. +pub(crate) fn opt_num(args: &Json, key: &str, m: &str, lo: f64, hi: f64) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + match v.as_f64().filter(|f| f.is_finite()) { + Some(f) if f >= lo && f <= hi => Ok(Some(f)), + Some(_) => Err(invalid(format!("{m}: `{key}` is {lo} to {hi}"))), + None => Err(invalid(format!("{m}: `{key}` is a number"))), + } +} + +pub(crate) fn need_num(args: &Json, key: &str, m: &str, lo: f64, hi: f64) -> Result { + opt_num(args, key, m, lo, hi)?.ok_or_else(|| invalid(format!("{m} needs `{key}`, a number"))) +} + +/// An engine id (a slide's or a shape's): a whole number the engine's ids +/// can be. +fn engine_id(v: &Json) -> Option { + whole(v).and_then(|n| u32::try_from(n).ok()) +} + +/// A required slide or shape id. +pub(crate) fn id(args: &Json, key: &str, m: &str) -> Result { + present(args, key).and_then(engine_id).ok_or_else(|| invalid(format!("{m} needs `{key}`, an id the deck gave"))) +} + +/// Ids: 1 to `max` of them, distinct (in the order given). +pub(crate) fn ids(args: &Json, key: &str, m: &str, max: usize) -> Result, String> { + let list = present(args, key).and_then(Json::as_array).ok_or_else(|| invalid(format!("{m} needs `{key}`, a list of ids")))?; + if list.is_empty() { + return Err(invalid(format!("{m}: `{key}` names at least one"))); + } + if list.len() > max { + return Err(too_large(format!("{m}: `{key}` names at most {max} in one call"))); + } + let mut out: Vec = Vec::with_capacity(list.len()); + for v in list { + let id = engine_id(v).ok_or_else(|| invalid(format!("{m}: `{key}` holds ids the deck gave")))?; + if !out.contains(&id) { + out.push(id); + } + } + Ok(out) +} + +/// An optional list of ids, as [`ids`]. +pub(crate) fn opt_ids(args: &Json, key: &str, m: &str, max: usize) -> Result>, String> { + if present(args, key).is_none() { + return Ok(None); + } + ids(args, key, m, max).map(Some) +} + +/// An optional 0-based position. +pub(crate) fn opt_index(args: &Json, key: &str, m: &str) -> Result, String> { + match opt_int(args, key, m)? { + Some(n) if n >= 0 => Ok(Some(n as usize)), + Some(_) => Err(invalid(format!("{m}: `{key}` is a position from 0"))), + None => Ok(None), + } +} + +pub(crate) fn need_index(args: &Json, key: &str, m: &str) -> Result { + opt_index(args, key, m)?.ok_or_else(|| invalid(format!("{m} needs `{key}`, a position from 0"))) +} + +fn finite(v: &Json) -> Option { + v.as_f64().filter(|f| f.is_finite()) +} + +/// A box `[x, y, w, h]` in points, with a width and height of at least 0. +/// (How far it may reach is the door's limit on the command's geometry.) +pub(crate) fn opt_rect(args: &Json, key: &str, m: &str) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + match v.as_array().map(|a| a.iter().map(finite).collect::>>()) { + Some(Some(r)) if r.len() == 4 && r[2] >= 0.0 && r[3] >= 0.0 => Ok(Some([r[0], r[1], r[2], r[3]])), + _ => Err(invalid(format!("{m}: `{key}` is [x, y, w, h] in points"))), + } +} + +/// A point `[x, y]` in points. +pub(crate) fn opt_point(args: &Json, key: &str, m: &str) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + match v.as_array().map(|a| a.iter().map(finite).collect::>>()) { + Some(Some(p)) if p.len() == 2 => Ok(Some([p[0], p[1]])), + _ => Err(invalid(format!("{m}: `{key}` is [x, y] in points"))), + } +} + +/// A table cell `[row, column]`, 0-based. +pub(crate) fn opt_cell(args: &Json, key: &str, m: &str) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + cell_of(v).map(Some).ok_or_else(|| invalid(format!("{m}: `{key}` is [row, column], from 0"))) +} + +pub(crate) fn cell_of(v: &Json) -> Option<[usize; 2]> { + let a = v.as_array().filter(|a| a.len() == 2)?; + let n = |i: usize| whole(&a[i]).and_then(|n| usize::try_from(n).ok()); + Some([n(0)?, n(1)?]) +} + +/// The theme's colour slots a colour may name. +const SLOTS: [&str; 16] = ["accent1", "accent2", "accent3", "accent4", "accent5", "accent6", "tx1", "tx2", "bg1", "bg2", "dk1", "dk2", "lt1", "lt2", "hlink", "folHlink"]; + +/// A colour as the service hands it to the engine: `#RRGGBB`, written from +/// the three bytes it parsed, or one of the theme's colour slots. +pub(crate) fn color_value(v: &Json) -> Option { + let s = v.as_str()?; + if let Some(slot) = SLOTS.iter().find(|slot| **slot == s) { + return Some((*slot).to_string()); + } + let hex = s.strip_prefix('#').filter(|h| h.len() == 6 && h.bytes().all(|b| b.is_ascii_hexdigit()))?; + let byte = |i: usize| u8::from_str_radix(&hex[i..i + 2], 16).ok(); + Some(format!("#{:02X}{:02X}{:02X}", byte(0)?, byte(2)?, byte(4)?)) +} + +pub(crate) fn opt_color(args: &Json, key: &str, m: &str) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + color_value(v).map(Some).ok_or_else(|| invalid(format!("{m}: `{key}` is #RRGGBB or a theme colour ({})", SLOTS.join(", ")))) +} + +/// One of `choices`, exactly. +pub(crate) fn opt_choice<'a>(args: &'a Json, key: &str, m: &str, choices: &[&str]) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + match v.as_str() { + Some(s) if choices.contains(&s) => Ok(Some(s)), + _ => Err(invalid(format!("{m}: `{key}` is {}", choices.join(", ")))), + } +} + +pub(crate) fn need_choice<'a>(args: &'a Json, key: &str, m: &str, choices: &[&str]) -> Result<&'a str, String> { + opt_choice(args, key, m, choices)?.ok_or_else(|| invalid(format!("{m} needs `{key}`: {}", choices.join(", ")))) +} + +/// A slide size: `"16:9"`, `"4:3"` or `[w, h]` in points. +pub(crate) fn opt_size(args: &Json, key: &str, m: &str) -> Result, String> { + let Some(v) = present(args, key) else { return Ok(None) }; + let wrong = || invalid(format!("{m}: `{key}` is \"16:9\", \"4:3\" or [w, h] in points")); + match v { + Json::String(s) if s == "16:9" => Ok(Some([960.0, 540.0])), + Json::String(s) if s == "4:3" => Ok(Some([720.0, 540.0])), + Json::Array(a) if a.len() == 2 => { + let (w, h) = (finite(&a[0]).ok_or_else(wrong)?, finite(&a[1]).ok_or_else(wrong)?); + // The engine's own bounds on a slide side; the door bounds the area. + if !(72.0..=4032.0).contains(&w) || !(72.0..=4032.0).contains(&h) { + return Err(invalid(format!("{m}: `{key}`'s sides are 72 to 4032 points"))); + } + Ok(Some([w, h])) + } + _ => Err(wrong()), + } +} diff --git a/apps/deck/host-service/src/cache.rs b/apps/deck/host-service/src/cache.rs new file mode 100644 index 00000000..195bcc25 --- /dev/null +++ b/apps/deck/host-service/src/cache.rs @@ -0,0 +1,340 @@ +//! The render cache (SERVICE.md "Reading and drawing", "Cache"), as PDF +//! Tools' (decision 6): `deck.view` writes each slide it draws, and +//! `deck.layouts` each layout picture, to `.cache/slides//` in the +//! caller's storage, and `deck.cover` a deck's first slide to +//! `.cache/covers/` ([`crate::covers`]). +//! +//! - At most [`MAX_BYTES`] (16 MiB) and [`MAX_FILES`] renders per storage, +//! slide renders, layout pictures and covers together, at most +//! [`MAX_COVERS`] of them covers. When the cache needs room, slide renders +//! (and layout pictures) go before covers, each oldest first (by when they +//! were written). +//! - The file cap is the service's own: an app's storage holds at most 256 +//! entries for the app's own writes (makepad's +//! `splash_storage::MAX_ENTRIES`), and the library, its pictures and its +//! exports need room too. +//! - A document's renders go when it closes ([`forget`]) and when a later +//! open finds it gone ([`prune`]); both reach `.cache/slides` alone, so +//! covers stay across closes and runs. A render of an older revision of +//! the same slide, size and mode goes when the new one is written +//! ([`drop_older`]). +//! - Before any write of the service would fail for room (`storage_full:`), +//! the whole cache, covers too, is cleared ([`make_room`]). +//! - The cache is the service's: a render replaces a stale render at the +//! same name. It never walks or writes through a link: a cache folder that +//! is a link is refused, and only regular files under plain folders are +//! counted or removed. + +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use octosense_engine_area::Area; + +use crate::codes::{self, invalid, too_large}; + +/// The cache's slide renders and layout pictures, relative to the storage. +pub(crate) const DIR: &str = ".cache/slides"; +/// Its covers, relative to the storage. +pub(crate) const COVERS: &str = ".cache/covers"; +/// The most bytes of renders one storage keeps. +pub(crate) const MAX_BYTES: u64 = 16 << 20; +/// The most renders one storage keeps. +pub(crate) const MAX_FILES: usize = 64; +/// The most covers among them. +pub(crate) const MAX_COVERS: usize = 32; + +/// Where a render named `file` of document `handle` goes. +pub(crate) fn render_path(handle: &str, file: &str) -> String { + format!("{DIR}/{handle}/{file}") +} + +/// Whether `path` is a plain folder (`Ok(false)`: not there); a link or a +/// file in the way is refused. +fn plain_dir(path: &Path, shown: &str) -> Result { + match std::fs::symlink_metadata(path) { + Ok(meta) if meta.is_dir() => Ok(true), + Ok(_) => Err(invalid(format!("the render cache folder `{shown}` is not a plain folder"))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(invalid(format!("the render cache folder `{shown}`: {e}"))), + } +} + +/// The cache's own folder in `area`, `.cache`, when it is there and plain. +fn cache_dir(area: &Area) -> Result, String> { + let dot = area.root.join(".cache"); + Ok(plain_dir(&dot, ".cache")?.then_some(dot)) +} + +/// The slide renders' folder in `area`, when it is there and plain all the +/// way. [`forget`] and [`prune`] reach nothing else. +fn root(area: &Area) -> Result, String> { + let Some(dot) = cache_dir(area)? else { return Ok(None) }; + let slides = dot.join("slides"); + Ok(plain_dir(&slides, DIR)?.then_some(slides)) +} + +/// The covers' folder in `area`, when it is there and plain all the way. +pub(crate) fn covers_root(area: &Area) -> Result, String> { + let Some(dot) = cache_dir(area)? else { return Ok(None) }; + let covers = dot.join("covers"); + Ok(plain_dir(&covers, COVERS)?.then_some(covers)) +} + +struct Render { + path: PathBuf, + len: u64, + written: SystemTime, + /// A cover (`.cache/covers`), not a slide render. + cover: bool, +} + +/// The regular files directly in `dir`, as renders. +fn files_in(dir: &Path, cover: bool, out: &mut Vec) { + let Ok(files) = std::fs::read_dir(dir) else { return }; + for file in files.flatten() { + let Ok(meta) = std::fs::symlink_metadata(file.path()) else { continue }; + if meta.is_file() { + out.push(Render { path: file.path(), len: meta.len(), written: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH), cover }); + } + } +} + +/// Every render in the cache folder `dot` (`.cache`): regular files in the +/// documents' plain folders under `slides/`, and in a plain `covers/`. +/// Links and anything else are neither counted nor followed. +fn renders(dot: &Path) -> Vec { + let mut out = Vec::new(); + let plain = |p: &Path| std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir()); + let slides = dot.join("slides"); + if plain(&slides) { + if let Ok(docs) = std::fs::read_dir(&slides) { + for doc in docs.flatten() { + if plain(&doc.path()) { + files_in(&doc.path(), false, &mut out); + } + } + } + } + let covers = dot.join("covers"); + if plain(&covers) { + files_in(&covers, true, &mut out); + } + out +} + +/// Remove a document's folder when it is empty (never a link). +fn drop_if_empty(dir: &Path) { + if std::fs::symlink_metadata(dir).is_ok_and(|m| m.is_dir()) { + let _ = std::fs::remove_dir(dir); + } +} + +/// The limits [`evict`] keeps to: the cache's own, or a test's. +#[derive(Clone, Copy)] +pub(crate) struct Limits { + pub bytes: u64, + pub files: usize, + pub covers: usize, +} + +pub(crate) const LIMITS: Limits = Limits { bytes: MAX_BYTES, files: MAX_FILES, covers: MAX_COVERS }; + +/// Make room in the cache folder `dot` (`.cache`) for one more render of +/// `incoming` bytes at `keep` (which it replaces, so it does not count): +/// covers past their cap go first, oldest first (a new cover at `keep` +/// counts); then slide renders before covers, each oldest first, until the +/// cache with it fits `limits`. +pub(crate) fn evict(dot: &Path, keep: &Path, incoming: u64, limits: Limits) { + let keeps_cover = keep.parent() == Some(dot.join("covers").as_path()); + let mut all: Vec = renders(dot).into_iter().filter(|r| r.path != keep).collect(); + // Slide renders before covers, each oldest first. + all.sort_by_key(|r| (r.cover, r.written)); + let cover_room = limits.covers.saturating_sub(usize::from(keeps_cover)); + let mut covers = all.iter().filter(|r| r.cover).count(); + let mut left: Vec = Vec::with_capacity(all.len()); + for old in all { + if old.cover && covers > cover_room && remove_render(&old, keep) { + covers -= 1; + } else { + left.push(old); + } + } + let mut bytes: u64 = left.iter().map(|r| r.len).sum(); + let mut files = left.len(); + for old in &left { + if bytes.saturating_add(incoming) <= limits.bytes && files < limits.files { + break; + } + if remove_render(old, keep) { + bytes = bytes.saturating_sub(old.len); + files -= 1; + } + } +} + +/// Remove one render, and a slide render's document folder once it is +/// empty (not the folder `keep` goes in). +fn remove_render(old: &Render, keep: &Path) -> bool { + if std::fs::remove_file(&old.path).is_err() { + return false; + } + if let Some(dir) = old.path.parent().filter(|d| !old.cover && Some(*d) != keep.parent()) { + drop_if_empty(dir); + } + true +} + +/// Clear the whole cache, slide renders and covers; the bytes it held. +fn clear(area: &Area) -> u64 { + let Ok(Some(dot)) = cache_dir(area) else { return 0 }; + let mut freed = 0; + for old in renders(&dot) { + if std::fs::remove_file(&old.path).is_ok() { + freed += old.len; + } + if let Some(dir) = old.path.parent().filter(|_| !old.cover) { + drop_if_empty(dir); + } + } + freed +} + +/// A render kept at `rel` in the cache: its width and height from its PNG +/// header, if it is a regular file under plain folders whose header reads as +/// a PNG within the cache's bounds; `None` otherwise (draw it). +pub(crate) fn kept_png(area: &Area, rel: &str) -> Option<(u64, u64)> { + cache_dir(area).ok()??; + let path = area.root.join(rel); + if let Some(dir) = path.parent() { + if !std::fs::symlink_metadata(dir).is_ok_and(|m| m.is_dir()) { + return None; + } + } + let meta = std::fs::symlink_metadata(&path).ok().filter(|m| m.is_file() && m.len() <= MAX_BYTES)?; + let mut head = [0u8; 24]; + std::fs::File::open(&path).ok()?.read_exact(&mut head).ok()?; + if meta.len() < 33 || &head[..8] != b"\x89PNG\r\n\x1a\n" || &head[12..16] != b"IHDR" { + return None; + } + let be = |at: usize| u64::from(u32::from_be_bytes([head[at], head[at + 1], head[at + 2], head[at + 3]])); + Some((be(16), be(20))).filter(|(w, h)| *w > 0 && *h > 0) +} + +/// Remove the files in the plain folder `dir` whose names start with +/// `prefix` but not with `current`: a render's or cover's older versions, +/// never another's. +fn drop_matching(dir: &Path, prefix: &str, current: &str) { + let Ok(files) = std::fs::read_dir(dir) else { return }; + for file in files.flatten() { + let name = file.file_name(); + let Some(name) = name.to_str() else { continue }; + if name.starts_with(prefix) && !name.starts_with(current) && std::fs::symlink_metadata(file.path()).is_ok_and(|m| m.is_file()) { + let _ = std::fs::remove_file(file.path()); + } + } +} + +/// Remove the covers in `.cache/covers` whose names start with `prefix` +/// (one deck's covers, [`crate::covers`]) but not with `current` (its +/// current version's): a deck's older covers, never another deck's. +pub(crate) fn drop_covers(area: &Area, prefix: &str, current: &str) { + if let Ok(Some(dir)) = covers_root(area) { + drop_matching(&dir, prefix, current); + } +} + +/// Remove document `handle`'s renders whose names start with `prefix` (one +/// slide, size and mode) but not with `current` (its current revision's). +pub(crate) fn drop_older(area: &Area, handle: &str, prefix: &str, current: &str) { + let Ok(Some(root)) = root(area) else { return }; + let dir = root.join(handle); + if std::fs::symlink_metadata(&dir).is_ok_and(|m| m.is_dir()) { + drop_matching(&dir, prefix, current); + } +} + +/// Before a write of `need` bytes in `area`: when the storage has less room +/// left, clear the render cache first and give the call the room it freed. +/// The write's own check then answers `storage_full:` if that was not +/// enough. +pub(crate) fn make_room(area: &Area, need: u64) { + if area.room().is_some_and(|room| need > room) { + area.credit(clear(area)); + } +} + +/// The size of the regular file at `path` (0: none, or a link). +pub(crate) fn regular_len(path: &Path) -> u64 { + std::fs::symlink_metadata(path).ok().filter(|m| m.is_file()).map_or(0, |m| m.len()) +} + +/// Whether the render at `rel` is still the `len` bytes this service wrote: +/// the cache may have been cleared or trimmed since. +pub(crate) fn still_there(area: &Area, rel: &str, len: u64) -> bool { + std::fs::symlink_metadata(area.root.join(rel)).is_ok_and(|m| m.is_file() && m.len() == len) +} + +/// Write `png` to the cache at `rel` (under [`DIR`] or [`COVERS`]), within +/// the cache's bounds and the storage's room. +pub(crate) fn store(area: &Area, rel: &str, png: &[u8], m: &str) -> Result<(), String> { + let len = png.len() as u64; + if len > MAX_BYTES { + return Err(too_large(format!("{m}: this picture is {} MB, more than the {} MB render cache holds: draw it smaller", len >> 20, MAX_BYTES >> 20))); + } + let target = crate::inside(area, rel, "path", m)?; + // The folders on the way must be plain: a link would have the cache + // count, trim or write files somewhere else in the storage. + root(area)?; + covers_root(area)?; + if let Some(dir) = target.parent() { + plain_dir(dir, &area.shown(dir))?; + } + if let Some(dot) = cache_dir(area)? { + evict(&dot, &target, len, LIMITS); + } + make_room(area, len.saturating_sub(regular_len(&target))); + // The service's own cache: a stale render at the same name goes, by an + // atomic rename that replaces a link rather than writing through it. + Area::new(area.root.clone(), area.room(), true).write(&target, png).map_err(codes::area) +} + +/// Remove the renders of document `handle` (it closed). +pub(crate) fn forget(area: &Area, handle: &str) { + if let Ok(Some(root)) = root(area) { + remove_doc(&root.join(handle)); + } +} + +/// Remove the renders of every document in `area` whose handle is not in +/// `open`: documents closed, released, or opened by an earlier run. +pub(crate) fn prune(area: &Area, open: &[String]) { + let Ok(Some(root)) = root(area) else { return }; + let Ok(docs) = std::fs::read_dir(&root) else { return }; + for doc in docs.flatten() { + if !open.iter().any(|h| doc.file_name().to_str() == Some(h.as_str())) { + remove_doc(&doc.path()); + } + } +} + +/// A document's cache folder and the regular files in it; a link there is +/// removed itself, never followed. +fn remove_doc(dir: &Path) { + match std::fs::symlink_metadata(dir) { + Ok(meta) if meta.is_dir() => { + if let Ok(files) = std::fs::read_dir(dir) { + for file in files.flatten() { + if std::fs::symlink_metadata(file.path()).is_ok_and(|m| !m.is_dir()) { + let _ = std::fs::remove_file(file.path()); + } + } + } + let _ = std::fs::remove_dir(dir); + } + Ok(_) => { + let _ = std::fs::remove_file(dir); + } + Err(_) => {} + } +} diff --git a/apps/deck/host-service/src/codes.rs b/apps/deck/host-service/src/codes.rs new file mode 100644 index 00000000..58f25508 --- /dev/null +++ b/apps/deck/host-service/src/codes.rs @@ -0,0 +1,121 @@ +//! The service's stable error codes (`apps/quickdeck/design/SERVICE.md`, +//! "Errors"): every `Err` of an open-document method, of `deck.cover` and of +//! the import guard starts with one, then a colon and plain words for a +//! person. The words never name a host path: `crate::serve` takes the +//! area's own spelling out of every error. The v1 file methods keep their +//! `deck.: …` words, except a refusal of the import guard, which +//! carries its code on every path. + +use std::fmt::Display; + +use deckcraft_engine::EngineError; +use octosense_engine_area::door::{Refusal, Refused}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Code { + /// No such file. + NotFound, + /// The engine couldn't read it; the engine's reason follows. + Damaged, + /// Not a presentation the engine reads: `.ppt`, `.key`, `.odp`, PDF. + Unsupported, + /// An encrypted `.pptx`, which needs a password. + Protected, + /// The deck holds audio or video, or its zip is beyond the import guard + /// (#448). + Held, + /// Over one of the service's caps. + TooLarge, + /// The storage has no room. + StorageFull, + /// The caller already has the most documents open. + TooManyOpen, + /// No such handle for this caller and scope. + UnknownDoc, + /// A slide or shape id the document doesn't have. + UnknownId, + /// The document has unsaved changes. + Unsaved, + /// Anything else the call got wrong: a path outside storage, a taken + /// output name, nothing to undo or redo, a key the method doesn't take. + Invalid, +} + +impl Code { + pub(crate) fn as_str(self) -> &'static str { + match self { + Code::NotFound => "not_found", + Code::Damaged => "damaged", + Code::Unsupported => "unsupported", + Code::Protected => "protected", + Code::Held => "held", + Code::TooLarge => "too_large", + Code::StorageFull => "storage_full", + Code::TooManyOpen => "too_many_open", + Code::UnknownDoc => "unknown_doc", + Code::UnknownId => "unknown_id", + Code::Unsaved => "unsaved", + Code::Invalid => "invalid", + } + } +} + +/// An error: `code`, a colon, and `words`. +pub(crate) fn fail(code: Code, words: impl Display) -> String { + format!("{}: {words}", code.as_str()) +} + +/// `fail(Invalid, …)`, the most common refusal. +pub(crate) fn invalid(words: impl Display) -> String { + fail(Code::Invalid, words) +} + +/// `fail(TooLarge, …)`. +pub(crate) fn too_large(words: impl Display) -> String { + fail(Code::TooLarge, words) +} + +/// The door's refusal of a method's commands: a count or a limit is over a +/// cap (`too_large:`), a held command is `held:`, anything else `invalid:` +/// (a command the service built that the gate does not run is the +/// service's mistake, never run). +pub(crate) fn refusal(r: &Refusal) -> String { + match r.kind { + Refused::Count | Refused::Limit => too_large(&r.words), + Refused::Held => fail(Code::Held, &r.words), + Refused::Other => invalid(&r.words), + } +} + +/// The engine's refusal of a command the gate admitted: what the call asked +/// for does not fit the document. +pub(crate) fn engine(method: &str, id: &str, e: &EngineError) -> String { + match e { + EngineError::Internal(..) => fail(Code::Damaged, format!("{method}: the deck engine failed in `{id}`: {e}")), + _ => invalid(format!("{method}: {e}")), + } +} + +/// A refusal from the area's write rules (`octosense_engine_area`): its +/// quota refusal (`octosense_engine_area::no_room`, the area's own test of +/// its own words) is `storage_full:`, and so is a disk that is full, which +/// the area passes on in the system's words (`ENOSPC`); anything else (a +/// name that is taken, a folder in the way) is `invalid:`. +pub(crate) fn area(words: impl Display) -> String { + let words = words.to_string(); + if octosense_engine_area::no_room(&words) || words.contains("No space left") { + fail(Code::StorageFull, words) + } else { + invalid(words) + } +} + +/// An error of the service's own file write: a full disk or quota +/// (`io::ErrorKind::StorageFull`, `QuotaExceeded`) is `storage_full:`, +/// anything else as [`area`]. +pub(crate) fn io(words: impl Display, e: &std::io::Error) -> String { + match e.kind() { + std::io::ErrorKind::StorageFull | std::io::ErrorKind::QuotaExceeded => fail(Code::StorageFull, format!("{words}: {e}")), + _ => area(format!("{words}: {e}")), + } +} diff --git a/apps/deck/host-service/src/covers.rs b/apps/deck/host-service/src/covers.rs new file mode 100644 index 00000000..5373b268 --- /dev/null +++ b/apps/deck/host-service/src/covers.rs @@ -0,0 +1,96 @@ +//! Covers (SERVICE.md "Reading and drawing", `deck.cover {path, width?}`): +//! slide 1 of a deck in the caller's storage, drawn once into the render +//! cache's `.cache/covers/` and kept there across closes and runs, so a +//! library shows its covers without opening each deck on every start, as +//! `pdf.cover` does (`apps/pdf/host-service/src/covers.rs`). +//! +//! - **The key.** A cover's file name is +//! `--@.png`: `` a hash of the deck's +//! storage-relative name (16 lowercase hex digits), `` its bytes and +//! `` its modification time in nanoseconds since the epoch. A +//! deck that changes (saved, or replaced under the same name) gets another +//! key, and writing its new cover removes the covers of its other +//! versions by the exact prefix `-`, which no other deck's covers +//! carry; a cover at another width of the same version stays. +//! - **A kept cover** answers with no engine work: the file's metadata and +//! its PNG header ([`cache::kept_png`]). +//! - **A new cover** reads the file as `deck.open` does, through the import +//! guard before the engine sees a byte (`crate::read_guarded`), within the +//! door's ceilings on what a session holds, and draws slide 1 with +//! deckcraft-render's `render_slide` (no command, no open document: no +//! handle, nothing counted toward a caller's 8). Its one write is the +//! service's own cache file, through [`cache::store`]'s bounds and link +//! checks. +//! - **Caps.** 48 to 3840 pixels wide (default 320), the longest side at +//! most 4096 and at most 16 megapixels; covers share the render cache's +//! 16 MiB and 64 files, at most 32 of them, and slide renders go before +//! covers when the cache needs room ([`cache`]). + +use std::path::{Component, Path}; +use std::time::UNIX_EPOCH; + +use deckcraft_engine::cmd::file as engine_file; +use deckcraft_engine::render::{self as render, RenderOpts}; +use serde_json::{json, Value as Json}; + +use crate::args; +use crate::cache; +use crate::codes::{fail, invalid, too_large, Code}; +use crate::reading; +use crate::Ctx; + +/// A cover's default width, in pixels. +pub(crate) const DEFAULT_WIDTH: i64 = 320; + +/// The key's first part for the storage-relative name `name`: FNV-1a over +/// its normal components joined with `/`, as 16 lowercase hex digits. +pub(crate) fn name_key(name: &str) -> String { + let joined: Vec = Path::new(name) + .components() + .filter_map(|c| match c { + Component::Normal(part) => Some(part.to_string_lossy().into_owned()), + _ => None, + }) + .collect(); + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in joined.join("/").bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} + +/// `deck.cover {path, width?}`. +pub(crate) fn cover(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.cover"; + args::only(a, &["path", "width"], M)?; + let path = args::need_str(a, "path", M)?; + let width = args::opt_int_in(a, "width", M, reading::WIDTH.0, reading::WIDTH.1)?.unwrap_or(DEFAULT_WIDTH) as u64; + let (file, len) = crate::input(cx.area, path, "path", M)?; + let meta = std::fs::metadata(&file).map_err(|e| invalid(format!("{path}: {e}")))?; + let modified = meta.modified().ok().and_then(|t| t.duration_since(UNIX_EPOCH).ok()).map_or(0, |d| d.as_nanos()); + let prefix = format!("{}-", name_key(path)); + let current = format!("{prefix}{len}-{modified}@"); + let rel = format!("{}/{current}{width}.png", cache::COVERS); + if let Some((w, h)) = cache::kept_png(cx.area, &rel) { + return Ok(json!({ "path": rel, "width": w, "height": h })); + } + let (name, deck, _) = crate::read_guarded(cx.area, path, "path", M)?; + let mut session = deckcraft_engine::Session::new(); + engine_file::add_opened(&mut session, &name, None, deck); + if let Some(why) = crate::Held::of(&session).over() { + return Err(too_large(format!("the deck holds {why}, more than the deck service opens"))); + } + let deck = session.doc().map_err(|_| fail(Code::Damaged, "the engine opened no deck"))?.doc.clone(); + if deck.slides.is_empty() { + return Err(fail(Code::Damaged, format!("{path} has no slides"))); + } + let (scale, _, _) = reading::size_for(&deck, width, M)?; + let img = render::render_slide(&deck, 0, &RenderOpts { scale, ..Default::default() }); + let png = img.to_png(); + // This deck's covers of other versions are stale: they go before the new + // one takes room. + cache::drop_covers(cx.area, &prefix, ¤t); + cache::store(cx.area, &rel, &png, M)?; + Ok(json!({ "path": rel, "width": img.width, "height": img.height })) +} diff --git a/apps/deck/host-service/src/doc_tests.rs b/apps/deck/host-service/src/doc_tests.rs new file mode 100644 index 00000000..f652c1c4 --- /dev/null +++ b/apps/deck/host-service/src/doc_tests.rs @@ -0,0 +1,1843 @@ +//! The open-document methods (Quick Deck v2, `apps/quickdeck/design/SERVICE.md`), +//! through [`serve_from`] as App Hub hands calls to the service, on decks the +//! engine itself writes and on hostile files built byte by byte +//! ([`crate::guard::fixtures`]). + +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use deckcraft_engine::cmd::file as engine_file; +use deckcraft_engine::Session; +use deckcraft_model::text::RunKind; +use deckcraft_model::{Presentation, ShapeId}; +use serde_json::{json, Value as Json}; + +use super::*; +use crate::guard::fixtures::{self as fx, Part}; + +const APP: &str = "os.quickdeck"; + +/// A resolver shaped like the shell's for an app's own requests: every call +/// works in `root`, the foreground may replace a file, within `quota`. +fn resolver(root: &Path, quota: Option) -> Slot { + let slot = Slot::new(); + let root = root.to_path_buf(); + slot.set(Some(Arc::new(move |call: &ServiceCall| Ok(Area::new(&root, quota, call.may_prompt))))); + slot +} + +/// An app's storage, served as the shell serves an app's own requests. +struct World { + _dir: tempfile::TempDir, + root: PathBuf, + areas: Slot, +} + +impl World { + fn new() -> World { + World::with_quota(None) + } + + fn with_quota(quota: Option) -> World { + // Tests run on many threads, and the engine's fonts load on one at a + // time (`crate::warm`): the warm-up first, as the shell does it. + crate::warm::ready(); + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("storage"); + std::fs::create_dir(&root).unwrap(); + let areas = resolver(&root, quota); + World { _dir: dir, root, areas } + } + + /// The same storage, with another quota. + fn tight(&self, quota: u64) -> Slot { + resolver(&self.root, Some(quota)) + } + + fn put(&self, rel: &str, bytes: &[u8]) { + let path = self.root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, bytes).unwrap(); + } + + /// A call of `app`, from the foreground (`may_prompt`) or not, from the + /// isolate whose heap key is `isolate` (0: none). + fn call_full(&self, app: &str, method: &str, args: Json, may_prompt: bool, isolate: usize) -> Result { + let call = ServiceCall { app_id: app.into(), service: format!("deck.{method}"), args, from_sheet: false, may_prompt, host_dir: self.root.join(".host") }; + serve_from(&self.areas, &call, isolate) + } + + fn call(&self, method: &str, args: Json) -> Result { + self.call_full(APP, method, args, true, 0) + } + + #[track_caller] + fn ok(&self, method: &str, args: Json) -> Json { + self.call(method, args.clone()).unwrap_or_else(|e| panic!("deck.{method} {args}: {e}")) + } + + #[track_caller] + fn err(&self, method: &str, args: Json) -> String { + match self.call(method, args.clone()) { + Ok(answer) => panic!("deck.{method} {args} answered {answer}"), + Err(e) => e, + } + } + + /// Open `path`: its handle. + #[track_caller] + fn open(&self, path: &str) -> String { + self.ok("open", json!({ "path": path }))["doc"].as_str().unwrap().to_string() + } + + /// The sample deck (`sample(n)`) saved at `rel` and opened. + fn sample_open(&self, rel: &str, n: usize) -> String { + self.put(rel, &native(&sample(n))); + self.open(rel) + } + + /// The ids of the open deck's slides, in order. + fn slide_ids(&self, doc: &str) -> Vec { + self.ok("slides", json!({ "doc": doc }))["slides"].as_array().unwrap().iter().map(|s| s["id"].as_u64().unwrap()).collect() + } + + /// Slide `slide`'s shapes, as `deck.slide` lists them. + fn shapes(&self, doc: &str, slide: u64) -> Vec { + self.ok("slide", json!({ "doc": doc, "slide": slide }))["shapes"].as_array().unwrap().clone() + } + + /// The id of the first shape on `slide` whose `key` is `value`. + fn shape_where(&self, doc: &str, slide: u64, key: &str, value: &str) -> u64 { + let shapes = self.shapes(doc, slide); + shapes.iter().find(|s| s[key] == value).unwrap_or_else(|| panic!("no shape with {key} {value}: {shapes:?}"))["id"].as_u64().unwrap() + } + + /// The files under the storage, relative (sorted). + fn files(&self, under: &str) -> Vec { + let mut out = Vec::new(); + let mut todo = vec![self.root.join(under)]; + while let Some(dir) = todo.pop() { + for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { + todo.push(p); + } else { + out.push(p.strip_prefix(&self.root).unwrap().to_string_lossy().into_owned()); + } + } + } + out.sort(); + out + } +} + +/// `error` starts with the stable code `code`. +#[track_caller] +fn coded(error: &str, code: &str) { + assert!(error.starts_with(&format!("{code}: ")), "expected {code}: {error}"); +} + +/// A 12x8 RGB PNG (two colour bands), as the photo service's tests use. +fn png() -> Vec { + const PNG: &str = "89504e470d0a1a0a0000000d494844520000000c000000080802000000428689a60000001d49444154789c6378616383866c725ea021063a2bb279d14310d15911005b9497817c6155610000000049454e44ae426082"; + (0..PNG.len()).step_by(2).map(|i| u8::from_str_radix(&PNG[i..i + 2], 16).unwrap()).collect() +} + +/// A deck as the engine makes it: a title slide, then `n - 1` slides with a +/// title and two points. Slide 3 holds a 3 × 2 table, slide 4 a column +/// chart, slide 5 a picture and two shapes that build in on click. +fn sample(n: usize) -> Presentation { + crate::warm::ready(); + let mut s = Session::new(); + let run = |s: &mut Session, id: &str, p: Json| s.execute(id, &p).unwrap_or_else(|e| panic!("{id}: {e}")); + run(&mut s, "file.new", json!({ "blank": true })); + run(&mut s, "slide.new", json!({ "layout": "title", "title": "Northwind Cooperative", "body": "Q3 2026 board update" })); + for k in 1..n { + run(&mut s, "slide.new", json!({ "layout": "titleAndContent", "title": format!("Slide {}", k + 1), "body": "First point\nSecond point" })); + match k { + 2 => { + run(&mut s, "insert.table", json!({ "rows": 3, "cols": 2, "rect": [100, 300, 400, 120], "data": [["Region", "Members"], ["North", "120"], ["South", "95"]] })); + } + 3 => { + run(&mut s, "insert.chart", json!({ "type": "column", "rect": [500, 150, 400, 300], "categories": ["Q1", "Q2", "Q3"], "series": [{ "name": "Revenue", "values": [3.1, 3.6, 4.2] }] })); + } + 4 => { + let data = deckcraft_engine::cmd::base64_encode(&png()); + run(&mut s, "insert.picture", json!({ "data": data, "name": "dot.png", "rect": [600, 200, 120, 80] })); + let a = run(&mut s, "shape.insert", json!({ "preset": "rect", "rect": [50, 400, 100, 60], "text": "First" }))["id"].clone(); + let b = run(&mut s, "shape.insert", json!({ "preset": "ellipse", "rect": [200, 400, 100, 60], "text": "Second" }))["id"].clone(); + run(&mut s, "animation.add", json!({ "effect": "fade", "start": "onClick", "ids": [a] })); + run(&mut s, "animation.add", json!({ "effect": "fade", "start": "onClick", "ids": [b] })); + } + _ => {} + } + } + s.doc().unwrap().doc.as_ref().clone() +} + +fn native(p: &Presentation) -> Vec { + engine_file::save_bytes(p, "deckcraft").unwrap() +} + +fn pptx(p: &Presentation) -> Vec { + engine_file::save_bytes(p, "pptx").unwrap() +} + +/// The text runs of a shape of the open document, as (text, bold). +fn runs(doc: &str, slide: u64, shape: u64) -> Vec<(String, bool)> { + docs::with_open(doc, |d| { + let deck = d.st().unwrap().doc.clone(); + let s = deck.slides.iter().find(|s| u64::from(s.id.0) == slide).unwrap(); + let sh = s.shape(ShapeId(shape as u32)).unwrap(); + sh.text + .iter() + .flat_map(|t| &t.paragraphs) + .enumerate() + .flat_map(|(i, p)| { + let mut v: Vec<(String, bool)> = if i > 0 { vec![("\n".to_string(), false)] } else { vec![] }; + v.extend(p.runs.iter().map(|r| (if r.kind == RunKind::Break { "\u{b}".to_string() } else { r.text.clone() }, r.props.bold == Some(true)))); + v + }) + .collect() + }) + .unwrap() +} + +/// How many undo steps the open document keeps. +fn undo_len(doc: &str) -> usize { + docs::with_open(doc, |d| d.st().unwrap().history.undo.len()).unwrap() +} + +// ------------------------------------------------------------ documents + +#[test] +fn opening_a_native_deck_a_pptx_and_an_outline() { + let w = World::new(); + let deck = sample(6); + w.put("decks/board.deckcraft", &native(&deck)); + w.put("imports/board.pptx", &pptx(&deck)); + w.put("outline.txt", b"Plan\n\tShip in May\nNext steps\n\tBudget\n"); + let open = w.ok("open", json!({ "path": "decks/board.deckcraft" })); + let doc = open["doc"].as_str().unwrap(); + assert!(!doc.is_empty() && doc.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), "lowercase hex: {open}"); + assert_eq!(open["path"], "decks/board.deckcraft"); + assert_eq!(open["title"], "board", "the file's name when the deck has no title: {open}"); + assert_eq!(open["size"], json!([960.0, 540.0])); + assert_eq!(open["slides"], 6); + assert_eq!(open["theme"], "DeckCraft"); + let layouts = open["layouts"].as_array().unwrap(); + assert_eq!(layouts.len(), 11, "the eleven layouts: {open}"); + assert!(layouts.iter().all(|l| l["id"].is_u64() && l["name"].is_string() && l["kind"].is_string()), "{open}"); + assert_eq!(open["sections"], json!([])); + assert_eq!((open["rev"].clone(), open["edited"].clone(), open["can_undo"].clone(), open["can_redo"].clone()), (json!(0), json!(false), json!(false), json!(false))); + assert!(open["missing_fonts"].is_array(), "{open}"); + assert!(open.get("changed").is_none()); + // The same deck from a .pptx, and an outline. + let p = w.ok("open", json!({ "path": "imports/board.pptx" })); + assert_eq!(p["slides"], 6, "{p}"); + let o = w.ok("open", json!({ "path": "outline.txt" })); + assert_eq!(o["slides"], 2, "{o}"); + // Nothing is in the open-document table for a closed deck. + assert_eq!(w.ok("close", json!({ "doc": doc })), json!({ "closed": true })); + coded(&w.err("state", json!({ "doc": doc })), "unknown_doc"); +} + +#[test] +fn creating_a_deck_writes_it_as_made() { + let w = World::new(); + let made = w.ok("create", json!({ "out": "decks/new.deckcraft" })); + assert_eq!((made["slides"].clone(), made["edited"].clone(), made["can_undo"].clone()), (json!(1), json!(false), json!(false)), "one title slide: {made}"); + assert_eq!(made["path"], "decks/new.deckcraft"); + assert!(std::fs::read(w.root.join("decks/new.deckcraft")).unwrap().starts_with(b"PK"), "written there as made"); + // From slides, with a theme, a size and notes. + let made = w.ok( + "create", + json!({ "out": "decks/harbor.deckcraft", "theme": "harbor", "size": "4:3", "slides": [ + { "layout": "title", "title": "Field trip", "body": "Spring 2027" }, + { "title": "Plan", "body": "Bus at 8\nMuseum at 10", "notes": "Remind them about lunch" }, + ] }), + ); + assert_eq!((made["theme"].clone(), made["size"].clone(), made["slides"].clone()), (json!("Harbor"), json!([720.0, 540.0]), json!(2)), "{made}"); + let doc = made["doc"].as_str().unwrap(); + let ids = w.slide_ids(doc); + assert_eq!(w.ok("slide", json!({ "doc": doc, "slide": ids[1] }))["notes"], "Remind them about lunch"); + // From an outline. + let made = w.ok("create", json!({ "out": "outline.deckcraft", "outline": "One\n\ta\nTwo\nThree" })); + assert_eq!(made["slides"], 3, "{made}"); + // Refusals before anything is made. + coded(&w.err("create", json!({ "out": "x.pptx" })), "invalid"); + coded(&w.err("create", json!({ "out": "decks/new.deckcraft" })), "invalid"); + coded(&w.err("create", json!({ "out": "y.deckcraft", "theme": "Neon" })), "invalid"); + coded(&w.err("create", json!({ "out": "y.deckcraft", "slides": [], })), "invalid"); + coded(&w.err("create", json!({ "out": "y.deckcraft", "slides": [{ "title": "a" }], "outline": "b" })), "invalid"); + let many: Vec = (0..201).map(|i| json!({ "title": format!("S{i}") })).collect(); + coded(&w.err("create", json!({ "out": "y.deckcraft", "slides": many })), "too_large"); + let outline = vec!["Slide"; 201].join("\n"); + coded(&w.err("create", json!({ "out": "y.deckcraft", "outline": outline })), "too_large"); + coded(&w.err("create", json!({ "out": "y.deckcraft", "size": [4000, 4000] })), "too_large"); + assert!(!w.root.join("y.deckcraft").exists(), "nothing written"); +} + +#[test] +fn state_close_and_unsaved_changes() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 3); + assert_eq!(w.ok("state", json!({ "doc": doc })), json!({ "rev": 0, "edited": false, "can_undo": false, "can_redo": false, "undo": null, "redo": null, "slides": 3 })); + let ids = w.slide_ids(&doc); + w.ok("notes", json!({ "doc": doc, "slide": ids[0], "text": "Welcome everyone" })); + let state = w.ok("state", json!({ "doc": doc })); + assert_eq!((state["rev"].clone(), state["edited"].clone(), state["undo"].clone()), (json!(1), json!(true), json!("Edit notes")), "{state}"); + coded(&w.err("close", json!({ "doc": doc })), "unsaved"); + assert_eq!(w.ok("close", json!({ "doc": doc, "discard": true })), json!({ "closed": true })); +} + +// ------------------------------------------------------------ handles and the caps on open documents + +#[test] +fn an_unknown_or_foreign_handle_is_unknown() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 3); + let ids = w.slide_ids(&doc); + let s = ids[1]; + // Another app, in the same storage. + coded(&w.call_full("os.notes", "state", json!({ "doc": doc }), true, 0).unwrap_err(), "unknown_doc"); + // The same app in another storage scope. + let other = World::new(); + for bad in [json!("0000"), json!("not a handle"), json!("../x"), json!("ABCDEF"), json!(7), json!(true), json!(["x"])] { + coded(&w.err("state", json!({ "doc": bad })), "unknown_doc"); + } + coded(&w.err("state", json!({})), "invalid"); + for (method, args) in [ + ("state", json!({ "doc": doc })), + ("close", json!({ "doc": doc, "discard": true })), + ("save", json!({ "doc": doc })), + ("slides", json!({ "doc": doc })), + ("slide", json!({ "doc": doc, "slide": s })), + ("hit", json!({ "doc": doc, "slide": s, "at": [10, 10] })), + ("view", json!({ "doc": doc, "slide": s, "width": 96 })), + ("layouts", json!({ "doc": doc })), + ("text", json!({ "doc": doc })), + ("format_state", json!({ "doc": doc, "slide": s, "id": 1 })), + ("slides_op", json!({ "doc": doc, "op": "new" })), + ("notes", json!({ "doc": doc, "slide": s, "text": "x" })), + ("transition", json!({ "doc": doc, "all": true, "kind": "fade" })), + ("insert", json!({ "doc": doc, "slide": s, "kind": "text" })), + ("set_text", json!({ "doc": doc, "slide": s, "id": 1, "text": "x" })), + ("format", json!({ "doc": doc, "slide": s, "ids": [1], "bold": true })), + ("shape", json!({ "doc": doc, "slide": s, "ids": [1], "op": "delete" })), + ("picture", json!({ "doc": doc, "slide": s, "id": 1, "op": "reset" })), + ("table", json!({ "doc": doc, "slide": s, "id": 1, "op": "style", "style": "none" })), + ("chart", json!({ "doc": doc, "slide": s, "id": 1, "title": "x" })), + ("design", json!({ "doc": doc, "theme": "Slate" })), + ("undo", json!({ "doc": doc })), + ("redo", json!({ "doc": doc })), + ("show", json!({ "doc": doc })), + ("export", json!({ "doc": doc, "kind": "outline", "out": "x.txt" })), + ] { + coded(&other.err(method, args.clone()), "unknown_doc"); + coded(&w.call_full("os.notes", method, args, true, 0).unwrap_err(), "unknown_doc"); + } + // Still open for its own caller. + assert_eq!(w.ok("state", json!({ "doc": doc }))["slides"], 3); +} + +#[test] +fn the_ninth_open_of_a_caller_and_the_thirty_third_in_all_are_refused() { + let w = World::new(); + w.put("a.deckcraft", &native(&sample(1))); + let docs: Vec = (0..docs::MAX_OPEN).map(|_| w.open("a.deckcraft")).collect(); + let mut unique = docs.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(unique.len(), 8, "a handle per open: {docs:?}"); + coded(&w.err("open", json!({ "path": "a.deckcraft" })), "too_many_open"); + coded(&w.err("create", json!({ "out": "b.deckcraft" })), "too_many_open"); + assert!(!w.root.join("b.deckcraft").exists(), "refused before anything is written"); + // A cover is no open document. + w.ok("cover", json!({ "path": "a.deckcraft" })); + // The cap is per caller: other apps open theirs, up to 32 in all. + let apps = ["os.one", "os.two", "os.three"]; + for app in apps { + for _ in 0..docs::MAX_OPEN { + w.call_full(app, "open", json!({ "path": "a.deckcraft" }), true, 0).unwrap(); + } + } + assert_eq!(docs::open_count(), docs::MAX_OPEN_ALL); + let e = w.call_full("os.four", "open", json!({ "path": "a.deckcraft" }), true, 0).unwrap_err(); + coded(&e, "too_many_open"); + assert!(e.contains("as many decks as it can"), "{e}"); + w.ok("close", json!({ "doc": docs[3] })); + w.call_full("os.four", "open", json!({ "path": "a.deckcraft" }), true, 0).unwrap(); +} + +/// A document opened from an app's isolate lives while that isolate's +/// storage scope does. +#[test] +fn a_document_goes_when_its_apps_storage_scope_does() { + use makepad_widgets::*; + let w = World::new(); + w.put("a.deckcraft", &native(&sample(2))); + let mut cx = Cx::new(Box::new(|_, _| {})); + let mut card = cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + let value = vm.eval(script! {use mod.widgets.* Splash{}}); + Splash::script_from_value(vm, value) + }); + card.set_host_tag(&mut cx, Some(APP.into())); + card.set_sandbox_dir(&mut cx, Some(w.root.clone())); + card.set_text(&mut cx, "View {}"); + let heap = card.isolate_heap_key(&mut cx).unwrap(); + let opened = w.call_full(APP, "open", json!({ "path": "a.deckcraft" }), true, heap).unwrap(); + let doc = opened["doc"].clone(); + let unbound = w.open("a.deckcraft"); + assert_eq!(w.call_full(APP, "state", json!({ "doc": doc }), true, heap).unwrap()["slides"], 2); + docs::sweep_at(Instant::now() + docs::IDLE + Duration::from_secs(60)); + assert_eq!(w.call_full(APP, "state", json!({ "doc": doc }), true, heap).unwrap()["slides"], 2, "idle is no reason to close a deck an open app holds"); + card.set_host_tag(&mut cx, None); + coded(&w.call_full(APP, "state", json!({ "doc": doc }), true, 0).unwrap_err(), "unknown_doc"); + coded(&w.err("state", json!({ "doc": unbound })), "unknown_doc"); + assert_eq!(docs::open_count(), 0); + // A scope that changes (another account's storage) is gone too. + card.set_host_tag(&mut cx, Some(APP.into())); + let opened = w.call_full(APP, "open", json!({ "path": "a.deckcraft" }), true, heap).unwrap(); + let elsewhere = tempfile::tempdir().unwrap(); + card.set_sandbox_dir(&mut cx, Some(elsewhere.path().to_path_buf())); + coded(&w.call_full(APP, "state", json!({ "doc": opened["doc"] }), true, heap).unwrap_err(), "unknown_doc"); +} + +/// An isolate closing releases its documents at once, with their renders, +/// before a new isolate can get its heap key. +#[test] +fn a_closed_isolates_documents_go_before_its_key_is_reused() { + use makepad_widgets::*; + crate::register(); + let w = World::new(); + w.put("a.deckcraft", &native(&sample(2))); + let mut cx = Cx::new(Box::new(|_, _| {})); + let mut card = cx.with_vm(|vm| { + makepad_widgets::script_mod(vm); + let value = vm.eval(script! {use mod.widgets.* Splash{}}); + Splash::script_from_value(vm, value) + }); + card.set_host_tag(&mut cx, Some(APP.into())); + card.set_sandbox_dir(&mut cx, Some(w.root.clone())); + card.set_text(&mut cx, "View {}"); + let heap = card.isolate_heap_key(&mut cx).unwrap(); + let from = |method: &str, args: Json| w.call_full(APP, method, args, true, heap); + let renders = |doc: &Json| w.root.join(format!(".cache/slides/{}", doc.as_str().unwrap())); + let old: Vec = (0..3).map(|_| from("open", json!({ "path": "a.deckcraft" })).unwrap()["doc"].clone()).collect(); + for doc in &old { + let s = from("slides", json!({ "doc": doc })).unwrap()["slides"][0]["id"].clone(); + from("view", json!({ "doc": doc, "slide": s, "width": 96 })).unwrap(); + assert!(renders(doc).is_dir()); + } + octosense_appstore::services::cancel_heap(heap.wrapping_add(64)); + assert_eq!(docs::open_count(), 3); + octosense_appstore::services::cancel_heap(heap); + assert_eq!(docs::open_count(), 0, "released by the listener"); + assert!(old.iter().all(|doc| !renders(doc).exists()), "their renders went with them"); + card.set_host_tag(&mut cx, Some(APP.into())); + card.set_sandbox_dir(&mut cx, Some(w.root.clone())); + let fresh: Vec = (0..docs::MAX_OPEN).map(|_| from("open", json!({ "path": "a.deckcraft" })).unwrap()["doc"].clone()).collect(); + coded(&from("open", json!({ "path": "a.deckcraft" })).unwrap_err(), "too_many_open"); + for doc in &old { + coded(&from("state", json!({ "doc": doc })).unwrap_err(), "unknown_doc"); + } + docs::while_busy(|| octosense_appstore::services::cancel_heap(heap)); + assert_eq!(docs::open_count(), 8, "queued, not lost"); + coded(&from("state", json!({ "doc": fresh[0] })).unwrap_err(), "unknown_doc"); + assert_eq!(docs::open_count(), 0); +} + +#[test] +fn a_document_no_isolate_holds_goes_after_it_is_idle() { + let w = World::new(); + let doc = w.sample_open("a.deckcraft", 1); + docs::sweep_at(Instant::now() + docs::IDLE - Duration::from_secs(30)); + assert_eq!(w.ok("state", json!({ "doc": doc }))["slides"], 1, "not idle long enough"); + docs::sweep_at(Instant::now() + docs::IDLE + Duration::from_secs(1)); + coded(&w.err("state", json!({ "doc": doc })), "unknown_doc"); +} + +// ------------------------------------------------------------ the import guard on every path + +/// Each hostile file, and the code it is refused with. +fn hostile() -> Vec<(&'static str, Vec, &'static str)> { + let bomb = fx::package(vec![Part::deflated("ppt/slides/slide1.xml", &vec![0u8; 16 << 20])], ""); + let liar = fx::package(vec![Part { declared: Some(100), ..Part::deflated("ppt/slides/slide1.xml", &vec![b' '; 1 << 20]) }], ""); + let many = fx::zip(&(0..=guard::MAX_ENTRIES).map(|i| Part::stored(&format!("f{i}"), b"")).collect::>()); + let nested = fx::package(vec![Part::stored("ppt/slides/inner.zip", &fx::zip(&[Part::stored("a", b"b")]))], ""); + // A chart's workbook that is a zip bomb: refused by its ratio. + let workbook_bomb = fx::package(vec![Part::deflated("ppt/embeddings/Microsoft_Excel_Worksheet1.xlsx", &[fx::zip(&[Part::stored("a", b"b")]), vec![0u8; 16 << 20]].concat())], ""); + let media = fx::package(vec![Part::stored("ppt/media/media1.mp4", b"\0\0\0\x18ftypisom")], ""); + // A zip whose end record is `end(directory size, directory offset)`. + let ended = |parts: &[Part], end: &dyn Fn(u32, u32) -> Vec| { + let l = fx::layout(parts); + [l.locals.clone(), l.central.clone(), end(l.central.len() as u32, l.locals.len() as u32)].concat() + }; + let types = || Part::deflated("[Content_Types].xml", b""); + // The review's proof of concept: one entry in all, two on this disk, the + // second a movie the guard never saw; then a zip bomb as the second. + let counted = ended(&[types(), Part::stored("ppt/media/evil.mp4", b"\0\0\0\x18ftypisom")], &|size, at| fx::end_record(2, 1, size, at, b"")); + let counted_bomb = ended(&[types(), Part::deflated("ppt/slides/slide1.xml", &vec![0u8; 16 << 20])], &|size, at| fx::end_record(2, 1, size, at, b"")); + // A directory shorter, or longer, than its end record declares. + let short = ended(&[types(), Part::stored("ppt/slides/slide1.xml", b"")], &|size, at| fx::end_record(2, 2, size - 10, at, b"")); + let long = ended(&[types(), Part::stored("ppt/slides/slide1.xml", b"")], &|size, at| fx::end_record(2, 2, size + 10, at, b"")); + // Two end records; one hidden in the other's comment. + let two = ended(&[types()], &|size, at| [fx::end_record(1, 1, size, at, b""), fx::end_record(1, 1, size, at, b"")].concat()); + let hidden = ended(&[types()], &|size, at| fx::end_record(1, 1, size, at, &[fx::end_record(1, 1, size, at, b""), b"pad".to_vec()].concat())); + // Data before the zip: its offsets counting from the zip's start, or + // from the file's. + let prepended = [b"PK\x03\x04 prepended".to_vec(), fx::package(vec![], "")].concat(); + let shifted = { + let junk = b"PK\x03\x04 prepended".to_vec(); + let l = fx::layout_from(&[types()], junk.len() as u32); + let at = (junk.len() + l.locals.len()) as u32; + [junk, l.locals.clone(), l.central.clone(), fx::end_record(1, 1, l.central.len() as u32, at, b"")].concat() + }; + vec![ + ("bomb.pptx", bomb, "held"), + ("liar.pptx", liar, "damaged"), + ("many.pptx", many, "held"), + ("nested.pptx", nested, "held"), + ("workbook_bomb.pptx", workbook_bomb, "held"), + ("counted.pptx", counted, "damaged"), + ("counted_bomb.pptx", counted_bomb, "damaged"), + ("short.pptx", short, "damaged"), + ("long.pptx", long, "damaged"), + ("two_ends.pptx", two, "damaged"), + ("hidden_end.pptx", hidden, "damaged"), + ("prepended.pptx", prepended, "damaged"), + ("shifted.pptx", shifted, "damaged"), + ("media.pptx", media, "held"), + ("locked.pptx", fx::ole(&["EncryptionInfo", "EncryptedPackage"]), "protected"), + ("legacy.ppt", fx::ole(&["PowerPoint Document", "Current User"]), "unsupported"), + ] +} + +/// Every refusal of the import guard comes before the engine reads a byte, +/// on every path that opens a file: `deck.open`, `deck.cover`, the v1 file +/// methods and through them the agent's `deck.info` and `deck.run`. +#[test] +fn the_import_guard_refuses_before_the_engine_runs_on_every_path() { + let w = World::new(); + for (name, bytes, code) in hostile() { + w.put(name, &bytes); + let before = engine_reads(); + for (method, args) in [ + ("open", json!({ "path": name })), + ("cover", json!({ "path": name })), + ("info", json!({ "path": name })), + ("text", json!({ "path": name })), + ("render", json!({ "path": name, "out": "r.png" })), + ("convert", json!({ "path": name, "out": "c.deckcraft" })), + ("run", json!({ "path": name, "cmds": [] })), + ] { + let e = w.err(method, args); + coded(&e, code); + assert!(e.len() > code.len() + 10, "plain words follow: {e}"); + } + assert_eq!(engine_reads(), before, "{name}: the engine read nothing"); + } + assert_eq!(docs::open_count(), 0); + assert!(!w.root.join("r.png").exists() && !w.root.join("c.deckcraft").exists()); + // An honest file passes the same paths, through the engine. + w.put("ok.deckcraft", &native(&sample(2))); + let before = engine_reads(); + w.ok("info", json!({ "path": "ok.deckcraft" })); + w.ok("open", json!({ "path": "ok.deckcraft" })); + assert_eq!(engine_reads(), before + 2); + // A .pptx whose chart keeps its workbook as an embedded object (a zip + // that neither the service nor the engine unzips) opens, on every path. + let workbook = fx::zip(&[Part::deflated("[Content_Types].xml", b""), Part::deflated("xl/workbook.xml", b"")]); + w.put("chart.pptx", &fx::with_parts(&pptx(&sample(4)), vec![Part::deflated("ppt/embeddings/Microsoft_Excel_Worksheet1.xlsx", &workbook)])); + let before = engine_reads(); + let opened = w.ok("open", json!({ "path": "chart.pptx" })); + assert_eq!(opened["slides"], 4); + w.ok("cover", json!({ "path": "chart.pptx" })); + w.ok("info", json!({ "path": "chart.pptx" })); + assert_eq!(engine_reads(), before + 3); + // The engine's own refusal of what passed the guard. + w.put("broken.deckcraft", &fx::native(vec![], "{not json")); + coded(&w.err("open", json!({ "path": "broken.deckcraft" })), "damaged"); + w.put("notes.rtf", b"{\\rtf1 hello}"); + coded(&w.err("open", json!({ "path": "notes.rtf" })), "unsupported"); + w.put("paper.pdf", b"%PDF-1.7\n"); + coded(&w.err("open", json!({ "path": "paper.pdf" })), "unsupported"); + coded(&w.err("open", json!({ "path": "missing.pptx" })), "not_found"); +} + +/// The guard's verdict picks the engine's reader (`crate::engine_read`), a +/// step-for-step copy of what the pinned `open_presentation` does after +/// its sniff: on every format both read the very same deck, so a step that +/// function grows on a repin fails here. +#[test] +fn the_guards_reader_reads_as_open_presentation() { + let deck = sample(5); + for (name, bytes, format) in [ + ("x.deckcraft", native(&deck), guard::Format::Native), + ("x.pptx", pptx(&deck), guard::Format::Pptx), + ("x.txt", b"Title\n\tPoint\nSecond\n\tAnother point\n".to_vec(), guard::Format::Outline), + ("x.md", b"".to_vec(), guard::Format::Outline), + ] { + assert_eq!(guard::check(name, &bytes), Ok(format), "{name}"); + assert_eq!(engine_read(name, &bytes, format).unwrap(), engine_file::open_presentation(name, &bytes).unwrap(), "{name}"); + } +} + +/// A package with its content-type list last, past its first 4 KiB, as +/// LibreOffice writes every one: the engine's own sniff misses it, the +/// guard finds it in the directory, and it opens on every path, still +/// behind every bound of the guard. +#[test] +fn a_package_with_its_content_types_late_opens_on_every_path() { + let w = World::new(); + let late = fx::with_last(&pptx(&sample(4)), "[Content_Types].xml", vec![]); + let at = late.windows(19).position(|win| win == b"[Content_Types].xml").unwrap(); + assert!(at > 4096, "the content types sit {at} bytes in"); + assert!(!deckcraft_pptx::sniff(&late) && engine_file::open_presentation("late.pptx", &late).is_err(), "the engine's own entry point misses it"); + w.put("late.pptx", &late); + let before = engine_reads(); + let opened = w.ok("open", json!({ "path": "late.pptx" })); + assert_eq!(opened["slides"], 4, "{opened}"); + assert!(w.root.join(w.ok("cover", json!({ "path": "late.pptx" }))["path"].as_str().unwrap()).is_file()); + assert_eq!(w.ok("info", json!({ "path": "late.pptx" }))["slides"].as_array().map(Vec::len), Some(4)); + assert!(w.ok("text", json!({ "path": "late.pptx" }))["outline"].as_str().unwrap().contains("Northwind Cooperative")); + w.ok("render", json!({ "path": "late.pptx", "out": "late.png" })); + w.ok("convert", json!({ "path": "late.pptx", "out": "late.deckcraft" })); + w.ok("run", json!({ "path": "late.pptx", "cmds": [] })); + assert_eq!(engine_reads(), before + 7, "every path read it through the engine"); + // Still behind every bound: the same layout with a movie in it, with a + // zip bomb in it, or with its entry counts disagreeing never reaches + // the engine. + let movie = fx::with_last(&pptx(&sample(4)), "[Content_Types].xml", vec![Part::stored("ppt/media/clip.mp4", b"\0\0\0\x18ftypisom")]); + let bomb = fx::with_last(&pptx(&sample(4)), "[Content_Types].xml", vec![Part::deflated("ppt/slides/slide9.xml", &vec![0u8; 16 << 20])]); + let mut counted = late.clone(); + let end = counted.len() - 22; + let total = u16::from_le_bytes([counted[end + 10], counted[end + 11]]); + counted[end + 8..end + 10].copy_from_slice(&(total + 1).to_le_bytes()); + for (name, bytes, code) in [("late-movie.pptx", movie, "held"), ("late-bomb.pptx", bomb, "held"), ("late-counted.pptx", counted, "damaged")] { + w.put(name, &bytes); + let before = engine_reads(); + coded(&w.err("open", json!({ "path": name })), code); + coded(&w.err("info", json!({ "path": name })), code); + assert_eq!(engine_reads(), before, "{name}: the engine read nothing"); + } +} + +/// The import guard passes what the engine itself writes, at the sizes the +/// service allows: a native deck's `presentation.json` repeats itself from +/// slide to slide, so its compression ratio is the one to watch. +#[test] +fn the_import_guard_passes_the_engines_own_large_decks() { + let worst = |bytes: &[u8]| -> f64 { + // Each entry's declared size over its compressed size, from the + // central directory the guard reads. + let end = (0..bytes.len() - 21).rev().find(|i| bytes[*i..*i + 4] == [0x50, 0x4b, 0x05, 0x06]).unwrap(); + let n = u16::from_le_bytes([bytes[end + 10], bytes[end + 11]]) as usize; + let mut at = u32::from_le_bytes(bytes[end + 16..end + 20].try_into().unwrap()) as usize; + let mut worst: f64 = 0.0; + for _ in 0..n { + let c = u32::from_le_bytes(bytes[at + 20..at + 24].try_into().unwrap()) as f64; + let u = u32::from_le_bytes(bytes[at + 24..at + 28].try_into().unwrap()) as f64; + if u > guard::RATIO_GRACE as f64 { + worst = worst.max(u / c.max(1.0)); + } + let skip = 46 + u16::from_le_bytes([bytes[at + 28], bytes[at + 29]]) as usize + u16::from_le_bytes([bytes[at + 30], bytes[at + 31]]) as usize + u16::from_le_bytes([bytes[at + 32], bytes[at + 33]]) as usize; + at += skip; + } + worst + }; + // Two hundred slides as the service makes them, and five hundred alike + // from an outline (the session's ceiling). + let mut s = Session::new(); + s.execute("file.new", &json!({ "blank": true })).unwrap(); + s.execute("slide.fromOutline", &json!({ "text": vec!["Slide\n\tA point\n\tAnother point"; 200].join("\n") })).unwrap(); + s.execute("slide.fromOutline", &json!({ "text": vec!["Slide\n\tA point\n\tAnother point"; 200].join("\n") })).unwrap(); + s.execute("slide.fromOutline", &json!({ "text": vec!["Slide\n\tA point\n\tAnother point"; 100].join("\n") })).unwrap(); + let alike = s.doc().unwrap().doc.as_ref().clone(); + assert_eq!(alike.slides.len(), 500); + for (name, deck) in [("sample", sample(200)), ("alike", alike)] { + for (ext, bytes) in [("deckcraft", native(&deck)), ("pptx", pptx(&deck))] { + let ratio = worst(&bytes); + eprintln!("{name}.{ext}: {} bytes, worst ratio past the grace {ratio:.1} to 1", bytes.len()); + assert!(guard::check(&format!("{name}.{ext}"), &bytes).is_ok(), "{name}.{ext}: {:?}", guard::check(&format!("{name}.{ext}"), &bytes)); + } + } +} + +#[test] +fn a_file_or_a_deck_over_the_caps_is_too_large() { + let w = World::new(); + let big = std::fs::File::create(w.root.join("big.pptx")).unwrap(); + big.set_len(MAX_DECK_BYTES + 1).unwrap(); + coded(&w.err("open", json!({ "path": "big.pptx" })), "too_large"); + coded(&w.err("cover", json!({ "path": "big.pptx" })), "too_large"); + // A deck past the session's 500 slides. + let mut deck = sample(1); + let first = deck.slides[0].clone(); + for k in 0..crate::MAX_RUN_SLIDES { + let mut s = (*first).clone(); + s.id = deckcraft_model::SlideId(100_000 + k as u32); + deck.slides.push(Arc::new(s)); + } + w.put("huge.deckcraft", &native(&deck)); + let e = w.err("open", json!({ "path": "huge.deckcraft" })); + coded(&e, "too_large"); + assert!(e.contains("501 slides"), "{e}"); + coded(&w.err("cover", json!({ "path": "huge.deckcraft" })), "too_large"); +} + +// ------------------------------------------------------------ reading and drawing + +#[test] +fn slides_and_a_slides_shapes() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 6); + let slides = w.ok("slides", json!({ "doc": doc })); + let list = slides["slides"].as_array().unwrap(); + assert_eq!(list.len(), 6); + assert_eq!(list[0]["title"], "Northwind Cooperative"); + assert_eq!((list[1]["index"].clone(), list[1]["hidden"].clone(), list[1]["comments"].clone()), (json!(1), json!(false), json!(0))); + assert_eq!(list[4]["builds"], 2, "two click builds: {}", list[4]); + assert_eq!(list[1]["builds"], 0); + assert!(list.iter().all(|s| s["layout"].is_u64()), "a layout id: {slides}"); + assert_eq!(slides["size"], json!([960.0, 540.0])); + // One slide's shapes: the title and body placeholders with their + // paragraphs, a table, a chart, a picture. + let ids: Vec = list.iter().map(|s| s["id"].as_u64().unwrap()).collect(); + let s1 = w.ok("slide", json!({ "doc": doc, "slide": ids[1] })); + assert_eq!((s1["id"].clone(), s1["index"].clone(), s1["hidden"].clone()), (json!(ids[1]), json!(1), json!(false)), "{s1}"); + let body = s1["shapes"].as_array().unwrap().iter().find(|s| s["text"] == "First point\nSecond point").unwrap_or_else(|| panic!("{s1}")); + assert_eq!(body["paragraphs"], json!([{ "text": "First point", "level": 0 }, { "text": "Second point", "level": 0 }])); + assert_eq!(body["box"].as_array().unwrap().len(), 4); + let table = w.shapes(&doc, ids[2]).into_iter().find(|s| s["kind"] == "table").unwrap(); + assert_eq!((table["table"]["rows"].clone(), table["table"]["cols"].clone()), (json!(3), json!(2)), "{table}"); + let chart = w.shapes(&doc, ids[3]).into_iter().find(|s| s["kind"] == "chart").unwrap(); + assert_eq!(chart["chart"]["categories"], json!(["Q1", "Q2", "Q3"]), "{chart}"); + let picture = w.shapes(&doc, ids[4]).into_iter().find(|s| s["kind"] == "picture").unwrap(); + assert_eq!(picture["picture"]["crop"], json!([0.0, 0.0, 0.0, 0.0]), "{picture}"); + assert!(w.shapes(&doc, ids[4]).iter().any(|s| s["line"].is_object()), "an outline for a shape"); + coded(&w.err("slide", json!({ "doc": doc, "slide": 999_999 })), "unknown_id"); + // The outline text. + let text = w.ok("text", json!({ "doc": doc })); + assert!(text["outline"].as_str().unwrap().starts_with("Northwind Cooperative\n"), "{text}"); +} + +#[test] +fn hit_testing_a_shape_its_text_and_its_handles() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 6); + let ids = w.slide_ids(&doc); + let rect = w.shape_where(&doc, ids[4], "text", "First"); + // The rectangle at [50, 400, 100, 60]: inside it, and nothing at a + // corner of the slide. + let hit = w.ok("hit", json!({ "doc": doc, "slide": ids[4], "at": [100, 430] })); + assert_eq!(hit["hit"]["id"], json!(rect), "{hit}"); + assert!(hit["hit"]["kind"] == "shape" || hit["hit"]["kind"] == "text", "{hit}"); + assert_eq!(w.ok("hit", json!({ "doc": doc, "slide": ids[4], "at": [955, 535] })), json!({ "hit": null })); + // With it selected, its bottom-right corner is a resize handle. + let handle = w.ok("hit", json!({ "doc": doc, "slide": ids[4], "at": [150, 460], "selected": [rect] })); + assert_eq!((handle["hit"]["kind"].clone(), handle["hit"]["id"].clone()), (json!("resize"), json!(rect)), "{handle}"); + assert!(handle["hit"]["handle"].is_u64()); + coded(&w.err("hit", json!({ "doc": doc, "slide": ids[4], "at": [1, 1], "selected": [999_999] })), "unknown_id"); + // An empty selection is no selection: the corner is the shape again, + // not a handle, and a tap in it still finds the shape. + let corner = json!({ "doc": doc, "slide": ids[4], "at": [150, 460] }); + let unselected = w.ok("hit", corner.clone()); + assert_ne!(unselected["hit"]["kind"], "resize", "{unselected}"); + let mut empty = corner; + empty["selected"] = json!([]); + assert_eq!(w.ok("hit", empty), unselected); + let tap = w.ok("hit", json!({ "doc": doc, "slide": ids[4], "at": [100, 430], "selected": [] })); + assert_eq!(tap["hit"]["id"], json!(rect), "{tap}"); + assert_eq!(w.ok("state", json!({ "doc": doc }))["edited"], false, "a hit changes nothing"); +} + +#[test] +fn viewing_a_slide_into_the_render_cache() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 6); + let ids = w.slide_ids(&doc); + let v = w.ok("view", json!({ "doc": doc, "slide": ids[1], "width": 480 })); + let path = v["path"].as_str().unwrap().to_string(); + assert!(path.starts_with(&format!(".cache/slides/{doc}/")), "{v}"); + assert_eq!((v["width"].clone(), v["height"].clone(), v["rev"].clone()), (json!(480), json!(270), json!(0)), "{v}"); + let file = w.root.join(&path); + let first = std::fs::read(&file).unwrap(); + assert!(first.starts_with(b"\x89PNG")); + let written = std::fs::metadata(&file).unwrap().modified().unwrap(); + std::thread::sleep(Duration::from_millis(20)); + assert_eq!(w.ok("view", json!({ "doc": doc, "slide": ids[1], "width": 480 })), v, "again from the cache"); + assert_eq!(std::fs::metadata(&file).unwrap().modified().unwrap(), written, "not drawn again"); + // The editor view, and the same slide with the body's text left out. + let body = w.shape_where(&doc, ids[1], "text", "First point\nSecond point"); + let edit = w.ok("view", json!({ "doc": doc, "slide": ids[1], "width": 480, "edit": true })); + let editing = w.ok("view", json!({ "doc": doc, "slide": ids[1], "width": 480, "edit": true, "editing": body })); + let png = |v: &Json| std::fs::read(w.root.join(v["path"].as_str().unwrap())).unwrap(); + assert_ne!(edit["path"], editing["path"]); + assert_ne!(png(&edit), png(&editing), "the text left out"); + // An edit of another slide leaves this render current; an edit of this + // one draws it again and removes the older revision. + w.ok("notes", json!({ "doc": doc, "slide": ids[2], "text": "x" })); + let again = w.ok("view", json!({ "doc": doc, "slide": ids[1], "width": 480 })); + assert_eq!((again["path"].clone(), again["rev"].clone()), (json!(path), json!(1)), "the slide did not change: {again}"); + let changed = w.ok("set_text", json!({ "doc": doc, "slide": ids[1], "id": body, "text": "One point" })); + assert_eq!(changed["changed"], json!([ids[1]]), "{changed}"); + let redrawn = w.ok("view", json!({ "doc": doc, "slide": ids[1], "width": 480 })); + assert_ne!(redrawn["path"], json!(path), "a new revision, a new file"); + assert!(!file.exists(), "the older revision of the same slide, size and mode went"); + assert_ne!(png(&redrawn), first, "shows its unsaved edit"); + // Caps. + for width in [47, 3841, 0] { + coded(&w.err("view", json!({ "doc": doc, "slide": ids[1], "width": width })), "invalid"); + } + coded(&w.err("view", json!({ "doc": doc, "slide": ids[1] })), "invalid"); + coded(&w.err("view", json!({ "doc": doc, "slide": ids[1], "width": 96, "step": 1, "edit": true })), "invalid"); + coded(&w.err("view", json!({ "doc": doc, "slide": ids[1], "width": 96, "editing": 999_999 })), "unknown_id"); + w.ok("view", json!({ "doc": doc, "slide": ids[1], "width": 3840 })); +} + +#[test] +fn present_shows_the_order_and_each_builds_end_state() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 6); + let ids = w.slide_ids(&doc); + w.ok("slides_op", json!({ "doc": doc, "op": "hide", "slides": [ids[2]], "hidden": true })); + let show = w.ok("show", json!({ "doc": doc })); + let order: Vec = show["order"].as_array().unwrap().iter().map(|e| e["slide"].as_u64().unwrap()).collect(); + assert_eq!(order, vec![ids[0], ids[1], ids[3], ids[4], ids[5]], "the hidden slide is left out: {show}"); + assert_eq!(show["loop"], false); + assert_eq!(show["order"][3]["steps"], 2, "{show}"); + let from = w.ok("show", json!({ "doc": doc, "from": ids[3] })); + let order: Vec = from["order"].as_array().unwrap().iter().map(|e| e["slide"].as_u64().unwrap()).collect(); + assert_eq!(order, vec![ids[3], ids[4], ids[5]]); + // Each step's end state: before the builds the two shapes are hidden, + // then one, then both show. + let step = |k: u64| { + let v = w.ok("view", json!({ "doc": doc, "slide": ids[4], "width": 240, "step": k })); + std::fs::read(w.root.join(v["path"].as_str().unwrap())).unwrap() + }; + let (s0, s1, s2) = (step(0), step(1), step(2)); + assert!(s0 != s1 && s1 != s2 && s0 != s2, "three different end states"); + let plain = w.ok("view", json!({ "doc": doc, "slide": ids[4], "width": 240 })); + assert_eq!(std::fs::read(w.root.join(plain["path"].as_str().unwrap())).unwrap(), s2, "after every build, the slide as it stands"); + coded(&w.err("view", json!({ "doc": doc, "slide": ids[4], "width": 240, "step": 3 })), "invalid"); + coded(&w.err("show", json!({ "doc": doc, "from": 999_999 })), "unknown_id"); +} + +#[test] +fn layouts_themes_fonts_and_shapes() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 2); + let layouts = w.ok("layouts", json!({ "doc": doc, "width": 120 })); + let list = layouts["layouts"].as_array().unwrap(); + assert_eq!(list.len(), 11, "{layouts}"); + for l in list { + assert!(std::fs::read(w.root.join(l["path"].as_str().unwrap())).unwrap().starts_with(b"\x89PNG"), "{l}"); + } + // A theme change draws them again; the older ones go. + let before = list[0]["path"].as_str().unwrap().to_string(); + w.ok("design", json!({ "doc": doc, "theme": "Nocturne" })); + let after = w.ok("layouts", json!({ "doc": doc, "width": 120 })); + assert_ne!(after["layouts"][0]["path"], json!(before)); + assert!(!w.root.join(&before).exists()); + let themes = w.ok("themes", json!({})); + let names: Vec<&str> = themes["themes"].as_array().unwrap().iter().map(|t| t["name"].as_str().unwrap()).collect(); + assert_eq!(names, ["DeckCraft", "Harbor", "Ember", "Meadow", "Nocturne", "Paper", "Coral Reef", "Slate"]); + assert_eq!(themes["themes"][0]["label"], "Default"); + assert!(themes["color_schemes"].as_array().unwrap().iter().any(|c| c == "Ocean"), "{themes}"); + assert!(themes["font_schemes"].as_array().unwrap().iter().any(|f| f["name"] == "Office Compatible"), "{themes}"); + let fonts = w.ok("fonts", json!({})); + assert!(fonts["families"].as_array().is_some_and(|f| !f.is_empty()), "at least the last-resort face: {fonts}"); + let shapes = w.ok("shapes", json!({})); + assert!(shapes["presets"].as_array().unwrap().iter().any(|p| p["name"] == "rect" && p["label"].is_string() && p["category"].is_string()), "{shapes}"); +} + +#[test] +fn format_state_of_a_box_and_of_a_paragraph() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 2); + let ids = w.slide_ids(&doc); + let body = w.shape_where(&doc, ids[1], "text", "First point\nSecond point"); + w.ok("format", json!({ "doc": doc, "slide": ids[1], "ids": [body], "paragraphs": [1, 1], "bold": true, "color": "#C0392B", "align": "center" })); + let box_state = w.ok("format_state", json!({ "doc": doc, "slide": ids[1], "id": body })); + assert_eq!(box_state["bold"], false, "the box's first run: {box_state}"); + let p1 = w.ok("format_state", json!({ "doc": doc, "slide": ids[1], "id": body, "paragraph": 1 })); + assert_eq!((p1["bold"].clone(), p1["color"].clone(), p1["align"].clone()), (json!(true), json!("#C0392B"), json!("center")), "{p1}"); + assert!(p1["font"].is_string() && p1["size"].is_number(), "{p1}"); + coded(&w.err("format_state", json!({ "doc": doc, "slide": ids[1], "id": body, "paragraph": 5 })), "invalid"); + assert_eq!(w.ok("state", json!({ "doc": doc }))["rev"], 1, "reading the format changed nothing"); +} + +// ------------------------------------------------------------ slides + +#[test] +fn slide_operations() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 4); + let ids = w.slide_ids(&doc); + // New, at the end by default, and at a position. + let made = w.ok("slides_op", json!({ "doc": doc, "op": "new", "layout": "titleOnly", "title": "Questions" })); + let q = made["slide"].as_u64().unwrap(); + assert_eq!(made["changed"], json!([q]), "{made}"); + assert_eq!(*w.slide_ids(&doc).last().unwrap(), q); + let at1 = w.ok("slides_op", json!({ "doc": doc, "op": "new", "at": 1 }))["slide"].as_u64().unwrap(); + assert_eq!(w.slide_ids(&doc)[1], at1); + // Duplicate: each copy follows its original. + let dup = w.ok("slides_op", json!({ "doc": doc, "op": "duplicate", "slides": [ids[2], ids[0]] })); + let copies: Vec = dup["slides"].as_array().unwrap().iter().map(|c| c.as_u64().unwrap()).collect(); + let now = w.slide_ids(&doc); + assert_eq!(now.len(), 8); + assert_eq!(now.iter().position(|x| *x == copies[0]).unwrap(), now.iter().position(|x| *x == ids[0]).unwrap() + 1, "{now:?} {copies:?}"); + assert_eq!(now.iter().position(|x| *x == copies[1]).unwrap(), now.iter().position(|x| *x == ids[2]).unwrap() + 1, "{now:?} {copies:?}"); + // Delete, but never every slide. + w.ok("slides_op", json!({ "doc": doc, "op": "delete", "slides": copies })); + let now = w.slide_ids(&doc); + assert_eq!(now.len(), 6); + coded(&w.err("slides_op", json!({ "doc": doc, "op": "delete", "slides": now })), "invalid"); + // Move: the slides named, in their order, at the position named. + let order = w.slide_ids(&doc); + w.ok("slides_op", json!({ "doc": doc, "op": "move", "slides": [order[1], order[0]], "to": 3 })); + let moved = w.slide_ids(&doc); + assert_eq!(moved, vec![order[2], order[3], order[4], order[1], order[0], order[5]], "{order:?} → {moved:?}"); + w.ok("slides_op", json!({ "doc": doc, "op": "move", "slides": [order[0]], "to": 0 })); + assert_eq!(w.slide_ids(&doc)[0], order[0]); + // Hide, layout, reset. + let s = w.slide_ids(&doc)[2]; + w.ok("slides_op", json!({ "doc": doc, "op": "hide", "slides": [s], "hidden": true })); + assert_eq!(w.ok("slide", json!({ "doc": doc, "slide": s }))["hidden"], true); + let blank = w.ok("open", json!({ "path": "board.deckcraft" }))["layouts"].as_array().unwrap().iter().find(|l| l["kind"] == "blank").unwrap()["id"].clone(); + let laid = w.ok("slides_op", json!({ "doc": doc, "op": "layout", "slides": [s], "layout": blank })); + assert_eq!(laid["changed"], json!([s])); + assert_eq!(w.ok("slide", json!({ "doc": doc, "slide": s }))["layout"], blank); + w.ok("slides_op", json!({ "doc": doc, "op": "reset", "slides": [s] })); + // Sections. + let secs = w.ok("slides_op", json!({ "doc": doc, "op": "section_add", "at": 2, "name": "Results" })); + assert_eq!(secs["sections"], json!([{ "name": "Default Section", "first": 0, "count": 2 }, { "name": "Results", "first": 2, "count": 4 }]), "{secs}"); + let secs = w.ok("slides_op", json!({ "doc": doc, "op": "section_rename", "section": 1, "name": "Numbers" })); + assert_eq!(secs["sections"][1]["name"], "Numbers"); + let n = w.slide_ids(&doc).len(); + let secs = w.ok("slides_op", json!({ "doc": doc, "op": "section_move", "section": 1, "to": 0 })); + assert_eq!(secs["sections"][0]["name"], "Numbers", "{secs}"); + let secs = w.ok("slides_op", json!({ "doc": doc, "op": "section_remove", "section": 0 })); + assert_eq!(secs["sections"].as_array().unwrap().len(), 1, "{secs}"); + assert_eq!(w.slide_ids(&doc).len(), n, "removing a section keeps its slides"); + // Refusals. + coded(&w.err("slides_op", json!({ "doc": doc, "op": "shuffle" })), "invalid"); + coded(&w.err("slides_op", json!({ "doc": doc, "op": "hide", "slides": [999_999], "hidden": true })), "unknown_id"); + coded(&w.err("slides_op", json!({ "doc": doc, "op": "section_rename", "section": 9, "name": "x" })), "invalid"); + let many: Vec = (0..501).collect(); + coded(&w.err("slides_op", json!({ "doc": doc, "op": "hide", "slides": many, "hidden": true })), "too_large"); +} + +#[test] +fn notes_and_transitions() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 3); + let ids = w.slide_ids(&doc); + let n = w.ok("notes", json!({ "doc": doc, "slide": ids[1], "text": "Pause here\nThen the chart" })); + assert_eq!(n["changed"], json!([ids[1]])); + assert_eq!(w.ok("slide", json!({ "doc": doc, "slide": ids[1] }))["notes"], "Pause here\nThen the chart"); + coded(&w.err("notes", json!({ "doc": doc, "slide": ids[1], "text": "x".repeat(20_001) })), "too_large"); + let t = w.ok("transition", json!({ "doc": doc, "slides": [ids[1]], "kind": "push", "option": "l", "duration": 800 })); + assert_eq!(t["changed"], json!([ids[1]])); + let s = w.ok("slide", json!({ "doc": doc, "slide": ids[1] })); + assert_eq!((s["transition"]["kind"].clone(), s["transition"]["option"].clone(), s["transition"]["duration"].clone()), (json!("push"), json!("l"), json!(800)), "{s}"); + let all = w.ok("transition", json!({ "doc": doc, "all": true, "kind": "fade" })); + assert_eq!(all["changed"].as_array().unwrap().len(), 3, "{all}"); + assert!(w.ok("slides", json!({ "doc": doc }))["slides"].as_array().unwrap().iter().all(|s| s["transition"] == "fade")); + coded(&w.err("transition", json!({ "doc": doc, "all": true, "kind": "teleport" })), "invalid"); + coded(&w.err("transition", json!({ "doc": doc, "all": true, "kind": "push", "option": "sideways" })), "invalid"); + coded(&w.err("transition", json!({ "doc": doc, "kind": "fade" })), "invalid"); + coded(&w.err("transition", json!({ "doc": doc, "all": true, "kind": "fade", "duration": 60_000 })), "invalid"); +} + +// ------------------------------------------------------------ objects + +#[test] +fn inserting_text_shapes_pictures_tables_and_charts() { + let w = World::new(); + w.put("images/dot.png", &png()); + let doc = w.sample_open("board.deckcraft", 2); + let s = w.slide_ids(&doc)[1]; + let t = w.ok("insert", json!({ "doc": doc, "slide": s, "kind": "text", "rect": [100, 100, 300, 50], "text": "A note" })); + assert_eq!((t["changed"].clone(), t["can_undo"].clone()), (json!([s]), json!(true)), "{t}"); + let shapes = w.shapes(&doc, s); + let note = shapes.iter().find(|x| x["id"] == t["id"]).unwrap(); + assert_eq!(note["text"], "A note"); + let sh = w.ok("insert", json!({ "doc": doc, "slide": s, "kind": "shape", "preset": "roundRect", "text": "Box" })); + let boxed = w.shapes(&doc, s).into_iter().find(|x| x["id"] == sh["id"]).unwrap(); + assert_eq!(boxed["box"], json!([408.0, 198.0, 144.0, 144.0]), "the service centres a default box: {boxed}"); + let p = w.ok("insert", json!({ "doc": doc, "slide": s, "kind": "picture", "path": "images/dot.png" })); + assert_eq!(p["size"].as_array().unwrap().len(), 2, "{p}"); + let tb = w.ok("insert", json!({ "doc": doc, "slide": s, "kind": "table", "rows": 2, "cols": 3, "cells": [["a", "b", "c"], ["1", "2"]], "style": "medium2-accent1" })); + let table = w.shapes(&doc, s).into_iter().find(|x| x["id"] == tb["id"]).unwrap(); + assert_eq!(table["table"]["cells"], json!([["a", "b", "c"], ["1", "2", ""]]), "{table}"); + assert_eq!(table["table"]["style"], "medium2-accent1"); + let ch = w.ok("insert", json!({ "doc": doc, "slide": s, "kind": "chart", "type": "bar", "categories": ["A", "B"], "series": [{ "name": "S", "values": [1, 2] }], "title": "Sales" })); + let chart = w.shapes(&doc, s).into_iter().find(|x| x["id"] == ch["id"]).unwrap(); + assert_eq!((chart["chart"]["title"].clone(), chart["chart"]["categories"].clone()), (json!("Sales"), json!(["A", "B"])), "{chart}"); + // Each insert is one undo step: the table and its style go together. + w.ok("undo", json!({ "doc": doc })); + w.ok("undo", json!({ "doc": doc })); + assert!(w.shapes(&doc, s).iter().all(|x| x["kind"] != "table" && x["kind"] != "chart")); + // Refusals. + coded(&w.err("insert", json!({ "doc": doc, "slide": s, "kind": "shape", "preset": "dragon" })), "invalid"); + coded(&w.err("insert", json!({ "doc": doc, "slide": s, "kind": "video", "path": "x.mp4" })), "invalid"); + coded(&w.err("insert", json!({ "doc": doc, "slide": s, "kind": "table", "rows": 76, "cols": 2 })), "invalid"); + coded(&w.err("insert", json!({ "doc": doc, "slide": s, "kind": "chart", "type": "pie3d", "categories": [], "series": [] })), "invalid"); + coded(&w.err("insert", json!({ "doc": doc, "slide": 999_999, "kind": "text" })), "unknown_id"); +} + +/// The caps on objects: SERVICE.md's and the door's, each refused before +/// anything changes. +#[test] +fn the_caps_on_objects() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 2); + let s = w.slide_ids(&doc)[1]; + let before = w.ok("state", json!({ "doc": doc })); + // A fill of 1,025 cells (33 × 32 = 1,056 > 1,024). + let row: Vec<&str> = vec!["x"; 32]; + let cells: Vec> = vec![row.clone(); 33]; + let e = w.err("insert", json!({ "doc": doc, "slide": s, "kind": "table", "rows": 33, "cols": 32, "cells": cells })); + coded(&e, "too_large"); + assert!(e.contains("1024"), "{e}"); + let cells: Vec> = vec![row; 32]; + w.ok("insert", json!({ "doc": doc, "slide": s, "kind": "table", "rows": 32, "cols": 32, "cells": cells })); + w.ok("undo", json!({ "doc": doc })); + // A chart of 10,001 points (the door's). + let values: Vec = vec![1.0; 10_000]; + let e = w.err("insert", json!({ "doc": doc, "slide": s, "kind": "chart", "type": "line", "categories": [], "series": [{ "name": "s", "values": values }] })); + coded(&e, "too_large"); + assert!(e.contains("10001 chart data points"), "{e}"); + // 1,001 line breaks. + coded(&w.err("insert", json!({ "doc": doc, "slide": s, "kind": "text", "text": "a\n".repeat(1_001) })), "too_large"); + // A position past 100,000 points (the door's). + let e = w.err("insert", json!({ "doc": doc, "slide": s, "kind": "text", "rect": [100_001, 0, 10, 10] })); + coded(&e, "too_large"); + assert!(e.contains("100001 points"), "{e}"); + // 501 shapes named. + let many: Vec = (1..=501).collect(); + coded(&w.err("shape", json!({ "doc": doc, "slide": s, "ids": many, "op": "delete" })), "too_large"); + // A slide past 1,920 × 1,080 points (the door's). + coded(&w.err("design", json!({ "doc": doc, "size": [2000, 1100] })), "too_large"); + let after = w.ok("state", json!({ "doc": doc })); + assert_eq!( + (after["edited"].clone(), after["can_undo"].clone(), after["can_redo"].clone()), + (before["edited"].clone(), json!(false), json!(true)), + "only the one fill that fit, undone: {after}" + ); +} + +/// The door's `RunBudget` ceilings hold after every command of an app's +/// method, as after `deck.run`'s: duplicating 201 slides of a 300-slide deck +/// crosses the session's 500 slides partway, and the whole call goes back. +/// A drawing keeps to 4,096 pixels a side and 16 megapixels. +#[test] +fn the_sessions_ceilings_hold_after_every_command() { + let w = World::new(); + w.put("long.deckcraft", &native(&sample(300))); + let doc = w.open("long.deckcraft"); + let ids = w.slide_ids(&doc); + let e = w.err("slides_op", json!({ "doc": doc, "op": "duplicate", "slides": ids[..201].to_vec() })); + coded(&e, "too_large"); + assert!(e.contains("`slide.duplicate` leaves the presentation with 501 slides, more than the 500"), "{e}"); + assert!(e.starts_with("too_large: deck.slides_op:"), "named by the method: {e}"); + let state = w.ok("state", json!({ "doc": doc })); + assert_eq!((state["slides"].clone(), state["rev"].clone(), state["can_undo"].clone()), (json!(300), json!(0), json!(false)), "all of it went back: {state}"); + w.ok("slides_op", json!({ "doc": doc, "op": "duplicate", "slides": ids[..200].to_vec() })); + assert_eq!(w.ok("state", json!({ "doc": doc }))["slides"], 500); + // A portrait slide drawn 3,840 pixels wide would be 6,827 high. + let small = w.sample_open("small.deckcraft", 1); + w.ok("design", json!({ "doc": small, "size": [540, 960] })); + let s = w.slide_ids(&small)[0]; + let e = w.err("view", json!({ "doc": small, "slide": s, "width": 3840 })); + coded(&e, "too_large"); + assert!(e.contains("3840 × 6827"), "{e}"); + w.ok("view", json!({ "doc": small, "slide": s, "width": 2304 })); +} + +#[test] +fn set_text_replaces_only_the_span_that_changed() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 2); + let s = w.slide_ids(&doc)[1]; + let id = w.ok("insert", json!({ "doc": doc, "slide": s, "kind": "text", "rect": [100, 100, 400, 60], "text": "Hello world today" }))["id"].as_u64().unwrap(); + // Make "world" bold, by its paragraph's characters 6 to 11. + docs::with_doc(&json!({ "doc": doc }), &Ctx { area: &Area::new(&w.root, None, true), app: APP, isolate: 0 }, "test", |d| { + let i = d.slide_index(s as u32)?; + for (cmd, p) in [ + ("slide.go", json!({ "index": i })), + ("text.edit", json!({ "id": id, "at": [0, 6] })), + ("text.select", json!({ "anchor": [0, 6], "caret": [0, 11] })), + ("format.bold", json!({ "on": true })), + ("text.exit", json!({})), + ] { + d.session.execute(cmd, &p).unwrap(); + } + Ok(()) + }) + .unwrap(); + let bold = |t: &str| (t.to_string(), true); + let plain = |t: &str| (t.to_string(), false); + assert_eq!(runs(&doc, s, id), [plain("Hello "), bold("world"), plain(" today")]); + let steps = undo_len(&doc); + // An insertion before the bold word takes the format at the caret. + let r = w.ok("set_text", json!({ "doc": doc, "slide": s, "id": id, "text": "Hello brave world today" })); + assert_eq!((r["id"].clone(), r["changed"].clone()), (json!(id), json!([s])), "{r}"); + assert_eq!(runs(&doc, s, id), [plain("Hello brave "), bold("world"), plain(" today")]); + // Replacing the bold word keeps it bold: the new text takes the format + // of the span's first character; the rest of the box keeps its runs. + w.ok("set_text", json!({ "doc": doc, "slide": s, "id": id, "text": "Hello brave there today" })); + assert_eq!(runs(&doc, s, id), [plain("Hello brave "), bold("there"), plain(" today")]); + // A deletion, and a new paragraph with a line break. + w.ok("set_text", json!({ "doc": doc, "slide": s, "id": id, "text": "Hello there today" })); + assert_eq!(runs(&doc, s, id), [plain("Hello "), bold("there"), plain(" today")]); + w.ok("set_text", json!({ "doc": doc, "slide": s, "id": id, "text": "Hello there today\nNext\u{b}line" })); + let text = w.shapes(&doc, s).into_iter().find(|x| x["id"] == json!(id)).unwrap(); + assert_eq!(text["text"], "Hello there today\nNext\u{b}line", "{text}"); + assert_eq!(runs(&doc, s, id)[..3], [plain("Hello "), bold("there"), plain(" today")]); + // Each call is one undo step: back through each of them, exactly. + assert_eq!(undo_len(&doc), steps + 4); + w.ok("undo", json!({ "doc": doc })); + w.ok("undo", json!({ "doc": doc })); + assert_eq!(runs(&doc, s, id), [plain("Hello brave "), bold("there"), plain(" today")]); + w.ok("undo", json!({ "doc": doc })); + w.ok("undo", json!({ "doc": doc })); + assert_eq!(runs(&doc, s, id), [plain("Hello "), bold("world"), plain(" today")]); + // The same text changes nothing. + let same = w.ok("set_text", json!({ "doc": doc, "slide": s, "id": id, "text": "Hello world today" })); + assert_eq!(same["changed"], json!([])); + // Text as typed: AutoCorrect and smart quotes are off. + let typed = "\"teh\" isn't -> (c) wich"; + w.ok("set_text", json!({ "doc": doc, "slide": s, "id": id, "text": typed })); + let got = w.shapes(&doc, s).into_iter().find(|x| x["id"] == json!(id)).unwrap(); + assert_eq!(got["text"], typed); + // An empty box takes its text whole; a table cell by its cell. + let empty = w.ok("insert", json!({ "doc": doc, "slide": s, "kind": "text", "rect": [100, 300, 200, 40] }))["id"].as_u64().unwrap(); + w.ok("set_text", json!({ "doc": doc, "slide": s, "id": empty, "text": "Fresh\n\tindented" })); + let fresh = w.shapes(&doc, s).into_iter().find(|x| x["id"] == json!(empty)).unwrap(); + assert_eq!(fresh["text"], "Fresh\n\tindented", "a leading tab stays a tab: {fresh}"); + let table = w.ok("insert", json!({ "doc": doc, "slide": s, "kind": "table", "rows": 2, "cols": 2 }))["id"].as_u64().unwrap(); + w.ok("set_text", json!({ "doc": doc, "slide": s, "id": table, "cell": [1, 0], "text": "Cell" })); + let cells = w.shapes(&doc, s).into_iter().find(|x| x["id"] == json!(table)).unwrap()["table"]["cells"].clone(); + assert_eq!(cells, json!([["", ""], ["Cell", ""]])); + coded(&w.err("set_text", json!({ "doc": doc, "slide": s, "id": table, "text": "x" })), "invalid"); + coded(&w.err("set_text", json!({ "doc": doc, "slide": s, "id": table, "cell": [5, 0], "text": "x" })), "invalid"); + coded(&w.err("set_text", json!({ "doc": doc, "slide": s, "id": 999_999, "text": "x" })), "unknown_id"); + coded(&w.err("set_text", json!({ "doc": doc, "slide": s, "id": id, "text": "a\n".repeat(1_001) })), "too_large"); +} + +#[test] +fn formatting_boxes_and_paragraphs_is_one_undo_step() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 2); + let s = w.slide_ids(&doc)[1]; + let body = w.shape_where(&doc, s, "text", "First point\nSecond point"); + let steps = undo_len(&doc); + let r = w.ok( + "format", + json!({ "doc": doc, "slide": s, "ids": [body], "font": "Inter", "size": 28, "bold": true, "italic": true, "underline": true, "color": "accent2", "align": "right", "line_spacing": 1.5, "space_before": 6, "anchor": "middle", "autofit": "none", "columns": 2, "margins": { "left": 10 } }), + ); + assert_eq!((r["changed"].clone(), r["can_undo"].clone()), (json!([s]), json!(true)), "{r}"); + assert_eq!(undo_len(&doc), steps + 1, "thirteen format commands, one undo step"); + assert_eq!(w.ok("state", json!({ "doc": doc }))["undo"], "Format text"); + let state = w.ok("format_state", json!({ "doc": doc, "slide": s, "id": body })); + assert_eq!((state["bold"].clone(), state["italic"].clone(), state["underline"].clone(), state["size"].clone(), state["align"].clone()), (json!(true), json!(true), json!(true), json!(28.0), json!("right")), "{state}"); + w.ok("undo", json!({ "doc": doc })); + let state = w.ok("format_state", json!({ "doc": doc, "slide": s, "id": body })); + assert_eq!((state["bold"].clone(), state["italic"].clone()), (json!(false), json!(false)), "all of it undone at once: {state}"); + w.ok("redo", json!({ "doc": doc })); + assert_eq!(w.ok("format_state", json!({ "doc": doc, "slide": s, "id": body }))["bold"], true); + // A paragraph's level, in and out. + w.ok("format", json!({ "doc": doc, "slide": s, "ids": [body], "paragraphs": [1, 1], "level": 2 })); + let paras = w.shapes(&doc, s).into_iter().find(|x| x["id"] == json!(body)).unwrap()["paragraphs"].clone(); + assert_eq!(paras, json!([{ "text": "First point", "level": 0 }, { "text": "Second point", "level": 2 }])); + w.ok("format", json!({ "doc": doc, "slide": s, "ids": [body], "paragraphs": [1, 1], "level": -1 })); + assert_eq!(w.shapes(&doc, s).into_iter().find(|x| x["id"] == json!(body)).unwrap()["paragraphs"][1]["level"], 1); + // Refusals. + coded(&w.err("format", json!({ "doc": doc, "slide": s, "ids": [body] })), "invalid"); + coded(&w.err("format", json!({ "doc": doc, "slide": s, "ids": [body], "paragraphs": [0, 9], "bold": true })), "invalid"); + coded(&w.err("format", json!({ "doc": doc, "slide": s, "ids": [body, body + 1], "paragraphs": [0, 0], "bold": true })), "invalid"); + coded(&w.err("format", json!({ "doc": doc, "slide": s, "ids": [body], "color": "red" })), "invalid"); + coded(&w.err("format", json!({ "doc": doc, "slide": s, "ids": [body], "size": 4001 })), "invalid"); +} + +#[test] +fn arranging_and_styling_shapes() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 6); + let s = w.slide_ids(&doc)[4]; + let a = w.shape_where(&doc, s, "text", "First"); + let b = w.shape_where(&doc, s, "text", "Second"); + let op = |args: Json| { + let mut args = args; + args["doc"] = json!(doc); + args["slide"] = json!(s); + w.ok("shape", args) + }; + let box_of = |id: u64| w.shapes(&doc, s).into_iter().find(|x| x["id"] == json!(id)).unwrap()["box"].clone(); + op(json!({ "ids": [a], "op": "bounds", "x": 10, "y": 20, "w": 30, "h": 40 })); + assert_eq!(box_of(a), json!([10.0, 20.0, 30.0, 40.0])); + op(json!({ "ids": [a], "op": "move", "dx": 5, "dy": -5 })); + assert_eq!(box_of(a), json!([15.0, 15.0, 30.0, 40.0])); + op(json!({ "ids": [a], "op": "rotate", "deg": 30 })); + assert_eq!(w.shapes(&doc, s).into_iter().find(|x| x["id"] == json!(a)).unwrap()["rotation"], 30.0); + op(json!({ "ids": [a], "op": "line", "color": "#000000", "width": 2, "dash": "dash" })); + let line = w.shapes(&doc, s).into_iter().find(|x| x["id"] == json!(a)).unwrap()["line"].clone(); + assert_eq!((line["color"].clone(), line["width"].clone(), line["dash"].clone()), (json!("#000000"), json!(2.0), json!("dash")), "{line}"); + // The same outline again changes nothing: no undo step, no new revision. + let (rev, steps) = (w.ok("state", json!({ "doc": doc }))["rev"].clone(), undo_len(&doc)); + let again = op(json!({ "ids": [a], "op": "line", "color": "#000000", "width": 2, "dash": "dash" })); + assert_eq!((again["changed"].clone(), again["rev"].clone()), (json!([]), rev), "{again}"); + assert_eq!(undo_len(&doc), steps); + for args in [ + json!({ "ids": [a], "op": "flip", "axis": "vertical" }), + json!({ "ids": [a], "op": "order", "to": "front" }), + json!({ "ids": [a, b], "op": "align", "edge": "top", "to": "selection" }), + json!({ "ids": [a, b], "op": "distribute", "dir": "horizontal" }), + json!({ "ids": [a], "op": "fill", "color": "#2E6FD8", "transparency": 0.25 }), + json!({ "ids": [a], "op": "fill", "gradient": { "stops": [[0, "#FFFFFF"], [1, "accent1"]], "angle": 45 } }), + json!({ "ids": [a], "op": "fill", "none": true }), + json!({ "ids": [a], "op": "effects", "shadow": "outer", "glow": 6, "soft_edges": 2, "reflection": "tight" }), + json!({ "ids": [a], "op": "style", "index": 9 }), + json!({ "ids": [a], "op": "lock", "locked": true }), + json!({ "ids": [a], "op": "lock", "locked": false }), + json!({ "ids": [a], "op": "hide", "hidden": true }), + json!({ "ids": [a], "op": "hide", "hidden": false }), + ] { + let r = op(args.clone()); + assert_eq!((r["changed"].clone(), r["ids"].clone()), (json!([s]), args["ids"].clone()), "{args}: {r}"); + } + let group = op(json!({ "ids": [a, b], "op": "group" }))["ids"].clone(); + assert_eq!(group.as_array().unwrap().len(), 1, "{group}"); + let members = op(json!({ "ids": group, "op": "ungroup" }))["ids"].clone(); + assert_eq!(members.as_array().unwrap().len(), 2, "{members}"); + let copies = op(json!({ "ids": [a], "op": "duplicate" }))["ids"].clone(); + assert_eq!(copies.as_array().unwrap().len(), 1); + assert_eq!(op(json!({ "ids": copies, "op": "delete" }))["ids"], json!([])); + // A fill never takes a picture, a pattern or a path. + for (key, value) in [("picture", json!(0)), ("path", json!("images/dot.png")), ("pattern", json!({ "preset": "pct50" }))] { + let mut args = json!({ "doc": doc, "slide": s, "ids": [a], "op": "fill", "color": "#000000" }); + args[key] = value; + let e = w.err("shape", args); + coded(&e, "invalid"); + assert!(e.contains(&format!("`{key}` is not one of them")), "{e}"); + } + coded(&w.err("shape", json!({ "doc": doc, "slide": s, "ids": [a, b], "op": "bounds", "x": 0, "y": 0, "w": 1, "h": 1 })), "invalid"); + coded(&w.err("shape", json!({ "doc": doc, "slide": s, "ids": [a], "op": "group" })), "invalid"); + coded(&w.err("shape", json!({ "doc": doc, "slide": s, "ids": [a], "op": "style", "index": 42 })), "invalid"); + coded(&w.err("shape", json!({ "doc": doc, "slide": s, "ids": [a], "op": "spin" })), "invalid"); + coded(&w.err("shape", json!({ "doc": doc, "slide": s, "ids": [999_999], "op": "delete" })), "unknown_id"); +} + +#[test] +fn pictures_tables_and_charts() { + let w = World::new(); + w.put("images/dot.png", &png()); + let doc = w.sample_open("board.deckcraft", 6); + let ids = w.slide_ids(&doc); + let pic = w.shape_where(&doc, ids[4], "kind", "picture"); + let pop = |args: Json| { + let mut args = args; + args["doc"] = json!(doc); + args["slide"] = json!(ids[4]); + args["id"] = json!(pic); + w.ok("picture", args) + }; + pop(json!({ "op": "crop", "left": 0.1, "right": 0.2 })); + pop(json!({ "op": "adjust", "brightness": 0.2, "contrast": -0.1, "saturation": 1.5, "grayscale": true, "transparency": 0.3 })); + let p = w.shapes(&doc, ids[4]).into_iter().find(|x| x["id"] == json!(pic)).unwrap()["picture"].clone(); + assert_eq!((p["crop"][0].clone(), p["grayscale"].clone()), (json!(0.1), json!(true)), "{p}"); + pop(json!({ "op": "reset" })); + pop(json!({ "op": "replace", "path": "images/dot.png" })); + pop(json!({ "op": "alt_text", "text": "A dot", "decorative": false })); + assert_eq!(w.shapes(&doc, ids[4]).into_iter().find(|x| x["id"] == json!(pic)).unwrap()["alt_text"], "A dot"); + coded(&w.err("picture", json!({ "doc": doc, "slide": ids[4], "id": w.shape_where(&doc, ids[4], "text", "First"), "op": "reset" })), "invalid"); + // A table. + let table = w.shape_where(&doc, ids[2], "kind", "table"); + let top = |args: Json| { + let mut args = args; + args["doc"] = json!(doc); + args["slide"] = json!(ids[2]); + args["id"] = json!(table); + w.ok("table", args) + }; + assert_eq!((top(json!({ "op": "insert_row", "row": 0, "below": true }))["rows"].clone()), json!(4)); + assert_eq!(top(json!({ "op": "insert_col", "col": 1, "right": false }))["cols"], 3); + assert_eq!(top(json!({ "op": "delete_row", "row": 3 }))["rows"], 3); + assert_eq!(top(json!({ "op": "delete_col", "col": 0 }))["cols"], 2); + top(json!({ "op": "merge", "from": [0, 0], "to": [0, 1] })); + top(json!({ "op": "split", "cell": [0, 0] })); + top(json!({ "op": "style", "style": "light1-accent2" })); + top(json!({ "op": "options", "header_row": true, "banded_rows": false })); + top(json!({ "op": "fill", "cells": [[1, 1], [2, 1]], "color": "#FFEEDD" })); + top(json!({ "op": "fill", "cells": [[1, 1]], "color": "none" })); + top(json!({ "op": "col_width", "col": 0, "width": 150 })); + let r = top(json!({ "op": "row_height", "row": 0, "height": 30 })); + assert_eq!((r["rows"].clone(), r["cols"].clone(), r["changed"].clone()), (json!(3), json!(2), json!([ids[2]])), "{r}"); + coded(&w.err("table", json!({ "doc": doc, "slide": ids[2], "id": table, "op": "delete_row", "row": 9 })), "invalid"); + coded(&w.err("table", json!({ "doc": doc, "slide": ids[2], "id": table, "op": "merge", "from": [0, 0], "to": [5, 5] })), "invalid"); + // A chart. + let chart = w.shape_where(&doc, ids[3], "kind", "chart"); + let r = w.ok("chart", json!({ "doc": doc, "slide": ids[3], "id": chart, "type": "line", "categories": ["A", "B"], "series": [{ "name": "X", "values": [1, null] }], "title": "Trend", "legend": "b", "data_labels": true, "gridlines": false, "palette": "colorful" })); + assert_eq!(r["changed"], json!([ids[3]])); + let c = w.shapes(&doc, ids[3]).into_iter().find(|x| x["id"] == json!(chart)).unwrap()["chart"].clone(); + assert_eq!((c["title"].clone(), c["categories"].clone()), (json!("Trend"), json!(["A", "B"])), "{c}"); + w.ok("chart", json!({ "doc": doc, "slide": ids[3], "id": chart, "title": "" })); + assert!(w.shapes(&doc, ids[3]).into_iter().find(|x| x["id"] == json!(chart)).unwrap()["chart"]["title"].is_null(), "an empty title removes it"); + coded(&w.err("chart", json!({ "doc": doc, "slide": ids[3], "id": table, "title": "x" })), "unknown_id"); + coded(&w.err("chart", json!({ "doc": doc, "slide": ids[3], "id": chart })), "invalid"); +} + +// ------------------------------------------------------------ design, history, revisions + +#[test] +fn design_changes_reach_every_slide() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 3); + let ids = w.slide_ids(&doc); + let r = w.ok("design", json!({ "doc": doc, "theme": "Harbor", "colors": "Ocean", "fonts": "Classic" })); + assert_eq!(r["changed"], json!(ids), "a theme change lists every slide: {r}"); + assert_eq!(w.ok("state", json!({ "doc": doc }))["undo"], "Change theme"); + let sized = w.ok("design", json!({ "doc": doc, "size": "4:3", "scale": "fit" })); + assert_eq!(sized["size"], json!([720.0, 540.0])); + let bg = w.ok("design", json!({ "doc": doc, "background": { "color": "#F2F4F8", "slides": [ids[1]] } })); + assert_eq!(bg["changed"], json!([ids[1]]), "{bg}"); + let all = w.ok("design", json!({ "doc": doc, "background": { "gradient": { "stops": [[0, "#FFFFFF"], [1, "#000000"]] }, "all": true } })); + assert_eq!(all["changed"], json!(ids)); + w.ok("design", json!({ "doc": doc, "background": { "reset": true, "all": true } })); + let footer = w.ok("design", json!({ "doc": doc, "footer": { "slide_numbers": true, "footer": true, "footer_text": "Northwind", "hide_on_title": true } })); + assert_eq!(footer["changed"], json!(ids), "{footer}"); + // A background never takes a picture. + for (key, value) in [("picture", json!("aGVsbG8=")), ("path", json!("images/dot.png")), ("style", json!(3))] { + let mut bg = json!({ "color": "#000000", "all": true }); + bg[key] = value; + let e = w.err("design", json!({ "doc": doc, "background": bg })); + coded(&e, "invalid"); + assert!(e.contains(&format!("`{key}` is not one of them")), "{e}"); + } + coded(&w.err("design", json!({ "doc": doc })), "invalid"); + coded(&w.err("design", json!({ "doc": doc, "theme": "Vapor" })), "invalid"); + coded(&w.err("design", json!({ "doc": doc, "background": { "color": "#000000" } })), "invalid"); + coded(&w.err("design", json!({ "doc": doc, "scale": "fit" })), "invalid"); +} + +#[test] +fn revisions_and_changed_slides() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 4); + let ids = w.slide_ids(&doc); + let r = w.ok("notes", json!({ "doc": doc, "slide": ids[2], "text": "x" })); + assert_eq!((r["rev"].clone(), r["changed"].clone()), (json!(1), json!([ids[2]]))); + // A move changes no slide's content, until slides show their numbers. + let m = w.ok("slides_op", json!({ "doc": doc, "op": "move", "slides": [ids[3]], "to": 1 })); + assert_eq!((m["rev"].clone(), m["changed"].clone()), (json!(2), json!([])), "{m}"); + w.ok("design", json!({ "doc": doc, "footer": { "slide_numbers": true } })); + let m = w.ok("slides_op", json!({ "doc": doc, "op": "move", "slides": [ids[3]], "to": 3 })); + let changed: Vec = m["changed"].as_array().unwrap().iter().map(|c| c.as_u64().unwrap()).collect(); + assert!(changed.contains(&ids[1]) && changed.contains(&ids[2]) && changed.contains(&ids[3]) && !changed.contains(&ids[0]), "the slides whose number changed: {m}"); + // Undo and redo bump the revision and list what they changed. + let u = w.ok("undo", json!({ "doc": doc })); + assert_eq!((u["rev"].clone(), u["redo"].clone()), (json!(5), json!("Move slides")), "{u}"); + let rd = w.ok("redo", json!({ "doc": doc })); + assert_eq!(rd["rev"], 6); + // A save changes no revision. + let saved = w.ok("save", json!({ "doc": doc })); + assert_eq!(saved["rev"], 6); + assert_eq!(w.ok("state", json!({ "doc": doc }))["edited"], false); +} + +#[test] +fn undo_and_redo_across_calls_with_a_limit_of_a_hundred() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 2); + let s = w.slide_ids(&doc)[1]; + coded(&w.err("undo", json!({ "doc": doc })), "invalid"); + coded(&w.err("redo", json!({ "doc": doc })), "invalid"); + w.ok("notes", json!({ "doc": doc, "slide": s, "text": "one" })); + w.ok("notes", json!({ "doc": doc, "slide": s, "text": "two" })); + let notes = || w.ok("slide", json!({ "doc": doc, "slide": s }))["notes"].clone(); + let u = w.ok("undo", json!({ "doc": doc })); + assert_eq!((u["can_undo"].clone(), u["can_redo"].clone(), u["undo"].clone(), u["redo"].clone()), (json!(true), json!(true), json!("Edit notes"), json!("Edit notes")), "{u}"); + assert_eq!(notes(), "one"); + w.ok("undo", json!({ "doc": doc })); + assert_eq!(notes(), ""); + assert_eq!(w.ok("state", json!({ "doc": doc }))["edited"], false, "back where it opened"); + w.ok("redo", json!({ "doc": doc })); + assert_eq!(notes(), "one"); + // A new change drops what could be redone. + w.ok("notes", json!({ "doc": doc, "slide": s, "text": "three" })); + coded(&w.err("redo", json!({ "doc": doc })), "invalid"); + // At most 100 steps. + for k in 0..105 { + w.ok("notes", json!({ "doc": doc, "slide": s, "text": format!("n{k}") })); + } + assert_eq!(undo_len(&doc), docs::MAX_UNDO); + for _ in 0..docs::MAX_UNDO { + w.ok("undo", json!({ "doc": doc })); + } + coded(&w.err("undo", json!({ "doc": doc })), "invalid"); + assert_eq!(notes(), "n4", "the oldest hundred steps were kept"); +} + +/// A call whose engine command fails leaves the document as it was: no +/// half-made change, no undo step. +#[test] +fn a_failing_call_changes_nothing() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 3); + let ids = w.slide_ids(&doc); + let before = w.ok("state", json!({ "doc": doc })); + let table = w.shape_where(&doc, ids[2], "kind", "table"); + // The engine refuses to delete a table's last row. + for _ in 0..2 { + w.ok("table", json!({ "doc": doc, "slide": ids[2], "id": table, "op": "delete_row", "row": 0 })); + } + let e = w.err("table", json!({ "doc": doc, "slide": ids[2], "id": table, "op": "delete_row", "row": 0 })); + coded(&e, "invalid"); + let after = w.ok("state", json!({ "doc": doc })); + assert_eq!(after["rev"], json!(before["rev"].as_u64().unwrap() + 2), "only the two deletes that worked"); + // A plan the door refuses runs nothing (a held command, through the gate). + docs::with_doc(&json!({ "doc": doc }), &Ctx { area: &Area::new(&w.root, None, true), app: APP, isolate: 0 }, "test", |d| { + let plan = gate::Plan { method: "deck.test", most: 3, cmds: vec![("slide.go", json!({ "index": 0 })), ("insert.video", json!({ "data": "AAAA" }))] }; + let e = gate::change(d, &Area::new(&w.root, None, true), plan, "x").err().unwrap(); + assert!(e.starts_with("held: deck.test: `insert.video` is held back"), "{e}"); + let plan = gate::Plan { method: "deck.test", most: 1, cmds: vec![("slide.go", json!({ "index": 0 })), ("slide.go", json!({ "index": 1 }))] }; + let e = gate::change(d, &Area::new(&w.root, None, true), plan, "x").err().unwrap(); + assert!(e.starts_with("too_large: deck.test: 2 engine commands, more than the 1"), "{e}"); + let plan = gate::Plan { method: "deck.test", most: 1, cmds: vec![("shape.fill", json!({ "picture": 0, "path": "x.png" }))] }; + let e = gate::change(d, &Area::new(&w.root, None, true), plan, "x").err().unwrap(); + assert!(e.starts_with("invalid: deck.test: `shape.fill`: `picture` would have it read a file"), "{e}"); + let plan = gate::Plan { method: "deck.test", most: 1, cmds: vec![("file.save", json!({}))] }; + assert!(gate::change(d, &Area::new(&w.root, None, true), plan, "x").err().unwrap().contains("classed host")); + Ok(()) + }) + .unwrap(); + assert_eq!(w.ok("state", json!({ "doc": doc }))["rev"], after["rev"]); +} + +/// The per-slide cache of what a session holds counts what `Held::of` +/// counts, after every kind of change. +#[test] +fn the_held_cache_counts_what_the_ceilings_count() { + let mut s = Session::new(); + engine_file::add_opened(&mut s, "x.deckcraft", None, sample(6)); + let mut cache = gate::HeldCache::default(); + assert_eq!(cache.held(&s), Held::of(&s)); + for (id, p) in [ + ("slide.go", json!({ "index": 2 })), + ("insert.table", json!({ "rows": 4, "cols": 4 })), + ("slide.duplicate", json!({ "index": 2 })), + ("design.theme", json!({ "name": "Slate" })), + ("slide.delete", json!({ "index": 0 })), + ("design.slideSize", json!({ "w": 720, "h": 540 })), + ] { + s.execute(id, &p).unwrap(); + assert_eq!(cache.held(&s), Held::of(&s), "after {id}"); + } +} + +// ------------------------------------------------------------ saving and exporting + +#[test] +fn saving_and_a_render_after_a_save_and_a_reopen() { + let w = World::new(); + let doc = w.sample_open("decks/board.deckcraft", 3); + let ids = w.slide_ids(&doc); + let body = w.shape_where(&doc, ids[1], "text", "First point\nSecond point"); + w.ok("set_text", json!({ "doc": doc, "slide": ids[1], "id": body, "text": "Saved point" })); + let saved = w.ok("save", json!({ "doc": doc })); + assert_eq!(saved["path"], "decks/board.deckcraft"); + assert_eq!(saved["bytes"].as_u64().unwrap(), std::fs::metadata(w.root.join("decks/board.deckcraft")).unwrap().len()); + let view = w.ok("view", json!({ "doc": doc, "slide": ids[1], "width": 320 })); + let drawn = std::fs::read(w.root.join(view["path"].as_str().unwrap())).unwrap(); + w.ok("close", json!({ "doc": doc })); + let again = w.open("decks/board.deckcraft"); + assert_eq!(w.slide_ids(&again), ids, "ids stay through a save and a reopen"); + assert!(w.shapes(&again, ids[1]).iter().any(|s| s["text"] == "Saved point")); + let reopened = w.ok("view", json!({ "doc": again, "slide": ids[1], "width": 320 })); + assert_eq!(std::fs::read(w.root.join(reopened["path"].as_str().unwrap())).unwrap(), drawn, "the same slide, drawn again after the reopen"); + // A deck from a .pptx has no native file: its first save names one. + w.put("imports/board.pptx", &pptx(&sample(2))); + let p = w.open("imports/board.pptx"); + coded(&w.err("save", json!({ "doc": p })), "invalid"); + assert_eq!(w.ok("save", json!({ "doc": p, "path": "decks/imported.deckcraft" }))["path"], "decks/imported.deckcraft"); + w.ok("notes", json!({ "doc": p, "slide": w.slide_ids(&p)[0], "text": "x" })); + w.ok("save", json!({ "doc": p })); + assert!(w.root.join("decks/imported.deckcraft").is_file() && w.root.join("imports/board.pptx").is_file()); +} + +/// A full save goes to a new file and never over an existing one; a save +/// over the deck's own file needs the foreground. +#[test] +fn a_full_save_never_replaces_another_file() { + let w = World::new(); + w.put("taken.deckcraft", b"keep me"); + w.put("other.deckcraft", &native(&sample(1))); + let doc = w.sample_open("board.deckcraft", 2); + let original = std::fs::read(w.root.join("board.deckcraft")).unwrap(); + w.ok("notes", json!({ "doc": doc, "slide": w.slide_ids(&doc)[0], "text": "x" })); + for out in ["taken.deckcraft", "other.deckcraft", "board.deckcraft"] { + let e = w.err("save", json!({ "doc": doc, "path": out })); + coded(&e, "invalid"); + assert!(e.contains("already exists"), "{e}"); + } + assert_eq!(std::fs::read(w.root.join("taken.deckcraft")).unwrap(), b"keep me"); + coded(&w.err("save", json!({ "doc": doc, "path": "board.pptx" })), "invalid"); + // From the background, never over its own file; a new file it may. + let e = w.call_full(APP, "save", json!({ "doc": doc }), false, 0).unwrap_err(); + coded(&e, "invalid"); + assert_eq!(std::fs::read(w.root.join("board.deckcraft")).unwrap(), original); + assert!(w.call_full(APP, "save", json!({ "doc": doc, "path": "copy.deckcraft" }), false, 0).is_ok()); + // The deck is the new file from then on; the original stays as it was. + w.ok("notes", json!({ "doc": doc, "slide": w.slide_ids(&doc)[0], "text": "y" })); + w.ok("save", json!({ "doc": doc })); + assert_eq!(std::fs::read(w.root.join("board.deckcraft")).unwrap(), original); + let staging: Vec = w.files("").into_iter().filter(|f| f.contains(octosense_engine_area::STAGING_PREFIX)).collect(); + assert!(staging.is_empty(), "{staging:?}"); +} + +#[test] +fn exporting_pptx_pdf_pictures_and_outline() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 6); + let ids = w.slide_ids(&doc); + let p = w.ok("export", json!({ "doc": doc, "kind": "pptx", "out": "exports/board.pptx" })); + assert_eq!(p["path"], "exports/board.pptx"); + // An exported .pptx opens back with the same slides. + let back = w.ok("open", json!({ "path": "exports/board.pptx" })); + assert_eq!(back["slides"], 6, "{back}"); + let pdf = w.ok("export", json!({ "doc": doc, "kind": "pdf", "out": "exports/board.pdf", "dpi": 72 })); + assert_eq!(pdf["pages"], 6, "{pdf}"); + assert!(std::fs::read(w.root.join("exports/board.pdf")).unwrap().starts_with(b"%PDF")); + let handouts = w.ok("export", json!({ "doc": doc, "kind": "pdf", "out": "exports/handouts.pdf", "layout": "handouts", "per_page": 4, "dpi": 72 })); + assert_eq!(handouts["pages"], 2, "{handouts}"); + w.ok("export", json!({ "doc": doc, "kind": "pdf", "out": "exports/notes.pdf", "layout": "notes", "slides": [ids[1], ids[2]], "dpi": 72 })); + let images = w.ok("export", json!({ "doc": doc, "kind": "images", "out_dir": "exports/pictures", "width": 320, "slides": [ids[0], ids[2]] })); + assert_eq!(images["paths"], json!(["exports/pictures/Slide 1.png", "exports/pictures/Slide 3.png"])); + for p in images["paths"].as_array().unwrap() { + assert!(std::fs::read(w.root.join(p.as_str().unwrap())).unwrap().starts_with(b"\x89PNG")); + } + let outline = w.ok("export", json!({ "doc": doc, "kind": "outline", "out": "exports/board.txt" })); + assert!(std::fs::read_to_string(w.root.join("exports/board.txt")).unwrap().starts_with("Northwind Cooperative"), "{outline}"); + // New files only, whole or not at all. + coded(&w.err("export", json!({ "doc": doc, "kind": "pptx", "out": "exports/board.pptx" })), "invalid"); + w.put("exports/more/Slide 2.png", b"theirs"); + coded(&w.err("export", json!({ "doc": doc, "kind": "images", "out_dir": "exports/more", "width": 64 })), "invalid"); + assert!(!w.root.join("exports/more/Slide 1.png").exists(), "nothing written"); + // Caps, before anything is written. + coded(&w.err("export", json!({ "doc": doc, "kind": "pdf", "out": "x.pdf", "dpi": 201 })), "invalid"); + coded(&w.err("export", json!({ "doc": doc, "kind": "pdf", "out": "x.pdf", "per_page": 5, "layout": "handouts" })), "invalid"); + coded(&w.err("export", json!({ "doc": doc, "kind": "images", "out_dir": "big", "width": 4097 })), "invalid"); + coded(&w.err("export", json!({ "doc": doc, "kind": "word", "out": "x.docx" })), "invalid"); + assert!(!w.root.join("x.pdf").exists() && !w.root.join("big").exists()); +} + +#[test] +fn export_caps_on_rasters_and_pictures() { + let w = World::new(); + w.put("long.deckcraft", &native(&sample(80))); + let doc = w.open("long.deckcraft"); + // 80 slides at 150 dpi are 180 megapixels: over the 160 one export + // rasters; at 72 dpi they fit. + let e = w.err("export", json!({ "doc": doc, "kind": "pdf", "out": "all.pdf" })); + coded(&e, "too_large"); + assert!(e.contains("megapixels"), "{e}"); + let e = w.err("export", json!({ "doc": doc, "kind": "images", "out_dir": "pics", "width": 64 })); + coded(&e, "too_large"); + assert!(e.contains("80 slides"), "{e}"); + let ids = w.slide_ids(&doc); + let e = w.err("export", json!({ "doc": doc, "kind": "images", "out_dir": "pics", "width": 4096, "slides": ids[..32].to_vec() })); + coded(&e, "too_large"); + assert!(e.contains("256"), "{e}"); + assert!(!w.root.join("all.pdf").exists() && !w.root.join("pics").exists()); +} + +#[test] +fn paths_never_leave_the_storage() { + let w = World::new(); + w.put("images/dot.png", &png()); + let doc = w.sample_open("board.deckcraft", 2); + let s = w.slide_ids(&doc)[1]; + let pic = w.ok("insert", json!({ "doc": doc, "slide": s, "kind": "picture", "path": "images/dot.png" }))["id"].clone(); + let parent = w.root.parent().unwrap().to_path_buf(); + std::fs::write(parent.join("beside.png"), png()).unwrap(); + std::fs::write(parent.join("beside.deckcraft"), native(&sample(1))).unwrap(); + #[cfg(unix)] + { + std::os::unix::fs::symlink(&parent, w.root.join("up")).unwrap(); + std::os::unix::fs::symlink(parent.join("beside.png"), w.root.join("link.png")).unwrap(); + } + for bad in ["../beside.deckcraft", "/etc/hosts", "a/../../beside.deckcraft", "up/beside.deckcraft", "./board.deckcraft"] { + coded(&w.err("open", json!({ "path": bad })), "invalid"); + coded(&w.err("cover", json!({ "path": bad })), "invalid"); + } + for bad in ["../x.deckcraft", "/tmp/x.deckcraft", "up/x.deckcraft"] { + coded(&w.err("create", json!({ "out": bad })), "invalid"); + coded(&w.err("save", json!({ "doc": doc, "path": bad })), "invalid"); + } + for bad in ["../x.pptx", "up/x.pptx"] { + coded(&w.err("export", json!({ "doc": doc, "kind": "pptx", "out": bad })), "invalid"); + } + coded(&w.err("export", json!({ "doc": doc, "kind": "images", "out_dir": "up", "width": 64 })), "invalid"); + for bad in ["../beside.png", "/etc/hosts", "up/beside.png", "link.png"] { + let e = w.err("insert", json!({ "doc": doc, "slide": s, "kind": "picture", "path": bad })); + coded(&e, "invalid"); + assert!(!e.contains(parent.to_string_lossy().as_ref()), "an error never spells a host path: {e}"); + coded(&w.err("picture", json!({ "doc": doc, "slide": s, "id": pic, "op": "replace", "path": bad })), "invalid"); + } + let names: Vec = std::fs::read_dir(&parent).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(names.len(), 3, "nothing landed beside the storage: {names:?}"); +} + +/// The reviewed picture reads take a picture inside storage only: a missing +/// file, one past 64 MiB, a sound or a movie, an SVG and a picture past 50 +/// megapixels are each refused before the engine reads it. +#[test] +fn the_picture_reads_take_pictures_only() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 2); + let s = w.slide_ids(&doc)[1]; + coded(&w.err("insert", json!({ "doc": doc, "slide": s, "kind": "picture", "path": "missing.png" })), "not_found"); + let big = std::fs::File::create(w.root.join("big.png")).unwrap(); + big.set_len(octosense_engine_area::door::MAX_READ_BYTES + 1).unwrap(); + coded(&w.err("insert", json!({ "doc": doc, "slide": s, "kind": "picture", "path": "big.png" })), "too_large"); + w.put("tone.wav", b"RIFF\x24\0\0\0WAVEfmt \x10\0\0\0"); + w.put("clip.png", b"\0\0\0\x18ftypisom"); + w.put("logo.svg", b""); + for bad in ["tone.wav", "clip.png", "logo.svg"] { + let e = w.err("insert", json!({ "doc": doc, "slide": s, "kind": "picture", "path": bad })); + coded(&e, "invalid"); + assert!(e.contains("not a picture"), "{e}"); + } + // A PNG header naming 20,000 × 20,000 pixels. + let mut bomb = png(); + bomb[16..20].copy_from_slice(&20_000u32.to_be_bytes()); + bomb[20..24].copy_from_slice(&20_000u32.to_be_bytes()); + w.put("bomb.png", &bomb); + let e = w.err("insert", json!({ "doc": doc, "slide": s, "kind": "picture", "path": "bomb.png" })); + coded(&e, "too_large"); + assert!(e.contains("20000 × 20000"), "{e}"); + // A picture whose header doesn't say its size fails closed: a PNG cut + // inside its header, a JPEG with no frame. + w.put("cut.png", &png()[..20]); + w.put("frameless.jpg", b"\xFF\xD8\xFF\xE0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00\xFF\xD9"); + for odd in ["cut.png", "frameless.jpg"] { + let e = w.err("insert", json!({ "doc": doc, "slide": s, "kind": "picture", "path": odd })); + coded(&e, "invalid"); + assert!(e.contains("can't read the picture's size"), "{e}"); + } + assert_eq!(w.ok("state", json!({ "doc": doc }))["rev"], 0, "nothing reached the deck"); + assert_eq!(pictures::dimensions(&png()), Some((12, 8))); +} + +// ------------------------------------------------------------ the cache and covers + +#[test] +fn the_render_cache_keeps_to_its_file_cap() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 2); + let s = w.slide_ids(&doc)[0]; + let mut written = Vec::new(); + for width in (48..48 + 70).map(|k| k * 2) { + written.push(w.ok("view", json!({ "doc": doc, "slide": s, "width": width }))["path"].as_str().unwrap().to_string()); + } + let kept: Vec<&String> = written.iter().filter(|p| w.root.join(p).exists()).collect(); + assert_eq!(kept.len(), cache::MAX_FILES); + let gone = written.len() - cache::MAX_FILES; + assert!(written[..gone].iter().all(|p| !w.root.join(p).exists()), "the oldest went first"); + let again = w.ok("view", json!({ "doc": doc, "slide": s, "width": 96 })); + assert!(w.root.join(again["path"].as_str().unwrap()).exists(), "an evicted render is drawn again"); +} + +#[test] +fn the_render_cache_keeps_to_its_byte_cap_and_covers_to_theirs() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join(".cache/slides"); + let old = root.join("aaaa"); + std::fs::create_dir_all(&old).unwrap(); + let mib = vec![0u8; 1 << 20]; + let base = std::time::SystemTime::now() - Duration::from_secs(3600); + for i in 0..17u64 { + let path = old.join(format!("{i}-s@960-r0.png")); + std::fs::write(&path, &mib).unwrap(); + std::fs::File::options().write(true).open(&path).unwrap().set_modified(base + Duration::from_secs(i)).unwrap(); + } + let keep = root.join("bbbb/1-s@960-r0.png"); + let dot = dir.path().join(".cache"); + cache::evict(&dot, &keep, 1 << 20, cache::LIMITS); + let left: Vec = (0..17).filter(|i| old.join(format!("{i}-s@960-r0.png")).exists()).collect(); + assert_eq!(left, (2..17).collect::>(), "15 MiB stay with the new 1 MiB render; the two oldest went"); + // Covers keep to their 32, and slide renders go before covers. + let w = World::new(); + let mut covers = Vec::new(); + for i in 0..=cache::MAX_COVERS { + let name = format!("deck {i:02}.deckcraft"); + w.put(&name, &native(&sample(1))); + let path = w.ok("cover", json!({ "path": name, "width": 48 }))["path"].as_str().unwrap().to_string(); + std::fs::File::options().write(true).open(w.root.join(&path)).unwrap().set_modified(base + Duration::from_secs(i as u64)).unwrap(); + covers.push(path); + } + let kept: Vec = covers.iter().map(|p| w.root.join(p).exists()).collect(); + assert!(!kept[0] && kept[1..].iter().all(|k| *k), "the 33rd cover took the oldest's place: {kept:?}"); +} + +#[test] +fn a_cover_is_kept_across_closes_and_changes_with_its_deck() { + let w = World::new(); + w.put("decks/board.deckcraft", &native(&sample(3))); + let cover = w.ok("cover", json!({ "path": "decks/board.deckcraft" })); + let path = cover["path"].as_str().unwrap().to_string(); + assert!(path.starts_with(".cache/covers/") && path.ends_with("@320.png"), "{cover}"); + assert_eq!((cover["width"].clone(), cover["height"].clone()), (json!(320), json!(180))); + assert_eq!(docs::open_count(), 0, "no open document"); + let before = engine_reads(); + assert_eq!(w.ok("cover", json!({ "path": "decks/board.deckcraft" })), cover, "kept: the deck was not read again"); + assert_eq!(engine_reads(), before); + // Opened, edited, saved: a new version, a new cover; the old one goes. + let doc = w.open("decks/board.deckcraft"); + w.ok("design", json!({ "doc": doc, "theme": "Nocturne" })); + std::thread::sleep(Duration::from_millis(20)); + w.ok("save", json!({ "doc": doc })); + w.ok("close", json!({ "doc": doc })); + let after = w.ok("cover", json!({ "path": "decks/board.deckcraft" })); + assert_ne!(after["path"], cover["path"]); + assert!(!w.root.join(&path).exists(), "the older cover went"); + assert!(w.root.join(after["path"].as_str().unwrap()).exists(), "kept across the close"); + for bad in [json!({ "path": "decks/board.deckcraft", "width": 47 }), json!({ "path": "decks/board.deckcraft", "page": 2 }), json!({})] { + coded(&w.err("cover", bad), "invalid"); + } +} + +/// The whole cache, covers too, is cleared before a write would fail with +/// `storage_full:`; with nothing left to clear, the write fails so. +#[test] +fn the_cache_is_cleared_before_storage_full() { + let roomy = World::new(); + roomy.put("board.deckcraft", &native(&sample(3))); + roomy.ok("cover", json!({ "path": "board.deckcraft", "width": 960 })); + let doc = roomy.open("board.deckcraft"); + let s = roomy.slide_ids(&doc)[0]; + roomy.ok("view", json!({ "doc": doc, "slide": s, "width": 1920 })); + let cached: u64 = roomy.files(".cache").iter().map(|f| std::fs::metadata(roomy.root.join(f)).unwrap().len()).sum(); + assert!(cached > 0); + let outline_len = 120; + let tight = World { _dir: tempfile::tempdir().unwrap(), root: roomy.root.clone(), areas: roomy.tight(outline_len) }; + let out = tight.ok("export", json!({ "doc": doc, "kind": "pptx", "out": "board.pptx" })); + let len = out["bytes"].as_u64().unwrap(); + assert!(len > outline_len && len <= outline_len + cached, "the export needed the cache's room: {len} bytes"); + assert!(tight.files(".cache").is_empty(), "the cache was cleared: {:?}", tight.files(".cache")); + let e = tight.err("export", json!({ "doc": doc, "kind": "pptx", "out": "again.pptx" })); + coded(&e, "storage_full"); + coded(&tight.err("save", json!({ "doc": doc, "path": "copy.deckcraft" })), "storage_full"); + coded(&tight.err("view", json!({ "doc": doc, "slide": s, "width": 1920 })), "storage_full"); + assert!(!tight.root.join("again.pptx").exists()); +} + +// ------------------------------------------------------------ the arguments every method takes + +#[test] +fn every_v2_method_refuses_arguments_it_does_not_take() { + let w = World::new(); + let doc = w.sample_open("board.deckcraft", 3); + let s = w.slide_ids(&doc)[1]; + for (method, args) in [ + ("open", json!({ "path": "board.deckcraft", "password": "x" })), + ("create", json!({ "out": "n.deckcraft", "template": "x" })), + ("close", json!({ "doc": doc, "force": true })), + ("state", json!({ "doc": doc, "x": 1 })), + ("save", json!({ "doc": doc, "format": "pptx" })), + ("slides", json!({ "doc": doc, "all": true })), + ("slide", json!({ "doc": doc, "slide": s, "index": 1 })), + ("hit", json!({ "doc": doc, "slide": s, "at": [1, 1], "mods": "shift" })), + ("view", json!({ "doc": doc, "slide": s, "width": 96, "out": "x.png" })), + ("layouts", json!({ "doc": doc, "master": 1 })), + ("themes", json!({ "x": 1 })), + ("fonts", json!({ "scan": true })), + ("shapes", json!({ "filter": "x" })), + ("text", json!({ "doc": doc, "level": 1 })), + ("format_state", json!({ "doc": doc, "slide": s, "id": 1, "run": 0 })), + ("cover", json!({ "path": "board.deckcraft", "slide": 2 })), + ("slides_op", json!({ "doc": doc, "op": "new", "data": "x" })), + ("notes", json!({ "doc": doc, "slide": s, "text": "x", "format": "html" })), + ("transition", json!({ "doc": doc, "all": true, "kind": "fade", "sound": "x.wav" })), + ("insert", json!({ "doc": doc, "slide": s, "kind": "text", "data": "aGk=" })), + ("set_text", json!({ "doc": doc, "slide": s, "id": 1, "text": "x", "at": [0, 0] })), + ("format", json!({ "doc": doc, "slide": s, "ids": [1], "bold": true, "outline": 3 })), + ("shape", json!({ "doc": doc, "slide": s, "ids": [1], "op": "move", "dx": 1, "x": 3 })), + ("picture", json!({ "doc": doc, "slide": s, "id": 1, "op": "reset", "data": "x" })), + ("table", json!({ "doc": doc, "slide": s, "id": 1, "op": "style", "style": "none", "rows": 3 })), + ("chart", json!({ "doc": doc, "slide": s, "id": 1, "title": "x", "raw": "" })), + ("design", json!({ "doc": doc, "theme": "Slate", "picture": "x" })), + ("undo", json!({ "doc": doc, "steps": 2 })), + ("redo", json!({ "doc": doc, "steps": 2 })), + ("show", json!({ "doc": doc, "presenter": true })), + ("export", json!({ "doc": doc, "kind": "pdf", "out": "x.pdf", "password": "x" })), + ] { + let e = w.err(method, args.clone()); + coded(&e, "invalid"); + assert!(e.contains("is not one of them"), "{method} {args}: {e}"); + } + assert_eq!(w.ok("state", json!({ "doc": doc }))["rev"], 0, "nothing reached the deck"); +} + +/// What a typical call costs, printed and checked: engine work runs on the +/// shell's UI thread (#399), so SERVICE.md asks a 30-slide deck to open and +/// draw its first slide in under a second, and a typical edit to answer in +/// well under 100 ms. Measure in release: +/// +/// cargo test --release --locked -p octosense-deck-service --lib -- --ignored --nocapture timings +#[test] +#[ignore = "prints timings; run by hand, in release"] +fn timings_on_a_thirty_slide_deck() { + let w = World::new(); + w.put("images/dot.png", &png()); + w.put("board.deckcraft", &native(&sample(30))); + // The font database's first catalog is the process's, not the call's. + deckcraft_fonts::FontDb::global().families(); + let mut report = Vec::new(); + let mut time = |label: &str, method: &str, args: Json| -> (Json, f64) { + let started = Instant::now(); + let out = w.ok(method, args); + let ms = started.elapsed().as_secs_f64() * 1000.0; + report.push(format!("{label:<40} {ms:>8.1} ms")); + (out, ms) + }; + let started = Instant::now(); + let (open, open_ms) = time("open (30 slides)", "open", json!({ "path": "board.deckcraft" })); + let doc = open["doc"].as_str().unwrap().to_string(); + let ids: Vec = w.slide_ids(&doc); + let (_, first_ms) = time("its first slide at 1280 px", "view", json!({ "doc": doc, "slide": ids[0], "width": 1280 })); + let open_and_first = started.elapsed().as_secs_f64() * 1000.0; + let body = w.shape_where(&doc, ids[1], "text", "First point\nSecond point"); + let mut edits = Vec::new(); + edits.push(time("set_text (one word)", "set_text", json!({ "doc": doc, "slide": ids[1], "id": body, "text": "First point\nSecond item" })).1); + edits.push(time("format (bold, size, colour)", "format", json!({ "doc": doc, "slide": ids[1], "ids": [body], "bold": true, "size": 24, "color": "#2E6FD8" })).1); + edits.push(time("slide (inspect)", "slide", json!({ "doc": doc, "slide": ids[1] })).1); + edits.push(time("slides (inspect)", "slides", json!({ "doc": doc })).1); + edits.push(time("shape move", "shape", json!({ "doc": doc, "slide": ids[1], "ids": [body], "op": "move", "dx": 4, "dy": 4 })).1); + edits.push(time("notes", "notes", json!({ "doc": doc, "slide": ids[1], "text": "Speak slowly" })).1); + edits.push(time("hit", "hit", json!({ "doc": doc, "slide": ids[1], "at": [200, 200] })).1); + edits.push(time("undo", "undo", json!({ "doc": doc })).1); + edits.push(time("redo", "redo", json!({ "doc": doc })).1); + time("view after an edit, 1280 px", "view", json!({ "doc": doc, "slide": ids[1], "width": 1280 })); + time("thumbnail, 240 px", "view", json!({ "doc": doc, "slide": ids[2], "width": 240 })); + time("insert picture", "insert", json!({ "doc": doc, "slide": ids[2], "kind": "picture", "path": "images/dot.png" })); + time("insert table 4 × 4 with cells", "insert", json!({ "doc": doc, "slide": ids[3], "kind": "table", "rows": 4, "cols": 4, "cells": [["a", "b"], ["c", "d"]] })); + time("design theme", "design", json!({ "doc": doc, "theme": "Harbor" })); + time("layouts at 240 px", "layouts", json!({ "doc": doc })); + time("save", "save", json!({ "doc": doc })); + time("export pptx", "export", json!({ "doc": doc, "kind": "pptx", "out": "board.pptx" })); + time("export pdf at 72 dpi", "export", json!({ "doc": doc, "kind": "pdf", "out": "board.pdf", "dpi": 72 })); + time("cover", "cover", json!({ "path": "board.deckcraft" })); + time("close", "close", json!({ "doc": doc })); + report.push(format!("{:<40} {open_and_first:>8.1} ms", "open and first slide, together")); + eprintln!("deck service timings:\n{}", report.join("\n")); + assert!(open_and_first < 1000.0, "open {open_ms:.1} ms + first slide {first_ms:.1} ms"); + let slowest = edits.iter().cloned().fold(0.0, f64::max); + assert!(slowest < 100.0, "a typical edit took {slowest:.1} ms"); +} diff --git a/apps/deck/host-service/src/docs.rs b/apps/deck/host-service/src/docs.rs new file mode 100644 index 00000000..2137531e --- /dev/null +++ b/apps/deck/host-service/src/docs.rs @@ -0,0 +1,636 @@ +//! Open documents (SERVICE.md "Open documents"): `deck.open` and +//! `deck.create` keep the engine's session for a deck, with its edits, undo +//! history, current slide and selection, between calls. +//! +//! **Keys.** A document belongs to the caller app, its storage scope (the +//! area's root, as the file system spells it) and its handle. A call from +//! another app or another scope finds no document under that handle and is +//! answered `unknown_doc:`, exactly as for a handle that never existed. A +//! caller has at most [`MAX_OPEN`] documents open, and the service +//! [`MAX_OPEN_ALL`] across callers (`too_many_open:`). `deck.cover` opens +//! no document. +//! +//! **One engine session per document**, made by [`app_session`]: the +//! engine's AutoCorrect and smart quotes off (text is stored as typed), and +//! its own undo limit lifted, because the service folds each call's undo +//! steps into one and keeps at most [`MAX_UNDO`] ([`crate::gate`]). +//! Closing a document drops its session. +//! +//! **The thread.** Every call reaches this service on the shell's UI +//! thread: App Hub's `services::pump` dispatches an app's `host.request` +//! inline from the card runner's event handler, and the agent relay and +//! components' host calls are pumped from the same place. So the table is a +//! `thread_local`, and the UI thread takes no lock for it (makepad's rule: +//! the UI thread never takes a lock another thread can hold). A call on any +//! other thread finds an empty table of its own: its handles are unknown +//! there, which fails closed and shares nothing. +//! +//! **Release**, as the pdf service's (`apps/pdf/host-service/src/docs.rs`): +//! a document goes on `deck.close`; when the isolate that opened it closes +//! (App Hub's `services::on_isolate_closed` listener, [`isolate_closed`], +//! called from `services::cancel_heap` before the isolate's heap key, an +//! address, can be reused); when its storage scope changes or goes (makepad's +//! `splash_storage::storage_for_heap` for that key, checked by every call's +//! [`sweep`]); and, for a caller with no isolate (a component's host call, a +//! test), after [`IDLE`] without a call. Its renders go with it, and any a +//! released document left behind go when the caller next opens a document +//! ([`cache::prune`]). + +use std::cell::{Cell, RefCell}; +use std::collections::HashMap; +use std::panic::{catch_unwind, AssertUnwindSafe}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, OnceLock}; +use std::time::{Duration, Instant}; + +use deckcraft_engine::cmd::file as engine_file; +use deckcraft_engine::{DocState, Session}; +use deckcraft_model::Presentation; +use octosense_appstore::makepad_widgets::splash_storage::{self, StorageAccess}; +use octosense_engine_area::Area; +use serde_json::{json, Value as Json}; + +use crate::args; +use crate::cache; +use crate::codes::{fail, invalid, too_large, Code}; +use crate::gate::{self, HeldCache, Plan}; +use crate::guard::Format; +use crate::Ctx; + +/// The most documents one caller (app and storage scope) keeps open. +pub(crate) const MAX_OPEN: usize = 8; +/// The most documents open across callers. +pub(crate) const MAX_OPEN_ALL: usize = 32; +/// How long a document no isolate holds stays open without a call. +pub(crate) const IDLE: Duration = Duration::from_secs(15 * 60); +/// The most undo steps a document keeps (SERVICE.md "One undo step per +/// call"; the engine's own default is 500 snapshots of shared slides). +pub(crate) const MAX_UNDO: usize = 100; +/// `deck.create`'s most slides (the door's `OUTLINE_SLIDES` for an outline). +pub(crate) const MAX_CREATE_SLIDES: usize = 200; +/// A new slide's longest title, and its longest body or notes. +pub(crate) const MAX_TITLE: usize = 2_000; +pub(crate) const MAX_BODY: usize = 20_000; + +/// What keeps a document open besides `deck.close`. +pub(crate) enum Binding { + /// Opened from an app's isolate: open while that isolate's storage + /// scope is. + Isolate { heap: usize, scope: StorageAccess }, + /// Opened by a caller with no isolate: open until [`IDLE`] passes + /// without a call. + Unbound, +} + +/// A render in the cache that shows this document: a slide, a slide at a +/// build step, or a layout picture. +pub(crate) struct Rendered { + /// The storage-relative file. + pub rel: String, + /// The revision of what it shows ([`OpenDoc::slide_rev`] for a slide, + /// [`OpenDoc::deck_rev`] for a layout). + pub rev: u64, + pub width: u64, + pub height: u64, + pub bytes: u64, +} + +/// One open document. +pub(crate) struct OpenDoc { + pub app: String, + pub owner: PathBuf, + pub handle: String, + pub session: Session, + /// The deck's own native file, relative to the area: what `deck.save` + /// without a path writes. `None` for a deck opened from a `.pptx` or an + /// outline, whose first save names a path. + pub file: Option, + pub binding: Binding, + pub last_used: Instant, + /// Bumped by every change, undo and redo. + pub rev: u64, + /// The revision at which each slide last changed (absent: as opened). + pub slide_revs: HashMap, + /// The revision of the last change to the whole deck (its theme, slide + /// size, footer), which the layout pictures show. + pub deck_rev: u64, + /// Renders in the cache, by what they show (slide, mode and width). + pub renders: HashMap, + /// What the session holds, as the door's ceilings count it, kept per + /// slide between calls ([`gate`]). + pub held: HeldCache, +} + +impl OpenDoc { + /// The engine's document: a session here always holds exactly one. + pub fn st(&self) -> Result<&DocState, String> { + self.session.doc().map_err(|_| fail(Code::Damaged, "the engine no longer holds this document")) + } + + pub fn st_mut(&mut self) -> Result<&mut DocState, String> { + self.session.doc_mut().map_err(|_| fail(Code::Damaged, "the engine no longer holds this document")) + } + + pub fn deck(&self) -> Result, String> { + Ok(self.st()?.doc.clone()) + } + + /// Slide `id`'s position, or `unknown_id:`. + pub fn slide_index(&self, id: u32) -> Result { + let st = self.st()?; + st.doc.slide_index(deckcraft_model::SlideId(id)).ok_or_else(|| unknown_slide(id)) + } + + /// The revision a render of slide `id` shows when it is current. + pub fn slide_rev(&self, id: u32) -> u64 { + self.slide_revs.get(&id).copied().unwrap_or(0) + } + + /// `{edited, can_undo, can_redo}`. + pub fn history(&self) -> Result { + let st = self.st()?; + Ok(json!({ "edited": st.is_dirty(), "can_undo": !st.history.undo.is_empty(), "can_redo": !st.history.redo.is_empty() })) + } + + /// SERVICE.md's common answer of a change: `{rev, changed, edited, + /// can_undo, can_redo}`. + pub fn common(&self, changed: &[u32]) -> Result { + let mut out = self.history()?; + out["rev"] = json!(self.rev); + out["changed"] = json!(changed); + Ok(out) + } + + /// The common answer with `extra`'s fields added. + pub fn answer(&self, changed: &[u32], extra: Json) -> Result { + let mut out = self.common(changed)?; + if let Json::Object(map) = extra { + for (k, v) in map { + out[k] = v; + } + } + Ok(out) + } + + fn alive(&self, now: Instant) -> bool { + match &self.binding { + Binding::Isolate { heap, scope } => splash_storage::storage_for_heap(*heap, &self.app).is_some_and(|now| now.same_scope(scope)), + Binding::Unbound => now.saturating_duration_since(self.last_used) < IDLE, + } + } + + fn bound_to(&self, closed: &[usize]) -> bool { + matches!(&self.binding, Binding::Isolate { heap, .. } if closed.contains(heap)) + } + + /// The document goes: its engine session, and its renders in the + /// service's cache. + fn release(self) { + cache::forget(&Area::new(self.owner.clone(), None, false), &self.handle); + } +} + +pub(crate) fn unknown_slide(id: u32) -> String { + fail(Code::UnknownId, format!("the deck has no slide {id} (an undo may have removed it)")) +} + +/// A session for an app's document (module doc): text is stored as typed. +pub(crate) fn app_session() -> Session { + let mut s = Session::new(); + s.prefs.autocorrect = false; + s.prefs.smart_quotes = false; + s +} + +#[derive(Default)] +struct Docs { + open: Vec, +} + +impl Docs { + fn find(&self, app: &str, owner: &Path, handle: &str) -> Option { + self.open.iter().position(|d| d.app == app && d.owner == owner && d.handle == handle) + } +} + +thread_local! { + /// The open documents of this thread: the UI thread's (module doc). + static DOCS: RefCell = RefCell::new(Docs::default()); + /// Heap keys of isolates that closed while a call held [`DOCS`]: the + /// next [`sweep`] releases their documents. A `Cell`, which a listener + /// can always fill. + static CLOSED: Cell> = const { Cell::new(Vec::new()) }; +} + +/// App Hub's `services::on_isolate_closed` listener ([`crate::register`]): +/// the isolate whose heap key is `heap` closed, so every document bound to +/// it goes now, with its renders, before a new isolate can get the key. If a +/// call holds the table (a listener reached from inside one), the key waits +/// for the next [`sweep`]. +pub(crate) fn isolate_closed(heap: usize) { + let released = DOCS.with(|docs| { + let mut docs = docs.try_borrow_mut().ok()?; + let (gone, kept): (Vec, Vec) = std::mem::take(&mut docs.open).into_iter().partition(|doc| doc.bound_to(&[heap])); + docs.open = kept; + Some(gone) + }); + match released { + // The engine sessions drop and the renders go outside the table. + Some(gone) => gone.into_iter().for_each(OpenDoc::release), + None => CLOSED.with(|closed| { + let mut keys = closed.take(); + keys.push(heap); + closed.set(keys); + }), + } +} + +/// Whose documents a call reaches: its area's root as the file system +/// spells it (two spellings of one folder are one scope). +pub(crate) fn owner(area: &Area) -> PathBuf { + area.root.canonicalize().unwrap_or_else(|_| area.root.clone()) +} + +/// A new handle: lowercase hex, unique in this process, and unlike any of +/// an earlier run (it names the document's render folder, which may still +/// be there). +fn mint() -> String { + static SEED: OnceLock = OnceLock::new(); + static NEXT: AtomicU64 = AtomicU64::new(1); + let seed = *SEED.get_or_init(|| { + let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0); + (nanos as u32) ^ (nanos >> 32) as u32 ^ std::process::id().rotate_left(16) + }); + format!("{seed:08x}{:x}", NEXT.fetch_add(1, Ordering::Relaxed)) +} + +/// The handle a call names: what `deck.open` or `deck.create` returned. +/// Anything else names no document. +fn handle(args: &Json, m: &str) -> Result { + match args.get("doc") { + None | Some(Json::Null) => Err(invalid(format!("{m} needs `doc`, the handle deck.open returned"))), + Some(Json::String(h)) if !h.is_empty() && h.len() <= 40 && h.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) => Ok(h.clone()), + Some(_) => Err(unknown()), + } +} + +fn unknown() -> String { + fail(Code::UnknownDoc, "no deck of this app is open under that handle: open it again") +} + +/// Release the documents of isolates that closed while a call held the +/// table, and those whose caller's scope went away (module doc). +pub(crate) fn sweep() { + sweep_at(Instant::now()); +} + +pub(crate) fn sweep_at(now: Instant) { + let closed = CLOSED.with(Cell::take); + let gone: Vec = DOCS.with(|docs| { + let mut docs = docs.borrow_mut(); + let (gone, kept): (Vec, Vec) = std::mem::take(&mut docs.open).into_iter().partition(|doc| doc.bound_to(&closed) || !doc.alive(now)); + docs.open = kept; + gone + }); + gone.into_iter().for_each(OpenDoc::release); +} + +/// How many documents are open on this thread (tests). +#[cfg(test)] +pub(crate) fn open_count() -> usize { + DOCS.with(|docs| docs.borrow().open.len()) +} + +/// Run `f` on the open document whose handle is `handle`, if there is one, +/// to look into the engine's copy of it (tests). +#[cfg(test)] +pub(crate) fn with_open(handle: &str, f: impl FnOnce(&OpenDoc) -> T) -> Option { + DOCS.with(|docs| docs.borrow().open.iter().find(|d| d.handle == handle).map(f)) +} + +/// Run `f` while a call holds the table, as a listener reached from inside +/// one would find it (tests). +#[cfg(test)] +pub(crate) fn while_busy(f: impl FnOnce()) { + DOCS.with(|docs| { + let _held = docs.borrow_mut(); + f(); + }); +} + +/// Run `f` on the document `args.doc` names, if this caller has it open. A +/// panic in the engine closes the document rather than leave it half +/// changed. +pub(crate) fn with_doc(args: &Json, cx: &Ctx, m: &str, f: impl FnOnce(&mut OpenDoc) -> Result) -> Result { + let handle = handle(args, m)?; + let owner = owner(cx.area); + let (result, dropped) = DOCS.with(|docs| { + let mut docs = docs.borrow_mut(); + let Some(i) = docs.find(cx.app, &owner, &handle) else { return (Err(unknown()), None) }; + docs.open[i].last_used = Instant::now(); + match catch_unwind(AssertUnwindSafe(|| f(&mut docs.open[i]))) { + Ok(result) => (result, None), + Err(_) => { + let doc = docs.open.remove(i); + (Err(fail(Code::Damaged, "the deck engine failed on this document, so it was closed: open it again")), Some(doc)) + } + } + }); + if let Some(doc) = dropped { + doc.release(); + } + result +} + +/// What keeps a document this call opens open (module doc). +fn bind(cx: &Ctx) -> Binding { + match splash_storage::storage_for_heap(cx.isolate, cx.app) { + Some(scope) => Binding::Isolate { heap: cx.isolate, scope }, + None => Binding::Unbound, + } +} + +/// Refuse a new document when the caller, or the service, holds the most. +fn room_for_one(cx: &Ctx, owner: &Path) -> Result<(), String> { + DOCS.with(|docs| { + let docs = docs.borrow(); + if docs.open.iter().filter(|d| d.app == cx.app && d.owner == owner).count() >= MAX_OPEN { + return Err(fail(Code::TooManyOpen, format!("{MAX_OPEN} decks are open already: close one first"))); + } + if docs.open.len() >= MAX_OPEN_ALL { + return Err(fail(Code::TooManyOpen, "the deck engine holds as many decks as it can: close one first")); + } + Ok(()) + }) +} + +/// Add `doc` to the table, and clear the renders of documents no longer +/// open in its storage. +fn admit(doc: OpenDoc, area: &Area) { + let (app, owner) = (doc.app.clone(), doc.owner.clone()); + let open: Vec = DOCS.with(|docs| { + let mut docs = docs.borrow_mut(); + docs.open.push(doc); + docs.open.iter().filter(|d| d.app == app && d.owner == owner).map(|d| d.handle.clone()).collect() + }); + cache::prune(area, &open); +} + +/// A fresh open document around `session` (which holds its deck). +fn new_doc(cx: &Ctx, owner: PathBuf, session: Session, file: Option) -> Result { + let mut doc = OpenDoc { + app: cx.app.to_string(), + owner, + handle: mint(), + session, + file, + binding: bind(cx), + last_used: Instant::now(), + rev: 0, + slide_revs: HashMap::new(), + deck_rev: 0, + renders: HashMap::new(), + held: HeldCache::default(), + }; + // The service keeps the undo steps itself (crate::gate). + doc.st_mut()?.history.limit = usize::MAX; + Ok(doc) +} + +/// `document.inspect {}`: `safe`, a read of the open document (the one +/// command `deck.open`, `deck.create`'s answer and `deck.state` run). +const INSPECT: usize = 1; + +/// `deck.open {path}` and `deck.create`'s answer: the deck as the app shows +/// it on opening. +fn opened(doc: &mut OpenDoc, cx: &Ctx, m: &'static str, path: &str) -> Result { + let inspected = gate::read(doc, cx.area, Plan { method: m, most: INSPECT, cmds: vec![("document.inspect", json!({}))] })?.remove(0); + let deck = doc.deck()?; + let stem = Path::new(path).file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + let title = Some(deck.props.title.trim()).filter(|t| !t.is_empty()).map(str::to_owned).unwrap_or(stem); + let layouts: Vec = deck + .masters + .first() + .map(|m| m.layouts.iter().map(|l| json!({ "id": l.id.0, "name": l.name, "kind": l.kind.xml() })).collect()) + .unwrap_or_default(); + let mut out = doc.common(&[])?; + for (k, v) in [ + ("doc", json!(doc.handle)), + ("path", json!(path)), + ("title", json!(title)), + ("size", inspected["slideSize"].clone()), + ("slides", json!(deck.slides.len())), + ("theme", inspected["theme"].clone()), + ("layouts", json!(layouts)), + ("sections", sections(&deck)), + ("missing_fonts", json!(missing_fonts(&deck))), + ] { + out[k] = v; + } + if let Some(map) = out.as_object_mut() { + map.remove("changed"); + } + Ok(out) +} + +/// The deck's sections: each one's name, the position of its first slide +/// and how many slides it holds. +pub(crate) fn sections(deck: &Presentation) -> Json { + let mut at = 0usize; + let list: Vec = deck + .sections + .iter() + .map(|s| { + let first = s.slides.iter().filter_map(|id| deck.slide_index(*id)).min().unwrap_or(at); + let count = s.slides.iter().filter(|id| deck.slide_index(**id).is_some()).count(); + at = first + count; + json!({ "name": s.name, "first": first, "count": count }) + }) + .collect(); + json!(list) +} + +/// The font families the deck names (its themes' and its text runs') that +/// the engine draws with a substitute: they are not installed and not +/// built in. +pub(crate) fn missing_fonts(deck: &Presentation) -> Vec { + let mut named: Vec = Vec::new(); + let mut add = |f: &str| { + let f = f.trim(); + if !f.is_empty() && !f.starts_with('+') && !named.iter().any(|n| n.eq_ignore_ascii_case(f)) { + named.push(f.to_string()); + } + }; + for m in &deck.masters { + add(&m.theme.fonts.major.latin); + add(&m.theme.fonts.minor.latin); + } + let mut runs = |shapes: &[deckcraft_model::Shape]| { + deckcraft_model::walk(shapes, &mut |sh, _| { + for p in sh.text.iter().flat_map(|t| &t.paragraphs) { + for r in &p.runs { + if let Some(f) = &r.props.font { + add(f); + } + } + } + }); + }; + for slide in &deck.slides { + runs(&slide.shapes); + } + let fonts = deckcraft_fonts::FontDb::global(); + named.retain(|f| !fonts.has_family(f)); + named.sort_by_key(|f| f.to_lowercase()); + named +} + +/// `deck.open {path}`. +pub(crate) fn open(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.open"; + args::only(a, &["path"], M)?; + let path = args::need_str(a, "path", M)?; + let owner = owner(cx.area); + room_for_one(cx, &owner)?; + let (rel, deck, format) = crate::read_guarded(cx.area, path, "path", M)?; + let mut session = app_session(); + engine_file::add_opened(&mut session, &rel, None, deck); + if let Some(why) = crate::Held::of(&session).over() { + return Err(too_large(format!("the deck holds {why}, more than the deck service opens"))); + } + let file = (format == Format::Native).then(|| rel.clone()); + let mut doc = new_doc(cx, owner, session, file)?; + let answer = opened(&mut doc, cx, M, &rel)?; + admit(doc, cx.area); + Ok(answer) +} + +/// `deck.create`'s most commands: the `file.new`, then a `slide.new` and its +/// `slide.notes` for each of at most [`MAX_CREATE_SLIDES`] slides. +const CREATE: usize = 1 + 2 * MAX_CREATE_SLIDES; + +/// The engine's name of a theme a call names (its own spelling, any case). +pub(crate) fn theme_name(name: &str, m: &str) -> Result { + let themes = deckcraft_model::theme::builtin_themes(); + themes + .iter() + .find(|t| t.name.eq_ignore_ascii_case(name) || (name.eq_ignore_ascii_case("default") && t.name == "DeckCraft")) + .map(|t| t.name.clone()) + .ok_or_else(|| invalid(format!("{m}: `theme` is one of {}", themes.iter().map(|t| t.name.as_str()).collect::>().join(", ")))) +} + +/// A layout a call names: an id from `deck.open`, or a name or kind the +/// engine resolves (`slide.new`, `slide.layout`). +pub(crate) fn layout_value(v: &Json, m: &str) -> Result { + match v { + Json::String(s) if !s.is_empty() && s.chars().count() <= 128 => Ok(json!(s)), + v => match args::whole(v).and_then(|n| u32::try_from(n).ok()) { + Some(id) => Ok(json!(id)), + None => Err(invalid(format!("{m}: `layout` is a layout id from deck.open, or a layout name"))), + }, + } +} + +/// `deck.create {out, theme?, size?, slides?, outline?}`. +pub(crate) fn create(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.create"; + args::only(a, &["out", "theme", "size", "slides", "outline"], M)?; + let out = args::need_str(a, "out", M)?; + if !out.to_ascii_lowercase().ends_with(".deckcraft") { + return Err(invalid(format!("{M}: `out` is a new .deckcraft file"))); + } + // A new file in storage, never over one, foreground or not. + let dest = crate::new_file(cx.area, out, "out", M)?; + let theme = match args::opt_str(a, "theme", M, 64)? { + Some(t) => theme_name(t, M)?, + None => "DeckCraft".into(), + }; + let [w, h] = args::opt_size(a, "size", M)?.unwrap_or([960.0, 540.0]); + let mut cmds: Vec<(&'static str, Json)> = vec![("file.new", json!({ "theme": theme, "size": [w, h], "blank": true }))]; + match (args::present(a, "slides"), args::opt_str(a, "outline", M, 200_000)?) { + (Some(_), Some(_)) => return Err(invalid(format!("{M} takes `slides` or `outline`, not both"))), + (Some(slides), None) => { + let slides = slides.as_array().ok_or_else(|| invalid(format!("{M}: `slides` is a list of {{layout?, title?, body?, notes?}}")))?; + if slides.is_empty() { + return Err(invalid(format!("{M}: `slides` names at least one slide"))); + } + if slides.len() > MAX_CREATE_SLIDES { + return Err(too_large(format!("{M}: `slides` is at most {MAX_CREATE_SLIDES} slides"))); + } + for (i, slide) in slides.iter().enumerate() { + let sm = "deck.create slides"; + args::only(slide, &["layout", "title", "body", "notes"], sm)?; + let mut params = json!({ "at": i }); + if let Some(layout) = args::present(slide, "layout") { + params["layout"] = layout_value(layout, sm)?; + } + if let Some(title) = args::opt_str(slide, "title", sm, MAX_TITLE)? { + params["title"] = json!(title); + } + if let Some(body) = args::opt_str(slide, "body", sm, MAX_BODY)? { + params["body"] = json!(body); + } + cmds.push(("slide.new", params)); + if let Some(notes) = args::opt_str(slide, "notes", sm, MAX_BODY)? { + cmds.push(("slide.notes", json!({ "text": notes, "index": i }))); + } + } + } + (None, Some(outline)) => cmds.push(("slide.fromOutline", json!({ "text": outline }))), + (None, None) => cmds.push(("slide.new", json!({ "at": 0, "layout": "title" }))), + } + let owner = owner(cx.area); + room_for_one(cx, &owner)?; + let mut session = app_session(); + gate::exec(&mut session, cx.area, &Plan { method: M, most: CREATE, cmds }, &mut HeldCache::default())?; + let st = session.doc_mut().map_err(|_| fail(Code::Damaged, "the engine made no deck"))?; + if st.doc.slides.is_empty() { + return Err(invalid(format!("{M}: the outline has no slide (a slide is an unindented line)"))); + } + // Written there as made: the deck's own file from now on. + let rel = crate::normal(out); + crate::writing::write_new(cx.area, &dest, &engine_file::save_bytes(&st.doc, "deckcraft").map_err(|e| fail(Code::Damaged, format!("{M}: {e}")))?, M)?; + st.saved_doc = st.doc.clone(); + st.history.undo.clear(); + st.history.redo.clear(); + let mut doc = new_doc(cx, owner, session, Some(rel.clone()))?; + let answer = opened(&mut doc, cx, M, &rel)?; + admit(doc, cx.area); + Ok(answer) +} + +/// `deck.close {doc, discard?}`. +pub(crate) fn close(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.close"; + args::only(a, &["doc", "discard"], M)?; + let discard = args::opt_bool(a, "discard", M)?.unwrap_or(false); + let handle = handle(a, M)?; + let owner = owner(cx.area); + let doc = DOCS.with(|docs| { + let mut docs = docs.borrow_mut(); + let i = docs.find(cx.app, &owner, &handle).ok_or_else(unknown)?; + let edited = docs.open[i].st().map(DocState::is_dirty).unwrap_or(false); + if edited && !discard { + return Err(fail(Code::Unsaved, "the deck has changes that are not saved: save it first, or close it with discard: true")); + } + Ok(docs.open.remove(i)) + })?; + doc.release(); + Ok(json!({ "closed": true })) +} + +/// `deck.state {doc}`. +pub(crate) fn state(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.state"; + args::only(a, &["doc"], M)?; + with_doc(a, cx, M, |doc| { + let inspected = gate::read(doc, cx.area, Plan { method: M, most: INSPECT, cmds: vec![("document.inspect", json!({}))] })?.remove(0); + let mut out = doc.history()?; + out["rev"] = json!(doc.rev); + out["undo"] = inspected["undo"].clone(); + out["redo"] = inspected["redo"].clone(); + out["slides"] = json!(inspected["slides"].as_array().map_or(0, Vec::len)); + Ok(out) + }) +} diff --git a/apps/deck/host-service/src/gate.rs b/apps/deck/host-service/src/gate.rs new file mode 100644 index 00000000..b039f1f9 --- /dev/null +++ b/apps/deck/host-service/src/gate.rs @@ -0,0 +1,332 @@ +//! One gate for the app's methods (SERVICE.md "One gate", decisions 2 and +//! 3): how a method's engine commands reach an open document. +//! +//! - **Admitted as a whole, before any runs.** A method builds every engine +//! command and its parameters itself, from reviewed values ([`Plan`]), +//! and the same door as `deck.run`'s (`crate::door`, from +//! `skill/safety.json` and `REVIEWED`) admits the whole list with +//! `Door::admit_method`: the allowlist, the held commands (#448), the +//! reviewed reads inside the area and every `Limit`. `deck.run`'s 64 +//! commands a call are `deck.run`'s; each method has its own most +//! commands a call ([`Plan::most`]), with its reason beside its constant. +//! A refused command refuses the call with nothing run. +//! - **The ceilings after every command.** The door's `RunBudget` ceilings +//! on the session (slides, shapes, cells, chart points, characters, +//! media, geometry, and what one call's edits copy) hold after each +//! command; the session's counts are kept per slide between calls +//! ([`HeldCache`]), so a check costs what changed, not the whole deck. +//! - **All or nothing.** A command the engine refuses, or one that crosses +//! a ceiling, puts the document back as it was before the call: its deck, +//! selection and undo and redo history. +//! - **One undo step per call** (decision 3): the engine makes each command +//! its own undo step; after a change method runs, the steps it pushed are +//! folded into one through the engine's public `History`, labelled by the +//! action, and the history keeps at most [`docs::MAX_UNDO`] steps. +//! - **`rev` and `changed`.** A change, an undo and a redo bump the +//! document's `rev`; `changed` lists the slides whose content changed, +//! found by comparing the engine's copy-on-write slide pointers: a new or +//! replaced slide, every slide after a deck-wide change (a master or its +//! theme, the slide size, the header and footer), and a slide whose +//! position changed when it shows its number. +//! - **A read never changes the document**: whatever a read's commands +//! changed goes back. + +use std::collections::HashMap; +use std::sync::Arc; + +use deckcraft_engine::{HistoryEntry, Selection, Session}; +use deckcraft_model::text::RunKind; +use deckcraft_model::{Master, Presentation, Shape, Slide}; +use octosense_engine_area::Area; +use serde_json::{json, Value as Json}; + +use crate::codes::{self, fail, invalid, too_large, Code}; +use crate::docs::{self, OpenDoc}; +use crate::{Held, RunBudget}; + +/// One method's engine work. +pub(crate) struct Plan { + /// The method, as refusals name it. + pub method: &'static str, + /// The most commands this method runs in one call (its reason is beside + /// the method's constant). + pub most: usize, + /// The commands, in order, with the parameters the service built. + pub cmds: Vec<(&'static str, Json)>, +} + +/// What the session holds, as the door's ceilings count it ([`Held`]), kept +/// per slide and per master between calls: a slide the engine did not +/// replace (copy-on-write) is counted once. Each entry holds its slide, so +/// its address cannot be reused while it is kept. +#[derive(Default)] +pub(crate) struct HeldCache { + slides: HashMap, Held)>, + masters: HashMap, Held)>, +} + +impl Held { + /// What one slide holds, as [`Held::of`] counts it. + fn of_slide(slide: &Slide) -> Held { + let mut held = Held::default(); + held.add_shapes(&slide.shapes); + held.most_on_a_slide = held.shapes; + held.add_text(&slide.notes); + held + } + + fn of_master(master: &Master) -> Held { + let mut held = Held::default(); + held.add_master(master); + held + } + + /// `part` added to `self`, in [`Held::of`]'s order (its first problem + /// stays the first). + fn absorb(&mut self, part: &Held) { + self.shapes += part.shapes; + self.most_on_a_slide = self.most_on_a_slide.max(part.most_on_a_slide); + self.cells += part.cells; + self.points += part.points; + self.chars += part.chars; + if self.problem.is_none() { + self.problem.clone_from(&part.problem); + } + } +} + +impl HeldCache { + /// What `s` holds now: [`Held::of`], counting only what changed since + /// the last call. + pub(crate) fn held(&mut self, s: &Session) -> Held { + let mut total = Held::default(); + let mut slides = HashMap::new(); + let mut masters = HashMap::new(); + for st in s.documents() { + let doc = &st.doc; + total.slides += doc.slides.len(); + total.media += doc.media.iter().map(|m| m.data.len() as u64).sum::(); + for slide in &doc.slides { + let key = Arc::as_ptr(slide) as usize; + let entry = self.slides.remove(&key).or_else(|| slides.remove(&key)).unwrap_or_else(|| (slide.clone(), Held::of_slide(slide))); + total.absorb(&entry.1); + slides.insert(key, entry); + } + for master in doc.masters.iter().chain(&doc.notes_master).chain(&doc.handout_master) { + let key = Arc::as_ptr(master) as usize; + let entry = self.masters.remove(&key).or_else(|| masters.remove(&key)).unwrap_or_else(|| (master.clone(), Held::of_master(master))); + total.absorb(&entry.1); + masters.insert(key, entry); + } + } + self.slides = slides; + self.masters = masters; + total + } +} + +/// Admit `plan` as a whole, then run it on `session` in order, with the +/// ceilings checked after every command (module doc). The results, in +/// order. +pub(crate) fn exec(session: &mut Session, area: &Area, plan: &Plan, held: &mut HeldCache) -> Result, String> { + let door = crate::door().map_err(|e| fail(Code::Damaged, e))?; + let admitted = door.admit_method(plan.method, &plan.cmds, plan.most, area, crate::UNREAD).map_err(|r| codes::refusal(&r))?; + let named = |e: String| too_large(e.replacen("deck.run", plan.method, 1)); + let now = held.held(session); + if let Some(why) = now.over() { + return Err(too_large(format!("{}: the deck holds {why}, so the service runs no commands on it", plan.method))); + } + let mut budget = RunBudget::fresh(session); + let mut results = Vec::with_capacity(admitted.len()); + for (id, params) in admitted { + budget.before(session, &id, ¶ms).map_err(named)?; + let result = session.execute(&id, ¶ms).map_err(|e| codes::engine(plan.method, &id, &e))?; + budget.after_held(held.held(session), session, &id, &result).map_err(named)?; + results.push(result); + } + Ok(results) +} + +/// The document as it was before a call. +struct Before { + doc: Arc, + selection: Selection, + undo: usize, + redo: Vec, +} + +impl Before { + fn take(doc: &OpenDoc) -> Result { + let st = doc.st()?; + Ok(Before { doc: st.doc.clone(), selection: st.selection.clone(), undo: st.history.undo.len(), redo: st.history.redo.clone() }) + } + + /// Put the document back as it was. + fn restore(self, doc: &mut OpenDoc) { + if let Ok(st) = doc.st_mut() { + st.doc = self.doc; + st.selection = self.selection; + st.history.undo.truncate(self.undo); + st.history.redo = self.redo; + st.interaction = None; + } + } +} + +/// What a change method did. +pub(crate) struct Change { + pub results: Vec, + /// The slides whose content changed. + pub changed: Vec, +} + +/// Run a change method's `plan` on `doc` (module doc): all or nothing, one +/// undo step labelled `label`. +pub(crate) fn change(doc: &mut OpenDoc, area: &Area, plan: Plan, label: &str) -> Result { + let before = Before::take(doc)?; + let results = match exec(&mut doc.session, area, &plan, &mut doc.held) { + Ok(results) => results, + Err(e) => { + before.restore(doc); + return Err(e); + } + }; + let st = doc.st_mut()?; + if Arc::ptr_eq(&st.doc, &before.doc) || same_content(&before.doc, &st.doc) { + // Nothing changed (the engine's copies aside): the deck as it was, no + // undo step, no new revision. + let selection = st.selection.clone(); + before.restore(doc); + doc.st_mut()?.selection = selection; + return Ok(Change { results, changed: Vec::new() }); + } + st.history.undo.truncate(before.undo); + st.history.undo.push(HistoryEntry { label: label.to_string(), doc: before.doc.clone(), selection: before.selection.clone() }); + st.history.redo.clear(); + let excess = st.history.undo.len().saturating_sub(docs::MAX_UNDO); + st.history.undo.drain(..excess); + let after = st.doc.clone(); + let changed = advance(doc, &before.doc, &after); + Ok(Change { results, changed }) +} + +/// Run a read method's `plan` on `doc`: the results, with the document as +/// it was. +pub(crate) fn read(doc: &mut OpenDoc, area: &Area, plan: Plan) -> Result, String> { + let before = Before::take(doc)?; + let results = exec(&mut doc.session, area, &plan, &mut doc.held); + let same = doc.st().is_ok_and(|st| Arc::ptr_eq(&st.doc, &before.doc)); + if !same || results.is_err() { + before.restore(doc); + } + results +} + +/// `edit.undo {}` or `edit.redo {}` (one folded step): the slides whose +/// content changed. +pub(crate) fn step(doc: &mut OpenDoc, area: &Area, redo: bool) -> Result, String> { + let (m, id) = if redo { ("deck.redo", "edit.redo") } else { ("deck.undo", "edit.undo") }; + { + let st = doc.st()?; + let empty = if redo { st.history.redo.is_empty() } else { st.history.undo.is_empty() }; + if empty { + return Err(invalid(format!("{m}: nothing to {}", if redo { "redo" } else { "undo" }))); + } + } + let before = Before::take(doc)?; + // `edit.undo` and `edit.redo` are `safe`: one command, the history's own. + if let Err(e) = exec(&mut doc.session, area, &Plan { method: m, most: 1, cmds: vec![(id, json!({}))] }, &mut doc.held) { + before.restore(doc); + return Err(e); + } + let after = doc.st()?.doc.clone(); + Ok(advance(doc, &before.doc, &after)) +} + +/// A new revision of `doc`, from `before` to `after`: the slides whose +/// content changed get it, and the deck does when the change was deck-wide. +fn advance(doc: &mut OpenDoc, before: &Presentation, after: &Presentation) -> Vec { + let (changed, deck_wide) = changed_slides(before, after); + doc.rev += 1; + for id in &changed { + doc.slide_revs.insert(*id, doc.rev); + } + if deck_wide { + doc.deck_rev = doc.rev; + } + changed +} + +/// The slides of `after` whose content changed since `before`, and whether +/// the change was deck-wide (module doc). A slide or master whose pointer +/// changed is compared by content too: the engine copies the current slide +/// on every edit (`Session::edit` reroutes its connectors through +/// `Arc::make_mut`), changed or not. +pub(crate) fn changed_slides(before: &Presentation, after: &Presentation) -> (Vec, bool) { + let same_master = |a: &Arc, b: &Arc| Arc::ptr_eq(a, b) || **a == **b; + let deck_wide = before.masters.len() != after.masters.len() + || before.masters.iter().zip(&after.masters).any(|(a, b)| !same_master(a, b)) + || before.slide_size != after.slide_size + || before.header_footer != after.header_footer + || before.first_slide_number != after.first_slide_number + || before.default_text_style != after.default_text_style; + if deck_wide { + return (after.slides.iter().map(|s| s.id.0).collect(), true); + } + let old: HashMap, usize)> = before.slides.iter().enumerate().map(|(i, s)| (s.id.0, (s, i))).collect(); + let numbered_by_masters = || after.masters.iter().any(|m| has_number(m.shapes.iter().filter(|s| s.ph.is_none())) || m.layouts.iter().any(|l| has_number(l.shapes.iter().filter(|s| s.ph.is_none())))); + let mut by_masters: Option = None; + let mut out = Vec::new(); + for (i, s) in after.slides.iter().enumerate() { + let id = s.id.0; + match old.get(&id) { + Some((prev, at)) if Arc::ptr_eq(prev, s) || ***prev == **s => { + if *at != i && (has_number(s.shapes.iter()) || *by_masters.get_or_insert_with(numbered_by_masters)) { + out.push(id); + } + } + _ => out.push(id), + } + } + (out, false) +} + +/// Whether `after` holds what `before` held: every slide and master the same +/// (by pointer, else by content), and the rest of the deck equal. Its next +/// free id is left out: the engine takes ids without using them. +fn same_content(before: &Presentation, after: &Presentation) -> bool { + let slides = before.slides.len() == after.slides.len() && before.slides.iter().zip(&after.slides).all(|(a, b)| Arc::ptr_eq(a, b) || **a == **b); + let masters = before.masters.len() == after.masters.len() && before.masters.iter().zip(&after.masters).all(|(a, b)| Arc::ptr_eq(a, b) || **a == **b); + slides + && masters + && before.notes_master == after.notes_master + && before.handout_master == after.handout_master + && before.slide_size == after.slide_size + && before.notes_size == after.notes_size + && before.sections == after.sections + && before.custom_shows == after.custom_shows + && before.show == after.show + && before.header_footer == after.header_footer + && before.props == after.props + && before.default_text_style == after.default_text_style + && before.media == after.media + && before.first_slide_number == after.first_slide_number + && before.embedded_fonts == after.embedded_fonts + && before.raw_ext == after.raw_ext +} + +/// Whether any of `shapes` (groups too) draws a slide-number field. +fn has_number<'a>(shapes: impl Iterator) -> bool { + let mut found = false; + for shape in shapes { + deckcraft_model::walk(std::slice::from_ref(shape), &mut |sh, _| { + if !found { + found = sh.text.iter().flat_map(|t| &t.paragraphs).flat_map(|p| &p.runs).any(|r| matches!(&r.kind, RunKind::Field { field } if field == "slidenum")); + } + }); + if found { + return true; + } + } + false +} diff --git a/apps/deck/host-service/src/guard.rs b/apps/deck/host-service/src/guard.rs new file mode 100644 index 00000000..abb5038f --- /dev/null +++ b/apps/deck/host-service/src/guard.rs @@ -0,0 +1,1450 @@ +//! The import guard (SERVICE.md "Import guard", decision 4): the service's +//! own check of a file, before the engine sees a byte of it, on every path +//! that opens a file (`deck.open`, `deck.cover`, the v1 file methods, and +//! through them the agent's `deck.info` and `deck.run`). +//! +//! deckcraft sizes what it allocates for a zip from the zip's own headers +//! (#448): its pptx reader takes up to 50,000 entries, 512 MiB a part and +//! 2 GiB in all, its native reader 2 GiB an entry, and neither checks that +//! an entry inflates to no more than its directory says. A hostile file +//! could make it allocate gigabytes on the shell's UI thread and abort the +//! process. So, before the engine reads anything: +//! +//! - **A zip** (`.pptx`, `.deckcraft`): its central directory is read with +//! bounds, by this module and not by the zip library: at most +//! [`MAX_ENTRIES`] entries, names of at most [`MAX_NAME_BYTES`], no entry +//! declaring over [`MAX_ENTRY_BYTES`] uncompressed, at most +//! [`MAX_TOTAL_BYTES`] declared in all, and no entry past [`RATIO_GRACE`] +//! compressing more than [`MAX_RATIO`] to 1 (Apache POI's zip-bomb rule). +//! Over any of those is `held:`. +//! - **Read alike.** The guard only checks what the engine will read if it +//! reads the zip exactly as the engine's zip reader does (the `zip` crate +//! behind deckcraft's package and native readers), so it takes the zip +//! only in a shape no two readers can take differently ([`directory`]): +//! the last end record, its comment ending the file, with no second end +//! record after it; its entry counts agreeing (on this disk and in all, +//! in the ZIP64 record too); the directory filling exactly the space it +//! declares, up to the end record or the ZIP64 record, every header in +//! it and every entry before it, the first at the very start of the file +//! (nothing prepended); every extra field read as that reader reads it. +//! Then that reader itself reads the directory ([`read_alike`]), and +//! anything it sees differently (where the zip starts, where its +//! directory is, how many entries, an entry's name, sizes, offsets, +//! method) refuses the file. Any of this, a directory that does not +//! parse, an entry outside the file, an encrypted entry or one +//! compressed in a way the engine does not read is `damaged:`. +//! - Then the guard says what it is, and its verdict picks the engine's +//! reader (`crate::engine_read`): a native deck by deckcraft's own marker +//! (`deckcraft_format::sniff`, which the engine tries first), else an +//! Office package by its `[Content_Types].xml` entry in the directory the +//! guard verified (the engine's `deckcraft_pptx::sniff` looks in a +//! package's first 4 KiB only, which no LibreOffice package meets). Any +//! other zip (`.key`, `.odp`) is `unsupported:`. +//! - Then every entry is inflated, bounded, into a scratch buffer (nothing +//! is kept but its first bytes): one that inflates past its declared size +//! is `damaged:`. Any audio or video part is `held:`, found by its +//! extension, by the content type the package declares for it +//! (`[Content_Types].xml`, or a native deck's `presentation.json`), or by +//! its first bytes (a movie or sound container's signature). +//! - So is a zip inside the zip, except an Office package's embedded object +//! ([`EMBEDDINGS`]): a chart's workbook or an embedded document is a zip +//! that neither the service nor the engine ever unzips (deckcraft's only +//! zip readers are its package and native readers; it reads a chart from +//! the chart's own cached values, and keeps only an embedded object's +//! XML and preview). Such a part passes, held like every entry to the +//! bounds above (entries, name, declared and actual size, ratio, totals) +//! and to the media checks; its own entries are never read, by anyone. +//! - **An OLE compound file** (`D0 CF 11 E0`): one whose directory holds an +//! `EncryptedPackage` stream is a password-protected Office file +//! (`protected:`); any other is a legacy `.ppt` or not a presentation at +//! all (`unsupported:`). Its directory is read with bounds too. +//! - **A PDF** is `unsupported:`, and so is any other file that is not +//! outline text (`.txt` or `.md`, by name, as the engine reads it). +//! +//! The guard reads only the bytes the service already read (at most the +//! service's 64 MiB a file). Beyond the directory's entries it allocates a +//! 64 KiB inflate buffer, the package's content-type list (at most +//! [`MAX_TYPES_BYTES`]) and, for an OLE file, a sector table of at most one +//! entry a sector; the engine's zip reader, run on a directory the guard +//! has bounded, holds one record an entry. Engine-side bounds stay tracked +//! in #448. + +use std::io::Read; + +use crate::codes::{fail, Code}; + +/// The most entries a zip may have. +pub(crate) const MAX_ENTRIES: usize = 10_000; +/// The longest entry name, in bytes. +pub(crate) const MAX_NAME_BYTES: usize = 1024; +/// The most one entry may declare, uncompressed. +pub(crate) const MAX_ENTRY_BYTES: u64 = 64 << 20; +/// The most a zip's entries may declare together. +pub(crate) const MAX_TOTAL_BYTES: u64 = 256 << 20; +/// The most an entry may compress to 1, once past [`RATIO_GRACE`]. +pub(crate) const MAX_RATIO: u64 = 100; +/// Entries up to this size are not held to [`MAX_RATIO`]: a small blank +/// picture or printer setting compresses far better than that. +pub(crate) const RATIO_GRACE: u64 = 100 << 10; +/// The most of `[Content_Types].xml` the guard reads for media types. +pub(crate) const MAX_TYPES_BYTES: u64 = 1 << 20; +/// The most extensible data a ZIP64 end record may carry beyond its 44 +/// bytes of fields: the engine's zip reader allocates all of it, and an +/// honest writer leaves it empty. +pub(crate) const MAX_ZIP64_EXTRA: u64 = 1024; + +/// What the engine will be asked to read, once the guard passed the file. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Format { + /// deckcraft's own `.deckcraft` zip, by its marker as + /// `deckcraft_format::sniff` finds it. + Native, + /// An Office Open XML package (`.pptx`): a `[Content_Types].xml` entry + /// in the directory the guard verified. + Pptx, + /// Outline text (`.txt`, `.md`). + Outline, +} + +/// The OLE compound file signature: a legacy Office file, or an encrypted +/// Office Open XML one. +const OLE: [u8; 8] = [0xD0, 0xCF, 0x11, 0xE0, 0xA1, 0xB1, 0x1A, 0xE1]; + +/// Check `bytes`, the file at `name` (storage-relative, only its extension +/// is used), before the engine reads it. `Ok` says how the engine will read +/// it; `Err` is the coded refusal. +pub(crate) fn check(name: &str, bytes: &[u8]) -> Result { + if bytes.starts_with(&OLE[..4]) { + return Err(ole(bytes)); + } + if bytes.starts_with(b"PK") { + return zip(bytes); + } + if bytes.starts_with(b"%PDF") { + return Err(fail(Code::Unsupported, "this is a PDF, not a presentation the deck engine reads (.pptx or .deckcraft)")); + } + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".txt") || lower.ends_with(".md") { + return Ok(Format::Outline); + } + Err(fail(Code::Unsupported, "this is not a presentation the deck engine reads (.pptx, .deckcraft, or outline .txt or .md)")) +} + +fn held(words: impl std::fmt::Display) -> String { + fail(Code::Held, words) +} + +fn damaged(words: impl std::fmt::Display) -> String { + fail(Code::Damaged, words) +} + +/// An entry's name as an error may show it: printable, and short. +fn shown(name: &str) -> String { + let clean: String = name.chars().filter(|c| !c.is_control()).collect(); + if clean.chars().count() > 80 { + format!("{}…", clean.chars().take(79).collect::()) + } else { + clean + } +} + +/// Little-endian numbers at an offset the file itself may have named: out +/// of range (or overflowing) is `None`, never a panic. +fn u16_at(b: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?)) +} + +fn u32_at(b: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?)) +} + +fn u64_at(b: &[u8], at: usize) -> Option { + Some(u64::from_le_bytes(b.get(at..at.checked_add(8)?)?.try_into().ok()?)) +} + +/// One entry of a zip's central directory, as the guard reads it, which is +/// how the engine's zip reader must read it too ([`read_alike`]). +#[derive(Debug)] +struct Entry { + /// The name as the checks match it and an error shows it. + name: String, + /// The name's bytes as stored. + raw: Vec, + flags: u16, + method: u16, + compressed: u64, + declared: u64, + offset: u64, +} + +impl Entry { + fn extension(&self) -> String { + let file = self.name.rsplit('/').next().unwrap_or(""); + file.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default() + } +} + +/// A zip's central directory: its entries, and where it starts. +#[derive(Debug)] +struct Directory { + entries: Vec, + start: usize, +} + +const EOCD: u32 = 0x0605_4b50; +const ZIP64_LOCATOR: u32 = 0x0706_4b50; +const ZIP64_EOCD: u32 = 0x0606_4b50; +const CENTRAL: u32 = 0x0201_4b50; +const LOCAL: u32 = 0x0403_4b50; + +/// The central directory, read with the bounds of the module doc, and only +/// in a shape that the engine's zip reader takes exactly as the guard does. +/// +/// Every rule the guard adds beyond its bounds closes a way two readers +/// part (the reviewed reader is the `zip` crate, 3.0.0: `read.rs` +/// `get_metadata`, `spec.rs` `find_central_directory`): +/// +/// - It takes the last end record whose comment ends the file, and refuses +/// any second end record after it, in its comment or overlapping it: the +/// reader searches backwards from the end of the file and tries the last +/// one first. +/// - The entries on this disk and the entries in all agree: the reader +/// counts the first (zip32) or the second (ZIP64), the guard the same +/// number either way. +/// - The directory fills exactly the space its end record declares, from +/// its offset up to the end record (or the ZIP64 record), every header +/// inside it: nothing past the last header the count names, and nothing +/// the guard leaves unread. +/// - Every entry starts before the directory, and the first at the very +/// start of the file: data put before the zip would make the reader +/// move every offset (its archive offset), the guard none. +/// - Whatever that reader refuses in this end record, the ZIP64 record or +/// a header, the guard refuses first: a reader that refuses the end +/// record it found searches backwards for another one, reading counts +/// the guard never saw. So the ZIP64 record ends exactly at its locator, +/// carries at most [`MAX_ZIP64_EXTRA`] bytes of extensible data (that +/// reader allocates them) and asks for no newer version than wrote it, +/// every disk number is 0, +/// no entry is encrypted or compressed other than stored or deflated, +/// and every extra field is one that reader takes ([`extras`]). +fn directory(b: &[u8]) -> Result { + let unreadable = || damaged("the presentation's zip directory can't be read"); + let parts = |what: &str| damaged(format!("the presentation's zip {what}, which zip readers take differently, so the deck service does not open it (#448)")); + let disks = || damaged("the presentation's zip spans several disks"); + // The end record: the last one in the final 64 KiB (its comment's most) + // whose comment ends the file. + let lowest = b.len().saturating_sub(22 + 0xFFFF); + let mut at = None; + let mut i = b.len().checked_sub(22).ok_or_else(unreadable)?; + loop { + if u32_at(b, i) == Some(EOCD) && u16_at(b, i + 20).is_some_and(|c| i + 22 + c as usize == b.len()) { + at = Some(i); + break; + } + if i == lowest { + break; + } + i -= 1; + } + let eocd = at.ok_or_else(unreadable)?; + if b[eocd + 1..].windows(4).any(|w| w == EOCD.to_le_bytes()) { + return Err(parts("has a second end record in its comment")); + } + let field16 = |o: usize| u16_at(b, eocd + o).ok_or_else(unreadable); + let field32 = |o: usize| u32_at(b, eocd + o).ok_or_else(unreadable); + if field16(4)? != 0 || field16(6)? != 0 { + return Err(disks()); + } + let (on_disk, total) = (field16(8)?, field16(10)?); + if on_disk != total { + return Err(parts(&format!("counts {total} entries in all but {on_disk} on this disk"))); + } + let mut count = total as u64; + let mut size = field32(12)? as u64; + let mut start = field32(16)? as u64; + // Where the directory must end: at the end record, or at the ZIP64 one. + let mut directory_end = eocd; + // ZIP64, when the reader looks for it (`may_be_zip64`): the entry count + // or the directory's offset at its most. Its locator is just before the + // end record and names the record. + if total == 0xFFFF || start == 0xFFFF_FFFF { + let locator = eocd.checked_sub(20).filter(|l| u32_at(b, *l) == Some(ZIP64_LOCATOR)).ok_or_else(unreadable)?; + if u32_at(b, locator + 4) != Some(0) || u32_at(b, locator + 16).is_none_or(|n| n > 1) { + return Err(disks()); + } + let record = usize::try_from(u64_at(b, locator + 8).ok_or_else(unreadable)?).map_err(|_| unreadable())?; + if u32_at(b, record) != Some(ZIP64_EOCD) { + return Err(unreadable()); + } + let record_size = u64_at(b, record + 4).ok_or_else(unreadable)?; + if record_size > 44 + MAX_ZIP64_EXTRA { + return Err(held(format!("the presentation's zip has a ZIP64 end record of {record_size} bytes, more than the {} the deck service opens (#448)", 44 + MAX_ZIP64_EXTRA))); + } + if record_size < 44 || (record as u64).checked_add(12).and_then(|r| r.checked_add(record_size)) != Some(locator as u64) { + return Err(unreadable()); + } + if u16_at(b, record + 14).ok_or_else(unreadable)? > u16_at(b, record + 12).ok_or_else(unreadable)? { + return Err(parts("asks for a newer zip version than the one that wrote it")); + } + if u32_at(b, record + 16) != Some(0) || u32_at(b, record + 20) != Some(0) { + return Err(disks()); + } + let (on_disk, total) = (u64_at(b, record + 24).ok_or_else(unreadable)?, u64_at(b, record + 32).ok_or_else(unreadable)?); + if on_disk != total { + return Err(parts(&format!("counts {total} entries in all but {on_disk} on this disk"))); + } + count = total; + size = u64_at(b, record + 40).ok_or_else(unreadable)?; + start = u64_at(b, record + 48).ok_or_else(unreadable)?; + directory_end = record; + } + if count > MAX_ENTRIES as u64 { + return Err(held(format!("the presentation's zip has {count} entries, more than the {MAX_ENTRIES} the deck service opens (#448)"))); + } + let start = usize::try_from(start).map_err(|_| unreadable())?; + let end = start.checked_add(usize::try_from(size).map_err(|_| unreadable())?).ok_or_else(unreadable)?; + let misplaced = || damaged("the presentation's zip directory isn't the size and place its end record declares"); + if end != directory_end { + return Err(misplaced()); + } + let mut entries = Vec::with_capacity(count as usize); + let mut total: u64 = 0; + let mut p = start; + for _ in 0..count { + if p + 46 > end || u32_at(b, p) != Some(CENTRAL) { + return Err(unreadable()); + } + let flags = u16_at(b, p + 8).ok_or_else(unreadable)?; + let method = u16_at(b, p + 10).ok_or_else(unreadable)?; + let mut compressed = u32_at(b, p + 20).ok_or_else(unreadable)? as u64; + let mut declared = u32_at(b, p + 24).ok_or_else(unreadable)? as u64; + let name_len = u16_at(b, p + 28).ok_or_else(unreadable)? as usize; + let extra_len = u16_at(b, p + 30).ok_or_else(unreadable)? as usize; + let comment_len = u16_at(b, p + 32).ok_or_else(unreadable)? as usize; + let mut offset = u32_at(b, p + 42).ok_or_else(unreadable)? as u64; + if name_len > MAX_NAME_BYTES { + return Err(held(format!("an entry of the presentation's zip has a name of {name_len} bytes, more than the {MAX_NAME_BYTES} the deck service opens (#448)"))); + } + let name_at = p + 46; + let extra_at = name_at + name_len; + let next = extra_at + extra_len + comment_len; + if next > end { + return Err(unreadable()); + } + let raw = b[name_at..extra_at].to_vec(); + let name = String::from_utf8_lossy(&raw).into_owned(); + if flags & 1 != 0 { + return Err(damaged(format!("`{}` in the presentation's zip is encrypted, which the deck engine doesn't read", shown(&name)))); + } + if method != 0 && method != 8 { + return Err(damaged(format!("`{}` in the presentation's zip is compressed with method {method}, which the deck engine doesn't read", shown(&name)))); + } + extras(b, extra_at, extra_at + extra_len, [&mut declared, &mut compressed, &mut offset], &name)?; + if offset >= start as u64 { + return Err(damaged(format!("`{}` in the presentation's zip starts after the zip's directory", shown(&name)))); + } + if declared > MAX_ENTRY_BYTES { + return Err(held(format!( + "`{}` in the presentation's zip declares {declared} bytes, more than the {MAX_ENTRY_BYTES} the deck service opens in one entry (#448)", + shown(&name) + ))); + } + total = total.saturating_add(declared); + if total > MAX_TOTAL_BYTES { + return Err(held(format!("the presentation's zip declares more than the {MAX_TOTAL_BYTES} bytes the deck service opens in all (#448)"))); + } + if declared > RATIO_GRACE && declared > compressed.saturating_mul(MAX_RATIO) { + return Err(held(format!( + "`{}` in the presentation's zip compresses {declared} bytes into {compressed}, more than the {MAX_RATIO} to 1 the deck service opens (#448)", + shown(&name) + ))); + } + entries.push(Entry { name, raw, flags, method, compressed, declared, offset }); + p = next; + } + if p != end { + return Err(misplaced()); + } + if entries.iter().map(|e| e.offset).min().is_some_and(|first| first != 0) { + return Err(parts("has data before its first entry")); + } + Ok(Directory { entries, start }) +} + +/// An entry's extra field (`b[from..to]`), read as the engine's zip reader +/// reads it (`zip` 3.0.0, `read::parse_single_extra_field`), so that reader +/// takes the very entries the guard checked, with the same sizes and +/// offset (`slots`: uncompressed, compressed, offset, as the header gave +/// them): +/// +/// - ZIP64 (`0x0001`): a field of 24 bytes or more gives all three values, +/// whatever the header's 32-bit fields say; a shorter one gives those +/// whose 32-bit field is at its most, in that order. A field too short +/// for what it must give is `damaged:`. +/// - NTFS times (`0x000a`) and extended times (`0x5455`) pass only in the +/// shapes that reader takes; any other is `damaged:`. +/// - AES (`0x9901`), and Info-ZIP's Unicode path and comment (`0x7075`, +/// `0x6375`), are `damaged:`: the first is encryption, and the second +/// would rename the entry for the engine after the guard checked its +/// name. +/// - A field that runs past the extra field is `damaged:`. +/// +/// Other fields that reader skips, and so does the guard. +fn extras(b: &[u8], from: usize, to: usize, slots: [&mut u64; 3], name: &str) -> Result<(), String> { + let refused = |what: &str| damaged(format!("`{}` in the presentation's zip has {what}, which the deck service does not open (#448)", shown(name))); + let [declared, compressed, offset] = slots; + let mut e = from; + while e < to { + let broken = || refused("a broken extra field"); + if e + 4 > to { + return Err(broken()); + } + let kind = u16_at(b, e).ok_or_else(broken)?; + let len = u16_at(b, e + 2).ok_or_else(broken)? as usize; + let body = e + 4; + let next = body + len; + if next > to { + return Err(broken()); + } + match kind { + 0x0001 => { + let all = len >= 24; + let mut q = body; + for slot in [&mut *declared, &mut *compressed, &mut *offset] { + if all || *slot == 0xFFFF_FFFF { + if q + 8 > next { + return Err(refused("a ZIP64 field of the wrong length")); + } + *slot = u64_at(b, q).ok_or_else(broken)?; + q += 8; + } + } + } + 0x000a => { + if len != 32 || u16_at(b, body + 4) != Some(1) || u16_at(b, body + 6) != Some(24) { + return Err(refused("an NTFS time field the deck engine's zip reader refuses")); + } + } + 0x5455 => { + let flags = if len >= 1 { b[body] } else { 0xFF }; + if !(len == 5 || len == 1 + 4 * flags.count_ones() as usize) || flags & 0xF8 != 0 { + return Err(refused("an extended time field the deck engine's zip reader refuses")); + } + } + 0x9901 => return Err(refused("AES encryption")), + 0x6375 | 0x7075 => return Err(refused("an Info-ZIP Unicode name or comment")), + _ => {} + } + e = next; + } + Ok(()) +} + +/// Where `e`'s data begins in `b`: past its local header, by that header's +/// own name and extra lengths, as the engine's zip reader finds it. +fn data_start(b: &[u8], e: &Entry) -> Result { + let outside = || damaged(format!("`{}` lies outside the presentation's zip", shown(&e.name))); + let at = usize::try_from(e.offset).map_err(|_| outside())?; + if u32_at(b, at) != Some(LOCAL) { + return Err(outside()); + } + let name_len = u16_at(b, at + 26).ok_or_else(outside)? as usize; + let extra_len = u16_at(b, at + 28).ok_or_else(outside)? as usize; + Ok(at + 30 + name_len + extra_len) +} + +/// Where `e`'s data lies in `b`. +fn data_of<'a>(b: &'a [u8], e: &Entry) -> Result<&'a [u8], String> { + let outside = || damaged(format!("`{}` lies outside the presentation's zip", shown(&e.name))); + let start = data_start(b, e)?; + let len = usize::try_from(e.compressed).map_err(|_| outside())?; + b.get(start..start.checked_add(len).ok_or_else(outside)?).ok_or_else(outside) +} + +/// The engine's own zip reader must read the directory exactly as the +/// guard did: the `zip` crate that deckcraft's package and native readers +/// open every deck with (`ZipArchive::new`, its default configuration; the +/// same crate and version, which a test checks in `Cargo.lock`). It runs +/// only after [`directory`] bounded the directory and refused everything +/// that reader would refuse in it, so the reader takes that very end +/// record and never looks for another, and it reads the directory and each +/// entry's local header, never an entry's data. Anything the reader sees +/// differently (data before the zip, another directory, another number of +/// entries, an entry's name, sizes, offset, where its data begins, its +/// method or encryption) is `damaged:`. +fn read_alike(b: &[u8], dir: &Directory) -> Result<(), String> { + let differs = |what: String| damaged(format!("the deck engine's zip reader reads the presentation differently ({what}), so the deck service does not open it (#448)")); + let mut z = zip::ZipArchive::new(std::io::Cursor::new(b)).map_err(|e| damaged(format!("the deck engine's zip reader can't read the presentation: {e}")))?; + if z.offset() != 0 { + return Err(differs(format!("{} bytes before the zip", z.offset()))); + } + if z.central_directory_start() != dir.start as u64 { + return Err(differs("another directory".into())); + } + if z.len() != dir.entries.len() { + return Err(differs(format!("{} entries, not {}", z.len(), dir.entries.len()))); + } + for (i, e) in dir.entries.iter().enumerate() { + let start = data_start(b, e)? as u64; + let f = z.by_index_raw(i).map_err(|err| damaged(format!("the deck engine's zip reader can't read `{}`: {err}", shown(&e.name))))?; + let method = match f.compression() { + zip::CompressionMethod::Stored => Some(0), + zip::CompressionMethod::Deflated => Some(8), + _ => None, + }; + let alike = f.name_raw() == e.raw.as_slice() + && f.compressed_size() == e.compressed + && f.size() == e.declared + && f.header_start() == e.offset + && f.data_start() == start + && method == Some(e.method) + && !f.encrypted(); + if !alike { + return Err(differs(format!("`{}`", shown(&e.name)))); + } + } + Ok(()) +} + +/// What an entry turned out to hold: its first bytes, and the whole of it +/// when the caller asked to keep it. +struct Inflated { + head: Vec, + kept: Option>, +} + +/// How many first bytes of each entry the signatures look at. +const HEAD: usize = 16; + +/// Inflate (or read) `e` with its declared size as the bound, keeping its +/// first bytes, the whole of it when `keep`, and feeding every chunk to +/// `watch`. +fn inflate(b: &[u8], e: &Entry, keep: bool, watch: &mut dyn FnMut(&[u8])) -> Result { + if e.flags & 1 != 0 { + return Err(damaged(format!("`{}` in the presentation's zip is encrypted, which the deck engine doesn't read", shown(&e.name)))); + } + let data = data_of(b, e)?; + let mut out = Inflated { head: Vec::with_capacity(HEAD), kept: keep.then(Vec::new) }; + let mut take = |chunk: &[u8], out: &mut Inflated| { + let room = HEAD.saturating_sub(out.head.len()); + out.head.extend_from_slice(&chunk[..chunk.len().min(room)]); + if let Some(kept) = out.kept.as_mut() { + kept.extend_from_slice(chunk); + } + watch(chunk); + }; + match e.method { + 0 => { + if e.compressed != e.declared { + return Err(damaged(format!("`{}` in the presentation's zip is not the size its directory says", shown(&e.name)))); + } + take(data, &mut out); + } + 8 => { + let mut decoder = flate2::read::DeflateDecoder::new(data); + let mut buf = vec![0u8; 64 << 10]; + let mut total: u64 = 0; + loop { + let n = decoder + .read(&mut buf) + .map_err(|err| damaged(format!("`{}` in the presentation's zip can't be inflated: {err}", shown(&e.name))))?; + if n == 0 { + break; + } + total += n as u64; + if total > e.declared { + return Err(damaged(format!( + "`{}` in the presentation's zip inflates past the {} bytes its directory declares", + shown(&e.name), + e.declared + ))); + } + take(&buf[..n], &mut out); + } + } + other => { + return Err(damaged(format!( + "`{}` in the presentation's zip is compressed with method {other}, which the deck engine doesn't read", + shown(&e.name) + ))) + } + } + Ok(out) +} + +/// File extensions of movies and sounds. +const MEDIA_EXTENSIONS: &[&str] = &[ + "mp4", "m4v", "mov", "qt", "wmv", "asf", "avi", "webm", "mkv", "mka", "mpg", "mpeg", "mpe", "m1v", "m2v", "3gp", "3g2", "flv", "ogv", "ogg", + "oga", "opus", "mp3", "wav", "wave", "m4a", "m4b", "aac", "wma", "flac", "aif", "aiff", "aifc", "mid", "midi", "au", "snd", "caf", "amr", + "swf", "mts", "m2ts", +]; + +/// The kind of movie or sound container `head` starts with, by its +/// signature (strong signatures only: a two-byte frame sync would also +/// match the first bytes of obfuscated fonts and other binary parts). +fn media_signature(head: &[u8]) -> Option<&'static str> { + let at = |i: usize, sig: &[u8]| head.get(i..i + sig.len()) == Some(sig); + if at(4, b"ftyp") { + Some("an MP4 or QuickTime movie or sound") + } else if at(0, b"RIFF") && at(8, b"WAVE") { + Some("a WAV sound") + } else if at(0, b"RIFF") && at(8, b"AVI ") { + Some("an AVI movie") + } else if at(0, b"OggS") { + Some("an Ogg movie or sound") + } else if at(0, b"fLaC") { + Some("a FLAC sound") + } else if at(0, &[0x1A, 0x45, 0xDF, 0xA3]) { + Some("a Matroska or WebM movie or sound") + } else if at(0, &[0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11]) { + Some("a Windows Media movie or sound") + } else if at(0, b"ID3") { + Some("an MP3 sound") + } else if at(0, b"FORM") && (at(8, b"AIFF") || at(8, b"AIFC")) { + Some("an AIFF sound") + } else if at(0, b"caff") { + Some("a Core Audio sound") + } else if at(0, b"MThd") { + Some("a MIDI sound") + } else if at(0, b"FLV\x01") { + Some("a Flash video") + } else if at(0, b".snd") { + Some("an AU sound") + } else if at(0, &[0, 0, 1, 0xBA]) || at(0, &[0, 0, 1, 0xB3]) { + Some("an MPEG movie") + } else if at(0, b"#!AMR") { + Some("an AMR sound") + } else { + None + } +} + +/// Whether `head` starts a zip archive: a zip inside the zip. +fn zip_signature(head: &[u8]) -> bool { + head.starts_with(b"PK\x03\x04") || head.starts_with(b"PK\x05\x06") || head.starts_with(b"PK\x07\x08") +} + +/// Where an Office package keeps its embedded objects (a chart's workbook, +/// an embedded document or OLE object), whose parts may be zips themselves. +pub(crate) const EMBEDDINGS: &str = "ppt/embeddings/"; + +/// Whether `name` is an embedded object's part: under [`EMBEDDINGS`], with +/// no empty, `.` or `..` segment and no backslash, so no other part can +/// borrow the exemption by its spelling. +fn embedded_object(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower.strip_prefix(EMBEDDINGS).is_some_and(|rest| !rest.contains('\\') && rest.split('/').all(|seg| !seg.is_empty() && seg != "." && seg != "..")) +} + +/// Pairs of names, lowercased. +type Pairs = Vec<(String, String)>; + +/// `[Content_Types].xml`'s declarations: (extension, type) defaults and +/// (part name, type) overrides, lowercased, part names without their +/// leading `/`. +fn content_types(xml: &str) -> (Pairs, Pairs) { + let mut defaults = Vec::new(); + let mut overrides = Vec::new(); + let mut rest = xml; + while let Some(lt) = rest.find('<') { + rest = &rest[lt + 1..]; + let end = rest.find('>').unwrap_or(rest.len()); + let tag = &rest[..end]; + let name_end = tag.find(|c: char| c.is_whitespace() || c == '/').unwrap_or(tag.len()); + let (name, attrs) = tag.split_at(name_end); + let attr = |key: &str| -> Option { + let mut a = attrs; + while let Some(i) = a.find(key) { + let before_ok = a[..i].chars().next_back().is_none_or(char::is_whitespace); + let after = a[i + key.len()..].trim_start(); + if before_ok && after.starts_with('=') { + let value = after[1..].trim_start(); + let quote = value.chars().next()?; + if quote == '"' || quote == '\'' { + let body = &value[1..]; + return body.find(quote).map(|j| body[..j].to_string()); + } + } + a = &a[i + key.len()..]; + } + None + }; + match name { + "Default" => { + if let (Some(ext), Some(ct)) = (attr("Extension"), attr("ContentType")) { + defaults.push((ext.to_ascii_lowercase(), ct.to_ascii_lowercase())); + } + } + "Override" => { + if let (Some(part), Some(ct)) = (attr("PartName"), attr("ContentType")) { + overrides.push((part.trim_start_matches('/').to_ascii_lowercase(), ct.to_ascii_lowercase())); + } + } + _ => {} + } + rest = &rest[end.min(rest.len())..]; + } + (defaults, overrides) +} + +/// A streaming search for `needles` across the chunks of one entry. +struct Scan { + needles: &'static [&'static [u8]], + tail: Vec, + found: bool, +} + +impl Scan { + fn new(needles: &'static [&'static [u8]]) -> Scan { + Scan { needles, tail: Vec::new(), found: false } + } + + fn feed(&mut self, chunk: &[u8]) { + if self.found { + return; + } + let longest = self.needles.iter().map(|n| n.len()).max().unwrap_or(1); + let mut window = std::mem::take(&mut self.tail); + window.extend_from_slice(chunk); + let first: Vec = self.needles.iter().filter_map(|n| n.first().copied()).collect(); + let mut i = 0; + while i < window.len() { + match window[i..].iter().position(|c| first.contains(c)) { + Some(k) => { + let at = i + k; + if self.needles.iter().any(|n| window[at..].starts_with(n)) { + self.found = true; + return; + } + i = at + 1; + } + None => break, + } + } + let keep = window.len().saturating_sub(longest - 1); + self.tail = window.split_off(keep); + } +} + +/// How a native deck's `presentation.json` names the content type of a +/// movie or a sound (`deckcraft_format` writes it compact). +const NATIVE_MEDIA_TYPES: &[&[u8]] = &[b"\"contentType\":\"audio/", b"\"contentType\":\"video/"]; + +/// The guard for a zip (module doc). +fn zip(b: &[u8]) -> Result { + let dir = directory(b)?; + read_alike(b, &dir)?; + let entries = &dir.entries; + // What it is (module doc): a native deck by deckcraft's own marker, + // which the engine tries first, else an Office package by its + // content-type list in the directory both readers read alike. The + // verdict picks the engine's reader (`crate::engine_read`). + let package = entries.iter().find(|e| e.name.eq_ignore_ascii_case("[Content_Types].xml")); + let format = if deckcraft_format::sniff(b) { + Format::Native + } else if package.is_some() { + Format::Pptx + } else { + return Err(fail(Code::Unsupported, "this zip is not a presentation the deck engine reads (.pptx or .deckcraft)")); + }; + // The content types a package declares, whichever reader takes it. + let (defaults, overrides) = match package { + Some(types) => { + if types.declared > MAX_TYPES_BYTES { + return Err(held(format!("the presentation's content-type list is larger than the {MAX_TYPES_BYTES} bytes the deck service reads (#448)"))); + } + let xml = inflate(b, types, true, &mut |_| {})?.kept.unwrap_or_default(); + content_types(&String::from_utf8_lossy(&xml)) + } + None => (Vec::new(), Vec::new()), + }; + for e in entries { + let mut scan = Scan::new(NATIVE_MEDIA_TYPES); + let watch_json = e.name == "presentation.json"; + let inflated = inflate(b, e, false, &mut |chunk| { + if watch_json { + scan.feed(chunk) + } + })?; + // An Office package's embedded object may be a zip: nothing unzips it. + if zip_signature(&inflated.head) && !(format == Format::Pptx && embedded_object(&e.name)) { + return Err(held(format!("the presentation holds a zip inside its zip (`{}`), which the deck service does not open (#448)", shown(&e.name)))); + } + let lower = e.name.to_ascii_lowercase(); + let declared_type = overrides + .iter() + .find(|(part, _)| *part == lower) + .or_else(|| defaults.iter().find(|(ext, _)| *ext == e.extension())) + .map(|(_, ct)| ct.as_str()); + let media = if MEDIA_EXTENSIONS.contains(&e.extension().as_str()) { + Some("a movie or sound file") + } else if declared_type.is_some_and(|ct| ct.starts_with("audio/") || ct.starts_with("video/")) { + Some("a part its package says is a movie or sound") + } else { + media_signature(&inflated.head) + }; + if let Some(what) = media { + return Err(held(format!( + "the presentation holds video or audio (`{}`, {what}), which the deck service does not open yet (#448)", + shown(&e.name) + ))); + } + if scan.found { + return Err(held("the presentation holds video or audio, which the deck service does not open yet (#448)")); + } + } + Ok(format) +} + +/// The guard for an OLE compound file: `protected:` when its directory holds +/// an `EncryptedPackage` stream, `unsupported:` otherwise (module doc). +fn ole(b: &[u8]) -> String { + if encrypted_package(b) == Some(true) { + fail(Code::Protected, "this presentation is protected with a password, and the deck service doesn't open those") + } else { + fail(Code::Unsupported, "this is a legacy .ppt or another OLE file, not a presentation the deck engine reads (.pptx or .deckcraft)") + } +} + +/// Whether the compound file's directory holds a stream named +/// `EncryptedPackage` (`None`: the file is no compound file it can read). +/// Every walk is bounded by the file's own sector count, so a looping chain +/// ends. +fn encrypted_package(b: &[u8]) -> Option { + const FREE: u32 = 0xFFFF_FFFA; + if b.len() < 512 || b[..8] != OLE { + return None; + } + let sector = match (u16_at(b, 0x1A)?, u16_at(b, 0x1E)?) { + (3, 9) => 512usize, + (4, 12) => 4096usize, + _ => return None, + }; + let sectors = b.len() / sector; + let sector_at = |s: u32| -> Option<&[u8]> { + let at = (s as usize).checked_add(1)?.checked_mul(sector)?; + b.get(at..at + sector) + }; + // The FAT's sectors: the header's 109, then the DIFAT chain; never more + // than the file has sectors, whatever the header says. + let fat_count = (u32_at(b, 0x2C)? as usize).min(sectors); + let mut fat: Vec = (0..109).filter_map(|i| u32_at(b, 0x4C + 4 * i)).filter(|s| *s < FREE).collect(); + let mut difat = u32_at(b, 0x44)?; + let mut steps = 0; + while difat < FREE && fat.len() < fat_count && steps <= sectors { + let sec = sector_at(difat)?; + let per = sector / 4 - 1; + fat.extend((0..per).filter_map(|i| u32_at(sec, 4 * i)).filter(|s| *s < FREE)); + difat = u32_at(sec, 4 * per)?; + steps += 1; + } + fat.truncate(fat_count); + let next = |s: u32| -> Option { + let per = sector / 4; + let fs = *fat.get(s as usize / per)?; + u32_at(sector_at(fs)?, (s as usize % per) * 4) + }; + // The directory chain: 128-byte entries, each a UTF-16 name. + let mut s = u32_at(b, 0x30)?; + let mut steps = 0; + while s < FREE { + if steps > sectors { + return None; + } + steps += 1; + let sec = sector_at(s)?; + for entry in sec.as_chunks::<128>().0 { + let len = u16_at(entry, 0x40)? as usize; + if entry[0x42] == 2 && (4..=64).contains(&len) { + let units: Vec = entry[..len - 2].as_chunks::<2>().0.iter().map(|c| u16::from_le_bytes(*c)).collect(); + if String::from_utf16_lossy(&units).eq_ignore_ascii_case("EncryptedPackage") { + return Some(true); + } + } + } + s = next(s)?; + } + Some(false) +} + +/// Hostile and honest files for the guard's tests, built here byte by byte +/// so a test can make a zip lie. +#[cfg(test)] +pub(crate) mod fixtures { + use std::io::Write; + + /// One entry of a test zip. + pub(crate) struct Part { + pub name: String, + pub data: Vec, + pub deflate: bool, + /// The uncompressed size the directory declares, when it lies. + pub declared: Option, + /// The name's bytes, when they are not `name`'s. + pub raw_name: Option>, + /// The general-purpose flags (bit 11: the name is UTF-8). + pub flags: u16, + /// The central header's extra field. + pub extra: Vec, + /// Only a local header and data, which no central header names. + pub hidden: bool, + } + + impl Part { + pub(crate) fn stored(name: &str, data: &[u8]) -> Part { + Part { name: name.into(), data: data.to_vec(), deflate: false, declared: None, raw_name: None, flags: 0, extra: Vec::new(), hidden: false } + } + pub(crate) fn deflated(name: &str, data: &[u8]) -> Part { + Part { deflate: true, ..Part::stored(name, data) } + } + } + + fn crc32(data: &[u8]) -> u32 { + let mut crc = 0xFFFF_FFFFu32; + for &byte in data { + crc ^= byte as u32; + for _ in 0..8 { + crc = if crc & 1 != 0 { (crc >> 1) ^ 0xEDB8_8320 } else { crc >> 1 }; + } + } + !crc + } + + /// `data` deflated as a part with `deflate` is. + pub(crate) fn deflate(data: &[u8]) -> Vec { + let mut e = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::fast()); + e.write_all(data).unwrap(); + e.finish().unwrap() + } + + /// A zip's two halves: its entries (local headers and data) and its + /// central directory, which names `count` of them. + pub(crate) struct Layout { + pub locals: Vec, + pub central: Vec, + pub count: u16, + } + + /// The entries and central directory of `parts`, honest unless a part + /// says otherwise, as if `base` bytes came before them. + pub(crate) fn layout_from(parts: &[Part], base: u32) -> Layout { + let mut out = Vec::new(); + let mut central = Vec::new(); + let mut count = 0u16; + for p in parts { + let body = if p.deflate { deflate(&p.data) } else { p.data.clone() }; + let crc = crc32(&p.data); + let declared = p.declared.unwrap_or(p.data.len() as u64) as u32; + let method: u16 = if p.deflate { 8 } else { 0 }; + let offset = base + out.len() as u32; + let name = p.raw_name.clone().unwrap_or_else(|| p.name.as_bytes().to_vec()); + out.extend_from_slice(&0x0403_4b50u32.to_le_bytes()); + out.extend_from_slice(&20u16.to_le_bytes()); + out.extend_from_slice(&p.flags.to_le_bytes()); + out.extend_from_slice(&method.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&0x21u16.to_le_bytes()); + out.extend_from_slice(&crc.to_le_bytes()); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend_from_slice(&declared.to_le_bytes()); + out.extend_from_slice(&(name.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + out.extend_from_slice(&name); + out.extend_from_slice(&body); + if p.hidden { + continue; + } + count += 1; + central.extend_from_slice(&0x0201_4b50u32.to_le_bytes()); + central.extend_from_slice(&20u16.to_le_bytes()); + central.extend_from_slice(&20u16.to_le_bytes()); + central.extend_from_slice(&p.flags.to_le_bytes()); + central.extend_from_slice(&method.to_le_bytes()); + central.extend_from_slice(&0u16.to_le_bytes()); + central.extend_from_slice(&0x21u16.to_le_bytes()); + central.extend_from_slice(&crc.to_le_bytes()); + central.extend_from_slice(&(body.len() as u32).to_le_bytes()); + central.extend_from_slice(&declared.to_le_bytes()); + central.extend_from_slice(&(name.len() as u16).to_le_bytes()); + central.extend_from_slice(&(p.extra.len() as u16).to_le_bytes()); + central.extend_from_slice(&[0u8; 10]); + central.extend_from_slice(&offset.to_le_bytes()); + central.extend_from_slice(&name); + central.extend_from_slice(&p.extra); + } + Layout { locals: out, central, count } + } + + /// [`layout_from`] at the start of the file. + pub(crate) fn layout(parts: &[Part]) -> Layout { + layout_from(parts, 0) + } + + /// An end record: `on_disk` and `total` entries, a directory of `size` + /// bytes at `offset`, and `comment`. + pub(crate) fn end_record(on_disk: u16, total: u16, size: u32, offset: u32, comment: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&0x0605_4b50u32.to_le_bytes()); + out.extend_from_slice(&[0u8; 4]); + out.extend_from_slice(&on_disk.to_le_bytes()); + out.extend_from_slice(&total.to_le_bytes()); + out.extend_from_slice(&size.to_le_bytes()); + out.extend_from_slice(&offset.to_le_bytes()); + out.extend_from_slice(&(comment.len() as u16).to_le_bytes()); + out.extend_from_slice(comment); + out + } + + /// A zip of `parts`, honest unless a part says otherwise. + pub(crate) fn zip(parts: &[Part]) -> Vec { + let l = layout(parts); + let end = end_record(l.count, l.count, l.central.len() as u32, l.locals.len() as u32, b""); + [l.locals, l.central, end].concat() + } + + /// A ZIP64 zip of `parts`: its directory named by a ZIP64 end record + /// (counting `on_disk` and `total` entries, needing version `needed`) + /// and its locator, the end record's own fields at their most. + pub(crate) fn zip64(parts: &[Part], on_disk: u64, total: u64, needed: u16) -> Vec { + zip64_with(parts, on_disk, total, needed, b"") + } + + /// [`zip64`], its end record carrying `extensible` data. + pub(crate) fn zip64_with(parts: &[Part], on_disk: u64, total: u64, needed: u16, extensible: &[u8]) -> Vec { + let l = layout(parts); + let record_at = (l.locals.len() + l.central.len()) as u64; + let mut record = Vec::new(); + record.extend_from_slice(&0x0606_4b50u32.to_le_bytes()); + record.extend_from_slice(&(44 + extensible.len() as u64).to_le_bytes()); + record.extend_from_slice(&45u16.to_le_bytes()); + record.extend_from_slice(&needed.to_le_bytes()); + record.extend_from_slice(&[0u8; 8]); + record.extend_from_slice(&on_disk.to_le_bytes()); + record.extend_from_slice(&total.to_le_bytes()); + record.extend_from_slice(&(l.central.len() as u64).to_le_bytes()); + record.extend_from_slice(&(l.locals.len() as u64).to_le_bytes()); + record.extend_from_slice(extensible); + let mut locator = Vec::new(); + locator.extend_from_slice(&0x0706_4b50u32.to_le_bytes()); + locator.extend_from_slice(&0u32.to_le_bytes()); + locator.extend_from_slice(&record_at.to_le_bytes()); + locator.extend_from_slice(&1u32.to_le_bytes()); + let end = end_record(0xFFFF, 0xFFFF, 0xFFFF_FFFF, 0xFFFF_FFFF, b""); + [l.locals, l.central, record, locator, end].concat() + } + + /// One extra field: `kind`, then `body`. + pub(crate) fn field(kind: u16, body: &[u8]) -> Vec { + [kind.to_le_bytes().as_slice(), (body.len() as u16).to_le_bytes().as_slice(), body].concat() + } + + /// An Office package's content-type list and its first part. + pub(crate) fn package(extra: Vec, types: &str) -> Vec { + let mut parts = vec![Part::deflated( + "[Content_Types].xml", + format!("{types}").as_bytes(), + )]; + parts.push(Part::deflated("ppt/presentation.xml", b"")); + parts.extend(extra); + zip(&parts) + } + + /// The zip `bytes` again, every entry read back with the guard's own + /// reader and deflated, with `extra` after them: an engine-written + /// package with parts added. + pub(crate) fn with_parts(bytes: &[u8], extra: Vec) -> Vec { + with_last(bytes, "", extra) + } + + /// [`with_parts`], the entry named `last` moved after every other + /// entry and `extra`: a package laid out as LibreOffice writes one, + /// its content-type list last. + pub(crate) fn with_last(bytes: &[u8], last: &str, extra: Vec) -> Vec { + let dir = super::directory(bytes).unwrap(); + let read = |e: &super::Entry| Part::deflated(&e.name, &super::inflate(bytes, e, true, &mut |_| {}).unwrap().kept.unwrap()); + let mut parts: Vec = dir.entries.iter().filter(|e| !e.name.ends_with('/') && e.name != last).map(read).collect(); + parts.extend(extra); + parts.extend(dir.entries.iter().filter(|e| e.name == last).map(read)); + zip(&parts) + } + + /// A native deck's skeleton: its `mimetype` and `presentation.json`. + pub(crate) fn native(extra: Vec, json: &str) -> Vec { + let mut parts = vec![Part::stored("mimetype", deckcraft_format::MIMETYPE.as_bytes()), Part::deflated("presentation.json", json.as_bytes())]; + parts.extend(extra); + zip(&parts) + } + + /// An OLE compound file (version 3, 512-byte sectors) whose directory + /// holds `streams` after its root entry. + pub(crate) fn ole(streams: &[&str]) -> Vec { + let mut header = vec![0u8; 512]; + header[..8].copy_from_slice(&super::OLE); + header[0x18..0x1A].copy_from_slice(&0x3Eu16.to_le_bytes()); + header[0x1A..0x1C].copy_from_slice(&3u16.to_le_bytes()); + header[0x1C..0x1E].copy_from_slice(&0xFFFEu16.to_le_bytes()); + header[0x1E..0x20].copy_from_slice(&9u16.to_le_bytes()); + header[0x20..0x22].copy_from_slice(&6u16.to_le_bytes()); + header[0x2C..0x30].copy_from_slice(&1u32.to_le_bytes()); + header[0x30..0x34].copy_from_slice(&1u32.to_le_bytes()); + header[0x38..0x3C].copy_from_slice(&4096u32.to_le_bytes()); + header[0x3C..0x40].copy_from_slice(&0xFFFF_FFFEu32.to_le_bytes()); + header[0x44..0x48].copy_from_slice(&0xFFFF_FFFEu32.to_le_bytes()); + for i in 0..109 { + let v: u32 = if i == 0 { 0 } else { 0xFFFF_FFFF }; + header[0x4C + 4 * i..0x50 + 4 * i].copy_from_slice(&v.to_le_bytes()); + } + // Sector 0: the FAT (itself, then the directory's two sectors). + let mut fat = vec![0xFFu8; 512]; + fat[..4].copy_from_slice(&0xFFFF_FFFDu32.to_le_bytes()); + fat[4..8].copy_from_slice(&2u32.to_le_bytes()); + fat[8..12].copy_from_slice(&0xFFFF_FFFEu32.to_le_bytes()); + // Sectors 1 and 2: the directory, four entries a sector. + let mut dir = vec![0u8; 1024]; + let names: Vec<(&str, u8)> = std::iter::once(("Root Entry", 5u8)).chain(streams.iter().map(|s| (*s, 2u8))).collect(); + for (i, (name, kind)) in names.iter().enumerate().take(8) { + let e = &mut dir[i * 128..(i + 1) * 128]; + let units: Vec = name.encode_utf16().collect(); + for (k, u) in units.iter().enumerate() { + e[k * 2..k * 2 + 2].copy_from_slice(&u.to_le_bytes()); + } + e[0x40..0x42].copy_from_slice(&(((units.len() + 1) * 2) as u16).to_le_bytes()); + e[0x42] = *kind; + e[0x44..0x50].copy_from_slice(&[0xFF; 12]); + } + let mut out = header; + out.extend_from_slice(&fat); + out.extend_from_slice(&dir); + out + } +} + +#[cfg(test)] +mod tests { + use super::fixtures::{end_record, field, layout, layout_from, native, ole, package, zip, zip64, zip64_with, Part}; + use super::*; + + #[track_caller] + fn coded(r: Result, code: &str) -> String { + let e = r.expect_err("refused"); + assert!(e.starts_with(&format!("{code}: ")), "expected {code}: {e}"); + e + } + + #[test] + fn honest_files_pass_and_say_what_they_are() { + assert_eq!(check("deck.pptx", &package(vec![Part::stored("ppt/media/image1.png", b"\x89PNG\r\n\x1a\nrest")], "")), Ok(Format::Pptx)); + assert_eq!(check("deck.deckcraft", &native(vec![Part::stored("media/7", b"\xFF\xD8\xFF\xE0jpeg")], "{\"format\":\"deckcraft\"}")), Ok(Format::Native)); + assert_eq!(check("outline.txt", b"Title\n\tPoint\n"), Ok(Format::Outline)); + assert_eq!(check("Notes.MD", b"# x"), Ok(Format::Outline)); + // The engine's own writers' output passes. + let deck = deckcraft_model::defaults::new_presentation(None); + assert_eq!(check("x.deckcraft", &deckcraft_format::save(&deck).unwrap()), Ok(Format::Native)); + assert_eq!(check("x.pptx", &deckcraft_engine::cmd::file::save_bytes(&deck, "pptx").unwrap()), Ok(Format::Pptx)); + } + + #[test] + fn what_the_engine_does_not_read_is_unsupported() { + coded(check("legacy.ppt", &ole(&["PowerPoint Document", "Current User"])), "unsupported"); + coded(check("broken.ppt", &OLE), "unsupported"); + coded(check("doc.pdf", b"%PDF-1.7\n..."), "unsupported"); + coded(check("deck.key", &zip(&[Part::stored("Index/Document.iwa", b"\0\0\0"), Part::stored("Metadata/Properties.plist", b"")])), "unsupported"); + coded(check("deck.odp", &zip(&[Part::stored("mimetype", b"application/vnd.oasis.opendocument.presentation")])), "unsupported"); + coded(check("deck.pptx", b"not a zip at all"), "unsupported"); + coded(check("image.png", b"\x89PNG\r\n\x1a\n"), "unsupported"); + } + + #[test] + fn an_encrypted_package_is_protected() { + let e = coded(check("locked.pptx", &ole(&["EncryptionInfo", "EncryptedPackage"])), "protected"); + assert!(e.contains("password"), "{e}"); + // Found past the first directory sector too. + coded(check("locked.pptx", &ole(&["a", "b", "c", "d", "EncryptedPackage"])), "protected"); + } + + #[test] + fn a_zip_beyond_the_guard_is_held() { + // Ten thousand entries and one more. + let many: Vec = (0..=MAX_ENTRIES).map(|i| Part::stored(&format!("f{i}"), b"")).collect(); + let e = coded(check("many.pptx", &zip(&many)), "held"); + assert!(e.contains("10001 entries"), "{e}"); + // At the cap the directory passes (and the names decide). + let at_cap: Vec = (0..MAX_ENTRIES).map(|i| Part::stored(&format!("f{i}"), b"")).collect(); + coded(check("cap.zip", &zip(&at_cap)), "unsupported"); + // A zip bomb: 16 MiB of zeros in a few kilobytes. + let bomb = package(vec![Part::deflated("ppt/slides/slide1.xml", &vec![0u8; 16 << 20])], ""); + assert!(bomb.len() < 1 << 20, "{} bytes", bomb.len()); + let e = coded(check("bomb.pptx", &bomb), "held"); + assert!(e.contains("to 1"), "{e}"); + // One entry declaring more than 64 MiB, and entries declaring more + // than 256 MiB in all. + let big = Part { declared: Some(MAX_ENTRY_BYTES + 1), ..Part::stored("ppt/big.bin", &vec![7u8; 1 << 20]) }; + coded(check("big.pptx", &package(vec![big], "")), "held"); + let parts: Vec = (0..5).map(|i| Part { declared: Some(60 << 20), ..Part::stored(&format!("ppt/p{i}.bin"), &vec![7u8; 1 << 20]) }).collect(); + let e = coded(check("total.pptx", &package(parts, "")), "held"); + assert!(e.contains("in all"), "{e}"); + // A name past 1,024 bytes. + coded(check("name.pptx", &package(vec![Part::stored(&"n".repeat(MAX_NAME_BYTES + 1), b"x")], "")), "held"); + } + + #[test] + fn a_lying_or_broken_zip_is_damaged() { + // Declares 100 bytes, inflates to a megabyte. + let liar = Part { declared: Some(100), ..Part::deflated("ppt/slides/slide1.xml", &vec![b' '; 1 << 20]) }; + let e = coded(check("liar.pptx", &package(vec![liar], "")), "damaged"); + assert!(e.contains("inflates past the 100 bytes"), "{e}"); + let stored_liar = Part { declared: Some(3), ..Part::stored("ppt/x.bin", b"0123456789") }; + coded(check("liar.pptx", &package(vec![stored_liar], "")), "damaged"); + // A directory that doesn't parse: cut in half. + let good = package(vec![], ""); + coded(check("cut.pptx", &good[..good.len() / 2]), "damaged"); + coded(check("cut.pptx", b"PK\x03\x04garbage"), "damaged"); + // An entry whose data lies outside the file. + let mut bad = package(vec![], ""); + let n = bad.len(); + // The last central entry's local offset (16 bytes before the end + // record's start + the name) — rewrite every offset to past the end. + for i in 0..n.saturating_sub(4) { + if bad[i..i + 4] == 0x0201_4b50u32.to_le_bytes() { + bad[i + 42..i + 46].copy_from_slice(&(n as u32 + 100).to_le_bytes()); + } + } + coded(check("outside.pptx", &bad), "damaged"); + } + + #[test] + fn embedded_objects_pass_and_other_nested_zips_and_media_are_held() { + let inner = zip(&[Part::deflated("[Content_Types].xml", b""), Part::deflated("xl/workbook.xml", b"")]); + // A chart's workbook, an embedded document or object: zips that + // nothing unzips, under the package's embeddings. + let xlsx = ""; + assert_eq!(check("chart.pptx", &package(vec![Part::stored("ppt/embeddings/Microsoft_Excel_Worksheet1.xlsx", &inner)], xlsx)), Ok(Format::Pptx)); + let objects = vec![ + Part::deflated("ppt/embeddings/Microsoft_Word_Document.docx", &inner), + Part::deflated("ppt/embeddings/oleObject1.bin", &inner), + Part::stored("PPT/Embeddings/Sheet2.xlsx", &inner), + ]; + assert_eq!(check("objects.pptx", &package(objects, "")), Ok(Format::Pptx)); + // A zip anywhere else stays held, and so does one only spelled to + // look embedded, or one in a native deck, which embeds no objects. + for name in ["ppt/media/x.zip", "ppt/slides/x.zip", "x.zip", "ppt/embeddings/../media/x.zip", "ppt/embeddings//x.xlsx", "ppt/embeddings/a/./x.xlsx", "ppt\\embeddings\\x.xlsx"] { + let e = coded(check("nested.pptx", &package(vec![Part::stored(name, &inner)], "")), "held"); + assert!(e.contains("zip inside its zip"), "{name}: {e}"); + } + coded(check("nested.deckcraft", &native(vec![Part::stored("ppt/embeddings/x.xlsx", &inner)], "{}")), "held"); + // An embedded object is held to every other bound: its declared + // size, its name, its actual size, and the ratio. + let big = Part { declared: Some(MAX_ENTRY_BYTES + 1), ..Part::stored("ppt/embeddings/big.xlsx", &vec![7u8; 1 << 20]) }; + coded(check("big.pptx", &package(vec![big], "")), "held"); + let long = format!("ppt/embeddings/{}.xlsx", "n".repeat(MAX_NAME_BYTES)); + coded(check("name.pptx", &package(vec![Part::stored(&long, &inner)], "")), "held"); + let liar = Part { declared: Some(100), ..Part::deflated("ppt/embeddings/liar.xlsx", &[inner.clone(), vec![0u8; 1 << 20]].concat()) }; + coded(check("liar.pptx", &package(vec![liar], "")), "damaged"); + // A workbook that is a zip bomb (16 MiB in a few kilobytes) is + // refused by its directory entry's ratio, before anything is + // inflated or unzipped: with its stream broken it is refused the + // same way, where inflating it would have found it damaged. + let name = "ppt/embeddings/bomb.xlsx"; + let mut bomb = package(vec![Part::deflated(name, &[inner.clone(), vec![0u8; 16 << 20]].concat())], ""); + assert!(bomb.len() < 1 << 20, "{} bytes", bomb.len()); + let e = coded(check("bomb.pptx", &bomb), "held"); + assert!(e.contains(name) && e.contains("100 to 1"), "{e}"); + let body = bomb.windows(name.len()).position(|w| w == name.as_bytes()).unwrap() + name.len(); + bomb[body + 64..body + 128].fill(0xFF); + let e = coded(check("bomb.pptx", &bomb), "held"); + assert!(e.contains("100 to 1"), "{e}"); + // Media under the embeddings is still media. + coded(check("movie.pptx", &package(vec![Part::stored("ppt/embeddings/clip.mp4", b"\0\0\0\x18ftypisom")], "")), "held"); + // Media by extension, by declared type, by signature; in a native + // deck by signature and by its manifest's content type. + let e = coded(check("movie.pptx", &package(vec![Part::stored("ppt/media/media1.mp4", b"\0\0\0\0data")], "")), "held"); + assert!(e.contains("video or audio") && e.contains("ppt/media/media1.mp4"), "{e}"); + coded(check("movie.pptx", &package(vec![Part::stored("ppt/media/clip.bin", b"0123")], "")), "held"); + coded(check("sound.pptx", &package(vec![Part::stored("ppt/media/a.dat", b"0123")], "")), "held"); + coded(check("sound.pptx", &package(vec![Part::deflated("ppt/media/a.bin", b"RIFF\x10\0\0\0WAVEfmt ")], "")), "held"); + coded(check("movie.pptx", &package(vec![Part::stored("ppt/media/b.bin", b"\0\0\0\x18ftypisom")], "")), "held"); + coded(check("sound.deckcraft", &native(vec![Part::stored("media/9", b"ID3\x04\0\0\0")], "{}")), "held"); + let json = "{\"presentation\":{\"media\":[{\"id\":9,\"name\":\"a.wav\",\"contentType\":\"audio/wav\"}]}}"; + let e = coded(check("sound.deckcraft", &native(vec![], json)), "held"); + assert!(e.contains("video or audio"), "by the manifest's content type: {e}"); + // A type declared for an extension no part has is no media. + assert_eq!(check("deck.pptx", &package(vec![], "")), Ok(Format::Pptx)); + } + + /// The two parts every package below starts with. + fn head() -> Vec { + vec![Part::deflated("[Content_Types].xml", b""), Part::deflated("ppt/presentation.xml", b"")] + } + + /// `l`'s entries and directory, then `end`. + fn with_end(l: &super::fixtures::Layout, end: Vec) -> Vec { + [l.locals.clone(), l.central.clone(), end].concat() + } + + #[test] + fn the_readers_never_part_on_the_entry_count() { + // The review's proof of concept: the end record counts one entry in + // all and two on this disk. The guard read one ([Content_Types].xml) + // and the engine's zip reader two, the second a movie. + let parts = vec![Part::deflated("[Content_Types].xml", b""), Part::stored("ppt/media/evil.mp4", b"\0\0\0\x18ftypisom")]; + let l = layout(&parts); + let (size, at) = (l.central.len() as u32, l.locals.len() as u32); + let poc = with_end(&l, end_record(2, 1, size, at, b"")); + let engine = zip::ZipArchive::new(std::io::Cursor::new(&poc)).unwrap(); + assert_eq!((engine.len(), engine.name_for_index(1)), (2, Some("ppt/media/evil.mp4")), "the engine's zip reader sees the movie"); + let e = coded(check("poc.pptx", &poc), "damaged"); + assert!(e.contains("1 entries in all but 2 on this disk"), "{e}"); + coded(check("poc.pptx", &with_end(&l, end_record(1, 2, size, at, b""))), "damaged"); + // The same with a zip bomb as the hidden entry. + let parts = vec![Part::deflated("[Content_Types].xml", b""), Part::deflated("ppt/slides/slide1.xml", &vec![0u8; 16 << 20])]; + let l = layout(&parts); + coded(check("poc.pptx", &with_end(&l, end_record(2, 1, l.central.len() as u32, l.locals.len() as u32, b""))), "damaged"); + // ZIP64: an honest record passes; its counts disagreeing, or a + // record asking for a newer version than wrote it, does not. + assert_eq!(check("big.pptx", &zip64(&head(), 2, 2, 45)), Ok(Format::Pptx)); + let e = coded(check("big.pptx", &zip64(&head(), 3, 2, 45)), "damaged"); + assert!(e.contains("2 entries in all but 3 on this disk"), "{e}"); + coded(check("big.pptx", &zip64(&head(), 2, 3, 45)), "damaged"); + coded(check("big.pptx", &zip64(&head(), 2, 2, 46)), "damaged"); + // Its extensible data, which the engine's zip reader allocates + // whole: up to the cap, then held. + let at_cap = vec![7u8; MAX_ZIP64_EXTRA as usize]; + assert_eq!(check("big.pptx", &zip64_with(&head(), 2, 2, 45, &at_cap)), Ok(Format::Pptx)); + let over = vec![7u8; MAX_ZIP64_EXTRA as usize + 1]; + let e = coded(check("big.pptx", &zip64_with(&head(), 2, 2, 45, &over)), "held"); + assert!(e.contains("ZIP64 end record"), "{e}"); + } + + #[test] + fn the_directory_fills_exactly_what_its_end_record_declares() { + let mut parts = head(); + parts.push(Part::stored("ppt/slides/slide1.xml", b"")); + let l = layout(&parts); + let (size, at) = (l.central.len() as u32, l.locals.len() as u32); + assert_eq!(check("ok.pptx", &with_end(&l, end_record(3, 3, size, at, b""))), Ok(Format::Pptx)); + // Declared shorter, or longer, than the headers there. + let e = coded(check("short.pptx", &with_end(&l, end_record(3, 3, size - 10, at, b""))), "damaged"); + assert!(e.contains("size and place"), "{e}"); + coded(check("long.pptx", &with_end(&l, end_record(3, 3, size + 10, at, b""))), "damaged"); + // A header past the count: two counted, three there. + let e = coded(check("past.pptx", &with_end(&l, end_record(2, 2, size, at, b""))), "damaged"); + assert!(e.contains("size and place"), "{e}"); + // A directory that starts elsewhere than where it is. + coded(check("moved.pptx", &with_end(&l, end_record(3, 3, size, at - 1, b""))), "damaged"); + } + + #[test] + fn one_end_record_ends_the_file() { + let l = layout(&head()); + let (size, at) = (l.central.len() as u32, l.locals.len() as u32); + let end = end_record(2, 2, size, at, b""); + assert_eq!(check("note.pptx", &with_end(&l, end_record(2, 2, size, at, b"made by hand"))), Ok(Format::Pptx)); + // Two end records, back to back. + coded(check("two.pptx", &[with_end(&l, end.clone()), end.clone()].concat()), "damaged"); + // One hidden in the other's comment, which a reader searching from + // the end of the file would try first. + let comment = [end.clone(), b"pad".to_vec()].concat(); + let e = coded(check("hidden.pptx", &with_end(&l, end_record(2, 2, size, at, &comment))), "damaged"); + assert!(e.contains("second end record"), "{e}"); + coded(check("hidden.pptx", &with_end(&l, end_record(2, 2, size, at, &end))), "damaged"); + // Anything after the comment. + coded(check("tail.pptx", &[with_end(&l, end), b"tail".to_vec()].concat()), "damaged"); + } + + #[test] + fn nothing_comes_before_the_zip() { + // Data before a zip whose offsets count from the zip's own start: + // the engine's zip reader would move every offset, the guard none. + let z = package(vec![], ""); + coded(check("junk.pptx", &[b"PK\x03\x04 prepended".to_vec(), z.clone()].concat()), "damaged"); + // Data before a zip whose offsets count from the file's start. + let junk = b"PK\x03\x04 prepended".to_vec(); + let l = layout_from(&head(), junk.len() as u32); + let at = (junk.len() + l.locals.len()) as u32; + let shifted = [junk, l.locals.clone(), l.central.clone(), end_record(2, 2, l.central.len() as u32, at, b"")].concat(); + let e = coded(check("before.pptx", &shifted), "damaged"); + assert!(e.contains("data before its first entry"), "{e}"); + // Data that isn't even a zip's start makes no zip. + coded(check("exe.pptx", &[b"MZ\x90\0".to_vec(), z].concat()), "unsupported"); + } + + #[test] + fn extra_fields_read_as_the_engines_zip_reader_reads_them() { + let with = |extra: Vec| package(vec![Part { extra, ..Part::stored("ppt/slides/slide1.xml", b"") }], ""); + let ntfs = |tag: u16, size: u16| field(0x000a, &[vec![0u8; 4], tag.to_le_bytes().to_vec(), size.to_le_bytes().to_vec(), vec![0u8; 24]].concat()); + let times = |flags: u8, len: usize| field(0x5455, &[vec![flags], vec![0u8; len - 1]].concat()); + // The shapes that reader takes, and a field it skips, pass. + for extra in [ntfs(1, 24), times(1, 5), times(3, 9), times(7, 5), field(0xCAFE, b"ok")] { + assert_eq!(check("t.pptx", &with(extra)), Ok(Format::Pptx)); + } + // The ones it refuses, the guard refuses first. + for extra in [ntfs(2, 24), ntfs(1, 16), times(1, 9), times(0x09, 5), field(0x9901, &[2, 0, 0x41, 0x45, 3, 8, 0]), [field(0xCAFE, b"ok"), vec![1, 2]].concat()] { + coded(check("t.pptx", &with(extra)), "damaged"); + } + // Info-ZIP's Unicode path would rename the entry for the engine. + let e = coded(check("t.pptx", &with(field(0x7075, &[1, 0, 0, 0, 0, b'x']))), "damaged"); + assert!(e.contains("Unicode"), "{e}"); + coded(check("t.pptx", &with(field(0x6375, &[1, 0, 0, 0, 0, b'x']))), "damaged"); + // A field running past the extra field. + coded(check("t.pptx", &with([0xCAFEu16.to_le_bytes(), 9u16.to_le_bytes()].concat())), "damaged"); + // ZIP64: a field of 24 bytes gives all three values over the 32-bit + // ones, to the engine's reader and so to the guard. Here it moves an + // innocent slide onto a movie no header names, which the guard then + // finds as that reader would. + let movie = b"\0\0\0\x18ftypisom-a-movie".to_vec(); + let n = movie.len() as u64; + let moved = field(0x0001, &[n.to_le_bytes(), n.to_le_bytes(), 0u64.to_le_bytes()].concat()); + let parts = vec![ + Part { hidden: true, ..Part::stored("hidden.bin", &movie) }, + Part::deflated("[Content_Types].xml", b""), + Part::deflated("ppt/presentation.xml", b""), + Part { extra: moved, ..Part::stored("ppt/slides/slide1.xml", b"") }, + ]; + let e = coded(check("moved.pptx", &zip(&parts)), "held"); + assert!(e.contains("video or audio") && e.contains("ppt/slides/slide1.xml"), "{e}"); + // And a ZIP64 field too short for what it must give. + let short = Part { declared: Some(0xFFFF_FFFF), extra: field(0x0001, &[0u8; 4]), ..Part::stored("ppt/x.xml", b"x") }; + coded(check("short.pptx", &package(vec![short], "")), "damaged"); + } + + #[test] + fn what_the_engines_zip_reader_sees_differently_is_refused() { + // "é" in UTF-8 (flagged) and in code page 437 (not flagged): one name + // to the engine's zip reader, which keeps the second's data under it, + // and two to the guard. + let utf8 = Part { flags: 1 << 11, ..Part::stored("ppt/é.xml", b"") }; + let cp437 = Part { raw_name: Some(b"ppt/\x82.xml".to_vec()), ..Part::stored("ppt/cp437.xml", b"") }; + let e = coded(check("twins.pptx", &package(vec![utf8, cp437], "")), "damaged"); + assert!(e.contains("reads the presentation differently") && e.contains("3 entries, not 4"), "{e}"); + // The same name twice. + coded(check("twice.pptx", &package(vec![Part::stored("ppt/a.xml", b"1"), Part::stored("ppt/a.xml", b"2")], "")), "damaged"); + } + + /// The guard's reader of record is the engine's own: the deck service + /// links the very `zip` crate deckcraft's package and native readers do. + #[test] + fn the_guard_reads_alike_with_the_engines_own_zip_crate() { + let lock = include_str!("../../../../Cargo.lock"); + let zip_of = |package: &str| -> String { + let block = lock.split("[[package]]").find(|b| b.contains(&format!("name = \"{package}\"\n"))).unwrap_or_else(|| panic!("{package} is not in Cargo.lock")); + let deps = block.split("dependencies = [").nth(1).unwrap_or_else(|| panic!("{package} has no dependencies")); + deps.lines().map(|l| l.trim().trim_matches(|c| c == '"' || c == ',')).find(|d| *d == "zip" || d.starts_with("zip ")).unwrap_or_else(|| panic!("{package} has no zip")).to_string() + }; + let ours = zip_of("octosense-deck-service"); + assert_eq!(ours, zip_of("deckcraft-pptx")); + assert_eq!(ours, zip_of("deckcraft-format")); + } + + #[test] + fn the_streaming_scan_finds_a_needle_across_chunks() { + const NEEDLES: &[&[u8]] = &[b"\"contentType\":\"audio/"]; + let mut scan = Scan::new(NEEDLES); + scan.feed(b"{\"media\":[{\"content"); + assert!(!scan.found); + scan.feed(b"Type\":\"audio/wav\"}]}"); + assert!(scan.found); + let mut scan = Scan::new(NEEDLES); + scan.feed(b"\"contentType\":\"image/png\""); + assert!(!scan.found); + } +} diff --git a/apps/deck/host-service/src/lib.rs b/apps/deck/host-service/src/lib.rs index a06c0f44..fcc88547 100644 --- a/apps/deck/host-service/src/lib.rs +++ b/apps/deck/host-service/src/lib.rs @@ -1,18 +1,36 @@ //! `octosense-deck-service` — the `deck` host service (ADR 0013). //! -//! deckcraft's presentation engine behind typed `deck.*` methods. Every -//! call is a fresh, stateless engine session. The service does all file -//! I/O itself and feeds the engine bytes, so the engine never touches a -//! path, and a link written inside a presentation (an external picture or -//! media relationship) is kept as a string and never followed. Everything -//! the service reads or writes lives in the call's area (ADR 0013, -//! 2026-10-08): the caller's own folder from the shell's resolver +//! deckcraft's presentation engine behind typed `deck.*` methods, in two +//! shapes: +//! +//! - **Files** (v1, and the system agent's two tools): a call that names a +//! `path` is a fresh, stateless engine session over the file (below). +//! - **Open documents** (Quick Deck v2, `apps/quickdeck/design/SERVICE.md`): +//! `deck.open` and `deck.create` keep the engine's session for a deck, its +//! edits and undo history, between calls under a handle that later calls +//! name as `doc`; `close`, `state`, `save`, `slides`, `slide`, `hit`, +//! `view`, `layouts`, `text`, `format_state`, `slides_op`, `notes`, +//! `transition`, `insert`, `set_text`, `format`, `shape`, `picture`, +//! `table`, `chart`, `design`, `undo`, `redo`, `show` and `export` work on +//! it, and `themes`, `fonts`, `shapes` and `cover` need none. Who holds an +//! open document, on which thread, and what releases it: [`docs`]. How a +//! method's engine commands reach it, through the same door as `deck.run`, +//! as one undo step: [`gate`]. +//! +//! The service does all file I/O itself and feeds the engine bytes, so the +//! engine never touches a path, and a link written inside a presentation +//! (an external picture or media relationship) is kept as a string and never +//! followed. Before the engine reads a byte of a file, on every path that +//! opens one, the service's own import guard checks it ([`guard`], #448). +//! Everything the service reads or writes lives in the call's area (ADR +//! 0013, 2026-10-08): the caller's own folder from the shell's resolver //! ([`set_area_resolver`]), or without one the legacy `/deck`. A //! write that may not replace (an agent's) only creates new files, within //! the area's quota ([`Area::write`]). Everything is JSON at the boundary; -//! the engine's types never cross it. +//! the engine's types never cross it. The open-document methods, `cover` +//! and the guard answer errors with stable codes ([`codes`]). //! -//! Methods (all under the `deck` family; paths relative to the call's area): +//! File methods (all under the `deck` family; paths relative to the call's area): //! - `info {path}` → the deck inspected: slides with titles, layouts, //! shape counts, sections, theme (a `.pptx`, `.deckcraft` or outline text) //! - `text {path}` → `{outline, slides}` — titles unindented, bullets @@ -45,7 +63,21 @@ /// The system agent's skill for this engine (ADR 0013). pub mod skill; +mod args; +mod cache; +mod codes; +mod covers; +mod docs; +mod gate; +mod guard; +mod objects; +mod pictures; +mod reading; +mod slides; +mod writing; + use std::collections::HashSet; +use std::panic::{catch_unwind, AssertUnwindSafe}; use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, OnceLock}; @@ -53,10 +85,12 @@ use deckcraft_engine::cmd::file as engine_file; use deckcraft_engine::Session; use deckcraft_model::{Presentation, Shape, ShapeKind, TextBody}; use octosense_appstore::services::{register_host_service, HostService, Replier, ServiceCall, ServiceHost}; -use octosense_engine_area::door::{Door, FileRead, Held as HeldBack, Limit, Measure, Reviewed}; +use octosense_engine_area::door::{Door, FileRead, Held as HeldBack, Limit, Measure, Reviewed, Unread}; use octosense_engine_area::{Area, Slot}; use serde_json::{json, Value as Json}; +use codes::{fail, invalid, Code}; + /// The longest preview edge `render` produces. const MAX_RENDER_SIDE: u64 = 4096; /// The largest file the service reads or writes (bytes). (What one `run` @@ -156,6 +190,20 @@ static HELD_BACK: &[HeldBack] = &[ HeldBack { id: "file.openBytes", why: ZIP }, ]; +/// The two `file` commands an app's own method runs without their file +/// access, which `deck.run` still refuses ([`Unread`]): `shape.fill` +/// (`cmd/shape.rs` 259-283) and `design.background` (`cmd/design.rs` +/// 151-184) read a file only when given a `picture` that is not a string, +/// through `insert::media_bytes` and its `path`. The service builds their +/// parameters itself (`deck.shape {op: "fill"}`, `deck.design +/// {background}`: a colour, a gradient, none or reset, and `transparency`, +/// `all` or `index`), so a method's call never names `picture` or `path`, +/// and the door refuses one that would. +static UNREAD: &[Unread] = &[ + Unread { id: "shape.fill", reads_through: &["picture", "path"] }, + Unread { id: "design.background", reads_through: &["picture", "path"] }, +]; + /// `insert.table {rows, cols}`: rows × cols cells, each a text body every /// render draws (unclipped). The engine's own clamp, 75 × 75 = 5,625 cells: /// inserting one takes ~5 ms, rendering it ~2 ms and its PDF page ~0.2 s at @@ -356,9 +404,11 @@ mod warm; pub use warm::warm; /// Register the `deck` service with App Hub's host-service registry, and -/// start paying the engine's first-call cost on a thread of its own -/// ([`warm`]). +/// its listener for closing isolates: the open documents an isolate holds go +/// when it closes ([`docs`]). Then start paying the engine's first-call cost +/// on a thread of its own ([`warm`]). pub fn register() { + octosense_appstore::services::on_isolate_closed(docs::isolate_closed); register_host_service(Box::new(DeckService)); warm(); } @@ -383,29 +433,88 @@ impl HostService for DeckService { } fn call(&mut self, call: ServiceCall, reply: Replier, _host: &mut dyn ServiceHost) { - reply.send(serve(&AREAS, &call)); + // App Hub calls this inline, holding its registry's lock: a panic + // in the engine must not unwind through it (and poison every + // service's registry), so it becomes this call's error. + let isolate = reply.isolate_key(); + let answer = catch_unwind(AssertUnwindSafe(|| serve_from(&AREAS, &call, isolate))) + .unwrap_or_else(|_| Err(fail(Code::Damaged, "the deck engine failed unexpectedly"))); + reply.send(answer); } } -/// One call, in the area `areas` gives it. +/// One call: where it works, who made it, and from which isolate. +pub(crate) struct Ctx<'a> { + pub area: &'a Area, + pub app: &'a str, + /// The requesting isolate's heap key (App Hub's `Replier::isolate_key`; + /// a key no isolate has for an agent's or a component's call): what a + /// document it opens is bound to ([`docs`]). + pub isolate: usize, +} + +/// One call with no isolate behind it, as an agent's or a test's. +#[cfg(test)] fn serve(areas: &Slot, call: &ServiceCall) -> Result { + serve_from(areas, call, 0) +} + +/// One call, from the isolate whose heap key is `isolate`, in the area +/// `areas` gives it. +fn serve_from(areas: &Slot, call: &ServiceCall, isolate: usize) -> Result { if !may_call(&call.app_id) { - return Err("The deck service serves system apps only.".into()); - } - let area = areas.area(call, "deck").map_err(|e| format!("deck: {e}"))?; - dispatch_in(call.method(), &call.args, &area) - .map(|answer| area.relative_json(answer)) - .map_err(|error| area.relative_text(&error)) + return Err(invalid("the deck service serves system apps only")); + } + // Documents whose isolate closed during a call, or whose caller's + // storage scope changed or went, go first, on every call ([`docs`]). + docs::sweep(); + let area = areas.area(call, "deck").map_err(|e| invalid(format!("this call has no folder to work in: {e}")))?; + let cx = Ctx { area: &area, app: &call.app_id, isolate }; + // The engine's fonts load on one thread at a time ([`warm`]). + warm::ready(); + dispatch_in(call.method(), &call.args, &cx).map(|answer| area.relative_json(answer)).map_err(|error| area.relative_text(&error)) } -fn dispatch_in(method: &str, args: &Json, area: &Area) -> Result { +fn dispatch_in(method: &str, args: &Json, cx: &Ctx) -> Result { + let area = cx.area; match method { "info" => info(args, area), + "text" if args.get("doc").is_some_and(|d| !d.is_null()) => reading::text(args, cx), "text" => text(args, area), "render" => render(args, area), "new" => new_deck(args, area), "convert" => convert(args, area), "run" => run(args, area), + "open" => docs::open(args, cx), + "create" => docs::create(args, cx), + "close" => docs::close(args, cx), + "state" => docs::state(args, cx), + "save" => writing::save(args, cx), + "slides" => reading::slides(args, cx), + "slide" => reading::slide(args, cx), + "hit" => reading::hit(args, cx), + "view" => reading::view(args, cx), + "layouts" => reading::layouts(args, cx), + "themes" => reading::themes(args, cx), + "fonts" => reading::fonts(args, cx), + "shapes" => reading::shapes(args, cx), + "format_state" => reading::format_state(args, cx), + "cover" => covers::cover(args, cx), + "slides_op" => slides::slides_op(args, cx), + "notes" => slides::notes(args, cx), + "transition" => slides::transition(args, cx), + "insert" => objects::insert(args, cx), + "set_text" => objects::set_text(args, cx), + "format" => objects::format(args, cx), + "shape" => objects::shape(args, cx), + "picture" => objects::picture(args, cx), + "table" => objects::table(args, cx), + "chart" => objects::chart(args, cx), + "design" => slides::design(args, cx), + "undo" => slides::history(args, cx, false), + "redo" => slides::history(args, cx, true), + "show" => reading::show(args, cx), + "export" => writing::export(args, cx), other => Err(format!("deck.{other} is not a method of the deck service")), } } @@ -416,7 +525,143 @@ fn dispatch_in(method: &str, args: &Json, area: &Area) -> Result { fn dispatch(method: &str, args: &Json, host_dir: &Path) -> Result { let area = Area::legacy(host_dir, "deck"); std::fs::create_dir_all(&area.root).map_err(|e| format!("deck: {e}"))?; - dispatch_in(method, args, &area) + dispatch_in(method, args, &Ctx { area: &area, app: "os.fixture", isolate: 0 }) +} + +/// `rel` as the service names a path in an answer: its normal components +/// joined with `/`. +pub(crate) fn normal(rel: &str) -> String { + let parts: Vec = Path::new(rel) + .components() + .filter_map(|c| match c { + Component::Normal(p) => Some(p.to_string_lossy().into_owned()), + _ => None, + }) + .collect(); + parts.join("/") +} + +/// The open-document methods' containment (coded errors): a path strictly +/// inside the call's area, relative, no `..`, no absolute or prefix +/// component, its resolved deepest existing ancestor under the area even +/// through symlinks. +pub(crate) fn inside(area: &Area, rel: &str, key: &str, m: &str) -> Result { + if rel.is_empty() { + return Err(invalid(format!("{m} needs `{key}`"))); + } + let outside = || invalid(format!("{m}: `{key}` stays inside this app's storage")); + let rel_path = Path::new(rel); + if rel_path.is_absolute() || rel_path.components().any(|c| !matches!(c, Component::Normal(_))) { + return Err(outside()); + } + let joined = area.root.join(rel_path); + let check_root = area.root.canonicalize().map_err(|e| invalid(format!("{m}: this call's folder: {e}")))?; + let deepest = { + let mut p = joined.clone(); + while !p.exists() { + match p.parent() { + Some(parent) => p = parent.to_path_buf(), + None => break, + } + } + p + }; + let resolved = deepest.canonicalize().map_err(|_| outside())?; + if !resolved.starts_with(&check_root) { + return Err(outside()); + } + Ok(joined) +} + +/// A new file the call may write at `rel`, checked before any work: inside +/// the area, and nothing there (never replaced). +pub(crate) fn new_file(area: &Area, rel: &str, key: &str, m: &str) -> Result { + let path = inside(area, rel, key, m)?; + Area::new(area.root.clone(), area.room(), false).check(&path, 0).map_err(codes::area)?; + Ok(path) +} + +/// A file the call reads at `rel`: inside the area, there, a file, and at +/// most [`MAX_DECK_BYTES`]. Its path and length. +pub(crate) fn input(area: &Area, rel: &str, key: &str, m: &str) -> Result<(PathBuf, u64), String> { + let path = inside(area, rel, key, m)?; + let meta = match std::fs::metadata(&path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(fail(Code::NotFound, format!("{rel} is not in this app's storage"))), + Err(e) => return Err(invalid(format!("{rel}: {e}"))), + }; + if !meta.is_file() { + return Err(invalid(format!("{rel} is not a file"))); + } + if meta.len() > MAX_DECK_BYTES { + return Err(fail(Code::TooLarge, format!("{rel} is larger than the {} MB the deck service opens", MAX_DECK_BYTES >> 20))); + } + Ok((path, meta.len())) +} + +#[cfg(test)] +thread_local! { + /// How many files this thread's calls handed the engine to read (tests: + /// a refusal of the import guard comes before any). + static ENGINE_READS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// How many files this thread's calls handed the engine (tests). +#[cfg(test)] +pub(crate) fn engine_reads() -> u32 { + ENGINE_READS.with(std::cell::Cell::get) +} + +/// The one place the engine reads a file's bytes, after the import guard +/// passed them ([`guard::check`]), with the reader the guard's verdict +/// names, not the engine's own sniffs: `deckcraft_pptx::sniff` looks for +/// `[Content_Types].xml` in a package's first 4 KiB only, and LibreOffice +/// writes that part far later, so `open_presentation` refuses every +/// package LibreOffice writes; the guard finds it in the directory it +/// verified. +/// +/// Each branch is a step-for-step copy of what deckcraft's +/// `cmd::file::open_presentation` does after its sniff +/// (`crates/engine/src/cmd/file.rs` lines 58-78 at the pinned d0e57d7e; +/// re-check them on every repin): a native deck through +/// `deckcraft_format::load` (59-61), an Office package through +/// `deckcraft_pptx::import` and then `deckcraft_format::repair` (62-66), +/// outline text through `open_presentation` itself (67-77), whose sniffs +/// pass over bytes that are no zip. `doc_tests::the_guards_reader_reads_ +/// as_open_presentation` compares the two on every format, so a step the +/// pinned function grows fails there. +fn engine_read(name: &str, bytes: &[u8], format: guard::Format) -> Result { + use deckcraft_engine::EngineError; + #[cfg(test)] + ENGINE_READS.with(|n| n.set(n.get() + 1)); + match format { + guard::Format::Native => deckcraft_format::load(bytes).map_err(|e| EngineError::Other(e.to_string())), + guard::Format::Pptx => { + let mut p = deckcraft_pptx::import(bytes).map_err(|e| EngineError::Other(e.to_string()))?; + deckcraft_format::repair(&mut p); + Ok(p) + } + guard::Format::Outline => engine_file::open_presentation(name, bytes), + } +} + +/// Read the deck at `rel` for an open-document method or a cover: checked +/// ([`input`]), read by the service, passed by the import guard, then read +/// by the engine. Its normal path, the deck, and how the engine read it. +pub(crate) fn read_guarded(area: &Area, rel: &str, key: &str, m: &str) -> Result<(String, Presentation, guard::Format), String> { + let (path, _) = input(area, rel, key, m)?; + let bytes = std::fs::read(&path).map_err(|e| invalid(format!("{rel}: {e}")))?; + let name = normal(rel); + let format = guard::check(&name, &bytes)?; + let deck = engine_read(&name, &bytes, format).map_err(|e| { + let words = e.to_string(); + if words.contains("not a presentation DeckCraft can read") { + fail(Code::Unsupported, "this is not a presentation the deck engine reads (.pptx, .deckcraft, or outline .txt or .md)") + } else { + fail(Code::Damaged, format!("the deck engine couldn't read it: {words}")) + } + })?; + Ok((name, deck, format)) } /// A path strictly inside the call's area: relative, no `..`, no absolute @@ -461,7 +706,8 @@ fn out_path(area: &Area, rel: &str, method: &str) -> Result { /// Read `args.path` from the call's area and seat it in a fresh engine /// session (the engine gets bytes, never a path). Reads `.pptx`, -/// `.deckcraft` and outline `.txt`/`.md`, by content. +/// `.deckcraft` and outline `.txt`/`.md`, by content, once the import guard +/// passed the bytes ([`guard`]; its refusal carries its code). fn read_deck(args: &Json, area: &Area, method: &str) -> Result<(Session, String), String> { let rel = args["path"].as_str().unwrap_or("").to_string(); let path = contained(area, &rel, method, "path")?; @@ -470,7 +716,8 @@ fn read_deck(args: &Json, area: &Area, method: &str) -> Result<(Session, String) return Err(format!("deck.{method}: the file is larger than the service reads")); } let bytes = std::fs::read(&path).map_err(|e| format!("deck.{method}: {rel}: {e}"))?; - let doc = engine_file::open_presentation(&rel, &bytes).map_err(|e| format!("deck.{method}: {e}"))?; + let format = guard::check(&rel, &bytes)?; + let doc = engine_read(&rel, &bytes, format).map_err(|e| format!("deck.{method}: {e}"))?; let mut s = Session::new(); engine_file::add_opened(&mut s, &rel, None, doc); Ok((s, rel)) @@ -672,7 +919,7 @@ fn run(args: &Json, area: &Area) -> Result { /// shapes on slides, masters and layouts, inside groups too; table cells; /// chart data; text in shapes, cells and notes; media bytes; and the first /// shape or text whose geometry the renderer could not afford. -#[derive(Clone, Debug, Default)] +#[derive(Clone, Debug, Default, PartialEq)] struct Held { slides: usize, shapes: usize, @@ -823,7 +1070,13 @@ impl RunBudget { return Err(format!("deck.run: the presentation holds {why}, so the door does not run commands on it")); } } - Ok(RunBudget { decks: decks(s), seen: deck_addresses(s), copied: Held::default(), raster: 0, result_bytes: 0 }) + Ok(RunBudget::fresh(s)) + } + + /// The budget of a call on `s` as it is, whose ceilings the caller + /// checked (an app's method, `crate::gate`). + fn fresh(s: &Session) -> RunBudget { + RunBudget { decks: decks(s), seen: deck_addresses(s), copied: Held::default(), raster: 0, result_bytes: 0 } } /// Before command `id`: what it will raster or fragment, where the gate @@ -848,7 +1101,13 @@ impl RunBudget { /// After command `id`: the session within the ceilings, its edits' /// copies within theirs, and its answer within the results budget. fn after(&mut self, s: &Session, id: &str, result: &Json) -> Result<(), String> { - if let Some(why) = Held::of(s).over() { + self.after_held(Held::of(s), s, id, result) + } + + /// [`RunBudget::after`], with what `s` holds counted by the caller + /// (`crate::gate`'s per-slide cache gives the same [`Held`]). + fn after_held(&mut self, held: Held, s: &Session, id: &str, result: &Json) -> Result<(), String> { + if let Some(why) = held.over() { return Err(format!("deck.run: `{id}` leaves the presentation with {why}")); } let now = decks(s); @@ -961,6 +1220,9 @@ fn json_len(v: &Json) -> u64 { count.0 } +#[cfg(test)] +mod doc_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/apps/deck/host-service/src/objects.rs b/apps/deck/host-service/src/objects.rs new file mode 100644 index 00000000..7bc1b564 --- /dev/null +++ b/apps/deck/host-service/src/objects.rs @@ -0,0 +1,1001 @@ +//! Objects on a slide (SERVICE.md "Objects"): insert, text, format, +//! arrange, pictures, tables and charts. Every method starts with +//! `slide.go {index}` (`safe`, the selection only: it makes the named slide +//! current and clears the selection), so the commands after it act on that +//! slide, and builds every other command's parameters itself from the +//! reviewed values `args` read. +//! +//! The safety review of the engine commands behind them, by their class in +//! `skill/safety.json`: +//! +//! - `insert.picture` and `picture.change` are `file`: their one file +//! access reads the picture their `path` names (`insert::media_bytes`). +//! They are the door's reviewed reads (`REVIEWED.file_reads`): the service +//! first checks the path stays inside the caller's storage, names a file +//! of at most 64 MiB, and is a picture the engine draws within +//! [`pictures::MAX_PIXELS`] (`crate::pictures`), then the door checks the +//! path again through links and hands the engine the file's resolved +//! path. They never get `data`. +//! - `shape.fill` is `file`, because a non-string `picture` makes it read a +//! `path`: it is not one of the door's reviewed reads, so the door refuses +//! it from `deck.run`. The app's `deck.shape {op: "fill"}` runs it with +//! `color`, `none` or `gradient` (and `transparency`) only, which the +//! service builds itself; it is one of the service's `UNREAD` commands, +//! which the door admits for a method only when the call names neither +//! `picture` nor `path` (`octosense_engine_area::door::Unread`). +//! - Everything else here is `safe` and passes the door: `slide.go`, +//! `edit.select`, `insert.textBox`, `shape.insert`, `insert.table` (its +//! rows and columns, and the `data` that fills its cells, which the +//! engine sets as each cell's text), `insert.chart`, `chart.type`, +//! `chart.data`, `chart.options`, `text.edit`, `text.select`, +//! `text.insert`, `text.delete`, `text.exit`, `text.set`, the `format.*` +//! commands, `shape.setBounds`, `shape.move`, `shape.rotate`, +//! `shape.flip`, the `arrange.*` commands, `edit.duplicate`, +//! `edit.delete`, `shape.line`, `shape.effects`, `shape.quickStyle`, +//! `shape.lock`, `shape.visible`, `shape.altText`, `picture.crop`, +//! `picture.adjust`, `picture.reset` and the `table.*` commands. The +//! door's limits apply to them as to `deck.run`: a table's 5,625 cells, a +//! chart's 10,000 data points, 1,000 line breaks a text, 100,000 points +//! of position or size, 400 % bullets. + +use deckcraft_model::{Shape, ShapeId, ShapeKind}; +use serde_json::{json, Value as Json}; + +use crate::args; +use crate::codes::{invalid, too_large}; +use crate::docs::{self, OpenDoc, MAX_BODY}; +use crate::gate::{self, Plan}; +use crate::pictures; +use crate::reading::{shapes_exist, MAX_SHAPES}; +use crate::Ctx; + +/// `slide.go` and one insert command (`insert.textBox`, `shape.insert`, +/// `insert.picture`, `insert.table`, `insert.chart`), and, for a table or a +/// chart, the one command that styles or titles it (`table.style`, +/// `chart.options`). +const INSERT: usize = 3; +/// The most cells one table insert fills (SERVICE.md decision 2): the rest +/// of a larger table's text goes cell by cell through `deck.set_text`. +pub(crate) const MAX_FILL_CELLS: usize = 1_024; +/// The longest text of one cell a fill sets. +const MAX_CELL_TEXT: usize = 10_000; +/// The most line breaks one text holds: the door's own limit on +/// `text.insert` (each break costs the engine more than the last). +pub(crate) const MAX_LINE_BREAKS: usize = 1_000; +/// The longest text `deck.set_text` sets. +const MAX_SET_TEXT: usize = 100_000; +/// The chart types `insert.chart` and `chart.type` take. +const CHART_TYPES: [&str; 27] = [ + "column", "bar", "line", "pie", "doughnut", "area", "scatter", "stackedColumn", "percentColumn", "stackedBar", "percentBar", "lineMarkers", + "stackedLine", "stackedArea", "bubble", "radar", "filledRadar", "waterfall", "funnel", "histogram", "pareto", "treemap", "sunburst", + "boxWhisker", "stock", "surface", "combo", +]; + +/// The position of slide `id` and, checked, the shapes `ids` on it. +fn on_slide(doc: &OpenDoc, id: u32, ids: &[u32]) -> Result { + let i = doc.slide_index(id)?; + shapes_exist(doc, i, ids)?; + Ok(i) +} + +/// A shape of slide `i`, cloned. +fn shape_of(doc: &OpenDoc, i: usize, id: u32) -> Result { + shapes_exist(doc, i, &[id])?; + Ok(doc.deck()?.slides[i].shape(ShapeId(id)).cloned().unwrap_or_default()) +} + +/// The line breaks in `text`. +fn line_breaks(text: &str) -> usize { + text.matches(['\n', '\r', '\u{b}']).count() +} + +fn check_breaks(text: &str, m: &str) -> Result<(), String> { + let n = line_breaks(text); + if n > MAX_LINE_BREAKS { + return Err(too_large(format!("{m}: the text has {n} line breaks, more than the {MAX_LINE_BREAKS} a text may have"))); + } + Ok(()) +} + +/// A box centred on the slide. +fn centred(doc: &OpenDoc, w: f64, h: f64) -> Result<[f64; 4], String> { + let size = doc.deck()?.slide_size; + Ok([(size.width - w) / 2.0, (size.height - h) / 2.0, w, h]) +} + +/// A chart's data as `insert.chart` and `chart.data` take it, from the +/// call's `categories` and `series`. +fn chart_data(a: &Json, m: &str, needed: bool) -> Result, String> { + let (cats, series) = (args::present(a, "categories"), args::present(a, "series")); + if cats.is_none() && series.is_none() && !needed { + return Ok(None); + } + let cats = cats.and_then(Json::as_array).ok_or_else(|| invalid(format!("{m} needs `categories`, a list of labels")))?; + let categories: Vec = cats + .iter() + .map(|c| match c { + Json::String(s) if s.chars().count() <= 256 => Ok(s.clone()), + Json::Number(n) => Ok(n.to_string()), + _ => Err(invalid(format!("{m}: `categories` holds labels of at most 256 characters"))), + }) + .collect::>()?; + let series = series.and_then(Json::as_array).ok_or_else(|| invalid(format!("{m} needs `series`, a list of {{name, values}}")))?; + if series.is_empty() { + return Err(invalid(format!("{m}: `series` holds at least one series"))); + } + let series: Vec = series + .iter() + .map(|s| { + args::only(s, &["name", "values"], m)?; + let name = args::opt_str(s, "name", m, 256)?.unwrap_or("Series"); + let values = args::present(s, "values").and_then(Json::as_array).ok_or_else(|| invalid(format!("{m}: each series has `values`, a list of numbers")))?; + let values: Vec = values + .iter() + .map(|v| match v { + Json::Null => Ok(Json::Null), + v => v.as_f64().filter(|f| f.is_finite()).map(|f| json!(f)).ok_or_else(|| invalid(format!("{m}: a series' values are numbers (or null)"))), + }) + .collect::>()?; + Ok(json!({ "name": name, "values": values })) + }) + .collect::>()?; + Ok(Some((json!(categories), json!(series)))) +} + +/// `deck.insert {doc, slide, kind, …}`. +pub(crate) fn insert(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.insert"; + let kind = args::need_str(a, "kind", M)?; + match kind { + "text" => { + args::only(a, &["doc", "slide", "kind", "rect", "text"], M)?; + let slide = args::id(a, "slide", M)?; + let rect = args::opt_rect(a, "rect", M)?; + let text = args::opt_str(a, "text", M, MAX_BODY)?.unwrap_or(""); + check_breaks(text, M)?; + docs::with_doc(a, cx, M, |doc| { + let i = on_slide(doc, slide, &[])?; + let rect = match rect { + Some(r) => r, + None => centred(doc, 216.0, 36.0)?, + }; + let cmds = vec![("slide.go", json!({ "index": i })), ("insert.textBox", json!({ "rect": rect, "text": text }))]; + inserted(doc, cx, cmds, "Insert text box", |_, _| Ok(json!({}))) + }) + } + "shape" => { + args::only(a, &["doc", "slide", "kind", "preset", "rect", "text"], M)?; + let slide = args::id(a, "slide", M)?; + let preset = args::need_str(a, "preset", M)?; + let info = deckcraft_engine::geom::preset::info(preset).ok_or_else(|| invalid(format!("{M}: `preset` is a name from deck.shapes, not {preset:?}")))?; + let rect = args::opt_rect(a, "rect", M)?; + let text = args::opt_str(a, "text", M, MAX_BODY)?; + if let Some(t) = text { + check_breaks(t, M)?; + } + docs::with_doc(a, cx, M, |doc| { + let i = on_slide(doc, slide, &[])?; + // The service centres a default box: a line is as wide and + // has no height. + let rect = match rect { + Some(r) => r, + None if deckcraft_engine::geom::preset::is_line_like(info.name) => centred(doc, 144.0, 0.0)?, + None => centred(doc, 144.0, 144.0)?, + }; + let mut params = json!({ "preset": info.name, "rect": rect }); + if let Some(t) = text { + params["text"] = json!(t); + } + let cmds = vec![("slide.go", json!({ "index": i })), ("shape.insert", params)]; + inserted(doc, cx, cmds, "Insert shape", |_, _| Ok(json!({}))) + }) + } + "picture" => { + args::only(a, &["doc", "slide", "kind", "path", "rect"], M)?; + let slide = args::id(a, "slide", M)?; + let path = args::need_str(a, "path", M)?; + let rect = args::opt_rect(a, "rect", M)?; + let rel = pictures::check(cx.area, path, "path", M)?; + docs::with_doc(a, cx, M, |doc| { + let i = on_slide(doc, slide, &[])?; + let mut params = json!({ "path": rel }); + if let Some(r) = rect { + params["rect"] = json!(r); + } + let cmds = vec![("slide.go", json!({ "index": i })), ("insert.picture", params)]; + inserted(doc, cx, cmds, "Insert picture", |doc, id| { + let deck = doc.deck()?; + let x = deck.slides[i].shape(ShapeId(id)).and_then(|s| s.xfrm).unwrap_or_default(); + Ok(json!({ "size": [round(x.w), round(x.h)] })) + }) + }) + } + "table" => { + args::only(a, &["doc", "slide", "kind", "rows", "cols", "rect", "cells", "style"], M)?; + let slide = args::id(a, "slide", M)?; + let rows = args::opt_int_in(a, "rows", M, 1, 75)?.ok_or_else(|| invalid(format!("{M} needs `rows`, 1 to 75")))?; + let cols = args::opt_int_in(a, "cols", M, 1, 75)?.ok_or_else(|| invalid(format!("{M} needs `cols`, 1 to 75")))?; + let rect = args::opt_rect(a, "rect", M)?; + let style = table_style(a, M)?; + let data = match args::present(a, "cells") { + Some(cells) => Some(fill_cells(cells, rows as usize, cols as usize, M)?), + None => None, + }; + docs::with_doc(a, cx, M, |doc| { + let i = on_slide(doc, slide, &[])?; + let mut params = json!({ "rows": rows, "cols": cols }); + if let Some(r) = rect { + params["rect"] = json!(r); + } + if let Some(d) = &data { + params["data"] = d.clone(); + } + let mut cmds = vec![("slide.go", json!({ "index": i })), ("insert.table", params)]; + // The new table is the selection: the style goes on it. + if let Some(s) = &style { + cmds.push(("table.style", json!({ "style": s }))); + } + inserted(doc, cx, cmds, "Insert table", |_, _| Ok(json!({}))) + }) + } + "chart" => { + args::only(a, &["doc", "slide", "kind", "type", "categories", "series", "rect", "title"], M)?; + let slide = args::id(a, "slide", M)?; + let chart = args::need_choice(a, "type", M, &CHART_TYPES)?; + let (categories, series) = chart_data(a, M, true)?.unwrap_or_default(); + let rect = args::opt_rect(a, "rect", M)?; + let title = args::opt_str(a, "title", M, 256)?; + docs::with_doc(a, cx, M, |doc| { + let i = on_slide(doc, slide, &[])?; + let mut params = json!({ "type": chart, "categories": categories, "series": series }); + if let Some(r) = rect { + params["rect"] = json!(r); + } + let mut cmds = vec![("slide.go", json!({ "index": i })), ("insert.chart", params)]; + // The new chart is the selection: the title goes on it. + if let Some(t) = title { + cmds.push(("chart.options", json!({ "title": t }))); + } + inserted(doc, cx, cmds, "Insert chart", |_, _| Ok(json!({}))) + }) + } + other => Err(invalid(format!("{M}: `kind` is text, shape, picture, table or chart, not {other:?}"))), + } +} + +fn round(v: f64) -> f64 { + (v * 100.0).round() / 100.0 +} + +/// Run an insert's `cmds` (the insert second): the common answer with the +/// new shape's `id` and what `extra` adds. +fn inserted(doc: &mut OpenDoc, cx: &Ctx, cmds: Vec<(&'static str, Json)>, label: &str, extra: impl FnOnce(&OpenDoc, u32) -> Result) -> Result { + let done = gate::change(doc, cx.area, Plan { method: "deck.insert", most: INSERT, cmds }, label)?; + let id = done.results.get(1).and_then(|r| r["id"].as_u64()).map(|n| n as u32).ok_or_else(|| invalid("deck.insert: the engine made no shape"))?; + let mut more = extra(doc, id)?; + more["id"] = json!(id); + doc.answer(&done.changed, more) +} + +/// A table style name, as the engine names them (`medium2-accent1`). +fn table_style(a: &Json, m: &str) -> Result, String> { + match args::opt_str(a, "style", m, 64)? { + Some(s) if !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') => Ok(Some(s.to_string())), + Some(_) => Err(invalid(format!("{m}: `style` is a table style name such as medium2-accent1"))), + None => Ok(None), + } +} + +/// A fill's cells as `insert.table`'s `data`: rows of text, within the table, +/// at most [`MAX_FILL_CELLS`] cells. +fn fill_cells(cells: &Json, rows: usize, cols: usize, m: &str) -> Result { + let wrong = || invalid(format!("{m}: `cells` is rows of cell text, within the table")); + let list = cells.as_array().ok_or_else(wrong)?; + if list.len() > rows { + return Err(wrong()); + } + let mut n = 0usize; + let mut data = Vec::with_capacity(list.len()); + for row in list { + let row = row.as_array().filter(|r| r.len() <= cols).ok_or_else(wrong)?; + let mut out = Vec::with_capacity(row.len()); + for cell in row { + let text = match cell { + Json::Null => String::new(), + Json::String(s) => s.clone(), + Json::Number(n) => n.to_string(), + _ => return Err(wrong()), + }; + if text.chars().count() > MAX_CELL_TEXT { + return Err(too_large(format!("{m}: a cell's text is at most {MAX_CELL_TEXT} characters"))); + } + check_breaks(&text, m)?; + n += 1; + out.push(json!(text)); + } + data.push(json!(out)); + } + if n > MAX_FILL_CELLS { + return Err(too_large(format!("{m}: `cells` fills {n} cells, more than the {MAX_FILL_CELLS} one call fills: set the rest with deck.set_text"))); + } + Ok(json!(data)) +} + +/// `slide.go`, `text.edit`, `text.select`, `text.insert`, `text.select` and +/// `text.delete` (the replaced span's first character, last), `text.exit`. +const SET_TEXT: usize = 7; + +/// A flat character offset in `body`'s text (`\n` between paragraphs) as +/// the engine's (paragraph, character). +fn position(lens: &[usize], mut offset: usize) -> [usize; 2] { + for (p, len) in lens.iter().enumerate() { + if offset <= *len { + return [p, offset]; + } + offset -= len + 1; + } + let last = lens.len().saturating_sub(1); + [last, lens.last().copied().unwrap_or(0)] +} + +/// `deck.set_text {doc, slide, id, text, cell?}` (SERVICE.md "deck.set_text +/// keeps formatting"): the service compares the box's text with `text` and +/// replaces only the span that changed, so the rest of the box keeps its +/// runs, and the new text takes the formatting of the span's first +/// character. A box with no text yet gets its text with `text.set`, unless +/// a paragraph would start with a tab, which `text.set` takes for an +/// outline level: the text stays exactly as typed either way. +pub(crate) fn set_text(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.set_text"; + args::only(a, &["doc", "slide", "id", "text", "cell"], M)?; + let slide = args::id(a, "slide", M)?; + let id = args::id(a, "id", M)?; + let text = args::need_text(a, "text", M, MAX_SET_TEXT)?.replace("\r\n", "\n").replace('\r', "\n"); + let cell = args::opt_cell(a, "cell", M)?; + check_breaks(&text, M)?; + docs::with_doc(a, cx, M, |doc| { + let i = doc.slide_index(slide)?; + let sh = shape_of(doc, i, id)?; + let body = match (&sh.kind, cell) { + (ShapeKind::Table(t), Some([r, c])) => t.cell(r, c).map(|x| x.text.clone()).ok_or_else(|| invalid(format!("{M}: table {id} has no cell [{r}, {c}]")))?, + (ShapeKind::Table(_), None) => return Err(invalid(format!("{M}: say which `cell` of table {id}"))), + (ShapeKind::Shape | ShapeKind::Connector { .. }, None) => sh.text.clone().unwrap_or_default(), + (_, Some(_)) => return Err(invalid(format!("{M}: shape {id} is not a table"))), + _ => return Err(invalid(format!("{M}: shape {id} doesn't hold text"))), + }; + let old: Vec = body.text().chars().collect(); + let new: Vec = text.chars().collect(); + if old == new { + return doc.answer(&[], json!({ "id": id })); + } + let mut cmds = vec![("slide.go", json!({ "index": i }))]; + let tabbed = text.split('\n').any(|p| p.starts_with('\t')); + if old.is_empty() && !tabbed { + let mut params = json!({ "id": id, "text": text }); + if let Some(c) = cell { + params["cell"] = json!(c); + } + cmds.push(("text.set", params)); + } else { + let prefix = old.iter().zip(&new).take_while(|(a, b)| a == b).count(); + let room = old.len().min(new.len()) - prefix; + let suffix = old.iter().rev().zip(new.iter().rev()).take(room).take_while(|(a, b)| a == b).count(); + let (start, end) = (prefix, old.len() - suffix); + let replacement: String = new[prefix..new.len() - suffix].iter().collect(); + let lens: Vec = body.paragraphs.iter().map(|p| p.char_len()).collect(); + let (s, e) = (position(&lens, start), position(&lens, end)); + let mut edit = json!({ "id": id, "at": s }); + if let Some(c) = cell { + edit["cell"] = json!(c); + } + let first_char_in_paragraph = start < end && s[1] < lens.get(s[0]).copied().unwrap_or(0); + if first_char_in_paragraph && !replacement.is_empty() { + // Replace all of the span but its first character, then that + // character: the new text is typed after it, in its format. + let after_first = [s[0], s[1] + 1]; + edit["at"] = json!(after_first); + cmds.push(("text.edit", edit)); + cmds.push(("text.select", json!({ "anchor": after_first, "caret": e }))); + cmds.push(("text.insert", json!({ "text": replacement }))); + cmds.push(("text.select", json!({ "anchor": s, "caret": after_first }))); + cmds.push(("text.delete", json!({}))); + } else { + cmds.push(("text.edit", edit)); + cmds.push(("text.select", json!({ "anchor": s, "caret": e }))); + if replacement.is_empty() { + cmds.push(("text.delete", json!({}))); + } else { + cmds.push(("text.insert", json!({ "text": replacement }))); + } + } + cmds.push(("text.exit", json!({}))); + } + let done = gate::change(doc, cx.area, Plan { method: M, most: SET_TEXT, cmds }, "Edit text")?; + doc.answer(&done.changed, json!({ "id": id })) + }) +} + +/// `slide.go`, `text.edit` and `text.select` (or `edit.select`), the 17 +/// `format.*` commands a call may set, up to 8 more `format.indent` or +/// `format.outdent` for `level`, and `text.exit`. +const FORMAT: usize = 3 + 17 + 8 + 1; +/// The alignments, the anchors and the autofit modes `deck.format` takes. +const ALIGNS: [&str; 4] = ["left", "center", "right", "justify"]; +const ANCHORS: [&str; 3] = ["top", "middle", "bottom"]; +const AUTOFITS: [&str; 3] = ["none", "shrink", "resize"]; + +/// `deck.format {doc, slide, ids, paragraphs?, …}`: run, paragraph and box +/// formatting of whole boxes, or of one box's paragraphs `[first, last]`. +/// `level` moves the paragraphs that many outline levels in (`format.indent`) +/// or, negative, out (`format.outdent`). +pub(crate) fn format(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.format"; + let keys = [ + "doc", "slide", "ids", "paragraphs", "font", "size", "bold", "italic", "underline", "strike", "color", "highlight", "align", "bullets", "numbering", "level", + "line_spacing", "space_before", "space_after", "anchor", "autofit", "columns", "margins", + ]; + args::only(a, &keys, M)?; + let slide = args::id(a, "slide", M)?; + let ids = args::ids(a, "ids", M, MAX_SHAPES)?; + let paragraphs = match args::present(a, "paragraphs") { + Some(v) => Some(args::cell_of(v).filter(|[f, l]| f <= l).ok_or_else(|| invalid(format!("{M}: `paragraphs` is [first, last], from 0")))?), + None => None, + }; + if paragraphs.is_some() && ids.len() != 1 { + return Err(invalid(format!("{M}: `paragraphs` formats one box: give one id"))); + } + let mut fmt: Vec<(&'static str, Json)> = Vec::new(); + if let Some(f) = args::opt_str(a, "font", M, 128)? { + if f.trim().is_empty() { + return Err(invalid(format!("{M}: `font` names a family"))); + } + fmt.push(("format.font", json!({ "family": f }))); + } + if let Some(s) = args::opt_num(a, "size", M, 1.0, 4000.0)? { + fmt.push(("format.size", json!({ "size": s }))); + } + for (key, id) in [("bold", "format.bold"), ("italic", "format.italic"), ("underline", "format.underline"), ("strike", "format.strikethrough")] { + if let Some(on) = args::opt_bool(a, key, M)? { + fmt.push((id, json!({ "on": on }))); + } + } + if let Some(c) = args::opt_color(a, "color", M)? { + fmt.push(("format.color", json!({ "color": c }))); + } + match args::present(a, "highlight") { + Some(Json::String(s)) if s == "none" => fmt.push(("format.highlight", json!({}))), + Some(_) => fmt.push(("format.highlight", json!({ "color": args::opt_color(a, "highlight", M)? }))), + None => {} + } + if let Some(al) = args::opt_choice(a, "align", M, &ALIGNS)? { + fmt.push(("format.align", json!({ "align": al }))); + } + if let Some(on) = args::opt_bool(a, "bullets", M)? { + fmt.push(("format.bullets", json!({ "on": on }))); + } + if let Some(on) = args::opt_bool(a, "numbering", M)? { + fmt.push(("format.numbering", json!({ "on": on }))); + } + if let Some(level) = args::opt_int_in(a, "level", M, -8, 8)? { + let id = if level > 0 { "format.indent" } else { "format.outdent" }; + for _ in 0..level.unsigned_abs() { + fmt.push((id, json!({}))); + } + } + if let Some(lines) = args::opt_num(a, "line_spacing", M, 0.5, 9.99)? { + fmt.push(("format.lineSpacing", json!({ "lines": lines }))); + } + let (before, after) = (args::opt_num(a, "space_before", M, 0.0, 1584.0)?, args::opt_num(a, "space_after", M, 0.0, 1584.0)?); + if before.is_some() || after.is_some() { + let mut p = json!({}); + if let Some(v) = before { + p["spaceBefore"] = json!(v); + } + if let Some(v) = after { + p["spaceAfter"] = json!(v); + } + fmt.push(("format.paragraph", p)); + } + if let Some(an) = args::opt_choice(a, "anchor", M, &ANCHORS)? { + fmt.push(("format.anchor", json!({ "anchor": an }))); + } + if let Some(mode) = args::opt_choice(a, "autofit", M, &AUTOFITS)? { + fmt.push(("format.autofit", json!({ "mode": mode }))); + } + if let Some(n) = args::opt_int_in(a, "columns", M, 1, 16)? { + fmt.push(("format.columns", json!({ "count": n }))); + } + if let Some(m) = args::present(a, "margins") { + args::only(m, &["left", "top", "right", "bottom"], "deck.format margins")?; + let mut p = json!({}); + for side in ["left", "top", "right", "bottom"] { + if let Some(v) = args::opt_num(m, side, "deck.format margins", 0.0, 1584.0)? { + p[side] = json!(v); + } + } + fmt.push(("format.margins", p)); + } + if fmt.is_empty() { + return Err(invalid(format!("{M}: name at least one formatting to change"))); + } + docs::with_doc(a, cx, M, |doc| { + let i = on_slide(doc, slide, &ids)?; + let mut cmds = vec![("slide.go", json!({ "index": i }))]; + match paragraphs { + Some([first, last]) => { + let sh = shape_of(doc, i, ids[0])?; + let lens: Vec = match (&sh.kind, &sh.text) { + (ShapeKind::Shape | ShapeKind::Connector { .. }, Some(t)) => t.paragraphs.iter().map(|p| p.char_len()).collect(), + _ => Vec::new(), + }; + if last >= lens.len() { + return Err(invalid(format!("{M}: shape {} has {} paragraphs, so `paragraphs` ends below {}", ids[0], lens.len(), lens.len()))); + } + cmds.push(("text.edit", json!({ "id": ids[0], "at": [first, 0] }))); + cmds.push(("text.select", json!({ "anchor": [first, 0], "caret": [last, lens[last]] }))); + cmds.extend(fmt); + cmds.push(("text.exit", json!({}))); + } + None => { + cmds.push(("edit.select", json!({ "ids": ids }))); + cmds.extend(fmt); + } + } + let done = gate::change(doc, cx.area, Plan { method: M, most: FORMAT, cmds }, "Format text")?; + doc.answer(&done.changed, json!({})) + }) +} + +/// `slide.go` and the op's one command. +const SHAPE: usize = 2; + +/// `deck.shape {doc, slide, ids, op, …}`. +pub(crate) fn shape(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.shape"; + let op = args::need_str(a, "op", M)?; + let slide = args::id(a, "slide", M)?; + let ids = args::ids(a, "ids", M, MAX_SHAPES)?; + let only = |keys: &[&str]| { + let mut all = vec!["doc", "slide", "ids", "op"]; + all.extend_from_slice(keys); + args::only(a, &all, M) + }; + let (id, mut params, label): (&'static str, Json, &str) = match op { + "bounds" => { + only(&["x", "y", "w", "h"])?; + if ids.len() != 1 { + return Err(invalid(format!("{M}: `bounds` sets one shape's box: give one id"))); + } + let n = |k: &str| args::need_num(a, k, M, -1.0e9, 1.0e9); + let (w, h) = (n("w")?, n("h")?); + if w < 0.0 || h < 0.0 { + return Err(invalid(format!("{M}: `w` and `h` are 0 or more"))); + } + ("shape.setBounds", json!({ "x": n("x")?, "y": n("y")?, "w": w, "h": h }), "Move and size") + } + "move" => { + only(&["dx", "dy"])?; + let dx = args::opt_num(a, "dx", M, -1.0e9, 1.0e9)?.unwrap_or(0.0); + let dy = args::opt_num(a, "dy", M, -1.0e9, 1.0e9)?.unwrap_or(0.0); + ("shape.move", json!({ "dx": dx, "dy": dy }), "Move") + } + "rotate" => { + only(&["deg"])?; + ("shape.rotate", json!({ "deg": args::need_num(a, "deg", M, -36_000.0, 36_000.0)? }), "Rotate") + } + "flip" => { + only(&["axis"])?; + ("shape.flip", json!({ "axis": args::need_choice(a, "axis", M, &["horizontal", "vertical"])? }), "Flip") + } + "order" => { + only(&["to"])?; + let id = match args::need_choice(a, "to", M, &["front", "back", "forward", "backward"])? { + "front" => "arrange.bringToFront", + "back" => "arrange.sendToBack", + "forward" => "arrange.bringForward", + _ => "arrange.sendBackward", + }; + (id, json!({}), "Arrange") + } + "align" => { + only(&["edge", "to"])?; + let mut p = json!({ "edge": args::need_choice(a, "edge", M, &["left", "center", "right", "top", "middle", "bottom"])? }); + if let Some(to) = args::opt_choice(a, "to", M, &["slide", "selection"])? { + p["to"] = json!(to); + } + ("arrange.align", p, "Align") + } + "distribute" => { + only(&["dir"])?; + ("arrange.distribute", json!({ "dir": args::need_choice(a, "dir", M, &["horizontal", "vertical"])? }), "Distribute") + } + "group" => { + only(&[])?; + if ids.len() < 2 { + return Err(invalid(format!("{M}: `group` takes two or more shapes"))); + } + ("arrange.group", json!({}), "Group") + } + "ungroup" => { + only(&[])?; + ("arrange.ungroup", json!({}), "Ungroup") + } + "duplicate" => { + only(&[])?; + ("edit.duplicate", json!({}), "Duplicate") + } + "delete" => { + only(&[])?; + ("edit.delete", json!({}), "Delete") + } + "fill" => { + only(&["color", "gradient", "none", "transparency"])?; + let mut p = json!({}); + let color = args::opt_color(a, "color", M)?; + let none = args::opt_bool(a, "none", M)?.unwrap_or(false); + let gradient = match args::present(a, "gradient") { + Some(g) => Some(gradient(g, M)?), + None => None, + }; + match (color, gradient, none) { + (Some(c), None, false) => p["color"] = json!(c), + (None, Some(g), false) => p["gradient"] = g, + (None, None, true) => p["none"] = json!(true), + (None, None, false) => {} + _ => return Err(invalid(format!("{M}: a fill is one of `color`, `gradient` or `none`"))), + } + if let Some(t) = args::opt_num(a, "transparency", M, 0.0, 1.0)? { + p["transparency"] = json!(t); + } + if p.as_object().is_some_and(|o| o.is_empty()) { + return Err(invalid(format!("{M}: give `color`, `gradient`, `none` or `transparency`"))); + } + ("shape.fill", p, "Fill") + } + "line" => { + only(&["color", "width", "dash", "none"])?; + let mut p = json!({}); + if let Some(c) = args::opt_color(a, "color", M)? { + p["color"] = json!(c); + } + if let Some(w) = args::opt_num(a, "width", M, 0.0, 1584.0)? { + p["width"] = json!(w); + } + if let Some(d) = args::opt_choice(a, "dash", M, &["solid", "dot", "dash", "lgDash", "dashDot", "sysDot", "sysDash"])? { + p["dash"] = json!(d); + } + if args::opt_bool(a, "none", M)? == Some(true) { + p["none"] = json!(true); + } + if p.as_object().is_some_and(|o| o.is_empty()) { + return Err(invalid(format!("{M}: give `color`, `width`, `dash` or `none`"))); + } + ("shape.line", p, "Outline") + } + "effects" => { + only(&["shadow", "glow", "soft_edges", "reflection"])?; + let mut p = json!({}); + if let Some(s) = args::opt_choice(a, "shadow", M, &["none", "outer", "inner", "perspective"])? { + p["shadow"] = json!(s); + } + match args::present(a, "glow") { + Some(Json::String(s)) if s == "none" => p["glow"] = json!("none"), + Some(_) => p["glow"] = json!(args::need_num(a, "glow", M, 0.0, 150.0)?), + None => {} + } + if let Some(v) = args::opt_num(a, "soft_edges", M, 0.0, 100.0)? { + p["softEdges"] = json!(v); + } + if let Some(r) = args::opt_choice(a, "reflection", M, &["none", "tight", "half", "full"])? { + p["reflection"] = json!(r); + } + if p.as_object().is_some_and(|o| o.is_empty()) { + return Err(invalid(format!("{M}: give `shadow`, `glow`, `soft_edges` or `reflection`"))); + } + ("shape.effects", p, "Effects") + } + "style" => { + only(&["index"])?; + ("shape.quickStyle", json!({ "index": args::opt_int_in(a, "index", M, 0, 41)?.ok_or_else(|| invalid(format!("{M} needs `index`, 0 to 41")))? }), "Quick style") + } + "lock" => { + only(&["locked"])?; + ("shape.lock", json!({ "locked": args::need_bool(a, "locked", M)? }), "Lock") + } + "hide" => { + only(&["hidden"])?; + ("shape.visible", json!({ "visible": !args::need_bool(a, "hidden", M)? }), "Show or hide") + } + other => { + return Err(invalid(format!( + "{M}: `op` is bounds, move, rotate, flip, order, align, distribute, group, ungroup, duplicate, delete, fill, line, effects, style, lock or hide, not {other:?}" + ))) + } + }; + params["ids"] = json!(ids); + docs::with_doc(a, cx, M, |doc| { + let i = on_slide(doc, slide, &ids)?; + let cmds = vec![("slide.go", json!({ "index": i })), (id, params)]; + let done = gate::change(doc, cx.area, Plan { method: M, most: SHAPE, cmds }, label)?; + let result = done.results.get(1).cloned().unwrap_or_default(); + let out_ids = match id { + "arrange.group" => json!([result["id"]]), + "arrange.ungroup" | "edit.duplicate" => result["ids"].clone(), + "edit.delete" => json!([]), + _ => json!(ids), + }; + doc.answer(&done.changed, json!({ "ids": out_ids })) + }) +} + +/// A gradient as `shape.fill` and `design.background` take it: 2 to 10 +/// stops `[position 0–1, colour]`, an angle, and linear or radial. +pub(crate) fn gradient(g: &Json, m: &str) -> Result { + args::only(g, &["stops", "angle", "kind"], m)?; + let wrong = || invalid(format!("{m}: a gradient's `stops` are 2 to 10 [position 0 to 1, colour]")); + let stops = g.get("stops").and_then(Json::as_array).filter(|s| (2..=10).contains(&s.len())).ok_or_else(wrong)?; + let stops: Vec = stops + .iter() + .map(|s| { + let pair = s.as_array().filter(|p| p.len() == 2).ok_or_else(wrong)?; + let pos = pair[0].as_f64().filter(|p| (0.0..=1.0).contains(p)).ok_or_else(wrong)?; + let color = args::color_value(&pair[1]).ok_or_else(wrong)?; + Ok(json!([pos, color])) + }) + .collect::>()?; + let mut out = json!({ "stops": stops }); + if let Some(angle) = args::opt_num(g, "angle", m, 0.0, 360.0)? { + out["angle"] = json!(angle); + } + if let Some(kind) = args::opt_choice(g, "kind", m, &["linear", "radial"])? { + out["kind"] = json!(kind); + } + Ok(out) +} + +/// `slide.go` and the op's one command. +const PICTURE: usize = 2; + +/// `deck.picture {doc, slide, id, op, …}`. +pub(crate) fn picture(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.picture"; + let op = args::need_str(a, "op", M)?; + let slide = args::id(a, "slide", M)?; + let id = args::id(a, "id", M)?; + let only = |keys: &[&str]| { + let mut all = vec!["doc", "slide", "id", "op"]; + all.extend_from_slice(keys); + args::only(a, &all, M) + }; + let (cmd, mut params, label): (&'static str, Json, &str) = match op { + "replace" => { + only(&["path"])?; + let rel = pictures::check(cx.area, args::need_str(a, "path", M)?, "path", M)?; + ("picture.change", json!({ "path": rel }), "Replace picture") + } + "crop" => { + only(&["left", "top", "right", "bottom"])?; + let mut p = json!({}); + for side in ["left", "top", "right", "bottom"] { + if let Some(v) = args::opt_num(a, side, M, 0.0, 0.95)? { + p[side] = json!(v); + } + } + ("picture.crop", p, "Crop picture") + } + "adjust" => { + only(&["brightness", "contrast", "saturation", "grayscale", "transparency"])?; + let mut p = json!({}); + for (key, lo, hi) in [("brightness", -1.0, 1.0), ("contrast", -1.0, 1.0), ("saturation", 0.0, 4.0), ("transparency", 0.0, 1.0)] { + if let Some(v) = args::opt_num(a, key, M, lo, hi)? { + p[key] = json!(v); + } + } + if let Some(g) = args::opt_bool(a, "grayscale", M)? { + p["grayscale"] = json!(g); + } + if p.as_object().is_some_and(|o| o.is_empty()) { + return Err(invalid(format!("{M}: give `brightness`, `contrast`, `saturation`, `grayscale` or `transparency`"))); + } + ("picture.adjust", p, "Adjust picture") + } + "reset" => { + only(&[])?; + ("picture.reset", json!({}), "Reset picture") + } + "alt_text" => { + only(&["text", "decorative"])?; + let mut p = json!({ "text": args::need_text(a, "text", M, 2_000)? }); + if let Some(d) = args::opt_bool(a, "decorative", M)? { + p["decorative"] = json!(d); + } + ("shape.altText", p, "Alt text") + } + other => return Err(invalid(format!("{M}: `op` is replace, crop, adjust, reset or alt_text, not {other:?}"))), + }; + params["ids"] = json!([id]); + docs::with_doc(a, cx, M, |doc| { + let i = doc.slide_index(slide)?; + if !matches!(shape_of(doc, i, id)?.kind, ShapeKind::Picture { .. }) { + return Err(invalid(format!("{M}: shape {id} is not a picture"))); + } + let cmds = vec![("slide.go", json!({ "index": i })), (cmd, params)]; + let done = gate::change(doc, cx.area, Plan { method: M, most: PICTURE, cmds }, label)?; + doc.answer(&done.changed, json!({})) + }) +} + +/// `slide.go` and the op's one command. +const TABLE: usize = 2; + +/// `deck.table {doc, slide, id, op, …}`: the table's `{rows, cols}` after. +pub(crate) fn table(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.table"; + let op = args::need_str(a, "op", M)?.to_string(); + let slide = args::id(a, "slide", M)?; + let id = args::id(a, "id", M)?; + let only = |keys: &[&str]| { + let mut all = vec!["doc", "slide", "id", "op"]; + all.extend_from_slice(keys); + args::only(a, &all, M) + }; + match op.as_str() { + "insert_row" => only(&["row", "below"])?, + "insert_col" => only(&["col", "right"])?, + "delete_row" | "row_height" => only(&["row", "height"])?, + "delete_col" | "col_width" => only(&["col", "width"])?, + "merge" => only(&["from", "to"])?, + "split" => only(&["cell"])?, + "style" => only(&["style"])?, + "options" => only(&["header_row", "total_row", "banded_rows", "first_column", "last_column", "banded_columns"])?, + "fill" => only(&["cells", "color"])?, + other => { + return Err(invalid(format!( + "{M}: `op` is insert_row, insert_col, delete_row, delete_col, merge, split, style, options, fill, col_width or row_height, not {other:?}" + ))) + } + } + docs::with_doc(a, cx, M, |doc| { + let i = doc.slide_index(slide)?; + let ShapeKind::Table(t) = shape_of(doc, i, id)?.kind else { return Err(invalid(format!("{M}: shape {id} is not a table"))) }; + let (rows, cols) = (t.n_rows(), t.n_cols()); + let row = |key: &str| -> Result { + let r = args::need_index(a, key, M)?; + if r >= rows { + return Err(invalid(format!("{M}: table {id} has {rows} rows, so `{key}` is below {rows}"))); + } + Ok(r) + }; + let col = |key: &str| -> Result { + let c = args::need_index(a, key, M)?; + if c >= cols { + return Err(invalid(format!("{M}: table {id} has {cols} columns, so `{key}` is below {cols}"))); + } + Ok(c) + }; + let cell = |key: &str| -> Result<[usize; 2], String> { + let c = args::opt_cell(a, key, M)?.ok_or_else(|| invalid(format!("{M} needs `{key}`, [row, column]")))?; + if c[0] >= rows || c[1] >= cols { + return Err(invalid(format!("{M}: table {id} is {rows} × {cols}, so `{key}` is within it"))); + } + Ok(c) + }; + let (cmd, params, label): (&'static str, Json, &str) = match op.as_str() { + "insert_row" => { + let below = args::opt_bool(a, "below", M)?.unwrap_or(true); + (if below { "table.insertRowBelow" } else { "table.insertRowAbove" }, json!({ "row": row("row")? }), "Insert row") + } + "insert_col" => { + let right = args::opt_bool(a, "right", M)?.unwrap_or(true); + (if right { "table.insertColumnRight" } else { "table.insertColumnLeft" }, json!({ "col": col("col")? }), "Insert column") + } + "delete_row" => ("table.deleteRow", json!({ "row": row("row")? }), "Delete row"), + "delete_col" => ("table.deleteColumn", json!({ "col": col("col")? }), "Delete column"), + "merge" => ("table.merge", json!({ "from": cell("from")?, "to": cell("to")? }), "Merge cells"), + "split" => ("table.split", json!({ "cell": cell("cell")? }), "Split cell"), + "style" => ("table.style", json!({ "style": table_style(a, M)?.ok_or_else(|| invalid(format!("{M} needs `style`")))? }), "Table style"), + "options" => { + let mut p = json!({}); + for (key, engine) in [ + ("header_row", "headerRow"), + ("total_row", "totalRow"), + ("banded_rows", "bandedRows"), + ("first_column", "firstColumn"), + ("last_column", "lastColumn"), + ("banded_columns", "bandedColumns"), + ] { + if let Some(v) = args::opt_bool(a, key, M)? { + p[engine] = json!(v); + } + } + if p.as_object().is_some_and(|o| o.is_empty()) { + return Err(invalid(format!("{M}: give at least one table option"))); + } + ("table.options", p, "Table options") + } + "fill" => { + let list = args::present(a, "cells").and_then(Json::as_array).ok_or_else(|| invalid(format!("{M} needs `cells`, a list of [row, column]")))?; + if list.is_empty() { + return Err(invalid(format!("{M}: `cells` names at least one cell"))); + } + let mut cells = Vec::with_capacity(list.len()); + for c in list { + let [r, k] = args::cell_of(c).ok_or_else(|| invalid(format!("{M}: `cells` holds [row, column]")))?; + if r >= rows || k >= cols { + return Err(invalid(format!("{M}: table {id} is {rows} × {cols}, so every cell is within it"))); + } + cells.push(json!([r, k])); + } + let mut p = json!({ "cells": cells }); + match args::present(a, "color") { + Some(Json::String(s)) if s == "none" => p["none"] = json!(true), + _ => p["color"] = json!(args::opt_color(a, "color", M)?.ok_or_else(|| invalid(format!("{M} needs `color`, or \"none\"")))?), + } + ("table.cellFill", p, "Cell fill") + } + "col_width" => ("table.columnWidth", json!({ "col": col("col")?, "width": args::need_num(a, "width", M, 4.0, 4000.0)? }), "Column width"), + _ => ("table.rowHeight", json!({ "row": row("row")?, "height": args::need_num(a, "height", M, 4.0, 4000.0)? }), "Row height"), + }; + let mut params = params; + params["id"] = json!(id); + let cmds = vec![("slide.go", json!({ "index": i })), (cmd, params)]; + let done = gate::change(doc, cx.area, Plan { method: M, most: TABLE, cmds }, label)?; + let ShapeKind::Table(t) = shape_of(doc, i, id)?.kind else { return Err(invalid(format!("{M}: shape {id} is not a table"))) }; + doc.answer(&done.changed, json!({ "rows": t.n_rows(), "cols": t.n_cols() })) + }) +} + +/// `slide.go`, then `chart.type`, `chart.data` and `chart.options`. +const CHART: usize = 4; + +/// `deck.chart {doc, slide, id, type?, categories?, series?, title?, legend?, +/// data_labels?, gridlines?, palette?}`. An empty `title` removes it, and +/// `legend: "none"` hides the legend. +pub(crate) fn chart(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.chart"; + args::only(a, &["doc", "slide", "id", "type", "categories", "series", "title", "legend", "data_labels", "gridlines", "palette"], M)?; + let slide = args::id(a, "slide", M)?; + let id = args::id(a, "id", M)?; + let mut cmds: Vec<(&'static str, Json)> = Vec::new(); + if let Some(t) = args::opt_choice(a, "type", M, &CHART_TYPES)? { + cmds.push(("chart.type", json!({ "type": t, "id": id }))); + } + if let Some((categories, series)) = chart_data(a, M, false)? { + cmds.push(("chart.data", json!({ "categories": categories, "series": series, "id": id }))); + } + let mut options = json!({ "id": id }); + if let Some(t) = args::opt_str(a, "title", M, 256)? { + options["title"] = if t.is_empty() { Json::Null } else { json!(t) }; + } + if let Some(l) = args::opt_choice(a, "legend", M, &["b", "t", "l", "r", "none"])? { + options["legend"] = if l == "none" { Json::Null } else { json!(l) }; + } + if let Some(v) = args::opt_bool(a, "data_labels", M)? { + options["dataLabels"] = json!(v); + } + if let Some(v) = args::opt_bool(a, "gridlines", M)? { + options["gridlines"] = json!(v); + } + if let Some(p) = args::opt_str(a, "palette", M, 64)? { + if p.is_empty() || !p.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') { + return Err(invalid(format!("{M}: `palette` is a palette name"))); + } + options["palette"] = json!(p); + } + if options.as_object().is_some_and(|o| o.len() > 1) { + cmds.push(("chart.options", options)); + } + if cmds.is_empty() { + return Err(invalid(format!("{M}: name at least one thing to change"))); + } + docs::with_doc(a, cx, M, |doc| { + let i = doc.slide_index(slide)?; + if !matches!(shape_of(doc, i, id)?.kind, ShapeKind::Chart(_)) { + return Err(invalid(format!("{M}: shape {id} is not a chart"))); + } + let mut all = vec![("slide.go", json!({ "index": i }))]; + all.extend(cmds); + let done = gate::change(doc, cx.area, Plan { method: M, most: CHART, cmds: all }, "Edit chart")?; + doc.answer(&done.changed, json!({})) + }) +} diff --git a/apps/deck/host-service/src/pictures.rs b/apps/deck/host-service/src/pictures.rs new file mode 100644 index 00000000..9be9148f --- /dev/null +++ b/apps/deck/host-service/src/pictures.rs @@ -0,0 +1,160 @@ +//! The service's own check of a picture `insert.picture` or +//! `picture.change` will read (their reviewed read, `REVIEWED.file_reads`), +//! before the door hands the engine the file's resolved path: +//! +//! - the path stays inside the caller's storage (and the door checks it +//! again, through links), names a file that is there, and is at most the +//! door's 64 MiB a call; +//! - the file is a picture the engine draws, by its first bytes: PNG, JPEG, +//! WebP, GIF, TIFF or BMP. Anything else is refused before the engine +//! sees it: an SVG (the engine has none), and a sound or a movie, which +//! the engine's `picture.change` would otherwise embed whatever its type; +//! - the picture is at most [`MAX_PIXELS`] by its own header: the engine +//! decodes a picture whole into memory on the UI thread, so a small file +//! naming a huge picture is refused here (`too_large:`), and so is one +//! whose header doesn't say its size (`invalid:`), which fails closed. + +use octosense_engine_area::door::MAX_READ_BYTES; +use octosense_engine_area::Area; + +use crate::codes::{fail, invalid, too_large, Code}; + +/// The most pixels a picture may have: a 48-megapixel phone photo fits. +pub(crate) const MAX_PIXELS: u64 = 50_000_000; + +/// The kind of picture `b` starts with. +pub(crate) fn kind(b: &[u8]) -> Option<&'static str> { + if b.starts_with(b"\x89PNG\r\n\x1a\n") { + Some("png") + } else if b.starts_with(&[0xFF, 0xD8, 0xFF]) { + Some("jpeg") + } else if b.starts_with(b"GIF87a") || b.starts_with(b"GIF89a") { + Some("gif") + } else if b.starts_with(b"RIFF") && b.get(8..12) == Some(b"WEBP") { + Some("webp") + } else if b.starts_with(b"BM") { + Some("bmp") + } else if b.starts_with(b"II*\0") || b.starts_with(b"MM\0*") { + Some("tiff") + } else { + None + } +} + +fn be16(b: &[u8], at: usize) -> Option { + Some(u16::from_be_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?) as u64) +} + +fn be32(b: &[u8], at: usize) -> Option { + Some(u32::from_be_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?) as u64) +} + +fn le16(b: &[u8], at: usize) -> Option { + Some(u16::from_le_bytes(b.get(at..at.checked_add(2)?)?.try_into().ok()?) as u64) +} + +fn le32(b: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(b.get(at..at.checked_add(4)?)?.try_into().ok()?) as u64) +} + +/// The width and height a picture's header names, if it reads. +pub(crate) fn dimensions(b: &[u8]) -> Option<(u64, u64)> { + match kind(b)? { + "png" => Some((be32(b, 16)?, be32(b, 20)?)), + "gif" => Some((le16(b, 6)?, le16(b, 8)?)), + "bmp" => match le32(b, 14)? { + 12 => Some((le16(b, 18)?, le16(b, 20)?)), + _ => { + let w = le32(b, 18)? as u32 as i32; + let h = le32(b, 22)? as u32 as i32; + Some((w.unsigned_abs() as u64, h.unsigned_abs() as u64)) + } + }, + "webp" => match b.get(12..16)? { + b"VP8 " => Some((le16(b, 26)? & 0x3FFF, le16(b, 28)? & 0x3FFF)), + b"VP8L" => { + let v = le32(b, 21)?; + Some(((v & 0x3FFF) + 1, ((v >> 14) & 0x3FFF) + 1)) + } + b"VP8X" => { + let n = |x: &[u8]| x[0] as u64 | (x[1] as u64) << 8 | (x[2] as u64) << 16; + Some((n(b.get(24..27)?) + 1, n(b.get(27..30)?) + 1)) + } + _ => None, + }, + "jpeg" => { + // The markers up to the first start-of-frame, each skipped by its + // own length. + let mut at = 2usize; + while at + 4 <= b.len() { + if b[at] != 0xFF { + return None; + } + let marker = b[at + 1]; + if marker == 0xFF { + at += 1; + continue; + } + if matches!(marker, 0x01 | 0xD0..=0xD7) { + at += 2; + continue; + } + let len = be16(b, at + 2)? as usize; + if matches!(marker, 0xC0..=0xCF) && !matches!(marker, 0xC4 | 0xC8 | 0xCC) { + return Some((be16(b, at + 7)?, be16(b, at + 5)?)); + } + at = at.checked_add(2 + len)?; + } + None + } + "tiff" => { + let little = b.starts_with(b"II"); + let u16_at = |at: usize| if little { le16(b, at) } else { be16(b, at) }; + let u32_at = |at: usize| if little { le32(b, at) } else { be32(b, at) }; + let ifd = u32_at(4)? as usize; + let n = u16_at(ifd)? as usize; + let (mut w, mut h) = (None, None); + for k in 0..n.min(512) { + let e = ifd + 2 + 12 * k; + let value = match u16_at(e + 2)? { + 3 => u16_at(e + 8)?, + 4 => u32_at(e + 8)?, + _ => continue, + }; + match u16_at(e)? { + 256 => w = Some(value), + 257 => h = Some(value), + _ => {} + } + } + Some((w?, h?)) + } + _ => None, + } +} + +/// Check the picture at `rel` (module doc): its storage-relative path, +/// normalized, for the reviewed read. +pub(crate) fn check(area: &Area, rel: &str, key: &str, m: &str) -> Result { + let path = crate::inside(area, rel, key, m)?; + let meta = match std::fs::metadata(&path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(fail(Code::NotFound, format!("{rel} is not in this app's storage"))), + Err(e) => return Err(invalid(format!("{rel}: {e}"))), + }; + if !meta.is_file() { + return Err(invalid(format!("{rel} is not a file"))); + } + if meta.len() > MAX_READ_BYTES { + return Err(too_large(format!("{rel} is larger than the {} MB a call reads", MAX_READ_BYTES >> 20))); + } + let bytes = std::fs::read(&path).map_err(|e| invalid(format!("{rel}: {e}")))?; + if kind(&bytes).is_none() { + return Err(invalid(format!("{rel} is not a picture the deck engine draws (PNG, JPEG, WebP, GIF, TIFF or BMP; it has no SVG)"))); + } + let (w, h) = dimensions(&bytes).ok_or_else(|| invalid(format!("{rel}: can't read the picture's size")))?; + if w.saturating_mul(h) > MAX_PIXELS { + return Err(too_large(format!("{rel} is {w} × {h} pixels, more than the {} megapixels a picture may have", MAX_PIXELS / 1_000_000))); + } + Ok(crate::normal(rel)) +} diff --git a/apps/deck/host-service/src/reading.rs b/apps/deck/host-service/src/reading.rs new file mode 100644 index 00000000..946f1262 --- /dev/null +++ b/apps/deck/host-service/src/reading.rs @@ -0,0 +1,572 @@ +//! Reading and drawing an open document (SERVICE.md "Reading and drawing", +//! "Present"), and the catalogs the inspector lists. +//! +//! The safety review of the engine commands behind them, all `safe` in +//! `skill/safety.json`: `document.inspect`, `slide.inspect`, `format.state`, +//! `design.themes` and `shape.presets` read the document (or the engine's +//! catalogs) and write nothing; `slide.go`, `edit.select`, `text.edit` (on a +//! shape that already holds text), `text.selectParagraph` and `text.exit` +//! change only the selection. Every one passes the gate (`crate::gate`), and +//! a read never changes the document. The functions they call beside the +//! registry are not commands and touch no file: `Session::hit_test`, +//! deckcraft-render's `render_slide` and `render_layout` (each one bounded +//! raster, written only into the service's own cache, [`cache::store`]), +//! deckcraft-anim's `show_order`, `ShowState::steps` and `Timeline::state`, +//! `slides_to_outline`, and the font database's `families`. + +use deckcraft_anim::{ShowState, Timeline}; +use deckcraft_engine::geom::{Point, Vec2}; +use deckcraft_engine::render::{self as render, ParaState, RenderOpts, ShapeState}; +use deckcraft_engine::tools::Hit; +use deckcraft_model::resolve::{self, Ctx}; +use deckcraft_model::style::Dash; +use deckcraft_model::text::{Align, Spacing}; +use deckcraft_model::{Fill, Presentation, Shape, ShapeId, ShapeKind, Slide}; +use serde_json::{json, Value as Json}; + +use crate::args; +use crate::cache; +use crate::codes::{fail, invalid, too_large, Code}; +use crate::docs::{self, OpenDoc, Rendered}; +use crate::gate::{self, HeldCache, Plan}; +use crate::Ctx as Call; + +/// A drawing's width in pixels (SERVICE.md: 48 to 3840). +pub(crate) const WIDTH: (i64, i64) = (48, 3840); +/// A drawing's longest side, and its most pixels. +pub(crate) const MAX_SIDE: u64 = 4096; +pub(crate) const MAX_PIXELS: u64 = 16_000_000; +/// A layout picture's default width. +pub(crate) const LAYOUT_WIDTH: i64 = 240; +/// The most layout pictures one call draws (a master's layouts). +pub(crate) const MAX_LAYOUTS: usize = 64; +/// The most a `notes` of `deck.slides` shows (the whole notes are +/// `deck.slide`'s): a deck's notes could otherwise pass App Hub's 4 MiB +/// answer. +pub(crate) const NOTES_PREVIEW: usize = 200; + +/// The pixels of slide `deck` drawn `width` wide: (scale, width, height), or +/// `too_large:` over the caps. +pub(crate) fn size_for(deck: &Presentation, width: u64, m: &str) -> Result<(f64, u64, u64), String> { + let (sw, sh) = (deck.slide_size.width.max(1.0), deck.slide_size.height.max(1.0)); + let scale = width as f64 / sw; + let height = (sh * scale).round().max(1.0) as u64; + if width.max(height) > MAX_SIDE || width * height > MAX_PIXELS { + return Err(too_large(format!("{m}: {width} × {height} pixels is past the {MAX_SIDE} pixels a side and {} megapixels of one drawing", MAX_PIXELS / 1_000_000))); + } + Ok((scale, width, height)) +} + +fn width(a: &Json, m: &str, default: i64) -> Result { + Ok(args::opt_int_in(a, "width", m, WIDTH.0, WIDTH.1)?.unwrap_or(default) as u64) +} + +/// A colour of `ctx` as `#RRGGBB`. +fn hex(ctx: &Ctx, color: &deckcraft_model::ColorRef, ph: Option) -> String { + format!("#{}", ctx.color(color, ph).hex()) +} + +/// The click builds of slide `i`: (its timeline, how many clicks, whether +/// its first step plays by itself as the slide appears). A show without +/// animation has none. +fn builds(deck: &Presentation, i: usize) -> (Timeline, usize, bool) { + if deck.show.without_animation { + return (Timeline::default(), 0, false); + } + let tl = ShowState::timeline(deck, i); + let auto = tl.step_is_auto(0); + let clicks = ShowState::steps(deck, i).saturating_sub(usize::from(auto)); + (tl, clicks, auto) +} + +/// What a slide shows after `step` click builds, each at its end state +/// (SERVICE.md "What Present shows"): the renderer's state of every shape. +fn show_state(tl: &Timeline, auto: bool, step: usize) -> impl Fn(ShapeId) -> ShapeState + '_ { + // The main step whose end shows, and the moment past its end (a build + // of no duration ends at once). + let main = (step + usize::from(auto)).checked_sub(1); + move |id: ShapeId| { + let at = |para: Option| match main { + None => tl.state(id, para, 0, 0.0), + Some(k) => tl.state(id, para, k, tl.step_duration(k) + 1.0), + }; + let a = at(None); + ShapeState { + visible: a.visible, + opacity: a.opacity, + offset: Vec2::new(a.offset_x, a.offset_y), + scale: (a.scale_x, a.scale_y), + rotate: a.rotate, + clip: a.clip, + tint: a.tint, + paras: tl + .paragraph_targets(id) + .into_iter() + .map(|p| { + let a = at(Some(p)); + (p, ParaState { visible: a.visible, opacity: a.opacity, offset: Vec2::new(a.offset_x, a.offset_y) }) + }) + .collect(), + } + } +} + +fn transition_json(slide: &Slide) -> Json { + match &slide.transition { + Some(t) => json!({ "kind": t.kind, "option": t.option, "duration": t.duration_ms, "advance_after": t.advance_after_ms.map(|ms| ms as f64 / 1000.0), "on_click": t.advance_on_click }), + None => Json::Null, + } +} + +/// The section a slide is in, by name. +fn section_of(deck: &Presentation, slide: &Slide) -> Json { + deck.section_of(slide.id).and_then(|i| deck.sections.get(i)).map_or(Json::Null, |s| json!(s.name)) +} + +/// `document.inspect {}`: one `safe` read. +const SLIDES: usize = 1; + +/// `deck.slides {doc}`. +pub(crate) fn slides(a: &Json, cx: &Call) -> Result { + const M: &str = "deck.slides"; + args::only(a, &["doc"], M)?; + docs::with_doc(a, cx, M, |doc| { + let inspected = gate::read(doc, cx.area, Plan { method: M, most: SLIDES, cmds: vec![("document.inspect", json!({}))] })?.remove(0); + let deck = doc.deck()?; + let list: Vec = deck + .slides + .iter() + .enumerate() + .map(|(i, s)| { + let row = &inspected["slides"][i]; + let notes = row["notes"].as_str().unwrap_or(""); + let notes = if notes.chars().count() > NOTES_PREVIEW { format!("{}…", notes.chars().take(NOTES_PREVIEW).collect::()) } else { notes.to_string() }; + json!({ + "id": s.id.0, + "index": i, + "title": row["title"], + "layout": s.layout.0, + "hidden": s.hidden, + "section": section_of(&deck, s), + "notes": notes, + "transition": row["transition"], + "builds": builds(&deck, i).1, + "comments": s.comments.len(), + }) + }) + .collect(); + Ok(json!({ "slides": list, "sections": docs::sections(&deck), "size": inspected["slideSize"] })) + }) +} + +/// `slide.inspect {index}`: one `safe` read. +const SLIDE: usize = 1; + +/// A shape as `deck.slide` lists it: `slide.inspect`'s summary, with its +/// paragraphs, outline, lock, alt text and picture from the model. +fn shape_json(ctx: Option<&Ctx>, summary: &Json, sh: &Shape) -> Json { + let mut out = json!({ + "id": sh.id.0, + "name": sh.name, + "kind": summary["kind"], + "box": summary["box"], + "rotation": summary["rotation"], + }); + for (from, to) in [("placeholder", "placeholder"), ("preset", "preset"), ("text", "text"), ("fill", "fill"), ("table", "table"), ("chart", "chart"), ("altText", "alt_text")] { + if !summary[from].is_null() { + out[to] = summary[from].clone(); + } + } + if let Some(t) = sh.text.as_ref().filter(|t| !t.paragraphs.is_empty()) { + out["paragraphs"] = json!(t.paragraphs.iter().map(|p| json!({ "text": p.text(), "level": p.level })).collect::>()); + } + if let Some(ctx) = ctx { + if !matches!(sh.kind, ShapeKind::Group { .. } | ShapeKind::Table(_) | ShapeKind::Chart(_)) { + let (line, ph) = resolve::line(ctx, sh); + let color = match &line.fill { + Some(Fill::Solid { color }) => json!(hex(ctx, color, ph)), + Some(Fill::None) | None => json!("none"), + Some(_) => json!("other"), + }; + let dash = match &line.dash { + Some(Dash::Preset { name }) => json!(name), + _ => json!("solid"), + }; + out["line"] = json!({ "color": color, "width": line.width, "dash": dash }); + } + } + if sh.hidden { + out["hidden"] = json!(true); + } + if sh.locked { + out["locked"] = json!(true); + } + if let ShapeKind::Picture { fill } = &sh.kind { + out["picture"] = json!({ + "crop": fill.crop, + "brightness": fill.adjust.brightness, + "contrast": fill.adjust.contrast, + "saturation": fill.adjust.saturation.unwrap_or(1.0), + "grayscale": fill.adjust.grayscale, + "transparency": 1.0 - fill.alpha.unwrap_or(1.0), + "decorative": sh.decorative, + }); + } + out +} + +/// `deck.slide {doc, slide}`. +pub(crate) fn slide(a: &Json, cx: &Call) -> Result { + const M: &str = "deck.slide"; + args::only(a, &["doc", "slide"], M)?; + let id = args::id(a, "slide", M)?; + docs::with_doc(a, cx, M, |doc| { + let i = doc.slide_index(id)?; + let inspected = gate::read(doc, cx.area, Plan { method: M, most: SLIDE, cmds: vec![("slide.inspect", json!({ "index": i }))] })?.remove(0); + let deck = doc.deck()?; + let s = &deck.slides[i]; + let ctx = Ctx::for_slide(&deck, s); + let summaries = inspected["shapes"].as_array().cloned().unwrap_or_default(); + let shapes: Vec = summaries.iter().zip(&s.shapes).map(|(summary, sh)| shape_json(ctx.as_ref(), summary, sh)).collect(); + Ok(json!({ + "id": id, + "index": i, + "layout": s.layout.0, + "background": inspected["background"], + "hidden": s.hidden, + "notes": inspected["notes"], + "transition": transition_json(s), + "builds": builds(&deck, i).1, + "shapes": shapes, + })) + }) +} + +/// `slide.go` and `edit.select`: the slide, and the shapes whose handles +/// may hit. +const HIT: usize = 2; +/// The most shapes a call names (SERVICE.md "Objects": 500). +pub(crate) const MAX_SHAPES: usize = 500; + +/// Every id of `ids` names a shape on slide `i` (groups' members too), or +/// `unknown_id:`. +pub(crate) fn shapes_exist(doc: &OpenDoc, i: usize, ids: &[u32]) -> Result<(), String> { + let deck = doc.deck()?; + let slide = &deck.slides[i]; + match ids.iter().find(|id| slide.shape(ShapeId(**id)).is_none()) { + Some(id) => Err(fail(Code::UnknownId, format!("slide {} has no shape {id} (an undo may have removed it)", slide.id.0))), + None => Ok(()), + } +} + +/// `deck.hit {doc, slide, at, tol?, selected?}`. An empty `selected` is no +/// selection, as a missing one is. +pub(crate) fn hit(a: &Json, cx: &Call) -> Result { + const M: &str = "deck.hit"; + args::only(a, &["doc", "slide", "at", "tol", "selected"], M)?; + let id = args::id(a, "slide", M)?; + let at = args::opt_point(a, "at", M)?.ok_or_else(|| invalid(format!("{M} needs `at`, [x, y] in points")))?; + let tol = args::opt_num(a, "tol", M, 0.0, 100.0)?.unwrap_or(4.0); + let selected = match args::present(a, "selected") { + Some(Json::Array(list)) if list.is_empty() => None, + _ => args::opt_ids(a, "selected", M, MAX_SHAPES)?, + }; + docs::with_doc(a, cx, M, |doc| { + let i = doc.slide_index(id)?; + let mut cmds = vec![("slide.go", json!({ "index": i }))]; + if let Some(ids) = &selected { + shapes_exist(doc, i, ids)?; + cmds.push(("edit.select", json!({ "ids": ids }))); + } + gate::read(doc, cx.area, Plan { method: M, most: HIT, cmds })?; + let hit = doc.session.hit_test(Point::new(at[0], at[1]), tol.max(0.01)); + Ok(json!({ "hit": hit.map(|h| match h { + Hit::Shape { id, text } => json!({ "kind": if text { "text" } else { "shape" }, "id": id.0 }), + Hit::Resize { id, handle } => json!({ "kind": "resize", "id": id.0, "handle": handle }), + Hit::Rotate { id } => json!({ "kind": "rotate", "id": id.0 }), + Hit::Adjust { id, index } => json!({ "kind": "adjust", "id": id.0, "index": index }), + Hit::LineEnd { id, end } => json!({ "kind": "line_end", "id": id.0, "end": end }), + }) })) + }) +} + +/// `deck.view {doc, slide, width, edit?, editing?, step?}`. +pub(crate) fn view(a: &Json, cx: &Call) -> Result { + const M: &str = "deck.view"; + args::only(a, &["doc", "slide", "width", "edit", "editing", "step"], M)?; + let id = args::id(a, "slide", M)?; + let w = args::opt_int_in(a, "width", M, WIDTH.0, WIDTH.1)?.ok_or_else(|| invalid(format!("{M} needs `width`, {} to {} pixels", WIDTH.0, WIDTH.1)))? as u64; + let edit = args::opt_bool(a, "edit", M)?.unwrap_or(false); + let editing = match args::present(a, "editing") { + Some(_) => Some(args::id(a, "editing", M)?), + None => None, + }; + let step = args::opt_int_in(a, "step", M, 0, 100_000)?.map(|s| s as usize); + if step.is_some() && (edit || editing.is_some()) { + return Err(invalid(format!("{M}: `step` draws the show view, without `edit` or `editing`"))); + } + docs::with_doc(a, cx, M, |doc| { + let i = doc.slide_index(id)?; + if let Some(e) = editing { + shapes_exist(doc, i, &[e])?; + } + let deck = doc.deck()?; + let (scale, width, _) = size_for(&deck, w, M)?; + let (tl, clicks, auto) = builds(&deck, i); + if let Some(k) = step.filter(|k| *k > clicks) { + return Err(invalid(format!("{M}: slide {id} has {clicks} builds, so `step` is 0 to {clicks}, not {k}"))); + } + let mode = match (edit, editing, step) { + (_, _, Some(k)) => format!("b{k}"), + (true, Some(e), None) => format!("et{e}"), + (false, Some(e), None) => format!("st{e}"), + (true, None, None) => "e".into(), + (false, None, None) => "s".into(), + }; + let key = format!("{id}-{mode}@{width}"); + let rev = doc.slide_rev(id); + if let Some(r) = doc.renders.get(&key).filter(|r| r.rev == rev && cache::still_there(cx.area, &r.rel, r.bytes)) { + return Ok(json!({ "path": r.rel, "width": r.width, "height": r.height, "rev": doc.rev })); + } + let state = step.filter(|_| !deck.show.without_animation).map(|k| show_state(&tl, auto, k)); + let opts = RenderOpts { + scale, + edit, + skip_text: editing.map(ShapeId), + state: state.as_ref().map(|f| f as &dyn Fn(ShapeId) -> ShapeState), + ..Default::default() + }; + let img = render::render_slide(&deck, i, &opts); + let png = img.to_png(); + let file = format!("{key}-r{rev}.png"); + let rel = cache::render_path(&doc.handle, &file); + cache::store(cx.area, &rel, &png, M)?; + // An older revision of the same slide, size and mode goes. + cache::drop_older(cx.area, &doc.handle, &format!("{key}-r"), &file); + let (drawn_w, drawn_h) = (u64::from(img.width), u64::from(img.height)); + doc.renders.insert(key, Rendered { rel: rel.clone(), rev, width: drawn_w, height: drawn_h, bytes: png.len() as u64 }); + Ok(json!({ "path": rel, "width": drawn_w, "height": drawn_h, "rev": doc.rev })) + }) +} + +/// `deck.layouts {doc, width?}`: a picture of each layout of the first +/// master, in the render cache. +pub(crate) fn layouts(a: &Json, cx: &Call) -> Result { + const M: &str = "deck.layouts"; + args::only(a, &["doc", "width"], M)?; + let w = width(a, M, LAYOUT_WIDTH)?; + docs::with_doc(a, cx, M, |doc| { + let deck = doc.deck()?; + let (scale, width, height) = size_for(&deck, w, M)?; + let Some(master) = deck.masters.first() else { return Ok(json!({ "layouts": [] })) }; + let n = master.layouts.len().min(MAX_LAYOUTS); + if (n as u64) * width * height > MAX_PIXELS { + return Err(too_large(format!("{M}: {n} layouts at {width} pixels are more than the {} megapixels one call draws: draw them smaller", MAX_PIXELS / 1_000_000))); + } + let rev = doc.deck_rev; + let mut out = Vec::with_capacity(n); + for (li, layout) in master.layouts.iter().take(n).enumerate() { + let key = format!("layout{}@{width}", layout.id.0); + let current = doc.renders.get(&key).filter(|r| r.rev == rev && cache::still_there(cx.area, &r.rel, r.bytes)).map(|r| r.rel.clone()); + let rel = match current { + Some(rel) => rel, + None => { + let img = render::render_layout(&deck, 0, Some(li), &RenderOpts { scale, edit: true, ..Default::default() }); + let png = img.to_png(); + let file = format!("{key}-r{rev}.png"); + let rel = cache::render_path(&doc.handle, &file); + cache::store(cx.area, &rel, &png, M)?; + cache::drop_older(cx.area, &doc.handle, &format!("{key}-r"), &file); + doc.renders.insert(key, Rendered { rel: rel.clone(), rev, width: u64::from(img.width), height: u64::from(img.height), bytes: png.len() as u64 }); + rel + } + }; + out.push(json!({ "id": layout.id.0, "name": layout.name, "kind": layout.kind.xml(), "path": rel })); + } + Ok(json!({ "layouts": out })) + }) +} + +/// A command on the engine's catalogs, on a session of its own: one +/// `safe` read through the gate. +fn catalog(cx: &Call, m: &'static str, id: &'static str) -> Result { + let mut s = docs::app_session(); + Ok(gate::exec(&mut s, cx.area, &Plan { method: m, most: 1, cmds: vec![(id, json!({}))] }, &mut HeldCache::default())?.remove(0)) +} + +/// `deck.themes {}`. +pub(crate) fn themes(a: &Json, cx: &Call) -> Result { + const M: &str = "deck.themes"; + args::only(a, &[], M)?; + let got = catalog(cx, M, "design.themes")?; + let themes: Vec = got["themes"] + .as_array() + .into_iter() + .flatten() + .map(|t| { + let name = t["name"].as_str().unwrap_or(""); + json!({ "name": name, "label": if name == "DeckCraft" { "Default" } else { name }, "colors": t["colors"], "major": t["major"], "minor": t["minor"] }) + }) + .collect(); + Ok(json!({ "themes": themes, "color_schemes": got["colorSchemes"], "font_schemes": got["fontSchemes"] })) +} + +/// `deck.fonts {}`: what the engine can draw on this device. +pub(crate) fn fonts(a: &Json, _cx: &Call) -> Result { + args::only(a, &[], "deck.fonts")?; + Ok(json!({ "families": deckcraft_fonts::FontDb::global().families() })) +} + +/// `deck.shapes {}`. +pub(crate) fn shapes(a: &Json, cx: &Call) -> Result { + const M: &str = "deck.shapes"; + args::only(a, &[], M)?; + Ok(json!({ "presets": catalog(cx, M, "shape.presets")? })) +} + +/// `deck.text {doc}`. +pub(crate) fn text(a: &Json, cx: &Call) -> Result { + const M: &str = "deck.text"; + args::only(a, &["doc"], M)?; + docs::with_doc(a, cx, M, |doc| { + let deck = doc.deck()?; + Ok(json!({ "outline": deckcraft_format::slides_to_outline(&deck) })) + }) +} + +/// `slide.go`, then `edit.select` or `text.edit` and +/// `text.selectParagraph`, `format.state` and `text.exit`. +const FORMAT_STATE: usize = 5; + +/// An alignment as SERVICE.md names it. +pub(crate) fn align_name(a: Align) -> &'static str { + match a { + Align::Left => "left", + Align::Center => "center", + Align::Right => "right", + Align::Justify => "justify", + Align::Distributed => "distributed", + } +} + +/// `deck.format_state {doc, slide, id, paragraph?}`: the formatting of a box +/// (its first run and paragraph), or of one of its paragraphs. +pub(crate) fn format_state(a: &Json, cx: &Call) -> Result { + const M: &str = "deck.format_state"; + args::only(a, &["doc", "slide", "id", "paragraph"], M)?; + let id = args::id(a, "slide", M)?; + let shape = args::id(a, "id", M)?; + let paragraph = args::opt_index(a, "paragraph", M)?; + docs::with_doc(a, cx, M, |doc| { + let i = doc.slide_index(id)?; + shapes_exist(doc, i, &[shape])?; + let deck = doc.deck()?; + let sh = deck.slides[i].shape(ShapeId(shape)).cloned().unwrap_or_default(); + let mut cmds = vec![("slide.go", json!({ "index": i }))]; + let para = match paragraph { + Some(p) => { + let n = sh.text.as_ref().map_or(0, |t| t.paragraphs.len()); + if p >= n { + return Err(invalid(format!("{M}: shape {shape} has {n} paragraphs, so `paragraph` is below {n}"))); + } + if !matches!(sh.kind, ShapeKind::Shape | ShapeKind::Connector { .. }) { + return Err(invalid(format!("{M}: shape {shape} holds no paragraphs of its own"))); + } + cmds.push(("text.edit", json!({ "id": shape, "at": [p, 0] }))); + cmds.push(("text.selectParagraph", json!({ "at": [p, 0] }))); + p + } + None => { + cmds.push(("edit.select", json!({ "ids": [shape] }))); + 0 + } + }; + cmds.push(("format.state", json!({}))); + if paragraph.is_some() { + cmds.push(("text.exit", json!({}))); + } + let got = gate::read(doc, cx.area, Plan { method: M, most: FORMAT_STATE, cmds })?; + let state = &got[got.len() - if paragraph.is_some() { 2 } else { 1 }]; + // The colour of the same run format.state reads, resolved. + let color = Ctx::for_slide(&deck, &deck.slides[i]).map(|ctx| { + let body = sh.text.clone().unwrap_or_default(); + let p = body.paragraphs.get(para).cloned().unwrap_or_default(); + let run = match paragraph { + Some(n) => deckcraft_model::edit::props_at(&body, (n, 1)), + None => p.runs.first().map(|r| r.props.clone()).unwrap_or_default(), + }; + format!("#{}", resolve::text_color(&ctx, &resolve::run(&ctx, &sh, &p, &run)).hex()) + }); + let align = state["align"].as_str().and_then(Align::from_xml).map(align_name); + let spacing: Option = serde_json::from_value(state["lineSpacing"].clone()).ok(); + Ok(json!({ + "font": state["font"], + "size": state["size"], + "bold": state["bold"], + "italic": state["italic"], + "underline": state["underline"], + "strike": state["strike"], + "color": color, + "align": align, + "bullets": state["bullets"], + "numbering": state["numbering"], + "line_spacing": match spacing { + Some(Spacing::Pct(lines)) => json!(lines), + Some(Spacing::Pts(pt)) => json!({ "pt": pt }), + None => Json::Null, + }, + })) + }) +} + +/// `deck.show {doc, from?}`: the slides the show visits, from `from` (or its +/// first slide), each with its click builds, and whether it loops. With +/// `loop`, the order runs on through the show's slides before `from`, so +/// the app can start again at `order[0]`. +pub(crate) fn show(a: &Json, cx: &Call) -> Result { + const M: &str = "deck.show"; + args::only(a, &["doc", "from"], M)?; + let from = match args::present(a, "from") { + Some(_) => Some(args::id(a, "from", M)?), + None => None, + }; + docs::with_doc(a, cx, M, |doc| { + let deck = doc.deck()?; + let order = deckcraft_anim::show_order(&deck); + let looping = deck.show.loop_until_esc || deck.show.show_type == "kiosk"; + let seq: Vec = match from { + None => order.clone(), + Some(id) => { + let i = doc.slide_index(id)?; + let mut seq = vec![i]; + match order.iter().position(|j| *j == i) { + Some(p) => { + seq.extend(&order[p + 1..]); + if looping { + seq.extend(&order[..p]); + } + } + None => { + seq.extend(order.iter().filter(|j| **j > i)); + if looping { + seq.extend(order.iter().filter(|j| **j < i)); + } + } + } + seq + } + }; + let entries: Vec = seq + .iter() + .map(|&i| { + let s = &deck.slides[i]; + let mut e = json!({ "slide": s.id.0, "steps": builds(&deck, i).1 }); + if let Some(after) = deck.show.use_timings.then_some(()).and(s.transition.as_ref()).and_then(|t| t.advance_after_ms) { + e["advance_after"] = json!(after as f64 / 1000.0); + } + e + }) + .collect(); + Ok(json!({ "order": entries, "loop": looping })) + }) +} diff --git a/apps/deck/host-service/src/slides.rs b/apps/deck/host-service/src/slides.rs new file mode 100644 index 00000000..78d616a3 --- /dev/null +++ b/apps/deck/host-service/src/slides.rs @@ -0,0 +1,453 @@ +//! Slides, notes, transitions, the deck's design, and its history +//! (SERVICE.md "Slides", "Design", "History and saving"). +//! +//! The safety review of the engine commands behind them, by their class in +//! `skill/safety.json`: +//! +//! - `design.background` is `file`, because a non-string `picture` makes it +//! read a `path`: the door refuses it from `deck.run`. `deck.design` runs +//! it with `color`, `gradient` or `reset` and `all` or `index` only, built +//! by the service; it is one of the service's `UNREAD` commands, which the +//! door admits for a method only when the call names neither `picture` +//! nor `path` (`octosense_engine_area::door::Unread`). A background is +//! never a picture. +//! - Everything else here is `safe` and passes the door with its limits: +//! `slide.new`, `slide.duplicate`, `slide.delete`, `slide.move`, +//! `slide.hide`, `slide.layout`, `slide.reset`, `slide.notes`, `slide.go` +//! and `slide.selectSlides` (the selection only: `slide.hide`, +//! `slide.layout` and `transition.set` then act on every slide selected, +//! one command and one undo snapshot for all of them), +//! `section.add`, `section.rename`, `section.remove` (never with its +//! `slides: true`, which would delete the section's slides), +//! `section.move`, `transition.set`, `transition.applyAll`, +//! `design.theme`, `design.colors`, `design.fonts`, `design.slideSize` +//! (within the door's 1920 × 1080 points of slide), `design.headerFooter`, +//! `edit.undo` and `edit.redo`. + +use serde_json::{json, Value as Json}; + +use crate::args; +use crate::codes::{invalid, too_large}; +use crate::docs::{self, OpenDoc, MAX_BODY, MAX_TITLE}; +use crate::gate::{self, Plan}; +use crate::objects::gradient; +use crate::Ctx; + +/// The most slides one call names (SERVICE.md "Slides": 500, the session's +/// own ceiling). +pub(crate) const MAX_SLIDES: usize = 500; +/// A slides op's most commands when it takes one a slide: `slide.duplicate` +/// (each copy after its original) and `slide.move`, one for each slide +/// named. +const PER_SLIDE: usize = MAX_SLIDES; +/// `slide.reset` acts on one slide at a time, after the `slide.go` that makes +/// it current: two commands a slide named. (The engine copies the current +/// slide on every edit; making each reset slide current first keeps a call +/// from copying another slide once per command.) +const RESET: usize = 2 * MAX_SLIDES; +/// `slide.selectSlides {ids}` (the selection only), then one command that +/// acts on every slide selected: `slide.hide`, `slide.layout`, +/// `transition.set`. +const SELECTED: usize = 2; +/// One command: `slide.new`, `slide.delete` (all the slides named, by id), +/// or a section's. +const ONE: usize = 1; +/// The longest notes (SERVICE.md "Caps": 20,000 characters). +pub(crate) const MAX_NOTES: usize = 20_000; +/// The longest section name. +const MAX_SECTION_NAME: usize = 256; + +/// The positions of the slides `ids` names, in their order, or +/// `unknown_id:`. +fn positions(doc: &OpenDoc, ids: &[u32]) -> Result, String> { + ids.iter().map(|id| doc.slide_index(*id)).collect() +} + +/// The `slide.move {from, to}` steps that bring the slides `named` (in +/// their order) together at position `to` of the deck that results, the +/// rest keeping theirs: each named slide follows the one before it, the +/// first follows the slide that ends up before position `to`. +pub(crate) fn moves(order: &[u32], named: &[u32], to: usize) -> Vec<(usize, usize)> { + let mut cur = order.to_vec(); + let rest: Vec = order.iter().filter(|id| !named.contains(id)).copied().collect(); + let mut prev = to.min(rest.len()).checked_sub(1).map(|k| rest[k]); + let mut out = Vec::new(); + for &id in named { + let Some(from) = cur.iter().position(|x| *x == id) else { continue }; + let target = match prev.and_then(|p| cur.iter().position(|x| *x == p)) { + None => 0, + Some(q) => (if q > from { q - 1 } else { q }) + 1, + }; + if from != target { + out.push((from, target)); + let moved = cur.remove(from); + cur.insert(target, moved); + } + prev = Some(id); + } + out +} + +/// `deck.slides_op {doc, op, …}`. +pub(crate) fn slides_op(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.slides_op"; + let op = args::need_str(a, "op", M)?.to_string(); + let only = |keys: &[&str]| { + let mut all = vec!["doc", "op"]; + all.extend_from_slice(keys); + args::only(a, &all, M) + }; + match op.as_str() { + "new" => only(&["at", "layout", "title", "body"])?, + "duplicate" | "delete" | "reset" => only(&["slides"])?, + "move" => only(&["slides", "to"])?, + "hide" => only(&["slides", "hidden"])?, + "layout" => only(&["slides", "layout"])?, + "section_add" => only(&["at", "name"])?, + "section_rename" => only(&["section", "name"])?, + "section_remove" => only(&["section"])?, + "section_move" => only(&["section", "to"])?, + other => { + return Err(invalid(format!( + "{M}: `op` is new, duplicate, delete, move, hide, layout, reset, section_add, section_rename, section_remove or section_move, not {other:?}" + ))) + } + } + let slides = |doc: &OpenDoc| -> Result<(Vec, Vec), String> { + let ids = args::ids(a, "slides", M, MAX_SLIDES)?; + let at = positions(doc, &ids)?; + Ok((ids, at)) + }; + docs::with_doc(a, cx, M, |doc| { + let deck = doc.deck()?; + let n = deck.slides.len(); + let (cmds, most, label): (Vec<(&'static str, Json)>, usize, &str) = match op.as_str() { + "new" => { + let at = args::opt_index(a, "at", M)?.unwrap_or(n); + if at > n { + return Err(invalid(format!("{M}: `at` is a position from 0 to {n}"))); + } + let mut p = json!({ "at": at }); + if let Some(layout) = args::present(a, "layout") { + p["layout"] = docs::layout_value(layout, M)?; + } + if let Some(t) = args::opt_str(a, "title", M, MAX_TITLE)? { + p["title"] = json!(t); + } + if let Some(b) = args::opt_str(a, "body", M, MAX_BODY)? { + p["body"] = json!(b); + } + (vec![("slide.new", p)], ONE, "New slide") + } + "duplicate" => { + let (_, mut at) = slides(doc)?; + at.sort_unstable(); + // Each copy follows its original: the k-th original has k + // copies before it by the time it is duplicated. + (at.iter().enumerate().map(|(k, i)| ("slide.duplicate", json!({ "index": i + k }))).collect(), PER_SLIDE, "Duplicate slides") + } + "delete" => { + let (ids, _) = slides(doc)?; + if ids.len() >= n { + return Err(invalid(format!("{M}: a deck keeps at least one slide: deleting every slide is refused"))); + } + (vec![("slide.delete", json!({ "ids": ids }))], ONE, "Delete slides") + } + "move" => { + let (ids, _) = slides(doc)?; + let to = args::need_index(a, "to", M)?; + if to > n { + return Err(invalid(format!("{M}: `to` is a position from 0 to {n}"))); + } + let order: Vec = deck.slides.iter().map(|s| s.id.0).collect(); + (moves(&order, &ids, to).into_iter().map(|(from, to)| ("slide.move", json!({ "from": from, "to": to }))).collect(), PER_SLIDE, "Move slides") + } + "hide" => { + let (ids, _) = slides(doc)?; + let hidden = args::need_bool(a, "hidden", M)?; + let label = if hidden { "Hide slides" } else { "Show slides" }; + (vec![("slide.selectSlides", json!({ "ids": ids })), ("slide.hide", json!({ "hidden": hidden }))], SELECTED, label) + } + "layout" => { + let (ids, _) = slides(doc)?; + let layout = docs::layout_value(args::present(a, "layout").ok_or_else(|| invalid(format!("{M} needs `layout`")))?, M)?; + (vec![("slide.selectSlides", json!({ "ids": ids })), ("slide.layout", json!({ "layout": layout }))], SELECTED, "Change layout") + } + "reset" => { + let (_, at) = slides(doc)?; + let cmds = at.iter().flat_map(|i| [("slide.go", json!({ "index": i })), ("slide.reset", json!({ "index": i }))]).collect(); + (cmds, RESET, "Reset slides") + } + "section_add" => { + let at = args::need_index(a, "at", M)?; + if at >= n { + return Err(invalid(format!("{M}: `at` is the position of the section's first slide, below {n}"))); + } + let name = args::opt_str(a, "name", M, MAX_SECTION_NAME)?.unwrap_or("Untitled Section"); + (vec![("section.add", json!({ "name": name, "at": at }))], ONE, "Add section") + } + _ => { + let section = args::need_index(a, "section", M)?; + let count = deck.sections.len(); + if section >= count { + return Err(invalid(format!("{M}: the deck has {count} sections, so `section` is below {count}"))); + } + match op.as_str() { + "section_rename" => { + let name = args::need_text(a, "name", M, MAX_SECTION_NAME)?; + (vec![("section.rename", json!({ "index": section, "name": name }))], ONE, "Rename section") + } + // The section goes; its slides stay, in the section + // before it. + "section_remove" => (vec![("section.remove", json!({ "index": section }))], ONE, "Remove section"), + _ => { + let to = args::need_index(a, "to", M)?; + if to >= count { + return Err(invalid(format!("{M}: `to` is a section position below {count}"))); + } + (vec![("section.move", json!({ "index": section, "to": to }))], ONE, "Move section") + } + } + } + }; + let done = gate::change(doc, cx.area, Plan { method: M, most, cmds }, label)?; + let deck = doc.deck()?; + let extra = match op.as_str() { + "new" => json!({ "slide": done.results.first().and_then(|r| r["id"].as_u64()) }), + "duplicate" => json!({ "slides": done.results.iter().filter_map(|r| r["ids"][0].as_u64()).collect::>() }), + o if o.starts_with("section_") => json!({ "sections": docs::sections(&deck) }), + _ => json!({}), + }; + doc.answer(&done.changed, extra) + }) +} + +/// `slide.notes {text, index}`: one command. +const NOTES: usize = 1; + +/// `deck.notes {doc, slide, text}`. +pub(crate) fn notes(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.notes"; + args::only(a, &["doc", "slide", "text"], M)?; + let slide = args::id(a, "slide", M)?; + let text = args::need_text(a, "text", M, MAX_NOTES)?; + docs::with_doc(a, cx, M, |doc| { + let i = doc.slide_index(slide)?; + let done = gate::change(doc, cx.area, Plan { method: M, most: NOTES, cmds: vec![("slide.notes", json!({ "text": text, "index": i }))] }, "Edit notes")?; + doc.answer(&done.changed, json!({})) + }) +} + +/// For the slides named, `slide.selectSlides` and one `transition.set` on +/// them all; for every slide, `slide.go`, `transition.set` and +/// `transition.applyAll`. +const TRANSITION: usize = 3; + +/// `deck.transition {doc, slides | all: true, kind, option?, duration?}`: +/// `kind` and `option` from the engine's transition catalog, `duration` in +/// milliseconds (at most the engine's 59,000). +pub(crate) fn transition(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.transition"; + args::only(a, &["doc", "slides", "all", "kind", "option", "duration"], M)?; + let kind = args::need_str(a, "kind", M)?; + let info = deckcraft_model::anim::TRANSITIONS + .iter() + .find(|t| t.0.eq_ignore_ascii_case(kind)) + .ok_or_else(|| invalid(format!("{M}: `kind` is a transition of the engine's catalog (none, fade, push, wipe, split, reveal, cover, morph…), not {kind:?}")))?; + let option = args::opt_str(a, "option", M, 64)?; + if let Some(o) = option.filter(|o| !info.4.contains(o)) { + return Err(invalid(format!("{M}: {}'s `option` is one of {}, not {o:?}", info.0, info.4.join(", ")))); + } + let duration = args::opt_int_in(a, "duration", M, 0, 59_000)?; + let all = args::opt_bool(a, "all", M)?.unwrap_or(false); + let ids = args::opt_ids(a, "slides", M, MAX_SLIDES)?; + let mut params = json!({ "kind": info.0 }); + if let Some(o) = option { + params["option"] = json!(o); + } + if let Some(d) = duration { + params["duration"] = json!(d); + } + docs::with_doc(a, cx, M, |doc| { + let cmds: Vec<(&'static str, Json)> = match (all, ids) { + (true, None) => { + let mut p = params.clone(); + p["index"] = json!(0); + vec![("slide.go", json!({ "index": 0 })), ("transition.set", p), ("transition.applyAll", json!({}))] + } + (false, Some(ids)) => { + positions(doc, &ids)?; + vec![("slide.selectSlides", json!({ "ids": ids })), ("transition.set", params.clone())] + } + _ => return Err(invalid(format!("{M}: give `slides`, or `all: true`"))), + }; + let done = gate::change(doc, cx.area, Plan { method: M, most: TRANSITION, cmds }, "Change transition")?; + doc.answer(&done.changed, json!({})) + }) +} + +/// The most masters a theme, colour or font change reaches in one call. +const MAX_MASTERS: usize = 16; +/// `deck.design`'s most commands: for each master its slides use, a +/// `slide.go` and `design.theme`, `design.colors` and `design.fonts`; one +/// `design.slideSize`; for each slide named a `slide.go` and its +/// `design.background` (or one `slide.go` and one for all); one +/// `design.headerFooter`. +const DESIGN: usize = 4 * MAX_MASTERS + 1 + 2 * MAX_SLIDES + 1; + +/// `deck.design {doc, theme?, colors?, fonts?, size?, scale?, background?, +/// footer?}`: the theme, colours and fonts of every master the slides use; +/// `background` is `{color | gradient | reset: true, slides | all: true}`, +/// never a picture; `footer` is `{slide_numbers?, footer?, footer_text?, +/// date?, hide_on_title?}` on every slide. +pub(crate) fn design(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.design"; + args::only(a, &["doc", "theme", "colors", "fonts", "size", "scale", "background", "footer"], M)?; + let theme = match args::opt_str(a, "theme", M, 64)? { + Some(t) => Some(docs::theme_name(t, M)?), + None => None, + }; + let named = |key: &str, names: Vec| -> Result, String> { + match args::opt_str(a, key, M, 64)? { + Some(n) => names.iter().find(|x| x.eq_ignore_ascii_case(n)).cloned().map(Some).ok_or_else(|| invalid(format!("{M}: `{key}` is one of {}", names.join(", ")))), + None => Ok(None), + } + }; + let colors = named("colors", deckcraft_model::theme::builtin_color_schemes().into_iter().map(|c| c.name).collect())?; + let fonts = named("fonts", deckcraft_model::theme::builtin_font_schemes().into_iter().map(|f| f.name).collect())?; + let size = args::opt_size(a, "size", M)?; + let scale = match args::opt_choice(a, "scale", M, &["fit", "maximize"])? { + Some("maximize") => "maximize", + Some(_) => "ensureFit", + None => "ensureFit", + }; + if args::present(a, "scale").is_some() && size.is_none() { + return Err(invalid(format!("{M}: `scale` goes with `size`"))); + } + // The background: what, and where. + let background = match args::present(a, "background") { + Some(bg) => { + let bm = "deck.design background"; + args::only(bg, &["color", "gradient", "reset", "slides", "all"], bm)?; + let mut p = json!({}); + match (args::opt_color(bg, "color", bm)?, args::present(bg, "gradient"), args::opt_bool(bg, "reset", bm)?.unwrap_or(false)) { + (Some(c), None, false) => p["color"] = json!(c), + (None, Some(g), false) => p["gradient"] = gradient(g, bm)?, + (None, None, true) => p["reset"] = json!(true), + _ => return Err(invalid(format!("{bm} is one of `color`, `gradient` or `reset: true`"))), + } + let all = args::opt_bool(bg, "all", bm)?.unwrap_or(false); + let ids = args::opt_ids(bg, "slides", bm, MAX_SLIDES)?; + match (all, ids) { + (true, None) => Some((p, None)), + (false, Some(ids)) => Some((p, Some(ids))), + _ => return Err(invalid(format!("{bm} applies to `slides`, or to `all: true`"))), + } + } + None => None, + }; + let footer = match args::present(a, "footer") { + Some(f) => { + let fm = "deck.design footer"; + args::only(f, &["slide_numbers", "footer", "footer_text", "date", "hide_on_title"], fm)?; + let mut p = json!({ "all": true }); + for (key, engine) in [("slide_numbers", "slideNumber"), ("footer", "footer"), ("date", "date"), ("hide_on_title", "hideOnTitle")] { + if let Some(v) = args::opt_bool(f, key, fm)? { + p[engine] = json!(v); + } + } + if let Some(t) = args::opt_str(f, "footer_text", fm, 512)? { + p["footerText"] = json!(t); + } + if p.as_object().is_some_and(|o| o.len() == 1) { + return Err(invalid(format!("{fm}: give at least one of its settings"))); + } + Some(p) + } + None => None, + }; + if theme.is_none() && colors.is_none() && fonts.is_none() && size.is_none() && background.is_none() && footer.is_none() { + return Err(invalid(format!("{M}: name at least one thing to change"))); + } + docs::with_doc(a, cx, M, |doc| { + let deck = doc.deck()?; + let mut cmds: Vec<(&'static str, Json)> = Vec::new(); + let mut labels: Vec<&str> = Vec::new(); + if theme.is_some() || colors.is_some() || fonts.is_some() { + // Every master the slides use, by the first slide using it (the + // engine changes the current slide's master). + let mut firsts: Vec<(usize, usize)> = Vec::new(); + for (i, s) in deck.slides.iter().enumerate() { + let master = deck.masters.iter().position(|m| m.layout(s.layout).is_some()).unwrap_or(0); + if !firsts.iter().any(|(m, _)| *m == master) { + firsts.push((master, i)); + } + } + if firsts.len() > MAX_MASTERS { + return Err(too_large(format!("{M}: the slides use {} masters, more than the {MAX_MASTERS} one call changes", firsts.len()))); + } + let targets: Vec> = if firsts.is_empty() { vec![None] } else { firsts.iter().map(|(_, i)| Some(*i)).collect() }; + for at in targets { + if let Some(i) = at { + cmds.push(("slide.go", json!({ "index": i }))); + } + if let Some(t) = &theme { + cmds.push(("design.theme", json!({ "name": t }))); + } + if let Some(c) = &colors { + cmds.push(("design.colors", json!({ "name": c }))); + } + if let Some(f) = &fonts { + cmds.push(("design.fonts", json!({ "name": f }))); + } + } + labels.push(if theme.is_some() { "Change theme" } else { "Change theme variant" }); + } + if let Some([w, h]) = size { + cmds.push(("design.slideSize", json!({ "w": w, "h": h, "scale": scale }))); + labels.push("Change slide size"); + } + if let Some((p, ids)) = &background { + match ids { + None => { + let mut p = p.clone(); + p["all"] = json!(true); + if !deck.slides.is_empty() { + cmds.push(("slide.go", json!({ "index": 0 }))); + } + cmds.push(("design.background", p)); + } + Some(ids) => { + for i in positions(doc, ids)? { + let mut p = p.clone(); + p["index"] = json!(i); + cmds.push(("slide.go", json!({ "index": i }))); + cmds.push(("design.background", p)); + } + } + } + labels.push("Change background"); + } + if let Some(p) = &footer { + cmds.push(("design.headerFooter", p.clone())); + labels.push("Header and footer"); + } + let label = if labels.len() == 1 { labels[0] } else { "Change design" }; + let done = gate::change(doc, cx.area, Plan { method: M, most: DESIGN, cmds }, label)?; + let size = doc.deck()?.slide_size; + doc.answer(&done.changed, json!({ "size": [size.width, size.height] })) + }) +} + +/// `deck.undo {doc}` and `deck.redo {doc}`: one folded step, and the labels +/// of the next undo and redo. +pub(crate) fn history(a: &Json, cx: &Ctx, redo: bool) -> Result { + let m = if redo { "deck.redo" } else { "deck.undo" }; + args::only(a, &["doc"], m)?; + docs::with_doc(a, cx, m, |doc| { + let changed = gate::step(doc, cx.area, redo)?; + let st = doc.st()?; + let (undo, redo) = (st.history.undo.last().map(|e| e.label.clone()), st.history.redo.last().map(|e| e.label.clone())); + doc.answer(&changed, json!({ "undo": undo, "redo": redo })) + }) +} diff --git a/apps/deck/host-service/src/warm.rs b/apps/deck/host-service/src/warm.rs index 56485da9..b095ad79 100644 --- a/apps/deck/host-service/src/warm.rs +++ b/apps/deck/host-service/src/warm.rs @@ -13,16 +13,27 @@ //! //! The thread never goes through App Hub's service registry, so it holds no //! lock a call takes on the way in; it reads font files only and uses no -//! network. A call that arrives while it runs shares only the font -//! database: before the catalog scan starts, the call scans and the thread -//! then finds it done; during the scan, the call waits for that one scan -//! (the database catalogs once per process) instead of starting another, -//! which never takes longer than scanning itself. +//! network. A call that arrives while it runs waits for it to finish +//! ([`ready`]) before the engine does any work. The font database is not +//! safe to load one family from two threads at once: a lookup that finds +//! none of a family's faces yet loads its files, finds every face already +//! added by the other thread, and falls back to the last-resort face, so a +//! slide's title was drawn in Ubuntu Light once (the deck service's tests +//! caught it), a drawing the render cache and a cover would keep. The wait +//! is never longer than the warm-up, most of which (the catalog scan, the +//! default families' files) the call would otherwise pay itself, and +//! there is nothing to wait for once it is done. Without a warm-up (a +//! service registered alone, as in tests), the first call draws the slide +//! itself, on its own thread, and every other caller waits for that. +use std::panic::{catch_unwind, AssertUnwindSafe}; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::OnceLock; use std::thread::JoinHandle; static STARTED: AtomicBool = AtomicBool::new(false); +/// Set once the warm-up's slide is drawn (or the engine failed to draw it). +static DONE: OnceLock<()> = OnceLock::new(); /// Start warming the engine on a background thread, once per process: /// `Some(thread)` the first time, `None` after that. If the thread cannot @@ -31,7 +42,16 @@ pub fn warm() -> Option> { if STARTED.swap(true, Ordering::SeqCst) { return None; } - std::thread::Builder::new().name("deck-warm".into()).spawn(draw_one_slide).ok() + std::thread::Builder::new().name("deck-warm".into()).spawn(ready).ok() +} + +/// The warm-up, done: drawn here if no thread has started it, or waited +/// for while one draws it (module doc). Every call runs it first. +pub(crate) fn ready() { + DONE.get_or_init(|| { + // A failure to draw leaves the first real call to fail as it would. + let _ = catch_unwind(AssertUnwindSafe(draw_one_slide)); + }); } /// A slide of the deck `new` makes, drawn small and thrown away. diff --git a/apps/deck/host-service/src/writing.rs b/apps/deck/host-service/src/writing.rs new file mode 100644 index 00000000..f91094b5 --- /dev/null +++ b/apps/deck/host-service/src/writing.rs @@ -0,0 +1,263 @@ +//! Saving and exporting an open document (SERVICE.md "History and saving", +//! "Export"). None of it runs an engine command: the service calls the +//! engine's encoders (`save_bytes` for the native format, `.pptx` and outline +//! text, `pdf_bytes` for a PDF, `render_png` for a picture of a slide), which +//! touch no file and return bytes, and writes every file itself, through a +//! staging folder inside the caller's storage, moved into place all or +//! nothing (`octosense_engine_area::Stage`), within what is left of the +//! storage (the render cache cleared first when it would not fit). A write +//! never replaces a file, except `deck.save` over the deck's own file, from +//! the foreground. The engine's `file.save`, `file.saveAs` and +//! `file.saveTemplate` (`host`) and `file.export` (`file`) never run. + +use std::path::{Path, PathBuf}; + +use deckcraft_engine::cmd::file as engine_file; +use deckcraft_model::Presentation; +use octosense_engine_area::Area; +use serde_json::{json, Value as Json}; + +use crate::args; +use crate::cache; +use crate::codes::{self, fail, invalid, too_large, Code}; +use crate::docs; +use crate::reading; +use crate::Ctx; + +/// The largest file the service writes (SERVICE.md "Caps": 64 MiB). +const MAX_WRITE: u64 = crate::MAX_DECK_BYTES; +/// One export call's most pixels to raster: the door's `MAX_RUN_RASTER`. +pub(crate) const MAX_RASTER: u64 = crate::MAX_RUN_RASTER; +/// One raster's most pixels (a PDF page's slide, or one picture). +const MAX_ONE_RASTER: u64 = crate::MAX_RASTER; +/// An image export's most slides, longest side and pixels in all. +pub(crate) const MAX_IMAGES: usize = 64; +const MAX_IMAGE_SIDE: u64 = 4096; +pub(crate) const MAX_IMAGE_PIXELS: u64 = 256_000_000; +/// A PDF's resolutions (SERVICE.md: 72 to 200, default 150). +const DPI: (i64, i64) = (72, 200); +const DEFAULT_DPI: i64 = 150; +/// A handout page's slides. +const PER_PAGE: [i64; 6] = [1, 2, 3, 4, 6, 9]; +/// The handout and notes paper the engine lays out (US Letter portrait). +const PAPER: (f64, f64) = (612.0, 792.0); + +/// Move `bytes` into place at `dest` (inside `area`, already checked) +/// through a staging folder: all or nothing, within the storage's room, +/// the render cache cleared first when it would not fit. `area`'s +/// `may_replace` decides whether an existing file may be replaced. +fn place(area: &Area, files: &[(PathBuf, &[u8])], m: &str) -> Result<(), String> { + let total: u64 = files.iter().map(|(_, b)| b.len() as u64).sum(); + if files.iter().any(|(_, b)| b.len() as u64 > MAX_WRITE) { + return Err(too_large(format!("{m}: the result is larger than the {} MB the service writes", MAX_WRITE >> 20))); + } + let stage = area.stage().map_err(codes::area)?; + let mut moves = Vec::with_capacity(files.len()); + for (k, (dest, bytes)) in files.iter().enumerate() { + let staged = stage.path(format!("{k}")); + std::fs::write(&staged, bytes).map_err(|e| codes::io(m, &e))?; + moves.push((staged, dest.clone())); + } + let freed: u64 = if area.may_replace { files.iter().map(|(d, _)| cache::regular_len(d)).sum() } else { 0 }; + cache::make_room(area, total.saturating_sub(freed)); + stage.commit(&moves).map_err(codes::area) +} + +/// An area like `area` whose writes never replace a file, whatever the +/// call's surface: where SERVICE.md says "a new file". +fn create_only(area: &Area) -> Area { + Area::new(area.root.clone(), area.room(), false) +} + +/// Write `bytes` to the new file `dest` (`deck.create`'s deck). +pub(crate) fn write_new(area: &Area, dest: &Path, bytes: &[u8], m: &str) -> Result<(), String> { + place(&create_only(area), &[(dest.to_path_buf(), bytes)], m) +} + +/// `deck.save {doc, path?}`: the native format to the deck's own file, or +/// to a new file, which the deck is from then on. +pub(crate) fn save(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.save"; + args::only(a, &["doc", "path"], M)?; + let to = args::opt_str(a, "path", M, 512)?; + docs::with_doc(a, cx, M, |doc| { + let (area, dest, rel) = match to { + None => { + let file = doc.file.clone().ok_or_else(|| invalid(format!("{M}: this deck has no .deckcraft file yet (it came from a .pptx or an outline): save it with a `path`")))?; + // The one write that replaces a file: the deck's own, from + // the foreground. + if !cx.area.may_replace { + return Err(invalid(format!("{M}: saving over the deck's own file needs the app in the foreground"))); + } + let dest = crate::inside(cx.area, &file, "path", M)?; + (Area::new(cx.area.root.clone(), cx.area.room(), true), dest, file) + } + Some(out) => { + if !out.to_ascii_lowercase().ends_with(".deckcraft") { + return Err(invalid(format!("{M}: `path` is a new .deckcraft file (.pptx goes through deck.export)"))); + } + let area = create_only(cx.area); + let dest = crate::new_file(&area, out, "path", M)?; + (area, dest, crate::normal(out)) + } + }; + let bytes = engine_file::save_bytes(&doc.st()?.doc, "deckcraft").map_err(|e| fail(Code::Damaged, format!("{M}: {e}")))?; + place(&area, &[(dest, &bytes)], M)?; + let st = doc.st_mut()?; + st.saved_doc = st.doc.clone(); + doc.file = Some(rel.clone()); + Ok(json!({ "path": rel, "bytes": bytes.len(), "rev": doc.rev })) + }) +} + +/// The slides an export names (ids, in deck order), or every slide; hidden +/// ones only with `include_hidden`. +fn export_slides(deck: &Presentation, doc: &docs::OpenDoc, a: &Json, m: &str, include_hidden: bool) -> Result, String> { + let mut at: Vec = match args::opt_ids(a, "slides", m, crate::slides::MAX_SLIDES)? { + Some(ids) => ids.iter().map(|id| doc.slide_index(*id)).collect::>()?, + None => (0..deck.slides.len()).collect(), + }; + at.sort_unstable(); + at.retain(|i| include_hidden || !deck.slides[*i].hidden); + if at.is_empty() { + return Err(invalid(format!("{m}: no slide to export (hidden slides need `include_hidden`)"))); + } + Ok(at) +} + +/// `deck.export {doc, kind, …}`: new files only. +pub(crate) fn export(a: &Json, cx: &Ctx) -> Result { + const M: &str = "deck.export"; + let kind = args::need_str(a, "kind", M)?.to_string(); + match kind.as_str() { + "pptx" | "outline" => args::only(a, &["doc", "kind", "out"], M)?, + "pdf" => args::only(a, &["doc", "kind", "out", "layout", "per_page", "slides", "include_hidden", "dpi"], M)?, + "images" => args::only(a, &["doc", "kind", "out_dir", "width", "slides"], M)?, + other => return Err(invalid(format!("{M}: `kind` is pptx, pdf, images or outline, not {other:?}"))), + } + let area = create_only(cx.area); + docs::with_doc(a, cx, M, |doc| { + let deck = doc.deck()?; + match kind.as_str() { + "pptx" | "outline" => { + let out = args::need_str(a, "out", M)?; + let lower = out.to_ascii_lowercase(); + let ok = if kind == "pptx" { lower.ends_with(".pptx") } else { lower.ends_with(".txt") || lower.ends_with(".md") }; + if !ok { + return Err(invalid(format!("{M}: `out` is a new {} file", if kind == "pptx" { ".pptx" } else { ".txt or .md" }))); + } + let dest = crate::new_file(&area, out, "out", M)?; + let bytes = engine_file::save_bytes(&deck, &kind).map_err(|e| fail(Code::Damaged, format!("{M}: {e}")))?; + place(&area, &[(dest, &bytes)], M)?; + Ok(json!({ "path": crate::normal(out), "bytes": bytes.len() })) + } + "pdf" => { + let out = args::need_str(a, "out", M)?; + if !out.to_ascii_lowercase().ends_with(".pdf") { + return Err(invalid(format!("{M}: `out` is a new .pdf file"))); + } + let dest = crate::new_file(&area, out, "out", M)?; + let layout = args::opt_choice(a, "layout", M, &["slides", "notes", "handouts"])?.unwrap_or("slides"); + let per_page = args::opt_int(a, "per_page", M)?; + if per_page.is_some() && layout != "handouts" { + return Err(invalid(format!("{M}: `per_page` goes with handouts"))); + } + let per_page = per_page.unwrap_or(6); + if !PER_PAGE.contains(&per_page) { + return Err(invalid(format!("{M}: `per_page` is 1, 2, 3, 4, 6 or 9"))); + } + let dpi = args::opt_int_in(a, "dpi", M, DPI.0, DPI.1)?.unwrap_or(DEFAULT_DPI); + let include_hidden = args::opt_bool(a, "include_hidden", M)?.unwrap_or(false); + let slides = export_slides(&deck, doc, a, M, include_hidden)?; + // Every cap before anything is drawn: each slide is rastered + // at `dpi` into its box on the page. + let (sw, sh) = (deck.slide_size.width.max(1.0), deck.slide_size.height.max(1.0)); + let box_w = match layout { + "slides" => sw, + "notes" => PAPER.0 - 144.0, + _ => handout_box_width(per_page as u8, sw / sh), + }; + let px = |pt: f64| (pt / 72.0 * dpi as f64).ceil().max(1.0) as u64; + let one = px(box_w) * px(box_w * sh / sw); + if one > MAX_ONE_RASTER { + return Err(too_large(format!("{M}: a {sw} × {sh} point slide at {dpi} dpi is a {one}-pixel raster, more than the {MAX_ONE_RASTER} one may have: export at a lower dpi"))); + } + let all = one.saturating_mul(slides.len() as u64); + if all > MAX_RASTER { + return Err(too_large(format!( + "{M}: {} slides at {dpi} dpi are {:.1} megapixels to raster, more than the {} one export rasters: export fewer slides, or at a lower dpi", + slides.len(), + all as f64 / 1e6, + MAX_RASTER / 1_000_000 + ))); + } + let mut opts = json!({ "layout": layout, "dpi": dpi, "slides": slides, "includeHidden": include_hidden }); + if layout == "handouts" { + opts["perPage"] = json!(per_page); + } + let bytes = engine_file::pdf_bytes(&deck, &opts).map_err(|e| fail(Code::Damaged, format!("{M}: {e}")))?; + place(&area, &[(dest, &bytes)], M)?; + let pages = if layout == "handouts" { slides.len().div_ceil(per_page as usize) } else { slides.len() }; + Ok(json!({ "path": crate::normal(out), "bytes": bytes.len(), "pages": pages })) + } + _ => { + let out_dir = args::need_str(a, "out_dir", M)?; + let dir = crate::inside(&area, out_dir, "out_dir", M)?; + let width = args::opt_int_in(a, "width", M, reading::WIDTH.0, MAX_IMAGE_SIDE as i64)?.unwrap_or(1920) as u64; + let slides = export_slides(&deck, doc, a, M, true)?; + if slides.len() > MAX_IMAGES { + return Err(too_large(format!("{M}: {} slides, more than the {MAX_IMAGES} pictures one export writes", slides.len()))); + } + let (sw, sh) = (deck.slide_size.width.max(1.0), deck.slide_size.height.max(1.0)); + let scale = width as f64 / sw; + let height = (sh * scale).round().max(1.0) as u64; + if height > MAX_IMAGE_SIDE { + return Err(too_large(format!("{M}: a picture {width} pixels wide is {height} high, more than {MAX_IMAGE_SIDE}: export them narrower"))); + } + let all = (width * height).saturating_mul(slides.len() as u64); + if all > MAX_IMAGE_PIXELS { + return Err(too_large(format!( + "{M}: {} pictures of {width} × {height} are {:.1} megapixels, more than the {} one export draws", + slides.len(), + all as f64 / 1e6, + MAX_IMAGE_PIXELS / 1_000_000 + ))); + } + let names: Vec = slides.iter().map(|i| format!("Slide {}.png", i + 1)).collect(); + for name in &names { + area.check(&dir.join(name), 0).map_err(codes::area)?; + } + let pngs: Vec> = slides.iter().map(|i| engine_file::render_png(&deck, *i, scale, false).0).collect(); + let files: Vec<(PathBuf, &[u8])> = names.iter().zip(&pngs).map(|(n, b)| (dir.join(n), b.as_slice())).collect(); + place(&area, &files, M)?; + let base = crate::normal(out_dir); + Ok(json!({ "paths": names.iter().map(|n| format!("{base}/{n}")).collect::>() })) + } + } + }) +} + +/// The width of a slide's box on a handout page of `per_page` slides +/// (deckcraft-pdf's `handout_boxes`, on US Letter). +fn handout_box_width(per_page: u8, aspect: f64) -> f64 { + let (pw, ph) = PAPER; + let (mx, top, bottom) = (54.0, 54.0, 54.0); + let (cols, rows, notes) = match per_page { + 0 | 1 => (1.0, 1.0, false), + 2 => (1.0, 2.0, false), + 3 => (1.0, 3.0, true), + 4 => (2.0, 2.0, false), + 6 => (2.0, 3.0, false), + _ => (3.0, 3.0, false), + }; + let gap = 18.0; + let aw = if notes { (pw - 2.0 * mx) * 0.5 } else { pw - 2.0 * mx }; + let ah = ph - top - bottom - 18.0; + let cw = (aw - gap * (cols - 1.0)) / cols; + let ch = (ah - gap * (rows - 1.0)) / rows; + if cw / ch > aspect { + ch * aspect + } else { + cw + } +} diff --git a/crates/engine-area/src/door.rs b/crates/engine-area/src/door.rs index f6ca553f..ff821520 100644 --- a/crates/engine-area/src/door.rs +++ b/crates/engine-area/src/door.rs @@ -47,6 +47,13 @@ //! What the commands then do to the open document is the service's to fence //! after each one (a document's links, an effect's file parameters), since //! no id check sees the data a command plants. +//! +//! A service's own typed methods (an app's open-document editing) build +//! their commands themselves and pass the same gate with +//! [`Door::admit_method`]: every command admitted as one of `run` would be, +//! before any runs, but under the method's own most commands per call +//! instead of [`MAX_COMMANDS`], and with a [`Refusal`] that says which kind +//! of refusal it is, so the service can answer with its stable codes. use std::collections::BTreeMap; use std::path::{Component, Path, PathBuf}; @@ -195,6 +202,48 @@ impl Reviewed { pub const NONE: Reviewed = Reviewed { file_reads: &[], setters: &[], inner: &[], limits: &[], copies_per_call: 1.0, held: &[] }; } +/// A `file` command a service's own method may run without its file access: +/// the command reaches a file only through the parameters `reads_through` +/// name (a non-string `picture`, then its `path`), and a method's call, +/// whose parameters the service builds itself, never names them. Only +/// [`Door::admit_method`] honours it, with the service's list: `run` never +/// runs such a command, and [`Door::runs`] and [`Door::runnable`] do not +/// count it. +pub struct Unread { + pub id: &'static str, + pub reads_through: &'static [&'static str], +} + +/// Which kind of refusal a [`Refusal`] is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Refused { + /// More commands than the call's ceiling. + Count, + /// Over a reviewed ceiling: a parameter that multiplies work, the files + /// the call reads, or the copies it makes. + Limit, + /// A command held back until the engine bounds hostile data ([`Held`]). + Held, + /// Anything else: an id the door does not run or does not know, a class + /// it never runs, a path outside the area, a parameter of the wrong + /// shape. + Other, +} + +/// Why the door refused a call of a service's own method +/// ([`Door::admit_method`]): the kind, and the words for a person. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Refusal { + pub kind: Refused, + pub words: String, +} + +impl Refusal { + fn new(kind: Refused, words: String) -> Refusal { + Refusal { kind, words } + } +} + /// What one call has asked for so far. struct Budget { /// Bytes its reviewed reads name. @@ -361,6 +410,7 @@ impl Door { /// parameters to run it with. pub fn admit_all(&self, cmds: &Value, area: &Area) -> Result, String> { let family = self.family; + let who = format!("{family}.run"); let cmds = cmds.as_array().ok_or_else(|| format!("{family}.run: `cmds` is a list of {{id, params?}}"))?; if cmds.len() > MAX_COMMANDS { return Err(format!("{family}.run: at most {MAX_COMMANDS} commands per call")); @@ -369,7 +419,7 @@ impl Door { let mut admitted = Vec::with_capacity(cmds.len()); for c in cmds { let id = c["id"].as_str().filter(|id| !id.is_empty()).ok_or_else(|| format!("{family}.run: each command has an `id`"))?; - admitted.push((id.to_string(), self.admit_at(id, &c["params"], area, 0, &mut budget)?)); + admitted.push((id.to_string(), self.admit_at(&who, id, &c["params"], area, 0, &mut budget, &[]).map_err(|r| r.words)?)); } Ok(admitted) } @@ -379,7 +429,25 @@ impl Door { /// in turn, and its reviewed file parameters inside the area. Returns /// the parameters to run it with (file parameters made absolute). pub fn admit(&self, id: &str, params: &Value, area: &Area) -> Result { - self.admit_at(id, params, area, 0, &mut Budget::default()) + self.admit_at(&format!("{}.run", self.family), id, params, area, 0, &mut Budget::default(), &[]).map_err(|r| r.words) + } + + /// Admit a whole call of one of the service's own methods before any of + /// it runs: the commands `method` built itself from reviewed values, at + /// most `most` of them (the method's own ceiling, its reason beside it in + /// the service, in place of `run`'s [`MAX_COMMANDS`]), each admitted as + /// [`Door::admit`] admits a command of `run`: the same allowlist, held + /// commands, setter keys, inner ids, reviewed reads inside the area and + /// limits, the reads and copies of the call sharing one budget. Besides, + /// a `file` command of `unread` runs when the call names none of the + /// parameters it would read a file through ([`Unread`]). Refusals name + /// `method` where `run`'s name `.run`, and say their kind. + pub fn admit_method(&self, method: &str, cmds: &[(&str, Value)], most: usize, area: &Area, unread: &[Unread]) -> Result, Refusal> { + if cmds.len() > most { + return Err(Refusal::new(Refused::Count, format!("{method}: {} engine commands, more than the {most} this method runs in one call", cmds.len()))); + } + let mut budget = Budget::default(); + cmds.iter().map(|(id, params)| Ok((id.to_string(), self.admit_at(method, id, params, area, 0, &mut budget, unread)?))).collect() } /// How many times an admitted command multiplies what the document @@ -396,64 +464,74 @@ impl Door { } /// [`Door::admit`] at nesting `depth`, adding what it asks for to the - /// call's `budget`. - fn admit_at(&self, id: &str, params: &Value, area: &Area, depth: usize, budget: &mut Budget) -> Result { + /// call's `budget`; `who` names the call in a refusal (`.run`, + /// or a service's own method), and `unread` is a method's reviewed list + /// ([`Unread`]; empty for `run`). + #[allow(clippy::too_many_arguments)] + fn admit_at(&self, who: &str, id: &str, params: &Value, area: &Area, depth: usize, budget: &mut Budget, unread: &[Unread]) -> Result { let family = self.family; + let other = |words: String| Refusal::new(Refused::Other, words); if depth > MAX_DEPTH { - return Err(format!("{family}.run: `{id}`: commands nest too deep")); + return Err(other(format!("{who}: `{id}`: commands nest too deep"))); } - let class = self.class(id).ok_or_else(|| format!("{family}.run: `{id}` is not a reviewed {family} command, so the door does not run it"))?; + let class = self.class(id).ok_or_else(|| other(format!("{who}: `{id}` is not a reviewed {family} command, so the door does not run it")))?; if let Some(held) = self.reviewed.held.iter().find(|h| h.id == id) { - return Err(format!("{family}.run: `{id}` is held back from the door: {}", held.why)); + return Err(Refusal::new(Refused::Held, format!("{who}: `{id}` is held back from the door: {}", held.why))); } let mut params = if params.is_null() { Value::Object(Default::default()) } else { params.clone() }; if !params.is_object() { - return Err(format!("{family}.run: `{id}`: `params` is an object")); + return Err(other(format!("{who}: `{id}`: `params` is an object"))); } match class { Class::Safe => {} + Class::File if !self.reviewed.file_reads.iter().any(|r| r.id == id) && unread.iter().any(|u| u.id == id) => { + let u = unread.iter().find(|u| u.id == id).map(|u| u.reads_through).unwrap_or(&[]); + if let Some(key) = u.iter().find(|key| params.get(**key).is_some()) { + return Err(other(format!("{who}: `{id}`: `{key}` would have it read a file, and the service never names it"))); + } + } Class::File => { let read = self.reviewed.file_reads.iter().find(|r| r.id == id).ok_or_else(|| { - format!("{family}.run: `{id}` reads or writes files beyond the door's `path` and `out`, and is not reviewed to run through it") + other(format!("{who}: `{id}` reads or writes files beyond the door's `path` and `out`, and is not reviewed to run through it")) })?; for key in read.params { if let Some(rel) = params.get(*key).cloned() { - let rel = rel.as_str().ok_or_else(|| format!("{family}.run: `{id}`: `{key}` is a relative path"))?; - let (abs, len) = existing_file(area, rel).map_err(|e| format!("{family}.run: `{id}`: `{key}`: {e}"))?; + let rel = rel.as_str().ok_or_else(|| other(format!("{who}: `{id}`: `{key}` is a relative path")))?; + let (abs, len) = existing_file(area, rel).map_err(|e| other(format!("{who}: `{id}`: `{key}`: {e}")))?; budget.read = budget.read.saturating_add(len); if budget.read > MAX_READ_BYTES { - return Err(format!("{family}.run: `{id}`: the files this call reads total more than {MAX_READ_BYTES} bytes")); + return Err(Refusal::new(Refused::Limit, format!("{who}: `{id}`: the files this call reads total more than {MAX_READ_BYTES} bytes"))); } params[*key] = Value::String(abs.to_string_lossy().into_owned()); } } } - other => { - return Err(format!("{family}.run: `{id}` is classed {}: {}, so the door never runs it", other.name(), other.refusal())); + never => { + return Err(other(format!("{who}: `{id}` is classed {}: {}, so the door never runs it", never.name(), never.refusal()))); } } if let Some(setter) = self.reviewed.setters.iter().find(|s| s.id == id) { let keys = (setter.keys_of)(¶ms); if setter.keys.is_empty() { - return Err(format!("{family}.run: `{id}` sets app-wide variables, and no key of it is reviewed for the door")); + return Err(other(format!("{who}: `{id}` sets app-wide variables, and no key of it is reviewed for the door"))); } if keys.is_empty() { - return Err(format!("{family}.run: `{id}`: name the key it sets")); + return Err(other(format!("{who}: `{id}`: name the key it sets"))); } if let Some(key) = keys.iter().find(|k| !setter.keys.contains(&k.as_str())) { - return Err(format!("{family}.run: `{id}`: `{key}` is not a key reviewed for the door")); + return Err(other(format!("{who}: `{id}`: `{key}` is not a key reviewed for the door"))); } } for inner in self.reviewed.inner.iter().filter(|i| i.id == id) { let Some(named) = params.get(inner.param).cloned() else { continue }; match &inner.rule { InnerRule::Command { prefix, params: key } => { - let inner_id = named.as_str().ok_or_else(|| format!("{family}.run: `{id}`: `{}` names a command", inner.param))?; + let inner_id = named.as_str().ok_or_else(|| other(format!("{who}: `{id}`: `{}` names a command", inner.param)))?; if !inner_id.starts_with(prefix) { - return Err(format!("{family}.run: `{id}` runs only `{prefix}*` commands, not `{inner_id}`")); + return Err(other(format!("{who}: `{id}` runs only `{prefix}*` commands, not `{inner_id}`"))); } let inner_params = params.get(*key).cloned().unwrap_or(Value::Null); - let admitted = self.admit_at(inner_id, &inner_params, area, depth + 1, budget)?; + let admitted = self.admit_at(who, inner_id, &inner_params, area, depth + 1, budget, unread)?; params[*key] = admitted; } InnerRule::Effect { builtin } => { @@ -464,33 +542,38 @@ impl Door { }; for name in names { if !builtin(name) { - return Err(format!( - "{family}.run: `{id}`: `{name}` is not an effect the engine builds in (an effect plug-in never runs through the door)" - )); + return Err(other(format!( + "{who}: `{id}`: `{name}` is not an effect the engine builds in (an effect plug-in never runs through the door)" + ))); } } } } } for limit in self.reviewed.limits.iter().filter(|l| l.id == id) { - let Some(amount) = limit.measure.of(¶ms).map_err(|e| format!("{family}.run: `{id}`: {e}"))? else { continue }; + let Some(amount) = limit.measure.of(¶ms).map_err(|e| other(format!("{who}: `{id}`: {e}")))? else { continue }; if amount.is_nan() || amount > limit.max { let (n, max, what) = (shown(amount), shown(limit.max), limit.what); - return Err(match &limit.measure { - Measure::Product(_) => format!( - "{family}.run: `{id}`: {} is {n}, more than the {max} {what} the door allows in one command", - limit.measure.label() - ), - Measure::Custom(_) => format!("{family}.run: `{id}` asks for {n} {what}, more than the {max} the door allows in one command"), - }); + return Err(Refusal::new( + Refused::Limit, + match &limit.measure { + Measure::Product(_) => { + format!("{who}: `{id}`: {} is {n}, more than the {max} {what} the door allows in one command", limit.measure.label()) + } + Measure::Custom(_) => format!("{who}: `{id}` asks for {n} {what}, more than the {max} the door allows in one command"), + }, + )); } if limit.copies { budget.copies *= amount.max(1.0); if budget.copies.is_nan() || budget.copies > self.reviewed.copies_per_call { - return Err(format!( - "{family}.run: `{id}`: the copies this call makes multiply to {}, more than the {} the door allows in one call (a copy multiplies what the commands before it made)", - shown(budget.copies), - shown(self.reviewed.copies_per_call) + return Err(Refusal::new( + Refused::Limit, + format!( + "{who}: `{id}`: the copies this call makes multiply to {}, more than the {} the door allows in one call (a copy multiplies what the commands before it made)", + shown(budget.copies), + shown(self.reviewed.copies_per_call) + ), )); } } @@ -749,6 +832,92 @@ mod tests { assert_eq!(d.copies("canvas.new", &json!({"size": [10, 10]})), 1.0, "not a copy"); } + /// A service's own method passes the same gate under its own ceiling: + /// at the ceiling the call is admitted, one over is refused as a count, + /// before any command is looked at. + #[test] + fn a_methods_own_ceiling_counts_its_commands() { + let (_dir, area) = area(); + let d = door(); + let cmds: Vec<(&str, Value)> = (0..3).map(|_| ("edit.undo", json!({}))).collect(); + assert_eq!(d.admit_method("demo.tidy", &cmds, 3, &area, &[]).unwrap().len(), 3); + let r = d.admit_method("demo.tidy", &cmds, 2, &area, &[]).unwrap_err(); + assert_eq!(r, Refusal { kind: Refused::Count, words: "demo.tidy: 3 engine commands, more than the 2 this method runs in one call".into() }); + // The count comes first: a call over its ceiling is refused even + // when every command would pass. + let many: Vec<(&str, Value)> = (0..MAX_COMMANDS + 10).map(|_| ("shape.rect", json!({}))).collect(); + assert_eq!(d.admit_method("demo.tidy", &many, MAX_COMMANDS + 10, &area, &[]).unwrap().len(), MAX_COMMANDS + 10, "not `run`'s 64"); + assert_eq!(d.admit_method("demo.tidy", &[], 0, &area, &[]).unwrap(), []); + } + + /// Every command of a method passes the allowlist, the held commands, + /// the reviewed reads and the limits exactly as a `run` command does, + /// with the reads and copies of the call sharing one budget; each + /// refusal names the method and says its kind. + #[test] + fn a_methods_commands_pass_the_same_gate_and_say_why_they_are_refused() { + let (dir, area) = area(); + let d = door(); + let refused = |cmds: &[(&str, Value)]| d.admit_method("demo.edit", cmds, 10, &area, &[]).unwrap_err(); + let r = refused(&[("shape.rect", json!({})), ("shape.ellipse", json!({}))]); + assert_eq!(r, Refusal { kind: Refused::Held, words: "demo.edit: `shape.ellipse` is held back from the door: the engine can abort on a huge one (#0)".into() }); + let r = refused(&[("shape.array", json!({"rows": 11, "columns": 10}))]); + assert_eq!(r.kind, Refused::Limit); + assert_eq!(r.words, "demo.edit: `shape.array`: `rows` × `columns` is 110, more than the 100 copies the door allows in one command"); + let r = refused(&[("shape.array", json!({"rows": 10, "columns": 10})), ("shape.array", json!({"rows": 10, "columns": 10}))]); + assert!(r.kind == Refused::Limit && r.words.contains("the copies this call makes multiply to 10000"), "{r:?}"); + for (id, class) in [("plugin.install", "code"), ("help.online", "network"), ("media.play", "device"), ("prefs.set", "host")] { + let r = refused(&[(id, json!({}))]); + assert!(r.kind == Refused::Other && r.words.starts_with(&format!("demo.edit: `{id}` is classed {class}")), "{r:?}"); + } + let r = refused(&[("file.saveAs", json!({"path": "x"}))]); + assert!(r.kind == Refused::Other && r.words.contains("not reviewed to run through it"), "{r:?}"); + let r = refused(&[("brand.new", json!({}))]); + assert!(r.kind == Refused::Other && r.words.contains("not a reviewed demo command"), "{r:?}"); + let r = refused(&[("insert.picture", json!({"path": "../outside.png"}))]); + assert!(r.kind == Refused::Other && r.words.starts_with("demo.edit: `insert.picture`: `path`: "), "a path outside the area: {r:?}"); + let r = refused(&[("effect.apply", json!({"effect": "plugin.evil"}))]); + assert_eq!(r.kind, Refused::Other); + let r = refused(&[("perspective.draw", json!({"command": "shape.ellipse", "params": {}}))]); + assert_eq!(r.kind, Refused::Held, "held through an inner command too: {r:?}"); + // A reviewed read comes back as the file's resolved path, and the + // reads of one call share the budget. + let big = std::fs::File::create(dir.path().join("big.png")).unwrap(); + big.set_len(MAX_READ_BYTES / 2 + 1).unwrap(); + let one = d.admit_method("demo.edit", &[("insert.picture", json!({"path": "big.png"}))], 1, &area, &[]).unwrap(); + assert_eq!(one[0].1["path"], json!(dir.path().canonicalize().unwrap().join("big.png").to_string_lossy())); + let r = refused(&[("insert.picture", json!({"path": "big.png"})), ("insert.picture", json!({"path": "big.png"}))]); + assert!(r.kind == Refused::Limit && r.words.contains("total more than"), "{r:?}"); + // `run`'s own words are unchanged. + assert!(d.admit("shape.ellipse", &json!({}), &area).unwrap_err().starts_with("demo.run: `shape.ellipse` is held back")); + } + + /// A `file` command reviewed to run without its file access runs only + /// for a service's own method, only when the call names none of the + /// parameters it reads a file through (present at all, `null` too), and + /// never through `run`. + #[test] + fn an_unread_file_command_runs_only_for_a_method_and_only_without_its_reading_keys() { + let (_dir, area) = area(); + let d = door(); + static UNREAD: &[Unread] = &[Unread { id: "file.saveAs", reads_through: &["path"] }]; + let ok = d.admit_method("demo.edit", &[("file.saveAs", json!({"format": "deck"}))], 1, &area, UNREAD).unwrap(); + assert_eq!(ok[0].1, json!({"format": "deck"}), "nothing resolved: it reads no file"); + for params in [json!({"path": "x.deck"}), json!({"path": null}), json!({"format": "deck", "path": "../out"})] { + let r = d.admit_method("demo.edit", &[("file.saveAs", params.clone())], 1, &area, UNREAD).unwrap_err(); + assert_eq!(r, Refusal { kind: Refused::Other, words: "demo.edit: `file.saveAs`: `path` would have it read a file, and the service never names it".into() }, "{params}"); + } + // Without the method's list, and through `run`, it stays refused. + let r = d.admit_method("demo.edit", &[("file.saveAs", json!({}))], 1, &area, &[]).unwrap_err(); + assert!(r.words.contains("not reviewed to run through it"), "{r:?}"); + assert!(d.admit("file.saveAs", &json!({}), &area).unwrap_err().contains("not reviewed to run through it")); + assert!(d.admit_all(&json!([{"id": "file.saveAs"}]), &area).unwrap_err().contains("not reviewed to run through it")); + assert!(!d.runs("file.saveAs") && !d.runnable().contains(&"file.saveAs")); + // The list widens no other class: a code command stays refused. + static WRONG: &[Unread] = &[Unread { id: "plugin.install", reads_through: &["path"] }]; + assert!(d.admit_method("demo.edit", &[("plugin.install", json!({}))], 1, &area, WRONG).unwrap_err().words.contains("classed code")); + } + #[test] fn a_held_command_is_refused_with_its_reason_wherever_it_is_named() { let (_dir, area) = area(); diff --git a/crates/engine-area/src/lib.rs b/crates/engine-area/src/lib.rs index 20ba372f..76b3871b 100644 --- a/crates/engine-area/src/lib.rs +++ b/crates/engine-area/src/lib.rs @@ -83,6 +83,19 @@ impl std::fmt::Debug for Area { /// Staging folders' names start with this, inside the area they stage for. pub const STAGING_PREFIX: &str = ".engine-staging-"; +/// The words that end every refusal of an [`Area`] for its quota +/// ([`Area::write`], [`Area::check`], [`Stage::commit`]): the result would +/// not fit what is left of the storage. Built into those messages here, so +/// [`no_room`] can never drift from them. +pub const NO_ROOM: &str = "bytes left in this storage"; + +/// Whether `error`, an error an [`Area`] returned, is its quota refusal +/// ([`NO_ROOM`]), which a service answers as its own "storage is full", +/// rather than any other refusal (a name taken, a path outside the area). +pub fn no_room(error: &str) -> bool { + error.ends_with(NO_ROOM) +} + static NEXT: AtomicU64 = AtomicU64::new(1); impl Area { @@ -201,7 +214,7 @@ impl Area { }; if let Some(room) = self.room() { if len > room.saturating_add(replaced) { - return Err(format!("the result is {len} bytes, more than the {room} bytes left in this storage")); + return Err(format!("the result is {len} bytes, more than the {room} {NO_ROOM}")); } } Ok(replaced) @@ -352,7 +365,7 @@ impl Stage<'_> { } if let Some(room) = self.area.room() { if added > room.saturating_add(freed) { - return Err(format!("the results are {added} bytes, more than the {room} bytes left in this storage")); + return Err(format!("the results are {added} bytes, more than the {room} {NO_ROOM}")); } } let mut placed: Vec<&Path> = Vec::new(); @@ -575,6 +588,22 @@ mod tests { assert_eq!(Area::new(dir.path(), None, false).room(), None); } + /// Every quota refusal, and only a quota refusal, is [`no_room`]'s. + #[test] + fn quota_refusals_are_told_apart_from_the_others() { + let dir = tempfile::tempdir().unwrap(); + let area = Area::new(dir.path(), Some(4), false); + assert!(no_room(&area.write(&dir.path().join("a"), b"123456").unwrap_err())); + assert!(no_room(&area.check(&dir.path().join("a"), 5).unwrap_err())); + let stage = area.stage().unwrap(); + std::fs::write(stage.path("b"), b"123456").unwrap(); + assert!(no_room(&stage.commit(&[(stage.path("b"), dir.path().join("b"))]).unwrap_err())); + area.write(&dir.path().join("c"), b"1").unwrap(); + let taken = area.write(&dir.path().join("c"), b"1").unwrap_err(); + assert!(!no_room(&taken), "{taken}"); + assert!(!no_room(&area.check(&dir.path().join("x/../y"), 1).unwrap_err())); + } + /// What a call removes outside its own writes (a cache it cleared) is /// room again, for a plain write and for a stage that borrows the area. #[test] diff --git a/docs/adr/0013-craft-engines-as-pinned-services.md b/docs/adr/0013-craft-engines-as-pinned-services.md index ba77d279..e84c8451 100644 --- a/docs/adr/0013-craft-engines-as-pinned-services.md +++ b/docs/adr/0013-craft-engines-as-pinned-services.md @@ -391,7 +391,7 @@ Each engine's surface: | Engine | The system agent's tools | What its door runs beyond `safe` ids | Caps (one call) | | --- | --- | --- | --- | | word | `word.info`, `word.run` | 328 of 389 ids. Reads: `insert.picture`, `picture.change` (`path`). | Tables ≤ 10,000 cells; pages 72–1584 pt a side; a replacement grows the text ≤ 1,000× (chained, ≤ 10,000×); the document ≤ 500,000 characters, 50,000 paragraphs and 128 MiB of pictures; a PDF ≤ 10,000 pages. | -| deck | `deck.info`, `deck.run` | 198 of 222. Reads: `insert.picture`, `picture.change` (`path`). Held back until deckcraft bounds its media and zip parsing (hostile data can abort the shell, #448): `insert.audio`, `insert.video`, `media.info`, `media.posterFrame`, `file.openBytes`. | Tables ≤ 5,625 cells; charts ≤ 10,000 points; a slide ≤ 1920 × 1080 pt of area; the presentations ≤ 500 slides, 20,000 shapes and 1,000,000 characters; rasters ≤ 160 MP a call, 4096² each. | +| deck | `deck.info`, `deck.run` | 198 of 222. Reads: `insert.picture`, `picture.change` (`path`). Held back until deckcraft bounds its media and zip parsing (hostile data can abort the shell, #448): `insert.audio`, `insert.video`, `media.info`, `media.posterFrame`, `file.openBytes`. Apps' own requests also reach Quick Deck v2's open documents (`apps/quickdeck/design/SERVICE.md`): each method's commands, with arguments the service builds, pass the same door under the method's own most commands a call, and are folded into one undo step; `shape.fill` and `design.background` run there without `picture` or `path`; the picture reads take a PNG, JPEG, WebP, GIF, TIFF or BMP in the app's storage. Every file the service opens, on every path, first passes its own import guard: a zip's directory read with bounds, only in a shape the engine's own zip reader (the `zip` crate deckcraft uses) reads alike and then checked against that reader's reading, and a zip inside it (except an embedded object under `ppt/embeddings/`, such as a chart's workbook, which nothing unzips), audio and video, an encrypted package and a legacy `.ppt` refused before the engine reads a byte. | Tables ≤ 5,625 cells; charts ≤ 10,000 points; a slide ≤ 1920 × 1080 pt of area; the presentations ≤ 500 slides, 20,000 shapes and 1,000,000 characters; rasters ≤ 160 MP a call, 4096² each. Apps' own requests: ≤ 8 open documents a caller, 32 in all; a drawing 48–3840 px wide, ≤ 4096 px a side and 16 MP; ≤ 500 slides or shapes named a call; a table fill ≤ 1,024 cells; notes ≤ 20,000 characters; a picture ≤ 50 MP; a PDF export ≤ 160 MP, an image export ≤ 64 slides and 256 MP; a render cache of ≤ 16 MiB and 64 files, cleared before a write would fail for room. The import guard: ≤ 10,000 entries, 64 MiB an entry, 256 MiB in all, 100 to 1 past 100 KiB. | | cad | `cad.info`, `cad.run` | 288 of 295. `setvar` is refused: it sets variables by name, and no name is reviewed. | Arrays and copies ≤ 10,000 copies, multiplied across the call ≤ 10,000; polygons ≤ 1,024 sides; spline fit points ≤ 2,000; hatch and linetype scale ≥ 0.0001; drawings ≤ 200,000 objects; a render ≤ about a second of drawing work, estimated first. | | light | `light.info`, `light.run` | 189 of 239. Nothing more. | Originals ≤ 64 MP; exports ≤ 16 MP (AVIF ≤ 4); ≤ 16 photos (virtual copies included); ≤ 16 masks, 256 strokes and 64 spots; a crop ≥ 1% a side. | | film | `film.info`, `film.run` | 525 of 675. Read: `captions.import` (`path`). Built-ins only: `effects.apply`, both transition commands, `effects.setDefaultTransition`, `mixer.addInsert`, `presets.apply`, `lumetri.applyPreset`, `essentialSound.applyPreset`. | Sequences ≤ 4096 a side and 9.4 MP, ≤ 120 fps, ≤ 96 kHz; speed 1–10,000%; durations ≤ 24 h; a call adds ≤ 5,000 elements; analyses ≤ 18,000 frames; exports ≤ 18,000 frames. | diff --git a/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md b/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md index 76485a1d..99c4850b 100644 --- a/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md +++ b/docs/adr/0013-craft-engines-as-pinned-services.zh-CN.md @@ -313,7 +313,7 @@ App Hub 的 card-host)。 | 引擎 | 系统代理的工具 | 入口在 `safe` id 之外还运行什么 | 上限(单次调用) | | --- | --- | --- | --- | | word | `word.info`、`word.run` | 389 个 id 中的 328 个。读取:`insert.picture`、`picture.change`(`path`)。 | 表格 ≤ 10,000 个单元格;页面每边 72–1584 pt;一次替换使文本最多增长 1,000 倍(连续替换合计 ≤ 10,000 倍);文档 ≤ 500,000 个字符、50,000 个段落、128 MiB 图片;PDF ≤ 10,000 页。 | -| deck | `deck.info`、`deck.run` | 222 个中的 198 个。读取:`insert.picture`、`picture.change`(`path`)。在 deckcraft 限制其媒体和 zip 解析之前暂缓开放(恶意数据可能让 Shell 进程中止,#448):`insert.audio`、`insert.video`、`media.info`、`media.posterFrame`、`file.openBytes`。 | 表格 ≤ 5,625 个单元格;图表 ≤ 10,000 个数据点;单张幻灯片面积 ≤ 1920 × 1080 pt;演示文稿 ≤ 500 张幻灯片、20,000 个形状、1,000,000 个字符;每次调用的栅格化 ≤ 160 MP,每张 ≤ 4096²。 | +| deck | `deck.info`、`deck.run` | 222 个中的 198 个。读取:`insert.picture`、`picture.change`(`path`)。在 deckcraft 限制其媒体和 zip 解析之前暂缓开放(恶意数据可能让 Shell 进程中止,#448):`insert.audio`、`insert.video`、`media.info`、`media.posterFrame`、`file.openBytes`。应用自己的请求还能使用 Quick Deck v2 的打开文档(`apps/quickdeck/design/SERVICE.md`):每个方法的命令参数由服务自己构造,在该方法自己的每次调用命令数上限之内通过同一个入口,并合并为一个撤销步骤;`shape.fill` 和 `design.background` 在这里运行时不带 `picture` 或 `path`;图片读取只接受应用存储中的 PNG、JPEG、WebP、GIF、TIFF 或 BMP。服务打开的每个文件,无论经由哪条路径,都先通过它自己的导入检查:有界地读取 zip 的目录,只接受引擎自己的 zip 读取器(deckcraft 使用的 `zip` crate)会以同样方式读取的结构,并与该读取器的读取结果逐项比对,并在引擎读到任何字节之前拒绝其中嵌套的 zip(`ppt/embeddings/` 下的嵌入对象除外,例如图表的工作簿,没有任何组件会解压它)、音频和视频、加密包以及旧版 `.ppt`。 | 表格 ≤ 5,625 个单元格;图表 ≤ 10,000 个数据点;单张幻灯片面积 ≤ 1920 × 1080 pt;演示文稿 ≤ 500 张幻灯片、20,000 个形状、1,000,000 个字符;每次调用的栅格化 ≤ 160 MP,每张 ≤ 4096²。应用自己的请求:每个调用方 ≤ 8 个打开的文档,合计 ≤ 32 个;绘制宽 48–3840 px、每边 ≤ 4096 px 且 ≤ 16 MP;每次调用列出的幻灯片或形状 ≤ 500 个;一次表格填充 ≤ 1,024 个单元格;备注 ≤ 20,000 个字符;图片 ≤ 50 MP;PDF 导出 ≤ 160 MP,图像导出 ≤ 64 张幻灯片、合计 ≤ 256 MP;渲染缓存 ≤ 16 MiB 和 64 个文件,写入会因空间不足失败之前先清空。导入检查:≤ 10,000 个条目,每个条目 ≤ 64 MiB,合计 ≤ 256 MiB,超过 100 KiB 的条目压缩比 ≤ 100 比 1。 | | cad | `cad.info`、`cad.run` | 295 个中的 288 个。`setvar` 被拒绝:它按名称设置变量,而没有任何名称经过审查。 | 阵列和复制 ≤ 10,000 份,一次调用内相乘 ≤ 10,000;多边形 ≤ 1,024 条边;样条拟合点 ≤ 2,000;填充和线型比例 ≥ 0.0001;图形 ≤ 200,000 个对象;渲染约 ≤ 一秒的绘制工作量,先估算再绘制。 | | light | `light.info`、`light.run` | 239 个中的 189 个。没有其他。 | 原图 ≤ 64 MP;导出 ≤ 16 MP(AVIF ≤ 4);≤ 16 张照片(含虚拟副本);≤ 16 个蒙版、256 笔画笔、64 个污点;裁剪每边 ≥ 1%。 | | film | `film.info`、`film.run` | 675 个中的 525 个。读取:`captions.import`(`path`)。只运行内置项:`effects.apply`、两个转场命令、`effects.setDefaultTransition`、`mixer.addInsert`、`presets.apply`、`lumetri.applyPreset`、`essentialSound.applyPreset`。 | 序列每边 ≤ 4096 且 ≤ 9.4 MP,≤ 120 fps,≤ 96 kHz;速度 1%–10,000%;时长 ≤ 24 小时;一次调用最多添加 5,000 个元素;分析 ≤ 18,000 帧;导出 ≤ 18,000 帧。 |