diff --git a/crates/pptx/README.md b/crates/pptx/README.md index b71778d4a..9e90e43f3 100644 --- a/crates/pptx/README.md +++ b/crates/pptx/README.md @@ -47,4 +47,7 @@ Transitions (`p:transition`, with the PowerPoint 2010 and later effects) and ani (`p:timing`, flattened into `anim::Animation` on reading and built back into the timing tree on writing, with media volume and playback settings) are read and written. -Not read or written yet (later changes): speaker notes, comments. +Speaker notes (`p:notes`, text with its formatting) and comments are read and written. Comments +go out twice, as PowerPoint writes them: the modern threaded part (`p188:`, with replies and the +resolved state) and the flattened legacy part with its authors; on reading, a slide's modern part +wins over its legacy copy. diff --git a/crates/pptx/src/read.rs b/crates/pptx/src/read.rs index 5e6529619..ffafb2319 100644 --- a/crates/pptx/src/read.rs +++ b/crates/pptx/src/read.rs @@ -17,9 +17,10 @@ use craft_xml::{El, Local, value}; use crate::package::{Doc, Package}; use crate::placeholder::{Owner, parents}; +use crate::text::TextBody; use crate::{ - Background, CustomShow, Defaults, EmbeddedFont, Error, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, MediaItem, - PhType, Presentation, Properties, Section, ShowSettings, Size, Slide, SlideId, + Background, Comment, CustomShow, Defaults, EmbeddedFont, Error, Geom, HeaderFooter, Layout, LayoutId, LayoutType, Master, MasterId, MediaId, + MediaItem, PhType, Presentation, Properties, Section, ShowSettings, Size, Slide, SlideId, }; /// Import state shared by all parts. @@ -419,7 +420,7 @@ pub(crate) fn import_with_chart_points(bytes: &[u8], defaults: &dyn Defaults, ch read_presentation_settings(&mut imp, &mut p, &pres_part, pres_el); let layout_by_part = read_masters(&mut imp, &mut p, &master_paths, &docs); - let file_slide_ids = read_slides(&mut imp, &mut p, &docs, &slide_ids, &layout_by_part); + let file_slide_ids = read_slides(&mut imp, &mut p, &pres_part, &docs, &slide_ids, &layout_by_part); read_sections(&mut p, &pres_part, pres_el, &file_slide_ids); p.tags = tags(&mut imp, &pres_part, pres_el); read_custom_shows(&mut p, &imp, &pres_part, pres_el); @@ -578,10 +579,11 @@ fn read_masters(imp: &mut Imp, p: &mut Presentation, master_paths: &[String], do layout_by_part } -/// Reads every registered slide. Returns the slide ids by file id. +/// Reads every registered slide with its notes and comments. Returns the slide ids by file id. fn read_slides( imp: &mut Imp, p: &mut Presentation, + pres_part: &Part, docs: &HashMap, slide_ids: &[(u32, String, SlideId)], layout_by_part: &HashMap, @@ -589,12 +591,19 @@ fn read_slides( let pkg = imp.pkg; let fallback_layout = p.masters.first().and_then(|m| m.layouts.first()).map(|l| l.id).unwrap_or_default(); let mut file_slide_ids: HashMap = HashMap::new(); + // A notes or comments part belongs to one slide; one shared by many must not be read (and + // copied) once per slide that names it. + let mut owned_parts: HashSet = HashSet::new(); for (file_id, path, id) in slide_ids { let Some(doc) = docs.get(&craft_opc::normalize(path)) else { continue }; let part = Part::new(pkg, path, doc); let layout = part.rels.first("slideLayout").and_then(|r| layout_by_part.get(&craft_opc::normalize(&r.target)).copied()).unwrap_or(fallback_layout); - let slide = read_slide(imp, &part, doc, *id, layout); + let mut slide = read_slide(imp, &part, doc, *id, layout); + if let Some(notes) = slide_notes(imp, &part, &mut owned_parts) { + slide.notes = notes; + } + read_slide_comments(pkg, pres_part, &part, &mut slide, &mut owned_parts); file_slide_ids.insert(*file_id, *id); p.slides.push(Arc::new(slide)); } @@ -602,6 +611,42 @@ fn read_slides( } /// The slide's notes text. A notes part is the notes of the first slide that names it. +fn slide_notes(imp: &mut Imp, part: &Part, owned: &mut HashSet) -> Option { + let n = part.rels.first("notesSlide")?; + if !owned.insert(craft_opc::normalize(&n.target)) { + log::warn!("pptx: notes part {} is named by more than one slide; kept on the first", n.target); + return None; + } + let pkg = imp.pkg; + let nd = load(pkg, &n.target)?; + let np = Part::new(pkg, &n.target, &nd); + Some(read_notes(imp, &np, &nd.root)) +} + +/// 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. +/// Each comments part is read once, for the first slide that names it. +fn read_slide_comments(pkg: &Package, pres_part: &Part, part: &Part, slide: &mut Slide, owned: &mut HashSet) { + let mut parts: Vec<(Doc, bool, bool)> = vec![]; + for c in part.rels.all("comments") { + // An already-owned part is not re-read, but its kind still decides modern precedence. + let fresh = owned.insert(craft_opc::normalize(&c.target)); + if let Some(cd) = pkg.xml(&c.target) { + // 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, fresh)); + } + } + let has_modern = parts.iter().any(|(_, m, _)| *m); + for (cd, modern, fresh) in parts { + if !fresh || (has_modern && !modern) { + continue; + } + slide.comments.extend(read_comments(pkg, pres_part, &cd.root, modern)); + } +} + /// Sections, and the extension elements the model does not understand (kept verbatim). fn read_sections(p: &mut Presentation, pres_part: &Part, pres_el: &El, file_slide_ids: &HashMap) { let Some(ext) = pres_el.child(Local("extLst")) else { return }; @@ -1141,6 +1186,73 @@ fn read_slide(imp: &mut Imp, part: &Part, doc: &Doc, id: SlideId, layout: Layout s } +fn read_notes(imp: &mut Imp, part: &Part, root: &El) -> TextBody { + let Some(tree) = root.path(&[Local("cSld"), Local("spTree")]) else { return TextBody::default() }; + for sp in tree.children(Local("sp")) { + let ph = sp.path(&[Local("nvSpPr"), Local("nvPr"), Local("ph")]); + let is_body = ph.is_some_and(|ph| matches!(ph.attr_local("type"), Some("body") | None) && ph.attr_local("type") != Some("sldImg")); + if is_body && let Some(tx) = sp.child(Local("txBody")) { + let mut t = dml::text_body(&mut Src(imp, part), tx); + // Notes keep only content: drop body formatting from the notes master placeholder. + t.list_style = Default::default(); + if t.is_empty() { + return TextBody::default(); + } + return t; + } + } + TextBody::default() +} + +fn read_comments(pkg: &Package, pres: &Part, root: &El, modern: bool) -> Vec { + // Authors: legacy commentAuthors.xml (numeric ids) and modern authors.xml (GUIDs). + let mut authors: HashMap = HashMap::new(); + for kind in ["commentAuthors", "authors"] { + if let Some(r) = pres.rels.first(kind) + && let Some(d) = pkg.xml(&r.target) + { + for a in d.root.elements() { + if let Some(id) = a.attr_local("id") { + authors + .insert(id.to_string(), (a.attr_local("name").unwrap_or("").to_string(), a.attr_local("initials").unwrap_or("").to_string())); + } + } + } + } + // 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, depth: usize, scale: f64) -> Comment { + let (author, initials) = e.attr_local("authorId").and_then(|a| authors.get(a).cloned()).unwrap_or_default(); + let text = if let Some(t) = e.child(Local("text")) { + t.deep_text() + } else if let Some(tb) = e.child(Local("txBody")) { + tb.children(Local("p")).map(|p| p.deep_text()).collect::>().join("\n") + } else { + String::new() + }; + let pos = e.child(Local("pos")); + let replies = if depth < 4 { + e.child(Local("replyLst")) + .map(|l| l.children(Local("reply")).take(1000).map(|r| one(r, authors, depth + 1, scale)).collect()) + .unwrap_or_default() + } else { + vec![] + }; + Comment { + author, + initials, + text, + date: e.attr_local("dt").or(e.attr_local("created")).unwrap_or("").to_string(), + x: pos.and_then(|p| p.attr_local("x").and_then(value::to_f64)).unwrap_or(0.0) / scale, + y: pos.and_then(|p| p.attr_local("y").and_then(value::to_f64)).unwrap_or(0.0) / scale, + resolved: e.attr_local("status") == Some("resolved"), + replies, + shape: None, + } + } + root.children(Local("cm")).take(10_000).map(|c| one(c, &authors, 0, scale)).collect() +} + fn read_show(sp: &El, shows: &HashMap) -> ShowSettings { let mut s = ShowSettings { loop_until_esc: sp.attr_local("loop").and_then(value::to_bool).unwrap_or(false), diff --git a/crates/pptx/src/write.rs b/crates/pptx/src/write.rs index 390330df4..0e8d01b13 100644 --- a/crates/pptx/src/write.rs +++ b/crates/pptx/src/write.rs @@ -16,12 +16,15 @@ use craft_xml::{Local, Writer}; use crate::text::{ListStyle, Paragraph, TextBody}; use crate::theme::Theme; -use crate::{Background, Defaults, Error, Layout, LayoutId, Master, MediaId, PhType, Placeholder, Presentation, Shape, ShapeId, Slide, SlideId}; +use crate::{ + Background, Comment, Defaults, Error, Layout, LayoutId, Master, MediaId, PhType, Placeholder, Presentation, Shape, ShapeId, Slide, SlideId, +}; pub(crate) const NS_A: &str = craft_drawingml::ns::NS_A; pub(crate) const NS_R: &str = craft_drawingml::ns::NS_R; pub(crate) const NS_P: &str = "http://schemas.openxmlformats.org/presentationml/2006/main"; pub(crate) const NS_MC: &str = "http://schemas.openxmlformats.org/markup-compatibility/2006"; +pub(crate) const NS_P188: &str = "http://schemas.microsoft.com/office/powerpoint/2018/8/main"; pub(crate) const NS_P14: &str = "http://schemas.microsoft.com/office/powerpoint/2010/main"; pub(crate) const RT_MEDIA: &str = "http://schemas.microsoft.com/office/2007/relationships/media"; pub(crate) const RT_MODEL3D: &str = "http://schemas.microsoft.com/office/2017/06/relationships/model3d"; @@ -43,6 +46,23 @@ const CT_TAGS: &str = "application/vnd.openxmlformats-officedocument.presentatio const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml"; const CT_CHARTEX: &str = "application/vnd.ms-office.chartex+xml"; const RT_CHARTEX: &str = "http://schemas.microsoft.com/office/2014/relationships/chartEx"; +const CT_NOTES: &str = "application/vnd.openxmlformats-officedocument.presentationml.notesSlide+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"; +/// Extension URI of the modern-comments anchor in a slide (names the threaded comment part). +const COMMENTS_EXT_URI: &str = "{6950BFC3-D8DA-4A85-94F7-54DA5524770B}"; const CT_CORE: &str = "application/vnd.openxmlformats-package.core-properties+xml"; const CT_APP: &str = "application/vnd.openxmlformats-officedocument.extended-properties+xml"; @@ -661,6 +681,11 @@ pub fn export(p: &Presentation, defaults: &dyn Defaults, main_ct: &str) -> Resul handout_rid = Some(pres.rels.add("handoutMaster", &craft_opc::relative("ppt/presentation.xml", handout_path), false)); } + // Comment authors. + let mut authors: Vec<(String, String, u32)> = vec![]; + let mut author_map: std::collections::HashMap = std::collections::HashMap::new(); + let mut n_comments = 0u32; + // Slides. let mut slide_rids = vec![]; for (i, s) in p.slides.iter().enumerate() { @@ -669,11 +694,62 @@ pub fn export(p: &Presentation, defaults: &dyn Defaults, main_ct: &str) -> Resul if let Some(lp) = layout_parts.get(&s.layout) { so.rels.add("slideLayout", &craft_opc::relative(&sname, lp), false); } - let xml = slide_xml(&mut x, &mut so, s); + if !s.notes.is_empty() { + let nname = format!("ppt/notesSlides/notesSlide{}.xml", i + 1); + so.rels.add("notesSlide", &craft_opc::relative(&sname, &nname), false); + let mut no = Out::new(&nname); + no.rels.add("notesMaster", &craft_opc::relative(&nname, notes_master), false); + no.rels.add("slide", &craft_opc::relative(&nname, &sname), false); + let xml = notes_xml(&mut x, &mut no, &s.notes); + 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", &craft_opc::relative(&sname, &cname), false); + x.pkg.xml_part(&cname, CT_COMMENTS, comments_xml(&s.comments, &mut authors, &mut author_map)); + // The modern threaded part next to the flattened legacy one: + // 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, &craft_opc::relative(&sname, &mname), false); + x.pkg.xml_part(&mname, CT_COMMENTS_MODERN, modern_comments_xml(&s.comments, &cname, &mut authors, &mut author_map)); + comment_rel = Some(rid); + } + 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", &craft_opc::relative("ppt/presentation.xml", &sname), false)); } + if !authors.is_empty() { + let name = "ppt/commentAuthors.xml"; + let mut w = Writer::document(); + root_open(&mut w, "p:cmAuthorLst", A::new()); + for (i, (n, ini, last)) in authors.iter().enumerate() { + w.tag("p:cmAuthor").attr("id", i).attr("name", n).attr("initials", ini).attr("lastIdx", last).attr("clrIdx", i % 8).empty(); + } + w.close(); + x.pkg.xml_part(name, CT_AUTHORS, w.into_bytes()); + pres.rels.add("commentAuthors", "commentAuthors.xml", false); + // The modern authors part the threaded comments' authorId GUIDs point at. + let mut w = Writer::document(); + w.tag("p188:authorLst").attr("xmlns:p188", NS_P188).open(); + for (n, ini, _) in authors.iter() { + w.tag("p188:author") + .attr("id", author_guid(n)) + .attr("name", n) + .attr("initials", ini) + .attr("userId", "") + .attr("providerId", "None") + .empty(); + } + w.close(); + x.pkg.xml_part("ppt/authors.xml", CT_AUTHORS_MODERN, w.into_bytes()); + pres.rels.add(RT_AUTHORS_MODERN, "authors.xml", false); + } + // Presentation-level parts. let theme1 = "theme/theme1.xml"; pres.rels.add("theme", theme1, false); @@ -763,7 +839,7 @@ fn master_xml(x: &mut Exp, o: &mut Out, m: &Master, layouts: &[(u64, String)]) - w.into_bytes() } -fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec { +fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide, comment_rel: Option<&str>) -> Vec { let mut w = Writer::document(); 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, &s.tags); @@ -774,11 +850,190 @@ fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide) -> Vec { 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(); w.into_bytes() } +fn notes_xml(x: &mut Exp, o: &mut Out, notes: &TextBody) -> Vec { + let mut w = Writer::document(); + root_open(&mut w, "p:notes", A::new()); + let img = Shape { + id: ShapeId(2), + name: "Slide Image Placeholder 1".into(), + ph: Some(Placeholder { kind: PhType::SlideImage, ..Default::default() }), + ..Default::default() + }; + let mut tb = notes.clone(); + tb.body = Default::default(); + let body = Shape { + id: ShapeId(3), + name: "Notes Placeholder 2".into(), + ph: Some(Placeholder { kind: PhType::Body, idx: 1, ..Default::default() }), + text: Some(tb), + ..Default::default() + }; + csld(&mut w, x, o, "", None, &[img, body], &[]); + w.open("p:clrMapOvr", &[]); + w.empty("a:masterClrMapping", &[]); + w.close(); + w.close(); + w.into_bytes() +} + +fn comments_xml(list: &[Comment], authors: &mut Vec<(String, String, u32)>, author_map: &mut std::collections::HashMap) -> Vec { + let mut w = Writer::document(); + root_open(&mut w, "p:cmLst", A::new()); + let mut flat: Vec<&Comment> = vec![]; + for c in list.iter().take(10_000) { + flat.push(c); + for r in c.replies.iter().take(1000) { + flat.push(r); + } + } + for c in flat { + let ai = author_slot(c, authors, author_map); + let idx = match authors.get_mut(ai) { + Some(a) => { + a.2 += 1; + a.2 + } + None => 1, + }; + let dt = if valid_date(&c.date) { c.date.clone() } else { "2024-01-01T00:00:00.000".into() }; + w.tag("p:cm").attr("authorId", ai).attr("dt", dt).attr("idx", idx).open(); + let pos = |v: f64| if v.is_finite() { (v * 8.0).round().clamp(0.0, 1.0e9) as i64 } else { 0 }; + w.tag("p:pos").attr("x", pos(c.x)).attr("y", pos(c.y)).empty(); + w.leaf("p:text", &[], &c.text); + w.close(); + } + w.close(); + w.into_bytes() +} + +/// 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)>, map: &mut std::collections::HashMap) -> usize { + let author = if c.author.is_empty() { "Author" } else { &c.author }; + if let Some(&i) = map.get(author) { + return i; + } + let ini = if c.initials.is_empty() { author.chars().filter(|ch| ch.is_alphabetic()).take(2).collect() } else { c.initials.clone() }; + let idx = authors.len(); + authors.push((author.to_string(), ini, 0)); + map.insert(author.to_string(), idx); + idx +} + +/// The modern (p188) threaded-comment part: replies nest and the resolved state is kept, which +/// the flattened legacy part cannot express. +fn modern_comments_xml( + list: &[Comment], + part: &str, + authors: &mut Vec<(String, String, u32)>, + author_map: &mut std::collections::HashMap, +) -> Vec { + let mut w = Writer::document(); + w.tag("p188:cmLst").attr("xmlns:p188", NS_P188).attr("xmlns:a", NS_A).attr("xmlns:r", NS_R).open(); + for (ti, c) in list.iter().take(10_000).enumerate() { + modern_comment(&mut w, c, part, ti, authors, author_map, 0); + } + w.close(); + w.into_bytes() +} + +fn modern_comment( + w: &mut Writer, + c: &Comment, + part: &str, + ti: usize, + authors: &mut Vec<(String, String, u32)>, + author_map: &mut std::collections::HashMap, + depth: usize, +) { + let ai = author_slot(c, authors, author_map); + 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", craft_drawingml::table::styles::guid(&format!("deckcraft-comment:{part}:{ti}"))) + .a("authorId", author_guid(&author)) + .a("created", dt); + if c.resolved { + a = a.a("status", "resolved"); + } + w.tag("p188:cm").list(a).open(); + w.empty("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.tag("p188:pos").attr("x", emu(c.x)).attr("y", emu(c.y)).empty(); + if depth < 4 && !c.replies.is_empty() { + w.open("p188:replyLst", &[]); + for (ri, r) in c.replies.iter().take(1000).enumerate() { + let ri_ai = author_slot(r, authors, author_map); + 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", craft_drawingml::table::styles::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.tag("p188:reply").list(ra).open(); + tx_body(w, &r.text); + w.close(); + } + w.close(); + } + tx_body(w, &c.text); + w.close(); +} + +/// An `a:txBody` with one paragraph per line, the shape the reader re-joins with `\n`. +fn tx_body(w: &mut Writer, text: &str) { + w.open("p188:txBody", &[]); + w.empty("a:bodyPr", &[]); + for line in text.split('\n') { + w.open("a:p", &[]); + w.open("a:r", &[]); + w.tag("a:rPr").attr("lang", "en-US").empty(); + w.leaf("a:t", &[], line); + w.close(); + w.close(); + } + w.close(); +} + +/// The modern author id for a name: the same GUID the authors part writes. The `deckcraft-` +/// seeds here and on comment ids are part of the format: files written earlier carry them. +fn author_guid(name: &str) -> String { + craft_drawingml::table::styles::guid(&format!("deckcraft-comment-author:{name}")) +} + +/// 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 Writer, raw: Option<&str>, comment_rel: Option<&str>) { + let mut exts: Vec = raw + .filter(|r| !r.trim().is_empty()) + .and_then(|raw| crate::package::parse(format!("{raw}").as_bytes()).ok()) + .map(|d| d.root.elements().filter(|e| e.is(Local("ext")) && e.attr_local("uri").is_some()).map(|e| e.to_xml()).collect()) + .unwrap_or_default(); + if let Some(rid) = comment_rel { + let mut e = Writer::new(); + e.tag("p:ext").attr("uri", COMMENTS_EXT_URI).open(); + e.tag("p188:commentRel").attr("xmlns:p188", NS_P188).attr("r:id", rid).empty(); + e.close(); + exts.push(e.finish()); + } + if exts.is_empty() { + return; + } + w.open("p:extLst", &[]); + for e in exts { + w.raw(&e); + } + w.close(); +} + /// Sections with every slide in exactly one section, in slide order. fn section_slides(p: &Presentation) -> Vec<(String, Vec)> { let mut out: Vec<(String, Vec)> = p.sections.iter().map(|s| (s.name.clone(), vec![])).collect(); diff --git a/crates/pptx/tests/comments.rs b/crates/pptx/tests/comments.rs new file mode 100644 index 000000000..224ffec6e --- /dev/null +++ b/crates/pptx/tests/comments.rs @@ -0,0 +1,216 @@ +//! 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. + +mod common; + +use std::sync::Arc; + +use common::{add_slide, export, import, part, rewrite}; +use craft_pptx::{Comment, Presentation}; + +fn deck() -> Presentation { + let mut p = common::deck(); + 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 & 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 +} + +#[test] +fn threads_and_resolved_round_trip() { + let p = deck(); + let bytes = export(&p); + let q = import(&bytes); + 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 & 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 = export(&deck()); + 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#"second line"#), "{modern}"); + let legacy = part(&bytes, "ppt/comments/comment1.xml"); + assert!(legacy.contains(" +"#; + let legacy_pkg = rewrite( + &bytes, + |name, data| { + if drop.contains(&name) { + return None; + } + let mut xml = String::from_utf8_lossy(data).into_owned(); + if name == "ppt/slides/slide1.xml" + && let Some(start) = xml.find("") + { + let end = xml.find("").map(|e| e + "".len()).unwrap_or(xml.len()); + xml.replace_range(start..end, ""); + return Some(xml.into_bytes()); + } + Some(data.to_vec()) + }, + &[("ppt/slides/_rels/slide1.xml.rels", legacy_rels)], + ); + + let q = import(&legacy_pkg); + 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 = export(&deck()); + let trimmed = rewrite(&bytes, |name, data| (name != "ppt/comments/comment1.xml").then(|| data.to_vec()), &[]); + let q = import(&trimmed); + 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"); +} + +/// Every prefix an exported part uses is bound: PowerPoint refuses a package whose part is not +/// namespace-well-formed (the modern comments part once used `a:` without declaring it). +#[test] +fn every_written_xml_part_binds_its_prefixes() { + let bytes = export(&deck()); + let pkg = craft_opc::Package::open(&bytes, &craft_pptx::LIMITS).unwrap(); + let mut modern = 0; + for name in pkg.names().filter(|n| n.ends_with(".xml") || n.ends_with(".rels")) { + modern += usize::from(name.contains("modernComment")); + common::assert_prefixes_bound(name, pkg.get(name).unwrap()); + } + assert!(modern > 0, "the deck should write a modern comments part"); +} + +#[test] +#[should_panic(expected = "unbound prefix on ")] +fn the_prefix_check_catches_an_unbound_prefix() { + common::assert_prefixes_bound("x.xml", br#""#); +} diff --git a/crates/pptx/tests/common/mod.rs b/crates/pptx/tests/common/mod.rs new file mode 100644 index 000000000..1e5464b99 --- /dev/null +++ b/crates/pptx/tests/common/mod.rs @@ -0,0 +1,74 @@ +//! What the integration tests share: a small deck to start from, and package surgery. +#![allow(dead_code)] // each test file uses some of these + +use std::sync::Arc; + +use craft_pptx::{Defaults, Plain, Presentation, Slide, SlideId}; + +/// A deck with [`Plain`]'s master and one slide on its layout. +pub fn deck() -> Presentation { + let mut p = Presentation::default(); + let (m, last) = Plain.master(p.slide_size, 0); + p.masters.push(Arc::new(m)); + p.next_id = last; + add_slide(&mut p); + p +} + +/// Appends a blank slide on the first layout. +pub fn add_slide(p: &mut Presentation) -> &mut Slide { + let layout = p.masters[0].layouts[0].id; + let id = SlideId(p.alloc_id().unwrap()); + p.slides.push(Arc::new(Slide { id, layout, ..Default::default() })); + Arc::make_mut(p.slides.last_mut().unwrap()) +} + +pub fn export(p: &Presentation) -> Vec { + craft_pptx::export(p, &Plain).unwrap() +} + +pub fn import(bytes: &[u8]) -> Presentation { + craft_pptx::import(bytes, &Plain).unwrap() +} + +pub fn bytes_of(pptx: &[u8], name: &str) -> Vec { + craft_opc::Package::open(pptx, &craft_pptx::LIMITS).unwrap().get(name).unwrap_or_else(|| panic!("no part {name}")).to_vec() +} + +pub fn part(pptx: &[u8], name: &str) -> String { + String::from_utf8(bytes_of(pptx, name)).unwrap() +} + +/// The package with each part passed through `f`: `None` drops it. `extra` parts are added. +pub fn rewrite(pptx: &[u8], f: impl Fn(&str, &[u8]) -> Option>, extra: &[(&str, &str)]) -> Vec { + let pkg = craft_opc::Package::open(pptx, &craft_pptx::LIMITS).unwrap(); + let mut out = craft_opc::ZipOut::new(); + for name in pkg.names() { + if let Some(data) = f(name, pkg.get(name).unwrap()) { + out.add(name, &data, craft_opc::Compression::Deflate).unwrap(); + } + } + for (name, data) in extra { + out.add(name, data.as_bytes(), craft_opc::Compression::Deflate).unwrap(); + } + out.finish().unwrap() +} + +/// Every element and attribute prefix in `xml` is declared on it or an ancestor. +pub fn assert_prefixes_bound(name: &str, xml: &[u8]) { + fn rec(e: &craft_xml::El, scope: &mut Vec, name: &str) { + let n = scope.len(); + scope.extend(e.attrs.iter().filter_map(|(k, _)| k.strip_prefix("xmlns:").map(str::to_string))); + let bound = |q: &str, scope: &Vec| q.split_once(':').is_none_or(|(p, _)| p == "xml" || p == "xmlns" || scope.iter().any(|s| s == p)); + assert!(bound(&e.name, scope), "{name}: unbound prefix on <{}>", e.name); + for (k, _) in &e.attrs { + assert!(bound(k, scope), "{name}: unbound prefix on attribute {k} of <{}>", e.name); + } + for c in e.elements() { + rec(c, scope, name); + } + scope.truncate(n); + } + let root = craft_xml::Options::default().strict().parse(xml).unwrap_or_else(|e| panic!("{name}: not well-formed: {e}")); + rec(&root, &mut vec![], name); +} diff --git a/crates/pptx/tests/notes.rs b/crates/pptx/tests/notes.rs new file mode 100644 index 000000000..6d9500889 --- /dev/null +++ b/crates/pptx/tests/notes.rs @@ -0,0 +1,65 @@ +//! Speaker notes keep their run and paragraph formatting: a bold heading, indented points with +//! custom bullets, plain lines. + +mod common; + +use std::sync::Arc; + +use common::{export, import, part, rewrite}; +use craft_pptx::text::{Bullet, ParaProps, TextBody}; + +const NOTES: &str = "ppt/notesSlides/notesSlide1.xml"; + +/// A notes slide written the way PowerPoint writes one (Slovak text): a bold heading, a plain +/// line, two level-2 points with a hanging indent and a Wingdings square bullet (one mixing a bold +/// label and plain text), a level-2 paragraph with buNone, and a closing plain line. +const NOTES_XML: &str = r#" +Metodický prehľadŽiaci pracujú vo dvojiciach.Cieľ: vysvetliť cyklusReflexiaBez odrážkyPomôcky: počítač"#; + +/// A deck with one notes slide, its notes replaced by [`NOTES_XML`]. +fn deck() -> Vec { + let mut p = common::deck(); + Arc::make_mut(&mut p.slides[0]).notes = TextBody::from_text("placeholder"); + let bytes = export(&p); + assert!(part(&bytes, NOTES).contains("placeholder"), "the export has no {NOTES}"); + rewrite(&bytes, |name, data| Some(if name == NOTES { NOTES_XML.as_bytes().to_vec() } else { data.to_vec() }), &[]) +} + +fn assert_formatted(notes: &TextBody) { + assert_eq!(notes.text(), "Metodický prehľad\nŽiaci pracujú vo dvojiciach.\nCieľ: vysvetliť cyklus\nReflexia\nBez odrážky\nPomôcky: počítač"); + let [head, plain, goal, reflect, unbulleted, closing] = notes.paragraphs.as_slice() else { panic!("six paragraphs: {notes:?}") }; + assert_eq!(head.runs[0].props.bold, Some(true), "bold heading"); + for para in [head, plain, closing] { + assert_eq!((para.level, ¶.props), (0, &ParaProps::default()), "{:?}", para.text()); + } + assert_eq!(plain.runs[0].props.bold, None); + for para in [goal, reflect] { + assert_eq!(para.level, 1, "level 2"); + assert_eq!(para.props.margin_left, Some(22.5), "marL 285750 EMU"); + assert_eq!(para.props.indent, Some(-22.5), "hanging indent"); + assert_eq!(para.props.bullet, Some(Bullet::Char { char: "§".into() })); + assert_eq!(para.props.bullet_font.as_deref(), Some("Wingdings")); + } + assert_eq!((goal.runs[0].text.as_str(), goal.runs[0].props.bold), ("Cieľ: ", Some(true)), "bold label"); + assert_eq!((goal.runs[1].text.as_str(), goal.runs[1].props.bold), ("vysvetliť cyklus", None), "then plain"); + assert_eq!(reflect.runs[0].props.italic, Some(true)); + assert_eq!((unbulleted.level, unbulleted.props.bullet.clone(), unbulleted.props.margin_left), (1, Some(Bullet::None), Some(22.5))); +} + +#[test] +fn notes_import_keeps_bold_levels_and_indents() { + let p = import(&deck()); + assert_formatted(&p.slides[0].notes); +} + +#[test] +fn notes_formatting_survives_a_round_trip() { + let p = import(&deck()); + let again = import(&export(&p)); + assert_formatted(&again.slides[0].notes); + // And the written XML says so, for PowerPoint. + let xml = part(&export(&again), NOTES); + assert!(xml.contains(r#"b="1""#), "{xml}"); + assert!(xml.contains(r#"lvl="1""#) && xml.contains(r#"marL="285750""#) && xml.contains(r#"indent="-285750""#), "{xml}"); + assert!(xml.contains(r#""), "{xml}"); +}