//! Comment threads through PPTX: the modern part keeps replies and resolved state, the legacy //! part stays readable, and a package with both imports every thread once. use std::io::Read; use std::sync::Arc; use deckcraft_model::{Comment, Presentation, defaults}; fn deck() -> Presentation { let mut p = defaults::new_presentation(None); let s = Arc::make_mut(&mut p.slides[0]); s.comments = vec![ Comment { author: "Ada".into(), initials: "AL".into(), text: "Top thread\nsecond line".into(), date: "2025-03-04T05:06:07Z".into(), x: 90.0, y: 45.0, resolved: true, replies: vec![ Comment { author: "Bob".into(), text: "A & more".into(), date: "2025-03-04T06:00:00Z".into(), ..Default::default() }, Comment { author: "Ada".into(), text: "Another reply".into(), date: "2025-03-04T06:30:00Z".into(), resolved: true, ..Default::default() }, ], shape: None, }, Comment { author: "Bob".into(), initials: "BC".into(), text: "Open thread".into(), date: "2025-03-05T01:02:03Z".into(), x: 120.0, y: 60.0, resolved: false, replies: vec![], shape: None, }, ]; p } fn part(bytes: &[u8], name: &str) -> String { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); let mut out = String::new(); z.by_name(name).expect("part").read_to_string(&mut out).expect("utf8"); out } /// Rewrite the package, dropping or replacing entries by name. fn rewrite(bytes: &[u8], drop: &[&str]) -> Vec { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).expect("zip"); let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); for i in 0..z.len() { let mut f = z.by_index(i).expect("entry"); if drop.iter().any(|d| f.name() == *d) { continue; } out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start"); std::io::copy(&mut f, &mut out).expect("copy"); } out.finish().expect("finish").into_inner() } #[test] fn threads_and_resolved_round_trip() { let p = deck(); let bytes = deckcraft_pptx::export(&p).expect("export"); let q = deckcraft_pptx::import(&bytes).expect("import"); let (a, b) = (&p.slides[0].comments, &q.slides[0].comments); assert_eq!(b.len(), a.len(), "comments duplicated: {b:?}"); assert_eq!(b[0].author, "Ada"); assert_eq!(b[0].initials, "AL"); assert_eq!(b[0].text, "Top thread\nsecond line"); assert_eq!(b[0].date, "2025-03-04T05:06:07Z"); assert!(b[0].resolved); assert_eq!((b[0].x, b[0].y), (90.0, 45.0)); assert_eq!(b[0].replies.len(), 2); assert_eq!(b[0].replies[0].author, "Bob"); assert_eq!(b[0].replies[0].text, "A & more"); assert_eq!(b[0].replies[0].date, "2025-03-04T06:00:00Z"); assert_eq!(b[0].replies[1].text, "Another reply"); assert!(!b[0].replies[0].resolved); assert!(b[0].replies[1].resolved); assert_eq!(b[1].author, "Bob"); assert!(!b[1].resolved); assert_eq!(b[1].text, "Open thread"); } #[test] fn package_carries_both_parts_and_the_slide_anchor() { let bytes = deckcraft_pptx::export(&deck()).expect("export"); let modern = part(&bytes, "ppt/comments/modernComment1.xml"); assert!(modern.contains("p188:cmLst"), "{modern}"); // The thread and the second reply are resolved; the first reply is not. assert_eq!(modern.matches(r#"status="resolved""#).count(), 2, "{modern}"); assert!(modern.contains("p188:replyLst") && modern.contains("A <reply> & more"), "{modern}"); assert!(modern.contains(r#"second line"#), "{modern}"); let legacy = part(&bytes, "ppt/comments/comment1.xml"); assert!(legacy.contains(" "#; let mut z = zip::ZipArchive::new(std::io::Cursor::new(&trimmed)).expect("zip"); let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); for i in 0..z.len() { let mut f = z.by_index(i).expect("entry"); out.start_file(f.name(), zip::write::SimpleFileOptions::default()).expect("start"); if f.name() == "ppt/slides/slide1.xml" { let mut xml = String::new(); f.read_to_string(&mut xml).expect("utf8"); if let Some(start) = xml.find("") { let end = xml.find("").map(|e| e + "".len()).unwrap_or(xml.len()); xml.replace_range(start..end, ""); } std::io::Write::write_all(&mut out, xml.as_bytes()).expect("write"); } else { std::io::copy(&mut f, &mut out).expect("copy"); } } out.start_file("ppt/slides/_rels/slide1.xml.rels", zip::write::SimpleFileOptions::default()).expect("start"); std::io::Write::write_all(&mut out, legacy_rels.as_bytes()).expect("write"); let legacy_pkg = out.finish().expect("finish").into_inner(); let q = deckcraft_pptx::import(&legacy_pkg).expect("import"); let b = &q.slides[0].comments; // The legacy part flattened the two replies into siblings: four top-level comments. assert_eq!(b.len(), 4, "{b:?}"); assert_eq!(b[0].text, "Top thread\nsecond line"); // The legacy format has no resolved state — exactly what the modern part adds. assert!(!b[0].resolved); assert_eq!((b[0].x, b[0].y), (90.0, 45.0), "legacy positions are 1/8 pt"); assert!(b.iter().all(|c| c.replies.is_empty())); } /// A modern-only package (no legacy part) imports threads and EMU positions. #[test] fn modern_only_package_imports() { let bytes = deckcraft_pptx::export(&deck()).expect("export"); let trimmed = rewrite(&bytes, &["ppt/comments/comment1.xml"]); let q = deckcraft_pptx::import(&trimmed).expect("import"); let b = &q.slides[0].comments; assert_eq!(b.len(), 2, "{b:?}"); assert_eq!(b[0].replies.len(), 2); assert!(b[0].resolved); assert_eq!((b[0].x, b[0].y), (90.0, 45.0), "modern positions are EMU"); }