Merge Shapes (union, combine, fragment, intersect, subtract); CI workflow running cargo xtask ci; control protocol and MCP docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-06 01:14:15 -04:00
parent f0d230bdcd
commit ad469f9b13
11 changed files with 404 additions and 6 deletions

52
.github/workflows/ci.yml vendored Normal file
View File

@ -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

43
Cargo.lock generated
View File

@ -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",

View File

@ -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"] }

View File

@ -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 }

View File

@ -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<Vec<[f64; 2]>>;
pub const OPS: [(&str, &str); 5] =
[("union", "Union"), ("combine", "Combine"), ("fragment", "Fragment"), ("intersect", "Intersect"), ("subtract", "Subtract")];
pub fn specs() -> Vec<CommandSpec> {
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::<f64>() / 2.0
})
.sum::<f64>()
.abs()
}
/// The merged areas for `op` (one per resulting shape).
pub fn merge_areas(op: &str, areas: &[Area]) -> Vec<Area> {
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<Area> = 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<Rect> = 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<Value> {
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<Shape> = 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<Area> = 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::<Vec<_>>()}))
}
#[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::<f64>();
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::<f64>() - 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());
}
}

View File

@ -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());

View File

@ -349,7 +349,7 @@ impl Session {
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
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));
}

View File

@ -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);

32
docs/control-protocol.md Normal file
View File

@ -0,0 +1,32 @@
# Control protocol
Start the app with `--control <port>`. 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.

37
docs/mcp.md Normal file
View File

@ -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.

View File

@ -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.
<!-- SUMMARY -->
**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 |