//! 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}; use std::sync::Arc; 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().unwrap().to_string(), b).is_none(), "duplicate part {}", f.name().unwrap()); } 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: &craft_wordmodel::Document) -> BTreeMap> { unzip(&craft_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 = craft_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(&craft_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 = craft_docx::read(&craft_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 = craft_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:?}"); } /// A local `w:date` with a `Z` (older files have no `dateUtc`) isn't saved as the UTC time either, /// so it doesn't shift by the user's offset after a round trip. #[test] fn a_local_date_with_a_z_is_not_saved_as_utc() { let mut doc = craft_docx::read(&comments_package(false)).unwrap(); for c in doc.comments.values_mut() { c.date.push('Z'); } let files = save(&doc); let (_, dates) = written_ids(&files); assert!(dates.iter().all(|(_, utc)| utc.is_empty()), "{dates:?}"); let back = craft_docx::read(&craft_docx::write(&doc).unwrap()).unwrap(); assert!(back.comments.values().all(|c| c.date_utc.is_none() && c.date.ends_with('Z'))); } #[test] fn comment_references_use_the_documents_annotation_reference_style() { let files = save(&craft_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 = craft_wordmodel::Document::new(); Arc::make_mut(&mut doc.styles).styles.retain(|s| !s.name.eq_ignore_ascii_case("annotation reference")); let rebuilt = craft_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 = craft_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 = craft_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; Arc::make_mut(&mut 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(&craft_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("")); } /// `name` with its letters' case flipped by the bits of `i`. fn spelling(name: &str, i: u32) -> String { let mut bit = 0; name.chars() .map(|c| { if !c.is_ascii_alphabetic() { return c; } bit += 1; if i >> (bit - 1) & 1 == 1 { c.to_ascii_uppercase() } else { c.to_ascii_lowercase() } }) .collect() } #[test] fn a_part_reached_under_many_spellings_is_kept_and_written_once() { // Part names are case-insensitive (ECMA-376 Part 2 §6.2.2.3): 64 relationships naming one picture // and one unmodelled part in 64 casings are two parts, not 128 copies on save (#3704). let ns = r#"xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture""#; let n = 64; let mut body = String::new(); let mut doc_rels = Vec::new(); for i in 0..n { body.push_str(&format!( r#""#, i + 1 )); doc_rels.push((format!("rIdP{i}"), format!("{REL}/image"), spelling("media/image1.png", i))); doc_rels.push((format!("rIdU{i}"), "urn:example:future-relationship".to_string(), spelling("unknownPart.xml", i))); } let doc_rels: Vec<(&str, &str, &str)> = doc_rels.iter().map(|(a, b, c)| (a.as_str(), b.as_str(), c.as_str())).collect(); let picture = format!("\u{89}PNG\r\n\u{1a}\n{}", "0".repeat(64 * 1024)); let unknown = format!(r#"{}"#, "x".repeat(64 * 1024)); let ct = CT.replace(""# ); let table_rels = rels(&[("rId1", &format!("{REL}/font"), "fonts/font1.odttf")]); let font_bytes = vec![9u8; 2048]; let ct = CT.replace( ")> = vec![ ("_rels/.rels", rels(&[("rIdMain", &format!("{REL}/officeDocument"), "word/document.xml")])), ("[Content_Types].xml", ct.into_bytes()), ( "word/document.xml", format!(r#"Body"#).into_bytes(), ), ("word/_rels/document.xml.rels", rels(&[("rId1", &format!("{REL}/fontTable"), "fontTable.xml")])), ]; entries.push(("WORD/FONTTABLE.XML", table.into_bytes())); entries.push(("WORD/_RELS/FONTTABLE.XML.RELS", table_rels)); entries.push(("WORD/FONTS/FONT1.ODTTF", font_bytes.clone())); let doc = craft_docx::read(&zip(&entries)).unwrap(); let fonts = craft_docx::embedded_fonts(&doc); assert_eq!(fonts.len(), 1, "the font loads"); assert_eq!(fonts[0].data, font_bytes); // And through a save and a reopen. let saved = craft_docx::write(&doc).unwrap(); let files = unzip(&saved); assert!(files.keys().any(|k| k.to_ascii_lowercase().ends_with("fonttable.xml")), "{:?}", files.keys()); let again = craft_docx::read(&saved).unwrap(); let fonts = craft_docx::embedded_fonts(&again); assert_eq!(fonts.len(), 1, "the font survives a save: {:?}", files.keys()); assert_eq!(fonts[0].data, font_bytes); } #[test] fn comment_ids_keep_sparse_threads_reservations_and_missing_parts() { use craft_wordmodel::{Comment, Document, Paragraph, Part, PartKind, para_block}; let mut doc = Document::from_text("Original thread fixture."); // New IDs must skip the exact durable ID reserved by a later comment, even one whose // missing part keeps it out of commentsIds.xml. Duplicate imported IDs are reassigned. let rows = [ (3, None, Some(90), false, true), (11, Some("5a1b2c3d"), Some(90), true, true), (25, Some("5A1B2C3D"), Some(999), false, true), (90, Some("10000001"), None, true, true), (999, Some("10000002"), None, false, false), (u32::MAX, Some("not-hex"), Some(3), false, true), ]; for (id, durable, parent, resolved, has_part) in rows { let part = id; if has_part { doc.parts.insert( part, Part { kind: PartKind::Comment, blocks: vec![para_block(Paragraph::with_text(&format!("Comment {id} β"), Default::default()))], ..Default::default() }, ); } doc.comments.insert(id, Comment { part, parent, resolved, durable_id: durable.map(str::to_string), ..Default::default() }); } let files = save(&doc); let (ids, _) = written_ids(&files); assert_eq!(ids.iter().map(|(_, id)| id.as_str()).collect::>(), ["10000003", "5A1B2C3D", "10000004", "10000001", "10000005"]); let ex = text(&files, "word/commentsExtended.xml"); assert_eq!(ex.matches("