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.
This commit is contained in:
Marco Arnulfo
2026-10-21 10:59:52 +02:00
parent 07a9880c14
commit 35fdb45156
4 changed files with 89 additions and 40 deletions
+50 -36
View File
@@ -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);
}
}
+1 -1
View File
@@ -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;
+8 -3
View File
@@ -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()
+30
View File
@@ -301,3 +301,33 @@ fn toc_gallery_around_the_engine_form_goes_to_the_toc() {
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}");
}
}