//! 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#""#); }