Merge PR #280 from albayazid: Comments: threads and resolved state survive a .pptx round trip (#70)

This commit is contained in:
Brandon Thomas
2026-10-14 12:57:23 -04:00
9 changed files with 398 additions and 34 deletions
+1
View File
@@ -142,6 +142,7 @@ app's JSON control channel (`deckcraft --control PORT`).
| 2026-10-13 | Slide show for interactive decks: click-triggered animations play (one step per click, repeating, applied in click order), a slide the show returns to keeps its state and its triggered animations go on where they stopped, shapes an animation has hidden cannot be clicked, a shape without an action blocks the one beneath it, hand cursor over clickable shapes, "advance on click" off respected |
| 2026-10-12 | SmartArt import: text laid out on its `dsp:txXfrm` text rectangle, not squeezed into the shape (list layouts no longer show text in narrow columns) |
| 2026-10-12 | Title bar: Export button next to Save, opening the Export dialog on every platform (a shortcut beside File › Export) |
| 2026-10-13 | PPTX export writes modern threaded comments (`p188`) next to the flattened legacy part; replies and resolved state survive a round trip, and real PowerPoint decks stop importing their comments twice (#70) |
| 2026-10-12 | Objects off the slide show and can be selected and moved around it in Normal view, as in PowerPoint; the show, exports and thumbnails still clip to the slide (#270) |
| 2026-10-12 | Korean, Japanese and Chinese input: IME composition shown in place and underlined, one undo step per composition, candidate window at the caret (slide text, notes, fields); interface text (title, notes, outline, sections, comments) borrows installed fonts for scripts the interface fonts lack (#101) |
| 2026-10-12 | Selection Pane like PowerPoint (#133): front-most first, groups fold, drag and drop restacks among siblings, Bring Forward / Send Backward, Show All / Hide All, eye, rename in place, keyboard; Lock (pane padlock, canvas menu) keeps objects from moving, sizing or rotating and round-trips PowerPoint's locking attributes |
+3
View File
@@ -21,6 +21,9 @@ use deckcraft_model::Presentation;
/// Extension URI of the PowerPoint 2010 section list in `presentation.xml`.
pub(crate) const SECTION_EXT_URI: &str = "{521415D9-36F7-43E2-AB2F-B90AF26B5E84}";
/// Extension URI of the modern-comments anchor in a slide (names the threaded comment part).
pub(crate) const COMMENTS_EXT_URI: &str = "{6950BFC3-D8DA-4A85-94F7-54DA5524770B}";
#[derive(Debug, thiserror::Error)]
pub enum PptxError {
#[error("not a PPTX file: {0}")]
+2
View File
@@ -24,6 +24,8 @@ pub const NS_P14: &str = "http://schemas.microsoft.com/office/powerpoint/2010/ma
pub const NS_P15: &str = "http://schemas.microsoft.com/office/powerpoint/2012/main";
pub const NS_P159: &str = "http://schemas.microsoft.com/office/powerpoint/2015/09/main";
pub const NS_A14: &str = "http://schemas.microsoft.com/office/drawing/2010/main";
/// Modern (threaded) comments, Office 2018/8 schema.
pub const NS_P188: &str = "http://schemas.microsoft.com/office/powerpoint/2018/8/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";
+22 -8
View File
@@ -348,12 +348,24 @@ pub fn import(bytes: &[u8]) -> Result<Presentation, PptxError> {
let np = Part::new(&pkg, &n.target, &nd);
slide.notes = read_notes(&mut imp, &np, &nd.root);
}
// Comments.
// Comments. A modern part and the legacy part can describe the same threads (PowerPoint
// writes both): the modern one wins, its flattened legacy copy is skipped.
let mut parts: Vec<(Doc, bool)> = vec![];
for c in part.rels.all_of("comments") {
if let Some(cd) = pkg.xml(&c.target) {
slide.comments.extend(read_comments(&pkg, &pres_part, &cd.root));
// The content type names the modern part; a package that types it only through a
// Default entry instead of an Override is still modern when its root is p188.
let modern = pkg.content_type(&c.target).as_deref() == Some(crate::write::CT_COMMENTS_MODERN) || cd.root.prefix() == "p188";
parts.push((cd, modern));
}
}
let has_modern = parts.iter().any(|(_, m)| *m);
for (cd, modern) in parts {
if has_modern && !modern {
continue;
}
slide.comments.extend(read_comments(&pkg, &pres_part, &cd.root, modern));
}
file_slide_ids.insert(*file_id, *id);
p.slides.push(Arc::new(slide));
}
@@ -782,7 +794,7 @@ fn read_notes(imp: &mut Imp, part: &Part, root: &El) -> TextBody {
TextBody::default()
}
fn read_comments(pkg: &Package, pres: &Part, root: &El) -> Vec<Comment> {
fn read_comments(pkg: &Package, pres: &Part, root: &El, modern: bool) -> Vec<Comment> {
// Authors: legacy commentAuthors.xml (numeric ids) and modern authors.xml (GUIDs).
let mut authors: HashMap<String, (String, String)> = HashMap::new();
for kind in ["commentAuthors", "authors"] {
@@ -796,7 +808,9 @@ fn read_comments(pkg: &Package, pres: &Part, root: &El) -> Vec<Comment> {
}
}
}
fn one(e: &El, authors: &HashMap<String, (String, String)>, depth: usize) -> Comment {
// Legacy positions are 1/8 pt; the modern part's p188:pos is an a:CT_Point2D in EMU.
let scale = if modern { 12_700.0 } else { 8.0 };
fn one(e: &El, authors: &HashMap<String, (String, String)>, depth: usize, scale: f64) -> Comment {
let (author, initials) = e.attr("authorId").and_then(|a| authors.get(a).cloned()).unwrap_or_default();
let text = if let Some(t) = e.child("text") {
t.text()
@@ -807,7 +821,7 @@ fn read_comments(pkg: &Package, pres: &Part, root: &El) -> Vec<Comment> {
};
let pos = e.child("pos");
let replies = if depth < 4 {
e.child("replyLst").map(|l| l.children_named("reply").take(1000).map(|r| one(r, authors, depth + 1)).collect()).unwrap_or_default()
e.child("replyLst").map(|l| l.children_named("reply").take(1000).map(|r| one(r, authors, depth + 1, scale)).collect()).unwrap_or_default()
} else {
vec![]
};
@@ -816,14 +830,14 @@ fn read_comments(pkg: &Package, pres: &Part, root: &El) -> Vec<Comment> {
initials,
text,
date: e.attr("dt").or(e.attr("created")).unwrap_or("").to_string(),
x: pos.and_then(|p| p.f64("x")).unwrap_or(0.0) / 8.0,
y: pos.and_then(|p| p.f64("y")).unwrap_or(0.0) / 8.0,
x: pos.and_then(|p| p.f64("x")).unwrap_or(0.0) / scale,
y: pos.and_then(|p| p.f64("y")).unwrap_or(0.0) / scale,
resolved: e.attr("status") == Some("resolved"),
replies,
shape: None,
}
}
root.children_named("cm").take(10_000).map(|c| one(c, &authors, 0)).collect()
root.children_named("cm").take(10_000).map(|c| one(c, &authors, 0, scale)).collect()
}
fn read_show(sp: &El, shows: &HashMap<String, String>) -> ShowSettings {
+150 -17
View File
@@ -16,7 +16,7 @@ use deckcraft_model::{
};
use crate::PptxError;
use crate::opc::{self, NS_A, NS_P, NS_P14, NS_R, PackageOut, RelsOut};
use crate::opc::{self, NS_A, NS_P, NS_P14, NS_P188, NS_R, PackageOut, RelsOut};
use crate::xml::{A, W};
const CT_PRES: &str = "application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml";
@@ -32,6 +32,12 @@ const CT_TABLE_STYLES: &str = "application/vnd.openxmlformats-officedocument.pre
const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
const CT_COMMENTS: &str = "application/vnd.openxmlformats-officedocument.presentationml.comments+xml";
const CT_AUTHORS: &str = "application/vnd.openxmlformats-officedocument.presentationml.commentAuthors+xml";
/// Modern threaded comments (read by PowerPoint 2021+ and DeckCraft; the flattened legacy
/// part next to it serves older readers).
pub(crate) const CT_COMMENTS_MODERN: &str = "application/vnd.ms-powerpoint.comments+xml";
const CT_AUTHORS_MODERN: &str = "application/vnd.ms-powerpoint.authors+xml";
const RT_COMMENTS_MODERN: &str = "http://schemas.microsoft.com/office/2018/10/relationships/comments";
const RT_AUTHORS_MODERN: &str = "http://schemas.microsoft.com/office/2018/10/relationships/authors";
const CT_CORE: &str = "application/vnd.openxmlformats-package.core-properties+xml";
const CT_APP: &str = "application/vnd.openxmlformats-officedocument.extended-properties+xml";
@@ -281,6 +287,31 @@ fn ext_children(raw: Option<&str>) -> Option<String> {
Some(raw.to_string())
}
/// The slide's extension list, plus the modern-comments anchor when the slide has a threaded
/// comment part: the rel reference is how PowerPoint finds it.
fn slide_ext_list(w: &mut W, raw: Option<&str>, comment_rel: Option<&str>) {
let mut exts: Vec<String> = raw
.filter(|r| !r.trim().is_empty())
.and_then(|raw| crate::xml::parse(format!("<x>{raw}</x>").as_bytes()).ok())
.map(|d| d.root.elements().filter(|e| e.is("ext") && e.has_attr("uri")).map(|e| e.to_xml()).collect())
.unwrap_or_default();
if let Some(rid) = comment_rel {
let mut e = W::frag();
e.open("p:ext", A::new().a("uri", crate::COMMENTS_EXT_URI));
e.empty("p188:commentRel", A::new().a("xmlns:p188", NS_P188).a("r:id", rid));
e.close("p:ext");
exts.push(e.s);
}
if exts.is_empty() {
return;
}
w.open0("p:extLst");
for e in exts {
w.raw(&e);
}
w.close("p:extLst");
}
fn notes_master_shapes(size: deckcraft_geom::Size) -> Vec<Shape> {
let (w, h) = (size.width, size.height);
let mk = |id: u32, name: &str, kind: PhType, idx: u32, x: f64, y: f64, ww: f64, hh: f64| Shape {
@@ -331,12 +362,24 @@ fn notes_style() -> deckcraft_model::text::ListStyle {
ls
}
/// A plausible xsd:dateTime (`YYYY-MM-DD[THH:MM:SS(.frac)?(Z|±HH:MM)?]`): the ranges are
/// checked, so a model value like `2025-13-45T99:99` falls back to the default instead of
/// reaching a required `dt`/`created` attribute.
fn valid_date(s: &str) -> bool {
let b = s.as_bytes();
b.len() >= 10
&& b.iter().take(4).all(|c| c.is_ascii_digit())
&& b.get(4) == Some(&b'-')
&& s.chars().all(|c| c.is_ascii_alphanumeric() || "-:.+TZ".contains(c))
let n = |r: std::ops::Range<usize>| s.get(r).and_then(|v| v.parse::<u32>().ok());
let (Some(_), Some(mo), Some(d)) = (n(0..4), n(5..7), n(8..10)) else { return false };
if b.get(4) != Some(&b'-') || b.get(7) != Some(&b'-') || !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
return false;
}
match b.get(10) {
None => true,
Some(b'T') => match (n(11..13), n(14..16), n(17..19)) {
(Some(h), Some(mi), Some(sec)) => h <= 23 && mi <= 59 && sec <= 60 && b.get(13) == Some(&b':') && b.get(16) == Some(&b':'),
_ => false,
},
Some(_) => false,
}
}
/// A copy of the presentation with every slide pointing at a layout that exists.
@@ -481,13 +524,21 @@ pub fn export(p: &Presentation) -> Result<Vec<u8>, PptxError> {
x.pkg.xml_part(&nname, CT_NOTES, xml);
x.pkg.rels(&nname, &no.rels);
}
let mut comment_rel = None;
if !s.comments.is_empty() {
n_comments += 1;
let cname = format!("ppt/comments/comment{n_comments}.xml");
so.rels.add("comments", &opc::relative(&sname, &cname), false);
x.pkg.xml_part(&cname, CT_COMMENTS, comments_xml(&s.comments, &mut authors));
// The modern threaded part next to the flattened legacy one (issue #70):
// PowerPoint 2021+ and DeckCraft read replies and resolved state from it,
// older readers keep the legacy part.
let mname = format!("ppt/comments/modernComment{n_comments}.xml");
let rid = so.rels.add(RT_COMMENTS_MODERN, &opc::relative(&sname, &mname), false);
x.pkg.xml_part(&mname, CT_COMMENTS_MODERN, modern_comments_xml(&s.comments, &cname, &mut authors));
comment_rel = Some(rid);
}
let xml = slide_xml(&mut x, &mut so, s);
let xml = slide_xml(&mut x, &mut so, s, comment_rel.as_deref());
x.pkg.xml_part(&sname, CT_SLIDE, xml);
x.pkg.rels(&sname, &so.rels);
slide_rids.push(pres.rels.add("slide", &opc::relative("ppt/presentation.xml", &sname), false));
@@ -502,6 +553,15 @@ pub fn export(p: &Presentation) -> Result<Vec<u8>, PptxError> {
w.close("p:cmAuthorLst");
x.pkg.xml_part(name, CT_AUTHORS, w.finish());
pres.rels.add("commentAuthors", "commentAuthors.xml", false);
// The modern authors part the threaded comments' authorId GUIDs point at.
let mut w = W::new();
w.open("p188:authorLst", A::new().a("xmlns:p188", NS_P188));
for (n, ini, _) in authors.iter() {
w.empty("p188:author", A::new().a("id", author_guid(n)).a("name", n).a("initials", ini).a("userId", "").a("providerId", "None"));
}
w.close("p188:authorLst");
x.pkg.xml_part("ppt/authors.xml", CT_AUTHORS_MODERN, w.finish());
pres.rels.add(RT_AUTHORS_MODERN, "authors.xml", false);
}
// Presentation-level parts.
@@ -565,7 +625,7 @@ fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) -
w.finish()
}
fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec<u8> {
fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide, comment_rel: Option<&str>) -> Vec<u8> {
let mut w = W::new();
root_open(&mut w, "p:sld", A::new().o("showMasterSp", (!s.show_master_shapes).then_some("0")).o("show", s.hidden.then_some("0")));
let ids = csld(&mut w, x, o, &s.name, s.background.as_ref(), &s.shapes);
@@ -576,7 +636,7 @@ fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec<u8> {
if !s.animations.is_empty() {
timing::timing(&mut w, x, &s.animations, &s.shapes, &ids);
}
ext_list(&mut w, s.raw_ext.as_deref());
slide_ext_list(&mut w, s.raw_ext.as_deref(), comment_rel);
w.close("p:sld");
w.finish()
}
@@ -618,15 +678,7 @@ fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>) -> V
}
}
for c in flat {
let author = if c.author.is_empty() { "Author".to_string() } else { c.author.clone() };
let ai = match authors.iter().position(|(n, _, _)| *n == author) {
Some(i) => i,
None => {
let ini = if c.initials.is_empty() { author.chars().filter(|c| c.is_alphabetic()).take(2).collect() } else { c.initials.clone() };
authors.push((author.clone(), ini, 0));
authors.len() - 1
}
};
let ai = author_slot(c, authors);
let idx = match authors.get_mut(ai) {
Some(a) => {
a.2 += 1;
@@ -645,6 +697,87 @@ fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>) -> V
w.finish()
}
/// The author's slot in the shared bucket list, adding them on first use. Legacy parts index the
/// list by number, the modern authors part gives every slot a GUID.
fn author_slot(c: &Comment, authors: &mut Vec<(String, String, u32)>) -> usize {
let author = if c.author.is_empty() { "Author".to_string() } else { c.author.clone() };
match authors.iter().position(|(n, _, _)| *n == author) {
Some(i) => i,
None => {
let ini = if c.initials.is_empty() { author.chars().filter(|ch| ch.is_alphabetic()).take(2).collect() } else { c.initials.clone() };
authors.push((author.clone(), ini, 0));
authors.len() - 1
}
}
}
/// The modern (p188) threaded-comment part: replies nest and the resolved state is kept, which
/// the flattened legacy part cannot express (issue #70).
fn modern_comments_xml(list: &[Comment], part: &str, authors: &mut Vec<(String, String, u32)>) -> Vec<u8> {
let mut w = W::new();
w.open("p188:cmLst", A::new().a("xmlns:p188", NS_P188));
for (ti, c) in list.iter().take(10_000).enumerate() {
modern_comment(&mut w, c, part, ti, authors, 0);
}
w.close("p188:cmLst");
w.finish()
}
fn modern_comment(w: &mut W, c: &Comment, part: &str, ti: usize, authors: &mut Vec<(String, String, u32)>, depth: usize) {
let ai = author_slot(c, authors);
let author = authors.get(ai).map(|(n, _, _)| n.clone()).unwrap_or_default();
let dt = if valid_date(&c.date) { c.date.clone() } else { "2024-01-01T00:00:00.000".into() };
let mut a = A::new().a("id", crate::tables::guid(&format!("deckcraft-comment:{part}:{ti}"))).a("authorId", author_guid(&author)).a("created", dt);
if c.resolved {
a = a.a("status", "resolved");
}
w.open("p188:cm", a);
w.empty0("p188:unknownAnchor");
let emu = |v: f64| if v.is_finite() { (v * 12_700.0).round().clamp(0.0, 1.0e9) as i64 } else { 0 };
w.empty("p188:pos", A::new().a("x", emu(c.x)).a("y", emu(c.y)));
if depth < 4 && !c.replies.is_empty() {
w.open0("p188:replyLst");
for (ri, r) in c.replies.iter().take(1000).enumerate() {
let ri_ai = author_slot(r, authors);
let r_author = authors.get(ri_ai).map(|(n, _, _)| n.clone()).unwrap_or_default();
let rdt = if valid_date(&r.date) { r.date.clone() } else { "2024-01-01T00:00:00.000".into() };
let mut ra = A::new()
.a("id", crate::tables::guid(&format!("deckcraft-comment:{part}:{ti}:{ri}")))
.a("authorId", author_guid(&r_author))
.a("created", rdt);
if r.resolved {
ra = ra.a("status", "resolved");
}
w.open("p188:reply", ra);
tx_body(w, &r.text);
w.close("p188:reply");
}
w.close("p188:replyLst");
}
tx_body(w, &c.text);
w.close("p188:cm");
}
/// An `a:txBody` with one paragraph per line, the shape the reader re-joins with `\n`.
fn tx_body(w: &mut W, text: &str) {
w.open0("p188:txBody");
w.empty0("a:bodyPr");
for line in text.split('\n') {
w.open0("a:p");
w.open0("a:r");
w.empty("a:rPr", A::new().a("lang", "en-US"));
w.elt("a:t", line);
w.close("a:r");
w.close("a:p");
}
w.close("p188:txBody");
}
/// The modern author id for a name: the same GUID the authors part writes.
fn author_guid(name: &str) -> String {
crate::tables::guid(&format!("deckcraft-comment-author:{name}"))
}
/// Sections with every slide in exactly one section, in slide order.
fn section_slides(p: &Presentation) -> Vec<(String, Vec<usize>)> {
let mut out: Vec<(String, Vec<usize>)> = p.sections.iter().map(|s| (s.name.clone(), vec![])).collect();
+208
View File
@@ -0,0 +1,208 @@
//! Comment threads through PPTX: the modern part keeps replies and resolved state, the legacy
//! part stays readable, and a package with both imports every thread once.
use std::io::Read;
use std::sync::Arc;
use deckcraft_model::{Comment, Presentation, defaults};
fn deck() -> Presentation {
let mut p = defaults::new_presentation(None);
let s = Arc::make_mut(&mut p.slides[0]);
s.comments = vec![
Comment {
author: "Ada".into(),
initials: "AL".into(),
text: "Top thread\nsecond line".into(),
date: "2025-03-04T05:06:07Z".into(),
x: 90.0,
y: 45.0,
resolved: true,
replies: vec![
Comment { author: "Bob".into(), text: "A <reply> & more".into(), date: "2025-03-04T06:00:00Z".into(), ..Default::default() },
Comment {
author: "Ada".into(),
text: "Another reply".into(),
date: "2025-03-04T06:30:00Z".into(),
resolved: true,
..Default::default()
},
],
shape: None,
},
Comment {
author: "Bob".into(),
initials: "BC".into(),
text: "Open thread".into(),
date: "2025-03-05T01:02:03Z".into(),
x: 120.0,
y: 60.0,
resolved: false,
replies: vec![],
shape: None,
},
];
p
}
fn part(bytes: &[u8], name: &str) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut out = String::new();
z.by_name(name).expect("part").read_to_string(&mut out).expect("utf8");
out
}
/// Rewrite the package, dropping or replacing entries by name.
fn rewrite(bytes: &[u8], drop: &[&str]) -> Vec<u8> {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip");
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
if drop.iter().any(|d| f.name() == *d) {
continue;
}
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");
std::io::copy(&mut f, &mut out).expect("copy");
}
out.finish().expect("finish").into_inner()
}
#[test]
fn threads_and_resolved_round_trip() {
let p = deck();
let bytes = deckcraft_pptx::export(&p).expect("export");
let q = deckcraft_pptx::import(&bytes).expect("import");
let (a, b) = (&p.slides[0].comments, &q.slides[0].comments);
assert_eq!(b.len(), a.len(), "comments duplicated: {b:?}");
assert_eq!(b[0].author, "Ada");
assert_eq!(b[0].initials, "AL");
assert_eq!(b[0].text, "Top thread\nsecond line");
assert_eq!(b[0].date, "2025-03-04T05:06:07Z");
assert!(b[0].resolved);
assert_eq!((b[0].x, b[0].y), (90.0, 45.0));
assert_eq!(b[0].replies.len(), 2);
assert_eq!(b[0].replies[0].author, "Bob");
assert_eq!(b[0].replies[0].text, "A <reply> & more");
assert_eq!(b[0].replies[0].date, "2025-03-04T06:00:00Z");
assert_eq!(b[0].replies[1].text, "Another reply");
assert!(!b[0].replies[0].resolved);
assert!(b[0].replies[1].resolved);
assert_eq!(b[1].author, "Bob");
assert!(!b[1].resolved);
assert_eq!(b[1].text, "Open thread");
}
#[test]
fn package_carries_both_parts_and_the_slide_anchor() {
let bytes = deckcraft_pptx::export(&deck()).expect("export");
let modern = part(&bytes, "ppt/comments/modernComment1.xml");
assert!(modern.contains("p188:cmLst"), "{modern}");
// The thread and the second reply are resolved; the first reply is not.
assert_eq!(modern.matches(r#"status="resolved""#).count(), 2, "{modern}");
assert!(modern.contains("p188:replyLst") && modern.contains("A &lt;reply&gt; &amp; more"), "{modern}");
assert!(modern.contains(r#"<a:t>second line</a:t>"#), "{modern}");
let legacy = part(&bytes, "ppt/comments/comment1.xml");
assert!(legacy.contains("<p:cm ") && legacy.contains("Top thread"), "{legacy}");
let authors = part(&bytes, "ppt/authors.xml");
assert!(authors.contains("p188:authorLst") && authors.contains("Ada"), "{authors}");
assert!(part(&bytes, "ppt/commentAuthors.xml").contains("p:cmAuthorLst"));
let rels = part(&bytes, "ppt/slides/_rels/slide1.xml.rels");
assert!(rels.contains("2018/10/relationships/comments"), "{rels}");
// The slide's commentRel anchor names the relationship that points at THIS modern part —
// the wiring PowerPoint needs and DeckCraft's own reader does not check.
let slide = part(&bytes, "ppt/slides/slide1.xml");
assert!(slide.contains("commentRel") && slide.contains("{6950BFC3-D8DA-4A85-94F7-54DA5524770B}"), "{slide}");
let anchor_at = slide.find("commentRel").expect("anchor");
let rid = slide[anchor_at..].split(r#"r:id=""#).nth(1).and_then(|s| s.split('"').next()).expect("anchor r:id");
let rel = rels.split("<Relationship ").find(|r| r.contains(&format!(r#"Id="{rid}""#))).expect("anchor rel");
assert!(rel.contains("2018/10/relationships/comments") && rel.contains("../comments/modernComment1.xml"), "{rel}");
let ct = part(&bytes, "[Content_Types].xml");
assert!(ct.contains("application/vnd.ms-powerpoint.comments+xml") && ct.contains("application/vnd.ms-powerpoint.authors+xml"), "{ct}");
}
/// Comments on several slides: every comment slide gets its own numbered pair of parts and its
/// own anchor; comment-free slides get none.
#[test]
fn comments_on_several_slides_round_trip() {
let mut p = deck();
let layout = p.slides[0].layout;
while p.slides.len() < 3 {
let s = defaults::new_slide(&mut p, layout);
p.slides.push(Arc::new(s));
}
let s = Arc::make_mut(&mut p.slides[2]);
s.comments = vec![Comment { author: "Cara".into(), text: "Slide three".into(), date: "2025-03-06T00:00:00Z".into(), ..Default::default() }];
let bytes = deckcraft_pptx::export(&p).expect("export");
assert!(part(&bytes, "ppt/comments/modernComment1.xml").contains("Top thread"));
// The parts are numbered by comment slide: the comment-free middle slide is skipped.
assert!(part(&bytes, "ppt/comments/comment2.xml").contains("Slide three"));
assert!(part(&bytes, "ppt/comments/modernComment2.xml").contains("Slide three"));
let rels2 = part(&bytes, "ppt/slides/_rels/slide2.xml.rels");
assert!(!rels2.contains("relationships/comments"), "{rels2}");
let q = deckcraft_pptx::import(&bytes).expect("import");
assert_eq!(q.slides[0].comments.len(), 2);
assert!(q.slides[1].comments.is_empty());
assert_eq!(q.slides[2].comments.len(), 1);
assert_eq!(q.slides[2].comments[0].text, "Slide three");
}
/// A legacy-only package (what older tools read and write) still imports: 1/8 pt positions,
/// flattened replies.
#[test]
fn legacy_only_package_imports() {
let bytes = deckcraft_pptx::export(&deck()).expect("export");
let trimmed = rewrite(
&bytes,
&[
"ppt/comments/modernComment1.xml",
"ppt/authors.xml",
"ppt/slides/_rels/slide1.xml.rels", // rewritten below without the modern rel
],
);
// Re-add the slide rels without the modern-comments relationship and without the anchor ext.
let legacy_rels = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments" Target="../comments/comment1.xml"/></Relationships>"#;
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&trimmed)).expect("zip");
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for i in 0..z.len() {
let mut f = z.by_index(i).expect("entry");
out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start");
if f.name() == "ppt/slides/slide1.xml" {
let mut xml = String::new();
f.read_to_string(&mut xml).expect("utf8");
if let Some(start) = xml.find("<p:extLst>") {
let end = xml.find("</p:extLst>").map(|e| e + "</p:extLst>".len()).unwrap_or(xml.len());
xml.replace_range(start..end, "");
}
std::io::Write::write_all(&mut out, xml.as_bytes()).expect("write");
} else {
std::io::copy(&mut f, &mut out).expect("copy");
}
}
out.start_file("ppt/slides/_rels/slide1.xml.rels", zip::write::SimpleFileOptions::default()).expect("start");
std::io::Write::write_all(&mut out, legacy_rels.as_bytes()).expect("write");
let legacy_pkg = out.finish().expect("finish").into_inner();
let q = deckcraft_pptx::import(&legacy_pkg).expect("import");
let b = &q.slides[0].comments;
// The legacy part flattened the two replies into siblings: four top-level comments.
assert_eq!(b.len(), 4, "{b:?}");
assert_eq!(b[0].text, "Top thread\nsecond line");
// The legacy format has no resolved state — exactly what the modern part adds.
assert!(!b[0].resolved);
assert_eq!((b[0].x, b[0].y), (90.0, 45.0), "legacy positions are 1/8 pt");
assert!(b.iter().all(|c| c.replies.is_empty()));
}
/// A modern-only package (no legacy part) imports threads and EMU positions.
#[test]
fn modern_only_package_imports() {
let bytes = deckcraft_pptx::export(&deck()).expect("export");
let trimmed = rewrite(&bytes, &["ppt/comments/comment1.xml"]);
let q = deckcraft_pptx::import(&trimmed).expect("import");
let b = &q.slides[0].comments;
assert_eq!(b.len(), 2, "{b:?}");
assert_eq!(b[0].replies.len(), 2);
assert!(b[0].resolved);
assert_eq!((b[0].x, b[0].y), (90.0, 45.0), "modern positions are EMU");
}
+3 -1
View File
@@ -1,6 +1,7 @@
# File-format parity with PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (SmartArt text follows `dsp:txXfrm`; previous: trivial, Groups with a small legacy child coordinate space draw outlines, dashes, arrowheads and ink at their own width (#233)) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
> **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)
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**
@@ -81,7 +82,7 @@ the round trip even though DeckCraft doesn't model it.
| 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) |
| Comments: legacy | yes | yes | |
| Comments: modern threaded (`p188`) | yes | no (written as legacy) | Replies and resolved state lost (#70) |
| Comments: modern threaded (`p188`) | yes | yes | Written next to the flattened legacy part; replies and resolved state round-trip (`threads_and_resolved_round_trip`) |
| Unknown slide-level elements (`graphicFrame` OLE, extensions, `mc:AlternateContent`) | kept | kept | Rendered as a labelled placeholder with the preview picture when present |
| Tags, custom XML, VBA | no / no / no | | |
| Embedded fonts | no | no | |
@@ -104,3 +105,4 @@ the round trip even though DeckCraft doesn't model it.
| 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) |
| 2026-10-10 | major | Created from PowerPoint 16.113.4's `CFBundleDocumentTypes`, `crates/pptx` reader/writer and tests, and the open round-trip issues |
| 2026-10-13 | minor | Comments: the writer emits the modern threaded part (`p188`) next to the flattened legacy one; the reader prefers the modern part and scales its EMU positions (#70) |
+8 -7
View File
@@ -1,6 +1,7 @@
# Where DeckCraft falls short of PowerPoint
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (interactive-deck clicks in the slide show; previous: 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-10 · **Last updated:** 2026-10-13 · **Change:** minor (gap 3 shrinks to media options and rewind: comment threads round-trip now, #70; 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)
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
@@ -19,7 +20,7 @@ revision history.
|---|---|---|:-:|---|---|
| 1 | PPTX never tested against a corpus of real decks | 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) |
| 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) |
| 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) |
| 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) |
@@ -68,12 +69,12 @@ Detail in [file-format-parity.md](file-format-parity.md).
`standards/test-corpora.md`), add import → render → export → re-import checks, have the owner open
outputs in PowerPoint, fix what breaks. **Beta blocker.**
### Round trip loses comment threads, media playback options and animation rewind (#70, #74, #77)
- **Evidence:** issues filed 2026-10-10 by an automated reviewer; the writer emits legacy comments
only (no `p188` modern comments with replies and resolved state, though the reader understands
them); media playback options and the animation rewind flag aren't written back.
- **Impact:** a deck opened and saved silently loses review state and show behaviour.
- **Estimate:** 3–6 h. **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
animation rewind flag aren't written back. Comment threads left with #70: the writer now emits
the `p188` modern part next to the legacy one.
- **Impact:** a deck opened and saved silently loses show behaviour.
- **Estimate:** 2–4 h. **Beta blocker.**
### SVG pictures don't render
- **Evidence:** `crates/render/src/images.rs` decodes through `image` 0.25 (PNG, JPEG, WebP, GIF,
+1 -1
View File
@@ -20,7 +20,7 @@ points and lead the beta plan.
| Present: full-screen show with transitions, animations, media, presenter view with notes, next slide and timer | yes | 48/48 transitions, 64 effects, triggers, builds, media playback, presenter window. Presenter view isn't placed on the second display automatically (drag it there) | 3–5 |
| Add business content: tables, charts with their data, simple diagrams | partial (not blocking) | Tables with styles, merge/split, all D or P. Charts insert with the common types and edit data in the Chart Data dialog (P0 partial); SmartArt is basic (pre-drawn shapes) | 12–20 |
| Share the deck: PDF of slides, notes or handouts; images | yes | `file.export` PDF with text layer, links, bookmarks, handouts 1–9; PNG/JPEG. Artwork is raster in the PDF; no direct print (print the PDF) | 10–16 |
| Review: comments with replies, resolve, spelling | partial (not blocking) | Comments, replies and resolve work and survive `.deckcraft`; threads are flattened on `.pptx` save (#70); spelling is a small built-in list | 4–8 |
| Review: comments with replies, resolve, spelling | partial (not blocking) | Comments, replies and resolve work and survive `.deckcraft` and `.pptx` (#70); spelling is a small built-in list | 4–8 |
## Current focus (2026-10-10)