diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs
index 6e56ba70b..4f4523767 100644
--- a/crates/docx/src/read/mod.rs
+++ b/crates/docx/src/read/mod.rs
@@ -460,8 +460,21 @@ fn toc_control_to_toc(body: &mut Blocks) {
let _ = p.delete(0, OBJ.len_utf8());
}
toc_field_to_heading(&mut toc);
- if crate::write::toc_span(&toc) == Some((s, e)) {
+ let written = |bl: &Blocks| crate::write::written_toc_span(bl) == Some((s, e));
+ if written(&toc) {
*body = toc;
+ return;
+ }
+ // A heading that isn't a `TOCHeading` is written empty, its field opening in the first entry.
+ // That TOC is handed over as the range read, which Update Table turns back into the engine's
+ // TOC in that paragraph (`range_toc_to_field`); kept in the control, the paragraph wouldn't be
+ // empty and each save and update would add a blank line above the entries (#2182).
+ if toc.get(s).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty() && p.section.is_none()) {
+ let mut engine = toc.clone();
+ field_to_heading(&mut engine, s + 1);
+ if written(&engine) {
+ *body = toc;
+ }
}
}
@@ -469,43 +482,44 @@ fn toc_control_to_toc(body: &mut Blocks) {
/// reproduce exactly. Other imported TOCs keep their explicit boundaries, independent of styles.
fn toc_field_to_heading(body: &mut Blocks) {
for i in 1..body.len() {
- let heading = body[i - 1].as_para().filter(|p| p.props.style.as_deref() == Some("TOCHeading"));
- let has_toc = |o: &InlineObject| matches!(o, InlineObject::Field { instr, .. } if is_toc(instr));
- if heading.is_none_or(|h| h.objects.iter().any(has_toc)) {
- continue;
+ if body[i - 1].as_para().is_some_and(|p| p.props.style.as_deref() == Some("TOCHeading")) {
+ field_to_heading(body, i);
}
- let Some((field, props, end)) = body[i].as_para().and_then(|p| {
- let (field, end) = match p.object_at(0) {
- Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
- InlineObject::Field {
- instr: instr.clone(),
- result: String::new(),
- locked: *locked,
- dirty: *dirty,
- code: false,
- form: String::new(),
- },
- Some(toc_range_end(body, i)?),
- ),
- Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
- _ => return None,
- };
- Some((field, p.props_of_char(0).clone(), end))
- }) else {
- continue;
+ }
+}
+
+/// Moves the TOC field that opens paragraph `i` (a complete range over the entries, or an empty
+/// field) to the end of paragraph `i - 1`, the engine's TOC heading, unless that one has a TOC
+/// field already.
+fn field_to_heading(body: &mut Blocks, i: usize) {
+ let has_toc = |o: &InlineObject| matches!(o, InlineObject::Field { instr, .. } if is_toc(instr));
+ if i == 0 || body.get(i - 1).and_then(|b| b.as_para()).is_none_or(|h| h.objects.iter().any(has_toc)) {
+ return;
+ }
+ let Some((field, props, end)) = body.get(i).and_then(|b| b.as_para()).and_then(|p| {
+ let (field, end) = match p.object_at(0) {
+ Some(InlineObject::FieldStart { instr, locked, dirty }) if is_toc(instr) => (
+ InlineObject::Field { instr: instr.clone(), result: String::new(), locked: *locked, dirty: *dirty, code: false, form: String::new() },
+ Some(toc_range_end(body, i)?),
+ ),
+ Some(f @ InlineObject::Field { result, .. }) if result.is_empty() && has_toc(f) => (f.clone(), None),
+ _ => return None,
};
- // Delete the end first: the two markers may occupy the same paragraph.
- if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
- let off = p.len().saturating_sub(OBJ.len_utf8());
- let _ = p.delete(off, p.len());
- }
- if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
- let _ = p.delete(0, OBJ.len_utf8());
- }
- if let Some(Block::Para(h)) = body.get_mut(i - 1).map(Arc::make_mut) {
- let end = h.len();
- let _ = h.insert_object(end, field, &props);
- }
+ Some((field, p.props_of_char(0).clone(), end))
+ }) else {
+ return;
+ };
+ // Delete the end first: the two markers may occupy the same paragraph.
+ if let Some(Block::Para(p)) = end.and_then(|end| body.get_mut(end)).map(Arc::make_mut) {
+ let off = p.len().saturating_sub(OBJ.len_utf8());
+ let _ = p.delete(off, p.len());
+ }
+ if let Some(Block::Para(p)) = body.get_mut(i).map(Arc::make_mut) {
+ let _ = p.delete(0, OBJ.len_utf8());
+ }
+ if let Some(Block::Para(h)) = body.get_mut(i - 1).map(Arc::make_mut) {
+ let end = h.len();
+ let _ = h.insert_object(end, field, &props);
}
}
diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs
index 1551468a9..c9dbef58d 100644
--- a/crates/docx/src/write/mod.rs
+++ b/crates/docx/src/write/mod.rs
@@ -9,7 +9,7 @@ mod math;
mod props;
mod sdt;
mod story;
-pub(crate) use story::toc_span;
+pub(crate) use story::written_toc_span;
mod watermark;
mod wordart;
diff --git a/crates/docx/src/write/story.rs b/crates/docx/src/write/story.rs
index 3b92cc806..7713f9768 100644
--- a/crates/docx/src/write/story.rs
+++ b/crates/docx/src/write/story.rs
@@ -35,8 +35,7 @@ impl Writer<'_> {
return;
}
let plan = super::sdt::plan(bl);
- // A TOC inside a content control already has one (read from a file).
- let toc = if top { toc_span(bl).filter(|(start, _)| !plan.covers(*start)) } else { None };
+ let toc = if top { written_toc_span(bl) } else { None };
// Whether the heading's TOC field was written and still needs its end.
let mut toc_field_open = false;
for (i, b) in bl.iter().enumerate() {
@@ -1688,9 +1687,15 @@ fn is_toc(instr: &str) -> bool {
instr.split_whitespace().next().is_some_and(|k| k.eq_ignore_ascii_case("TOC"))
}
+/// The body TOC the writer wraps in Word's Table of Contents control (`toc_span`): not one whose
+/// heading is inside a content control already (read from a file).
+pub(crate) fn written_toc_span(bl: &Blocks) -> Option<(usize, usize)> {
+ toc_span(bl).filter(|(start, _)| !super::sdt::plan(bl).covers(*start))
+}
+
/// The engine's TOC: a paragraph holding an empty `TOC` field, then its TOC-styled entries.
/// Returns (heading, last entry) so the field can be written spanning the entries, as Word does.
-pub(crate) fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
+fn toc_span(bl: &Blocks) -> Option<(usize, usize)> {
let start = bl.iter().position(|b| {
b.as_para().is_some_and(|p| {
p.section.is_none()
diff --git a/crates/docx/tests/content_controls.rs b/crates/docx/tests/content_controls.rs
index d630e7c36..7aab1415e 100644
--- a/crates/docx/tests/content_controls.rs
+++ b/crates/docx/tests/content_controls.rs
@@ -301,3 +301,33 @@ fn toc_gallery_around_the_engine_form_goes_to_the_toc() {
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}");
+ }
+}