Round-trip comments, links, shows, groups and inherited placeholders

An owned corpus of those element kinds survives open, save and reopen.
Paragraph default run properties, including a link, and action sounds
were dropped on save, so import and export now keep them.
This commit is contained in:
Nikita Rakitin
2026-10-13 23:43:03 +03:00
parent 457406a7a5
commit f9d5a43174
15 changed files with 421 additions and 44 deletions
+6 -3
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~20 points and ~120–210 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (hours to ~95% per audience added; ready 56% → 55%; stage stays alpha; previous: major, full re-measure against PowerPoint for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (owned PPTX corpus for comments, links, custom shows, groups and inherited placeholders; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
DeckCraft aims at full PowerPoint parity, and to be better: faster, open (a documented zip+JSON
format plus PPTX), scriptable by agents (CLI, JSON control channel, MCP), and available everywhere
@@ -111,8 +111,9 @@ Ranked; detail and the full beta plan in [docs/roadmap.md](docs/roadmap.md).
1. **No data loss, no startup crashes:** Close's save prompt (#60), Windows GPU crashes (#40, #59),
image paste (#53), editor soak tests. 10–20 h.
2. **PPTX on real decks:** owned corpus, round-trip checks, fixes; #70, #74, #77; EMF/WMF and SVG.
36–64 h.
2. **PPTX on real decks:** owned corpus now covers comments, links and actions, custom shows,
rotated groups and placeholders that inherit from the layout. Still open: a larger corpus,
embedded fonts, #70, #74, #77, EMF/WMF and SVG. 36–64 h.
3. **Localization:** catalog, system language (#15), zh-Hans, es, fr, de, ja with IME. 27–46 h.
4. **Output:** vector PDF, printing. 10–16 h.
5. **Depth that users hit:** font weights (#69), chart data and elements, Animation Pane timeline,
@@ -128,6 +129,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | What landed |
|---|---|
| 2026-10-13 | PPTX round trip of comments, hyperlinks and actions, custom shows, rotated groups, and placeholders that inherit from the layout. Paragraph default formatting (`defRPr`, including a link) and action sounds (the clip, stop-previous, and a custom show's "Show and return") are kept. Embedded fonts and comment threads stay open |
| 2026-10-13 | Slide transitions like PowerPoint: every transition and option measured frame by frame against PowerPoint's video export and retuned (curves, directions, soft edges, 3-D perspective, contents moving over backgrounds); PowerPoint's own XML elements and defaults read and written (diagonal Wipe, Zoom, Shape In/Out, Glitter Hexagons, Shred In/Out, Page Curl Single); `deckcraft-cli render-transition` renders frames headless |
| 2026-10-13 | Wild text cases measured against PowerPoint (102 synthetic slides): bidi by run language, East Asian vertical text upright, mongolianVert/wordArtVert/wordArtVertRtl, underline styles and strikethrough at the font's metrics, centre/right/decimal tabs, East Asian, Thai, Hindi, Arabic and Hebrew numbering, small caps, all-caps "ß", transparent text, soft hyphens, kinsoku, rtlCol and just/dist anchoring, emoji presentation, fallback faces that no longer move the baseline |
| 2026-10-13 | Render matches PowerPoint more closely on wild shape, picture and table cases: even-odd custom paths, dashes and caps, arrowheads, radial and path gradients, tile rects, rotWithShape, gradient lines, shadows cast by what is drawn (group and perspective too), group fill, picture borders, tiles, reflection, recolour (biLevel, alphaInv, brightness/contrast), table borders and grid default, 20+ preset shapes by their ECMA formulas, default miter joins, and approximate 3-D bevels, cameras and face lighting |
@@ -150,6 +152,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | Progress log: PPTX corpus for comments, links, custom shows, groups and inherited placeholders |
| 2026-10-13 | trivial | Progress log: wild text cases |
| 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) |
| 2026-10-12 | trivial | Progress log: lines in scaled groups (#233) |
+3 -2
View File
@@ -589,7 +589,8 @@ fn hyperlink(s: &mut Session, p: &Value) -> Result<Value> {
return Err(bad("insert.hyperlink", "missing `url` or `slide`"));
};
let display = str_param(p, "text").map_or(display, str::to_string);
let link = Hyperlink { action, tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), highlight_click: false };
let link =
Hyperlink { action, tooltip: str_param(p, "tooltip").unwrap_or("").to_string(), highlight_click: false, sound: None, stop_sound: false };
if s.doc()?.selection.text.is_some() {
return super::text::link_text(s, link, &display);
}
@@ -621,7 +622,7 @@ fn action_button(s: &mut Session, p: &Value) -> Result<Value> {
xfrm: Some(rect),
geom: Geom::preset(preset),
style: Some(ShapeStyle::accent(SchemeSlot::Accent1)),
click: action.map(|a| Hyperlink { action: a, tooltip: String::new(), highlight_click: true }),
click: action.map(|a| Hyperlink { action: a, tooltip: String::new(), highlight_click: true, sound: None, stop_sound: false }),
..Default::default()
};
let id = add_shape(s, shape, true)?;
+2 -1
View File
@@ -88,7 +88,8 @@ pub fn insert(body: &mut TextBody, pos: Pos, text: &str, props: Option<RunProps>
let Some(p) = body.paragraphs.get_mut(pi) else { break };
let idx = split_at(p, ch);
let tail: Vec<Run> = p.runs.drain(idx..).collect();
let newp = Paragraph { level: p.level, props: p.props.clone(), runs: tail, end_props: p.end_props.clone() };
let newp =
Paragraph { level: p.level, props: p.props.clone(), runs: tail, def_props: p.def_props.clone(), end_props: p.end_props.clone() };
p.end_props = props.clone();
body.paragraphs.insert(pi + 1, newp);
pi += 1;
+8 -2
View File
@@ -284,16 +284,22 @@ pub fn level_style(ctx: &Ctx, shape: &Shape, level: u8) -> LevelStyle {
/// Fully resolved run properties for `run` in paragraph `para` (explicit → paragraph style).
pub fn run(ctx: &Ctx, shape: &Shape, para: &crate::text::Paragraph, run: &RunProps) -> RunProps {
let mut r = run.clone();
// Paragraph `defRPr` fills gaps in the run, and the list style (including the layout
// placeholder this shape inherits from) fills whatever is still unset.
r.inherit(&para.def_props);
let lvl = level_style(ctx, shape, para.level);
r.inherit(&lvl.run);
// Hyperlinked text is drawn in the theme's hyperlink colour and underlined, as in PowerPoint,
// unless the run itself sets a colour (honoured since PowerPoint 2019) or an underline
// (`u="none"` removes it). Colours and underlines from list styles don't count.
if r.link.is_some() {
if run.fill.is_none() {
// A colour or underline on the run, or on the same `defRPr` that carries the link, is
// kept. A colour that arrived only from a list style is not.
let from_def = run.link.is_none();
if run.fill.is_none() && !(from_def && para.def_props.fill.is_some()) {
r.fill = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink)));
}
if run.underline.is_none() {
if run.underline.is_none() && !(from_def && para.def_props.underline.is_some()) {
r.underline = Some("sng".into());
}
}
+9 -2
View File
@@ -165,7 +165,13 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() {
let ctx = Ctx::for_slide(&p, s).unwrap();
let title = &s.shapes[0];
let para = Paragraph::default();
let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false });
let link = Some(Hyperlink {
action: Action::Url { url: "https://example.org".into() },
tooltip: String::new(),
highlight_click: false,
sound: None,
stop_sound: false,
});
let hlink = Some(Fill::solid(ColorRef::scheme(SchemeSlot::Hlink)));
// The title's master style colours its text tx1; a link is hlink and underlined instead.
assert_eq!(resolve::run(&ctx, title, &para, &RunProps::default()).fill, Some(Fill::solid(ColorRef::scheme(SchemeSlot::Tx1))));
@@ -173,7 +179,8 @@ fn hyperlinked_runs_take_the_theme_hyperlink_look() {
assert_eq!((r.fill, r.underline.as_deref()), (hlink.clone(), Some("sng")));
assert_eq!(ctx.color(&ColorRef::scheme(SchemeSlot::Hlink), None), p.masters[0].theme.colors.get(SchemeSlot::Hlink));
// A slide link looks the same.
let slide = Some(Hyperlink { action: Action::Slide { slide: s.id }, tooltip: String::new(), highlight_click: false });
let slide =
Some(Hyperlink { action: Action::Slide { slide: s.id }, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false });
assert_eq!(resolve::run(&ctx, title, &para, &RunProps { link: slide, ..Default::default() }).fill, hlink);
// The run's own colour and underline win; u="none" removes the underline.
let red = Some(Fill::solid(ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))));
+33 -4
View File
@@ -295,19 +295,38 @@ pub enum Strike {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum Action {
Url { url: String },
Slide { slide: crate::SlideId },
Url {
url: String,
},
Slide {
slide: crate::SlideId,
},
NextSlide,
PreviousSlide,
FirstSlide,
LastSlide,
EndShow,
LastViewed,
CustomShow { name: String },
Program { path: String },
/// Run a custom show. `returns` is PowerPoint's "Show and return".
CustomShow {
name: String,
#[serde(default = "custom_show_returns", skip_serializing_if = "is_true")]
returns: bool,
},
Program {
path: String,
},
PlayMedia,
}
fn custom_show_returns() -> bool {
true
}
fn is_true(v: &bool) -> bool {
*v
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Hyperlink {
@@ -316,6 +335,12 @@ pub struct Hyperlink {
pub tooltip: String,
#[serde(default)]
pub highlight_click: bool,
/// Sound played when the action runs (`a:snd`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sound: Option<crate::MediaId>,
/// `endSnd`: stop the sound that is already playing.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub stop_sound: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -448,6 +473,10 @@ pub struct Paragraph {
#[serde(skip_serializing_if = "is_default_para")]
pub props: ParaProps,
pub runs: Vec<Run>,
/// `a:defRPr`: defaults for runs in this paragraph that omit a property. Sits above the
/// placeholder's list style, so a layout can still supply what this paragraph leaves unset.
#[serde(skip_serializing_if = "is_default_props")]
pub def_props: RunProps,
/// Properties of the paragraph end mark (used for an empty paragraph's caret).
#[serde(skip_serializing_if = "is_default_props")]
pub end_props: RunProps,
+14 -4
View File
@@ -431,7 +431,7 @@ pub fn ppr(imp: &mut Imp, part: &Part, p: &El) -> (Option<u8>, ParaProps, Option
(lvl, pp, def)
}
pub fn hyperlink(imp: &Imp, part: &Part, h: &El) -> Option<Hyperlink> {
pub fn hyperlink(imp: &mut Imp, part: &Part, h: &El) -> Option<Hyperlink> {
let rid = h.attr("r:id").unwrap_or("");
let rel = part.rels.get(rid);
let action = h.attr("action").unwrap_or("");
@@ -450,7 +450,8 @@ pub fn hyperlink(imp: &Imp, part: &Part, h: &El) -> Option<Hyperlink> {
Action::Slide { slide }
} else if let Some(q) = action.strip_prefix("ppaction://customshow?") {
let id = q.split('&').find_map(|kv| kv.strip_prefix("id=")).unwrap_or("0");
Action::CustomShow { name: imp.custom_show_names.get(id).cloned().unwrap_or_else(|| id.to_string()) }
let returns = !matches!(q.split('&').find_map(|kv| kv.strip_prefix("return=")), Some("false" | "0"));
Action::CustomShow { name: imp.custom_show_names.get(id).cloned().unwrap_or_else(|| id.to_string()), returns }
} else if action.starts_with("ppaction://program") {
Action::Program { path: rel.map(|r| r.target.clone()).unwrap_or_default() }
} else if action.starts_with("ppaction://media") {
@@ -470,7 +471,13 @@ pub fn hyperlink(imp: &Imp, part: &Part, h: &El) -> Option<Hyperlink> {
} else {
return None;
};
Some(Hyperlink { action: a, tooltip: h.attr("tooltip").unwrap_or("").to_string(), highlight_click: h.bool("highlightClick").unwrap_or(false) })
Some(Hyperlink {
action: a,
tooltip: h.attr("tooltip").unwrap_or("").to_string(),
highlight_click: h.bool("highlightClick").unwrap_or(false),
stop_sound: h.bool("endSnd").unwrap_or(false),
sound: h.child("snd").and_then(|s| media_ref(imp, part, s)),
})
}
pub fn rpr(imp: &mut Imp, part: &Part, r: &El) -> RunProps {
@@ -545,9 +552,12 @@ pub fn paragraph(imp: &mut Imp, part: &Part, p: &El) -> Paragraph {
for e in p.elements() {
match e.local() {
"pPr" => {
let (lvl, pp, _) = ppr(imp, part, e);
let (lvl, pp, def) = ppr(imp, part, e);
para.level = lvl.unwrap_or(0);
para.props = pp;
if let Some(d) = def {
para.def_props = d;
}
}
"r" => {
let props = e.child("rPr").map(|r| rpr(imp, part, r)).unwrap_or_default();
+28 -12
View File
@@ -377,9 +377,10 @@ pub fn hyperlink(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, h: &Hyperlink)
Action::LastSlide => (String::new(), Some("ppaction://hlinkshowjump?jump=lastslide".into())),
Action::EndShow => (String::new(), Some("ppaction://hlinkshowjump?jump=endshow".into())),
Action::LastViewed => (String::new(), Some("ppaction://hlinkshowjump?jump=lastslideviewed".into())),
Action::CustomShow { name } => {
Action::CustomShow { name, returns } => {
let id = x.p.custom_shows.iter().position(|c| c.name == *name).unwrap_or(0);
(String::new(), Some(format!("ppaction://customshow?id={id}&return=true")))
let ret = if *returns { "true" } else { "false" };
(String::new(), Some(format!("ppaction://customshow?id={id}&return={ret}")))
}
Action::Program { path } => {
if path.is_empty() {
@@ -389,14 +390,28 @@ pub fn hyperlink(w: &mut W, x: &mut Exp, o: &mut Out, tag: &str, h: &Hyperlink)
}
Action::PlayMedia => (String::new(), Some("ppaction://media".into())),
};
w.empty(
tag,
A::new()
.a("r:id", rid)
.o("action", action)
.o("tooltip", (!h.tooltip.is_empty()).then_some(h.tooltip.as_str()))
.t("highlightClick", h.highlight_click),
);
let attrs = A::new()
.a("r:id", rid)
.o("action", action)
.o("tooltip", (!h.tooltip.is_empty()).then_some(h.tooltip.as_str()))
.t("highlightClick", h.highlight_click)
.t("endSnd", h.stop_sound);
let sound = h.sound.and_then(|id| x.media_rel(o, id, "audio").map(|rid| (rid, id)));
let Some((sound_rid, sound_id)) = sound else {
w.empty(tag, attrs);
return;
};
w.open(tag, attrs);
let name = x.p.media(sound_id).map(|m| sound_label(&m.name)).unwrap_or_else(|| "sound.wav".into());
w.empty("a:snd", A::new().a("r:embed", sound_rid).a("name", name));
w.close(tag);
}
/// Display name PowerPoint stores on `a:snd`.
fn sound_label(name: &str) -> String {
let file = name.rsplit(['/', '\\']).next().unwrap_or(name);
let label: String = file.chars().filter(|c| c.is_ascii() && !c.is_control() && *c != '"' && *c != '<' && *c != '>').take(80).collect();
if label.is_empty() { "sound.wav".into() } else { label }
}
pub fn body_pr(w: &mut W, b: &BodyProps) {
@@ -677,8 +692,9 @@ fn run_text(w: &mut W, x: &mut Exp, o: &mut Out, text: &str, props: &RunProps) {
pub fn paragraph(w: &mut W, x: &mut Exp, o: &mut Out, p: &Paragraph, math: bool, index: usize) {
w.open0("a:p");
if p.level > 0 || !is_default_ppr(&p.props) {
ppr(w, x, o, "a:pPr", Some(p.level), &p.props, None);
let def = (p.def_props != RunProps::default()).then_some(&p.def_props);
if p.level > 0 || !is_default_ppr(&p.props) || def.is_some() {
ppr(w, x, o, "a:pPr", Some(p.level), &p.props, def);
}
for (ri, r) in p.runs.iter().enumerate() {
match &r.kind {
+2
View File
@@ -582,6 +582,8 @@ fn media(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, m: &deckcraft_
action: deckcraft_model::text::Action::PlayMedia,
tooltip: String::new(),
highlight_click: false,
sound: None,
stop_sound: false,
});
}
cnvpr(w, x, o, id, &s2);
+283
View File
@@ -0,0 +1,283 @@
//! Owned round trip: comments, hyperlinks and actions, custom shows, grouped
//! rotated objects, and placeholders that inherit from the layout.
//!
//! Embedded fonts stay out of this check; another open change owns that part.
use std::sync::Arc;
use deckcraft_color::Rgba;
use deckcraft_geom::Xfrm;
use deckcraft_model::style::{ColorRef, Fill};
use deckcraft_model::text::{Action, Hyperlink, LevelStyle, ListStyle, Paragraph, Run, RunProps, TextBody};
use deckcraft_model::{Comment, CustomShow, Geom, LayoutType, PhType, Presentation, Shape, ShapeId, ShapeKind, defaults, resolve};
const CHIME: &[u8] = b"RIFFdeckcraft-chime-WAVEfmt ";
fn link(action: Action) -> Hyperlink {
Hyperlink { action, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false }
}
fn box_at(p: &mut Presentation, name: &str, x: f64, y: f64) -> Shape {
Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, y, 80.0, 28.0)), ..Default::default() }
}
fn round(p: &Presentation) -> Presentation {
deckcraft_pptx::import(&deckcraft_pptx::export(p).expect("export")).expect("import")
}
fn shape<'a>(p: &'a Presentation, slide: usize, name: &str) -> &'a Shape {
p.slides[slide].shapes.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("missing {name}"))
}
fn placeholder(p: &Presentation, kind: PhType) -> &Shape {
p.slides[0].shapes.iter().find(|s| s.ph_type() == Some(kind)).unwrap_or_else(|| panic!("missing {kind:?}"))
}
fn deck() -> Presentation {
let mut p = defaults::new_presentation(None);
let layout = defaults::layout_of_kind(&p, LayoutType::TitleAndContent).expect("layout");
let master = Arc::make_mut(&mut p.masters[0]);
let lay = master.layouts.iter_mut().find(|l| l.id == layout).expect("layout");
for s in &mut lay.shapes {
if s.ph_type() != Some(PhType::Obj) {
continue;
}
s.xfrm = Some(Xfrm::new(48.0, 120.0, 600.0, 300.0));
s.geom = Geom::preset("ellipse");
s.fill = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(20, 40, 80))));
let mut ls = ListStyle::default();
ls.set(0, LevelStyle { run: RunProps { size: Some(28.0), font: Some("Source Serif 4".into()), ..Default::default() }, ..Default::default() });
let body = s.text.get_or_insert_with(TextBody::default);
body.list_style = ls;
}
let chime = p.add_media("chime.wav", "audio/wav", CHIME.to_vec());
let mut a = defaults::new_slide(&mut p, layout);
a.name = "Review".into();
if let Some(title) = a.shapes.iter_mut().find(|s| s.ph_type() == Some(PhType::Title)) {
title.text = Some(TextBody::from_text("Review"));
}
if let Some(body) = a.shapes.iter_mut().find(|s| s.ph_type() == Some(PhType::Obj)) {
body.text = Some(TextBody {
paragraphs: vec![
Paragraph {
runs: vec![Run::new("Quoted")],
def_props: RunProps {
size: Some(22.0),
bold: Some(true),
font: Some("Literata".into()),
link: Some(Hyperlink {
action: Action::Url { url: "https://example.com/quote".into() },
tooltip: "Source".into(),
highlight_click: false,
sound: None,
stop_sound: false,
}),
..Default::default()
},
..Default::default()
},
Paragraph { runs: vec![Run::new("Inherits")], ..Default::default() },
],
..Default::default()
});
}
let mut gem = box_at(&mut p, "Gem", 12.0, 18.0);
gem.xfrm = Some(Xfrm { x: 12.0, y: 18.0, w: 80.0, h: 40.0, rot: 20.0, flip_v: true, ..Default::default() });
gem.text = Some(TextBody::from_text("Tilted"));
let mut cluster = box_at(&mut p, "Cluster", 80.0, 40.0);
cluster.xfrm = Some(Xfrm { x: 80.0, y: 40.0, w: 220.0, h: 140.0, rot: 25.0, flip_h: true, ..Default::default() });
cluster.kind = ShapeKind::Group { children: vec![gem], child: Xfrm::new(10.0, 20.0, 400.0, 240.0) };
a.shapes.push(cluster);
let mut site = box_at(&mut p, "Site", 40.0, 460.0);
site.click = Some(Hyperlink {
action: Action::Url { url: "https://example.com/deck".into() },
tooltip: "Example".into(),
highlight_click: true,
sound: None,
stop_sound: false,
});
a.shapes.push(site);
a.shapes.push(box_at(&mut p, "Go second", 140.0, 460.0));
for (name, x, action) in [
("Next", 240.0, Action::NextSlide),
("End", 330.0, Action::EndShow),
("Back", 420.0, Action::LastViewed),
("Last", 510.0, Action::LastSlide),
("Open file", 600.0, Action::Program { path: "Talk notes.txt".into() }),
("Play clip", 700.0, Action::PlayMedia),
] {
let mut s = box_at(&mut p, name, x, 500.0);
s.click = Some(link(action));
a.shapes.push(s);
}
let mut hover = box_at(&mut p, "Hover", 40.0, 500.0);
hover.hover =
Some(Hyperlink { action: Action::PreviousSlide, tooltip: "Back one".into(), highlight_click: false, sound: None, stop_sound: true });
a.shapes.push(hover);
let mut home = box_at(&mut p, "Home", 800.0, 460.0);
home.geom = Geom::preset("actionButtonHome");
home.click = Some(Hyperlink { action: Action::FirstSlide, tooltip: String::new(), highlight_click: true, sound: None, stop_sound: false });
a.shapes.push(home);
let mut board = box_at(&mut p, "To board", 140.0, 500.0);
board.click = Some(Hyperlink {
action: Action::CustomShow { name: "Board".into(), returns: false },
tooltip: "Open the board show".into(),
highlight_click: false,
sound: Some(chime),
stop_sound: false,
});
a.shapes.push(board);
let mut short = box_at(&mut p, "To short", 240.0, 500.0);
short.click = Some(link(Action::CustomShow { name: "Short".into(), returns: true }));
a.shapes.push(short);
a.comments = vec![Comment {
author: "Mina".into(),
initials: "ML".into(),
text: "Check the quote".into(),
date: "2026-03-01T09:30:00.000".into(),
x: 36.0,
y: 48.0,
..Default::default()
}];
let mut b = defaults::new_slide(&mut p, layout);
if let Some(title) = b.shapes.iter_mut().find(|s| s.ph_type() == Some(PhType::Title)) {
title.text = Some(TextBody::from_text("Second"));
}
// The jump is filled in once this slide has an id.
let b_id = b.id;
if let Some(go) = a.shapes.iter_mut().find(|s| s.name == "Go second") {
go.click = Some(link(Action::Slide { slide: b_id }));
}
p.slides = vec![Arc::new(a), Arc::new(b)];
let ids: Vec<_> = p.slides.iter().map(|s| s.id).collect();
p.custom_shows =
vec![CustomShow { name: "Short".into(), slides: vec![ids[0]] }, CustomShow { name: "Board".into(), slides: vec![ids[1], ids[0]] }];
p.show.custom_show = Some("Board".into());
p
}
fn assert_deck(q: &Presentation) {
assert_eq!(q.slides.len(), 2);
assert_eq!(placeholder(q, PhType::Title).text.as_ref().map(|t| t.text()), Some("Review".into()));
let body = placeholder(q, PhType::Obj);
assert!(body.xfrm.is_none(), "a placeholder with no box of its own keeps inheriting the layout");
assert!(matches!(&body.geom, Geom::Preset { name, .. } if name == "ellipse"), "layout geometry: {:?}", body.geom);
let ctx = resolve::Ctx::for_slide(q, &q.slides[0]).expect("ctx");
let x = resolve::xfrm(&ctx, body);
assert_eq!((x.x, x.y, x.w, x.h), (48.0, 120.0, 600.0, 300.0));
let (fill, _) = resolve::fill(&ctx, body);
assert_eq!(fill, Some(Fill::solid(ColorRef::rgb(Rgba::rgb(20, 40, 80)))));
let paras = &body.text.as_ref().expect("text").paragraphs;
assert_eq!(paras[0].runs[0].text, "Quoted");
assert!(paras[0].runs[0].props.size.is_none(), "the run itself stays unset");
assert_eq!(paras[0].def_props.size, Some(22.0));
assert_eq!(paras[0].def_props.bold, Some(true));
assert_eq!(paras[0].def_props.font.as_deref(), Some("Literata"));
match &paras[0].def_props.link {
Some(h) => match &h.action {
Action::Url { url } => assert_eq!(url, "https://example.com/quote"),
other => panic!("paragraph link: {other:?}"),
},
None => panic!("paragraph default run lost its link"),
}
assert_eq!(paras[0].def_props.link.as_ref().map(|h| h.tooltip.as_str()), Some("Source"));
let quoted = resolve::run(&ctx, body, &paras[0], &paras[0].runs[0].props);
assert_eq!(quoted.size, Some(22.0));
assert_eq!(paras[1].runs[0].text, "Inherits");
let inherited = resolve::run(&ctx, body, &paras[1], &paras[1].runs[0].props);
assert_eq!(inherited.size, Some(28.0), "the second paragraph takes the layout list style");
assert_eq!(inherited.font.as_deref(), Some("Source Serif 4"));
let cluster = shape(q, 0, "Cluster");
let g = cluster.xfrm.expect("group box");
assert_eq!((g.x, g.y, g.w, g.h, g.rot, g.flip_h), (80.0, 40.0, 220.0, 140.0, 25.0, true));
let ShapeKind::Group { children, child } = &cluster.kind else { panic!("not a group") };
assert_eq!((child.x, child.y, child.w, child.h), (10.0, 20.0, 400.0, 240.0));
let gem = &children[0];
assert_eq!(gem.text.as_ref().map(|t| t.text()), Some("Tilted".into()));
let gx = gem.xfrm.expect("gem");
assert_eq!((gx.x, gx.y, gx.w, gx.h, gx.rot, gx.flip_v), (12.0, 18.0, 80.0, 40.0, 20.0, true));
let c = &q.slides[0].comments;
assert_eq!(c.len(), 1);
assert_eq!((c[0].author.as_str(), c[0].initials.as_str(), c[0].text.as_str()), ("Mina", "ML", "Check the quote"));
assert_eq!(c[0].date, "2026-03-01T09:30:00.000");
assert_eq!((c[0].x, c[0].y), (36.0, 48.0));
let click = |name: &str| shape(q, 0, name).click.clone().unwrap_or_else(|| panic!("{name} lost its click"));
match click("Site").action {
Action::Url { url } => assert_eq!(url, "https://example.com/deck"),
other => panic!("{other:?}"),
}
assert_eq!(click("Site").tooltip, "Example");
assert!(click("Site").highlight_click);
match click("Go second").action {
Action::Slide { slide } => assert_eq!(q.slides.iter().position(|s| s.id == slide), Some(1)),
other => panic!("{other:?}"),
}
assert!(matches!(click("Next").action, Action::NextSlide));
assert!(matches!(click("End").action, Action::EndShow));
assert!(matches!(click("Back").action, Action::LastViewed));
assert!(matches!(click("Last").action, Action::LastSlide));
match click("Open file").action {
Action::Program { path } => assert_eq!(path, "Talk notes.txt"),
other => panic!("{other:?}"),
}
assert!(matches!(click("Play clip").action, Action::PlayMedia));
assert!(matches!(click("Home").action, Action::FirstSlide));
assert!(click("Home").highlight_click);
assert!(matches!(shape(q, 0, "Home").geom, Geom::Preset { ref name, .. } if name == "actionButtonHome"));
let hover = shape(q, 0, "Hover").hover.clone().expect("hover");
assert!(matches!(hover.action, Action::PreviousSlide));
assert!(hover.stop_sound);
assert_eq!(hover.tooltip, "Back one");
match click("To board").action {
Action::CustomShow { name, returns } => {
assert_eq!(name, "Board");
assert!(!returns, "Show and return was forced on");
}
other => panic!("{other:?}"),
}
let snd = click("To board").sound.expect("action sound");
assert_eq!(q.media(snd).map(|m| m.data.as_ref().clone()).as_deref(), Some(CHIME));
match click("To short").action {
Action::CustomShow { name, returns } => {
assert_eq!(name, "Short");
assert!(returns);
}
other => panic!("{other:?}"),
}
assert_eq!(q.custom_shows.len(), 2);
assert_eq!(q.custom_shows[0].name, "Short");
assert_eq!(q.custom_shows[0].slides, vec![q.slides[0].id]);
assert_eq!(q.custom_shows[1].name, "Board");
assert_eq!(q.custom_shows[1].slides, vec![q.slides[1].id, q.slides[0].id]);
assert_eq!(q.show.custom_show.as_deref(), Some("Board"));
assert_eq!(
q.slides[1].shapes.iter().find(|s| s.ph_type() == Some(PhType::Title)).and_then(|s| s.text.as_ref()).map(|t| t.text()),
Some("Second".into())
);
}
#[test]
fn comments_links_shows_groups_and_inherited_placeholders_round_trip() {
let p = deck();
let once = round(&p);
assert_deck(&once);
let twice = round(&once);
assert_deck(&twice);
}
+5 -2
View File
@@ -82,6 +82,8 @@ pub fn rich_deck() -> Presentation {
action: Action::Url { url: "https://example.org/a?b=1&c=2".into() },
tooltip: "tip".into(),
highlight_click: false,
sound: None,
stop_sound: false,
}),
..Default::default()
},
@@ -153,7 +155,7 @@ pub fn rich_deck() -> Presentation {
}],
..Default::default()
});
b.click = Some(Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: true });
b.click = Some(Hyperlink { action: Action::NextSlide, tooltip: String::new(), highlight_click: true, sound: None, stop_sound: false });
let mut c = shape(&mut p, "Freeform", 450.0, 120.0, 100.0, 100.0);
c.geom = Geom::Custom {
paths: vec![CustomPath {
@@ -336,7 +338,8 @@ pub fn rich_deck() -> Presentation {
let target = p.slides[1].id;
let mut link = shape(&mut p, "Go to shapes", 600.0, 450.0, 200.0, 40.0);
link.text = Some(TextBody::from_text("Back to shapes"));
link.click = Some(Hyperlink { action: Action::Slide { slide: target }, tooltip: String::new(), highlight_click: false });
link.click =
Some(Hyperlink { action: Action::Slide { slide: target }, tooltip: String::new(), highlight_click: false, sound: None, stop_sound: false });
let mut eq = shape(&mut p, "Equation", 100.0, 450.0, 300.0, 40.0);
eq.text = Some(TextBody {
paragraphs: vec![Paragraph {
+7 -1
View File
@@ -539,7 +539,13 @@ fn hyperlinks_draw_in_the_hyperlink_colour_underlined() {
let o = RenderOpts { scale: 0.5, ..Default::default() };
let px = |p: &Presentation| render_slide(p, 0, &o);
let diff = |a: &Image, b: &Image| a.pixels.iter().zip(&b.pixels).filter(|(x, y)| x.abs_diff(**y) > 64).count();
let link = Some(Hyperlink { action: Action::Url { url: "https://example.org".into() }, tooltip: String::new(), highlight_click: false });
let link = Some(Hyperlink {
action: Action::Url { url: "https://example.org".into() },
tooltip: String::new(),
highlight_click: false,
sound: None,
stop_sound: false,
});
let hlink = Some(Fill::solid(ColorRef::scheme(deckcraft_color::SchemeSlot::Hlink)));
let red = Some(Fill::solid(ColorRef::rgb(Rgba::rgb(200, 0, 0))));
let _ = px(&run_deck(RunProps::default())); // faces load lazily
+4 -3
View File
@@ -1,6 +1,6 @@
# File-format parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233); previously major: created; formats taken from PowerPoint 16.113.4's Info.plist, PPTX coverage measured from `crates/pptx`) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus for comments, links and actions, custom shows, rotated groups and inherited placeholders; paragraph `defRPr` and action sounds kept; previous: trivial, groups with a small legacy child coordinate space) · **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**
@@ -16,7 +16,7 @@ PowerPoint opens and saves; Viewer = opens only).
| Format | PowerPoint | DeckCraft read | DeckCraft write | Fidelity / notes | Tested by |
|---|---|---|---|---|---|
| `.pptx` presentation | Editor | yes | yes | Broad (see below); never measured on real decks | `crates/pptx/tests/roundtrip.rs`, `sample.rs`, `malformed.rs`, `sniff.rs` (30 tests); opened in PowerPoint by hand 2026-10-06 |
| `.pptx` presentation | Editor | yes | yes | Broad (see below); an owned corpus covers comments, links and actions, custom shows, rotated groups and placeholders that inherit from the layout. Embedded fonts and a corpus of decks from outside this repo are still open | `crates/pptx/tests/roundtrip.rs`, `corpus_elements.rs`, `sample.rs`, `malformed.rs`, `sniff.rs`; opened in PowerPoint by hand 2026-10-06 |
| `.potx` template | Editor | yes (as a presentation) | no: written with the `.pptx` content type | `file.saveTemplate` is Save As under another name | none |
| `.ppsx` slide show | Editor | yes (as a presentation) | no: written as `.pptx` content | Doesn't start in show mode | none |
| `.pptm` / `.potm` / `.ppsm` macro-enabled | Editor | no | no | Should open and keep `vbaProject.bin` without running it | none |
@@ -68,7 +68,7 @@ the round trip even though DeckCraft doesn't model it.
| Lines: dash, compound, caps, joins, arrowheads | yes | yes | |
| Effects: shadow, glow, soft edges, reflection | yes | yes | |
| 3-D: `scene3d`, `sp3d` | kept | kept | Not rendered |
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow` and date fields |
| Text: runs, paragraphs, bullets, numbering, levels, autofit, columns, vertical, RTL, script fonts, fields, hyperlinks | yes | yes | Round-trip tests incl. `justLow`, date fields and paragraph `defRPr` (the defaults a placeholder's runs inherit, including a link). A click or hover action keeps its sound, `endSnd`, and a custom show's "Show and return" |
| 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 | |
@@ -99,6 +99,7 @@ the round trip even though DeckCraft doesn't model it.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Owned corpus for comments, links and actions, custom shows, rotated groups and inherited placeholders. Paragraph `defRPr` and action sounds (including custom-show return) survive a save |
| 2026-10-12 | minor | EMF/WMF text and pattern brushes drawn; OLE (VML) and ActiveX previews read |
| 2026-10-12 | minor | Text warps rendered; their adjust values read and written |
| 2026-10-12 | trivial | Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233) |
+10 -4
View File
@@ -1,6 +1,6 @@
# Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101; previous: minor, core-workflow alpha gate added; stage stays alpha; before that: major, first itemized gap list; full re-measure against PowerPoint for Mac 16.113.4 and the open GitHub issues) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned PPTX corpus for comments, links, custom shows, groups and inherited placeholders; paragraph defaults and action sounds kept; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Every known shortfall, one entry each, ranked by how much it stops someone who presents with
PowerPoint from switching. This is the work list: pick from the top. Numbers are summarized in
@@ -59,9 +59,14 @@ revision history.
Detail in [file-format-parity.md](file-format-parity.md).
### PPTX has never met a corpus of real decks
- **Evidence:** every PPTX test (`crates/pptx/tests`, 30 tests) uses decks DeckCraft generates;
the 2026-10-07 alpha checklist already listed this as open. No reference renders from PowerPoint
exist to compare against.
- **Evidence:** PPTX tests use decks DeckCraft generates. An owned check
(`crates/pptx/tests/corpus_elements.rs`) now opens, saves and reopens comments, hyperlinks and
actions, custom shows, grouped rotated objects, and placeholders that inherit position, geometry
and text from the layout. That check found paragraph default run properties (`defRPr`, including
a link) and action sounds (the sound itself, stop-previous, and a custom show's "Show and
return") were dropped on save; they are kept now. Embedded fonts, comment threads (#70), media
options (#74) and animation rewind (#77) are still open, and there is no corpus of decks from
outside this repo. No reference renders from PowerPoint exist to compare against.
- **Impact:** the main reason to switch is "open my PowerPoint decks". Fidelity on real decks
(corporate templates, SmartArt, `chartex` charts, embedded fonts, EMF) is unknown.
- **Estimate:** 20–35 h: build an owned corpus in `storytold/photocraft-corpus` style (craftrules
@@ -256,6 +261,7 @@ Detail in [localization-parity.md](localization-parity.md).
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Owned PPTX corpus for comments, links and actions, custom shows, rotated groups and inherited placeholders. Paragraph `defRPr` and action sounds kept. Embedded fonts and #70/#74/#77 stay open |
| 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) |
| 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines |
| 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions |
+7 -4
View File
@@ -1,6 +1,6 @@
# DeckCraft roadmap: milestones and what's next
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** minor (core-workflow alpha gate added; stage stays alpha; previous: major, created; milestones carried from the local execution plan, the beta plan built from gaps.md) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (owned PPTX corpus covers comments, links, custom shows, groups and inherited placeholders; previous: minor, core-workflow alpha gate) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
Forward-looking: where DeckCraft is going and in what order. The one-page summary is
[ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in
@@ -26,8 +26,10 @@ points and lead the beta plan.
1. **Stop losing work and crashing:** land Close's save prompt (#60, PR #75), the Windows GPU
startup crashes (#40, #59), image paste on Windows/Linux (#53). 6–12 h.
2. **PPTX on real decks:** build an owned corpus, round-trip checks in CI, fix what breaks;
comment threads, media options and rewind on save (#70, #74, #77); EMF/WMF and SVG. 31–55 h.
2. **PPTX on real decks:** the owned corpus now round-trips comments, hyperlinks and actions,
custom shows, grouped rotated objects, and placeholders that inherit from the layout, and keeps
paragraph default formatting and action sounds. Still open: a larger corpus, embedded fonts,
comment threads, media options and rewind (#70, #74, #77), EMF/WMF and SVG. 31–55 h.
3. **Review the open PRs:** equations (#55), browser print (#66), Morph alignment (#67), macOS
open-with (#68), font lookup (#80), Ukrainian (#34, after the catalog exists). 4–8 h.
@@ -96,7 +98,7 @@ on common paths. On 2026-10-07 it was ~80% there with these blockers:
| Blocker (2026-10-07) | Status 2026-10-10 |
|---|---|
| First real release run: signing, notarization, installers verified on each OS | Signed releases v0.1.0–v0.4.0 published; installers not hand-checked on each OS; Windows startup crashes on some GPUs (#40, #59) |
| PPTX fidelity on a corpus of real decks | Still open; now beta item 2 |
| PPTX fidelity on a corpus of real decks | Owned check for comments, links, custom shows, groups and inherited placeholders. A larger corpus, embedded fonts and #70/#74/#77 stay open; beta item 2 |
| Presenter view and Animation Pane polish | Still open; beta items 9 and 10 |
| Soak and fuzz the editor | Still open; beta item 1 |
| UI fidelity pass on the most-used ribbon groups and dialogs; first run | Partly (start-screen fixes #20, #49); beta item 11 |
@@ -109,5 +111,6 @@ plan above.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | trivial | Owned PPTX corpus covers comments, links and actions, custom shows, rotated groups and inherited placeholders. Beta item 2 stays open for a larger corpus, embedded fonts and #70/#74/#77 |
| 2026-10-10 | minor | Added the alpha gate (six core workflows): all pass, two with partial depth; stage stays alpha |
| 2026-10-10 | major | Created: current focus, ranked beta plan with hours, milestones M0–M14 with status, the old alpha checklist as history |