mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-24 02:17:24 -06:00
* CI: line-table debug info for dev builds, none for dependencies * CI: one integration-test binary per crate * CI: nextest with doctests, test build without examples, one wasm check * CI: the interface audit reports missing catalog entries instead of failing * Docs: the interface audit, not CI as a whole, passes with missing catalog entries
334 lines
18 KiB
Rust
334 lines
18 KiB
Rust
//! Content controls (`w:sdt`) keep their properties, levels and nesting through a round trip.
|
||
//! All markup here is synthetic, written from ECMA-376 §17.5.2 and the [MS-DOCX] `w14`/`w15`
|
||
//! extensions.
|
||
|
||
use std::io::Write;
|
||
|
||
use wordcraft_doc::control::{ControlKind, ControlLock, ListItem};
|
||
use wordcraft_doc::{Block, ContentControl, Document, StoryRef};
|
||
use wordcraft_docx::{read, write};
|
||
|
||
const NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" 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 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 cell(text: &str) -> String {
|
||
format!("<w:tc>{}</w:tc>", p(&t(text)))
|
||
}
|
||
|
||
/// Every kind of control, at every level, with nesting.
|
||
fn form() -> Vec<u8> {
|
||
let block = sdt(
|
||
r#"<w:rPr><w:b/></w:rPr><w:alias w:val="Intro"/><w:tag w:val="intro"/><w:id w:val="-1201"/><w:lock w:val="sdtLocked"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder><w:showingPlcHdr/><w15:appearance w15:val="tags"/>"#,
|
||
&format!("{}{}", p(&t("Write the introduction.")), p(&t("Second paragraph"))),
|
||
);
|
||
let inline = p(&format!(
|
||
"{}{}{}{}{}{}",
|
||
t("Name: "),
|
||
sdt(r#"<w:alias w:val="Name"/><w:id w:val="7"/><w:text w:multiLine="1"/>"#, &t("Ada")),
|
||
t(" Agree: "),
|
||
sdt(
|
||
r#"<w:id w:val="8"/><w14:checkbox><w14:checked w14:val="1"/><w14:checkedState w14:val="2612" w14:font="Symbola"/><w14:uncheckedState w14:val="2610" w14:font="Symbola"/></w14:checkbox>"#,
|
||
&t("\u{2612}")
|
||
),
|
||
sdt(
|
||
r#"<w:id w:val="9"/><w:lock w:val="contentLocked"/><w:dropDownList w:lastValue="b"><w:listItem w:displayText="Alpha" w:value="a"/><w:listItem w:displayText="Beta" w:value="b"/></w:dropDownList>"#,
|
||
&t("Beta")
|
||
),
|
||
sdt(
|
||
r#"<w:id w:val="10"/><w:dataBinding w:xpath="/root/when" w:storeItemID="{00000000-0000-0000-0000-000000000001}"/><w:date w:fullDate="2026-10-11T00:00:00Z"><w:dateFormat w:val="d.M.yyyy"/><w:lid w:val="de-DE"/><w:storeMappedDataAs w:val="dateTime"/><w:calendar w:val="gregorian"/></w:date>"#,
|
||
&t("11.10.2026")
|
||
),
|
||
));
|
||
// A combo box holding a nested rich text control.
|
||
let nested = p(&sdt(
|
||
r#"<w:id w:val="11"/><w:comboBox><w:listItem w:displayText="One" w:value="1"/></w:comboBox>"#,
|
||
&format!("{}{}", t("Pick "), sdt(r#"<w:id w:val="12"/><w:richText/>"#, &t("inner"))),
|
||
));
|
||
let gallery = sdt(r#"<w:id w:val="13"/><w:docPartObj><w:docPartGallery w:val="Cover Pages"/><w:docPartUnique/></w:docPartObj>"#, &p(&t("Cover")));
|
||
// A repeating section around table rows, each row an item; a control around one cell; a
|
||
// picture control holding nothing but a paragraph mark's worth of text.
|
||
let row = |a: &str, b: &str| format!("<w:tr>{}{}</w:tr>", cell(a), cell(b));
|
||
let table = format!(
|
||
"<w:tbl><w:tblGrid><w:gridCol w:w=\"2000\"/><w:gridCol w:w=\"2000\"/></w:tblGrid>{}</w:tbl>",
|
||
sdt(
|
||
r#"<w:id w:val="20"/><w15:repeatingSection><w15:sectionTitle w:val="Items"/></w15:repeatingSection>"#,
|
||
&format!(
|
||
"{}{}",
|
||
sdt(r#"<w:id w:val="21"/><w15:repeatingSectionItem/>"#, &row("x", "y")),
|
||
sdt(
|
||
r#"<w:id w:val="22"/><w15:repeatingSectionItem/>"#,
|
||
&format!("<w:tr>{}{}</w:tr>", cell("z"), sdt(r#"<w:id w:val="23"/><w:picture/>"#, &cell("pic")))
|
||
)
|
||
)
|
||
)
|
||
);
|
||
// A block control whose content is a table.
|
||
let around_table = sdt(
|
||
r#"<w:id w:val="30"/><w:tag w:val="grid"/>"#,
|
||
&format!("<w:tbl><w:tblGrid><w:gridCol w:w=\"2000\"/></w:tblGrid>{}</w:tbl>", format_args!("<w:tr>{}</w:tr>", cell("t"))),
|
||
);
|
||
package(&format!("{block}{inline}{nested}{gallery}{table}{around_table}{}", p(&t("End"))))
|
||
}
|
||
|
||
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 controls(d: &Document) -> Vec<ContentControl> {
|
||
let mut out = Vec::new();
|
||
wordcraft_doc::control::walk_controls(&d.body, &mut |c| out.push(c.clone()));
|
||
out
|
||
}
|
||
|
||
fn by_id(d: &Document, id: i64) -> ContentControl {
|
||
controls(d).into_iter().find(|c| c.id == Some(id)).unwrap_or_else(|| panic!("control {id}"))
|
||
}
|
||
|
||
#[test]
|
||
fn every_kind_reads_with_its_properties() {
|
||
let d = read(&form()).unwrap();
|
||
let intro = controls(&d).into_iter().find(|c| c.title == "Intro").unwrap();
|
||
assert!(intro.block && intro.showing_placeholder);
|
||
assert_eq!((intro.tag.as_str(), intro.id, intro.lock), ("intro", Some(-1201), ControlLock::SdtLocked));
|
||
assert_eq!(intro.placeholder, "DefaultPlaceholder_1");
|
||
assert_eq!(intro.placeholder_text, "Write the introduction.");
|
||
assert!(intro.rpr_xml.contains("w:b"));
|
||
assert_eq!(intro.extra, vec![r#"<w15:appearance w15:val="tags"/>"#.to_string()]);
|
||
let ranges = d.control_ranges(StoryRef::Body);
|
||
let r = ranges.iter().find(|r| r.control.title == "Intro").unwrap();
|
||
assert_eq!(d.control_text(r), "Write the introduction.\nSecond paragraph");
|
||
|
||
assert_eq!(by_id(&d, 7).kind, ControlKind::Text { multi_line: true });
|
||
assert!(!by_id(&d, 7).block);
|
||
assert!(
|
||
matches!(by_id(&d, 8).kind, ControlKind::CheckBox { checked: true, checked_char: '\u{2612}', ref checked_font, .. } if checked_font == "Symbola")
|
||
);
|
||
let dd = by_id(&d, 9);
|
||
assert_eq!(dd.lock, ControlLock::ContentLocked);
|
||
assert_eq!(
|
||
dd.kind,
|
||
ControlKind::DropDown {
|
||
items: vec![ListItem { display: "Alpha".into(), value: "a".into() }, ListItem { display: "Beta".into(), value: "b".into() }],
|
||
last_value: "b".into()
|
||
}
|
||
);
|
||
assert!(
|
||
matches!(by_id(&d, 10).kind, ControlKind::Date { ref full_date, ref format, .. } if full_date == "2026-10-11T00:00:00Z" && format == "d.M.yyyy")
|
||
);
|
||
assert_eq!(by_id(&d, 10).extra.len(), 1, "data binding kept");
|
||
assert_eq!(by_id(&d, 12).kind, ControlKind::RichText);
|
||
assert!(matches!(by_id(&d, 13).kind, ControlKind::Gallery { list: false, ref gallery, unique: true, .. } if gallery == "Cover Pages"));
|
||
assert!(matches!(by_id(&d, 20).kind, ControlKind::RepeatingSection { ref title, .. } if title == "Items"));
|
||
assert_eq!(by_id(&d, 21).kind, ControlKind::RepeatingSectionItem);
|
||
assert_eq!(by_id(&d, 23).kind, ControlKind::Picture);
|
||
// Row-level controls sit on the rows, the cell-level one on its cell, the table's on the table.
|
||
let tables: Vec<_> = d.body.iter().filter_map(|b| b.as_table()).collect();
|
||
assert_eq!(tables.len(), 2);
|
||
let rows = &tables[0].rows;
|
||
assert_eq!(rows[0].controls.open.iter().map(|c| c.id).collect::<Vec<_>>(), vec![Some(20), Some(21)]);
|
||
assert_eq!((rows[0].controls.close, rows[1].controls.close), (1, 2));
|
||
assert_eq!(rows[1].cells[1].controls.open[0].id, Some(23));
|
||
assert_eq!(tables[1].controls.open[0].tag, "grid");
|
||
assert_eq!(tables[1].controls.close, 1);
|
||
// The text reads as before.
|
||
assert!(d.plain_text(StoryRef::Body).contains("Name: Ada Agree: \u{2612}Beta11.10.2026"));
|
||
}
|
||
|
||
#[test]
|
||
fn controls_round_trip() {
|
||
let d = read(&form()).unwrap();
|
||
let bytes = write(&d).unwrap();
|
||
let back = read(&bytes).unwrap();
|
||
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());
|
||
let names: Vec<_> = back.control_ranges(StoryRef::Body).iter().map(|r| (r.control.id, r.start.path.clone(), r.end.path.clone())).collect();
|
||
let before: Vec<_> = d.control_ranges(StoryRef::Body).iter().map(|r| (r.control.id, r.start.path.clone(), r.end.path.clone())).collect();
|
||
assert_eq!(names, before);
|
||
// Written in schema order, with the extension elements the file had.
|
||
let xml = document_xml(&bytes);
|
||
assert!(xml.contains(
|
||
r#"<w:sdtPr><w:rPr><w:b/></w:rPr><w:alias w:val="Intro"/><w:tag w:val="intro"/><w:id w:val="-1201"/><w:lock w:val="sdtLocked"/><w:placeholder><w:docPart w:val="DefaultPlaceholder_1"/></w:placeholder><w:showingPlcHdr/><w15:appearance w15:val="tags"/></w:sdtPr>"#
|
||
));
|
||
assert!(xml.contains(r#"<w14:checkbox><w14:checked w14:val="1"/><w14:checkedState w14:val="2612" w14:font="Symbola"/>"#));
|
||
// The block control is around the paragraphs, the inline ones inside theirs.
|
||
assert!(xml.contains("<w:sdtContent><w:p>"));
|
||
assert!(xml.contains(r#"<w:dataBinding w:xpath="/root/when" w:storeItemID="{00000000-0000-0000-0000-000000000001}"/><w:date"#));
|
||
}
|
||
|
||
#[test]
|
||
fn edited_documents_still_write_whole_controls() {
|
||
let mut d = read(&form()).unwrap();
|
||
// Merge the block control's first paragraph into the one before nothing: delete across the
|
||
// start of the inline name control and the end of the block control.
|
||
let ranges = d.control_ranges(StoryRef::Body);
|
||
let name = ranges.iter().find(|r| r.control.id == Some(7)).unwrap().clone();
|
||
let intro = ranges.iter().find(|r| r.control.title == "Intro").unwrap().clone();
|
||
let from = wordcraft_doc::Pos { off: 3, ..intro.end.clone() };
|
||
let to = wordcraft_doc::Pos { off: name.start.off + 4, ..name.start.clone() };
|
||
d.delete_range(&from, &to).unwrap();
|
||
// Both controls are still there, emptied where the deletion went through them.
|
||
let after = d.control_ranges(StoryRef::Body);
|
||
assert!(after.iter().any(|r| r.control.id == Some(7)));
|
||
assert!(after.iter().any(|r| r.control.title == "Intro"));
|
||
let back = read(&write(&d).unwrap()).unwrap();
|
||
assert_eq!(controls(&back).len(), controls(&d).len());
|
||
// An orphan marker (a table carrying an open that lost its table) is dropped on save.
|
||
if let Some(Block::Table(t)) = d.body.iter_mut().rev().find(|b| b.as_table().is_some()).map(std::sync::Arc::make_mut) {
|
||
t.controls.close = 0;
|
||
}
|
||
assert!(d.has_unbalanced_controls());
|
||
let back = read(&write(&d).unwrap()).unwrap();
|
||
assert!(!back.has_unbalanced_controls());
|
||
}
|
||
|
||
#[test]
|
||
fn deeply_nested_controls_are_bounded() {
|
||
let mut s = t("core");
|
||
for i in 0..400 {
|
||
s = sdt(&format!(r#"<w:id w:val="{i}"/>"#), &s);
|
||
}
|
||
let mut b = p(&t("x"));
|
||
for i in 0..400 {
|
||
b = sdt(&format!(r#"<w:id w:val="{i}"/>"#), &b);
|
||
}
|
||
for body in [p(&s), b] {
|
||
// Too deep for the parser: an error or a document, never a panic or a hang.
|
||
if let Ok(d) = read(&package(&body)) {
|
||
let _ = write(&d).unwrap();
|
||
}
|
||
}
|
||
let mut s = t("core");
|
||
for i in 0..60 {
|
||
s = sdt(&format!(r#"<w:id w:val="{i}"/>"#), &s);
|
||
}
|
||
let d = read(&package(&p(&s))).unwrap();
|
||
let back = read(&write(&d).unwrap()).unwrap();
|
||
assert_eq!(controls(&back).len(), 60);
|
||
}
|
||
|
||
/// A control's `w:rPr` (ECMA-376 §17.5.2.27) is read as the formatting entered text takes, and
|
||
/// still written back as it was.
|
||
#[test]
|
||
fn a_controls_run_properties_are_read_and_kept() {
|
||
let docx = package(&p(&format!(
|
||
"{}{}",
|
||
t("Name: "),
|
||
sdt(r#"<w:rPr><w:rStyle w:val="Eingabe"/><w:sz w:val="20"/></w:rPr><w:id w:val="11"/><w:showingPlcHdr/>"#, &t("Eingabe"))
|
||
)));
|
||
let d = read(&docx).unwrap();
|
||
let c = by_id(&d, 11);
|
||
let rp = c.run_props.expect("run properties read");
|
||
assert_eq!((rp.style.as_deref(), rp.size), (Some("Eingabe"), Some(10.0)));
|
||
let out = document_xml(&write(&d).unwrap());
|
||
assert!(out.contains(r#"<w:rPr><w:rStyle w:val="Eingabe"/><w:sz w:val="20"/></w:rPr>"#), "{out}");
|
||
}
|
||
|
||
/// Word's Table of Contents control (`w:docPartObj`, #2427) around a TOC the engine's
|
||
/// heading/entry form doesn't cover (no `TOCHeading`, unstyled entries, the field ending in a
|
||
/// paragraph of its own) is kept as read: nothing rebuilds it on save.
|
||
#[test]
|
||
fn toc_gallery_around_a_free_form_toc_round_trips() {
|
||
let pr = r#"<w:sdtPr><w:id w:val="-1234567"/><w:docPartObj><w:docPartGallery w:val="Table of Contents"/><w:docPartUnique/></w:docPartObj></w:sdtPr><w:sdtEndPr/><w:sdtContent>"#;
|
||
let field = format!(
|
||
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>{}<w:r><w:tab/></w:r>{}"#,
|
||
t("Chapter One"),
|
||
t("1")
|
||
);
|
||
let end = r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
|
||
let body = format!("<w:sdt>{pr}{}{}{}</w:sdtContent></w:sdt>{}", p(&t("Contents")), p(&field), p(end), p(&t("Chapter One")));
|
||
let mut bytes = package(&body);
|
||
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(), 1, "pass {pass}: {xml}");
|
||
let open = xml.find(pr).unwrap_or_else(|| panic!("pass {pass}: {xml}"));
|
||
let close = xml.find("</w:sdtContent></w:sdt>").unwrap();
|
||
let (contents, fend) = (xml.find(">Contents<").unwrap(), xml.find(r#"w:fldCharType="end""#).unwrap());
|
||
assert!(open < contents && fend < close, "pass {pass}: {xml}");
|
||
assert!(close < xml.rfind(">Chapter One<").unwrap(), "the body paragraph stays outside: {xml}");
|
||
}
|
||
}
|
||
|
||
/// The control around a TOC in the engine's form (a `TOCHeading`, then TOC-styled entries the
|
||
/// field spans) still goes to the TOC: written once, around the heading and the entries, and the
|
||
/// field opens in the heading as References › Update Table expects.
|
||
#[test]
|
||
fn toc_gallery_around_the_engine_form_goes_to_the_toc() {
|
||
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!(
|
||
"{}{}{}",
|
||
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();
|
||
assert!(controls(&d).is_empty(), "pass {pass}");
|
||
let heading = d.body[0].as_para().unwrap();
|
||
assert!(heading.objects.iter().any(|o| matches!(o, wordcraft_doc::InlineObject::Field { instr, .. } if instr.trim().starts_with("TOC"))));
|
||
bytes = write(&d).unwrap();
|
||
let xml = document_xml(&bytes);
|
||
assert_eq!(xml.matches("<w:sdt>").count(), 1, "pass {pass}: {xml}");
|
||
assert!(xml.contains(r#"<w:docPartGallery w:val="Table of Contents"/>"#), "pass {pass}: {xml}");
|
||
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}");
|
||
}
|
||
}
|