//! What a save keeps from a Word file beyond the document model (settings: `settings_merge.rs`): //! package parts WordCraft doesn't write (carried verbatim), //! comment durable ids and UTC dates, localized default styles, latent styles, list identities //! and extended properties. Hand-written fixtures (from the spec), no files produced by Word. use std::collections::BTreeMap; use std::io::{Read, Write}; const W: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; const REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; fn zip(entries: &[(&str, Vec)]) -> Vec { let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); for (name, bytes) in entries { zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap(); zw.write_all(bytes).unwrap(); } zw.finish().unwrap().into_inner() } fn unzip(bytes: &[u8]) -> BTreeMap> { let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut out = BTreeMap::new(); for i in 0..z.len() { let mut f = z.by_index(i).unwrap(); let mut b = Vec::new(); f.read_to_end(&mut b).unwrap(); assert!(out.insert(f.name().to_string(), b).is_none(), "duplicate part {}", f.name()); } out } fn text(files: &BTreeMap>, name: &str) -> String { String::from_utf8(files.get(name).unwrap_or_else(|| panic!("no {name} in {:?}", files.keys())).clone()).unwrap() } fn rels(list: &[(&str, &str, &str)]) -> Vec { let mut s = String::from(r#""#); for (id, ty, target) in list { s.push_str(&format!(r#""#)); } s.push_str(""); s.into_bytes() } /// A package: `body`, document relationships, root relationships after the main document's, /// and extra parts. fn package(body: &str, doc_rels: &[(&str, &str, &str)], root_rels: &[(&str, &str, &str)], extra: &[(&str, &str)]) -> Vec { let doc = format!( r#"{body}"# ); let mut root = vec![("rIdMain", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument", "word/document.xml")]; root.extend_from_slice(root_rels); let mut entries: Vec<(&str, Vec)> = vec![("_rels/.rels", rels(&root)), ("word/document.xml", doc.into_bytes()), ("word/_rels/document.xml.rels", rels(doc_rels))]; for (p, c) in extra { entries.push((p, c.as_bytes().to_vec())); } zip(&entries) } fn save(doc: &wordcraft_doc::Document) -> BTreeMap> { unzip(&wordcraft_docx::write(doc).expect("write")) } const SETTINGS: &str = r#" "#; fn pos(s: &str, needle: &str) -> usize { s.find(needle).unwrap_or_else(|| panic!("{needle} missing from {s}")) } const CT: &str = r#""#; #[test] fn unmodelled_parts_are_carried_with_relationships_and_content_types() { let web = r#""#; let swe = r#""#; let people = r#""#; let panes = r#""#; let ext = r#""#; let unknown = r#""#; let thumb = "\u{ff}\u{d8}\u{ff}JPEG"; let panes_rels = String::from_utf8(rels(&[("rId1", "http://schemas.microsoft.com/office/2011/relationships/webextension", "webextension1.xml")])).unwrap(); let bytes = package( r#"Body"#, &[ ("rId1", &format!("{REL}/webSettings"), "webSettings.xml"), ("rId2", "http://schemas.microsoft.com/office/2007/relationships/stylesWithEffects", "stylesWithEffects.xml"), ("rId3", "http://schemas.microsoft.com/office/2011/relationships/people", "people.xml"), ("rId4", "urn:example:future-relationship", "unknownPart.xml"), ], &[ ("rIdT", "http://schemas.openxmlformats.org/package/2006/relationships/metadata/thumbnail", "docProps/thumbnail.jpeg"), ("rIdW", "http://schemas.microsoft.com/office/2011/relationships/webextensiontaskpanes", "word/webextensions/taskpanes.xml"), ], &[ ("[Content_Types].xml", CT), ("word/webSettings.xml", web), ("word/stylesWithEffects.xml", swe), ("word/people.xml", people), ("word/unknownPart.xml", unknown), ("docProps/thumbnail.jpeg", thumb), ("word/webextensions/taskpanes.xml", panes), ("word/webextensions/_rels/taskpanes.xml.rels", &panes_rels), ("word/webextensions/webextension1.xml", ext), ], ); let doc = wordcraft_docx::read(&bytes).unwrap(); let files = save(&doc); let input = unzip(&bytes); for part in [ "word/webSettings.xml", "word/stylesWithEffects.xml", "word/people.xml", "word/unknownPart.xml", "docProps/thumbnail.jpeg", "word/webextensions/taskpanes.xml", "word/webextensions/webextension1.xml", ] { assert_eq!(files.get(part), input.get(part), "{part} not carried verbatim"); } let doc_rels = text(&files, "word/_rels/document.xml.rels"); for ty in ["/webSettings\"", "/stylesWithEffects\"", "/people\"", "urn:example:future-relationship"] { assert_eq!(doc_rels.matches(ty).count(), 1, "{ty}: {doc_rels}"); } let root_rels = text(&files, "_rels/.rels"); assert!(root_rels.contains(r#"Target="docProps/thumbnail.jpeg""#) && root_rels.contains("webextensiontaskpanes"), "{root_rels}"); assert!(text(&files, "word/webextensions/_rels/taskpanes.xml.rels").contains("webextension1.xml")); let ct = text(&files, "[Content_Types].xml"); for (part, ty) in [ ("/word/webSettings.xml", "webSettings+xml"), ("/word/stylesWithEffects.xml", "stylesWithEffects+xml"), ("/word/people.xml", "people+xml"), ("/word/webextensions/taskpanes.xml", "webextensiontaskpanes+xml"), ("/docProps/thumbnail.jpeg", "image/jpeg"), ] { let entry = ct.split("Body"#, &[("rId7", &format!("{REL}/styles"), "other-styles.xml"), ("rId8", "urn:example:referenced", "referenced.xml")], &[], &[ ("word/other-styles.xml", r#""#), ("word/referenced.xml", ""), ], ); let files = save(&wordcraft_docx::read(&bytes).unwrap()); assert!(!files.contains_key("word/other-styles.xml") && !files.contains_key("word/referenced.xml"), "{:?}", files.keys()); assert_eq!(text(&files, "word/_rels/document.xml.rels").matches("/styles\"").count(), 1); } fn comments_package(with_ids: bool) -> Vec { let comments = r#"FirstReply"#; let ex = r#""#; let ids = r#""#; let cex = r#""#; let body = r#"Text"#; let styles = r#""#; let mut doc_rels = vec![ ("rId1", format!("{REL}/comments"), "comments.xml"), ("rId2", "http://schemas.microsoft.com/office/2011/relationships/commentsExtended".to_string(), "commentsExtended.xml"), ("rId5", format!("{REL}/styles"), "styles.xml"), ]; let mut extra = vec![("word/comments.xml", comments), ("word/commentsExtended.xml", ex), ("word/styles.xml", styles)]; if with_ids { doc_rels.push(("rId3", "http://schemas.microsoft.com/office/2016/09/relationships/commentsIds".into(), "commentsIds.xml")); doc_rels.push(("rId4", "http://schemas.microsoft.com/office/2018/08/relationships/commentsExtensible".into(), "commentsExtensible.xml")); extra.push(("word/commentsIds.xml", ids)); extra.push(("word/commentsExtensible.xml", cex)); } let doc_rels: Vec<(&str, &str, &str)> = doc_rels.iter().map(|(a, b, c)| (*a, b.as_str(), *c)).collect(); package(body, &doc_rels, &[], &extra) } /// (paragraph id → durable id) from commentsIds, and (durable id → UTC date) from /// commentsExtensible, as written. fn written_ids(files: &BTreeMap>) -> (Vec<(String, String)>, Vec<(String, String)>) { let attr = |el: &str, name: &str| -> Option { el.split(&format!("{name}=\"")).nth(1)?.split('"').next().map(str::to_string) }; let ids = text(files, "word/commentsIds.xml") .split(" = doc.comments.values().collect(); assert_eq!(c[0].durable_id.as_deref(), Some("5A1B2C3D")); assert_eq!(c[1].date_utc.as_deref(), Some("2026-01-03T10:00:00Z")); let files = save(&doc); let (ids, dates) = written_ids(&files); assert_eq!(ids.iter().map(|(_, d)| d.as_str()).collect::>(), ["5A1B2C3D", "0F00BA12"]); // Each paragraph id is the one the comment's paragraph got in comments.xml. let comments = text(&files, "word/comments.xml"); for (pid, _) in &ids { assert!(comments.contains(&format!(r#"w14:paraId="{pid}""#)), "{pid}: {comments}"); } assert_eq!(dates, [("5A1B2C3D".into(), "2026-01-02T09:00:00Z".into()), ("0F00BA12".into(), "2026-01-03T10:00:00Z".into())]); let rels = text(&files, "word/_rels/document.xml.rels"); assert!(rels.contains("relationships/commentsIds") && rels.contains("relationships/commentsExtensible"), "{rels}"); // Read back: the same durable ids and dates. let back = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); assert_eq!( back.comments.values().map(|c| c.durable_id.clone()).collect::>(), doc.comments.values().map(|c| c.durable_id.clone()).collect::>() ); assert_eq!( back.comments.values().map(|c| c.date_utc.clone()).collect::>(), doc.comments.values().map(|c| c.date_utc.clone()).collect::>() ); } #[test] fn comments_without_durable_ids_get_unique_ones() { let doc = wordcraft_docx::read(&comments_package(false)).unwrap(); let (ids, dates) = written_ids(&save(&doc)); assert_eq!(ids.len(), 2); assert_ne!(ids[0].1, ids[1].1); for (_, d) in &ids { let v = u32::from_str_radix(d, 16).unwrap(); assert!(v != 0 && v < 0x7FFF_FFFF, "{d}"); } // Local dates without a zone aren't passed off as UTC. assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}"); } #[test] fn comment_references_use_the_documents_annotation_reference_style() { let files = save(&wordcraft_docx::read(&comments_package(true)).unwrap()); let body = text(&files, "word/document.xml"); assert!(body.contains(r#""#), "{body}"); assert!(!body.contains(r#"w:val="CommentReference""#), "{body}"); // A document without that style keeps the English id. let mut doc = wordcraft_doc::Document::new(); doc.styles.styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference")); let rebuilt = wordcraft_docx::read(&comments_package(true)).unwrap(); doc.body = rebuilt.body.clone(); doc.parts = rebuilt.parts.clone(); doc.comments = rebuilt.comments.clone(); assert!(text(&save(&doc), "word/document.xml").contains(r#"w:val="CommentReference""#)); } #[test] fn localized_default_styles_keep_their_default_flag() { let styles = r#""#; let bytes = package("x", &[("rId1", &format!("{REL}/styles"), "styles.xml")], &[], &[("word/styles.xml", styles)]); let doc = wordcraft_docx::read(&bytes).unwrap(); let s = text(&save(&doc), "word/styles.xml"); for id in ["Domylnaczcionkaakapitu", "Standardowy", "Bezlisty"] { assert!(s.contains(&format!(r#"w:default="1" w:styleId="{id}""#)) || s.contains(&format!(r#"w:styleId="{id}" w:default="1""#)), "{id}: {s}"); } assert_eq!(s.matches(r#"w:default="1""#).count(), 4, "{s}"); // Latent styles come back as they were, before the styles. assert!(s.contains(r#""#), "{s}"); assert!(pos(&s, ""#; let bytes = package( r#"x"#, &[("rId1", &format!("{REL}/numbering"), "numbering.xml")], &[], &[("word/numbering.xml", numbering)], ); let mut doc = wordcraft_docx::read(&bytes).unwrap(); let n = text(&save(&doc), "word/numbering.xml"); assert!(n.contains(r#""#), "{n}"); // A copy of the definition (as the engine makes when restarting a list) doesn't reuse the nsid. let mut copy = doc.numbering.abstracts[0].clone(); copy.id = 7; doc.numbering.abstracts.push(copy); let n = text(&save(&doc), "word/numbering.xml"); assert_eq!(n.matches("1F2E3D4C").count(), 1, "{n}"); } #[test] fn extended_properties_are_kept_without_words_counts_and_personal_ones_follow_the_privacy_setting() { let app = r#"4210740000250000Microsoft Office Word020001700falseMFirmafalse290000falsehttps://example.com/false16.0000"#; let make = |settings: &str| { let mut entries = vec![("docProps/app.xml", app)]; let mut doc_rels = vec![]; let s; if !settings.is_empty() { s = settings.to_string(); entries.push(("word/settings.xml", s.as_str())); doc_rels.push(("rId1", format!("{REL}/settings"), "settings.xml")); } let doc_rels: Vec<(&str, &str, &str)> = doc_rels.iter().map(|(a, b, c)| (*a, b.as_str(), *c)).collect(); let b = package( "x", &doc_rels, &[("rIdA", "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties", "docProps/app.xml")], &entries, ); text(&save(&wordcraft_docx::read(&b).unwrap()), "docProps/app.xml") }; let a = make(""); for kept in [ "", "Firma", "M", "https://example.com/", ] { assert!(a.contains(kept), "{kept}: {a}"); } for gone in ["Pages", "Characters", "Lines", "TotalTime", "AppVersion", "Microsoft Office Word", "40000"] { assert!(!a.contains(gone), "{gone}: {a}"); } assert!(a.contains("WordCraft")); let private = make(SETTINGS); assert!(!private.contains("Company") && !private.contains("Manager"), "{private}"); assert!(private.contains("")); }