diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..e8f2af5d 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-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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (progress log: PPTX corpus for models, media and timing) · **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 @@ -112,7 +112,7 @@ 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. + `corpus_media.rs` keeps 3-D models and animation bounce end. 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 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-14 | PPTX: an owned corpus round-trips SmartArt and OLE kept as XML, audio and video bookmarks and trims, a section with a hidden slide, slide-number and date fields, ink (as freeform lines), 3-D models (`.glb`, including the fallback picture) and animation repeat, bounce end and triggers. Relationship-bearing SmartArt and OLE (#203), playback options (#74) and rewind (#77) 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 +151,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Progress log: PPTX corpus for 3-D models, media bookmarks and animation bounce end | | 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) | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 30437b15..f2699136 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -58,6 +58,12 @@ fn media_of_shapes(shapes: &[Shape], out: &mut Vec) { out.push(p); } } + deckcraft_model::ShapeKind::Model3d { media, poster } => { + out.push(*media); + if let Some(p) = poster { + out.push(*p); + } + } _ => {} } if let Some(deckcraft_model::Fill::Picture(pf)) = &s.fill { diff --git a/crates/engine/src/cmd/insert.rs b/crates/engine/src/cmd/insert.rs index 2643ab11..00a3ea4a 100644 --- a/crates/engine/src/cmd/insert.rs +++ b/crates/engine/src/cmd/insert.rs @@ -114,6 +114,7 @@ pub(crate) fn add_shape(s: &mut Session, mut shape: Shape, select: bool) -> Resu ShapeKind::Chart(_) => "Chart".to_string(), ShapeKind::Media(m) if m.video => "Video".to_string(), ShapeKind::Media(_) => "Audio".to_string(), + ShapeKind::Model3d { .. } => "3D Model".to_string(), _ if shape.text_box => "TextBox".to_string(), _ => shape .geom diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..c7f7c979 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1259,7 +1259,12 @@ fn hit_shape(st: &crate::DocState, sh: &Shape, p: Point, tol: f64) -> Option, }, + /// A 3-D model (`p:contentPart` with a `model3d` relationship, usually a `.glb`). + /// `poster` is the fallback picture an `mc:AlternateContent` choice carried for older readers. + Model3d { + media: MediaId, + #[serde(default)] + poster: Option, + }, /// Something we read but don't model (SmartArt without fallback, OLE…): its XML is kept and its /// fallback picture, if any, is shown. Opaque { @@ -388,6 +395,7 @@ impl Shape { ShapeKind::Media(m) if m.video => "video", ShapeKind::Media(_) => "audio", ShapeKind::Ink { .. } => "ink", + ShapeKind::Model3d { .. } => "model", ShapeKind::Opaque { .. } => "object", } } diff --git a/crates/pptx/src/lib.rs b/crates/pptx/src/lib.rs index 92274b62..26b527cf 100644 --- a/crates/pptx/src/lib.rs +++ b/crates/pptx/src/lib.rs @@ -78,6 +78,7 @@ pub(crate) fn mime_for(name: &str) -> &'static str { "wma" => "audio/x-ms-wma", "ogg" => "audio/ogg", "flac" => "audio/flac", + "glb" => "model/gltf-binary", "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "fntdata" => "application/x-fontdata", _ => "application/octet-stream", diff --git a/crates/pptx/src/opc.rs b/crates/pptx/src/opc.rs index 4ef21c3d..fca2404f 100644 --- a/crates/pptx/src/opc.rs +++ b/crates/pptx/src/opc.rs @@ -27,6 +27,8 @@ pub const NS_A14: &str = "http://schemas.microsoft.com/office/drawing/2010/main" pub const RT_BASE: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/"; pub const RT_MEDIA: &str = "http://schemas.microsoft.com/office/2007/relationships/media"; +/// A 3-D model part (`.glb`). Office relationship, not an ECMA-376 base type. +pub const RT_MODEL3D: &str = "http://schemas.microsoft.com/office/2017/06/relationships/model3d"; pub const RT_CORE: &str = "http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties"; /// The files of a package, keyed by normalised part name (no leading slash, lowercase). diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 057614eb..277cd99b 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -119,8 +119,36 @@ pub fn keep_xml(el: &El, ns: &[(String, String)]) -> String { /// Namespace prefixes whose `mc:Choice` content we understand. const SUPPORTED_MC: [&str; 9] = ["p14", "p15", "p159", "a14", "a16", "p1510", "a15", "c14", "p188"]; +/// A relationship to an embedded 3-D model (`.glb`). +pub(crate) fn is_model3d(rel: &opc::Rel) -> bool { + !rel.external && (rel.kind == "model3d" || rel.target.to_ascii_lowercase().ends_with(".glb")) +} + +fn choice_has_model3d(el: &El, rels: &Rels) -> bool { + fn rec(el: &El, rels: &Rels, depth: usize) -> bool { + if depth > 16 { + return false; + } + if el.is("contentPart") + && let Some(id) = el.attr("r:id").filter(|s| !s.is_empty()) + && rels.get(id).is_some_and(is_model3d) + { + return true; + } + el.elements().any(|c| rec(c, rels, depth + 1)) + } + rec(el, rels, 0) +} + /// Replace every `mc:AlternateContent` by the children of the branch we can read best. +/// A 3-D model choice is left in place (even when its `Requires` prefix is not one we otherwise +/// understand) so the shape reader can keep the model and its fallback picture. Ink content parts +/// still fall through to the fallback picture. pub fn resolve_mc(el: &mut El, depth: usize) { + resolve_mc_rels(el, depth, None); +} + +fn resolve_mc_rels(el: &mut El, depth: usize, rels: Option<&Rels>) { if depth > crate::xml::MAX_DEPTH { return; } @@ -129,6 +157,10 @@ pub fn resolve_mc(el: &mut El, depth: usize) { for c in std::mem::take(&mut el.children) { match c { Node::El(ac) if ac.is("AlternateContent") => { + if rels.is_some_and(|rels| ac.children_named("Choice").any(|ch| choice_has_model3d(ch, rels))) { + out.push(Node::El(ac)); + continue; + } let choice = ac.children_named("Choice").find(|ch| { let req_ok = ch.attr("Requires").unwrap_or("").split_whitespace().all(|r| SUPPORTED_MC.contains(&r)); // Ink content parts are not modelled; prefer their fallback picture. @@ -157,13 +189,14 @@ pub fn resolve_mc(el: &mut El, depth: usize) { el.children = out; } for c in el.elements_mut() { - resolve_mc(c, depth + 1); + resolve_mc_rels(c, depth + 1, rels); } } fn load(pkg: &Package, name: &str) -> Option { let mut d = pkg.xml(name)?; - resolve_mc(&mut d.root, 0); + let rels = pkg.rels(name); + resolve_mc_rels(&mut d.root, 0, Some(&rels)); Some(d) } diff --git a/crates/pptx/src/read/shapes.rs b/crates/pptx/src/read/shapes.rs index c4b3f77f..1a9750e2 100644 --- a/crates/pptx/src/read/shapes.rs +++ b/crates/pptx/src/read/shapes.rs @@ -1,5 +1,5 @@ //! Shape trees: `p:sp`, `p:pic`, `p:grpSp`, `p:cxnSp`, `p:graphicFrame` (tables, charts, SmartArt, -//! OLE and other objects). +//! OLE and other objects) and `p:contentPart` (3-D models). use std::collections::{HashMap, HashSet}; @@ -70,10 +70,11 @@ pub fn sp_tree(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, tree: &El, depth: us "grpSp" => Some(grp(imp, part, ctx, e, depth)), "cxnSp" => Some(cxn(imp, part, ctx, e)), "graphicFrame" => Some(frame(imp, part, ctx, e, depth)), - "contentPart" => { - log::warn!("pptx: ink content part in {} not imported", part.name); + "contentPart" => content_part(imp, part, ctx, e).or_else(|| { + log::warn!("pptx: content part in {} not imported", part.name); None - } + }), + "AlternateContent" => read_model_alternate(imp, part, ctx, e), _ => None, }; if let Some(s) = s { @@ -279,6 +280,58 @@ fn ms(v: Option<&str>) -> u32 { v.and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()).map(|v| v.clamp(0.0, u32::MAX as f64) as u32).unwrap_or(0) } +fn find_content_part(el: &El) -> Option<&El> { + fn rec(el: &El, depth: usize) -> Option<&El> { + if depth > 16 { + return None; + } + if el.is("contentPart") { + return Some(el); + } + el.elements().find_map(|c| rec(c, depth + 1)) + } + rec(el, 0) +} + +/// `p:contentPart` whose relationship is a 3-D model. Other content parts (ink) are left out. +fn content_part(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Option { + let id = e.attr("r:id").filter(|s| !s.is_empty())?; + let rel = part.rels.get(id)?; + if !super::is_model3d(rel) { + return None; + } + let target = rel.target.clone(); + let media = imp.media_for(&target).or_else(|| { + log::warn!("pptx: 3-D model part missing in {}", part.name); + None + })?; + let mut s = base(imp, part, ctx, e.child("nvContentPartPr")); + if let Some(x) = e.child("xfrm") { + s.xfrm = Some(dml::xfrm(x).0); + } + s.kind = ShapeKind::Model3d { media, poster: None }; + Some(s) +} + +/// A 3-D model wrapped in `mc:AlternateContent`: the model choice, plus the fallback picture. +/// If the model part is missing, the fallback picture is the shape. +fn read_model_alternate(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Option { + if let Some(content) = e.children_named("Choice").find_map(find_content_part) + && let Some(mut s) = content_part(imp, part, ctx, content) + { + let poster = e.child("Fallback").and_then(|fb| { + let mut pics = vec![]; + fb.find_all("blip", &mut pics); + pics.into_iter().find_map(|b| dml::media_ref(imp, part, b)) + }); + if let ShapeKind::Model3d { media, .. } = s.kind { + s.kind = ShapeKind::Model3d { media, poster }; + } + return Some(s); + } + e.child("Fallback").and_then(|fb| fb.elements().find(|c| c.is("pic")).map(|p| pic(imp, part, ctx, p))) +} + fn pic(imp: &mut Imp, part: &Part, ctx: &mut IdCtx, e: &El) -> Shape { let mut s = base(imp, part, ctx, e.child("nvPicPr")); if let Some(pr) = e.child("spPr") { diff --git a/crates/pptx/src/read/timing.rs b/crates/pptx/src/read/timing.rs index 500bb485..19739337 100644 --- a/crates/pptx/src/read/timing.rs +++ b/crates/pptx/src/read/timing.rs @@ -139,6 +139,7 @@ pub fn animations(imp: &mut Imp, part: &Part, ctx: &IdCtx, timing: &El, _shapes: auto_reverse: c.bool("autoRev").unwrap_or(false), smooth_start: c.f64("accel").map(|v| v / 100_000.0).unwrap_or(0.0), smooth_end: c.f64("decel").map(|v| v / 100_000.0).unwrap_or(0.0), + bounce_end: c.f64("bounceEnd").map(|v| (v / 100_000.0).clamp(0.0, 1.0)).unwrap_or(0.0), ..Default::default() }; a.repeat = match c.attr("repeatCount") { diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 7fb6a713..48f5991e 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -82,6 +82,7 @@ fn ext_for(content_type: &str, name: &str, data: &[u8]) -> String { "audio/x-ms-wma" => "wma", "audio/ogg" => "ogg", "audio/flac" => "flac", + "model/gltf-binary" => "glb", _ => "", }; if !by_ct.is_empty() { diff --git a/crates/pptx/src/write/shapes.rs b/crates/pptx/src/write/shapes.rs index 8b2ea35d..0e538fbb 100644 --- a/crates/pptx/src/write/shapes.rs +++ b/crates/pptx/src/write/shapes.rs @@ -1,4 +1,4 @@ -//! Shape trees: autoshapes, pictures, groups, connectors, tables, charts, media, ink and kept objects. +//! Shape trees: autoshapes, pictures, groups, connectors, tables, charts, media, ink, 3-D models and kept objects. use std::collections::{HashMap, HashSet}; @@ -376,6 +376,7 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth let id = cx.id_for(s); media(w, x, o, id, s, m); } + ShapeKind::Model3d { media, poster } => write_model(w, x, o, cx, s, *media, *poster), ShapeKind::Ink { strokes } => { // Ink becomes a group of freeform lines. let id = cx.id_for(s); @@ -400,6 +401,64 @@ pub fn shape(w: &mut W, x: &mut Exp, o: &mut Out, cx: &mut Ctx, s: &Shape, depth } } +fn model_rel(x: &mut Exp, o: &mut Out, media: deckcraft_model::MediaId) -> Option { + if x.p.media(media).is_none_or(|m| m.data.is_empty()) { + return None; + } + x.media_rel(o, media, crate::opc::RT_MODEL3D) +} + +fn write_model_part(w: &mut W, x: &mut Exp, o: &mut Out, id: u32, s: &Shape, rid: &str) { + w.open("p:contentPart", A::new().a("r:id", rid)); + w.open0("p:nvContentPartPr"); + cnvpr(w, x, o, id, s); + w.empty0("p:cNvContentPartPr"); + w.empty0("p:nvPr"); + w.close("p:nvContentPartPr"); + if let Some(xf) = &s.xfrm { + dml::xfrm(w, "p:xfrm", xf, None); + } + w.close("p:contentPart"); +} + +fn write_model( + w: &mut W, + x: &mut Exp, + o: &mut Out, + cx: &mut Ctx, + s: &Shape, + media: deckcraft_model::MediaId, + poster: Option, +) { + let Some(rid) = model_rel(x, o, media) else { + log::warn!("pptx: 3-D model '{}' has no embedded part", s.name); + return; + }; + let id = cx.id_for(s); + let poster = poster.filter(|p| x.p.media(*p).is_some_and(|m| !m.data.is_empty())); + let Some(poster) = poster else { + write_model_part(w, x, o, id, s, &rid); + return; + }; + // Older readers draw the fallback picture; readers that know `am3d` use the model. + w.open("mc:AlternateContent", A::new().a("xmlns:mc", NS_MC)); + w.open("mc:Choice", A::new().a("xmlns:am3d", "http://schemas.microsoft.com/office/drawing/2017/model3d").a("Requires", "am3d")); + write_model_part(w, x, o, id, s, &rid); + w.close("mc:Choice"); + w.open0("mc:Fallback"); + let pic = Shape { + id: ShapeId(0), + name: s.name.clone(), + xfrm: s.xfrm, + kind: ShapeKind::Picture { fill: deckcraft_model::style::PictureFill { media: poster, ..Default::default() } }, + ..Default::default() + }; + let pid = cx.ids.fresh(); + shape_with_id(w, x, o, cx, &pic, pid); + w.close("mc:Fallback"); + w.close("mc:AlternateContent"); +} + fn ink_shape(st: &deckcraft_model::InkStroke, i: usize) -> Option { let pts: Vec<(f64, f64)> = st.points.iter().filter(|p| p.0.is_finite() && p.1.is_finite()).map(|p| (p.0, p.1)).collect(); let (x0, y0) = pts.iter().fold((f64::MAX, f64::MAX), |a, p| (a.0.min(p.0), a.1.min(p.1))); diff --git a/crates/pptx/src/write/timing.rs b/crates/pptx/src/write/timing.rs index aa577ca7..bf87353c 100644 --- a/crates/pptx/src/write/timing.rs +++ b/crates/pptx/src/write/timing.rs @@ -433,6 +433,9 @@ fn effect_par(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff, first_in_click: bo }; let accel = (a.smooth_start > 0.0).then(|| (a.smooth_start.clamp(0.0, 1.0) * 100_000.0).round() as i64); let decel = (a.smooth_end > 0.0).then(|| (a.smooth_end.clamp(0.0, 1.0) * 100_000.0).round() as i64); + // `bounceEnd` is a positive percentage (100000 = 100%), same scale as accel/decel. The model stores 0–1. + let bounce = + (a.bounce_end.is_finite() && a.bounce_end > 0.0).then(|| (a.bounce_end.clamp(0.0, 1.0) * 100_000.0).round() as i64).filter(|n| *n > 0); w.open0("p:par"); w.open( "p:cTn", @@ -444,6 +447,7 @@ fn effect_par(w: &mut W, x: &mut Exp, ids: &mut Ids, e: &Eff, first_in_click: bo .o("repeatCount", repeat) .o("accel", accel) .o("decel", decel) + .o("bounceEnd", bounce) .t("autoRev", a.auto_reverse) .a("fill", "hold") .a("grpId", e.grp) diff --git a/crates/pptx/tests/corpus_media.rs b/crates/pptx/tests/corpus_media.rs new file mode 100644 index 00000000..26a9539d --- /dev/null +++ b/crates/pptx/tests/corpus_media.rs @@ -0,0 +1,373 @@ +//! Owned corpus: SmartArt and OLE kept as XML, audio and video bookmarks and trims, a section with a +//! hidden slide, slide-number and date fields, ink, 3-D models, and animation repeat, bounce and triggers. +//! +//! Relationship-bearing SmartArt and OLE parts stay with issue #203. Media playback options stay +//! with #74. Animation rewind (`cTn fill="remove"`) stays with #77. This file does not assert those. + +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_color::Rgba; +use deckcraft_geom::Xfrm; +use deckcraft_model::anim::{AnimClass, Animation}; +use deckcraft_model::text::{Run, RunKind, RunProps, TextBody}; +use deckcraft_model::{Geom, InkStroke, MediaClip, Presentation, Section, Shape, ShapeId, ShapeKind, Slide, SlideId, defaults}; + +fn png(rgb: [u8; 3]) -> Vec { + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(4, 4, image::Rgb(rgb))).write_to(&mut out, image::ImageFormat::Png).expect("png"); + out.into_inner() +} + +fn glb(marker: u8) -> Vec { + let mut v = b"glTF\x02\x00\x00\x00\x0d\x00\x00\x00".to_vec(); + v.push(marker); + v +} + +fn shape(p: &mut Presentation, name: &str, x: f64, y: f64, w: f64, h: f64) -> Shape { + Shape { id: ShapeId(p.alloc_id()), name: name.into(), xfrm: Some(Xfrm::new(x, y, w, h)), ..Default::default() } +} + +fn new_slide(p: &mut Presentation, title: &str) -> Slide { + let layout = defaults::layout_of_kind(p, deckcraft_model::LayoutType::TitleOnly).unwrap_or_default(); + let mut s = defaults::new_slide(p, layout); + if let Some(t) = s.shapes.first_mut() { + t.text = Some(TextBody::from_text(title)); + } + s +} + +fn field(text: &str, kind: &str) -> TextBody { + let mut body = TextBody::from_text(""); + body.paragraphs[0].runs = vec![Run { text: text.into(), props: RunProps::default(), kind: RunKind::Field { field: kind.into() } }]; + body +} + +const SMARTART: &str = r#"Quarter"#; + +const OLE: &str = r#""#; + +fn corpus() -> (Presentation, Vec, Vec, Vec) { + let mut p = defaults::new_presentation(None); + let audio_bytes = b"audio-bytes-deckcraft".to_vec(); + let video_bytes = b"video-bytes-deckcraft".to_vec(); + let block = glb(1); + let globe = glb(2); + let poster = png([20, 80, 160]); + let audio = p.add_media("clip.m4a", "audio/mp4", audio_bytes); + let video = p.add_media("clip.mp4", "video/mp4", video_bytes); + let block_id = p.add_media("block.glb", "model/gltf-binary", block.clone()); + let globe_id = p.add_media("globe.glb", "model/gltf-binary", globe.clone()); + let poster_id = p.add_media("globe-poster.png", "image/png", poster.clone()); + + let mut objects = new_slide(&mut p, "Objects"); + let mut diagram = shape(&mut p, "Diagram", 40.0, 80.0, 288.0, 144.0); + diagram.kind = ShapeKind::Opaque { xml: SMARTART.into(), preview: None, label: "SmartArt".into() }; + let mut embedded = shape(&mut p, "Embedded", 340.0, 80.0, 144.0, 72.0); + embedded.kind = ShapeKind::Opaque { xml: OLE.into(), preview: None, label: "DeckCraft.Note.1".into() }; + let mut block_shape = shape(&mut p, "Block", 40.0, 240.0, 120.0, 90.0); + block_shape.kind = ShapeKind::Model3d { media: block_id, poster: None }; + let mut globe_shape = shape(&mut p, "Globe", 180.0, 240.0, 160.0, 120.0); + globe_shape.kind = ShapeKind::Model3d { media: globe_id, poster: Some(poster_id) }; + let mut number = shape(&mut p, "Slide number", 360.0, 240.0, 80.0, 28.0); + number.text = Some(field("4", "slidenum")); + let mut date = shape(&mut p, "Date", 360.0, 276.0, 160.0, 28.0); + date.text = Some(field("14 October 2026", "datetime4")); + objects.shapes.extend([diagram, embedded, block_shape, globe_shape, number, date]); + p.slides.push(Arc::new(objects)); + + let mut media = new_slide(&mut p, "Media"); + let mut clip = shape(&mut p, "Narration", 40.0, 80.0, 80.0, 80.0); + clip.kind = ShapeKind::Media(MediaClip { + media: audio, + video: false, + trim_start_ms: 400, + trim_end_ms: 2600, + fade_in_ms: 80, + fade_out_ms: 120, + bookmarks: vec![("Intro".into(), 400), ("Outro".into(), 2200)], + volume: 1.0, + ..Default::default() + }); + let mut film = shape(&mut p, "Film", 160.0, 80.0, 240.0, 135.0); + film.kind = ShapeKind::Media(MediaClip { + media: video, + video: true, + poster: Some(poster_id), + trim_start_ms: 250, + trim_end_ms: 9000, + bookmarks: vec![("Mark".into(), 1500)], + volume: 1.0, + ..Default::default() + }); + let mut trigger = shape(&mut p, "Trigger", 40.0, 240.0, 100.0, 36.0); + trigger.text = Some(TextBody::from_text("Play")); + let mut fader = shape(&mut p, "Fader", 160.0, 240.0, 140.0, 60.0); + fader.text = Some(TextBody::from_text("Hello")); + let trigger_id = trigger.id; + let fader_id = fader.id; + media.shapes.extend([clip, film, trigger, fader]); + media.animations = vec![ + Animation { + shape: fader_id, + effect: "fade".into(), + duration_ms: 600, + repeat: 4, + bounce_end: 0.25, + trigger: Some(trigger_id), + ..Default::default() + }, + Animation { shape: fader_id, class: AnimClass::Emphasis, effect: "pulse".into(), duration_ms: 400, repeat: u32::MAX, ..Default::default() }, + ]; + p.slides.push(Arc::new(media)); + + let mut aside = new_slide(&mut p, "Aside"); + aside.hidden = true; + let mut ink = shape(&mut p, "Sketch", 48.0, 96.0, 200.0, 80.0); + ink.kind = ShapeKind::Ink { + strokes: vec![ + InkStroke { + points: vec![(48.0, 100.0, 0.4), (90.0, 140.0, 0.8), (150.0, 110.0, 0.3)], + color: Rgba::rgb(20, 20, 20), + width: 2.0, + highlighter: false, + }, + InkStroke { points: vec![(50.0, 150.0, 1.0), (160.0, 156.0, 1.0)], color: Rgba::rgb(255, 230, 40), width: 12.0, highlighter: true }, + ], + }; + aside.shapes.push(ink); + p.slides.push(Arc::new(aside)); + + let ids: Vec = p.slides.iter().map(|s| s.id).collect(); + let hidden = *ids.last().expect("aside"); + p.sections = vec![ + Section { name: "Open".into(), slides: ids.iter().copied().filter(|id| *id != hidden).collect() }, + Section { name: "Aside".into(), slides: vec![hidden] }, + ]; + p.first_slide_number = 4; + (p, block, globe, poster) +} + +fn round(p: &Presentation) -> Presentation { + let bytes = deckcraft_pptx::export(p).expect("export"); + deckcraft_pptx::import(&bytes).expect("import") +} + +fn by_name<'a>(p: &'a Presentation, name: &str) -> &'a Shape { + let mut found = None; + for s in &p.slides { + deckcraft_model::walk(&s.shapes, &mut |x, _| { + if found.is_none() && x.name == name { + found = Some(x); + } + }); + } + found.unwrap_or_else(|| panic!("no shape {name}")) +} + +fn media_bytes(p: &Presentation, shape: &str) -> Vec { + let id = match &by_name(p, shape).kind { + ShapeKind::Model3d { media, .. } | ShapeKind::Media(MediaClip { media, .. }) => *media, + other => panic!("{shape} has no media: {other:?}"), + }; + p.media(id).expect("media").data.as_ref().clone() +} + +fn assert_field(p: &Presentation, name: &str, kind: &str, text: &str) { + let run = &by_name(p, name).text.as_ref().expect("text").paragraphs[0].runs[0]; + assert!(matches!(&run.kind, RunKind::Field { field } if field == kind), "{name}: {:?}", run.kind); + assert_eq!(run.text, text); +} + +fn assert_ink(s: &Shape) { + let ShapeKind::Group { children, .. } = &s.kind else { panic!("ink comes back as a group, got {:?}", s.kind) }; + assert_eq!(children.len(), 2, "pen and highlighter"); + assert!(children.iter().all(|c| matches!(c.geom, Geom::Custom { .. }))); +} + +#[test] +fn object_corpus_round_trips() { + let (p, block, globe, poster) = corpus(); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let types = zip_text(&bytes, "[Content_Types].xml"); + assert!(types.contains("model/gltf-binary"), "{types}"); + assert!(zip_has_payload(&bytes, &block), "block model was not written"); + assert!(zip_has_payload(&bytes, &globe), "globe model was not written"); + let once = deckcraft_pptx::import(&bytes).expect("import"); + let twice = round(&once); + + for q in [&once, &twice] { + let diagram = by_name(q, "Diagram"); + let ShapeKind::Opaque { xml, label, .. } = &diagram.kind else { panic!("{:?}", diagram.kind) }; + assert_eq!(label, "SmartArt"); + assert!(xml.contains("Quarter"), "{xml}"); + assert!(!xml.contains("r:dm"), "diagram parts are not part of this corpus"); + + let embedded = by_name(q, "Embedded"); + let ShapeKind::Opaque { xml, label, .. } = &embedded.kind else { panic!("{:?}", embedded.kind) }; + assert_eq!(label, "DeckCraft.Note.1"); + assert!(xml.contains("DeckCraft.Note.1"), "{xml}"); + assert!(!xml.contains("r:id"), "an embedding part is issue #203"); + + let block_shape = by_name(q, "Block"); + assert!(matches!(block_shape.kind, ShapeKind::Model3d { poster: None, .. }), "{:?}", block_shape.kind); + assert_eq!(block_shape.xfrm, Some(Xfrm::new(40.0, 240.0, 120.0, 90.0))); + assert_eq!(media_bytes(q, "Block"), block); + let ShapeKind::Model3d { media, .. } = &block_shape.kind else { panic!("{:?}", block_shape.kind) }; + let block_media = q.media(*media).expect("block media"); + assert_eq!(block_media.content_type, "model/gltf-binary"); + assert!(block_media.name.to_ascii_lowercase().ends_with(".glb"), "{}", block_media.name); + + let globe_shape = by_name(q, "Globe"); + let ShapeKind::Model3d { media, poster: Some(poster_media) } = &globe_shape.kind else { panic!("{:?}", globe_shape.kind) }; + assert_eq!(globe_shape.xfrm, Some(Xfrm::new(180.0, 240.0, 160.0, 120.0))); + assert_eq!(q.media(*media).expect("globe").data.as_ref(), globe.as_slice()); + assert_eq!(q.media(*poster_media).expect("poster").data.as_ref(), poster.as_slice()); + + let narration = by_name(q, "Narration"); + let ShapeKind::Media(m) = &narration.kind else { panic!("{:?}", narration.kind) }; + assert!(!m.video); + assert_eq!(m.trim_start_ms, 400); + assert_eq!(m.trim_end_ms, 2600); + assert_eq!(m.fade_in_ms, 80); + assert_eq!(m.fade_out_ms, 120); + assert_eq!(m.bookmarks, vec![("Intro".into(), 400), ("Outro".into(), 2200)]); + assert_eq!(q.media(m.media).expect("audio").data.as_ref(), b"audio-bytes-deckcraft".as_slice()); + + let film = by_name(q, "Film"); + let ShapeKind::Media(m) = &film.kind else { panic!("{:?}", film.kind) }; + assert!(m.video); + assert_eq!(m.trim_start_ms, 250); + assert_eq!(m.trim_end_ms, 9000); + assert_eq!(m.bookmarks, vec![("Mark".into(), 1500)]); + assert_eq!(q.media(m.media).expect("video").data.as_ref(), b"video-bytes-deckcraft".as_slice()); + + assert_field(q, "Slide number", "slidenum", "4"); + assert_field(q, "Date", "datetime4", "14 October 2026"); + assert_eq!(q.first_slide_number, 4); + + assert_eq!(q.sections.iter().map(|s| s.name.as_str()).collect::>(), ["Open", "Aside"]); + let hidden = q.slides.iter().position(|s| s.hidden).expect("a hidden slide"); + assert_eq!(q.slides.iter().filter(|s| s.hidden).count(), 1); + assert!(q.sections[1].slides.contains(&q.slides[hidden].id)); + assert!(!q.sections[0].slides.contains(&q.slides[hidden].id)); + + assert_ink(by_name(q, "Sketch")); + + let timing = q.slides.iter().find(|s| s.animations.iter().any(|a| a.effect == "fade")).expect("timing slide"); + let fade = timing.animations.iter().find(|a| a.effect == "fade").expect("fade"); + let pulse = timing.animations.iter().find(|a| a.effect == "pulse").expect("pulse"); + assert_eq!(fade.repeat, 4); + assert!((fade.bounce_end - 0.25).abs() < 1e-9, "{}", fade.bounce_end); + assert_eq!(fade.trigger, Some(by_name(q, "Trigger").id)); + assert_eq!(fade.shape, by_name(q, "Fader").id); + assert_eq!(pulse.repeat, u32::MAX); + assert_eq!(pulse.class, AnimClass::Emphasis); + assert!(pulse.trigger.is_none()); + } +} + +/// An exported deck with `parts` added (or replaced) and `edit` applied to every XML part. +fn with_parts(parts: &[(&str, Vec)], edit: impl Fn(&str, String) -> String) -> Vec { + let base = deckcraft_pptx::export(&defaults::new_presentation(None)).expect("export"); + let mut z = zip::ZipArchive::new(Cursor::new(base)).expect("zip"); + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let o = zip::write::SimpleFileOptions::default(); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let name = f.name().to_string(); + if parts.iter().any(|(n, _)| *n == name) { + continue; + } + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + if name.ends_with(".xml") || name.ends_with(".rels") { + d = edit(&name, String::from_utf8(d).expect("utf8")).into_bytes(); + } + out.start_file(name, o).expect("start"); + out.write_all(&d).expect("write"); + } + for (name, data) in parts { + out.start_file(*name, o).expect("start"); + out.write_all(data).expect("write"); + } + out.finish().expect("finish").into_inner() +} + +fn zip_text(bytes: &[u8], name: &str) -> String { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut f = z.by_name(name).unwrap_or_else(|_| panic!("no {name}")); + let mut d = String::new(); + f.read_to_string(&mut d).expect("read"); + d +} + +fn zip_has_payload(bytes: &[u8], payload: &[u8]) -> bool { + let mut z = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + for i in 0..z.len() { + let mut f = z.by_index(i).expect("entry"); + let mut d = vec![]; + f.read_to_end(&mut d).expect("read"); + if d == payload { + return true; + } + } + false +} + +#[test] +fn an_alternate_content_model_keeps_the_glb_and_its_poster() { + let model = glb(7); + let poster = png([200, 30, 40]); + let frame = r#""#; + let bytes = with_parts(&[("ppt/media/model.glb", model.clone()), ("ppt/media/poster.png", poster.clone())], |name, s| { + if name == "ppt/slides/slide1.xml" { + s.replacen("", &format!("{frame}"), 1) + } else if name == "ppt/slides/_rels/slide1.xml.rels" { + s.replacen( + "", + r#""#, + 1, + ) + } else { + s + } + }); + let once = deckcraft_pptx::import(&bytes).expect("import"); + let twice = round(&once); + for q in [&once, &twice] { + assert!(q.slides[0].shapes.iter().all(|s| s.name != "Model poster"), "the fallback picture replaced the model"); + let s = by_name(q, "Model from file"); + let ShapeKind::Model3d { media, poster: Some(poster_id) } = &s.kind else { panic!("{:?}", s.kind) }; + assert_eq!(s.xfrm, Some(Xfrm::new(40.0, 50.0, 120.0, 90.0))); + let model_item = q.media(*media).expect("glb"); + assert_eq!(model_item.data.as_ref(), model.as_slice()); + assert_eq!(model_item.content_type, "model/gltf-binary"); + assert_eq!(q.media(*poster_id).expect("poster").data.as_ref(), poster.as_slice()); + } +} + +#[test] +fn an_ink_content_part_still_uses_its_fallback_picture() { + let poster = png([10, 10, 10]); + let frame = r#""#; + let bytes = with_parts(&[("ppt/ink/ink1.xml", b"".to_vec()), ("ppt/media/ink-poster.png", poster.clone())], |name, s| { + if name == "ppt/slides/slide1.xml" { + s.replacen("", &format!("{frame}"), 1) + } else if name == "ppt/slides/_rels/slide1.xml.rels" { + s.replacen( + "", + r#""#, + 1, + ) + } else { + s + } + }); + let p = deckcraft_pptx::import(&bytes).expect("import"); + assert!(p.slides[0].shapes.iter().all(|s| s.name != "Ink marks")); + let s = by_name(&p, "Ink poster"); + let ShapeKind::Picture { fill } = &s.kind else { panic!("{:?}", s.kind) }; + assert_eq!(p.media(fill.media).expect("poster").data.as_ref(), poster.as_slice()); +} diff --git a/docs/animation-parity.md b/docs/animation-parity.md index 8ec5d0f4..ce82c1d7 100644 --- a/docs/animation-parity.md +++ b/docs/animation-parity.md @@ -1,6 +1,6 @@ # Transitions and animations: parity with PowerPoint -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** major (transitions measured frame by frame against PowerPoint for Windows' video export and retuned; previous: created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-14 · **Change:** trivial (animation bounce end and repeat round-trip; rewind still #77) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) Transitions and animations are what make a deck a presentation, and the part of PowerPoint people notice first when it's missing. Scores roll up into [target-app-parity.md](target-app-parity.md) @@ -65,7 +65,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 |---|---|---| | Start On Click / With Previous / After Previous, duration, delay | yes | yes | | Effect Options (direction, sequence by paragraph) | yes | yes | -| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; rewind lost on PPTX save (#77) | +| Effect dialog: sound, after-animation dim/hide, animate text by word/letter, smooth start/end, bounce end, repeat, rewind | yes | partly; repeat and bounce end round-trip; rewind lost on PPTX save (#77) | | By-paragraph text builds, sub-bullets follow parents | yes | yes | | Triggers (on click of a shape) | yes | yes | | Triggers on a media bookmark | yes | no | @@ -83,7 +83,7 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 |---|---| | Animation Pane timeline, preview from selected, effect dialog depth | 5–8 | | The remaining ~126 effects (most are parameter variations of existing ones) | 3–6 | -| Rewind and other effect options on round trip (#77) | 1–2 | +| Rewind on round trip (#77) | 1–2 | | Transition sounds | 1–2 | | Morph across pictures, `!!` names, align once per transition (#67) | 2–4 | | Cloth, paper and particle transitions as real meshes (Curtains, Peel Off, Page Curl, Airplane, Origami, Vortex, Shred, Honeycomb) | 4–8 | @@ -93,5 +93,6 @@ In, Wipe, Zoom, Float In, Grow/Shrink, Pulse, Spin, motion lines): weighted, ~85 | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Repeat and bounce end round-trip through PPTX; rewind is still lost (#77) | | 2026-10-13 | major | Transitions measured frame by frame against PowerPoint's video export and retuned (mean diff 35.7 → 15.0); PowerPoint's XML defaults and option meanings; new options | | 2026-10-10 | major | Created: 48/48 transitions, 64 of ~190 animation effects, pane and option depth, estimates | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e888c7a..4f7b9448 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-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-14 · **Last updated:** 2026-10-14 · **Change:** minor (3-D model parts and animation bounce end round-trip; `corpus_media.rs` covers SmartArt and OLE XML, media bookmarks and trims, sections, fields, ink and timing) · **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); never measured on real decks | `crates/pptx/tests/roundtrip.rs`, `sample.rs`, `malformed.rs`, `sniff.rs` (30 tests), `corpus_media.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 | @@ -47,7 +47,7 @@ PowerPoint opens and saves; Viewer = opens only). | WMV | yes (Windows) | embedded, not playable | | | Online video (YouTube embeds) | yes | no | Cloud feature | | Embedded OLE objects | yes | kept on round trip (opaque), not insertable or editable | `ShapeKind::Opaque` | -| 3-D models (`.glb`) | yes | no | | +| 3-D models (`.glb`) | yes | kept | The model part round-trips (`p:contentPart`, including an `mc:AlternateContent` choice and its fallback picture). Not drawn as a mesh and not inserted from the ribbon | | Embedded fonts | yes | no | `p:embeddedFontLst` ignored | ## PPTX coverage (PresentationML / DrawingML) @@ -74,12 +74,12 @@ the round trip even though DeckCraft doesn't model it. | Tables with styles | yes | yes | | | Charts (`c:chartSpace`) | yes | yes | Original XML kept in `Chart.raw` | | Charts (`cx:` chartex: waterfall, funnel, treemap, sunburst, histogram, box) | no | no | | -| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save | +| SmartArt (`dgm`) | drawing part only, as a group | as shapes | Diagram data model lost on save. A diagram with no relationship parts keeps its XML; the drawing and data parts are still dropped (#203) | | Equations (OMML, `a14:m`) | no | no | PR #55 open | | Audio / video (`p14:media`, trim, fades, bookmarks) | yes | partly | Playback options lost on save (#74) | | Ink (`contentPart`) | yes | as a group of freeform lines | PowerPoint sees shapes, not ink | | Transitions incl. p14/p15 (Vortex, Morph…) | all 48 | all 48 | `every_transition_kind_round_trips` | -| Animations: timing tree, build paragraphs, triggers, motion paths | yes (flattened to the Animation Pane list) | yes (rebuilt) | `every_animation_effect_survives`; rewind lost (#77) | +| Animations: timing tree, build paragraphs, triggers, motion paths | yes (flattened to the Animation Pane list) | yes (rebuilt) | `every_animation_effect_survives`; repeat, triggers and bounce end kept; rewind lost (#77) | | Comments: legacy | yes | yes | | | Comments: modern threaded (`p188`) | yes | no (written as legacy) | Replies and resolved state lost (#70) | | Unknown slide-level elements (`graphicFrame` OLE, extensions, `mc:AlternateContent`) | kept | kept | Rendered as a labelled placeholder with the preview picture when present | @@ -99,6 +99,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | 3-D models (`.glb`) round-trip, including an alternate-content fallback picture; animation bounce end is read and written (`corpus_media.rs`) | | 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) | diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..6edf56eb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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-14 · **Last updated:** 2026-10-14 · **Change:** minor (owned corpus `corpus_media.rs`: 3-D model parts and animation bounce end round-trip; the real-deck corpus and #70/#74/#77 stay open) · **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,13 @@ 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:** every PPTX test (`crates/pptx/tests`, 30 tests, plus `corpus_media.rs`) uses decks + DeckCraft generates; the 2026-10-07 alpha checklist already listed this as open. No reference + renders from PowerPoint exist to compare against. `corpus_media.rs` opens, saves and reopens + SmartArt and OLE kept as XML (no diagram or embedding parts; those stay with #203), audio and + video bookmarks and trims, a section holding a hidden slide, slide-number and date fields, ink + (saved as freeform lines), `.glb` 3-D models, and animation repeat, bounce end and triggers. + Playback options (#74) and rewind (#77) are still lost. - **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 +260,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Owned corpus `corpus_media.rs`: 3-D model parts and animation bounce end now round-trip. The real-deck corpus 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 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 97953034..e7ccfe10 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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-14 · **Last updated:** 2026-10-14 · **Change:** trivial (`corpus_media.rs` keeps 3-D models and animation bounce end; real-deck corpus still open) · **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:** build an owned corpus, round-trip checks in CI, fix what breaks. + `corpus_media.rs` keeps SmartArt and OLE XML, media bookmarks and trims, sections, fields, ink, + 3-D models and animation bounce end. Comment threads, media options and rewind on save + (#70, #74, #77), relationship-bearing SmartArt and OLE (#203), and EMF/WMF and SVG are still open. 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. @@ -109,5 +111,6 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | trivial | Current focus: `corpus_media.rs` keeps 3-D models and animation bounce end; the real-deck corpus stays open | | 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 |