Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-15 | A `.pptx` slide that can't be read is reported, not dropped in silence: `lostSlides` from `file.open`, a message and a repaired copy in the app, a warning from `deckcraft-cli` (#203) |
| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off |
| 2026-10-16 | A PowerPoint Show (`.ppsx`) opened from the command line or the file association starts its slide show, as in PowerPoint; File › Open still opens it for editing (#862) |
| 2026-10-15 | Design › Browse for Themes… with a `.pptx` or `.potx` brings its slide master and layouts, not just the theme: the master the current slide uses is replaced (a preserved one stays beside it), the template stretches to the deck's slide size, and each slide moves to the layout with the same name or kind and keeps its content. One undo step (#807) |
| 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) |
| 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) |
| 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. |
Expand Down Expand Up @@ -276,6 +277,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-16 | trivial | Progress log: responsive mouse-wheel navigation on the fitted canvas |
| 2026-10-21 | trivial | Progress log: theme `spDef` anchor no longer applied to a file's own shapes |
| 2026-10-21 | trivial | Progress log: 3-D sizes in scaled groups stay in points |
| 2026-10-15 | trivial | Progress log: Browse for Themes applies a template's master and layouts (#807) |
| 2026-10-14 | trivial | remote MCP checks reply IDs, envelopes and bounded newline framing (#718) |
| 2026-10-14 | minor | Gray list markers on empty paragraphs while editing; caret keeps the list indent before typing |
| 2026-10-14 | minor | Community PR wave folded: headline breadth 84% over 191 features (P0 92%, P1 82%, P2 55%) and the feature-area breadths from `cargo xtask parity`; one row per area; Upcoming item 2 merged; per-PR count notes dropped from the progress log. Ready stays ~55%, stage alpha |
Expand Down
167 changes: 165 additions & 2 deletions crates/engine/src/cmd/design.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Browse for Themes…",
["Design", "Themes"],
None,
"{path} | {name, data: base64} — apply the theme of a .thmx, .pptx or .potx file",
"{path} | {name, data: base64} — apply a .pptx or .potx's slide master and layouts (slides move to the matching layouts), or a .thmx's theme",
has_doc,
browse_theme
),
Expand Down Expand Up @@ -161,6 +161,13 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
if !deckcraft_pptx::sniff(&bytes) {
return Err(bad("design.browseTheme", "not a theme or PowerPoint file"));
}
// A presentation or template brings its slide master and layouts, as in PowerPoint; a .thmx
// (its main part is a theme, not a presentation) brings the theme alone.
if let Ok(src) = super::file::open_presentation("theme.pptx", &bytes)
&& src.masters.first().is_some_and(|m| !m.layouts.is_empty())
{
return apply_master(s, &src);
}
let (theme, media) = deckcraft_pptx::import_theme(&bytes).map_err(|e| bad("design.browseTheme", e.to_string()))?;
let name = theme.name.clone();
s.edit(|doc, sel| {
Expand All @@ -180,6 +187,58 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"theme": name}))
}

/// Design › Browse for Themes… with a `.pptx` / `.potx`: its first master and layouts replace the
/// master the current slide uses (a preserved master stays beside it), stretched to this deck's
/// slide size. Each slide on it moves to the new layout with the same name, else the same kind,
/// keeping its content as Layout does.
fn apply_master(s: &mut Session, src: &Presentation) -> Result<Value> {
let from = src.masters.first().ok_or_else(|| bad("design.browseTheme", "no master"))?;
let theme = from.theme.name.clone();
s.edit(|doc, sel| {
let mi = master_index(doc, sel);
let old = doc.masters.get(mi).cloned().ok_or_else(|| bad("design.browseTheme", "no master"))?;
let (mut media, mut layouts) = Default::default();
let mut m = super::reuse::import_master("design.browseTheme", doc, src, from, &mut media, &mut layouts)?;
let (sx, sy) = (doc.slide_size.width / src.slide_size.width.max(1.0), doc.slide_size.height / src.slide_size.height.max(1.0));
if (sx - 1.0).abs() > 1e-9 || (sy - 1.0).abs() > 1e-9 {
scale_shapes(&mut m.shapes, sx, sy, 1.0, 0.0, 0.0);
for l in &mut m.layouts {
scale_shapes(&mut l.shapes, sx, sy, 1.0, 0.0, 0.0);
}
}
let moves: Vec<(usize, deckcraft_model::LayoutId)> =
doc.slides.iter().enumerate().filter_map(|(i, sl)| old.layout(sl.layout).and_then(|l| matching_layout(&m, l)).map(|l| (i, l))).collect();
let layouts = m.layouts.len();
if old.preserve {
doc.masters.insert(mi, Arc::new(m));
} else if let Some(slot) = doc.masters.get_mut(mi) {
*slot = Arc::new(m);
}
for (i, l) in &moves {
super::slide::apply_layout(doc, *i, *l)?;
}
if let Target::Layout { master, .. } = sel.target {
sel.target = Target::Master { master };
}
sel.shapes.clear();
sel.text = None;
Ok(json!({"theme": theme, "layouts": layouts, "slides": moves.len()}))
})
}

/// The layout of `m` a slide on `from` moves to: same name, else same kind (not for custom
/// layouts), else Title and Content, else the first.
fn matching_layout(m: &deckcraft_model::Master, from: &deckcraft_model::Layout) -> Option<deckcraft_model::LayoutId> {
use deckcraft_model::LayoutType;
let ls = &m.layouts;
ls.iter()
.find(|l| !from.name.is_empty() && l.name.trim().eq_ignore_ascii_case(from.name.trim()))
.or_else(|| ls.iter().find(|l| from.kind != LayoutType::Custom && l.kind == from.kind))
.or_else(|| ls.iter().find(|l| l.kind == LayoutType::TitleAndContent))
.or_else(|| ls.first())
.map(|l| l.id)
}

/// `theme` with every `media` id field rewritten through `map` (format-scheme picture fills).
fn remap_media_ids(theme: &deckcraft_model::theme::Theme, map: &std::collections::HashMap<u32, u32>) -> Result<deckcraft_model::theme::Theme> {
fn rec(v: &mut Value, map: &std::collections::HashMap<u32, u32>, depth: usize) {
Expand Down Expand Up @@ -1955,8 +2014,10 @@ mod tests {

#[cfg(test)]
mod browse_theme_tests {
use deckcraft_model::PhType;
use std::sync::Arc;

use deckcraft_model::text::RunKind;
use deckcraft_model::{Background, Fill, PhType};
use serde_json::json;

use crate::{Session, Target};
Expand All @@ -1982,6 +2043,108 @@ mod browse_theme_tests {
assert!(s.execute("design.browseTheme", &json!({})).is_err());
}

/// A 4:3 brand template: a logo and a background on the master, its layouts renamed (so slides
/// can only find them by kind) and its content layout's title moved.
fn brand_template() -> (String, Vec<u8>, deckcraft_model::Xfrm) {
let mut img = image::RgbaImage::new(2, 2);
img.put_pixel(0, 0, image::Rgba([200, 20, 30, 255]));
let mut logo = Vec::new();
image::DynamicImage::ImageRgba8(img).write_to(&mut std::io::Cursor::new(&mut logo), image::ImageFormat::Png).unwrap();
let mut t = Session::with_new();
t.execute("design.slideSize", &json!({"preset": "standard", "scale": "none"})).unwrap();
let title = t
.edit(|doc, _| {
let media = doc.add_media("logo.png", "image/png", logo.clone()).unwrap();
let id = deckcraft_model::ShapeId(doc.alloc_id().unwrap());
let m = Arc::make_mut(doc.masters.get_mut(0).unwrap());
m.shapes.push(deckcraft_model::Shape {
id,
name: "Logo".into(),
kind: deckcraft_model::ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media, ..Default::default() } },
xfrm: Some(deckcraft_model::Xfrm { x: 600.0, y: 20.0, w: 100.0, h: 40.0, ..Default::default() }),
..Default::default()
});
m.background =
Some(Background::Fill { fill: Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0x0A, 0x2B, 0x4C))) });
for l in &mut m.layouts {
l.name = format!("Brand {}", l.name);
}
let obj = m.layouts.iter_mut().find(|l| l.kind == deckcraft_model::LayoutType::TitleAndContent).unwrap();
let title = obj.shapes.iter_mut().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
let x = deckcraft_model::Xfrm { x: 72.0, y: 400.0, w: 576.0, h: 80.0, ..Default::default() };
title.xfrm = Some(x);
Ok(x)
})
.unwrap();
(crate::cmd::base64_encode(&deckcraft_pptx::export_as(&t.doc().unwrap().doc, "potx").unwrap()), logo, title)
}

#[test]
fn a_template_brings_its_master_and_layouts_and_slides_follow() {
let (data, logo, title) = brand_template();
let mut s = Session::with_new();
s.execute("slide.new", &json!({"layout": "titleAndContent", "title": "Agenda", "body": "Point one"})).unwrap();
let before = s.doc().unwrap().doc.clone();
let n = undo_len(&s);
let r = s.execute("design.browseTheme", &json!({"name": "Brand.potx", "data": data})).unwrap();
assert_eq!(r["slides"], json!(2), "{r}");
assert_eq!(undo_len(&s), n + 1, "one undo step");

let doc = &s.doc().unwrap().doc;
assert!(doc.validate().is_empty(), "{:?}", doc.validate());
assert_eq!(doc.masters.len(), 1, "the old master is replaced");
let m = &doc.masters[0];
assert_ne!(m.id, before.masters[0].id);
assert!(m.layouts.iter().all(|l| l.name.starts_with("Brand ")));
assert!(matches!(m.background, Some(Background::Fill { fill: Fill::Solid { .. } })));
let pic = m.shapes.iter().find(|sh| sh.name == "Logo").expect("the master's logo comes along");
let deckcraft_model::ShapeKind::Picture { fill } = &pic.kind else { panic!("logo is a picture") };
assert_eq!(doc.media(fill.media).map(|m| m.data.as_ref().clone()), Some(logo));
// The 4:3 master stretches to this deck's 16:9.
let k = doc.slide_size.width / 720.0;
let px = pic.xfrm.unwrap();
assert!((px.x - 600.0 * k).abs() < 0.01 && (px.w - 100.0 * k).abs() < 0.01 && (px.y - 20.0).abs() < 0.01, "{px:?}");

// Each slide moved to the brand layout of its kind, and kept its text.
for (old, new) in before.slides.iter().zip(&doc.slides) {
let (_, was) = before.layout(old.layout).unwrap();
let (owner, now) = doc.layout(new.layout).expect("on a layout of the new master");
assert_eq!(owner.id, m.id);
assert_eq!(now.kind, was.kind);
}
let agenda = doc.slides.iter().find(|sl| sl.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Agenda"))).unwrap();
assert!(agenda.shapes.iter().any(|sh| sh.text.as_ref().is_some_and(|t| t.text() == "Point one")));
let (_, lay) = doc.layout(agenda.layout).unwrap();
let lt = lay.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap().xfrm.unwrap();
assert!((lt.x - title.x * k).abs() < 0.01 && (lt.y - title.y).abs() < 0.01, "{lt:?}");
let st = agenda.shapes.iter().find(|sh| sh.ph_type().is_some_and(PhType::is_title)).unwrap();
assert!(st.xfrm.is_none(), "the slide title follows the new layout");

s.execute("edit.undo", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.masters, before.masters);
assert_eq!(doc.slides, before.slides);
}

#[test]
fn a_preserved_master_stays_beside_the_template() {
let (data, _, _) = brand_template();
let mut s = Session::with_new();
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
s.execute("view.slideMaster", &json!({"master": 0})).unwrap();
s.execute("master.preserve", &json!({"preserve": true})).unwrap();
let old = s.doc().unwrap().doc.masters[0].id;
s.execute("view.slideMaster", &json!({"master": 0, "layout": 1})).unwrap();
s.execute("design.browseTheme", &json!({"name": "Brand.pptx", "data": data})).unwrap();
let st = s.doc().unwrap();
assert_eq!(st.doc.masters.len(), 2);
assert_eq!(st.doc.masters[1].id, old);
assert_eq!(st.selection.target, Target::Master { master: 0 }, "Slide Master view shows the new master");
let new = &st.doc.masters[0];
assert!(st.doc.slides.iter().all(|sl| new.layout(sl.layout).is_some()));
}

fn undo_len(s: &crate::Session) -> usize {
s.doc().unwrap().history.undo.len()
}
Expand Down
56 changes: 35 additions & 21 deletions crates/engine/src/cmd/reuse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::collections::HashMap;
use std::sync::Arc;

use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody};
use deckcraft_model::{LayoutId, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId};
use serde_json::{Value, json};

use super::*;
Expand Down Expand Up @@ -114,9 +114,9 @@ fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap<SlideId, S
}

/// Fresh shape ids for `shapes`, and the old → new map.
fn renumber(doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
fn renumber(cmd: &str, doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result<HashMap<ShapeId, ShapeId>> {
let old = shape_ids(shapes);
super::edit::reid("slide.reuse", doc, shapes)?;
super::edit::reid(cmd, doc, shapes)?;
let map: HashMap<ShapeId, ShapeId> = old.into_iter().zip(shape_ids(shapes)).collect();
remap_connectors(shapes, &map, 0);
Ok(map)
Expand Down Expand Up @@ -171,19 +171,7 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
continue;
}
masters_done.push(m.id);
let mut used = vec![];
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
copy_media(doc, &src, &used, &mut media_map)?;
let mut m = m.clone();
media_refs::visit_mut::master(&mut m, &mut remap(&media_map));
m.id = MasterId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
renumber(doc, &mut m.shapes)?;
for l in &mut m.layouts {
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
layout_map.insert(l.id, new);
l.id = new;
renumber(doc, &mut l.shapes)?;
}
let m = import_master("slide.reuse", doc, &src, m, &mut media_map, &mut layout_map)?;
doc.masters.push(Arc::new(m));
}
}
Expand All @@ -192,11 +180,11 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
let mut slide_map: HashMap<SlideId, SlideId> = HashMap::new();
for &i in &picked {
let Some(from) = src.slides.get(i) else { continue };
copy_media(doc, &src, &media_refs::slide_media(from), &mut media_map)?;
copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?;
let mut sl: Slide = from.as_ref().clone();
media_refs::visit_mut::slide(&mut sl, &mut remap(&media_map));
sl.id = SlideId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
let map = renumber(doc, &mut sl.shapes)?;
let map = renumber("slide.reuse", doc, &mut sl.shapes)?;
for a in &mut sl.animations {
a.shape = map.get(&a.shape).copied().unwrap_or(a.shape);
a.trigger = a.trigger.map(|t| map.get(&t).copied().unwrap_or(t));
Expand Down Expand Up @@ -227,22 +215,48 @@ fn reuse(s: &mut Session, p: &Value) -> Result<Value> {
})
}

/// A copy of `src`'s master `m` ready to join `doc`: its media copied in, fresh master, layout and
/// shape ids. `layouts` gains each source layout id → its copy's.
pub(crate) fn import_master(
cmd: &str,
doc: &mut Presentation,
src: &Presentation,
m: &Master,
media_map: &mut HashMap<MediaId, MediaId>,
layouts: &mut HashMap<LayoutId, LayoutId>,
) -> Result<Master> {
let mut used = vec![];
media_refs::visit::master(m, &mut |id: &MediaId| used.push(*id));
copy_media(cmd, doc, src, &used, media_map)?;
let mut m = m.clone();
media_refs::visit_mut::master(&mut m, &mut remap(media_map));
m.id = MasterId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
renumber(cmd, doc, &mut m.shapes)?;
for l in &mut m.layouts {
let new = LayoutId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
layouts.insert(l.id, new);
l.id = new;
renumber(cmd, doc, &mut l.shapes)?;
}
Ok(m)
}

/// Copy the source media `used` into `doc` under fresh ids, once each (identical bytes already in
/// `doc` are shared; a linked item keeps its link).
fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap<MediaId, MediaId>) -> Result<()> {
for &id in used {
if map.contains_key(&id) {
continue;
}
let Some(m) = src.media(id) else { continue };
let new = if m.link.is_some() {
let mut item = m.clone();
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?);
item.id = MediaId(doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?);
let new = item.id;
doc.media.push(item);
new
} else {
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad("slide.reuse", NO_ID_SPACE))?
doc.add_media(&m.name, &m.content_type, m.data.as_ref().clone()).ok_or_else(|| bad(cmd, NO_ID_SPACE))?
};
map.insert(id, new);
}
Expand Down
Loading
Loading