From 35fdb45156beaa8e207c6f5eb38950d268bffbdd Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Wed, 21 Oct 2026 10:59:52 +0200 Subject: [PATCH] DOCX: hand WordCraft's own TOC control back to the TOC, keep it when a block control covers the heading (#2427) A TOC whose heading isn't a TOCHeading is written with an empty heading paragraph inside the control. Keeping the control around it on reading left that paragraph non-empty, so each save and Update Table added a blank line (toc_menu_offers_update_field_after_save_and_reopen, #2182). The reader now compares against the TOC the writer would actually wrap (written_toc_span), which also keeps the original control when another block control covers the TOC heading. --- crates/docx/src/read/mod.rs | 86 ++++++++++++++++----------- crates/docx/src/write/mod.rs | 2 +- crates/docx/src/write/story.rs | 11 +++- crates/docx/tests/content_controls.rs | 30 ++++++++++ 4 files changed, 89 insertions(+), 40 deletions(-) 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}"); + } +}