From a7833e094ddf9b426576740b6d287caf31f7353f Mon Sep 17 00:00:00 2001 From: Faabyy <68284158+Faabyy@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:41:19 +0000 Subject: [PATCH] #807: Browse for Themes brings a template's slide master and layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design › Browse for Themes… with a .pptx or .potx now does what PowerPoint does: the file's first slide master and its layouts replace the master the current slide uses (a preserved master stays beside it), stretched to this deck's slide size. Each slide on the old master moves to the new layout with the same name, else the same kind, and keeps its content as Layout does. One undo step. A .thmx still brings its theme only. Reuse Slides' master copy (media, fresh master, layout and shape ids) moves into reuse::import_master so both commands share it. Co-Authored-By: Claude Code # Please enter the commit message for your changes. Lines starting # with '#' will be kept; you may remove them yourself if you want to. # An empty message aborts the commit. # # interactive rebase in progress; onto 22db926 # Last command done (1 command done): # pick 8992623 #807: Browse for Themes brings a template's slide master and layouts # No commands remaining. # You are currently rebasing. # # Changes to be committed: # modified: ROADMAP.md # modified: crates/engine/src/cmd/design.rs # modified: crates/engine/src/cmd/reuse.rs # modified: docs/file-format-parity.md # --- ROADMAP.md | 2 + crates/engine/src/cmd/design.rs | 167 +++++++++++++++++++++++++++++++- crates/engine/src/cmd/reuse.rs | 56 +++++++---- docs/file-format-parity.md | 3 +- 4 files changed, 204 insertions(+), 24 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 933bc7d1..244a54ca 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -142,6 +142,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. | @@ -240,6 +241,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-15 | trivial | Progress log: PPTX trigger click-step round trip. Feature scores and milestone names unchanged | | 2026-10-16 | trivial | Progress log: PNG export with a transparent background (#784) | | 2026-10-16 | trivial | Progress log: a `.ppsx` from the system starts the slide show (#862) | +| 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 | diff --git a/crates/engine/src/cmd/design.rs b/crates/engine/src/cmd/design.rs index 03468f37..6659adf8 100644 --- a/crates/engine/src/cmd/design.rs +++ b/crates/engine/src/cmd/design.rs @@ -20,7 +20,7 @@ pub fn specs() -> Vec { "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 ), @@ -160,6 +160,13 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result { 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| { @@ -179,6 +186,58 @@ fn browse_theme(s: &mut Session, p: &Value) -> Result { 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 { + 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 { + 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) -> Result { fn rec(v: &mut Value, map: &std::collections::HashMap, depth: usize) { @@ -1731,8 +1790,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}; @@ -1758,6 +1819,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, 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() } diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index 1e6c09ac..bf295f43 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -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::*; @@ -114,9 +114,9 @@ fn relink_shapes(shapes: &mut [deckcraft_model::Shape], map: &HashMap Result> { +fn renumber(cmd: &str, doc: &mut Presentation, shapes: &mut [deckcraft_model::Shape]) -> Result> { let old = shape_ids(shapes); - super::edit::reid("slide.reuse", doc, shapes)?; + super::edit::reid(cmd, doc, shapes)?; let map: HashMap = old.into_iter().zip(shape_ids(shapes)).collect(); remap_connectors(shapes, &map, 0); Ok(map) @@ -171,19 +171,7 @@ fn reuse(s: &mut Session, p: &Value) -> Result { 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)); } } @@ -192,11 +180,11 @@ fn reuse(s: &mut Session, p: &Value) -> Result { let mut slide_map: HashMap = 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)); @@ -227,9 +215,35 @@ fn reuse(s: &mut Session, p: &Value) -> Result { }) } +/// 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, + layouts: &mut HashMap, +) -> Result { + 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) -> Result<()> { +fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: &mut HashMap) -> Result<()> { for &id in used { if map.contains_key(&id) { continue; @@ -237,12 +251,12 @@ fn copy_media(doc: &mut Presentation, src: &Presentation, used: &[MediaId], map: 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); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b8bbedb4..844c7f0a 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -22,7 +22,7 @@ PowerPoint opens and saves; Viewer = opens only). | `.pptm` / `.potm` / `.ppsm` macro-enabled | Editor | no | no | Should open and keep `vbaProject.bin` without running it | none | | `.ppt` / `.pot` / `.pps` 97–2003 binary | Editor | no | no | Owner decision; import only would be enough | none | | `.odp` OpenDocument | Editor | no | no | | none | -| `.thmx` Office theme | Viewer | no | no | Browse for themes is a P2 row | none | +| `.thmx` Office theme | Viewer | theme only (Design › Browse for Themes…) | no | Browse for Themes takes a `.thmx`'s colours, fonts and effects; a `.pptx` or `.potx` brings its slide master and layouts as well, and slides move to the matching layouts (#807). A `.thmx`'s own master and layouts aren't read yet | `crates/pptx/src/lib.rs` (`import_theme_tests`), `crates/engine/src/cmd/design.rs` (`browse_theme_tests`) | | `.deckcraft` native (zip + JSON) | n/a | yes (also `.slidecraft`) | yes | Lossless for the DeckCraft model, forward compatible | `crates/format` tests | | PDF | export | no | yes | Slides, notes pages, handouts (1–9); real-text layer with embedded subsets, links, bookmarks. Slide artwork is a raster image at print resolution, not vector | `crates/pdf` tests | | Outline `.rtf` / `.txt` | export (RTF), import (outline) | `.txt` outline; `.md` as Markdown (headings are slides, list items bullets) (`slide.fromOutline`) | `.txt` outline | No RTF | engine tests | @@ -107,6 +107,7 @@ the round trip even though DeckCraft doesn't model it. | 2026-10-15 | minor | A `.pptx` slide that can't be read is reported instead of dropped in silence: `file.open` and `file.openBytes` return `lostSlides`, the app shows a message and opens a repaired copy (Save asks for a new name), and `deckcraft-cli` warns on stderr (#203) | | 2026-10-16 | minor | PNG slide export with a transparent background (#784) | | 2026-10-16 | trivial | A `.ppsx` opened from the command line or the file association starts the slide show (#862) | +| 2026-10-15 | minor | `.thmx` row: Browse for Themes reads a theme's colours, fonts and effects; a `.pptx`/`.potx` brings its slide master and layouts (#807) | | 2026-10-14 | minor | Community PR wave folded: one row each for `.pptx` (seven owned corpus files), outline, 3-D models, embedded fonts, text, effects, 3-D (extrusion sides draw now), SmartArt (written as a diagram) and VBA (tags and custom XML are kept) | | 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box | | 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain |