diff --git a/crates/docx/src/write/sdt.rs b/crates/docx/src/write/sdt.rs
index 0f8f5a4a92..1b0f4d401b 100644
--- a/crates/docx/src/write/sdt.rs
+++ b/crates/docx/src/write/sdt.rs
@@ -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()];
diff --git a/crates/docx/tests/it/main.rs b/crates/docx/tests/it/main.rs
index 8a1addee7d..68caa1f5be 100644
--- a/crates/docx/tests/it/main.rs
+++ b/crates/docx/tests/it/main.rs
@@ -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;
diff --git a/crates/docx/tests/it/nested_block_controls.rs b/crates/docx/tests/it/nested_block_controls.rs
new file mode 100644
index 0000000000..30a0c272b2
--- /dev/null
+++ b/crates/docx/tests/it/nested_block_controls.rs
@@ -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#""#;
+
+fn package(body: &str) -> Vec {
+ let doc = format!(r#"{body}"#);
+ 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#"{s}"#)
+}
+fn p(inner: &str) -> String {
+ format!("{inner}")
+}
+fn sdt(pr: &str, content: &str) -> String {
+ format!("{pr}{content}")
+}
+
+fn controls(d: &Document) -> Vec {
+ 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("").count() + head.matches("").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("").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#""#, &t("note 1"));
+ let item = sdt(r#""#, &p(&format!("{field}{}", t(" "))));
+ let section = sdt(r#""#, &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#""#), 0, "section written inside a paragraph:\n{xml}");
+ assert_eq!(depth_of_control(&xml, r#""#), 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#""#), 1);
+ assert!(xml.contains(""#, &p(&t("Only paragraph")));
+ let outer = sdt(r#""#, &inner);
+ let (d, back, xml) = round_trip(&format!("{outer}{}", p(&t("After"))));
+ assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}");
+ assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}");
+ assert_eq!(controls(&back), controls(&d));
+}
+
+#[test]
+fn nested_block_controls_in_a_table_cell_stay_block_level() {
+ let inner = sdt(r#""#, &p(&t("In cell")));
+ let outer = sdt(r#""#, &inner);
+ let table = format!(r#"{outer}"#);
+ let (_, _, xml) = round_trip(&format!("{table}{}", p(&t("After"))));
+ assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}");
+ assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}");
+}
+
+#[test]
+fn a_run_level_control_stays_inside_its_paragraph() {
+ let field = sdt(r#""#, &t("value"));
+ let (_, _, xml) = round_trip(&p(&format!("{}{field}", t("Name: "))));
+ assert_eq!(depth_of_control(&xml, r#""#), 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#""#
+ } else {
+ ""
+ };
+ let trailing = if bookmark { r#""# } else { "" };
+ let bind = |x: &str| format!(r#""#);
+ let field = format!(
+ r#"{}{}"#,
+ if bound { bind("/a[1]/n[1]") } else { String::new() },
+ t("note 1")
+ );
+ let item = format!(
+ r#"{}"#,
+ p(&format!("{field}{}", t(" ")))
+ );
+ let section = format!(
+ r#"{section_binding}{item}"#
+ );
+ format!(r#"{}{section}{trailing}"#, p(&t("Notes")))
+}
+
+fn assert_section_block_level(body: &str) {
+ let (d, back, xml) = round_trip(body);
+ assert_eq!(depth_of_control(&xml, r#""#), 0, "section written inside a paragraph:\n{xml}");
+ assert_eq!(depth_of_control(&xml, r#""#), 0, "item written inside a paragraph:\n{xml}");
+ assert_eq!(depth_of_control(&xml, r#""#), 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#""#, &p(&t("Last paragraph")));
+ let outer = sdt(r#""#, &inner);
+ let body = format!(r#"{}{outer}"#, p(&t("First")));
+ let (d, back, xml) = round_trip(&body);
+ assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}");
+ assert_eq!(depth_of_control(&xml, r#""#), 0, "{xml}");
+ assert_eq!(controls(&back), controls(&d));
+ // The bookmark is kept.
+ assert!(xml.contains(r#"w:name="_GoBack""#), "{xml}");
+}
diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md
index b8296f0511..f0022bfe8b 100644
--- a/docs/file-format-parity.md
+++ b/docs/file-format-parity.md
@@ -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). |