diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a4d2916 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,52 @@ +name: ci + +# Quality gates for every push to main and every pull request: rustfmt, clippy (-D warnings), +# tests, asset attribution, crate layering and the wasm32 check — `cargo xtask ci`. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + # Same craft-fonts commit as release.yml (Latin presentation fonts). + CRAFT_FONTS_REF: 43913056ce82a3fdad0ef69f6b88d1dc922a8104 + +jobs: + gates: + name: gates (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-15] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - name: Check out craft-fonts + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + repository: storytold/craft-fonts + ref: ${{ env.CRAFT_FONTS_REF }} + path: craft-fonts + - name: Linux build dependencies + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libasound2-dev libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + with: + key: ci-${{ matrix.os }} + - name: cargo xtask ci + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + run: cargo xtask ci diff --git a/Cargo.lock b/Cargo.lock index 58e3fcc..deee629 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1588,6 +1588,48 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "i_float" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecaff1ce29bcacd72cdf581ba6fce636ef4c4e0b2da7e7846dd0137d98141e2" +dependencies = [ + "libm", +] + +[[package]] +name = "i_key_sort" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c6c58d0c60705e66264ce0f788a69a2f21472aeb8188559e7c8c619dbdc10fa" + +[[package]] +name = "i_overlay" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a518dce2d7cf7e3758159747e7175c8b86ccdcd8d333dfcb7dbeb3eac2e324fc" +dependencies = [ + "i_float", + "i_key_sort", + "i_shape", + "i_tree", +] + +[[package]] +name = "i_shape" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa1878f91ef7921feaf1b2346253ebc5e661878b22d5715195c1d5dcf0f20ae5" +dependencies = [ + "i_float", +] + +[[package]] +name = "i_tree" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9d4a992a9fe83130f41ceacceac3bb116a4355dfc9c8d6ecea4f1e4b4c6caf" + [[package]] name = "image" version = "0.25.10" @@ -3161,6 +3203,7 @@ dependencies = [ name = "slidecraft-engine" version = "0.1.0" dependencies = [ + "i_overlay", "image", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index 71400ab..b0d1b10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,6 +45,7 @@ vello_cpu = "0.2" vello_common = { version = "0.2", default-features = false } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "tiff", "bmp"] } krilla = { version = "0.8", features = ["pdf"] } +i_overlay = "9" quick-xml = "0.38" zip = { version = "3", default-features = false, features = ["deflate"] } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 981acc6..ba24456 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -20,6 +20,7 @@ slidecraft-format = { workspace = true } slidecraft-pptx = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +i_overlay = { workspace = true } thiserror = { workspace = true } log = { workspace = true } image = { workspace = true } diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs new file mode 100644 index 0000000..207e3fe --- /dev/null +++ b/crates/engine/src/cmd/merge.rs @@ -0,0 +1,219 @@ +//! Merge Shapes (Shape Format ▸ Insert Shapes): union, combine, fragment, intersect and subtract +//! the outlines of the selected shapes. The result takes the formatting of the first selected +//! shape, like PowerPoint. + +use i_overlay::core::fill_rule::FillRule; +use i_overlay::core::overlay_rule::OverlayRule; +use i_overlay::float::single::SingleFloatOverlay; +use serde_json::{Value, json}; +use slidecraft_geom::{PathEl, Rect, Xfrm}; +use slidecraft_model::{CustomPath, Geom, Shape, ShapeId, ShapeKind}; + +use super::*; +use crate::{Result, Session}; + +/// Closed polygons (slide points) describing a filled area (non-zero winding). +type Area = Vec>; + +pub const OPS: [(&str, &str); 5] = + [("union", "Union"), ("combine", "Combine"), ("fragment", "Fragment"), ("intersect", "Intersect"), ("subtract", "Subtract")]; + +pub fn specs() -> Vec { + vec![cmd!( + "shape.merge", + "Merge Shapes", + ["Shape Format", "Insert Shapes", "Merge Shapes"], + None, + "{op: union|combine|fragment|intersect|subtract, ids?: shapes in selection order (the first one's formatting is kept)} → {ids}", + has_two, + merge + )] +} + +/// The filled outline of `s` in slide points (curves flattened). +fn area_of(doc: &slidecraft_model::Presentation, sel: &crate::Selection, s: &Shape) -> Area { + let x = xfrm_of(doc, sel, s); + let geo = slidecraft_render::shape_geometry(s, x.w, x.h); + let a = x.affine(); + let mut out: Area = vec![]; + for sp in geo.paths.iter().filter(|p| p.fill != slidecraft_geom::preset::FillMode::None) { + let mut cur: Vec<[f64; 2]> = vec![]; + let flush = |cur: &mut Vec<[f64; 2]>, out: &mut Area| { + if cur.len() > 2 { + out.push(std::mem::take(cur)); + } else { + cur.clear(); + } + }; + slidecraft_geom::preset_flatten(&sp.path, &mut |el| match el { + PathEl::MoveTo(p) => { + flush(&mut cur, &mut out); + let q = a * p; + cur.push([q.x, q.y]); + } + PathEl::LineTo(p) => { + let q = a * p; + cur.push([q.x, q.y]); + } + PathEl::ClosePath => flush(&mut cur, &mut out), + _ => {} + }); + flush(&mut cur, &mut out); + } + out +} + +fn overlay(a: &Area, b: &Area, rule: OverlayRule) -> Area { + if a.is_empty() && matches!(rule, OverlayRule::Union | OverlayRule::Xor) { + return b.clone(); + } + if b.is_empty() { + return if matches!(rule, OverlayRule::Intersect) { vec![] } else { a.clone() }; + } + a.overlay(b, rule, FillRule::NonZero).into_iter().flatten().filter(|c| c.len() > 2).collect() +} + +fn area_size(a: &Area) -> f64 { + a.iter() + .map(|c| { + let n = c.len(); + (0..n).map(|i| c[i][0] * c[(i + 1) % n][1] - c[(i + 1) % n][0] * c[i][1]).sum::() / 2.0 + }) + .sum::() + .abs() +} + +/// The merged areas for `op` (one per resulting shape). +pub fn merge_areas(op: &str, areas: &[Area]) -> Vec { + let Some((first, rest)) = areas.split_first() else { return vec![] }; + let fold = |rule| rest.iter().fold(first.clone(), |acc, b| overlay(&acc, b, rule)); + let out = match op { + "union" => vec![fold(OverlayRule::Union)], + "combine" => vec![fold(OverlayRule::Xor)], + "intersect" => vec![fold(OverlayRule::Intersect)], + "subtract" => { + let others = rest.iter().fold(Area::new(), |acc, b| overlay(&acc, b, OverlayRule::Union)); + vec![overlay(first, &others, OverlayRule::Difference)] + } + _ => { + // Fragment: every region of the arrangement becomes its own shape. + let mut pieces: Vec = vec![first.clone()]; + let mut covered = first.clone(); + for b in rest { + let mut next = vec![]; + for p in &pieces { + next.push(overlay(p, b, OverlayRule::Intersect)); + next.push(overlay(p, b, OverlayRule::Difference)); + } + next.push(overlay(b, &covered, OverlayRule::Difference)); + covered = overlay(&covered, b, OverlayRule::Union); + pieces = next; + } + // Split pieces into connected outer contours with their holes is not needed for + // rendering; keep each piece as one shape. + pieces + } + }; + out.into_iter().filter(|a| area_size(a) > 0.01).collect() +} + +/// A custom-geometry box for `area`: its bounds and path data relative to them. +fn area_geom(area: &Area) -> Option<(Xfrm, Geom)> { + let mut b: Option = None; + for p in area.iter().flatten() { + let r = Rect::new(p[0], p[1], p[0], p[1]); + b = Some(b.map_or(r, |x| x.union(r))); + } + let b = b?; + let (w, h) = (b.width().max(0.01), b.height().max(0.01)); + let f = |v: f64| { + let s = format!("{v:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() + }; + let mut d = String::new(); + for c in area { + for (i, p) in c.iter().enumerate() { + d += &format!("{}{} {} ", if i == 0 { "M " } else { "L " }, f(p[0] - b.x0), f(p[1] - b.y0)); + } + d += "Z "; + } + let geom = Geom::Custom { paths: vec![CustomPath { w, h, d: d.trim_end().to_string(), ..Default::default() }] }; + Some((Xfrm::new(b.x0, b.y0, w, h), geom)) +} + +fn merge(s: &mut Session, p: &Value) -> Result { + let op = str_param(p, "op").unwrap_or("union").to_ascii_lowercase(); + if !OPS.iter().any(|o| o.0 == op) { + return Err(bad("shape.merge", format!("unknown op `{op}` (union, combine, fragment, intersect, subtract)"))); + } + let ids = targets(s, p)?; + let st = s.doc()?; + let shapes: Vec = ids.iter().filter_map(|id| st.shape(*id).cloned()).collect(); + let mergeable = |x: &Shape| matches!(x.kind, ShapeKind::Shape | ShapeKind::Picture { .. }) && !x.is_line(); + if shapes.len() < 2 || !shapes.iter().all(mergeable) { + return Err(bad("shape.merge", "select two or more shapes (not lines, groups, tables or charts)")); + } + let areas: Vec = shapes.iter().map(|x| area_of(&st.doc, &st.selection, x)).collect(); + let results = merge_areas(&op, &areas); + let template = shapes[0].clone(); + let mut new_ids = vec![]; + s.edit(|doc, sel| { + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.merge", "no slide"))?; + let at = list.iter().position(|x| x.id == template.id).unwrap_or(list.len()); + let mut made = vec![]; + for (k, area) in results.iter().enumerate() { + let Some((x, geom)) = area_geom(area) else { continue }; + let mut sh = template.clone(); + sh.id = ShapeId(0); + sh.ph = None; + sh.xfrm = Some(x); + sh.geom = geom; + sh.name = format!("Freeform {}", k + 1); + // Text stays with the first piece only. + if k > 0 { + sh.text = None; + } + made.push(sh); + } + for sh in &mut made { + sh.id = ShapeId(doc.alloc_id()); + new_ids.push(sh.id); + } + let list = crate::shapes_mut(doc, sel).ok_or_else(|| bad("shape.merge", "no slide"))?; + let at = at.min(list.len()); + for (k, sh) in made.into_iter().enumerate() { + list.insert(at + k, sh); + } + list.retain(|x| !ids.contains(&x.id)); + sel.shapes = new_ids.clone(); + let _ = list; + Ok(()) + })?; + Ok(json!({"ids": new_ids.iter().map(|i| i.0).collect::>()})) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn square(x: f64, y: f64, s: f64) -> Area { + vec![vec![[x, y], [x + s, y], [x + s, y + s], [x, y + s]]] + } + + #[test] + fn boolean_ops_have_the_expected_areas() { + let a = square(0.0, 0.0, 10.0); + let b = square(5.0, 5.0, 10.0); + let areas = [a, b]; + let size = |op| merge_areas(op, &areas).iter().map(area_size).sum::(); + assert!((size("union") - 175.0).abs() < 1e-6); + assert!((size("intersect") - 25.0).abs() < 1e-6); + assert!((size("subtract") - 75.0).abs() < 1e-6); + assert!((size("combine") - 150.0).abs() < 1e-6); + let frags = merge_areas("fragment", &areas); + assert_eq!(frags.len(), 3); + assert!((frags.iter().map(area_size).sum::() - 175.0).abs() < 1e-6); + // Disjoint intersect is empty. + assert!(merge_areas("intersect", &[square(0.0, 0.0, 1.0), square(5.0, 5.0, 1.0)]).is_empty()); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index a086420..74c44ef 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -8,6 +8,7 @@ pub mod file; pub mod format; pub mod insert; pub mod inspect; +pub mod merge; pub mod review; pub mod shape; pub mod slide; @@ -76,13 +77,14 @@ pub fn has_slide(s: &Session) -> std::result::Result<(), String> { if s.active().is_some_and(|d| !d.doc.slides.is_empty() || d.selection.target != crate::Target::Slides) { Ok(()) } else { Err("no slides".into()) } } pub const NOTHING_SELECTED: &str = "nothing selected"; +pub const SELECT_TWO: &str = "select two or more objects"; pub fn has_selection(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; if s.active().is_some_and(|d| !d.selection.shapes.is_empty()) { Ok(()) } else { Err(NOTHING_SELECTED.into()) } } pub fn has_two(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; - if s.active().is_some_and(|d| d.selection.shapes.len() >= 2) { Ok(()) } else { Err("select two or more objects".into()) } + if s.active().is_some_and(|d| d.selection.shapes.len() >= 2) { Ok(()) } else { Err(SELECT_TWO.into()) } } pub fn has_text(s: &Session) -> std::result::Result<(), String> { has_doc(s)?; @@ -127,6 +129,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(insert::specs()); v.extend(shape::specs()); v.extend(arrange::specs()); + v.extend(merge::specs()); v.extend(text::specs()); v.extend(format::specs()); v.extend(design::specs()); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index df73445..57a3a70 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -349,7 +349,7 @@ impl Session { fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; if let Err(e) = (spec.enabled)(self) { - let named = e == cmd::NOTHING_SELECTED && (params.get("ids").is_some() || params.get("id").is_some()); + let named = (e == cmd::NOTHING_SELECTED || e == cmd::SELECT_TWO) && (params.get("ids").is_some() || params.get("id").is_some()); if !named { return Err(EngineError::Disabled(id.into(), e)); } diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index e385da7..13e43f7 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1851,6 +1851,16 @@ fn shape_format(app: &mut SlideApp, ui: &mut Ui) { run(app, "shape.change", json!({"preset": p})); } }); + let can_merge = enabled(app, "shape.merge"); + let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes"); + egui::Popup::menu(&r).show(|ui| { + for (op, label) in slidecraft_engine::cmd::merge::OPS { + if ui.button(label).clicked() { + run(app, "shape.merge", json!({"op": op})); + ui.close(); + } + } + }); }); group(ui, |ui| { let r = big_button(ui, Icon::QuickStyles, "Shape\nStyles", sel); diff --git a/docs/control-protocol.md b/docs/control-protocol.md new file mode 100644 index 0000000..6fdbaac --- /dev/null +++ b/docs/control-protocol.md @@ -0,0 +1,32 @@ +# Control protocol + +Start the app with `--control `. The server listens on `127.0.0.1` only and speaks JSON +lines: one request object per line, one reply per line. + +```json +{"id": 1, "method": "engine.execute", "params": {"command": "shape.insert", "params": {"preset": "roundRect", "rect": [100, 100, 240, 120], "text": "Hello"}}} +{"id": 1, "ok": true, "result": {"id": 258}} +``` + +Coordinates are slide points (1/72 inch; a 16:9 slide is 960 × 540) unless noted. + +| Method | Params | What it does | +|---|---|---| +| `engine.execute` / `ui.menu.invoke` / `command` | `{command, params}` | Run any command (see `engine.commands`; `slidecraft-cli describe ID` documents one) | +| `engine.commands` | — | Every command: id, label, menu path, shortcut, params doc, enabled / disabled reason | +| `document.inspect` | — | Slides, sections, layouts, shapes, selection | +| `ui.inspect` | — | UI state, active tool, window/canvas/slide rects, dialog, slide show state, documents, perf (fps, render ms) | +| `ui.tool.select` | `{tool, preset?}` | `select`, `text`, `shape` + preset, any preset name, `curve`, `freeform`, `scribble`, `pen`, `highlighter`, `eraser` | +| `ui.pointer` | `{events: [{kind: down\|drag\|up\|move\|doubleclick\|tripleclick, x, y, mods?}], mods?}` | Drive the active tool in slide points through the same code path as the mouse | +| `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Keyboard input (also drives a running slide show) | +| `ui.move` / `ui.click` / `ui.drag` | screen points | Real egui pointer input — reaches every widget, menu and pane | +| `ui.set` | `{tab?, view?, pane?, notes?, zoom?, brightness?, ruler?, gridlines?, guides?, …}` | UI state (returns the full state) | +| `ui.dialog.open` / `ui.dialog.close` | `{id, fields?}` | Open or close a dialog by id | +| `ui.screenshot` | `{path?}` | PNG of the whole window (base64 when no path) | +| `ui.render` | `{path?, slide?, scale?}` | Render a slide headlessly | +| `ui.resize` / `ui.focus` | | Window control | +| `show.start` / `show.end` | `{from?: slide index, presenter?, reading?}` | Slide show | +| `app.open` / `app.save` / `app.export` / `app.quit` | `{path, …}` | Files (export takes the `file.export` params, e.g. `{"path": "deck.pdf", "layout": "handouts", "perPage": 3}`) | + +The MCP server (`slidecraft-cli mcp`, see [mcp.md](mcp.md)) wraps the same methods for Claude +and other agents; `slidecraft-cli app METHOD [JSON]` sends one request from a shell. diff --git a/docs/mcp.md b/docs/mcp.md new file mode 100644 index 0000000..b23e02a --- /dev/null +++ b/docs/mcp.md @@ -0,0 +1,37 @@ +# MCP server + +`slidecraft-cli mcp` runs a Model Context Protocol server on stdio. By default it drives its own +headless engine (no window); with `--connect PORT` it drives a running SlideCraft started with +`--control PORT`, so an agent and a person can work on the same deck. `--sample` opens the sample +deck. + +```json +{ + "mcpServers": { + "slidecraft": { "command": "slidecraft-cli", "args": ["mcp", "--connect", "7990"] } + } +} +``` + +## Tools + +| Tool | What it does | +|---|---| +| `list_commands` | Every command (id, label, menu, shortcut, params doc), optionally filtered | +| `run_command` | Run one command: `{command, params}` — every menu item, ribbon button and gesture is a command | +| `batch` | Run several commands in order and stop at the first error | +| `new_presentation`, `open_presentation`, `save_presentation`, `export` | Files: native `.slidecraft`, PowerPoint `.pptx`, PDF (slides, notes pages, handouts), PNG/JPEG, outline | +| `inspect_document`, `inspect_slide` | Structure of the deck / one slide (shapes, text, placeholders, animations) | +| `render_slide` | A slide as an image the model can look at | +| `add_slide`, `go_to_slide` | Slides by layout | +| `add_shape`, `add_text_box`, `set_text` | Shapes and text | +| `insert_picture`, `insert_table`, `insert_chart` | Content | +| `select`, `pointer`, `select_tool`, `key`, `type_text` | The same selection, mouse and keyboard paths as the UI | +| `screenshot` | The whole window, UI included (connected mode) | +| `call_app` | Any [control protocol](control-protocol.md) method (connected mode) | + +Useful commands for building decks: `slide.new {layout, title, body}`, `shape.insert {preset, +rect, text}`, `shape.connect {from, to, preset}`, `shape.freeform {points, closed, smooth}`, +`shape.merge {op, ids}`, `format.*`, `design.theme`, `transition.set`, `animation.set`, +`chart.*`, `table.*`, `file.export {path, layout}`. `slidecraft-cli describe ID` prints any +command's parameters. diff --git a/docs/parity.md b/docs/parity.md index 8ca1413..ad59415 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -11,12 +11,12 @@ Each row is scored **D** (done), **P** (partial: works but lacks options, UI or Live Presentations), add-ins and VBA macros. -**Weighted breadth parity: 76%** over 187 features. +**Weighted breadth parity: 77%** over 187 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 72 | 58 | 13 | 1 | 90% | -| P1 | 65 | 34 | 23 | 8 | 70% | +| P1 | 65 | 35 | 23 | 7 | 72% | | P2 | 50 | 11 | 11 | 28 | 33% | | Area | Parity | @@ -26,7 +26,7 @@ Live Presentations), add-ins and VBA macros. | Slides | 95% | | Views | 90% | | Masters & themes | 80% | -| Shapes | 79% | +| Shapes | 86% | | Format | 75% | | Arrange | 100% | | Text | 83% | @@ -117,7 +117,7 @@ Open P0 items: Start screen (new, templates/themes, recent), Contextual tabs (Sh | Shapes | Connector glue to connection sites | P1 | D | | Shapes | Freeform, curve and scribble tools | P1 | D | | Shapes | Edit points | P1 | M | -| Shapes | Merge shapes (union, combine, fragment, intersect, subtract) | P1 | M | +| Shapes | Merge shapes (union, combine, fragment, intersect, subtract) | P1 | D | | Shapes | Change shape | P1 | D | | Shapes | Text boxes | P0 | D | | Shapes | WordArt | P1 | P |