diff --git a/crates/docx/src/read/toc.rs b/crates/docx/src/read/toc.rs
index 1c246732a2..3516044616 100644
--- a/crates/docx/src/read/toc.rs
+++ b/crates/docx/src/read/toc.rs
@@ -83,13 +83,14 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
}
// An empty paragraph opening the control takes the field. It may end a section, as above
// entries in columns (#3889).
- if engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()) {
+ let empty_heading = engine.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty());
+ if empty_heading {
field_to_heading(&mut engine, s + 1);
} else {
toc_field_to_heading(&mut engine);
}
if crate::write::written_toc_span(&engine) == Some((s, last)) {
- *body = toc;
+ *body = if empty_heading { toc } else { engine };
}
}
@@ -97,7 +98,7 @@ pub(super) fn toc_control_to_toc(body: &mut Blocks) {
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
pub(super) fn toc_field_to_heading(body: &mut Blocks) {
for i in 1..body.len() {
- if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
+ if body.get(i - 1).and_then(|b| b.as_para()).is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
field_to_heading(body, i);
}
}
diff --git a/crates/docx/tests/it/content_controls.rs b/crates/docx/tests/it/content_controls.rs
index 63b79c4c4f..ef420e42d8 100644
--- a/crates/docx/tests/it/content_controls.rs
+++ b/crates/docx/tests/it/content_controls.rs
@@ -342,3 +342,57 @@ fn toc_gallery_around_a_toc_whose_heading_is_in_a_block_control_round_trips() {
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}");
+}