DOCX: nested block-level content controls stay block-level when a bookmark follows them, so Word opens the file (#5811)

## What changed

The DOCX writer's content-control planner (`crates/docx/src/write/sdt.rs`) decides whether a control that
covers a whole paragraph is written block-level (`w:sdt` around `w:p`) or run-level (inside the paragraph).
It only treated the control as covering the whole paragraph when nothing but control-end markers followed
its end. Bookmark, comment and permission range markers after the end now count as "nothing" too, so the
control stays block-level and the markers stay at the end of its paragraph.

## Why

Word writes a body-level `_GoBack` bookmark (`<w:bookmarkStart w:name="_GoBack"/><w:bookmarkEnd/>` directly in
`w:body`) in nearly every file it saves. When the last body-level content control was followed by it, the
reader put the bookmark at the end of the control's paragraph, and the writer then turned the control (and
any controls nested in it) into run-level `w:sdt`s inside that paragraph. For a repeating section
(`w15:repeatingSection` / `w15:repeatingSectionItem`), which must wrap whole paragraphs or rows, Microsoft
365 Word then refuses to open the saved file ("the file appears to be corrupted"). Plain nested block
controls changed what they cover the same way.

Found by the real-file corpus: Word reported our saved copy of a form with a repeating section as corrupted.

## How it was tested

- New tests in `crates/docx/tests/it/nested_block_controls.rs` (synthetic packages): a repeating section around
  one paragraph, plain nested block controls, nested block controls in a table cell, a run-level control that
  must stay in its paragraph, and repeating sections / block controls followed by a body-level bookmark (with
  and without data bindings) all round-trip with the controls at the same level and the text kept.
- `cargo xtask ci` passes.

Co-authored-by: tpawley2001 <268951182+tpawley2001@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-11-08 05:59:51 +01:00
1 parent c4d5f70c1d
commit cd4f755dcf
4 files changed
+200 -1

No files matched your search

+18 -1
View File
@@ -86,11 +86,28 @@ pub fn plan(bl: &Blocks) -> Plan<'_> {
&& (0..off).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlStart { .. })))
})
};
// Bookmark, comment and permission markers after the end may stay there: a body-level
// bookmark after a block control (Word's `_GoBack`) is read onto the end of its paragraph,
// and the control still covers the whole paragraph.
let at_end = |b: usize, off: usize| {
use wordcraft_doc::InlineObject as O;
para(b).is_some_and(|p| {
let from = off + OBJ.len_utf8();
p.text.get(from..).is_some_and(|t| t.chars().all(|c| c == OBJ))
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| matches!(p.object_at(o), Some(wordcraft_doc::InlineObject::ControlEnd)))
&& (from..p.len()).step_by(OBJ.len_utf8()).all(|o| {
matches!(
p.object_at(o),
Some(
O::ControlEnd
| O::BookmarkStart { .. }
| O::BookmarkEnd { .. }
| O::CommentStart { .. }
| O::CommentEnd { .. }
| O::PermStart { .. }
| O::PermEnd { .. }
)
)
})
})
};
let mut mode = vec![Mode::Inline; ranges.len()];
+1
View File
@@ -21,6 +21,7 @@ mod heading_levels;
mod legacy_forms;
mod ligatures;
mod malformed;
mod nested_block_controls;
mod note_comments;
mod passthrough;
mod picture_links;
@@ -0,0 +1,180 @@
//! A body-level content control nested directly in another body-level one stays body-level when
//! saved, even when it holds a single paragraph (ECMA-376 §17.5.2.29-30; [MS-DOCX] `w15`
//! repeating sections, which must wrap whole paragraphs or rows). All markup here is synthetic.
use std::io::Write;
use wordcraft_doc::{ContentControl, Document, StoryRef};
use wordcraft_docx::{read, write};
const NS: &str =
r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w15="http://schemas.microsoft.com/office/word/2012/wordml""#;
const ROOT_RELS: &str = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
fn package(body: &str) -> Vec<u8> {
let doc = format!(r#"<w:document {NS}><w:body>{body}</w:body></w:document>"#);
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in [("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())] {
zw.start_file(name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn document_xml(docx: &[u8]) -> String {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(docx)).unwrap();
let mut s = String::new();
std::io::Read::read_to_string(&mut z.by_name("word/document.xml").unwrap(), &mut s).unwrap();
s
}
fn t(s: &str) -> String {
format!(r#"<w:r><w:t xml:space="preserve">{s}</w:t></w:r>"#)
}
fn p(inner: &str) -> String {
format!("<w:p>{inner}</w:p>")
}
fn sdt(pr: &str, content: &str) -> String {
format!("<w:sdt><w:sdtPr>{pr}</w:sdtPr><w:sdtContent>{content}</w:sdtContent></w:sdt>")
}
fn controls(d: &Document) -> Vec<ContentControl> {
let mut out = Vec::new();
wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone()));
out
}
/// How many `w:p` elements are open at byte `at` of `xml`.
fn open_paragraphs(xml: &str, at: usize) -> usize {
let head = &xml[..at];
let opened = head.matches("<w:p>").count() + head.matches("<w:p ").count();
opened - head.matches("</w:p>").count()
}
/// Paragraph depth at the start of the `w:sdt` whose `w:sdtPr` holds `marker`.
fn depth_of_control(xml: &str, marker: &str) -> usize {
let at = xml.find(marker).unwrap_or_else(|| panic!("{marker} not written:\n{xml}"));
let start = xml[..at].rfind("<w:sdt>").expect("control start");
open_paragraphs(xml, start)
}
fn round_trip(body: &str) -> (Document, Document, String) {
let d = read(&package(body)).unwrap();
let bytes = write(&d).unwrap();
let back = read(&bytes).unwrap();
(d, back, document_xml(&bytes))
}
fn repeating_section() -> String {
let field = sdt(r#"<w:alias w:val="note"/><w:id w:val="3"/><w:text/>"#, &t("note 1"));
let item = sdt(r#"<w:alias w:val="item"/><w:id w:val="2"/><w15:repeatingSectionItem/>"#, &p(&format!("{field}{}", t(" "))));
let section = sdt(r#"<w:alias w:val="notes"/><w:id w:val="1"/><w15:repeatingSection/>"#, &item);
format!("{}{section}{}", p(&t("Notes")), p(&t("End")))
}
#[test]
fn a_repeating_section_around_one_paragraph_stays_around_the_paragraph() {
let (_, _, xml) = round_trip(&repeating_section());
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
// The text control stays inside the paragraph, and the item holds that paragraph.
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1);
assert!(xml.contains("<w15:repeatingSectionItem/></w:sdtPr><w:sdtContent><w:p"), "{xml}");
}
#[test]
fn a_repeating_section_round_trips_its_controls_and_text() {
let (d, back, _) = round_trip(&repeating_section());
assert_eq!(controls(&back), controls(&d));
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
assert_eq!(back.body.len(), d.body.len());
}
#[test]
fn plain_nested_block_controls_around_one_paragraph_stay_block_level() {
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="11"/>"#, &p(&t("Only paragraph")));
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="10"/>"#, &inner);
let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After"))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
assert_eq!(controls(&back), controls(&d));
}
#[test]
fn nested_block_controls_in_a_table_cell_stay_block_level() {
let inner = sdt(r#"<w:alias w:val="cellInner"/><w:id w:val="21"/>"#, &p(&t("In cell")));
let outer = sdt(r#"<w:alias w:val="cellOuter"/><w:id w:val="20"/>"#, &inner);
let table = format!(r#"<w:tbl><w:tblGrid><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc>{outer}</w:tc></w:tr></w:tbl>"#);
let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After"))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellOuter"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="cellInner"/>"#), 0, "{xml}");
}
#[test]
fn a_run_level_control_stays_inside_its_paragraph() {
let field = sdt(r#"<w:alias w:val="inline"/><w:id w:val="30"/>"#, &t("value"));
let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: "))));
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inline"/>"#), 1, "{xml}");
}
/// A bound repeating section as Word writes one at the end of a form, optionally followed by
/// the body-level `_GoBack` bookmark Word leaves after the last control.
fn bound_section_body(bound: bool, bookmark: bool) -> String {
let section_binding = if bound {
r#"<w15:dataBinding w:prefixMappings="" w:xpath="/a[1]/n[1]" w:storeItemID="{00000000-0000-0000-0000-0000000000AA}"/>"#
} else {
""
};
let trailing = if bookmark { r#"<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"# } else { "" };
let bind = |x: &str| format!(r#"<w:dataBinding w:prefixMappings="" w:xpath="{x}" w:storeItemID="{{00000000-0000-0000-0000-0000000000AA}}"/>"#);
let field = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="note"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder>{}<w:text/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
if bound { bind("/a[1]/n[1]") } else { String::new() },
t("note 1")
);
let item = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="item"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_2"/></w:placeholder><w15:repeatingSectionItem/></w:sdtPr><w:sdtEndPr/><w:sdtContent>{}</w:sdtContent></w:sdt>"#,
p(&format!("{field}{}", t(" ")))
);
let section = format!(
r#"<w:sdt><w:sdtPr><w:alias w:val="notes"/>{section_binding}<w15:repeatingSection/></w:sdtPr><w:sdtContent>{item}</w:sdtContent></w:sdt>"#
);
format!(r#"{}{section}{trailing}<w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr>"#, p(&t("Notes")))
}
fn assert_section_block_level(body: &str) {
let (d, back, xml) = round_trip(body);
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="notes"/>"#), 0, "section written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="item"/>"#), 0, "item written inside a paragraph:\n{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="note"/>"#), 1, "{xml}");
assert_eq!(controls(&back), controls(&d));
assert_eq!(back.plain_text(StoryRef::Body), d.plain_text(StoryRef::Body));
}
#[test]
fn a_bound_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
assert_section_block_level(&bound_section_body(true, true));
}
#[test]
fn a_bound_repeating_section_at_the_end_of_the_body_stays_block_level() {
assert_section_block_level(&bound_section_body(true, false));
}
#[test]
fn a_repeating_section_followed_by_a_body_bookmark_stays_block_level() {
assert_section_block_level(&bound_section_body(false, true));
}
#[test]
fn a_block_control_followed_by_a_body_bookmark_stays_around_its_paragraph() {
let inner = sdt(r#"<w:alias w:val="inner"/><w:id w:val="41"/>"#, &p(&t("Last paragraph")));
let outer = sdt(r#"<w:alias w:val="outer"/><w:id w:val="40"/>"#, &inner);
let body = format!(r#"{}{outer}<w:bookmarkStart w:id="0" w:name="_GoBack"/><w:bookmarkEnd w:id="0"/>"#, p(&t("First")));
let (d, back, xml) = round_trip(&body);
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="outer"/>"#), 0, "{xml}");
assert_eq!(depth_of_control(&xml, r#"<w:alias w:val="inner"/>"#), 0, "{xml}");
assert_eq!(controls(&back), controls(&d));
// The bookmark is kept.
assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}");
}
+1
View File
@@ -682,3 +682,4 @@ For field results, the engine parser treats a backslash in quoted arguments as e
| 2026-11-03 | trivial | Opening a password-protected file named on the command line shows the Password box also when a chosen region replaced the first blank document (the window followed the replacement only on the first frame, which dropped the box, #5299) |
| 2026-10-27 | trivial | 2026-10-27 · #4533 review: numeric REF updates lazily index body bookmarks once per update, retaining the first body bookmark for duplicate names. Unsupported switches and ordinary REF/PAGEREF behavior are unchanged; a synthetic 64-reference regression covers repeated updates and duplicate names. Estimates unchanged. |
| 2026-10-27 | trivial | 2026-10-27 · #4533: numeric REF fields use paragraph list numbers for no/full/relative context, reusing bounded numbering. Shared context already embedded in a level label is removed; merged-away cells do not count. Unsupported separator/text/position switches and ambiguous contexts or delimiter-free joins retain cached results. Synthetic OOXML regressions; no native Word or real-document validation, estimates unchanged. |
| 2026-11-05 | trivial | DOCX: a block-level content control (including `w15:repeatingSection` / `w15:repeatingSectionItem` nested around one paragraph) followed by body-level bookmark, comment or permission markup (Word's `_GoBack` bookmark) is written back around its paragraph instead of inside it, so Word opens the saved file; the markers are kept at the end of that paragraph (P0069). |