From 034b312a644ffb2639bc11efe8439bbcfb081aa2 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:08:31 +0300 Subject: [PATCH] Round-trip tables, charts, animation, media, sections and masters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An owned corpus of those deck kinds survives open, save and reopen. The check found the handout master — printed header, footer, date and slide number — was dropped on save, so import and export now keep that part. --- ROADMAP.md | 8 +- crates/model/src/lib.rs | 4 + crates/pptx/src/read/mod.rs | 29 ++ crates/pptx/src/write/mod.rs | 33 +- crates/pptx/tests/corpus_kinds.rs | 605 ++++++++++++++++++++++++++++++ docs/file-format-parity.md | 14 +- docs/gaps.md | 20 +- docs/roadmap.md | 10 +- 8 files changed, 699 insertions(+), 24 deletions(-) create mode 100644 crates/pptx/tests/corpus_kinds.rs diff --git a/ROADMAP.md b/ROADMAP.md index 46a87add..9edc372d 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-13 · **Last updated:** 2026-10-13 · **Change:** trivial (progress log: owned PPTX corpus for more deck kinds, handout-master text; 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,8 @@ 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:** a larger owned corpus and the remaining round-trip losses; #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 +128,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-13 | PPTX corpus round-trip for tables, charts, animations, media, sections and masters/layouts (open → save → reopen, package parts and text). A handout master's header, footer, date and slide number are kept | | 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-13 | trivial | Progress log: owned PPTX corpus for more deck kinds; handout-master text | | 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/model/src/lib.rs b/crates/model/src/lib.rs index 43561f94..b148437a 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -878,6 +878,10 @@ impl Presentation { for md in &self.media { m = m.max(md.id.0); } + for extra in [self.notes_master.as_deref(), self.handout_master.as_deref()].into_iter().flatten() { + m = m.max(extra.id.0); + walk(&extra.shapes, &mut |sh, _| m = m.max(sh.id.0)); + } self.next_id = m; } pub fn slide_index(&self, id: SlideId) -> Option { diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 057614eb..047027eb 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -385,6 +385,35 @@ pub fn import(bytes: &[u8]) -> Result { } p.notes_master = Some(Arc::new(m)); } + // Handout master (header, footer, date and slide number for printed handouts). + if let Some(r) = pres_part.rels.first_of("handoutMaster") + && let Some(d) = load(&pkg, &r.target) + { + let part = Part::new(&pkg, &r.target, &d); + let mut ctx = shapes::IdCtx::default(); + let mut m = Master { id: MasterId(imp.alloc()), name: "Handout Master".into(), ..Default::default() }; + if let Some(t) = part.rels.first_of("theme") + && let Some(td) = pkg.xml(&t.target) + { + let tp = Part::new(&pkg, &t.target, &td); + m.theme = theme::read_theme(&mut imp, &tp, &td.root); + } else if let Some(first) = p.masters.first() { + m.theme = first.theme.clone(); + } + if let Some(csld) = d.root.child("cSld") { + if let Some(n) = csld.attr("name").filter(|s| !s.is_empty()) { + m.name = n.to_string(); + } + m.background = background(&mut imp, &part, csld); + if let Some(tree) = csld.child("spTree") { + m.shapes = shapes::sp_tree(&mut imp, &part, &mut ctx, tree, 0); + } + } + if let Some(cm) = d.root.child("clrMap") { + m.color_map = color_map(cm); + } + p.handout_master = Some(Arc::new(m)); + } // Document properties. if let Some(r) = root_rels.first_of("core-properties") && let Some(d) = pkg.xml(&r.target) diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index 7fb6a713..f6e6c282 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -24,6 +24,7 @@ const CT_SLIDE: &str = "application/vnd.openxmlformats-officedocument.presentati const CT_LAYOUT: &str = "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"; const CT_MASTER: &str = "application/vnd.openxmlformats-officedocument.presentationml.slideMaster+xml"; const CT_NOTES_MASTER: &str = "application/vnd.openxmlformats-officedocument.presentationml.notesMaster+xml"; +const CT_HANDOUT_MASTER: &str = "application/vnd.openxmlformats-officedocument.presentationml.handoutMaster+xml"; const CT_NOTES: &str = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+xml"; const CT_THEME: &str = "application/vnd.openxmlformats-officedocument.theme+xml"; const CT_PRES_PROPS: &str = "application/vnd.openxmlformats-officedocument.presentationml.presProps+xml"; @@ -459,6 +460,29 @@ pub fn export(p: &Presentation) -> Result, PptxError> { notes_master_rid = Some(pres.rels.add("notesMaster", &opc::relative("ppt/presentation.xml", notes_master), false)); } + // Handout master. Its header, footer, date and slide-number text live on this part; a deck + // that has never had one does not gain an empty master on save. + let handout_path = "ppt/handoutMasters/handoutMaster1.xml"; + let mut handout_rid = None; + if let Some(hm) = p.handout_master.as_ref() { + let tindex = p.masters.len() + 1 + usize::from(notes_master_rid.is_some()); + let tname = format!("ppt/theme/theme{tindex}.xml"); + let mut ho = Out::new(handout_path); + ho.rels.add("theme", &opc::relative(handout_path, &tname), false); + let mut to = Out::new(&tname); + let txml = theme::theme_xml(&mut x, &mut to, &hm.theme); + x.pkg.xml_part(&tname, CT_THEME, txml); + x.pkg.rels(&tname, &to.rels); + let mut w = W::new(); + root_open(&mut w, "p:handoutMaster", A::new()); + csld(&mut w, &mut x, &mut ho, &hm.name, hm.background.as_ref(), &hm.shapes); + clr_map(&mut w, &hm.color_map); + w.close("p:handoutMaster"); + x.pkg.xml_part(handout_path, CT_HANDOUT_MASTER, w.finish()); + x.pkg.rels(handout_path, &ho.rels); + handout_rid = Some(pres.rels.add("handoutMaster", &opc::relative("ppt/presentation.xml", handout_path), false)); + } + // Comment authors. let mut authors: Vec<(String, String, u32)> = vec![]; let mut n_comments = 0u32; @@ -514,7 +538,7 @@ pub fn export(p: &Presentation) -> Result, PptxError> { x.pkg.xml_part("ppt/viewProps.xml", CT_VIEW_PROPS, view_props_xml()); let ts = crate::tables::table_styles_xml(&x.table_styles, &p.table_styles); x.pkg.xml_part("ppt/tableStyles.xml", CT_TABLE_STYLES, ts); - let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), &slide_rids); + let pxml = presentation_xml(&mut x, &mut pres, &master_rids, notes_master_rid.as_deref(), handout_rid.as_deref(), &slide_rids); x.pkg.xml_part("ppt/presentation.xml", CT_PRES, pxml); x.pkg.rels("ppt/presentation.xml", &pres.rels); @@ -665,7 +689,7 @@ fn section_slides(p: &Presentation) -> Vec<(String, Vec)> { out } -fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes: Option<&str>, slides: &[String]) -> Vec { +fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes: Option<&str>, handout: Option<&str>, slides: &[String]) -> Vec { let p = x.p; let mut w = W::new(); let first = (p.first_slide_number != 1).then_some(p.first_slide_number.min(9999)); @@ -680,6 +704,11 @@ fn presentation_xml(x: &mut Exp, o: &mut Out, masters: &[(u64, String)], notes: w.empty("p:notesMasterId", A::new().a("r:id", n)); w.close("p:notesMasterIdLst"); } + if let Some(h) = handout { + w.open0("p:handoutMasterIdLst"); + w.empty("p:handoutMasterId", A::new().a("r:id", h)); + w.close("p:handoutMasterIdLst"); + } if !slides.is_empty() { w.open0("p:sldIdLst"); for (i, rid) in slides.iter().enumerate() { diff --git a/crates/pptx/tests/corpus_kinds.rs b/crates/pptx/tests/corpus_kinds.rs new file mode 100644 index 00000000..52dae76e --- /dev/null +++ b/crates/pptx/tests/corpus_kinds.rs @@ -0,0 +1,605 @@ +//! Owned corpus of more deck kinds: tables, charts, animations, media, sections, masters and layouts. +//! +//! Each package is built here. The check is open → save → reopen: text in the package and the +//! content parts. A second save keeps the same part names and text. + +use std::collections::BTreeMap; +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_model::anim::{AnimStart, Animation, TextBuild}; +use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::style::PictureFill; +use deckcraft_model::table::Table; +use deckcraft_model::text::{Action, Hyperlink, Run, RunProps, TextBody}; +use deckcraft_model::{Geom, LayoutId, LayoutType, MasterId, MediaClip, PhType, Presentation, ShapeKind, Slide, defaults}; + +fn png(w: u32, h: u32, rgb: [u8; 3]) -> Vec { + let img = image::RgbImage::from_pixel(w, h, image::Rgb(rgb)); + let mut out = Cursor::new(vec![]); + image::DynamicImage::ImageRgb8(img).write_to(&mut out, image::ImageFormat::Png).expect("png"); + out.into_inner() +} + +fn shape(p: &mut Presentation, name: &str, x: f64, y: f64, w: f64, h: f64) -> deckcraft_model::Shape { + deckcraft_model::Shape { + id: deckcraft_model::ShapeId(p.alloc_id()), + name: name.into(), + xfrm: Some(deckcraft_geom::Xfrm::new(x, y, w, h)), + ..Default::default() + } +} + +fn slide_with(p: &mut Presentation, kind: LayoutType, title: &str) -> Slide { + let layout = defaults::layout_of_kind(p, kind).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 tables() -> Presentation { + let mut p = defaults::new_presentation(None); + let mut s = slide_with(&mut p, LayoutType::TitleOnly, "Regions"); + let mut t = Table::new(3, 3, 420.0, 28.0); + for (r, c, text) in [ + (0, 0, "Quarter"), + (0, 1, "Region"), + (0, 2, "Amount"), + (1, 0, "Q1"), + (1, 1, "North"), + (1, 2, "12"), + (2, 0, "Q2"), + (2, 1, "South"), + (2, 2, "Total"), + ] { + if let Some(cell) = t.cell_mut(r, c) { + cell.text = TextBody::from_text(text); + } + } + t.merge(2, 1, 1, 2); + t.style = "light2-accent3".into(); + let mut ts = shape(&mut p, "Region table", 80.0, 140.0, 420.0, 84.0); + ts.kind = ShapeKind::Table(t); + s.shapes.push(ts); + p.slides = vec![Arc::new(s)]; + p +} + +fn chart_of(p: &mut Presentation, name: &str, kind: ChartType, title: &str, x: f64) -> deckcraft_model::Shape { + let mut chart = Chart::sample(kind); + chart.title = Some(title.into()); + chart.categories = vec!["Jan".into(), "Feb".into()]; + chart.series.truncate(1); + if let Some(series) = chart.series.first_mut() { + series.name = "Actual".into(); + series.values = vec![Some(10.0), Some(20.0)]; + } + if !matches!(kind, ChartType::Pie) { + chart.axis_titles = (Some("Month".into()), Some("Amount due".into())); + } + let mut ch = shape(p, name, x, 120.0, 280.0, 200.0); + ch.kind = ShapeKind::Chart(Box::new(chart)); + ch +} + +fn charts() -> Presentation { + let mut p = defaults::new_presentation(None); + let mut s = slide_with(&mut p, LayoutType::Blank, "Charts"); + s.shapes.extend([ + chart_of(&mut p, "Column chart", ChartType::Column, "Revenue", 40.0), + chart_of(&mut p, "Line chart", ChartType::Line, "Trend", 340.0), + chart_of(&mut p, "Pie chart", ChartType::Pie, "Share", 640.0), + ]); + p.slides = vec![Arc::new(s)]; + p +} + +fn animations() -> Presentation { + let mut p = defaults::new_presentation(None); + let mut s = slide_with(&mut p, LayoutType::TitleOnly, "Motion"); + let mut flyer = shape(&mut p, "Flyer", 80.0, 160.0, 200.0, 80.0); + flyer.geom = Geom::preset("roundRect"); + flyer.text = Some(TextBody::from_text("Fly in")); + let fly = flyer.id; + let mut built = shape(&mut p, "Build list", 360.0, 160.0, 240.0, 120.0); + built.text = Some(TextBody::from_text("One\nTwo\nThree")); + let body = built.id; + s.shapes.extend([flyer, built]); + s.animations = vec![ + Animation { shape: fly, effect: "fly".into(), option: "l".into(), duration_ms: 750, ..Default::default() }, + Animation { + shape: body, + effect: "fade".into(), + text_build: TextBuild::ByParagraph, + start: AnimStart::AfterPrevious, + duration_ms: 400, + ..Default::default() + }, + ]; + s.transition = Some(deckcraft_model::anim::Transition { kind: "wipe".into(), option: "l".into(), duration_ms: 800, ..Default::default() }); + p.slides = vec![Arc::new(s)]; + p +} + +fn media() -> Presentation { + let mut p = defaults::new_presentation(None); + let picture = p.add_media("mark.png", "image/png", png(4, 4, [20, 90, 160])); + let tone = p.add_media("tone.wav", "audio/wav", b"owned-wav-tone".to_vec()); + let clip = p.add_media("clip.mp4", "video/mp4", b"owned-mp4-clip".to_vec()); + let mut s = slide_with(&mut p, LayoutType::TitleOnly, "Media"); + let mut pic = shape(&mut p, "Mark", 60.0, 120.0, 80.0, 80.0); + pic.kind = ShapeKind::Picture { fill: PictureFill { media: picture, ..Default::default() } }; + pic.geom = Geom::preset("rect"); + let mut audio = shape(&mut p, "Tone", 180.0, 120.0, 80.0, 80.0); + audio.kind = ShapeKind::Media(MediaClip { media: tone, video: false, bookmarks: vec![("Cue the demo".into(), 400)], ..Default::default() }); + let mut video = shape(&mut p, "Clip", 300.0, 120.0, 160.0, 90.0); + video.kind = ShapeKind::Media(MediaClip { media: clip, video: true, poster: Some(picture), ..Default::default() }); + s.shapes.extend([pic, audio, video]); + p.slides = vec![Arc::new(s)]; + p +} + +fn sections() -> Presentation { + let mut p = defaults::new_presentation(None); + let mut first = slide_with(&mut p, LayoutType::Title, "Opening remarks"); + if let Some(t) = first.shapes.get_mut(1) { + t.text = Some(TextBody { + paragraphs: vec![deckcraft_model::text::Paragraph { + runs: vec![Run::with( + "Brief", + RunProps { + link: Some(Hyperlink { + action: Action::Url { url: "https://example.org/brief?q=1&x=2".into() }, + tooltip: "Open the brief".into(), + highlight_click: false, + }), + ..Default::default() + }, + )], + ..Default::default() + }], + ..Default::default() + }); + } + let second = slide_with(&mut p, LayoutType::SectionHeader, "Figures"); + let ids = [first.id, second.id]; + p.slides = vec![Arc::new(first), Arc::new(second)]; + p.sections = vec![ + deckcraft_model::Section { name: "Opening".into(), slides: vec![ids[0]] }, + deckcraft_model::Section { name: "Figures".into(), slides: vec![ids[1]] }, + ]; + p.custom_shows = vec![deckcraft_model::CustomShow { name: "Short show".into(), slides: vec![ids[0]] }]; + p +} + +fn masters() -> Presentation { + let mut p = defaults::new_presentation(None); + if let Some(m) = p.masters.first_mut() { + let m = Arc::make_mut(m); + if let Some(f) = m.shapes.iter_mut().find(|s| s.ph_type() == Some(PhType::Footer)) { + f.text = Some(TextBody::from_text("Slide footer line")); + } + } + let mut evening = (**p.masters.first().expect("master")).clone(); + evening.id = MasterId(p.alloc_id()); + evening.name = "Evening".into(); + evening.shapes.retain(|s| s.ph_type().is_some()); + let mut banner = shape(&mut p, "Evening banner", 40.0, 40.0, 400.0, 40.0); + banner.text = Some(TextBody::from_text("Evening master")); + evening.shapes.push(banner); + let mut quote = evening.layouts.first().expect("layout").clone(); + quote.id = LayoutId(p.alloc_id()); + quote.name = "Quote".into(); + quote.kind = LayoutType::Custom; + quote.shapes.retain(|s| s.ph_type() == Some(PhType::CtrTitle) || s.ph_type() == Some(PhType::Title)); + let mut line = shape(&mut p, "Quote line", 80.0, 200.0, 600.0, 80.0); + line.text = Some(TextBody::from_text("Pull quote")); + quote.shapes.push(line); + evening.layouts = vec![quote.clone()]; + let quote_id = quote.id; + p.masters.push(Arc::new(evening)); + + let title = slide_with(&mut p, LayoutType::Title, "Welcome"); + let agenda = slide_with(&mut p, LayoutType::SectionHeader, "Agenda"); + let compared = slide_with(&mut p, LayoutType::Comparison, "Comparison"); + let mut quoted = defaults::new_slide(&mut p, quote_id); + if let Some(t) = quoted.shapes.first_mut() { + t.text = Some(TextBody::from_text("A line")); + } + p.slides = vec![Arc::new(title), Arc::new(agenda), Arc::new(compared), Arc::new(quoted)]; + p +} + +struct Pkg { + parts: BTreeMap>, +} + +impl Pkg { + fn open(bytes: &[u8]) -> Self { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).expect("zip"); + let mut parts = BTreeMap::new(); + for i in 0..zip.len() { + let mut f = zip.by_index(i).expect("entry"); + if f.is_dir() { + continue; + } + let name = f.name().trim_start_matches('/').to_string(); + let mut data = vec![]; + f.read_to_end(&mut data).expect("read"); + parts.insert(name, data); + } + Pkg { parts } + } + + fn bytes(&self) -> Vec { + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + for (n, data) in &self.parts { + out.start_file(n, opts).expect("start"); + out.write_all(data).expect("write"); + } + out.finish().expect("finish").into_inner() + } + + fn text_mut(&mut self, name: &str, body: String) { + self.parts.insert(name.to_string(), body.into_bytes()); + } +} + +fn xml_text(parts: &BTreeMap>) -> BTreeMap { + let mut counts = BTreeMap::new(); + for (name, bytes) in parts { + let lower = name.to_ascii_lowercase(); + if !(lower.ends_with(".xml") || lower.ends_with(".rels")) { + continue; + } + let Ok(xml) = std::str::from_utf8(bytes) else { continue }; + for tag in ["a:t", "c:v", "p:text", "dc:title", "dc:subject", "dc:creator", "dc:description"] { + for text in element_texts(xml, tag) { + if !text.is_empty() { + *counts.entry(text).or_insert(0) += 1; + } + } + } + for tag in ["p:cSld", "p14:section", "p:custShow"] { + for text in tag_attr(xml, tag, "name") { + if !text.is_empty() { + *counts.entry(text).or_insert(0) += 1; + } + } + } + for url in attr_values(xml, "Target") { + if url.starts_with("http://") || url.starts_with("https://") { + *counts.entry(url).or_insert(0) += 1; + } + } + for tip in attr_values(xml, "tooltip") { + if !tip.is_empty() { + *counts.entry(tip).or_insert(0) += 1; + } + } + } + counts +} + +fn element_texts(xml: &str, tag: &str) -> Vec { + let mut out = vec![]; + let open = format!("<{tag}"); + let mut rest = xml; + while let Some(i) = rest.find(&open) { + let after = &rest[i + open.len()..]; + let boundary = after.chars().next(); + if !matches!(boundary, Some('>' | '/' | ' ' | '\n' | '\t' | '\r')) { + rest = after; + continue; + } + let Some(gt) = after.find('>') else { break }; + if after.as_bytes().get(gt.saturating_sub(1)) == Some(&b'/') { + rest = &after[gt + 1..]; + continue; + } + let body = &after[gt + 1..]; + let close = format!(""); + let Some(end) = body.find(&close) else { break }; + out.push(decode_xml(&body[..end])); + rest = &body[end + close.len()..]; + } + out +} + +fn tag_attr(xml: &str, tag: &str, attr: &str) -> Vec { + let mut out = vec![]; + let open = format!("<{tag}"); + let mut rest = xml; + while let Some(i) = rest.find(&open) { + let after = &rest[i..]; + let Some(gt) = after.find('>') else { break }; + let start = after[..gt].find(&format!("{attr}=\"")).map(|k| k + attr.len() + 2); + if let Some(start) = start + && let Some(end) = after[start..gt].find('"') + { + out.push(decode_xml(&after[start..start + end])); + } + rest = &after[gt + 1..]; + } + out +} + +fn attr_values(xml: &str, name: &str) -> Vec { + let mut out = vec![]; + let key = format!("{name}=\""); + let mut rest = xml; + while let Some(i) = rest.find(&key) { + let after = &rest[i + key.len()..]; + let Some(end) = after.find('"') else { break }; + out.push(decode_xml(&after[..end])); + rest = &after[end + 1..]; + } + out +} + +fn decode_xml(s: &str) -> String { + let mut out = String::new(); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + let after = &rest[i..]; + let Some(end) = after.find(';') else { + out.push_str(after); + return out; + }; + let ent = &after[1..end]; + let ch = match ent { + "amp" => "&".into(), + "lt" => "<".into(), + "gt" => ">".into(), + "quot" => "\"".into(), + "apos" => "'".into(), + hex if hex.starts_with("#x") || hex.starts_with("#X") => { + u32::from_str_radix(&hex[2..], 16).ok().and_then(char::from_u32).map(|c| c.to_string()).unwrap_or_default() + } + dec if dec.starts_with('#') => dec[1..].parse::().ok().and_then(char::from_u32).map(|c| c.to_string()).unwrap_or_default(), + _ => String::new(), + }; + out.push_str(&ch); + rest = &after[end + 1..]; + } + out.push_str(rest); + out +} + +fn part_count(parts: &BTreeMap>, pred: impl Fn(&str) -> bool) -> usize { + parts.keys().filter(|n| pred(n)).count() +} + +/// Slides, charts, media, comments, handout masters. +fn content_counts(parts: &BTreeMap>) -> [usize; 5] { + let is = |prefix: &str, name: &str| { + let n = name.to_ascii_lowercase(); + n.starts_with(prefix) && n.ends_with(".xml") && !n.contains("_rels") + }; + [ + part_count(parts, |n| is("ppt/slides/slide", n)), + part_count(parts, |n| is("ppt/charts/chart", n)), + part_count(parts, |n| n.to_ascii_lowercase().starts_with("ppt/media/") && !n.to_ascii_lowercase().contains("_rels")), + part_count(parts, |n| is("ppt/comments/comment", n)), + part_count(parts, |n| is("ppt/handoutmasters/handoutmaster", n)), + ] +} + +fn lost<'a>(origin: &'a BTreeMap, saved: &BTreeMap) -> Vec<(&'a str, usize, usize)> { + origin + .iter() + .filter_map(|(s, n)| { + let got = saved.get(s).copied().unwrap_or(0); + (got < *n).then_some((s.as_str(), *n, got)) + }) + .collect() +} + +fn round_trip(label: &str, bytes: &[u8]) -> (Pkg, Pkg) { + let origin = Pkg::open(bytes); + let once = deckcraft_pptx::export(&deckcraft_pptx::import(bytes).unwrap_or_else(|e| panic!("{label}: import: {e}"))) + .unwrap_or_else(|e| panic!("{label}: save: {e}")); + let saved = Pkg::open(&once); + let origin_text = xml_text(&origin.parts); + let missing = lost(&origin_text, &xml_text(&saved.parts)); + assert!(missing.is_empty(), "{label}: text lost on save: {missing:?}"); + assert_eq!(content_counts(&origin.parts), content_counts(&saved.parts), "{label}: content parts changed"); + let again = deckcraft_pptx::export(&deckcraft_pptx::import(&once).unwrap_or_else(|e| panic!("{label}: reopen: {e}"))) + .unwrap_or_else(|e| panic!("{label}: second save: {e}")); + let saved2 = Pkg::open(&again); + assert_eq!(saved.parts.keys().collect::>(), saved2.parts.keys().collect::>(), "{label}: part names changed on the second save"); + assert_eq!(xml_text(&saved.parts), xml_text(&saved2.parts), "{label}: text changed on the second save"); + (origin, saved) +} + +fn require(text: &BTreeMap, label: &str, words: &[&str]) { + for s in words { + assert!(text.contains_key(*s), "{label} corpus is missing {s:?}"); + } +} + +fn ph(id: u32, kind: &str, name: &str, text: &str) -> String { + format!( + "\ + \ + {text}" + ) +} + +fn next_rid(rels: &str) -> String { + let mut max = 0u32; + let mut rest = rels; + while let Some(i) = rest.find("rId") { + let digits: String = rest[i + 3..].chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(n) = digits.parse::() { + max = max.max(n); + } + rest = &rest[i + 3..]; + } + format!("rId{}", max + 1) +} + +/// A deck whose handout master carries the header, footer, date and slide number. +fn handout_deck() -> Vec { + let mut p = defaults::new_presentation(None); + let s = slide_with(&mut p, LayoutType::TitleOnly, "Leave-behind"); + p.slides = vec![Arc::new(s)]; + let bytes = deckcraft_pptx::export(&p).expect("export"); + let mut pkg = Pkg::open(&bytes); + let master = format!( + "\ + \ + \ + \ + {}{}{}{}\ + \ + \ + ", + ph(2, "hdr", "Header Placeholder 1", "Board pack"), + ph(3, "ftr", "Footer Placeholder 2", "Not for circulation"), + ph(4, "dt", "Date Placeholder 3", "1 March 2026"), + ph(5, "sldNum", "Slide Number Placeholder 4", "3"), + ); + pkg.parts.insert("ppt/handoutMasters/handoutMaster1.xml".into(), master.into_bytes()); + pkg.parts.insert( + "ppt/handoutMasters/_rels/handoutMaster1.xml.rels".into(), + b"\ + \ + \ + " + .to_vec(), + ); + let rels_name = "ppt/_rels/presentation.xml.rels"; + let rels = String::from_utf8(pkg.parts.get(rels_name).expect("rels").clone()).expect("utf8"); + let rid = next_rid(&rels); + let rels = rels.replacen( + "", + &format!( + "" + ), + 1, + ); + pkg.text_mut(rels_name, rels); + let pres_name = "ppt/presentation.xml"; + let pres = String::from_utf8(pkg.parts.get(pres_name).expect("presentation").clone()).expect("utf8"); + let list = format!(""); + let pres = if let Some(at) = pres.find("", + "", + 1, + ); + pkg.text_mut("[Content_Types].xml", ct); + pkg.bytes() +} + +fn placeholder_text(p: &Presentation, kind: PhType) -> String { + p.handout_master + .as_ref() + .and_then(|m| m.shapes.iter().find(|s| s.ph_type() == Some(kind))) + .and_then(|s| s.text.as_ref()) + .map(|t| t.text()) + .unwrap_or_default() +} + +#[test] +fn authored_kinds_round_trip_parts_and_text() { + let tables_bytes = deckcraft_pptx::export(&tables()).expect("tables"); + let (origin, _) = round_trip("tables", &tables_bytes); + require(&xml_text(&origin.parts), "tables", &["Quarter", "North", "12", "Total", "Regions"]); + assert_eq!(content_counts(&origin.parts), [1, 0, 0, 0, 0]); + + let charts_bytes = deckcraft_pptx::export(&charts()).expect("charts"); + let (origin, _) = round_trip("charts", &charts_bytes); + require(&xml_text(&origin.parts), "charts", &["Revenue", "Trend", "Share", "Actual", "Jan", "Feb", "Month", "Amount due"]); + assert_eq!(content_counts(&origin.parts), [1, 3, 0, 0, 0]); + + let anim_bytes = deckcraft_pptx::export(&animations()).expect("animations"); + let (origin, saved) = round_trip("animations", &anim_bytes); + require(&xml_text(&origin.parts), "animations", &["Motion", "Fly in", "One", "Two", "Three"]); + let anim = deckcraft_pptx::import(&saved.bytes()).expect("animations import"); + assert_eq!(anim.slides[0].animations.len(), 2); + assert_eq!(anim.slides[0].animations[0].effect, "fly"); + assert_eq!(anim.slides[0].animations[1].text_build, TextBuild::ByParagraph); + assert_eq!(anim.slides[0].transition.as_ref().map(|t| t.kind.as_str()), Some("wipe")); + + let media_bytes = deckcraft_pptx::export(&media()).expect("media"); + let (origin, saved) = round_trip("media", &media_bytes); + require(&xml_text(&origin.parts), "media", &["Media"]); + let mark = png(4, 4, [20, 90, 160]); + assert!(saved.parts.values().any(|b| b == &mark), "the picture bytes did not survive the save"); + assert!(saved.parts.values().any(|b| b == b"owned-wav-tone"), "the audio bytes did not survive the save"); + assert!(saved.parts.values().any(|b| b == b"owned-mp4-clip"), "the video bytes did not survive the save"); + assert!(content_counts(&origin.parts)[2] >= 3, "picture, audio and video"); + let media = deckcraft_pptx::import(&saved.bytes()).expect("media import"); + let tone = media.slides[0].shapes.iter().find(|s| s.name == "Tone").expect("tone"); + let ShapeKind::Media(clip) = &tone.kind else { panic!("not media") }; + assert_eq!(clip.bookmarks, vec![("Cue the demo".into(), 400)]); + assert!(!clip.video); + + let section_bytes = deckcraft_pptx::export(§ions()).expect("sections"); + let (origin, saved) = round_trip("sections", §ion_bytes); + require( + &xml_text(&origin.parts), + "sections", + &["Opening remarks", "Brief", "Figures", "Opening", "Short show", "https://example.org/brief?q=1&x=2", "Open the brief"], + ); + let sections = deckcraft_pptx::import(&saved.bytes()).expect("sections import"); + assert_eq!(sections.sections.iter().map(|s| s.name.as_str()).collect::>(), vec!["Opening", "Figures"]); + assert_eq!(sections.custom_shows[0].name, "Short show"); + + let master_bytes = deckcraft_pptx::export(&masters()).expect("masters"); + let (origin, saved) = round_trip("masters", &master_bytes); + require( + &xml_text(&origin.parts), + "masters", + &[ + "Welcome", + "Agenda", + "Comparison", + "Pull quote", + "Evening", + "Evening master", + "Quote", + "Slide footer line", + "Title Slide", + "Section Header", + ], + ); + let masters = deckcraft_pptx::import(&saved.bytes()).expect("masters import"); + assert_eq!(masters.masters.len(), 2); + assert!(masters.masters.iter().any(|m| m.name == "Evening")); + assert!(masters.masters.iter().flat_map(|m| &m.layouts).any(|l| l.name == "Quote" && l.kind == LayoutType::Custom)); + + let handout = handout_deck(); + let (origin, _) = round_trip("handout master", &handout); + require(&xml_text(&origin.parts), "handout", &["Leave-behind", "Board pack", "Not for circulation", "1 March 2026", "Handouts"]); + assert_eq!(content_counts(&origin.parts)[4], 1, "handout master part"); + let opened = deckcraft_pptx::import(&handout).expect("import handout"); + assert_eq!(opened.handout_master.as_ref().map(|m| m.name.as_str()), Some("Handouts")); + assert_eq!(placeholder_text(&opened, PhType::Header), "Board pack"); + assert_eq!(placeholder_text(&opened, PhType::Footer), "Not for circulation"); + assert_eq!(placeholder_text(&opened, PhType::Date), "1 March 2026"); + assert_eq!(placeholder_text(&opened, PhType::SlideNum), "3"); + let saved = deckcraft_pptx::export(&opened).expect("save handout"); + let reopened = deckcraft_pptx::import(&saved).expect("reopen handout"); + assert_eq!(placeholder_text(&reopened, PhType::Header), "Board pack"); + assert_eq!(placeholder_text(&reopened, PhType::Footer), "Not for circulation"); + assert_eq!(placeholder_text(&reopened, PhType::Date), "1 March 2026"); + assert_eq!(placeholder_text(&reopened, PhType::SlideNum), "3"); + assert_eq!(reopened.slides[0].shapes.first().and_then(|s| s.text.as_ref()).map(|t| t.text()).unwrap_or_default(), "Leave-behind"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 1e888c7a..0da5e120 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus for tables, charts, animations, media, sections and masters; handout-master text 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); corpus is decks we author, not measured against PowerPoint renders | `crates/pptx/tests/corpus_kinds.rs`, `roundtrip.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 | @@ -62,7 +62,7 @@ the round trip even though DeckCraft doesn't model it. | Slide masters, layouts, placeholders and inheritance | yes | yes | | | Themes: colour, font (Latin/EA/CS), effect schemes, background styles | yes | yes | Unknown theme parts kept | | Notes slides and notes master | yes | yes | No notes-master editing view | -| Handout master | no | no | | +| Handout master | yes | yes | Header, footer, date and slide-number text is kept. No handout-master editing view | | Shapes: preset geometry (~180 presets), custom geometry, groups, connectors | yes | yes | Presets re-derived in `crates/geom/src/preset.rs` (and `preset/extra.rs`). Outlines, dashes, arrowheads and ink inside a group, and text, keep their size in points whatever the group's child-coordinate scale (#233). | | Fills: solid, gradient, picture (stretch/tile/crop), pattern, group | yes | yes | | | Lines: dash, compound, caps, joins, arrowheads | yes | yes | | @@ -89,9 +89,10 @@ the round trip even though DeckCraft doesn't model it. ### What would move the PPTX number -1. An owned corpus of real-world-shaped decks (corporate templates, SmartArt, chartex charts, EMF, - embedded fonts, every transition and animation) in a corpus repo, with import → render → export - → re-import checks in CI and a PowerPoint reference render for each (owner runs PowerPoint). +1. A larger owned corpus (corporate templates, SmartArt, chartex charts, EMF, embedded fonts, + every transition and animation) in a corpus repo, with a PowerPoint reference render for each. + `crates/pptx/tests/corpus_kinds.rs` already checks tables, charts, animations, media, sections + and masters/layouts (open → save → reopen). 2. The round-trip losses above (#70, #74, #77). 3. SVG rendering, and clip regions inside EMF/WMF. @@ -99,6 +100,7 @@ the round trip even though DeckCraft doesn't model it. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Owned corpus round-trip for tables, charts, animations, media, sections and masters. Handout-master header, footer, date and slide-number text is written back | | 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..40a27c9a 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-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned PPTX corpus for more deck kinds; handout-master text 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 @@ -17,7 +17,7 @@ revision history. | # | Gap | Category | Beta | Hours | Doc | |---|---|---|:-:|---|---| -| 1 | PPTX never tested against a corpus of real decks | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) | +| 1 | PPTX corpus is only decks we author (no PowerPoint reference renders) | File format | B | 20–35 | [file-format-parity.md](file-format-parity.md) | | 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file | | 3 | PPTX round trip drops comment threads, media options, rewind (#70, #74, #77) | File format | B | 3–6 | [file-format-parity.md](file-format-parity.md) | | 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 6–12 | [file-format-parity.md](file-format-parity.md) | @@ -58,12 +58,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. +### PPTX corpus is only decks we author +- **Evidence:** `crates/pptx/tests/corpus_kinds.rs` builds decks in this repo and checks open → save → + reopen (package parts and text): tables, charts, animations, media, sections, and several masters + and layouts. A handout master with a header, footer, date and slide number used to disappear on + save; that text is kept now. There is still no larger corpus and no reference render from PowerPoint. - **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. + (corporate templates, SmartArt, `chartex` charts, embedded fonts, EMF) is still mostly unknown. - **Estimate:** 20–35 h: build an owned corpus in `storytold/photocraft-corpus` style (craftrules `standards/test-corpora.md`), add import → render → export → re-import checks, have the owner open outputs in PowerPoint, fix what breaks. **Beta blocker.** @@ -160,8 +161,8 @@ Detail in [file-format-parity.md](file-format-parity.md). Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a **beta blocker**. ### Notes and Handout masters -- **Evidence:** checklist row missing; PPTX writes a notes master but there's no view to edit it, - and no handout master at all. +- **Evidence:** checklist row missing the editing view. PPTX writes a notes master, and a handout + master's header, footer, date and slide number are kept. There is no view to edit either master. - **Estimate:** 4–6 h. ### Edit points, text warp, 3-D @@ -256,6 +257,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Owned PPTX corpus for tables, charts, animations, media, sections and masters. Handout-master text is kept; the corpus gap is now a larger set and PowerPoint reference renders | | 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..625516b4 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-13 · **Last updated:** 2026-10-13 · **Change:** trivial (owned PPTX corpus covers more deck kinds; handout-master text kept; 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,9 @@ 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:** `crates/pptx/tests/corpus_kinds.rs` round-trips tables, charts, animations, + media, sections and masters/layouts, and keeps a handout master's header and footer. Still open: + a larger corpus, 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 +97,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 | First owned check of tables, charts, animations, media, sections and masters; handout-master text kept. A larger corpus is still 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 +110,6 @@ plan above. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | trivial | Owned PPTX corpus covers tables, charts, animations, media, sections and masters; handout-master text kept. Beta item 2 stays open for a larger corpus | | 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 |