mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
craft-pptx: speaker notes and comments (from DeckCraft) (#1071)
* Add craft-drawingml: units, colours, preset shapes, custom geometry and text warps The parts of DrawingML that need no document model, moved from DeckCraft (60f91f78): crates/color, crates/geom (preset, warp, Xfrm and the EMU conversions) and crates/pptx/src/custgeom.rs with CustomPath from crates/model. The code is unchanged except that custgeom reads craft-xml trees and writes to a craft-xml Writer instead of DeckCraft's own XML helper, and matches elements by local name. The 59 tests of the moved code come with it; six more cover the custom geometry limits. * drawingml: evaluate a custom geometry's guide lists once per coordinate space read_cust_geom rebuilt Guides and evaluated avLst and gdLst again for every path: up to 1,000 paths times up to 10,000 guides, each name copied and hashed. Guides are now cached per path coordinate space (w, h). At most 16 spaces and 4 MiB of guide names are kept per geometry; paths in further spaces are left out. The first space is always kept, and a geometry without guides is not capped. Ported from storytold/deckcraft#1789. A list counts as having guides only when it has gd children, as that PR's review asked. * drawingml: the model types for fills, lines, text, tables, charts and diagrams Moved from DeckCraft (60f91f78) crates/model/src: style.rs, text.rs, table.rs, chart.rs, numfmt.rs and trendline.rs, plus Diagram, DiagramNode and the MediaId and SlideId newtypes from lib.rs. The files are unchanged except that deckcraft_color:: is crate::color:: and Fill and TableBorder allow clippy::large_enum_variant. Their 60 tests come with them. Readers and writers follow. * drawingml: the theme model (colour, font and format schemes) Moved from DeckCraft crates/model/src/theme.rs at 60f91f78: FontSet, ScriptFont, FontScheme, FormatScheme, Theme and default_format. The app's theme gallery, its variants and its own default theme stay in the app. Theme::default() is a plain theme built in place, so nothing here depends on a gallery. * drawingml: readers and writers for fills, lines, text, charts, themes, table styles and diagrams Moved from DeckCraft crates/pptx/src at 60f91f78: read/dml.rs, write/dml.rs, read/chart.rs, read/chartex.rs, write/chart.rs, read/theme.rs, write/theme.rs, tables.rs and dgm.rs, onto craft-xml. Package lookups (Imp, Part, Exp, Out) are the caller's part::Source and part::Sink. The chart frame budget and kept-part capture stay with the presentation reader. * drawingml: what WordCraft and GridCraft read that DeckCraft's copy did not Every ST_PresetColorVal name has its colour, in any case and with the dk/lt/med abbreviations; a sysClr without lastClr takes the usual value of its name; a Strict percentage (50%) reads anywhere a percentage does; a chart series keeps its c:f formulas and the writer uses them. * craft-drawingml: extra named theme colours and explicit tint rows Ported from the format part of storytold/deckcraft#1746 by @jagjordi: ColorScheme::{extra, tints, computed_tints}, ExtraColor, GalleryVariant, variants_of, and the a:clrScheme extension that keeps them in a file. * drawingml xmlx: copy only used, bounded namespace declarations onto kept fragments and mc branches From storytold/deckcraft#1740 (988a4920), which landed after the extraction commit 60f91f78. * Add craft-pptx: the PresentationML model (from DeckCraft) Presentation, masters, layouts, slides, shapes, placeholders, media, comments, sections, show settings, properties, embedded fonts, kept parts and drawing guides from DeckCraft's crates/model/src/lib.rs, and transitions and animations from anim.rs. The DrawingML types come from craft-drawingml. The placeholder matching from DeckCraft's resolve.rs moves as placeholder::parents, without the rest of resolve. Presentation::default() is now an empty 16:9 deck with no masters; the starting deck stays with the application. * craft-pptx: the reader (from DeckCraft), on craft-opc and craft-drawingml DeckCraft's crates/pptx reader (read.rs, read/shapes.rs and the chart frame code of read/chart.rs) moved onto craft-opc's Package and craft-xml trees, with the DrawingML readers of craft-drawingml behind a part::Source over the import state. What DeckCraft took from its own defaults (a master for a file without one, the theme of a master that names none) comes from a caller-supplied Defaults; Plain is the neutral one. Placeholder geometry inheritance uses placeholder::parents. Transitions, animations, notes and comments follow in later changes. * craft-pptx: drop DeckCraft issue numbers from reader comments * craft-pptx: link the package budgets from the tag budget's doc * craft-pptx: the writer (from DeckCraft) DeckCraft's crates/pptx writer (write.rs, write/shapes.rs and the kept chart parts of write/chart.rs) moved onto craft-opc's PackageOut and craft-xml's Writer, with craft-drawingml's DrawingML writers behind a part::Sink over the export state. What DeckCraft took from its own defaults (a master for a deck without one, master and default text styles, its name in docProps/app.xml and in table style names) comes from Defaults. export and export_as take the defaults. Transitions, animations, notes slides and comments follow in later changes. * craft-pptx: transitions and animations (from DeckCraft) DeckCraft's read/timing.rs, write/timing.rs and maps.rs on craft-xml: p:transition with the PowerPoint 2010+ effects, and p:timing flattened into anim::Animation on reading and built back on writing, with the media settings of the timing tree. * craft-pptx: speaker notes and comments (from DeckCraft) Notes slides and comments (the modern threaded part with its authors, and the flattened legacy part) on reading and writing, as DeckCraft's read.rs and write.rs had them. DeckCraft's comments.rs and notes.rs integration tests come along, on a shared tests/common module. * craft-pptx: say why the comment GUID seeds keep their name --------- Co-authored-by: Jordi Altayó <jordiag@kth.se>
This commit is contained in:
1 parent
228f6d0c50
commit
97cd9c8a98
6 files changed
+735
-10
No files matched your search
@@ -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.
|
||||
+117
-5
@@ -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<String, Doc>,
|
||||
slide_ids: &[(u32, String, SlideId)],
|
||||
layout_by_part: &HashMap<String, LayoutId>,
|
||||
@@ -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<u32, SlideId> = 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<String> = 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<String>) -> Option<TextBody> {
|
||||
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<String>) {
|
||||
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<u32, SlideId>) {
|
||||
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<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"] {
|
||||
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<String, (String, String)>, 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::<Vec<_>>().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<String, String>) -> ShowSettings {
|
||||
let mut s = ShowSettings {
|
||||
loop_until_esc: sp.attr_local("loop").and_then(value::to_bool).unwrap_or(false),
|
||||
|
||||
+259
-4
@@ -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<String, usize> = 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<u8> {
|
||||
fn slide_xml(x: &mut Exp, o: &mut Out, s: &Slide, comment_rel: Option<&str>) -> Vec<u8> {
|
||||
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<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();
|
||||
w.into_bytes()
|
||||
}
|
||||
|
||||
fn notes_xml(x: &mut Exp, o: &mut Out, notes: &TextBody) -> Vec<u8> {
|
||||
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<String, usize>) -> Vec<u8> {
|
||||
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<String, usize>) -> 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<String, usize>,
|
||||
) -> Vec<u8> {
|
||||
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<String, usize>,
|
||||
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<String> = raw
|
||||
.filter(|r| !r.trim().is_empty())
|
||||
.and_then(|raw| crate::package::parse(format!("<x>{raw}</x>").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<usize>)> {
|
||||
let mut out: Vec<(String, Vec<usize>)> = p.sections.iter().map(|s| (s.name.clone(), vec![])).collect();
|
||||
|
||||
@@ -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 <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
|
||||
}
|
||||
|
||||
#[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 <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 = 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#"<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 the 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();
|
||||
while p.slides.len() < 3 {
|
||||
add_slide(&mut p);
|
||||
}
|
||||
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 = export(&p);
|
||||
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 = import(&bytes);
|
||||
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 slide that names a modern part another slide already owns, plus its own legacy part, must
|
||||
/// not import the legacy copy: modern precedence holds even when the modern part is not re-read.
|
||||
#[test]
|
||||
fn owned_modern_part_still_suppresses_later_legacy() {
|
||||
let mut p = deck();
|
||||
add_slide(&mut p);
|
||||
Arc::make_mut(&mut p.slides[1]).comments = vec![Comment { author: "Cara".into(), text: "Slide two".into(), ..Default::default() }];
|
||||
let bytes = export(&p);
|
||||
let rels2 = part(&bytes, "ppt/slides/_rels/slide2.xml.rels").replace("modernComment2.xml", "modernComment1.xml");
|
||||
let q = import(&rewrite(
|
||||
&bytes,
|
||||
|name, data| Some(if name == "ppt/slides/_rels/slide2.xml.rels" { rels2.clone().into_bytes() } else { data.to_vec() }),
|
||||
&[],
|
||||
));
|
||||
assert_eq!(q.slides[0].comments.len(), 2);
|
||||
assert!(q.slides[1].comments.is_empty(), "{:?}", q.slides[1].comments);
|
||||
}
|
||||
|
||||
/// 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 = export(&deck());
|
||||
let drop = ["ppt/comments/modernComment1.xml", "ppt/authors.xml", "ppt/slides/_rels/slide1.xml.rels"];
|
||||
// The slide's rels again without the modern-comments relationship, and the slide without the
|
||||
// anchor extension.
|
||||
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 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("<p:extLst>")
|
||||
{
|
||||
let end = xml.find("</p:extLst>").map(|e| e + "</p:extLst>".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 <a:t>")]
|
||||
fn the_prefix_check_catches_an_unbound_prefix() {
|
||||
common::assert_prefixes_bound("x.xml", br#"<p:cmLst xmlns:p="urn:p"><p:cm><a:t/></p:cm></p:cmLst>"#);
|
||||
}
|
||||
@@ -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<u8> {
|
||||
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<u8> {
|
||||
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<Vec<u8>>, extra: &[(&str, &str)]) -> Vec<u8> {
|
||||
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<String>, 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<String>| 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);
|
||||
}
|
||||
@@ -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#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<p:notes xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"><p:cSld><p:spTree><p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr><p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr><p:sp><p:nvSpPr><p:cNvPr id="2" name="Slide Image Placeholder 1"/><p:cNvSpPr><a:spLocks noGrp="1" noRot="1" noChangeAspect="1"/></p:cNvSpPr><p:nvPr><p:ph type="sldImg"/></p:nvPr></p:nvSpPr><p:spPr/></p:sp><p:sp><p:nvSpPr><p:cNvPr id="3" name="Notes Placeholder 2"/><p:cNvSpPr><a:spLocks noGrp="1"/></p:cNvSpPr><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr><p:spPr/><p:txBody><a:bodyPr/><a:lstStyle/><a:p><a:r><a:rPr lang="sk-SK" b="1" dirty="0"/><a:t>Metodický prehľad</a:t></a:r></a:p><a:p><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Žiaci pracujú vo dvojiciach.</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buFont typeface="Wingdings" panose="05000000000000000000" pitchFamily="2" charset="2"/><a:buChar char="§"/></a:pPr><a:r><a:rPr lang="sk-SK" b="1" dirty="0"/><a:t>Cieľ: </a:t></a:r><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>vysvetliť cyklus</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buFont typeface="Wingdings" panose="05000000000000000000" pitchFamily="2" charset="2"/><a:buChar char="§"/></a:pPr><a:r><a:rPr lang="sk-SK" i="1" dirty="0"/><a:t>Reflexia</a:t></a:r></a:p><a:p><a:pPr marL="285750" lvl="1" indent="-285750"><a:buNone/></a:pPr><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Bez odrážky</a:t></a:r></a:p><a:p><a:r><a:rPr lang="sk-SK" dirty="0"/><a:t>Pomôcky: počítač</a:t></a:r></a:p></p:txBody></p:sp></p:spTree></p:cSld><p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr></p:notes>"#;
|
||||
|
||||
/// A deck with one notes slide, its notes replaced by [`NOTES_XML`].
|
||||
fn deck() -> Vec<u8> {
|
||||
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#"<a:buFont typeface="Wingdings""#) && xml.contains("<a:buChar") && xml.contains("<a:buNone/>"), "{xml}");
|
||||
}
|
||||
Reference in new issue
Block a user