//! Content controls (`w:sdt`) keep their properties, levels and nesting through a round trip. //! All markup here is synthetic, written from ECMA-376 §17.5.2 and the [MS-DOCX] `w14`/`w15` //! extensions. use std::io::Write; use craft_docx::{read, write}; use craft_wordmodel::control::{ControlKind, ControlLock, ListItem}; use craft_wordmodel::{Block, ContentControl, Document, StoryRef}; const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#; const ROOT_RELS: &str = r#""#; fn package(body: &str) -> Vec { let doc = format!(r#"{body}"#); let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] { zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap(); zw.write_all(bytes).unwrap(); } zw.finish().unwrap().into_inner() } fn t(s: &str) -> String { format!(r#"{s}"#) } fn p(inner: &str) -> String { format!("{inner}") } fn sdt(pr: &str, content: &str) -> String { format!("{pr}{content}") } fn cell(text: &str) -> String { format!("{}", p(&t(text))) } /// Every kind of control, at every level, with nesting. fn form() -> Vec { let block = sdt( r#""#, &format!("{}{}", p(&t("Write the introduction.")), p(&t("Second paragraph"))), ); let inline = p(&format!( "{}{}{}{}{}{}", t("Name: "), sdt(r#""#, &t("Ada")), t(" Agree: "), sdt( r#""#, &t("\u{2612}") ), sdt( r#""#, &t("Beta") ), sdt( r#""#, &t("11.10.2026") ), )); // A combo box holding a nested rich text control. let nested = p(&sdt( r#""#, &format!("{}{}", t("Pick "), sdt(r#""#, &t("inner"))), )); let gallery = sdt(r#""#, &p(&t("Cover"))); // A repeating section around table rows, each row an item; a control around one cell; a // picture control holding nothing but a paragraph mark's worth of text. let row = |a: &str, b: &str| format!("{}{}", cell(a), cell(b)); let table = format!( "{}", sdt( r#""#, &format!( "{}{}", sdt(r#""#, &row("x", "y")), sdt( r#""#, &format!("{}{}", cell("z"), sdt(r#""#, &cell("pic"))) ) ) ) ); // A block control whose content is a table. let around_table = sdt( r#""#, &format!("{}", format_args!("{}", cell("t"))), ); package(&format!("{block}{inline}{nested}{gallery}{table}{around_table}{}", p(&t("End")))) } fn document_xml(docx: &[u8]) -> String { let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap(); let mut s = String::new(); std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap(); s } fn controls(d: &Document) -> Vec { let mut out = Vec::new(); craft_wordmodel::control::walk_controls(&d.body, &mut |c| out.push(c.clone())); out } fn by_id(d: &Document, id: i64) -> ContentControl { controls(d).into_iter().find(|c| c.id == Some(id)).unwrap_or_else(|| panic!("control {id}")) } #[test] fn every_kind_reads_with_its_properties() { let d = read(&form()).unwrap(); assert_intro_control(&d); assert_control_kinds(&d); assert_table_controls(&d); } fn assert_intro_control(d: &Document) { let intro = controls(d).into_iter().find(|c| c.title == "Intro").unwrap(); assert!(intro.block && intro.showing_placeholder); assert_eq!((intro.tag.as_str(), intro.id, intro.lock), ("intro", Some(-1201), ControlLock::SdtLocked)); assert_eq!(intro.placeholder, "DefaultPlaceholder_1"); assert_eq!(intro.placeholder_text, "Write the introduction."); assert!(intro.rpr_xml.contains("w:b")); assert_eq!(intro.extra, vec![r#""#.to_string()]); let ranges = d.control_ranges(StoryRef::Body); let r = ranges.iter().find(|r| r.control.title == "Intro").unwrap(); assert_eq!(d.control_text(r), "Write the introduction.\nSecond paragraph"); } fn assert_control_kinds(d: &Document) { assert_eq!(by_id(d, 7).kind, ControlKind::Text { multi_line: true }); assert!(!by_id(d, 7).block); assert!( matches!(by_id(d, 8).kind, ControlKind::CheckBox { checked: true, checked_char: '\u{2612}', ref checked_font, .. } if checked_font == "Symbola") ); let dd = by_id(d, 9); assert_eq!(dd.lock, ControlLock::ContentLocked); assert_eq!( dd.kind, ControlKind::DropDown { items: vec![ListItem { display: "Alpha".into(), value: "a".into() }, ListItem { display: "Beta".into(), value: "b".into() }], last_value: "b".into() } ); assert!( matches!(by_id(d, 10).kind, ControlKind::Date { ref full_date, ref format, .. } if full_date == "2026-10-11T00:00:00Z" && format == "d.M.yyyy") ); assert_eq!(by_id(d, 10).extra.len(), 1, "data binding kept"); assert_eq!(by_id(d, 12).kind, ControlKind::RichText); assert!(matches!(by_id(d, 13).kind, ControlKind::Gallery { list: false, ref gallery, unique: true, .. } if gallery == "Cover Pages")); assert!(matches!(by_id(d, 20).kind, ControlKind::RepeatingSection { ref title, .. } if title == "Items")); assert_eq!(by_id(d, 21).kind, ControlKind::RepeatingSectionItem); assert_eq!(by_id(d, 23).kind, ControlKind::Picture); } fn assert_table_controls(d: &Document) { // Row-level controls sit on the rows, the cell-level one on its cell, the table's on the table. let tables: Vec<_> = d.body.iter().filter_map(|b| b.as_table()).collect(); assert_eq!(tables.len(), 2); let rows = &tables[0].rows; assert_eq!(rows[0].controls.open.iter().map(|c| c.id).collect::>(), vec![Some(20), Some(21)]); assert_eq!((rows[0].controls.close, rows[1].controls.close), (1, 2)); assert_eq!(rows[1].cells[1].controls.open[0].id, Some(23)); assert_eq!(tables[1].controls.open[0].tag, "grid"); assert_eq!(tables[1].controls.close, 1); // The text reads as before. assert!(d.plain_text(StoryRef::Body).contains("Name: Ada Agree: \u{2612}Beta11.10.2026")); } #[test] fn controls_round_trip() { let d = read(&form()).unwrap(); let bytes = write(&d).unwrap(); let back = read(&bytes).unwrap(); assert_eq!(controls(&back), controls(&d)); assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body)); assert_eq!(back.body.len(), d.body.len()); let names: Vec<_> = back.control_ranges(StoryRef::Body).iter().map(|r| (r.control.id, r.start.path.clone(), r.end.path.clone())).collect(); let before: Vec<_> = d.control_ranges(StoryRef::Body).iter().map(|r| (r.control.id, r.start.path.clone(), r.end.path.clone())).collect(); assert_eq!(names, before); // Written in schema order, with the extension elements the file had. let xml = document_xml(&bytes); assert!(xml.contains( r#""# )); assert!(xml.contains(r#""#)); // The block control is around the paragraphs, the inline ones inside theirs. assert!(xml.contains("")); assert!(xml.contains(r#""#), &s); } let mut b = p(&t("x")); for i in 0..400 { b = sdt(&format!(r#""#), &b); } for body in [p(&s), b] { // Too deep for the parser: an error or a document, never a panic or a hang. if let Ok(d) = read(&package(&body)) { let _ = write(&d).unwrap(); } } let mut s = t("core"); for i in 0..60 { s = sdt(&format!(r#""#), &s); } let d = read(&package(&p(&s))).unwrap(); let back = read(&write(&d).unwrap()).unwrap(); assert_eq!(controls(&back).len(), 60); } /// A control's `w:rPr` (ECMA-376 §17.5.2.27) is read as the formatting entered text takes, and /// still written back as it was. #[test] fn a_controls_run_properties_are_read_and_kept() { let docx = package(&p(&format!( "{}{}", t("Name: "), sdt(r#""#, &t("Eingabe")) ))); let d = read(&docx).unwrap(); let c = by_id(&d, 11); let rp = c.run_props.expect("run properties read"); assert_eq!((rp.style.as_deref(), rp.size), (Some("Eingabe"), Some(10.0))); let out = document_xml(&write(&d).unwrap()); assert!(out.contains(r#""#), "{out}"); } /// Word's Table of Contents control (`w:docPartObj`, #2427) around a TOC the engine's /// heading/entry form doesn't cover (no `TOCHeading`, unstyled entries, the field ending in a /// paragraph of its own) is kept as read: nothing rebuilds it on save. #[test] fn toc_gallery_around_a_free_form_toc_round_trips() { let pr = r#""#; let field = format!( r#" TOC \o "1-3" \h \z \u {}{}"#, t("Chapter One"), t("1") ); let end = r#""#; let body = format!("{pr}{}{}{}{}", p(&t("Contents")), p(&field), p(end), p(&t("Chapter One"))); let mut bytes = package(&body); for pass in 0..2 { let d = read(&bytes).unwrap(); bytes = write(&d).unwrap(); let xml = document_xml(&bytes); assert_eq!(xml.matches("").count(), 1, "pass {pass}: {xml}"); let open = xml.find(pr).unwrap_or_else(|| panic!("pass {pass}: {xml}")); let close = xml.find("").unwrap(); let (contents, fend) = (xml.find(">Contents<").unwrap(), xml.find(r#"w:fldCharType="end""#).unwrap()); assert!(open < contents && fend < close, "pass {pass}: {xml}"); assert!(close < xml.rfind(">Chapter One<").unwrap(), "the body paragraph stays outside: {xml}"); } } /// The control around a TOC in the engine's form (a `TOCHeading`, then TOC-styled entries the /// field spans) still goes to the TOC: written once, around the heading and the entries, and the /// field opens in the heading as References › Update Table expects. #[test] fn toc_gallery_around_the_engine_form_goes_to_the_toc() { let style = |s: &str, inner: &str| format!(r#"{inner}"#); let begin = r#" TOC \o "1-3" \h \z \u "#; let end = r#""#; let content = format!( "{}{}{}", style("TOCHeading", &t("Contents")), style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))), style("TOC2", &format!("{}{end}", t("Section\t2"))) ); let pr = r#""#; let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One")))); for pass in 0..2 { let d = read(&bytes).unwrap(); assert!(controls(&d).is_empty(), "pass {pass}"); let heading = d.body[0].as_para().unwrap(); assert!(heading.objects.iter().any(|o| matches!(o, craft_wordmodel::InlineObject::Field { instr, .. } if instr.trim().starts_with("TOC")))); bytes = write(&d).unwrap(); let xml = document_xml(&bytes); assert_eq!(xml.matches("").count(), 1, "pass {pass}: {xml}"); assert!(xml.contains(r#""#), "pass {pass}: {xml}"); assert!(xml.find("").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}"); } } /// A rich text control around the heading and the first entry of a TOC in the engine's form, /// inside Word's Table of Contents control: the writer doesn't put a TOC control around a heading /// already in a block control, so the outer one is kept as read rather than lost on save. #[test] fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() { let style = |s: &str, inner: &str| format!(r#"{inner}"#); let begin = r#" TOC \o "1-3" \h \z \u "#; let end = r#""#; let content = format!( "{}{}", sdt( r#""#, &format!("{}{}", style("TOCHeading", &t("Contents")), style("TOC1", &format!("{begin}{}", t("Chapter One\t1")))) ), style("TOC2", &format!("{}{end}", t("Section\t2"))) ); let pr = r#""#; let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One")))); for pass in 0..2 { let d = read(&bytes).unwrap(); bytes = write(&d).unwrap(); let xml = document_xml(&bytes); assert_eq!(xml.matches("").count(), 2, "pass {pass}: {xml}"); let (gallery, rich) = (xml.find(&format!("{pr}")).unwrap_or_else(|| panic!("pass {pass}: {xml}")), xml.find(r#""#).unwrap()); assert!(gallery < rich && rich < xml.find(">Contents<").unwrap(), "pass {pass}: {xml}"); assert!(xml.rfind("").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}"); } } #[test] fn word_toc_gallery_with_field_end_after_entries_round_trips_without_update() { let style = |s: &str, inner: &str| format!(r#"{inner}"#); let begin = r#" TOC \o "1-3" \h \z \u "#; let content = format!( "{}{}{}", style("TOCHeading", &t("Contents")), style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))), style("TOC2", &t("Section\t2")) ); let pr = r#""#; let mut bytes = package(&format!("{}{}", sdt(pr, &content), p(&t("Chapter One")))); for pass in 0..2 { let d = read(&bytes).unwrap(); bytes = write(&d).unwrap(); let xml = document_xml(&bytes); assert_eq!(xml.matches("").count(), 1, "pass {pass}: {xml}"); assert!(xml.contains(r#""#), "pass {pass}: {xml}"); assert!(xml.find("").unwrap() < xml.rfind(">Chapter One<").unwrap(), "pass {pass}: {xml}"); } } #[test] fn range_toc_without_control_stays_unwrapped_and_section_break_toc_control_hands_over() { let style = |s: &str, inner: &str| format!(r#"{inner}"#); let begin = r#" TOC \o "1-3" \h \z \u "#; // A range TOC that was never in a content control (with FieldEnd in its own paragraph) must // not gain a Table of Contents w:sdt on save. let bare = format!( "{}{}{}{}", style("TOCHeading", &t("Contents")), style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))), style("TOC2", &t("Section\t2")), p(&t("Chapter One")), ); let d_bare = read(&package(&bare)).unwrap(); let xml_bare = document_xml(&write(&d_bare).unwrap()); assert_eq!(xml_bare.matches("").count(), 0, "{xml_bare}"); // #3889: a Table of Contents control opening on an empty paragraph that ends a section above // the entries hands its control over on read so Update Table reuses the section-break paragraph. let sect_toc = format!( r#"{}{}"#, style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))), style("TOC2", &t("Section\t2")), ); let pr = r#""#; let d_sect = read(&package(&format!("{}{}", sdt(pr, §_toc), p(&t("Chapter One"))))).unwrap(); assert!(controls(&d_sect).is_empty()); assert!(d_sect.body[0].as_para().unwrap().section.is_some()); let xml_sect = document_xml(&write(&d_sect).unwrap()); assert!(xml_sect.contains("Chapter One") && xml_sect.contains("Section"), "{xml_sect}"); }