Files
craft-libs/crates/pptx/tests/comments.rs
T
Dylan M. TaylorandJordi Altayó 97cd9c8a98 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>
2026-11-11 22:12:59 -05:00

217 lines
9.8 KiB
Rust

//! 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 &lt;reply&gt; &amp; more"), "{modern}");
assert!(modern.contains(r#"<a:t>second line</a:t>"#), "{modern}");
let legacy = part(&bytes, "ppt/comments/comment1.xml");
assert!(legacy.contains("<p:cm ") && legacy.contains("Top thread"), "{legacy}");
let authors = part(&bytes, "ppt/authors.xml");
assert!(authors.contains("p188:authorLst") && authors.contains("Ada"), "{authors}");
assert!(part(&bytes, "ppt/commentAuthors.xml").contains("p:cmAuthorLst"));
let rels = part(&bytes, "ppt/slides/_rels/slide1.xml.rels");
assert!(rels.contains("2018/10/relationships/comments"), "{rels}");
// The slide's commentRel anchor names the relationship that points at THIS modern part —
// the wiring PowerPoint needs and 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>"#);
}