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:
parent
f0d230bdcd
commit
ad469f9b13
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue