diff --git a/ROADMAP.md b/ROADMAP.md index 29d740e2..ff5e8385 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,6 +9,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (right click selects; previous: 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: codec parsers reject overflowing numeric fields, #426; previous: minor, hours to ~95% per audience) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt Transform gallery; previous: hours to ~95% per audience added; ready 56% → 55%; stage stays alpha) · **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 round-trip, notes-master header and footer; 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 @@ -117,8 +118,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, @@ -143,6 +144,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-13 | Embedded fonts (`p:embeddedFontLst`) are read, written and used to draw. TrueType, OpenType and Embedded OpenType faces load; MicroType Express subsets are kept for the round trip and still draw in a stand-in | | 2026-10-13 | Codec parsers (#426): H.264 and HEVC quantiser offsets, Matroska block times, Opus granule timing and QuickTime PCM frame sizes are range-checked before the arithmetic, so a hostile number is a format error instead of an overflow | | 2026-10-13 | WordArt Transform: Shape Format ▸ Text Effects shows the 40 text warps and No Transform; `format.textWarp` applies one to the selected text. Adjust handles are still open | +| 2026-10-13 | PPTX corpus round-trip for decks we author (open → save → reopen, package parts and text). A notes master's header and footer are kept instead of being replaced by an empty notes master | | 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 | @@ -176,6 +178,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-13 | trivial | Progress log: EMF/WMF rectangle clip regions | | 2026-10-13 | trivial | Progress log: embedded fonts | | 2026-10-13 | trivial | Progress log: codec parsers reject overflowing numeric fields (#426) | +| 2026-10-13 | trivial | Progress log: owned PPTX corpus round-trip; notes-master header and footer | | 2026-10-13 | trivial | Progress log: wild text cases | | 2026-10-13 | trivial | Progress log: slide show for interactive decks | | 2026-10-12 | trivial | Progress log: File tab (Backstage, #76) | diff --git a/crates/pptx/src/read/mod.rs b/crates/pptx/src/read/mod.rs index 25bb1856..70570e11 100644 --- a/crates/pptx/src/read/mod.rs +++ b/crates/pptx/src/read/mod.rs @@ -428,12 +428,19 @@ pub fn import(bytes: &[u8]) -> Result { let tp = Part::new(&pkg, &t.target, &td); m.theme = theme::read_theme(&mut imp, &tp, &td.root); } - if let Some(tree) = d.root.path(&["cSld", "spTree"]) { - m.shapes = shapes::sp_tree(&mut imp, &part, &mut ctx, tree, 0); + if let Some(csld) = d.root.child("cSld") { + 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); } + // Notes text styles are the body's list style; slide masters use title/body/other instead. + if let Some(ns) = d.root.child("notesStyle") { + m.body_style = dml::list_style(&mut imp, &part, ns); + } p.notes_master = Some(Arc::new(m)); } // Document properties. diff --git a/crates/pptx/src/write/mod.rs b/crates/pptx/src/write/mod.rs index f719127e..f31a4609 100644 --- a/crates/pptx/src/write/mod.rs +++ b/crates/pptx/src/write/mod.rs @@ -10,7 +10,8 @@ use std::collections::HashMap; use std::sync::Arc; use deckcraft_geom::Xfrm; -use deckcraft_model::text::{Paragraph, TextBody}; +use deckcraft_model::text::{ListStyle, Paragraph, TextBody}; +use deckcraft_model::theme::Theme; use deckcraft_model::{ Background, Comment, Layout, LayoutId, Master, MediaId, PhType, Placeholder, Presentation, Shape, ShapeId, Slide, SlideId, defaults, }; @@ -341,7 +342,42 @@ fn notes_master_shapes(size: deckcraft_geom::Size) -> Vec { ] } -fn notes_style() -> deckcraft_model::text::ListStyle { +/// What the notes master should be on save. A master we read is written back, including its +/// header, footer, date and slide-number text. A deck that has never had one gets the placeholders +/// we generate. +struct NotesMasterWrite { + shapes: Vec, + background: Option, + color_map: Vec<(String, String)>, + style: ListStyle, + theme: Option, +} + +fn notes_master_write(p: &Presentation) -> Option { + let kept = p.notes_master.as_ref().filter(|m| !m.shapes.is_empty()); + let has_notes = p.slides.iter().any(|s| !s.notes.is_empty()); + if !has_notes && kept.is_none() { + return None; + } + Some(match kept { + Some(m) => NotesMasterWrite { + shapes: m.shapes.clone(), + background: m.background.clone(), + color_map: m.color_map.clone(), + style: if m.body_style.is_empty() { notes_style() } else { m.body_style.clone() }, + theme: Some(m.theme.clone()), + }, + None => NotesMasterWrite { + shapes: notes_master_shapes(p.notes_size), + background: Some(Background::Ref { idx: 1001, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1) }), + color_map: vec![], + style: notes_style(), + theme: None, + }, + }) +} + +fn notes_style() -> ListStyle { let mut ls = deckcraft_model::text::ListStyle::default(); for i in 0..9u8 { ls.set( @@ -479,25 +515,24 @@ pub fn export(p: &Presentation, main_ct: &str) -> Result, PptxError> { master_rids.push((master_id, pres.rels.add("slideMaster", &opc::relative("ppt/presentation.xml", &mname), false))); } - // Notes master when any slide has notes. - let has_notes = p.slides.iter().any(|s| !s.notes.is_empty()); + // Notes master when a slide has notes, or the file already has one (its header and footer + // live on the master, not on the notes slides). let notes_master = "ppt/notesMasters/notesMaster1.xml"; let mut notes_master_rid = None; - if has_notes { + if let Some(nm) = notes_master_write(p) { let tname = format!("ppt/theme/theme{}.xml", p.masters.len() + 1); let mut no = Out::new(notes_master); no.rels.add("theme", &opc::relative(notes_master, &tname), false); - let theme = p.notes_master.as_ref().map(|m| m.theme.clone()).or_else(|| p.masters.first().map(|m| m.theme.clone())).unwrap_or_default(); + let theme = nm.theme.or_else(|| p.masters.first().map(|m| m.theme.clone())).unwrap_or_default(); let mut to = Out::new(&tname); let txml = theme::theme_xml(&mut x, &mut to, &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:notesMaster", A::new()); - let bg = Background::Ref { idx: 1001, color: deckcraft_model::ColorRef::scheme(deckcraft_color::SchemeSlot::Bg1) }; - csld(&mut w, &mut x, &mut no, "", Some(&bg), ¬es_master_shapes(p.notes_size)); - clr_map(&mut w, &[]); - dml::list_style(&mut w, &mut x, &mut no, "p:notesStyle", ¬es_style()); + csld(&mut w, &mut x, &mut no, "", nm.background.as_ref(), &nm.shapes); + clr_map(&mut w, &nm.color_map); + dml::list_style(&mut w, &mut x, &mut no, "p:notesStyle", &nm.style); w.close("p:notesMaster"); x.pkg.xml_part(notes_master, CT_NOTES_MASTER, w.finish()); x.pkg.rels(notes_master, &no.rels); diff --git a/crates/pptx/tests/corpus.rs b/crates/pptx/tests/corpus.rs new file mode 100644 index 00000000..1f14f6ee --- /dev/null +++ b/crates/pptx/tests/corpus.rs @@ -0,0 +1,382 @@ +//! Owned corpus: decks this repository authors, then open → save → reopen. +//! +//! Each package is built here (nothing from another product). The check compares the text in the +//! package and the content parts. A second save must come out the same as the first. + +use std::collections::BTreeMap; +use std::io::{Cursor, Read, Write}; +use std::sync::Arc; + +use deckcraft_model::chart::{Chart, ChartType}; +use deckcraft_model::style::PictureFill; +use deckcraft_model::table::Table; +use deckcraft_model::text::{Action, Hyperlink, Paragraph, Run, RunProps, TextBody}; +use deckcraft_model::{Comment, Geom, 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 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 +} + +/// A short talk: title, notes, a link, a comment, a picture, a table and a chart. +fn talk() -> Presentation { + let mut p = defaults::new_presentation(None); + p.props.title = "Quarterly review".into(); + p.props.subject = "Owned corpus".into(); + p.props.author = "DeckCraft tests".into(); + let img = p.add_media("mark.png", "image/png", png(4, 4, [20, 90, 160])); + + { + let id = p.slides[0].id; + let s = p.slide_mut(id).expect("slide"); + s.notes = TextBody::from_text("Say the number slowly"); + if let Some(t) = s.shapes.first_mut() { + t.text = Some(TextBody { + paragraphs: vec![Paragraph { + runs: vec![ + Run::new("Quarterly review"), + Run::with( + "North report", + RunProps { + link: Some(Hyperlink { + action: Action::Url { url: "https://example.org/north?q=1&x=2".into() }, + tooltip: "Open the report".into(), + highlight_click: false, + }), + ..Default::default() + }, + ), + ], + ..Default::default() + }], + ..Default::default() + }); + } + s.comments = vec![Comment { + author: "Ada".into(), + initials: "AL".into(), + text: "Ada checks the axis".into(), + date: "2026-03-04T05:06:07.000".into(), + x: 40.0, + y: 40.0, + ..Default::default() + }]; + } + + let mut s = new_slide(&mut p, "Figures"); + let mut pic = shape(&mut p, "Mark", 60.0, 80.0, 80.0, 80.0); + pic.kind = ShapeKind::Picture { fill: PictureFill { media: img, ..Default::default() } }; + pic.geom = Geom::preset("rect"); + let mut table = Table::new(2, 2, 200.0, 24.0); + for (r, c, text) in [(0, 0, "Region"), (0, 1, "Amount"), (1, 0, "North"), (1, 1, "12")] { + if let Some(cell) = table.cell_mut(r, c) { + cell.text = TextBody::from_text(text); + } + } + let mut ts = shape(&mut p, "Regions", 160.0, 80.0, 200.0, 48.0); + ts.kind = ShapeKind::Table(table); + let mut chart = Chart::sample(ChartType::Column); + chart.title = Some("Revenue".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)]; + } + chart.axis_titles = (Some("Month".into()), Some("Amount due".into())); + let mut ch = shape(&mut p, "Revenue chart", 400.0, 80.0, 280.0, 180.0); + ch.kind = ShapeKind::Chart(Box::new(chart)); + s.shapes.extend([pic, ts, ch]); + p.slides.push(Arc::new(s)); + 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 replace(&self, name: &str, data: Vec) -> Vec { + let mut out = zip::ZipWriter::new(Cursor::new(Vec::new())); + let opts = zip::write::SimpleFileOptions::default(); + let mut wrote = false; + for (n, bytes) in &self.parts { + let body = if n == name { + wrote = true; + data.as_slice() + } else { + bytes.as_slice() + }; + out.start_file(n, opts).expect("start"); + out.write_all(body).expect("write"); + } + assert!(wrote, "missing part {name}"); + out.finish().expect("finish").into_inner() + } +} + +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 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 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() +} + +fn content_counts(parts: &BTreeMap>) -> [usize; 6] { + 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/notesslides/notesslide", n)), + part_count(parts, |n| is("ppt/notesmasters/notesmaster", 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)), + ] +} + +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() +} + +/// Open, save, reopen. Text from the original is still there, content parts are still there, and +/// saving again does not change the package's part names or text. +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 set_placeholder_text(xml: &str, ph: &str, text: &str) -> String { + let needle = format!("type=\"{ph}\""); + let at = xml.find(&needle).unwrap_or_else(|| panic!("notes master has no {ph} placeholder")); + let after = &xml[at..]; + let start_rel = after.find("").unwrap_or_else(|| panic!("no txBody after {ph}")); + let end_rel = after.find("").unwrap_or_else(|| panic!("no txBody end after {ph}")); + assert!(start_rel < end_rel, "{ph} txBody is out of order"); + let start = at + start_rel; + let end = at + end_rel + "".len(); + let body = format!("{text}"); + let mut out = String::with_capacity(xml.len() + body.len()); + out.push_str(&xml[..start]); + out.push_str(&body); + out.push_str(&xml[end..]); + out +} + +/// A received deck whose notes master carries the header and footer (where PowerPoint stores them). +fn notes_header_deck() -> Vec { + let mut p = defaults::new_presentation(None); + let id = p.slides[0].id; + let s = p.slide_mut(id).expect("slide"); + if let Some(t) = s.shapes.first_mut() { + t.text = Some(TextBody::from_text("Quarterly review")); + } + s.notes = TextBody::from_text("Pause after the title"); + let bytes = deckcraft_pptx::export(&p).expect("export"); + let pkg = Pkg::open(&bytes); + let master = pkg.parts.get("ppt/notesMasters/notesMaster1.xml").expect("notes master").clone(); + let xml = String::from_utf8(master).expect("utf8"); + let xml = set_placeholder_text(&xml, "hdr", "Acme review"); + let xml = set_placeholder_text(&xml, "ftr", "Do not circulate"); + pkg.replace("ppt/notesMasters/notesMaster1.xml", xml.into_bytes()) +} + +fn placeholder_text(p: &Presentation, kind: PhType) -> String { + p.notes_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 owned_corpus_round_trip_keeps_parts_and_text() { + let talk_bytes = deckcraft_pptx::export(&talk()).expect("talk"); + let (origin, saved) = round_trip("talk", &talk_bytes); + let text = xml_text(&origin.parts); + for s in [ + "Quarterly review", + "North report", + "Say the number slowly", + "Region", + "North", + "Revenue", + "Jan", + "Feb", + "Actual", + "Month", + "Amount due", + "Ada checks the axis", + "Owned corpus", + "DeckCraft tests", + "https://example.org/north?q=1&x=2", + "Open the report", + ] { + assert!(text.contains_key(s), "talk corpus is missing {s:?}"); + } + 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_eq!(content_counts(&origin.parts), [2, 1, 1, 1, 1, 1], "talk corpus shape"); + + let notes = notes_header_deck(); + let _ = round_trip("notes header", ¬es); + let opened = deckcraft_pptx::import(¬es).expect("import notes header"); + assert_eq!(placeholder_text(&opened, PhType::Header), "Acme review"); + assert_eq!(placeholder_text(&opened, PhType::Footer), "Do not circulate"); + let saved = deckcraft_pptx::export(&opened).expect("save notes header"); + let reopened = deckcraft_pptx::import(&saved).expect("reopen notes header"); + assert_eq!(placeholder_text(&reopened, PhType::Header), "Acme review"); + assert_eq!(placeholder_text(&reopened, PhType::Footer), "Do not circulate"); + assert_eq!(reopened.slides[0].notes_text(), "Pause after the title"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index cd026121..5da46a5c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -4,6 +4,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width comments: modern threaded parts written, #70; 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-10 · **Last updated:** 2026-10-13 · **Change:** minor (EMF/WMF rectangle clip regions; previous: trivial, groups with a small legacy child space, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (embedded fonts read, written and drawn; MicroType Express subsets kept but not drawn; previously trivial: groups with a small legacy child coordinate space, #233) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned corpus round-trip; notes-master header and footer 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** @@ -19,7 +20,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.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 | @@ -64,7 +65,7 @@ the round trip even though DeckCraft doesn't model it. | Presentation, slide size, sections (p14), custom shows | yes | yes | | | 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 | +| Notes slides and notes master | yes | yes | Header, footer, date and slide-number text on the notes master is kept. No notes-master editing view | | Handout master | no | no | | | 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 | | @@ -92,9 +93,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. `crates/pptx/tests/corpus.rs` already checks + open → save → reopen (parts and text) for decks we author. Still needed: render checks and a + PowerPoint reference render for each (owner runs PowerPoint). 2. The round-trip losses above (#70, #74, #77). 3. SVG rendering. EMF+ records inside EMF/WMF are still not drawn. @@ -105,6 +107,7 @@ the round trip even though DeckCraft doesn't model it. | 2026-10-12 | minor | SmartArt import lays each shape's text out on its `dsp:txXfrm` text rectangle instead of the shape box | | 2026-10-13 | minor | EMF/WMF rectangle clip regions limit later drawing; EMF+ records remain | | 2026-10-13 | minor | Embedded fonts read and written; TrueType, OpenType and Embedded OpenType faces drawn | +| 2026-10-13 | minor | Owned corpus round-trip (parts and text). Notes-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 4008af20..3604b80b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -5,6 +5,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (EMF/WMF rectangle clip regions; previous: minor, 3-D bevel, camera and face lighting rendered) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (embedded fonts are read, written and drawn, except MicroType Express subsets; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (WordArt Transform gallery applies a text warp; previous: minor, 3-D bevel, camera and face lighting rendered; previous: minor, IME composition and CJK interface text landed, #101) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (owned PPTX corpus round-trip; notes-master header and footer 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 @@ -21,7 +22,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 media options and rewind (#74, #77) | File format | B | 2–4 | [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) | @@ -74,15 +75,17 @@ 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.rs` builds decks in this repo and checks open → save → + reopen (package parts and text): a talk with a table, chart, picture, link and comment, and a + notes master with a header and footer. Saving used to replace that notes master with an empty + one, so "Acme review" and "Do not circulate" disappeared; they are 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. -- **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.** + (corporate templates, SmartArt, `chartex` charts, embedded fonts, EMF) is still mostly unknown. +- **Estimate:** 20–35 h: grow the owned corpus in `storytold/photocraft-corpus` style (craftrules + `standards/test-corpora.md`), add render checks, have the owner open outputs in PowerPoint, fix + what breaks. **Beta blocker.** ### Round trip loses media playback options and animation rewind (#74, #77) - **Evidence:** issues filed 2026-10-10 by an automated reviewer; media playback options and the @@ -300,6 +303,7 @@ Detail in [localization-parity.md](localization-parity.md). | 2026-10-13 | minor | WordArt Transform gallery: Shape Format ▸ Text Effects lists the 40 presets and No Transform; `format.textWarp` applies one (adjust handles still open) | | 2026-10-13 | minor | Edit Points: smooth/straight/corner point types, Bézier handle dragging, right-click point menu, no selection box while editing | | 2026-10-13 | minor | Edit Points: convert to freeform, drag, add and delete vertices (`shape.editPoints`, `shape.movePoint`, `shape.addPoint`, `shape.deletePoint`); left: point types and control handles | +| 2026-10-13 | minor | Owned PPTX corpus round-trip (parts and text). Notes-master header and footer 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-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features | | 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 | diff --git a/docs/roadmap.md b/docs/roadmap.md index 0075e45c..0096fdd7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,6 +2,7 @@ > **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** trivial (embedded fonts leave the beta list except MicroType Express subsets; previous: minor, core-workflow alpha gate added; stage stays alpha) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) > **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (M9 text warps narrowed to adjust handles; the Transform gallery applies a preset) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365) +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** trivial (owned PPTX corpus check started; 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 @@ -27,8 +28,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:** `crates/pptx/tests/corpus.rs` round-trips decks we author (open → save → + reopen, parts and text) and keeps a notes master's header and footer. Still open: a larger + corpus, comment threads, media options and rewind on save (#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. @@ -97,7 +100,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 in CI; notes-master header and footer 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 | @@ -112,5 +115,6 @@ plan above. |---|---|---| | 2026-10-13 | trivial | Beta item 3: embedded fonts are in, except MicroType Express subsets | | 2026-10-13 | minor | M9: the WordArt Transform gallery applies a text warp; adjust handles stay open | +| 2026-10-13 | trivial | Owned PPTX corpus check started; notes-master header and footer 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 |