From 6cdbee50a3518d33a8f0855830cedbfb4a9931bc Mon Sep 17 00:00:00 2001 From: Harrison Booth <73434116+Harrison-Booth@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:20:52 -0600 Subject: [PATCH 1/2] Reuse custom table styles with slides --- ROADMAP.md | 4 +- crates/engine/src/cmd/reuse.rs | 196 ++++++++++++++++++++++++++++++++- docs/file-format-parity.md | 5 +- docs/parity-checklist.md | 5 +- 4 files changed, 204 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 2c8c6571..76e5c428 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~20 points and ~120–210 h away -> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-02 · **Change:** trivial (File › About replaces Account; product links, credits and appearance retained; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-11-02 · **Last updated:** 2026-11-03 · **Change:** trivial (Reuse Slides brings along referenced custom table styles, remapping conflicting style ids; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-01 · **Change:** trivial (locked No Fill and no-outline click-through regression coverage added; No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-30 · **Change:** trivial (progress log: read-only `slide.actions` lists shape and table text actions; parity scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-27 · **Change:** trivial (progress log: Set Up Slide Show ▸ Keep mouse cursor visible, #1445; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) @@ -150,6 +150,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| | 2026-11-02 | File › About replaces the unused Account page, with version, project links, contributor/model credits and appearance preferences | +| 2026-11-03 | Reuse Slides brings along referenced custom table styles, including grouped tables, and remaps conflicting style ids without changing destination tables | | 2026-10-31 | No Fill shape interiors allow selection and editing of underlying objects, even when a quick style remains; hit-testing uses the renderer’s fill resolution. Outlines, rotation, text targets and actual fills keep their hit behaviour (#1621) | | 2026-10-30 | Print-preview tests use the URL emitted by egui and its exact file path; previews are explicitly removed before the macOS process exits | | 2026-10-30 | `slide.actions` lists shape click and hover actions and text-run hyperlinks (including table cells and grouped shapes) for audit; listing never runs an action. The UI pane is a follow-up | @@ -321,6 +322,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-11-03 | trivial | Reuse Slides brings referenced custom table styles along; scores unchanged | | 2026-11-02 | trivial | File › About replaces Account; product links, credits and appearance retained | | 2026-10-31 | trivial | No Fill hit-testing follows resolved fill instead of the presence of a style (#1621); scores unchanged | | 2026-10-30 | trivial | Progress log: read-only slide action inspection; parity scores unchanged | diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index 91cf7c93..8ab5732f 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -6,9 +6,10 @@ //! renumbered with animations, triggers and connector ends following them. Links to another //! reused slide follow its copy. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use deckcraft_model::table::TableStyleDef; use deckcraft_model::text::{Action, Hyperlink, RunProps, TextBody}; use deckcraft_model::{LayoutId, Master, MasterId, MediaId, Presentation, ShapeId, ShapeKind, Slide, SlideId}; use serde_json::{Value, json}; @@ -152,6 +153,88 @@ fn matching_layout(doc: &Presentation, name: &str, kind: deckcraft_model::Layout .unwrap_or_default() } +fn collect_table_styles(shapes: &[deckcraft_model::Shape], styles: &mut Vec, seen: &mut HashSet, depth: usize) { + if depth > 64 { + return; + } + for shape in shapes { + if let ShapeKind::Table(table) = &shape.kind + && seen.insert(table.style.to_ascii_lowercase()) + { + styles.push(table.style.clone()); + } + collect_table_styles(shape.children(), styles, seen, depth + 1); + } +} + +fn remap_table_styles(shapes: &mut [deckcraft_model::Shape], styles: &HashMap, depth: usize) { + if depth > 64 { + return; + } + for shape in shapes { + if let ShapeKind::Table(table) = &mut shape.kind + && let Some(style) = styles.get(&table.style.to_ascii_lowercase()) + { + table.style = style.clone(); + } + if let Some(children) = shape.children_mut() { + remap_table_styles(children, styles, depth + 1); + } + } +} + +fn same_table_style(a: &TableStyleDef, b: &TableStyleDef) -> bool { + a.name == b.name && a.parts == b.parts && a.background == b.background && a.raw == b.raw +} + +/// Copy only definitions used on selected slides. Conflicting GUIDs receive a fresh GUID so +/// existing destination tables continue to resolve to their original definitions. +fn import_table_styles(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[String]) -> Result> { + let mut map = HashMap::new(); + let mut source_defs = HashMap::new(); + let mut source_ids = HashSet::new(); + for (index, style) in src.table_styles.iter().enumerate() { + let key = style.id.to_ascii_lowercase(); + source_defs.entry(key.clone()).or_insert(index); + source_ids.insert(key); + } + let mut destination_defs = HashMap::new(); + for (index, style) in doc.table_styles.iter().enumerate() { + destination_defs.entry(style.id.to_ascii_lowercase()).or_insert(index); + } + for id in used { + let key = id.to_ascii_lowercase(); + if map.contains_key(&key) { + continue; + } + let Some(source_index) = source_defs.get(&key).copied() else { continue }; + let Some(source) = src.table_styles.get(source_index) else { continue }; + let destination = destination_defs.get(&key).and_then(|index| doc.table_styles.get(*index)); + let mut imported = source.clone(); + if let Some(existing) = destination { + if same_table_style(existing, source) { + map.insert(key, existing.id.clone()); + continue; + } + let candidate = loop { + let n = doc.alloc_id().ok_or_else(|| bad(cmd, NO_ID_SPACE))?; + let candidate = format!("{{00000000-0000-4000-8000-{n:012X}}}"); + let key = candidate.to_ascii_lowercase(); + if !destination_defs.contains_key(&key) && !source_ids.contains(&key) { + break candidate; + } + }; + imported.id.clone_from(&candidate); + map.insert(key, candidate); + } else { + map.insert(key, imported.id.clone()); + } + destination_defs.insert(imported.id.to_ascii_lowercase(), doc.table_styles.len()); + doc.table_styles.push(imported); + } + Ok(map) +} + fn reuse(s: &mut Session, p: &Value) -> Result { let (name, bytes) = super::insert::media_bytes(p, "slide.reuse")?; let src = super::file::open_presentation(&name, &bytes)?; @@ -178,6 +261,14 @@ fn reuse(s: &mut Session, p: &Value) -> Result { let at = usize_param(p, "at"); s.edit(|doc, sel| { let at = at.unwrap_or(sel.slide + 1).min(doc.slides.len()); + let mut used_table_styles = Vec::new(); + let mut seen_table_styles = HashSet::new(); + for &i in &picked { + if let Some(slide) = src.slides.get(i) { + collect_table_styles(&slide.shapes, &mut used_table_styles, &mut seen_table_styles, 0); + } + } + let table_style_map = import_table_styles("slide.reuse", doc, &src, &used_table_styles)?; // Media: what the slides use is copied under fresh ids. let mut media_map: HashMap = HashMap::new(); // Keep Source Formatting: bring each source master used, once, with fresh ids. @@ -201,6 +292,7 @@ fn reuse(s: &mut Session, p: &Value) -> Result { let Some(from) = src.slides.get(i) else { continue }; copy_media("slide.reuse", doc, &src, &media_refs::slide_media(from), &mut media_map)?; let mut sl: Slide = from.as_ref().clone(); + remap_table_styles(&mut sl.shapes, &table_style_map, 0); 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("slide.reuse", doc, &mut sl.shapes)?; @@ -285,12 +377,114 @@ fn copy_media(cmd: &str, doc: &mut Presentation, src: &Presentation, used: &[Med #[cfg(test)] mod tests { use deckcraft_model::style::{Artistic, Fill, PictureFill}; + use deckcraft_model::table::TableStyleDef; use deckcraft_model::text::{Action, Bullet, Hyperlink, TextBody}; use deckcraft_model::{Background, MediaClip, Shape, ShapeKind, defaults}; use serde_json::json; use super::*; + const CUSTOM_STYLE: &str = "{11111111-2222-4333-8444-555555555555}"; + + fn styled_table_presentation(id: &str, color: &str) -> Presentation { + let mut p = deckcraft_model::defaults::new_presentation(None); + let raw = format!( + r#""# + ); + p.table_styles.push(TableStyleDef { id: id.into(), name: "Owned".into(), raw, ..Default::default() }); + let table = deckcraft_model::Table { style: id.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) }; + let child = deckcraft_model::Shape { id: ShapeId(p.alloc_id().expect("shape id")), kind: ShapeKind::Table(table), ..Default::default() }; + let group = deckcraft_model::Shape { + id: ShapeId(p.alloc_id().expect("group id")), + kind: ShapeKind::Group { children: vec![child], child: Default::default() }, + ..Default::default() + }; + let slide = p.slides.first_mut().expect("default slide"); + Arc::make_mut(slide).shapes.push(group); + p + } + + fn first_table_style(shapes: &[deckcraft_model::Shape]) -> Option { + for shape in shapes { + if let ShapeKind::Table(table) = &shape.kind { + return Some(table.style.clone()); + } + if let Some(style) = first_table_style(shape.children()) { + return Some(style); + } + } + None + } + + #[test] + fn reused_grouped_table_styles_survive_native_and_pptx_round_trips_and_undo() { + for (name, bytes) in [ + ("source.deckcraft", deckcraft_format::save(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("native source")), + ("source.pptx", deckcraft_pptx::export(&styled_table_presentation(CUSTOM_STYLE, "FF0000")).expect("pptx source")), + ] { + let data = super::super::base64_encode(&bytes); + let mut session = Session::with_new(); + session.execute("slide.reuse", &json!({"data": data, "name": name, "slides": [0], "keepFormatting": true})).expect("reuse"); + let doc = &session.doc().expect("doc").doc; + assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1); + assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("FF0000"))); + assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE)); + + session.execute("edit.undo", &json!({})).expect("undo reuse"); + let doc = &session.doc().expect("doc").doc; + assert!(doc.table_styles.iter().all(|style| style.id != CUSTOM_STYLE)); + session.execute("edit.redo", &json!({})).expect("redo reuse"); + let bytes = crate::cmd::file::save_bytes(&session.doc().expect("doc").doc, "deckcraft").expect("save destination"); + let reopened = crate::cmd::file::open_presentation("destination.deckcraft", &bytes).expect("reopen destination"); + assert!(reopened.table_styles.iter().any(|style| style.id == CUSTOM_STYLE)); + assert_eq!(first_table_style(&reopened.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE)); + } + } + + #[test] + fn reused_table_style_definitions_are_deduplicated_or_remapped_on_collision() { + let source = styled_table_presentation(CUSTOM_STYLE, "FF0000"); + let source_bytes = deckcraft_format::save(&source).expect("save source"); + + let mut identical = Session::with_new(); + identical + .edit(|doc, _| { + doc.table_styles.push(source.table_styles.first().cloned().unwrap_or_default()); + Ok(()) + }) + .expect("seed identical definition"); + identical + .execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]})) + .expect("reuse identical definition"); + let doc = &identical.doc().expect("doc").doc; + assert_eq!(doc.table_styles.iter().filter(|style| style.id == CUSTOM_STYLE).count(), 1); + assert_eq!(first_table_style(&doc.slides.get(1).expect("reused slide").shapes).as_deref(), Some(CUSTOM_STYLE)); + + let mut conflicting = Session::with_new(); + conflicting + .edit(|doc, _| { + let mut own = source.table_styles.first().cloned().unwrap_or_default(); + own.raw = own.raw.replace("FF0000", "0000FF"); + doc.table_styles.push(own); + let table = deckcraft_model::Table { style: CUSTOM_STYLE.into(), ..deckcraft_model::Table::new(1, 1, 100.0, 24.0) }; + let id = ShapeId(doc.alloc_id().expect("destination table id")); + if let Some(slide) = doc.slides.first_mut() { + Arc::make_mut(slide).shapes.push(Shape { id, kind: ShapeKind::Table(table), ..Default::default() }); + } + Ok(()) + }) + .expect("seed conflicting definition"); + conflicting + .execute("slide.reuse", &json!({"data": super::super::base64_encode(&source_bytes), "name": "source.deckcraft", "slides": [0]})) + .expect("reuse conflicting definition"); + let doc = &conflicting.doc().expect("doc").doc; + assert_eq!(first_table_style(&doc.slides.first().expect("destination slide").shapes).as_deref(), Some(CUSTOM_STYLE)); + let imported_style = first_table_style(&doc.slides.get(1).expect("reused slide").shapes).expect("grouped table style"); + assert_ne!(imported_style, CUSTOM_STYLE); + assert!(doc.table_styles.iter().any(|style| style.id == imported_style && style.raw.contains("FF0000"))); + assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("0000FF"))); + } + /// Every kind of media a slide can hold comes along, each pointing at its own bytes: the /// artistic-effect original, a background picture, poster, sounds and picture bullets too. #[test] diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 329a69f8..87887bed 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity with PowerPoint -> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: seven owned PPTX corpus files, embedded fonts, EMF/WMF clip regions, threaded comments (#70), chart trendlines and workbooks (#335), SmartArt written as a diagram, tags and custom XML kept, picture export DPI; duplicated rows and headers folded; previous: trivial, lines in scaled groups, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-11-03 · **Change:** trivial (Reuse Slides transfers referenced custom table-style definitions and remaps conflicting ids; native/PPTX reuse regressions) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Every format PowerPoint reads or writes, DeckCraft's support, fidelity and how it is tested. Gaps are itemized in [gaps.md](gaps.md#file-formats). Dimension score: **~58% ready** @@ -87,7 +87,7 @@ the round trip even though DeckCraft doesn't model it. | 3-D: `scene3d`, `sp3d` | kept | kept | A bevel preset with no `sp3d` of its own is written back on shapes and theme effect styles. Bevels, cameras, face lighting and extrusion sides draw approximately | | Text warp (`prstTxWarp`) | yes | yes | All 40 presets rendered (`crates/geom/src/warp.rs`); adjust values (`avLst`) round-trip | | Pictures with crop, recolour, duotone, alpha, luminance | yes | yes | | -| Tables with styles | yes | yes | Merges and both diagonal borders round-trip | +| Tables with styles | yes | yes | Merges and both diagonal borders round-trip; Reuse Slides transfers definitions referenced by source tables, including grouped tables, with conflicting ids remapped | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw`. Trendlines (type, order or period, forecast, intercept, equation and R²) are read, written and drawn. A combo chart's secondary value axis is read and is not written back when the chart is rebuilt. An unchanged chart also keeps its embedded workbook and the other parts its relationships point at, under fresh `keptN` names so they never replace a part DeckCraft writes (#335); editing the chart drops that workbook instead of writing a stale one | | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, Pareto, box) | yes | yes | Unchanged parts kept, with their embedded workbook (#335); edited and new ones written from the model with a plain-chart fallback. Treemap and sunburst hierarchy levels kept; region maps kept, drawn as tiles (no map geometry) | | SmartArt (`dgm`) | the drawing part as a group (text laid out on its `dsp:txXfrm` rectangle), and the outline from the data model (Text Pane) | as a diagram: data model from the outline; layout, quick style and colour parts (the file's own kept until the layout is switched); the group as the cached drawing | DeckCraft's layout definitions are simple; a diagram without a cached drawing isn't drawn. SmartArt kept only as XML, without its relationship parts, still loses the drawing and data parts (#203) | @@ -119,6 +119,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-11-03 | trivial | Reuse Slides transfers referenced custom table styles from native and PPTX sources; grouped tables, undo, native save/reopen and style-id conflicts are covered | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged | | 2026-10-15 | trivial | Native `.deckcraft` files serialize a nonempty chart `kept` sidecar (relationships, part names, content types and bytes). Older files without it still load; empty sidecars are omitted. Headless PPTX → edit → native save/reopen → PPTX checks keep the workbook and drawing bytes; editing chart data still drops the stale workbook | | 2026-10-15 | trivial | PPTX interactive timing sequences keep later On Click steps and paragraph click builds (`trigger_steps.rs`); mixed starts and independent buttons survive import/edit/save/reopen | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 606f1228..b29dbd58 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -2,7 +2,7 @@ > **Generated:** 2026-10-14 (bullets/numbering remain D after empty-line marker review; summary by `cargo xtask parity`; rows re-scored by the 2026-10-14 community PR wave, duplicate Thesaurus, AutoCorrect, Artistic effects and Corrections rows folded; Preview in editor re-scored 2026-10-13; earlier rows re-scored 2026-10-10 against PowerPoint for Mac 16.113.4; video/animated GIF row re-scored 2026-10-11, #171) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) -> **Last updated:** 2026-11-01 · Locked-shape and no-outline regressions added for #1621; fill row re-assessed: remains D; No Fill hit-testing now follows resolved appearance. Scores unchanged. +> **Last updated:** 2026-11-03 · Reuse Slides now transfers referenced custom table styles and safely remaps conflicts; feature status and summary scores unchanged. The summary between ` **Weighted breadth parity: 84%** over 191 features. @@ -70,7 +70,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P | | Files | Print (slides, handouts, notes, outline) | P1 | M | | Files | Password protection | P2 | M | -| Files | Reuse slides from another file | P1 | P | +| Files | Reuse slides from another file (including referenced custom table styles) | P1 | P | | Files | Drag and drop files onto the window | P1 | D | | Files | OpenDocument .odp open/save | P2 | M | | Slides | New slide by layout | P0 | D | @@ -240,4 +240,5 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Date | Change | Summary | |---|---|---| +| 2026-11-03 | trivial | Reuse Slides transfers referenced custom table styles; row remains P because other reuse behavior is still partial, summary scores unchanged | | 2026-10-31 | trivial | No Fill interior hit-testing follows resolved fill (#1621); fill row remains D and summary scores are unchanged | From a6e2b64875d173c0aab39bafd815ae74f074eba7 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Tue, 3 Nov 2026 08:51:44 -0500 Subject: [PATCH 2/2] Reuse: a remapped table style's own XML carries its new id; checklist row name kept --- crates/engine/src/cmd/reuse.rs | 8 +++++++- docs/parity-checklist.md | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/reuse.rs b/crates/engine/src/cmd/reuse.rs index 8ab5732f..d8eb487f 100644 --- a/crates/engine/src/cmd/reuse.rs +++ b/crates/engine/src/cmd/reuse.rs @@ -224,6 +224,8 @@ fn import_table_styles(cmd: &str, doc: &mut Presentation, src: &Presentation, us break candidate; } }; + // The definition's own XML names it too, and PPTX export writes that XML as is. + imported.raw = imported.raw.replacen(&format!("styleId=\"{}\"", source.id), &format!("styleId=\"{candidate}\""), 1); imported.id.clone_from(&candidate); map.insert(key, candidate); } else { @@ -481,7 +483,11 @@ mod tests { assert_eq!(first_table_style(&doc.slides.first().expect("destination slide").shapes).as_deref(), Some(CUSTOM_STYLE)); let imported_style = first_table_style(&doc.slides.get(1).expect("reused slide").shapes).expect("grouped table style"); assert_ne!(imported_style, CUSTOM_STYLE); - assert!(doc.table_styles.iter().any(|style| style.id == imported_style && style.raw.contains("FF0000"))); + assert!( + doc.table_styles.iter().any(|style| style.id == imported_style + && style.raw.contains("FF0000") + && style.raw.contains(&format!("styleId=\"{imported_style}\""))) + ); assert!(doc.table_styles.iter().any(|style| style.id == CUSTOM_STYLE && style.raw.contains("0000FF"))); } diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e08249ec..18b60ad7 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -74,7 +74,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Files | Export video (MP4) / animated GIF (GIF: one frame per slide, no transitions; no video) | P2 | P | | Files | Print (slides, handouts, notes, outline) | P1 | M | | Files | Password protection | P2 | M | -| Files | Reuse slides from another file (including referenced custom table styles) | P1 | P | +| Files | Reuse slides from another file | P1 | P | | Files | Drag and drop files onto the window | P1 | D | | Files | OpenDocument .odp open/save | P2 | M | | Slides | New slide by layout | P0 | D |