//! The glossary document (ECMA-376 ยง17.12: building blocks, Quick Parts, AutoText): kept byte //! for byte while unchanged, read into `Document::glossary`, and written from the model once //! edited. Hand-written fixtures (from the spec), no files produced by Word. use std::collections::BTreeMap; use std::io::{Read, Write}; use wordcraft_doc::{Block, BuildingBlock, CharProps, Document, Paragraph, para_block}; 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"; const GLOSSARY_REL: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/glossaryDocument"; const GLOSSARY_CT: &str = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml"; 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)]) -> String { let mut s = String::from(r#""#); for (id, ty, target) in list { s.push_str(&format!(r#""#)); } s.push_str(""); s } fn content_types(extra: &[(&str, &str)]) -> String { let mut s = String::from( r#""#, ); for (part, ct) in extra { s.push_str(&format!(r#""#)); } s.push_str(""); s } /// A package with body text, a main document relating to `doc_rels`, and `extra` parts. fn package(doc_rels: &[(&str, &str, &str)], extra: &[(&str, String)]) -> Vec { let doc = format!( r#"Body"# ); let root = rels(&[("rIdMain", &format!("{REL}/officeDocument"), "word/document.xml")]); let mut entries: Vec<(&str, Vec)> = vec![ ("_rels/.rels", root.into_bytes()), ("word/document.xml", doc.into_bytes()), ("word/_rels/document.xml.rels", rels(doc_rels).into_bytes()), ]; for (p, c) in extra { entries.push((p, c.as_bytes().to_vec())); } zip(&entries) } fn glossary_xml(doc_parts: &str) -> String { format!( r#"{doc_parts}"# ) } const SIGNATURE: &str = r#"Kind regards, Ann"#; const PRICES: &str = r#"ItemPricePrices exclude tax."#; const GLOSSARY_STYLES: &str = r#""#; const GLOSSARY_WEB: &str = r#""#; /// A document whose glossary holds a Quick Part and an AutoText entry (with a table), with the /// glossary's own styles and web settings. fn with_glossary() -> Vec { package( &[("rId7", GLOSSARY_REL, "glossary/document.xml")], &[ ( "[Content_Types].xml", content_types(&[ ("/word/glossary/document.xml", GLOSSARY_CT), ("/word/glossary/styles.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"), ("/word/glossary/webSettings.xml", "application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml"), ]), ), ("word/glossary/document.xml", glossary_xml(&format!("{SIGNATURE}{PRICES}"))), ( "word/glossary/_rels/document.xml.rels", rels(&[("rId1", &format!("{REL}/styles"), "styles.xml"), ("rId2", &format!("{REL}/webSettings"), "webSettings.xml")]), ), ("word/glossary/styles.xml", GLOSSARY_STYLES.to_string()), ("word/glossary/webSettings.xml", GLOSSARY_WEB.to_string()), ], ) } fn save(doc: &Document) -> BTreeMap> { unzip(&wordcraft_docx::write(doc).expect("write")) } fn block_text(b: &Block) -> String { match b { Block::Para(p) => p.text.clone(), Block::Table(t) => { t.rows.iter().flat_map(|r| r.cells.iter()).flat_map(|c| c.blocks.iter()).map(|b| block_text(b)).collect::>().join("|") } } } fn entry_text(e: &BuildingBlock) -> String { e.blocks.iter().map(|b| block_text(b)).collect::>().join("\n") } /// The glossary part a saved package's main document relates to. fn glossary_target(files: &BTreeMap>) -> Option { let doc_rels = text(files, "word/_rels/document.xml.rels"); assert!(doc_rels.matches("/glossaryDocument\"").count() <= 1, "{doc_rels}"); let at = doc_rels.find("/glossaryDocument\"")?; let rest = doc_rels.get(at..)?; let t = rest.find("Target=\"")? + "Target=\"".len(); let end = rest.get(t..)?.find('"')?; Some(format!("word/{}", rest.get(t..t + end)?)) } #[test] fn unchanged_glossary_is_carried_byte_for_byte() { let bytes = with_glossary(); let mut doc = wordcraft_docx::read(&bytes).unwrap(); doc.body.push(para_block(Paragraph::with_text("Added after opening", CharProps::default()))); let files = save(&doc); let input = unzip(&bytes); assert_eq!(glossary_target(&files).as_deref(), Some("word/glossary/document.xml")); for part in ["word/glossary/document.xml", "word/glossary/styles.xml", "word/glossary/webSettings.xml"] { assert_eq!(files.get(part), input.get(part), "{part} not carried verbatim"); } let grels = text(&files, "word/glossary/_rels/document.xml.rels"); assert!(grels.contains("styles.xml") && grels.contains("webSettings.xml"), "{grels}"); let ct = text(&files, "[Content_Types].xml"); assert!(ct.contains(&format!(r#"PartName="/word/glossary/document.xml" ContentType="{GLOSSARY_CT}""#)), "{ct}"); assert!(ct.contains(r#"PartName="/word/glossary/styles.xml""#), "{ct}"); let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); assert_eq!(again.glossary.len(), 2); assert!(again.plain_text(wordcraft_doc::StoryRef::Body).contains("Added after opening")); } #[test] fn glossary_entries_are_read_into_the_model() { let doc = wordcraft_docx::read(&with_glossary()).unwrap(); let g = &doc.glossary; assert_eq!(g.iter().map(|e| e.name.as_str()).collect::>(), ["Signature", "Price table"]); let sig = &g[0]; assert_eq!((sig.gallery.as_str(), sig.category.as_str()), ("docParts", "General")); assert_eq!(sig.description, "A closing line"); assert_eq!(sig.guid, "{0F1E2D3C-4B5A-6978-8796-A5B4C3D2E1F0}"); assert_eq!( (sig.style.as_str(), sig.types.as_slice(), sig.behaviors.as_slice()), ("Normal", &["normal".to_string()][..], &["content".to_string()][..]) ); assert_eq!(entry_text(sig), "Kind regards, Ann"); let prices = &g[1]; assert_eq!((prices.gallery.as_str(), prices.category.as_str()), ("autoTxt", "Sales")); // Types and behaviours outside the spec's lists aren't kept. assert_eq!(prices.types, ["bbPlcHdr", "autoExp"]); assert_eq!(prices.behaviors, ["p", "pg"]); assert!(matches!(&*prices.blocks[0], Block::Table(_)), "{:?}", prices.blocks); assert_eq!(entry_text(prices), "Item|Price\nPrices exclude tax."); // The glossary's text isn't the document's. assert!(!doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Kind regards")); } #[test] fn edited_glossary_is_written_from_the_model() { let mut doc = wordcraft_docx::read(&with_glossary()).unwrap(); doc.glossary[0].name = "Sign-off".into(); let num = doc.numbering.add_list(wordcraft_doc::ListKind::Bullet); let mut listed = Paragraph::with_text("Second line", CharProps::default()); listed.props.numbering = Some(wordcraft_doc::props::NumRef { num, level: 0 }); doc.glossary.push(BuildingBlock { name: "Address".into(), gallery: "docParts".into(), category: "General".into(), blocks: vec![para_block(Paragraph::with_text("1 Main Street", CharProps::default())), para_block(listed)], ..Default::default() }); let files = save(&doc); let target = glossary_target(&files).expect("a glossary relationship"); let g = text(&files, &target); assert!(g.contains("w:glossaryDocument") && g.contains("Sign-off") && g.contains("1 Main Street"), "{g}"); assert!(!files.values().any(|b| String::from_utf8_lossy(b).contains(r#"w:val="Signature""#)), "the old glossary is gone"); let ct = text(&files, "[Content_Types].xml"); assert!(ct.contains(&format!(r#"PartName="/{target}" ContentType="{GLOSSARY_CT}""#)), "{ct}"); let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); assert_eq!(again.glossary.iter().map(|e| e.name.as_str()).collect::>(), ["Sign-off", "Price table", "Address"]); assert_eq!(entry_text(&again.glossary[1]), "Item|Price\nPrices exclude tax."); assert_eq!(again.glossary[1].gallery, "autoTxt"); assert_eq!(again.glossary[0].guid, "{0F1E2D3C-4B5A-6978-8796-A5B4C3D2E1F0}"); assert_eq!(again.glossary[0].description, "A closing line"); assert_eq!(entry_text(&again.glossary[2]), "1 Main Street\nSecond line"); // Style, types and behaviours survive the rewrite. for (a, b) in again.glossary.iter().zip(&doc.glossary) { assert_eq!((&a.style, &a.types, &a.behaviors), (&b.style, &b.types, &b.behaviors), "{}", a.name); } assert_eq!(again.glossary[1].types, ["bbPlcHdr", "autoExp"]); assert_eq!(again.glossary[1].behaviors, ["p", "pg"]); assert_eq!(again.glossary[0].style, "Normal"); // A list in an entry has its definitions beside the glossary (its own numbering part). let grels = text(&files, &glossary_rels(&target)); assert!(grels.contains("/numbering\""), "{grels}"); let numbering = grels.split("/numbering\" Target=\"").nth(1).and_then(|r| r.split('"').next()).expect("numbering target"); let numbering = text(&files, &format!("word/{numbering}")); assert!(numbering.contains("w:abstractNum") && numbering.contains(&format!(r#"w:numId="{num}""#)), "{numbering}"); // Saving the reopened document again keeps its (now unchanged) glossary verbatim. let twice = save(&again); assert_eq!(glossary_target(&twice).as_deref(), Some(target.as_str())); assert_eq!(twice.get(&target), files.get(&target)); } /// The relationships part of a part in `word/`. fn glossary_rels(part: &str) -> String { let (dir, file) = part.rsplit_once('/').unwrap(); format!("{dir}/_rels/{file}.rels") } #[test] fn an_unchanged_glossary_whose_bytes_werent_kept_is_written_from_the_model() { let mut doc = wordcraft_docx::read(&with_glossary()).unwrap(); // The verbatim copy is gone (over the kept-part caps, say): the model is all there is. doc.passthrough.remove("word/glossary/document.xml"); let files = save(&doc); let target = glossary_target(&files).expect("a glossary relationship"); assert!(text(&files, &target).contains("Kind regards, Ann")); let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); assert_eq!(again.glossary.iter().map(|e| e.name.as_str()).collect::>(), ["Signature", "Price table"]); } #[test] fn an_external_glossary_relationship_is_passed_over() { let doc_rels = format!( r#""# ); let doc = format!( r#"Body"# ); let bytes = zip(&[ ("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)]).into_bytes()), ("_rels/.rels", rels(&[("rIdMain", &format!("{REL}/officeDocument"), "word/document.xml")]).into_bytes()), ("word/document.xml", doc.into_bytes()), ("word/_rels/document.xml.rels", doc_rels.into_bytes()), ("word/glossary/document.xml", glossary_xml(SIGNATURE).into_bytes()), ]); let doc = wordcraft_docx::read(&bytes).unwrap(); assert_eq!(doc.glossary.iter().map(|e| e.name.as_str()).collect::>(), ["Signature"]); // The same glossary is the one kept: saved unchanged, it's written back as it was. let files = save(&doc); assert_eq!(files.get("word/glossary/document.xml"), unzip(&bytes).get("word/glossary/document.xml")); } #[test] fn a_document_without_glossary_writes_none() { let files = save(&Document::new()); assert_eq!(glossary_target(&files), None); assert!(!text(&files, "[Content_Types].xml").contains("glossary")); // Removing every entry removes the glossary. let mut doc = wordcraft_docx::read(&with_glossary()).unwrap(); doc.glossary.clear(); let files = save(&doc); assert_eq!(glossary_target(&files), None); assert!(!files.keys().any(|k| k.contains("glossary")), "{:?}", files.keys()); assert!(!text(&files, "[Content_Types].xml").contains("glossary")); assert!(wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap().glossary.is_empty()); } #[test] fn a_new_entry_in_a_new_document_makes_a_glossary() { let mut doc = Document::new(); doc.glossary.push(BuildingBlock { name: "Greeting".into(), gallery: "docParts".into(), category: "General".into(), blocks: vec![para_block(Paragraph::with_text("Hello there", CharProps::default()))], ..Default::default() }); let again = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); assert_eq!(again.glossary.len(), 1); assert_eq!(again.glossary[0].name, "Greeting"); assert_eq!(entry_text(&again.glossary[0]), "Hello there"); assert!(again.plain_text(wordcraft_doc::StoryRef::Body).trim().is_empty()); } /// Hostile glossaries open without a panic; entries that can't be read are skipped. #[test] fn hostile_glossaries_never_panic() { let ct = content_types(&[("/word/glossary/document.xml", GLOSSARY_CT)]); let one = |g: String| { package(&[("rId7", GLOSSARY_REL, "glossary/document.xml")], &[("[Content_Types].xml", ct.clone()), ("word/glossary/document.xml", g)]) }; let read = |bytes: Vec| -> Document { let doc = wordcraft_docx::read(&bytes).unwrap(); // Saving and reopening must not panic either. let _ = wordcraft_docx::read(&wordcraft_docx::write(&doc).unwrap()).unwrap(); doc }; // No body, an empty body, no name, no properties: skipped. let doc = read(one(glossary_xml( r#"nameless"#, ))); assert!(doc.glossary.iter().all(|e| !e.name.is_empty() && !e.blocks.is_empty()), "{:?}", doc.glossary); assert!(doc.glossary.iter().all(|e| e.name != "NoBody")); // Duplicate names: both read, file order. let doc = read(one(glossary_xml(&format!("{SIGNATURE}{SIGNATURE}")))); assert_eq!(doc.glossary.len(), 2); // Huge counts are capped. let many: String = (0..12_000) .map(|i| format!(r#""#)) .collect(); let doc = read(one(glossary_xml(&many))); assert!(doc.glossary.len() <= 10_000, "{}", doc.glossary.len()); // Deep nesting (tables in tables). let deep = format!( r#"{}{}"#, "".repeat(400), "".repeat(400) ); let _ = read(one(glossary_xml(&deep))); // Not a glossary at all, or a wrong content type: nothing read. let doc = read(one(format!(r#"x"#))); assert!(doc.glossary.is_empty()); let wrong = package( &[("rId7", GLOSSARY_REL, "glossary/document.xml")], &[ ("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", "image/png")])), ("word/glossary/document.xml", glossary_xml(SIGNATURE)), ], ); assert!(read(wrong).glossary.is_empty()); // Broken XML. let _ = read(one("".into())); // A glossary relationship pointing at the main document, or a missing part. for target in ["document.xml", "missing.xml", "../../../../etc/passwd"] { let doc = read(package(&[("rId7", GLOSSARY_REL, target)], &[("[Content_Types].xml", ct.clone())])); assert!(doc.glossary.is_empty(), "{target}"); assert!(!doc.plain_text(wordcraft_doc::StoryRef::Body).contains("Kind regards")); } // A glossary relating to itself, or with a glossary of its own. for own in [rels(&[("rId1", GLOSSARY_REL, "document.xml")]), rels(&[("rId1", GLOSSARY_REL, "inner.xml"), ("rId2", GLOSSARY_REL, "../document.xml")])] { let bytes = package( &[("rId7", GLOSSARY_REL, "glossary/document.xml")], &[ ("[Content_Types].xml", content_types(&[("/word/glossary/document.xml", GLOSSARY_CT), ("/word/glossary/inner.xml", GLOSSARY_CT)])), ("word/glossary/document.xml", glossary_xml(SIGNATURE)), ("word/glossary/_rels/document.xml.rels", own), ("word/glossary/inner.xml", glossary_xml(PRICES)), ], ); let doc = read(bytes); assert_eq!(doc.glossary.iter().map(|e| e.name.as_str()).collect::>(), ["Signature"]); } // Two glossary relationships: the first counts. let two = package( &[("rId7", GLOSSARY_REL, "glossary/document.xml"), ("rId8", GLOSSARY_REL, "glossary/document.xml")], &[("[Content_Types].xml", ct.clone()), ("word/glossary/document.xml", glossary_xml(SIGNATURE))], ); let doc = read(two); assert_eq!(doc.glossary.len(), 1); }