//! Citation-manager documents (Zotero and the like): `ADDIN` fields keep their formatted, //! multi-paragraph results, and custom document properties round-trip. All markup here is //! synthetic, written from ECMA-376. use std::io::Write; use wordcraft_doc::para::OBJ; use wordcraft_doc::{CharProps, Document, InlineObject, Path, StoryRef}; use wordcraft_docx::{read, write}; const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main""#; const ROOT_RELS: &str = r#""#; const CITE_A: &str = r#" ADDIN ZOTERO_ITEM CSL_CITATION {"citationID":"k1","properties":{"formattedCitation":"(Doe, 2020)"},"#; const CITE_B: &str = r#""citationItems":[{"id":7,"uris":["http://zotero.org/users/local/x/items/ABCD"]}]} "#; const BIBL: &str = r#" ADDIN ZOTERO_BIBL {"uncited":[],"omitted":[],"custom":[]} CSL_BIBLIOGRAPHY "#; fn zip(entries: &[(&str, &[u8])]) -> 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 fld(kind: &str) -> String { format!(r#""#) } fn instr(s: &str) -> String { format!(r#"{}"#, s.replace('&', "&").replace('<', "<").replace('"', """)) } fn t(s: &str) -> String { format!(r#"{s}"#) } fn ti(s: &str) -> String { format!(r#"{s}"#) } fn custom_xml() -> String { let p = |pid: u32, name: &str, v: &str| { format!(r#"{v}"#) }; format!( r#"{}{}{}{}"#, p(2, "ZOTERO_PREF_1", r#"<data data-version="3"><session id="abc"/>"#), p(3, "ZOTERO_PREF_2", r#"<style id="http://www.zotero.org/styles/apa"/></data>"#), p(4, "Pages", "12"), p(5, "Tags", r#"ab & c"#), ) } fn package(body: &str) -> Vec { let doc = format!(r#"{body}"#); zip(&[("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes()), ("docProps/custom.xml", custom_xml().as_bytes())]) } /// A cited sentence, a PAGE field, and a bibliography spanning two paragraphs. fn zotero_docx() -> Vec { let p0 = [ t("See "), fld("begin"), instr(CITE_A), instr(CITE_B), fld("separate"), t("(Doe, "), ti("2020"), t(")"), fld("end"), t(". Page "), fld("begin"), instr("PAGE"), fld("separate"), t("1"), fld("end"), ]; let p0 = format!("{}", p0.concat()); let p1 = format!("{}{}{}{}{}", fld("begin"), instr(BIBL), fld("separate"), t("Doe, J. (2020). "), ti("A Title")); let p2 = format!("{}{}", t("Roe, R. (2019). Other."), fld("end")); let p3 = format!("{}", t("After")); package(&format!("{p0}{p1}{p2}{p3}")) } fn para_text(d: &Document, i: usize) -> String { d.para(StoryRef::Body, &Path::top(i)).map(|p| p.plain_text()).unwrap_or_default() } fn italic_at(d: &Document, i: usize, needle: &str) -> bool { let p = d.para(StoryRef::Body, &Path::top(i)).unwrap(); let off = p.text.find(needle).unwrap(); p.props_of_char(off).italic == Some(true) } fn check_zotero(d: &Document) { assert_eq!(para_text(d, 0), "See (Doe, 2020). Page 1"); assert_eq!(para_text(d, 1), "Doe, J. (2020). A Title"); assert_eq!(para_text(d, 2), "Roe, R. (2019). Other."); assert_eq!(para_text(d, 3), "After"); // The result keeps its formatting. assert!(italic_at(d, 0, "2020")); assert!(!italic_at(d, 0, "(Doe")); assert!(italic_at(d, 1, "A Title")); let r = d.field_ranges(StoryRef::Body); assert_eq!(r.len(), 2, "{r:?}"); assert_eq!(r[0].instr, format!("{CITE_A}{CITE_B}").trim()); assert_eq!(r[0].start.path, Path::top(0)); assert_eq!(r[0].end.path, Path::top(0)); assert_eq!(r[1].instr, BIBL.trim()); assert_eq!((r[1].start.path.clone(), r[1].start.off), (Path::top(1), 0)); assert_eq!(r[1].end.path, Path::top(2)); assert!(!d.has_unbalanced_field_ranges()); // Engine fields stay atomic. let p0 = d.para(StoryRef::Body, &Path::top(0)).unwrap(); assert!(p0.objects.iter().any(|o| matches!(o, InlineObject::Field { instr, .. } if instr == "PAGE"))); let names: Vec<&str> = d.custom_props.iter().map(|p| p.name.as_str()).collect(); assert_eq!(names, ["ZOTERO_PREF_1", "ZOTERO_PREF_2", "Pages", "Tags"]); assert_eq!(d.custom_prop("ZOTERO_PREF_1"), Some(r#""#)); assert_eq!(d.custom_prop("zotero_pref_2"), Some(r#"