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). |