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}"); +}