Files
craft-libs/crates/docx/tests/it/content_controls.rs
Dylan M. Taylor e3e31693c7 Add craft-docx: WordCraft's DOCX reader and writer on craft-wordmodel, craft-opc and craft-xml (#1015)
* Add craft-wordmodel: WordCraft's document model without its editing code

WordCraft's crates/doc (wordcraft-doc, at 07f97737) moved as it is, so the DOCX,
RTF, ODT and other text-format crates can read into and write from one model.
Module paths, type names and serde attributes are unchanged: an app that
re-exports the crate keeps its native file format.

Left in the app: range editing (edit.rs), IME text and equation editing. Not
model, and already in craft-libs: code pages (craft-rtf), the ZIP entry-count
check (craft-opc), the compound-file guard (craft-cfb). The paragraph walks
moved to walk.rs and the unit helpers of wordcraft-geom to geom.rs.

* craft-wordmodel: math_tree, the arguments of an equation node and a walk over its runs

The DOCX reader and writer walk an equation's runs. The walk and the child
accessors it uses were in WordCraft's equation editor (math_edit.rs), which
stays in the app; they are structure, not editing.

* craft-wordmodel: keep the document operations (edit.rs) with the model

The DOCX and RTF readers use them: alternative-format chunks are merged into
a document with adopt_blocks, and a reader formats ranges with format_range
and inserts objects. They operate on the document, not on a view, so they
belong beside it. The tests that drove them come back with them; the
paragraph walks stay in walk.rs and edit re-exports them.

base64 0.23, as WordCraft and craft-offcrypto use.

* craft-docx: WordCraft's crates/docx as it is at wordcraft 07f97737

The source, unchanged, so the next commit shows only what the move changes.
This commit does not build on its own (it still names wordcraft-doc and
wordcraft-geom, and is not a workspace member yet).

* craft-docx: on craft-wordmodel, craft-opc, craft-xml and craft-offcrypto

- The package (zip reading, part names, relationships, content types,
  zip writing) is craft-opc's; the module keeps WordCraft's bookkeeping
  (passthrough keys, the embedded-part manifest) and its own Rel/Rels shape.
- Parts are parsed with craft-xml's canonical names (the Strict drawing
  names and mc:Choice/@Requires through its hooks), then given the tree
  shape the reader was written against.
- Encryption and Restrict Editing hashes are craft-offcrypto's; this crate
  maps its errors and the w:documentProtection attributes.
- wordcraft-doc is craft-wordmodel; wordcraft-geom's helpers are its geom.

Tests that covered what now lives in craft-opc and craft-offcrypto are left to
those crates; the DIFAT regression is craft-cfb's. Test-only zip use follows
zip 9's fallible names. The attribute-cap test times the read only.
2026-11-11 20:31:40 -05:00

399 lines
21 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
fn package(body: &str) -> Vec<u8> {
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
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#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
}
fn p(inner: &str) -> String {
format!("<w:p>{inner}</w:p>")
}
fn sdt(pr: &str, content: &str) -> String {
format!("<w:sdt><w:sdtPr>{pr}</w:sdtPr><w:sdtContent>{content}</w:sdtContent></w:sdt>")
}
fn cell(text: &str) -> String {
format!("<w:tc>{}</w:tc>", p(&t(text)))
}
/// Every kind of control, at every level, with nesting.
fn form() -> Vec<u8> {
let block = sdt(
r#"<w:rPr><w:b/></w:rPr><w:alias w:val="Intro"/><w:tag w:val="intro"/><w:id w:val="-1201"/><w:lock w:val="sdtLocked"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder><w:showingPlcHdr/><w15:appearance w15:val="tags"/>"#,
&format!("{}{}", p(&t("Write the introduction.")), p(&t("Second paragraph"))),
);
let inline = p(&format!(
"{}{}{}{}{}{}",
t("Name: "),
sdt(r#"<w:alias w:val="Name"/><w:id w:val="7"/><w:text w:multiLine="1"/>"#, &t("Ada")),
t(" Agree: "),
sdt(
r#"<w:id w:val="8"/><w14:checkbox><w14:checked w14:val="1"/><w14:checkedState w14:val="2612" w14:font="Symbola"/><w14:uncheckedState w14:val="2610" w14:font="Symbola"/></w14:checkbox>"#,
&t("\u{2612}")
),
sdt(
r#"<w:id w:val="9"/><w:lock w:val="contentLocked"/><w:dropDownList w:lastValue="b"><w:listItem w:displayText="Alpha" w:value="a"/><w:listItem w:displayText="Beta" w:value="b"/></w:dropDownList>"#,
&t("Beta")
),
sdt(
r#"<w:id w:val="10"/><w:dataBinding w:xpath="/root/when" w:storeItemID="{00000000-0000-0000-0000-000000000001}"/><w:date w:fullDate="2026-10-11T00:00:00Z"><w:dateFormat w:val="d.M.yyyy"/><w:lid w:val="de-DE"/><w:storeMappedDataAs w:val="dateTime"/><w:calendar w:val="gregorian"/></w:date>"#,
&t("11.10.2026")
),
));
// A combo box holding a nested rich text control.
let nested = p(&sdt(
r#"<w:id w:val="11"/><w:comboBox><w:listItem w:displayText="One" w:value="1"/></w:comboBox>"#,
&format!("{}{}", t("Pick "), sdt(r#"<w:id w:val="12"/><w:richText/>"#, &t("inner"))),
));
let gallery = sdt(r#"<w:id w:val="13"/><w:docPartObj><w:docPartGallery w:val="Cover Pages"/><w:docPartUnique/></w:docPartObj>"#, &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!("<w:tr>{}{}</w:tr>", cell(a), cell(b));
let table = format!(
"<w:tbl><w:tblGrid><w:gridCol w:w=\"2000\"/><w:gridCol w:w=\"2000\"/></w:tblGrid>{}</w:tbl>",
sdt(
r#"<w:id w:val="20"/><w15:repeatingSection><w15:sectionTitle w:val="Items"/></w15:repeatingSection>"#,
&format!(
"{}{}",
sdt(r#"<w:id w:val="21"/><w15:repeatingSectionItem/>"#, &row("x", "y")),
sdt(
r#"<w:id w:val="22"/><w15:repeatingSectionItem/>"#,
&format!("<w:tr>{}{}</w:tr>", cell("z"), sdt(r#"<w:id w:val="23"/><w:picture/>"#, &cell("pic")))
)
)
)
);
// A block control whose content is a table.
let around_table = sdt(
r#"<w:id w:val="30"/><w:tag w:val="grid"/>"#,
&format!("<w:tbl><w:tblGrid><w:gridCol w:w=\"2000\"/></w:tblGrid>{}</w:tbl>", format_args!("<w:tr>{}</w:tr>", 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<ContentControl> {
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#"<w15:appearance w15:val="tags"/>"#.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<_>>(), 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#"<w:sdtPr><w:rPr><w:b/></w:rPr><w:alias w:val="Intro"/><w:tag w:val="intro"/><w:id w:val="-1201"/><w:lock w:val="sdtLocked"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder><w:showingPlcHdr/><w15:appearance w15:val="tags"/></w:sdtPr>"#
));
assert!(xml.contains(r#"<w14:checkbox><w14:checked w14:val="1"/><w14:checkedState w14:val="2612" w14:font="Symbola"/>"#));
// The block control is around the paragraphs, the inline ones inside theirs.
assert!(xml.contains("<w:sdtContent><w:p>"));
assert!(xml.contains(r#"<w:dataBinding w:xpath="/root/when" w:storeItemID="{00000000-0000-0000-0000-000000000001}"/><w:date"#));
}
#[test]
fn edited_documents_still_write_whole_controls() {
let mut d = read(&form()).unwrap();
// Merge the block control's first paragraph into the one before nothing: delete across the
// start of the inline name control and the end of the block control.
let ranges = d.control_ranges(StoryRef::Body);
let name = ranges.iter().find(|r| r.control.id == Some(7)).unwrap().clone();
let intro = ranges.iter().find(|r| r.control.title == "Intro").unwrap().clone();
let from = craft_wordmodel::Pos { off: 3, ..intro.end.clone() };
let to = craft_wordmodel::Pos { off: name.start.off + 4, ..name.start.clone() };
d.delete_range(&from, &to).unwrap();
// Both controls are still there, emptied where the deletion went through them.
let after = d.control_ranges(StoryRef::Body);
assert!(after.iter().any(|r| r.control.id == Some(7)));
assert!(after.iter().any(|r| r.control.title == "Intro"));
let back = read(&write(&d).unwrap()).unwrap();
assert_eq!(controls(&back).len(), controls(&d).len());
// An orphan marker (a table carrying an open that lost its table) is dropped on save.
if let Some(Block::Table(t)) = d.body.iter_mut().rev().find(|b| b.as_table().is_some()).map(std::sync::Arc::make_mut) {
t.controls.close = 0;
}
assert!(d.has_unbalanced_controls());
let back = read(&write(&d).unwrap()).unwrap();
assert!(!back.has_unbalanced_controls());
}
#[test]
fn deeply_nested_controls_are_bounded() {
let mut s = t("core");
for i in 0..400 {
s = sdt(&format!(r#"<w:id w:val="{i}"/>"#), &s);
}
let mut b = p(&t("x"));
for i in 0..400 {
b = sdt(&format!(r#"<w:id w:val="{i}"/>"#), &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#"<w:id w:val="{i}"/>"#), &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#"<w:rPr><w:rStyle w:val="Eingabe"/><w:sz w:val="20"/></w:rPr><w:id w:val="11"/><w:showingPlcHdr/>"#, &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#"<w:rPr><w:rStyle w:val="Eingabe"/><w:sz w:val="20"/></w:rPr>"#), "{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#"<w:sdtPr><w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtEndPr/><w:sdtContent>"#;
let field = format!(
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{}<w:r><w:tab/></w:r>{}"#,
t("Chapter One"),
t("1")
);
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
let body = format!("<w:sdt>{pr}{}{}{}</w:sdtContent></w:sdt>{}", 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("<w:sdt>").count(), 1, "pass {pass}: {xml}");
let open = xml.find(pr).unwrap_or_else(|| panic!("pass {pass}: {xml}"));
let close = xml.find("</w:sdtContent></w:sdt>").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#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w: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#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
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("<w:sdt>").count(), 1, "pass {pass}: {xml}");
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
assert!(xml.find("</w:sdt>").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#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
let content = format!(
"{}{}",
sdt(
r#"<w:id w:val="31"/><w:richText/>"#,
&format!("{}{}", style("TOCHeading", &t("Contents")), style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))))
),
style("TOC2", &format!("{}{end}", t("Section\t2")))
);
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
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("<w:sdt>").count(), 2, "pass {pass}: {xml}");
let (gallery, rich) =
(xml.find(&format!("<w:sdtPr>{pr}")).unwrap_or_else(|| panic!("pass {pass}: {xml}")), xml.find(r#"<w:id w:val="31"/>"#).unwrap());
assert!(gallery < rich && rich < xml.find(">Contents<").unwrap(), "pass {pass}: {xml}");
assert!(xml.rfind("</w:sdt>").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#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
let content = format!(
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>",
style("TOCHeading", &t("Contents")),
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &t("Section\t2"))
);
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
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("<w:sdt>").count(), 1, "pass {pass}: {xml}");
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
assert!(xml.find("</w:sdt>").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#"<w:p><w:pPr><w:pStyle w:val="{s}"/></w:pPr>{inner}</w:p>"#);
let begin = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> TOC \o "1-3" \h \z \u </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>"#;
// 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!(
"{}{}{}<w:p><w:r><w:fldChar w:fldCharType=\"end\"/></w:r></w:p>{}",
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("<w:sdt>").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#"<w:p><w:pPr><w:sectPr><w:pgSz w:w="11906" w:h="16838"/></w:sectPr></w:pPr></w:p>{}{}<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#,
style("TOC1", &format!("{begin}{}", t("Chapter One\t1"))),
style("TOC2", &t("Section\t2")),
);
let pr = r#"<w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj>"#;
let d_sect = read(&package(&format!("{}{}", sdt(pr, &sect_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}");
}