mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
This commit is contained in:
@@ -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 |
|
||||
|
||||
@@ -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}")]
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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 <reply> & 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");
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user