Files
wordcraft/crates/docx/tests/fixtures.rs
T
Brandon ThomasandClaude Opus 5.5 99561097d5 DOCX: a list level without a list takes its style chain's list (#926)
Found in NIH's monitoring-plan template, compared with Word for Windows.

Before: a w:numPr that gives a level (w:ilvl) but no list (w:numId) was
dropped. Outline-numbered templates do this all the time: Heading 3 is
based on Heading 2 (which names the list) and only says "level 3", so
every Heading 3 numbered as a Heading 2 (6.15 where Word shows 6.4.5).

After: such a level takes the list of the nearest style in its based-on
chain that names one (ECMA-376 17.3.1.19), in styles and in paragraphs;
with no list anywhere in the chain there is no numbering. Saved files
carry an ordinary list reference.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 09:37:38 -04:00

1885 lines
124 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Hand-written minimal WordprocessingML fixtures (written for these tests from the spec).
use std::io::Write;
use wordcraft_doc::para::{Anchor, FloatAlign, NoteKind, ShapeKind, Wrap};
use wordcraft_doc::props::{Align, Border, BorderStyle, Rgb, TextColor, VMerge};
use wordcraft_doc::{Block, Document, InlineObject, Paragraph, RevisionKind};
const W_NS: &str = r#"xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:wpg="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office""#;
const ROOT_RELS: &str = r#"<?xml version="1.0" encoding="UTF-8"?><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>"#;
pub fn zip(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut zw = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, bytes) in entries {
zw.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zw.write_all(bytes).unwrap();
}
zw.finish().unwrap().into_inner()
}
fn rels(list: &[(&str, &str, &str)]) -> String {
let mut s = String::from(r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#);
for (id, ty, target) in list {
let ext = if target.starts_with("http") { r#" TargetMode="External""# } else { "" };
s.push_str(&format!(
r#"<Relationship Id="{id}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/{ty}" Target="{target}"{ext}/>"#
));
}
s.push_str("</Relationships>");
s
}
/// A package with `body` as the document body and extra (path, content) parts.
fn docx(body: &str, doc_rels: &[(&str, &str, &str)], extra: &[(&str, &str)]) -> Vec<u8> {
let doc = format!(r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let r = rels(doc_rels);
let mut entries: Vec<(&str, &[u8])> =
vec![("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes()), ("word/_rels/document.xml.rels", r.as_bytes())];
for (p, c) in extra {
entries.push((p, c.as_bytes()));
}
zip(&entries)
}
fn read_body(body: &str) -> Document {
wordcraft_docx::read(&docx(body, &[], &[])).unwrap()
}
#[test]
fn paragraph_default_presence_survives_repeated_saves() {
// Omission leaves paragraph defaults application-defined (ISO 29500 pPrDefault).
// An explicitly empty container must therefore remain distinct from omission.
for (defaults, present, after) in [
("", false, None),
("<w:docDefaults/>", false, None),
("<w:docDefaults><w:rPrDefault><w:rPr><w:sz w:val=\"22\"/></w:rPr></w:rPrDefault></w:docDefaults>", false, None),
("<w:docDefaults><w:pPrDefault/></w:docDefaults>", true, None),
("<w:docDefaults><w:pPrDefault><w:pPr/></w:pPrDefault></w:docDefaults>", true, None),
("<w:docDefaults><w:pPrDefault><w:pPr><w:spacing w:after=\"160\"/></w:pPr></w:pPrDefault></w:docDefaults>", true, Some(8.0)),
] {
let styles = format!(
r#"<w:styles {W_NS}>{defaults}<w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#
);
let mut d = wordcraft_docx::read(&docx(
"<w:p><w:r><w:t>First</w:t></w:r></w:p><w:p><w:r><w:t>Second</w:t></w:r></w:p>",
&[("rId1", "styles", "styles.xml")],
&[("word/styles.xml", &styles)],
))
.unwrap();
for _ in 0..2 {
let bytes = wordcraft_docx::write(&d).unwrap();
let xml = text(&unzip(&bytes), "word/styles.xml");
assert_eq!(xml.contains("<w:pPrDefault>"), present, "{defaults}");
d = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(d.styles.default_para.space_after, after);
assert_eq!(paras(&d).iter().map(|p| p.text.as_str()).collect::<Vec<_>>(), ["First", "Second"]);
}
}
}
#[test]
fn omitted_paragraph_defaults_can_be_edited() {
let styles =
format!(r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style></w:styles>"#);
let mut d = wordcraft_docx::read(&docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
assert!(!d.styles.default_para_present);
d.styles.default_para.space_after = Some(12.0);
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert!(again.styles.default_para_present);
assert_eq!(again.styles.default_para.space_after, Some(12.0));
let new = Document::new();
assert_eq!(wordcraft_docx::read(&wordcraft_docx::write(&new).unwrap()).unwrap().styles.default_para, new.styles.default_para);
}
fn paras(d: &Document) -> Vec<&Paragraph> {
d.body.iter().filter_map(|b| b.as_para()).collect()
}
#[test]
fn single_entry_toc_keeps_formatted_result_and_nested_page_field() {
let d = read_body(
r#"
<w:p><w:pPr><w:pStyle w:val="TOCHeading"/></w:pPr><w:r><w:t>Contents</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr>
<w:r><w:fldChar w:fldCharType="begin" w:fldLock="true"/></w:r>
<w:r><w:instrText> TOC \o "1-1" \h </w:instrText></w:r>
<w:r><w:fldChar w:fldCharType="separate"/></w:r>
<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:b/></w:rPr><w:t>Intro</w:t></w:r>
<w:r><w:tab/></w:r><w:fldSimple w:instr=" PAGEREF _Toc1 \h "><w:r><w:t>1</w:t></w:r></w:fldSimple></w:hyperlink>
<w:r><w:fldChar w:fldCharType="end"/></w:r>
</w:p>"#,
);
let p = paras(&d);
assert_eq!(p[0].plain_text(), "Contents");
assert_eq!(p[0].objects, vec![InlineObject::Field { instr: r#"TOC \o "1-1" \h"#.into(), result: String::new(), locked: true, code: false }]);
assert_eq!(p[1].plain_text(), "Intro\t1");
assert_eq!(p[1].props_of_char(0).bold, Some(true));
assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc1"));
assert_eq!(p[1].objects, vec![InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false }]);
}
#[test]
fn toc_complex_field_spanning_paragraphs() {
let body = r#"
<w:p><w:pPr><w:pStyle w:val="TOC1"/></w:pPr>
<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:hyperlink w:anchor="_Toc1"><w:r><w:t>Intro</w:t></w:r><w:r><w:tab/></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> PAGEREF _Toc1 \h </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>1</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:hyperlink>
</w:p>
<w:p><w:hyperlink w:anchor="_Toc2"><w:r><w:t>Body</w:t></w:r><w:r><w:tab/></w:r><w:r><w:t>2</w:t></w:r></w:hyperlink></w:p>
<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>
<w:p><w:bookmarkStart w:id="0" w:name="_Toc1"/><w:r><w:t>Intro</w:t></w:r><w:bookmarkEnd w:id="0"/></w:p>
<w:p><w:r><w:t xml:space="preserve">Page </w:t></w:r><w:fldSimple w:instr=" PAGE "><w:r><w:t>7</w:t></w:r></w:fldSimple></w:p>"#;
let d = read_body(body);
let p = paras(&d);
assert_eq!(p.len(), 5);
assert_eq!(p[0].text, "\u{FFFC}Intro\t\u{FFFC}");
assert_eq!(p[0].objects[0], InlineObject::FieldStart { instr: r#"TOC \o "1-3" \h \z \u"#.into(), locked: false });
assert_eq!(p[0].objects[1], InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: false, code: false });
assert_eq!(p[0].props_of_char(3).link.as_deref(), Some("#_Toc1"));
assert_eq!(p[0].props.style.as_deref(), Some("TOC1"));
assert_eq!(p[1].text, "Body\t2");
assert_eq!(p[1].props_of_char(0).link.as_deref(), Some("#_Toc2"));
assert_eq!(p[2].objects, vec![InlineObject::FieldEnd]);
assert_eq!(p[3].objects, vec![InlineObject::BookmarkStart { name: "_Toc1".into() }, InlineObject::BookmarkEnd { name: "_Toc1".into() }]);
assert_eq!(p[4].plain_text(), "Page 7");
assert_eq!(p[4].objects[0], InlineObject::Field { instr: "PAGE".into(), result: "7".into(), locked: false, code: false });
// And it writes back out and reads the same.
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(again.body, d.body);
}
#[test]
fn hyperlink_runs_and_wrappers() {
let body = r#"
<w:p>
<w:hyperlink r:id="rId5" w:history="1"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>site</w:t></w:r></w:hyperlink>
<w:hyperlink r:id="rId5" w:anchor="frag"><w:r><w:t>X</w:t></w:r></w:hyperlink>
<w:smartTag><w:r><w:t>A</w:t></w:r></w:smartTag><w:customXml><w:r><w:t>B</w:t></w:r></w:customXml>
<w:sdt><w:sdtPr/><w:sdtContent><w:r><w:t>C</w:t></w:r></w:sdtContent></w:sdt>
<w:r><w:sym w:font="Wingdings" w:char="F04A"/><w:noBreakHyphen/><w:softHyphen/><w:cr/><w:br w:type="page"/><w:br w:type="column"/><w:t xml:space="preserve"> e&amp;</w:t></w:r>
<w:proofErr w:type="spellStart"/><w:r><w:rPr><w:b w:val="false"/><w:i w:val="0"/><w:caps w:val="off"/><w:strike w:val="true"/><w:color w:val="auto"/></w:rPr><w:t>T</w:t></w:r>
</w:p>
<w:sdt><w:sdtContent><w:p><w:r><w:t>in block sdt</w:t></w:r></w:p></w:sdtContent></w:sdt>"#;
let bytes = docx(body, &[("rId5", "hyperlink", "https://example.org/")], &[]);
let d = wordcraft_docx::read(&bytes).unwrap();
let p = paras(&d);
// The content control around "C" is kept (zero-width markers in the text).
assert_eq!(p[0].plain_text(), "siteXABC\u{F04A}\u{2011}\u{AD}\n\u{C}\u{E} e&T");
assert!(matches!(p[0].objects.first(), Some(InlineObject::ControlStart { .. })));
assert_eq!(p[0].props_of_char(0).link.as_deref(), Some("https://example.org/"));
assert_eq!(p[0].props_of_char(0).style.as_deref(), Some("Hyperlink"));
assert_eq!(p[0].props_of_char(4).link.as_deref(), Some("https://example.org/#frag"));
let sym_off = p[0].text.find('\u{F04A}').unwrap();
assert_eq!(p[0].props_of_char(sym_off).font.as_deref(), Some("Wingdings"));
let t = p[0].props_of_char(p[0].text.len() - 1);
assert_eq!((t.bold, t.italic, t.caps, t.strike, t.color), (Some(false), Some(false), Some(false), Some(true), Some(TextColor::Auto)));
assert_eq!(p[1].plain_text(), "in block sdt");
}
#[test]
fn alternate_content_choice_and_fallback() {
let body = r#"
<w:p><w:r><mc:AlternateContent>
<mc:Choice Requires="wps"><w:drawing><wp:anchor behindDoc="0" distL="12700"><wp:positionH relativeFrom="page"><wp:posOffset>127000</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="914400" cy="457200"/><wp:wrapSquare wrapText="bothSides"/><wp:docPr id="1" name="Text Box 1"/>
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:cNvSpPr txBox="1"/><wps:spPr><a:prstGeom prst="rect"/></wps:spPr><wps:txbx><w:txbxContent><w:p><w:r><w:t>boxed</w:t></w:r></w:p></w:txbxContent></wps:txbx><wps:bodyPr/></wps:wsp></a:graphicData></a:graphic>
</wp:anchor></w:drawing></mc:Choice>
<mc:Fallback><w:pict><v:shape style="width:72pt;height:36pt"><v:textbox><w:txbxContent><w:p><w:r><w:t>old</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape></w:pict></mc:Fallback>
</mc:AlternateContent></w:r></w:p>
<w:p><mc:AlternateContent><mc:Choice Requires="w99"><w:r><w:t>future</w:t></w:r></mc:Choice><mc:Fallback><w:r><w:t>fallback text</w:t></w:r></mc:Fallback></mc:AlternateContent></w:p>"#;
let d = read_body(body);
let p = paras(&d);
match &p[0].objects[0] {
InlineObject::Shape { kind, w, h, float, story, .. } => {
assert_eq!((*kind, *w, *h), (ShapeKind::TextBox, 72.0, 36.0));
assert_eq!(float.wrap, Wrap::Square);
assert_eq!(float.x, 10.0);
assert_eq!(float.dist, 1.0);
let s = d.parts.get(&story.unwrap()).unwrap();
assert_eq!(s.blocks[0].as_para().unwrap().text, "boxed");
}
o => panic!("{o:?}"),
}
assert_eq!(p[1].text, "fallback text");
}
#[test]
fn table_look_bitmask_and_legacy_merges() {
let body = r#"
<w:tbl>
<w:tblPr><w:tblStyle w:val="TableGrid"/><w:tblW w:w="5000" w:type="pct"/><w:jc w:val="center"/><w:tblLook w:val="04A0"/></w:tblPr>
<w:tblGrid><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/><w:gridCol w:w="2000"/></w:tblGrid>
<w:tr><w:tc><w:tcPr><w:hMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>wide</w:t></w:r></w:p></w:tc><w:tc><w:tcPr><w:hMerge/></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:vMerge w:val="restart"/><w:shd w:val="clear" w:fill="FF0000"/></w:tcPr><w:p/></w:tc></w:tr>
<w:tr><w:trPr><w:trHeight w:val="400"/></w:trPr><w:tc><w:tcPr><w:gridSpan w:val="2"/></w:tcPr><w:p/></w:tc><w:sdt><w:sdtContent><w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc></w:sdtContent></w:sdt></w:tr>
</w:tbl>
<w:tbl><w:tblPr><w:tblLook w:firstRow="0" w:lastRow="1" w:firstColumn="1" w:lastColumn="0" w:noHBand="1" w:noVBand="0"/></w:tblPr><w:tr><w:tc><w:p/></w:tc></w:tr></w:tbl>
<w:p/>"#;
let d = read_body(body);
let Block::Table(t) = &*d.body[0] else { panic!() };
assert_eq!(t.props.width_pct, Some(100.0));
assert_eq!(t.props.align, Some(Align::Center));
let l = t.props.look;
assert!(l.header_row && l.first_column && l.banded_rows && !l.banded_columns && !l.total_row && !l.last_column);
assert_eq!(t.rows[0].cells.len(), 2);
assert_eq!(t.rows[0].cells[0].span(), 2);
assert_eq!(t.rows[0].cells[1].props.vmerge, VMerge::Restart);
assert_eq!(t.rows[1].cells[1].props.vmerge, VMerge::Continue);
assert_eq!(t.rows[1].props.height, Some(20.0));
assert_eq!(t.grid, vec![100.0, 100.0, 100.0]);
let Block::Table(t2) = &*d.body[1] else { panic!() };
let l = t2.props.look;
assert!(!l.header_row && l.total_row && l.first_column && !l.last_column && !l.banded_rows && l.banded_columns);
}
#[test]
fn styles_numbering_settings_notes_comments_from_parts() {
let styles = format!(
r#"<w:styles {W_NS}>
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:asciiTheme="minorHAnsi" w:hAnsiTheme="minorHAnsi"/><w:sz w:val="22"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="160" w:line="259" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="a"><w:name w:val="Normal"/><w:qFormat/></w:style>
<w:style w:type="paragraph" w:styleId="1"><w:name w:val="heading 1"/><w:basedOn w:val="a"/><w:next w:val="a"/><w:uiPriority w:val="9"/><w:qFormat/><w:pPr><w:keepNext/><w:outlineLvl w:val="0"/></w:pPr><w:rPr><w:rFonts w:asciiTheme="majorHAnsi"/><w:b/><w:sz w:val="32"/></w:rPr></w:style>
<w:style w:type="paragraph" w:customStyle="1" w:styleId="Fancy"><w:name w:val="Fancy"/><w:basedOn w:val="Fancy"/></w:style>
<w:style w:type="table" w:styleId="Grid"><w:name w:val="Grid"/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="4"/></w:tblBorders></w:tblPr><w:tblStylePr w:type="firstRow"><w:rPr><w:b/></w:rPr><w:tcPr><w:shd w:val="clear" w:fill="4472C4"/></w:tcPr></w:tblStylePr></w:style>
</w:styles>"#
);
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:clrScheme name="T"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:accent1><a:srgbClr val="4472C4"/></a:accent1></a:clrScheme><a:fontScheme name="T"><a:majorFont><a:latin typeface="Major Face"/></a:majorFont><a:minorFont><a:latin typeface="Minor Face"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
let numbering = format!(
r#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="7"><w:name w:val="L"/><w:lvl w:ilvl="0"><w:start w:val="3"/><w:numFmt w:val="upperLetter"/><w:lvlText w:val="%1)"/><w:lvlJc w:val="right"/><w:pPr><w:ind w:left="720" w:hanging="360"/></w:pPr></w:lvl><w:lvl w:ilvl="1"><w:numFmt w:val="bullet"/><w:suff w:val="space"/><w:lvlText w:val="o"/><w:rPr><w:rFonts w:ascii="Courier New"/></w:rPr></w:lvl></w:abstractNum><w:num w:numId="2"><w:abstractNumId w:val="7"/><w:lvlOverride w:ilvl="0"><w:startOverride w:val="5"/></w:lvlOverride></w:num></w:numbering>"#
);
let settings = format!(
r#"<w:settings {W_NS}><w:trackRevisions/><w:defaultTabStop w:val="708"/><w:evenAndOddHeaders w:val="1"/><w:footnotePr><w:pos w:val="sideways"/><w:numFmt w:val="lowerRoman"/><w:numStart w:val="-99999999999"/><w:numRestart w:val="eachPage"/></w:footnotePr></w:settings>"#
);
let footnotes = format!(
r#"<w:footnotes {W_NS}><w:footnote w:type="separator" w:id="-1"><w:p><w:r><w:separator/></w:r></w:p></w:footnote><w:footnote w:id="1"><w:p><w:r><w:footnoteRef/></w:r><w:r><w:t xml:space="preserve"> Note text</w:t></w:r></w:p></w:footnote></w:footnotes>"#
);
let comments = format!(
r#"<w:comments {W_NS}><w:comment w:id="5" w:author="Zed" w:initials="Z" w:date="2026-01-01T00:00:00Z"><w:p><w:r><w:annotationRef/></w:r><w:r><w:t>Look</w:t></w:r></w:p></w:comment></w:comments>"#
);
let header = format!(r#"<w:hdr {W_NS}><w:p><w:r><w:t>H</w:t></w:r></w:p></w:hdr>"#);
let core = r#"<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/"><dc:title>Doc Title</dc:title><cp:revision>3</cp:revision></cp:coreProperties>"#;
let body = r#"
<w:p><w:pPr><w:pStyle w:val="1"/><w:numPr><w:ilvl w:val="1"/><w:numId w:val="2"/></w:numPr></w:pPr><w:commentRangeStart w:id="5"/><w:r><w:rPr><w:rFonts w:asciiTheme="majorHAnsi"/></w:rPr><w:t>Heading</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:commentReference w:id="5"/></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="a"/></w:pPr><w:ins w:id="1" w:author="I" w:date="2026-01-01T00:00:00Z"><w:r><w:t>new</w:t></w:r></w:ins><w:del w:id="2" w:author="I"><w:r><w:delText>old</w:delText></w:r></w:del></w:p>
<w:sectPr><w:headerReference w:type="default" r:id="rId9"/><w:pgSz w:w="16838" w:h="11906" w:orient="landscape"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="708" w:footer="708" w:gutter="0"/><w:cols w:space="708"/><w:titlePg w:val="0"/></w:sectPr>"#;
let doc_rels = [
("rId1", "styles", "styles.xml"),
("rId2", "theme", "theme/theme1.xml"),
("rId3", "numbering", "numbering.xml"),
("rId4", "settings", "/word/settings.xml"),
("rId5", "footnotes", "footnotes.xml"),
("rId6", "comments", "comments.xml"),
("rId9", "header", "header1.xml"),
];
let root_rels = 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"/><Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/></Relationships>"#;
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let r = rels(&doc_rels);
let bytes = zip(&[
("_rels/.rels", root_rels.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", r.as_bytes()),
("word/styles.xml", styles.as_bytes()),
("word/theme/theme1.xml", theme.as_bytes()),
("word/numbering.xml", numbering.as_bytes()),
("word/settings.xml", settings.as_bytes()),
("word/footnotes.xml", footnotes.as_bytes()),
("word/comments.xml", comments.as_bytes()),
("word/header1.xml", header.as_bytes()),
("docProps/core.xml", core.as_bytes()),
]);
let d = wordcraft_docx::read(&bytes).unwrap();
// Styles: localized default id renamed to Normal; display names mapped.
let h = d.styles.get("1").unwrap();
assert_eq!(h.name, "Heading 1");
assert_eq!(h.based_on.as_deref(), Some("Normal"));
assert!(h.builtin && h.quick);
assert_eq!(h.chr.font.as_deref(), Some("Major Face"));
assert!(d.styles.get("Normal").is_some());
assert_eq!(d.styles.get("Fancy").unwrap().based_on, None, "self-reference dropped");
assert!(!d.styles.get("Fancy").unwrap().builtin);
assert_eq!(d.styles.default_chr.font.as_deref(), Some("Minor Face"));
assert_eq!(d.styles.default_chr.size, Some(11.0));
let g = d.styles.get("Grid").unwrap().table.as_ref().unwrap();
assert_eq!(g.header_fill, Some(wordcraft_doc::Rgb(0x44, 0x72, 0xC4)));
assert_eq!(g.header_chr.bold, Some(true));
assert_eq!(d.settings.theme_colors[4], wordcraft_doc::Rgb(0x44, 0x72, 0xC4));
assert_eq!(d.settings.major_font, "Major Face");
// Numbering.
let lv = d.numbering.level(2, 0).unwrap();
assert_eq!((lv.start, lv.text.as_str(), lv.indent, lv.hanging, lv.align), (3, "%1)", 36.0, 18.0, Align::Right));
let lv1 = d.numbering.level(2, 1).unwrap();
assert_eq!(lv1.chr.font.as_deref(), Some("Courier New"));
assert_eq!(lv1.suffix, wordcraft_doc::numbering::LevelSuffix::Space);
assert_eq!(d.numbering.num(2).unwrap().start_overrides, vec![(0, 5)]);
// Settings.
assert!(d.settings.track_changes && d.settings.even_odd_headers);
assert_eq!(d.settings.default_tab, 35.4);
assert_eq!(d.settings.footnote_format, wordcraft_doc::section::NumFormat::LowerRoman);
// A hostile start is clamped, an unknown position left out.
let fp = d.settings.footnote_pr;
assert_eq!((fp.pos, fp.num_start, fp.num_restart), (None, Some(1), Some(wordcraft_doc::section::NoteRestart::EachPage)));
// Body.
let p = paras(&d);
assert_eq!(p[0].props.style.as_deref(), Some("1"));
assert_eq!(p[0].props.numbering, Some(wordcraft_doc::props::NumRef { num: 2, level: 1 }));
assert_eq!(p[0].props_of_char(4).font.as_deref(), Some("Major Face"));
match &p[0].objects[..] {
[InlineObject::CommentStart { id: 5 }, InlineObject::NoteRef { kind: NoteKind::Footnote, id, .. }, InlineObject::CommentEnd { id: 5 }] => {
let note = d.parts.get(id).unwrap().blocks[0].as_para().unwrap();
assert_eq!(note.text, format!("{} Note text", wordcraft_doc::para::OBJ));
assert_eq!(note.objects, vec![InlineObject::NoteRef { kind: NoteKind::Footnote, id: *id, custom: String::new() }]);
}
o => panic!("{o:?}"),
}
let c = d.comments.get(&5).unwrap();
assert_eq!((c.author.as_str(), c.initials.as_str()), ("Zed", "Z"));
assert_eq!(d.parts.get(&c.part).unwrap().blocks[0].as_para().unwrap().text, "Look");
assert_eq!(p[1].props.style.as_deref(), Some("Normal"));
assert_eq!(p[1].text, "newold");
assert_eq!(d.revisions.len(), 2);
assert_eq!(p[1].props_of_char(0).ins, Some(0));
assert_eq!(p[1].props_of_char(3).del, Some(1));
// Section + header.
let s = &d.last_section;
assert!(s.landscape);
assert_eq!((s.page_w, s.page_h), (841.9, 595.3));
assert!(!s.title_page);
let hid = s.headers.default.unwrap();
assert_eq!(d.parts.get(&hid).unwrap().blocks[0].as_para().unwrap().text, "H");
assert_eq!(d.core.title, "Doc Title");
assert_eq!(d.core.revision, 3);
}
/// A table style's own pPr/rPr, its `wholeTable` region (which covers every cell) and its table
/// shading and cell margins; a derived style keeps only what it sets itself.
#[test]
fn table_style_whole_table_shading_and_margins() {
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="table" w:styleId="Base"><w:name w:val="Base"/><w:pPr><w:spacing w:after="0" w:line="240" w:lineRule="auto"/></w:pPr><w:tblPr><w:shd w:val="clear" w:color="auto" w:fill="EEEEEE"/><w:tblCellMar><w:top w:w="20" w:type="dxa"/><w:left w:w="288" w:type="dxa"/><w:bottom w:w="0" w:type="dxa"/><w:right w:w="288" w:type="dxa"/></w:tblCellMar></w:tblPr></w:style>
<w:style w:type="table" w:styleId="Whole"><w:name w:val="Whole"/><w:basedOn w:val="Base"/><w:rPr><w:b/></w:rPr><w:tblStylePr w:type="wholeTable"><w:pPr><w:jc w:val="center"/></w:pPr><w:rPr><w:color w:val="1F4E79"/></w:rPr><w:tcPr><w:shd w:val="clear" w:color="auto" w:fill="DDEBF7"/></w:tcPr></w:tblStylePr><w:tblStylePr w:type="bogus"/><w:tblStylePr/></w:style>
</w:styles>"#
);
let bytes = docx("<w:p/>", &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)]);
let d = wordcraft_docx::read(&bytes).unwrap();
let base = d.styles.get("Base").unwrap();
let parts = base.table.as_ref().unwrap();
assert_eq!(parts.fill, Some(wordcraft_doc::Rgb(0xEE, 0xEE, 0xEE)));
assert_eq!(parts.cell_margins, Some([1.0, 14.4, 0.0, 14.4]));
let whole = d.styles.get("Whole").unwrap();
assert_eq!(whole.para.align, Some(Align::Center));
assert_eq!(whole.chr.bold, Some(true));
assert_eq!(whole.chr.color, Some(TextColor::Rgb(wordcraft_doc::Rgb(0x1F, 0x4E, 0x79))));
assert_eq!(whole.table.as_ref().unwrap().fill, Some(wordcraft_doc::Rgb(0xDD, 0xEB, 0xF7)));
assert_eq!(whole.table.as_ref().unwrap().cell_margins, None);
let merged = d.styles.table_style("Whole").unwrap();
assert_eq!(merged.para.space_after, Some(0.0), "from Base");
assert_eq!(merged.para.align, Some(Align::Center));
assert_eq!(merged.parts.fill, Some(wordcraft_doc::Rgb(0xDD, 0xEB, 0xF7)));
assert_eq!(merged.parts.cell_margins, Some([1.0, 14.4, 0.0, 14.4]), "from Base");
}
/// Word starts every note with a `w:footnoteRef` / `w:endnoteRef` mark: the note's own number.
/// It reads as a reference to the note itself, so the note text shows its number.
#[test]
fn note_marks_reference_their_own_note() {
let note = |tag: &str, mark: &str, style: &str, id: u32, text: &str| {
format!(
r#"<w:{tag} w:id="{id}"><w:p><w:pPr><w:pStyle w:val="{style}Text"/></w:pPr><w:r><w:rPr><w:rStyle w:val="{style}Reference"/></w:rPr><w:{mark}/></w:r><w:r><w:t xml:space="preserve"> {text}</w:t></w:r></w:p></w:{tag}>"#
)
};
let sep = |tag: &str| {
format!(
r#"<w:{tag} w:type="separator" w:id="-1"><w:p><w:r><w:separator/></w:r></w:p></w:{tag}><w:{tag} w:type="continuationSeparator" w:id="0"><w:p><w:r><w:continuationSeparator/></w:r></w:p></w:{tag}>"#
)
};
// The second footnote has a stray endnote mark, which is not its number.
let footnotes = format!(
r#"<w:footnotes {W_NS}>{}{}<w:footnote w:id="2"><w:p><w:r><w:endnoteRef/><w:t>Stray</w:t></w:r></w:p></w:footnote>{}</w:footnotes>"#,
sep("footnote"),
note("footnote", "footnoteRef", "Footnote", 1, "First."),
note("footnote", "footnoteRef", "Footnote", 3, "Third."),
);
let endnotes = format!(r#"<w:endnotes {W_NS}>{}{}</w:endnotes>"#, sep("endnote"), note("endnote", "endnoteRef", "Endnote", 1, "End."));
// A mark outside a note (here in the body) refers to nothing.
let body = r#"<w:p><w:r><w:footnoteRef/><w:t>A</w:t></w:r><w:r><w:footnoteReference w:id="1"/></w:r><w:r><w:footnoteReference w:id="2"/></w:r><w:r><w:footnoteReference w:id="3"/></w:r><w:r><w:endnoteReference w:id="1"/></w:r></w:p>"#;
let bytes = docx(
body,
&[("rId1", "footnotes", "footnotes.xml"), ("rId2", "endnotes", "endnotes.xml")],
&[("word/footnotes.xml", &footnotes), ("word/endnotes.xml", &endnotes)],
);
let d = wordcraft_docx::read(&bytes).unwrap();
let p = paras(&d)[0];
assert_eq!(p.text.chars().filter(|c| *c == wordcraft_doc::para::OBJ).count(), 4, "{:?}", p.objects);
let refs: Vec<(NoteKind, u32)> = p
.objects
.iter()
.map(|o| match o {
InlineObject::NoteRef { kind, id, .. } => (*kind, *id),
o => panic!("{o:?}"),
})
.collect();
let first_para = |id: u32| d.parts.get(&id).unwrap().blocks[0].as_para().unwrap();
for (kind, id) in [refs[0], refs[2], refs[3]] {
let n = first_para(id);
assert_eq!(n.objects, vec![InlineObject::NoteRef { kind, id, custom: String::new() }], "note {id}");
assert!(n.text.starts_with(wordcraft_doc::para::OBJ), "{:?}", n.text);
let style = if kind == NoteKind::Footnote { "FootnoteReference" } else { "EndnoteReference" };
assert_eq!(n.props_of_char(0).style.as_deref(), Some(style));
}
assert_eq!(first_para(refs[0].1).plain_text(), " First.");
assert_eq!(first_para(refs[3].1).plain_text(), " End.");
let stray = first_para(refs[1].1);
assert!(stray.objects.is_empty(), "{:?}", stray.objects);
assert_eq!(stray.text, "Stray");
}
#[test]
fn empty_tbl_borders_elements_leave_sides_unset() {
// Style TS: single borders on all six sides. Document: table styled TS with an empty
// paired <w:tblBorders></w:tblBorders> and empty <w:tcBorders></w:tcBorders> per cell.
// (Hand-written from ECMA-376 §17.4.39/§17.4.43; do not use files produced by Word.)
let styles = format!(
r#"<w:styles {W_NS}><w:style w:type="table" w:styleId="TS"><w:name w:val="TS"/><w:tblPr><w:tblBorders><w:top w:val="single" w:sz="2" w:color="000000"/><w:left w:val="single" w:sz="2" w:color="000000"/><w:bottom w:val="single" w:sz="2" w:color="000000"/><w:right w:val="single" w:sz="2" w:color="000000"/><w:insideH w:val="single" w:sz="2" w:color="000000"/><w:insideV w:val="single" w:sz="2" w:color="000000"/></w:tblBorders></w:tblPr></w:style></w:styles>"#
);
let body = r#"<w:tbl><w:tblPr><w:tblStyle w:val="TS"/><w:tblBorders></w:tblBorders></w:tblPr><w:tblGrid><w:gridCol w:w="4000"/><w:gridCol w:w="4000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:tcBorders></w:tcBorders></w:tcPr><w:p/></w:tc><w:tc><w:tcPr><w:tcBorders></w:tcBorders></w:tcPr><w:p/></w:tc></w:tr></w:tbl>"#;
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let t = d.body.iter().find_map(|b| b.as_table()).unwrap();
assert_eq!(t.props.borders, Some(wordcraft_doc::props::Borders::default()));
assert_eq!(t.rows[0].cells[0].props.borders, Some(wordcraft_doc::props::Borders::default()));
let parts = d.styles.get("TS").unwrap().table.clone().unwrap();
let b = parts.borders.unwrap();
assert!(b.top.is_some() && b.between.is_some() && b.inside_v.is_some());
}
#[test]
fn vml_image_and_inline_drawing() {
let png: &[u8] = b"\x89PNG\r\n\x1a\nnot really a png";
let body = r#"
<w:p><w:r><w:pict><v:shape style="width:1in;height:36pt" alt="vml pic"><v:imagedata r:id="rIdImg" o:title="t"/></v:shape></w:pict></w:r>
<w:r><w:drawing><wp:inline><wp:extent cx="1270000" cy="635000"/><wp:docPr id="3" name="P" descr="alt text"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdImg"/><a:srcRect l="10000"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>
<w:r><w:drawing><wp:inline><wp:extent cx="1" cy="1"/><a:graphic><a:graphicData><pic:pic><pic:blipFill><a:blip r:embed="rIdMissing"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let r = rels(&[("rIdImg", "image", "media/pic.png"), ("rIdMissing", "image", "media/nope.png")]);
let bytes = zip(&[
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", r.as_bytes()),
("word/media/pic.png", png),
]);
let d = wordcraft_docx::read(&bytes).unwrap();
let p = paras(&d);
assert_eq!(p[0].objects.len(), 2);
match (&p[0].objects[0], &p[0].objects[1]) {
(InlineObject::Image { media: m1, w: w1, h: h1, alt: a1, .. }, InlineObject::Image { media: m2, w: w2, h: h2, alt: a2, crop, .. }) => {
assert_eq!((m1.as_str(), *w1, *h1, a1.as_str()), ("pic.png", 72.0, 36.0, "vml pic"));
assert_eq!((m2.as_str(), *w2, *h2, a2.as_str()), ("pic.png", 100.0, 50.0, "alt text"));
assert_eq!(crop[0], 0.1);
}
o => panic!("{o:?}"),
}
assert_eq!(d.media.get("pic.png").unwrap().as_slice(), png);
}
#[test]
fn chart_and_diagram_drawings_are_graphic_objects() {
// A diagram's data holds a blip: the URI decides first, so no picture is read from it.
let drawing = |uri: &str, data: &str| {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1828800"/><wp:docPr id="5" name="G" descr="sales"/><a:graphic><a:graphicData uri="{uri}">{data}</a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
};
let body = format!(
"<w:p>{}{}</w:p>",
drawing("http://schemas.openxmlformats.org/drawingml/2006/chart", ""),
drawing("http://schemas.openxmlformats.org/drawingml/2006/diagram", r#"<a:blip r:embed="rIdImg"/>"#)
);
let d = read_body(&body);
let p = paras(&d);
assert_eq!(p[0].objects.len(), 2);
match (&p[0].objects[0], &p[0].objects[1]) {
(InlineObject::Graphic { w: w1, h: h1, alt, graphic: g1, .. }, InlineObject::Graphic { graphic: g2, .. }) => {
assert_eq!((*w1, *h1, alt.as_str()), (216.0, 144.0, "sales"));
assert_eq!(g1.kind, wordcraft_doc::graphic::GraphicKind::Chart);
assert_eq!(g2.kind, wordcraft_doc::graphic::GraphicKind::Diagram);
assert!(g1.items.is_empty() && g2.items.is_empty());
}
o => panic!("{o:?}"),
}
}
#[test]
fn a_chart_part_is_built_once_and_only_through_a_chart_relationship() {
let chart = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart></c:chartSpace>"#;
let drawing = |id: &str| {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1828800"/><wp:docPr id="5" name="G"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="{id}"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
};
let body = format!("<w:p>{}{}{}</w:p>", drawing("rIdChart"), drawing("rIdChart"), drawing("rIdNotChart"));
let bytes = docx(
&body,
&[("rIdChart", "chart", "charts/chart1.xml"), ("rIdNotChart", "image", "charts/chart1.xml")],
&[("word/charts/chart1.xml", chart)],
);
let d = wordcraft_docx::read(&bytes).unwrap();
let graphics: Vec<_> = paras(&d)[0]
.objects
.iter()
.filter_map(|o| if let InlineObject::Graphic { graphic, .. } = o { Some(graphic.clone()) } else { None })
.collect();
assert_eq!(graphics.len(), 3);
assert!(!graphics[0].items.is_empty(), "the chart is drawn");
assert!(std::sync::Arc::ptr_eq(&graphics[0], &graphics[1]), "one build for both references");
assert!(graphics[2].items.is_empty(), "an image relationship is not a chart");
}
#[test]
fn strict_namespace_and_bad_numbers() {
let doc = r#"<w:document xmlns:w="http://purl.oclc.org/ooxml/wordprocessingml/main"><w:body>
<w:p><w:pPr><w:jc w:val="end"/><w:ind w:start="1in" w:hanging="abc"/><w:spacing w:before="-50" w:line="99999999999999999999" w:lineRule="exact"/><w:outlineLvl w:val="300"/><w:numPr><w:numId w:val="-4"/></w:numPr></w:pPr>
<w:r><w:rPr><w:sz w:val="NaN"/><w:w w:val="999999"/><w:position w:val="-1e30"/><w:color w:val="GGGGGG"/><w:highlight w:val="mauve"/><w:u w:val="squiggle"/></w:rPr><w:t>strict</w:t></w:r></w:p>
<w:tbl><w:tblGrid><w:gridCol w:w="-5"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:gridSpan w:val="99999"/></w:tcPr><w:p/></w:tc></w:tr></w:tbl>
<w:sectPr><w:pgSz w:w="0" w:h="x"/><w:cols w:num="-3"/><w:pgNumType w:start="-1" w:fmt="klingon"/></w:sectPr>
</w:body></w:document>"#;
let bytes = zip(&[("_rels/.rels", ROOT_RELS.as_bytes()), ("word/document.xml", doc.as_bytes())]);
let d = wordcraft_docx::read(&bytes).unwrap();
let p = paras(&d);
assert_eq!(p[0].text, "strict");
assert_eq!(p[0].props.align, Some(Align::Right));
assert_eq!(p[0].props.indent_left, Some(72.0));
assert_eq!(p[0].props.space_before, Some(0.0));
assert_eq!(p[0].props.outline_level, Some(9));
let c = p[0].props_of_char(0);
assert_eq!(c.size, None);
assert_eq!(c.scale, Some(600.0));
assert_eq!(c.color, None);
let Block::Table(t) = &*d.body[1] else { panic!() };
assert_eq!(t.rows[0].cells[0].span(), 63);
assert_eq!(d.last_section.page_w, 612.0);
assert_eq!(d.last_section.columns.count, 1);
// The document can be written back.
wordcraft_docx::write(&d).unwrap();
}
#[test]
fn unbalanced_fields_and_markers() {
let body = r#"
<w:p><w:r><w:fldChar w:fldCharType="end"/></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>a</w:t></w:r><w:bookmarkEnd w:id="77"/><w:commentRangeEnd w:id="3"/></w:p>
<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>IF </w:instrText></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>MERGEFIELD x</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>X</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r><w:r><w:instrText> = "y"</w:instrText></w:r></w:p>
<w:bookmarkStart w:id="1" w:name="between"/>
<w:p><w:r><w:t>after</w:t></w:r></w:p>
<w:bookmarkEnd w:id="1"/>"#;
let d = read_body(body);
let p = paras(&d);
assert_eq!(p[0].text, "a");
assert_eq!(p[1].objects, vec![InlineObject::Field { instr: "IF X = \"y\"".into(), result: String::new(), locked: false, code: false }]);
assert_eq!(p[2].objects, vec![InlineObject::BookmarkStart { name: "between".into() }, InlineObject::BookmarkEnd { name: "between".into() }]);
assert_eq!(p[2].plain_text(), "after");
}
#[test]
fn header_self_reference_and_escaping_targets() {
let hdr = format!(
r#"<w:hdr {W_NS}><w:p><w:pPr><w:sectPr><w:headerReference w:type="default" r:id="rId1"/></w:sectPr></w:pPr><w:r><w:t>loop</w:t></w:r></w:p></w:hdr>"#
);
let hdr_rels = rels(&[("rId1", "header", "header1.xml")]);
let body = r#"<w:p><w:r><w:drawing><wp:inline><a:graphic><a:graphicData><pic:pic><pic:blipFill><a:blip r:embed="rIdEvil"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/><w:headerReference w:type="even" r:id="rIdH"/><w:footerReference w:type="first" r:id="rIdNone"/></w:sectPr>"#;
let doc = format!(r#"<w:document {W_NS}><w:body>{body}</w:body></w:document>"#);
let r = rels(&[("rIdH", "header", "header1.xml"), ("rIdEvil", "image", "../../../../etc/passwd"), ("rIdNone", "footer", "missing.xml")]);
let bytes = zip(&[
("_rels/.rels", ROOT_RELS.as_bytes()),
("word/document.xml", doc.as_bytes()),
("word/_rels/document.xml.rels", r.as_bytes()),
("word/header1.xml", hdr.as_bytes()),
("word/_rels/header1.xml.rels", hdr_rels.as_bytes()),
]);
let d = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(d.last_section.headers.default, d.last_section.headers.even);
assert!(d.media.is_empty());
let out = wordcraft_docx::write(&d).unwrap();
wordcraft_docx::read(&out).unwrap();
}
/// A document whose settings part is `settings` (None: no settings part at all).
fn with_settings(settings: Option<&str>) -> Document {
let body = "<w:p><w:r><w:t>x</w:t></w:r></w:p>";
match settings {
None => read_body(body),
Some(inner) => {
let s = format!(r#"<w:settings {W_NS}>{inner}</w:settings>"#);
wordcraft_docx::read(&docx(body, &[("rId1", "settings", "settings.xml")], &[("word/settings.xml", &s)])).unwrap()
}
}
}
#[test]
fn compatibility_mode_is_read_and_defaults_to_word_2007() {
let compat = |v: &str| {
format!(r#"<w:compat><w:compatSetting w:name="compatibilityMode" w:uri="http://schemas.microsoft.com/office/word" w:val="{v}"/></w:compat>"#)
};
assert_eq!(with_settings(Some(&compat("15"))).settings.compat_mode, 15);
assert_eq!(with_settings(Some(&compat("14"))).settings.compat_mode, 14);
// Unnamed, out of range or junk values.
assert_eq!(with_settings(Some("<w:compat/>")).settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
assert_eq!(with_settings(None).settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
assert_eq!(with_settings(Some(&compat("-3"))).settings.compat_mode, 11);
assert_eq!(with_settings(Some(&compat("99999"))).settings.compat_mode, 99);
assert_eq!(with_settings(Some(&compat("abc"))).settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
// Another vendor's setting of the same name is ignored.
let other = r#"<w:compat><w:compatSetting w:name="compatibilityMode" w:uri="urn:example" w:val="15"/></w:compat>"#;
assert_eq!(with_settings(Some(other)).settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
}
#[test]
fn line_rule_is_read_case_insensitively() {
// Word accepts `atleast` as well as the spec's `atLeast`.
let d = read_body(
r#"<w:p><w:pPr><w:spacing w:line="280" w:lineRule="atleast"/></w:pPr><w:r><w:t>x</w:t></w:r></w:p><w:p><w:pPr><w:spacing w:line="300" w:lineRule="EXACT"/></w:pPr></w:p>"#,
);
let p = paras(&d);
assert_eq!(p[0].props.line_spacing, Some(wordcraft_doc::props::LineSpacing::AtLeast(14.0)));
assert_eq!(p[1].props.line_spacing, Some(wordcraft_doc::props::LineSpacing::Exactly(15.0)));
}
#[test]
fn char_border_reads_and_nil_resolves_off() {
let d = read_body(
r#"<w:p><w:r><w:rPr><w:bdr w:val="single" w:sz="8" w:space="1" w:color="FF0000"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:bdr w:val="nil"/></w:rPr><w:t>b</w:t></w:r></w:p>"#,
);
let p = paras(&d)[0];
assert_eq!(p.props_of_char(0).border, Some(Border { style: BorderStyle::Single, width: 1.0, color: Some(Rgb(0xFF, 0, 0)), space: 1.0 }));
let nil = p.props_of_char(1);
assert_eq!(nil.border.map(|b| b.style), Some(BorderStyle::None));
assert_eq!(d.styles.resolve_char(None, nil).border, None);
}
#[test]
fn anchor_alignment_reference_areas_and_distances() {
let shape = |pos: &str, dist: &str| {
format!(
r#"<w:r><w:drawing><wp:anchor behindDoc="0" {dist}>{pos}<wp:extent cx="127000" cy="127000"/><wp:wrapTopAndBottom/><wp:docPr id="1" name="S"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp><wps:spPr><a:prstGeom prst="ellipse"/></wps:spPr></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></w:r>"#
)
};
let body = format!(
"<w:p>{}{}</w:p>",
shape(
r#"<wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="topMargin"><wp:posOffset>-12700</wp:posOffset></wp:positionV>"#,
r#"distT="25400" distB="50800" distL="0" distR="114300""#
),
shape(
r#"<wp:positionH relativeFrom="leftMargin"><wp:posOffset>63500</wp:posOffset></wp:positionH><wp:positionV relativeFrom="line"><wp:align>bottom</wp:align></wp:positionV>"#,
""
),
);
let d = read_body(&body);
let p = paras(&d);
let floats: Vec<_> =
p[0].objects.iter().filter_map(|o| if let InlineObject::Shape { float, .. } = o { Some(float.clone()) } else { None }).collect();
let [a, b] = floats.as_slice() else { panic!("{floats:?}") };
assert_eq!((a.h_rel, a.h_align, a.v_rel, a.v_align, a.y), (Anchor::Margin, Some(FloatAlign::Center), Anchor::TopMargin, None, -1.0));
assert_eq!((a.dist, a.dist_top, a.dist_bottom), (9.0, 2.0, 4.0));
assert_eq!((b.h_rel, b.h_align, b.x, b.v_rel, b.v_align), (Anchor::LeftMargin, None, 5.0, Anchor::Line, Some(FloatAlign::End)));
assert_eq!((b.dist, b.dist_top, b.dist_bottom), (0.0, 0.0, 0.0));
}
#[test]
fn documents_without_a_compatibility_mode_are_laid_out_as_word_2007() {
assert_eq!(read_body("<w:p/>").settings.compat_mode, wordcraft_doc::LEGACY_COMPAT_MODE);
}
#[test]
fn style_rfonts_without_ascii_inherits_doc_defaults_font() {
// Issue #93: Normal names only East Asian / complex-script fonts, so Latin text keeps the
// docDefaults font. A run hinted as East Asian still uses its East Asian font.
let styles = format!(
r#"<w:styles {W_NS}>
<w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="Calibri" w:eastAsia="Calibri" w:hAnsi="Calibri" w:cs="Calibri"/></w:rPr></w:rPrDefault><w:pPrDefault/></w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/><w:qFormat/><w:rPr><w:rFonts w:eastAsia="Times New Roman" w:cs="Times New Roman"/></w:rPr></w:style>
</w:styles>"#
);
let body = r#"<w:p><w:r><w:t>Plain</w:t></w:r></w:p><w:p><w:r><w:rPr><w:rFonts w:hint="eastAsia" w:eastAsia="SimSun"/></w:rPr><w:t>漢字</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
assert_eq!(d.styles.get("Normal").unwrap().chr.font, None);
let ps = paras(&d);
let font = |p: &Paragraph| d.styles.resolve_char(p.props.style.as_deref(), &p.runs[0].props).font;
assert_eq!(font(ps[0]), "Calibri");
assert_eq!(font(ps[1]), "SimSun");
}
#[test]
fn bare_break_directly_in_paragraph() {
let body = r#"<w:p><w:r><w:t>First</w:t></w:r><w:br/><w:r><w:t>Second</w:t></w:r><w:cr/><w:br w:type="page"/><w:r><w:t>Third</w:t></w:r></w:p>"#;
let d = read_body(body);
assert_eq!(paras(&d)[0].text, "First\nSecond\n\u{C}Third");
let out = wordcraft_docx::write(&d).unwrap();
let d2 = wordcraft_docx::read(&out).unwrap();
assert_eq!(paras(&d2)[0].text, "First\nSecond\n\u{C}Third");
}
/// Issue #149: an absolutely positioned VML text box in a header floats where its style puts it
/// (relative to the page here, behind the text at a negative z-index, no wrapping) instead of
/// sitting inline in the header's flow, and it keeps that placement when saved and read back.
#[test]
fn absolute_vml_text_box_in_header_floats() {
let w10 = r#"xmlns:w10="urn:schemas-microsoft-com:office:word""#;
let boxes = r##"<w:p><w:r><w:pict><v:shape id="Box" type="#_x0000_t202" style="position:absolute;left:0;margin-left:0pt;margin-top:80pt;width:405pt;height:491.4pt;z-index:-1;mso-position-horizontal-relative:page;mso-position-vertical-relative:page"><v:textbox><w:txbxContent><w:p><w:r><w:t>WATERMARK</w:t></w:r></w:p></w:txbxContent></v:textbox><w10:wrap type="none"/></v:shape></w:pict></w:r></w:p>
<w:p><w:r><w:pict><v:rect style="position:absolute;margin-left:12pt;margin-top:-6pt;width:1in;height:36pt;z-index:3;mso-position-horizontal:center;mso-position-horizontal-relative:margin;mso-position-vertical-relative:top-margin-area;mso-wrap-distance-left:9pt;mso-wrap-distance-bottom:4pt"><v:textbox><w:txbxContent><w:p><w:r><w:t>square</w:t></w:r></w:p></w:txbxContent></v:textbox><w10:wrap type="square"/></v:rect></w:pict></w:r>
<w:r><w:pict><v:shape style="position:absolute;margin-left:1in;margin-top:2pt;width:72pt;height:20pt;z-index:5"><v:textbox><w:txbxContent><w:p><w:r><w:t>front</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r>
<w:r><w:pict><v:shape style="width:72pt;height:20pt"><v:textbox><w:txbxContent><w:p><w:r><w:t>inline</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>""##;
let header = format!(r#"<w:hdr {W_NS} {w10}><w:p><w:r><w:t>Header label</w:t></w:r></w:p>{boxes}</w:hdr>"#);
let body = r#"<w:p><w:r><w:t>Body</w:t></w:r></w:p><w:sectPr><w:headerReference w:type="default" r:id="rIdH"/></w:sectPr>"#;
let bytes = docx(body, &[("rIdH", "header", "header1.xml")], &[("word/header1.xml", &header)]);
let check = |d: &Document| {
let hid = d.last_section.headers.default.unwrap();
let blocks = &d.parts.get(&hid).unwrap().blocks;
let shapes: Vec<_> = blocks
.iter()
.filter_map(|b| b.as_para())
.flat_map(|p| &p.objects)
.filter_map(|o| {
if let InlineObject::Shape { kind, w, h, float, story, .. } = o { Some((*kind, *w, *h, float.clone(), *story)) } else { None }
})
.collect();
let [(k0, w0, h0, f0, s0), (_, w1, h1, f1, _), (_, _, _, f2, _), (_, _, _, f3, _)] = shapes.as_slice() else { panic!("{shapes:?}") };
assert_eq!(*k0, ShapeKind::TextBox);
assert!((w0 - 405.0).abs() < 0.01 && (h0 - 491.4).abs() < 0.01, "{w0} x {h0}");
assert_eq!((f0.wrap, f0.h_rel, f0.v_rel, f0.h_align, f0.v_align), (Wrap::BehindText, Anchor::Page, Anchor::Page, None, None));
assert!(f0.x.abs() < 0.01 && (f0.y - 80.0).abs() < 0.01, "offset {} {}", f0.x, f0.y);
let text = d.parts.get(&s0.unwrap()).unwrap().blocks[0].as_para().unwrap().text.clone();
assert_eq!(text, "WATERMARK");
assert_eq!((*w1, *h1), (72.0, 36.0));
assert_eq!(
(f1.wrap, f1.h_rel, f1.h_align, f1.v_rel, f1.v_align),
(Wrap::Square, Anchor::Margin, Some(FloatAlign::Center), Anchor::TopMargin, None)
);
assert!((f1.y + 6.0).abs() < 0.01 && (f1.dist - 9.0).abs() < 0.01 && (f1.dist_bottom - 4.0).abs() < 0.01, "{f1:?}");
// No wrap element: in front of the text, relative to the column and paragraph.
assert_eq!((f2.wrap, f2.h_rel, f2.v_rel), (Wrap::InFrontOfText, Anchor::Column, Anchor::Paragraph));
assert!((f2.x - 72.0).abs() < 0.01 && (f2.y - 2.0).abs() < 0.01, "{f2:?}");
// Not absolutely positioned: still inline.
assert_eq!(f3.wrap, Wrap::Inline);
};
let d = wordcraft_docx::read(&bytes).unwrap();
check(&d);
// Saved (as DrawingML anchors) and read back, the boxes keep their placement.
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
#[test]
fn hostile_vml_style_values_stay_finite() {
let body = r#"<w:p><w:r><w:pict><v:shape style="position:ABSOLUTE;margin-left:1e39pt;left:-1e39pt;margin-top:NaNpt;top:;width:-5pt;height:1e30in;z-index:99999999999999999999999;mso-position-horizontal:bogus;mso-wrap-distance-left:-3pt;mso-wrap-distance-top:1e9pt;;:;position"><v:textbox><w:txbxContent><w:p/></w:txbxContent></v:textbox></v:shape></w:pict></w:r></w:p>"#;
let d = read_body(body);
let p = paras(&d);
let Some(InlineObject::Shape { w, h, float, .. }) = p[0].objects.first() else { panic!("{:?}", p[0].objects) };
assert_eq!(float.wrap, Wrap::InFrontOfText);
assert!([*w, *h, float.x, float.y, float.dist, float.dist_top].iter().all(|v| v.is_finite()), "{w} {h} {float:?}");
assert_eq!((float.h_align, float.dist, float.dist_top), (None, 0.0, 1584.0));
}
// ---- charts and OLE objects written back (#319) ----
const CHART_XML: &str = r#"<c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><c:chart><c:plotArea><c:barChart><c:ser><c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>1</c:v></c:pt><c:pt idx="1"><c:v>2</c:v></c:pt></c:numLit></c:val></c:ser></c:barChart></c:plotArea></c:chart><c:externalData r:id="rId1"><c:autoUpdate val="0"/></c:externalData></c:chartSpace>"#;
/// Stands in for the chart's workbook: opaque bytes, never opened.
const WORKBOOK: &str = "PK\u{3}\u{4} not really a workbook";
const COLORS_XML: &str = r#"<cs:colorStyle xmlns:cs="http://schemas.microsoft.com/office/drawing/2012/chartStyle" meth="cycle" id="10"/>"#;
const CT_CHART: &str = "application/vnd.openxmlformats-officedocument.drawingml.chart+xml";
const CT_XLSX: &str = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
/// A package holding one inline chart (3 × 1.5 in) with its workbook and colour style parts.
fn chart_docx() -> Vec<u8> {
let body = r#"<w:p><w:r><w:t>Sales</w:t></w:r><w:r><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="Chart 1"/><wp:cNvGraphicFramePr/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="rId7"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p>"#;
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/package" Target="../embeddings/Microsoft_Excel_Worksheet.xlsx"/><Relationship Id="rId2" Type="http://schemas.microsoft.com/office/2011/relationships/chartColorStyle" Target="colors1.xml"/></Relationships>"#;
let types = format!(
r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Default Extension="xlsx" ContentType="{CT_XLSX}"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/><Override PartName="/word/charts/chart1.xml" ContentType="{CT_CHART}"/><Override PartName="/word/charts/colors1.xml" ContentType="application/vnd.ms-office.chartcolorstyle+xml"/></Types>"#
);
docx(
body,
&[("rId7", "chart", "charts/chart1.xml")],
&[
("[Content_Types].xml", &types),
("word/charts/chart1.xml", CHART_XML),
("word/charts/_rels/chart1.xml.rels", chart_rels),
("word/charts/colors1.xml", COLORS_XML),
("word/embeddings/Microsoft_Excel_Worksheet.xlsx", WORKBOOK),
],
)
}
/// The entries of a zip, by name.
fn unzip(bytes: &[u8]) -> std::collections::BTreeMap<String, Vec<u8>> {
use std::io::Read;
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut out = std::collections::BTreeMap::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut b = Vec::new();
f.read_to_end(&mut b).unwrap();
out.insert(f.name().to_string(), b);
}
out
}
fn text(files: &std::collections::BTreeMap<String, Vec<u8>>, name: &str) -> String {
String::from_utf8(files.get(name).unwrap_or_else(|| panic!("no {name} in {:?}", files.keys())).clone()).unwrap()
}
/// Values of attribute `attr` on the elements named `el` (in written order).
fn attr_values(xml: &str, el: &str, attr: &str) -> Vec<String> {
let start = |s: &str| format!(" {}", s.split('>').next().unwrap_or(""));
xml.split(&format!("<{el} "))
.skip(1)
.filter_map(|s| start(s).split(&format!(" {attr}=\"")).nth(1)?.split('"').next().map(str::to_string))
.collect()
}
/// (type, target) of relationship `id` in a relationships part.
fn rel(rels: &str, id: &str) -> (String, String) {
let r = rels.split("<Relationship ").find(|r| r.contains(&format!("Id=\"{id}\""))).unwrap_or_else(|| panic!("no {id} in {rels}"));
let get = |a: &str| r.split(&format!("{a}=\"")).nth(1).unwrap().split('"').next().unwrap().to_string();
(get("Type"), get("Target"))
}
/// Package part a relationship target of `from_dir` names.
fn part_at(from_dir: &str, target: &str) -> String {
let mut parts: Vec<&str> = from_dir.split('/').filter(|s| !s.is_empty()).collect();
for seg in target.split('/') {
match seg {
".." => {
parts.pop();
}
"." | "" => {}
s => parts.push(s),
}
}
parts.join("/")
}
/// Checks a written chart: its part and the parts it relates to are all in the package, with the
/// original bytes and content types. Returns the chart's part name.
fn check_chart(files: &std::collections::BTreeMap<String, Vec<u8>>, rid: &str) -> String {
let (kind, target) = rel(&text(files, "word/_rels/document.xml.rels"), rid);
assert!(kind.ends_with("/chart"), "{kind}");
let chart = part_at("word", &target);
assert_eq!(text(files, &chart), CHART_XML);
let (dir, file) = chart.rsplit_once('/').unwrap();
let chart_rels = text(files, &format!("{dir}/_rels/{file}.rels"));
let (kind, wb) = rel(&chart_rels, "rId1");
assert!(kind.ends_with("/package"), "{kind}");
let wb = part_at(dir, &wb);
assert_eq!(files.get(&wb).map(Vec::as_slice), Some(WORKBOOK.as_bytes()), "{wb}");
let (kind, colors) = rel(&chart_rels, "rId2");
assert!(kind.ends_with("/chartColorStyle"), "{kind}");
assert_eq!(text(files, &part_at(dir, &colors)), COLORS_XML);
let types = text(files, "[Content_Types].xml");
assert!(types.contains(&format!(r#"PartName="/{chart}" ContentType="{CT_CHART}""#)), "{types}");
assert!(types.contains(&format!(r#"PartName="/{wb}" ContentType="{CT_XLSX}""#)), "{types}");
chart
}
fn graphics(d: &Document) -> Vec<InlineObject> {
paras(d).iter().flat_map(|p| p.objects.iter()).filter(|o| matches!(o, InlineObject::Graphic { .. })).cloned().collect()
}
#[test]
fn chart_round_trips_with_its_parts() {
let d = wordcraft_docx::read(&chart_docx()).unwrap();
let out = wordcraft_docx::write(&d).unwrap();
let files = unzip(&out);
let body = text(&files, "word/document.xml");
let ids = attr_values(&body, "c:chart", "r:id");
assert_eq!(ids.len(), 1, "{body}");
assert_eq!(check_chart(&files, &ids[0]), "word/charts/chart1.xml");
assert_eq!(attr_values(&body, "wp:extent", "cx"), ["2743200"]);
// Read back: still drawn, and a second save still carries it.
let back = wordcraft_docx::read(&out).unwrap();
let g = graphics(&back);
let [InlineObject::Graphic { w, graphic, .. }] = g.as_slice() else { panic!("{g:?}") };
assert_eq!(*w, 216.0);
assert!(!graphic.items.is_empty() && graphic.source.is_some());
let again = unzip(&wordcraft_docx::write(&back).unwrap());
let ids = attr_values(&text(&again, "word/document.xml"), "c:chart", "r:id");
check_chart(&again, &ids[0]);
}
#[test]
fn moved_and_copied_chart_keeps_its_parts() {
let d = wordcraft_docx::read(&chart_docx()).unwrap();
let g = graphics(&d);
let [InlineObject::Graphic { alt, graphic, .. }] = g.as_slice() else { panic!("{g:?}") };
// Resized and dragged to float on the page in WordCraft, then copied and pasted.
let float = wordcraft_doc::para::Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 36.0, y: 18.0, ..Default::default() };
let moved = InlineObject::Graphic { w: 288.0, h: 144.0, alt: alt.clone(), float, graphic: graphic.clone() };
let mut p = Paragraph::new();
p.insert_object(0, moved.clone(), &Default::default()).unwrap();
p.insert_object(p.len(), moved, &Default::default()).unwrap();
let mut doc = d.clone();
doc.body = vec![wordcraft_doc::para_block(p)];
let out = wordcraft_docx::write(&doc).unwrap();
let files = unzip(&out);
let body = text(&files, "word/document.xml");
assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"], "the new size");
assert_eq!(body.matches("<wp:posOffset>457200</wp:posOffset>").count(), 2, "the new position");
let ids = attr_values(&body, "c:chart", "r:id");
assert_eq!(ids.len(), 2);
let charts: Vec<String> = ids.iter().map(|id| check_chart(&files, id)).collect();
assert_ne!(charts[0], charts[1], "each copy has its own chart part");
let back = wordcraft_docx::read(&out).unwrap();
let g = graphics(&back);
assert_eq!(g.len(), 2);
for o in &g {
let InlineObject::Graphic { w, float, graphic, .. } = o else { panic!() };
assert_eq!((*w, float.wrap, float.x), (288.0, Wrap::Square, 36.0));
assert!(!graphic.items.is_empty() && graphic.source.is_some());
}
}
#[test]
fn ole_object_round_trips() {
let body = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt" o:ole=""><v:imagedata r:id="rId8" o:title=""/></v:shape><o:OLEObject Type="Embed" ProgID="Package" ShapeID="_x0000_i1025" DrawAspect="Content" ObjectID="_1000000001" r:id="rId9"/></w:object></w:r></w:p>"##;
let picture = "\u{89}PNG stand-in";
let ole = "\u{d0}\u{cf} opaque OLE storage";
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/vnd.openxmlformats-officedocument.oleObject"/></Types>"#;
let bytes = docx(
body,
&[("rId8", "image", "media/image1.png"), ("rId9", "oleObject", "embeddings/oleObject1.bin")],
&[("[Content_Types].xml", types), ("word/media/image1.png", picture), ("word/embeddings/oleObject1.bin", ole)],
);
let d = wordcraft_docx::read(&bytes).unwrap();
let Some(InlineObject::Image { w, ole: Some(_), .. }) = paras(&d)[0].objects.first() else { panic!("{:?}", paras(&d)[0].objects) };
assert_eq!(*w, 72.0);
// Resized in WordCraft.
let mut doc = d.clone();
if let Some(wordcraft_doc::Block::Para(p)) = doc.body.first_mut().map(std::sync::Arc::make_mut)
&& let Some(InlineObject::Image { w, h, .. }) = p.objects.first_mut()
{
(*w, *h) = (144.0, 72.0);
}
let out = wordcraft_docx::write(&doc).unwrap();
let files = unzip(&out);
let body = text(&files, "word/document.xml");
let rels = text(&files, "word/_rels/document.xml.rels");
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:144pt;height:72pt"]);
let [ole_id] = attr_values(&body, "o:OLEObject", "r:id").try_into().unwrap();
let (kind, target) = rel(&rels, &ole_id);
assert!(kind.ends_with("/oleObject"), "{kind}");
let ole_part = part_at("word", &target);
assert_eq!(files.get(&ole_part).map(Vec::as_slice), Some(ole.as_bytes()));
assert!(
text(&files, "[Content_Types].xml")
.contains(&format!(r#"PartName="/{ole_part}" ContentType="application/vnd.openxmlformats-officedocument.oleObject""#))
);
let [pic_id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap();
let (kind, target) = rel(&rels, &pic_id);
assert!(kind.ends_with("/image"), "{kind}");
assert_eq!(files.get(&part_at("word", &target)).map(Vec::as_slice), Some(picture.as_bytes()));
assert!(!body.contains("<w:drawing>"), "the object, not a plain picture");
let back = wordcraft_docx::read(&out).unwrap();
assert!(matches!(paras(&back)[0].objects.first(), Some(InlineObject::Image { w: 144.0, ole: Some(_), .. })));
}
#[test]
fn hostile_embedded_relationships_stay_bounded() {
// The chart relates to itself, the main document, a relationships part and a missing part;
// another drawing uses an id with no relationship. Reading and saving neither loop nor carry
// those along, and the chart is still written back.
let chart_rels = r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="x/package" Target="chart1.xml"/><Relationship Id="rId2" Type="x/a" Target="../document.xml"/><Relationship Id="rId3" Type="x/b" Target="../_rels/document.xml.rels"/><Relationship Id="rId4" Type="x/c" Target="gone.bin"/><Relationship Id="rId4" Type="x/d" Target="chart1.xml"/></Relationships>"#;
let drawing = |id: &str| {
format!(
r#"<w:r><w:drawing><wp:inline><wp:extent cx="2743200" cy="1371600"/><wp:docPr id="1" name="C"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/chart"><c:chart xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" r:id="{id}"/></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
)
};
let body = format!("<w:p>{}{}</w:p>", drawing("rId7"), drawing("rIdNowhere"));
let bytes = docx(
&body,
&[("rId7", "chart", "charts/chart1.xml")],
&[("word/charts/chart1.xml", CHART_XML), ("word/charts/_rels/chart1.xml.rels", chart_rels)],
);
let d = wordcraft_docx::read(&bytes).unwrap();
let g = graphics(&d);
assert_eq!(g.len(), 2);
let sources: Vec<bool> = g.iter().map(|o| matches!(o, InlineObject::Graphic { graphic, .. } if graphic.source.is_some())).collect();
assert_eq!(sources, [true, false], "a dangling id can't be written back");
let files = unzip(&wordcraft_docx::write(&d).unwrap());
let body = text(&files, "word/document.xml");
let [id] = attr_values(&body, "c:chart", "r:id").try_into().unwrap();
let chart = part_at("word", &rel(&text(&files, "word/_rels/document.xml.rels"), &id).1);
let rels = text(&files, "word/charts/_rels/chart1.xml.rels");
assert_eq!(rel(&rels, "rId1").1, "chart1.xml", "its relationship to itself, to the same written part");
assert!(!rels.contains("document.xml") && !rels.contains("gone.bin") && rels.matches("Id=\"rId4\"").count() <= 1, "{rels}");
assert_eq!(text(&files, &chart), CHART_XML);
assert_eq!(files.keys().filter(|k| k.starts_with("word/charts/") && !k.contains("_rels")).count(), 1);
}
/// A group as Word saves one (in `mc:AlternateContent`, needing `wpg`): its members' space
/// starts at `a:chOff`, and a nested group is flattened into it through its own transform.
#[test]
fn word_group_with_nested_group() {
let body = r#"<w:p><w:r><mc:AlternateContent><mc:Choice Requires="wpg"><w:drawing><wp:anchor behindDoc="0" distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="column"><wp:posOffset>0</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>0</wp:posOffset></wp:positionV><wp:extent cx="2540000" cy="1270000"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapSquare wrapText="bothSides"/><wp:docPr id="1" name="Group 1"/><wp:cNvGraphicFramePr/>
<a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="2540000" cy="1270000"/><a:chOff x="127000" y="127000"/><a:chExt cx="2540000" cy="1270000"/></a:xfrm></wpg:grpSpPr>
<wps:wsp><wps:cNvPr id="2" name="Rectangle 2"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="127000" y="127000"/><a:ext cx="1270000" cy="635000"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
<wpg:grpSp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="1397000" y="762000"/><a:ext cx="1270000" cy="635000"/><a:chOff x="0" y="0"/><a:chExt cx="2540000" cy="1270000"/></a:xfrm></wpg:grpSpPr>
<wps:wsp><wps:cNvPr id="3" name="Oval 3"/><wps:cNvSpPr/><wps:spPr><a:xfrm><a:off x="254000" y="254000"/><a:ext cx="508000" cy="254000"/></a:xfrm><a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom></wps:spPr><wps:bodyPr/></wps:wsp>
</wpg:grpSp></wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict/></mc:Fallback></mc:AlternateContent></w:r></w:p>"#;
let d = read_body(body);
let p = paras(&d);
let InlineObject::Group { w, h, float, ch_w, ch_h, children, .. } = &p[0].objects[0] else { panic!("{:?}", p[0].objects) };
assert_eq!((*w, *h, *ch_w, *ch_h), (200.0, 100.0, 200.0, 100.0));
assert_eq!(float.wrap, Wrap::Square);
let got: Vec<(f32, f32, ShapeKind, f32, f32)> = children
.iter()
.map(|c| match &c.obj {
InlineObject::Shape { kind, w, h, .. } => (c.x, c.y, *kind, *w, *h),
o => panic!("{o:?}"),
})
.collect();
assert_eq!(got, [(0.0, 0.0, ShapeKind::Rectangle, 100.0, 50.0), (110.0, 60.0, ShapeKind::Ellipse, 20.0, 10.0)]);
}
/// The first object of the first paragraph, made mutable.
fn first_object(doc: &mut Document) -> &mut InlineObject {
let Some(wordcraft_doc::Block::Para(p)) = doc.body.first_mut().map(std::sync::Arc::make_mut) else { panic!("a paragraph") };
p.objects.first_mut().expect("an object")
}
/// #319 with #332: an OLE object turned or flipped in WordCraft is written back turned, in its VML
/// shape (`rotation`, `flip`) and in a DrawingML picture (`a:xfrm` rot/flipH/flipV, with the
/// effect extent covering the rotated bounds like other drawings).
#[test]
fn rotated_ole_objects_round_trip() {
let picture = "\u{89}PNG stand-in";
let ole = "\u{d0}\u{cf} opaque OLE storage";
let types = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="png" ContentType="image/png"/><Default Extension="bin" ContentType="application/vnd.openxmlformats-officedocument.oleObject"/></Types>"#;
let parts = [("[Content_Types].xml", types), ("word/media/image1.png", picture), ("word/embeddings/oleObject1.bin", ole)];
let doc_rels = [("rId8", "image", "media/image1.png"), ("rId9", "oleObject", "embeddings/oleObject1.bin")];
let spin = |o: &InlineObject| o.frame().map(|f| f.2.spin()).unwrap();
// VML: read turned, written with the new turn.
let vml = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><v:shape id="_x0000_i1025" type="#_x0000_t75" style="width:72pt;height:36pt;rotation:30;flip:x" o:ole=""><v:imagedata r:id="rId8" o:title=""/></v:shape><o:OLEObject Type="Embed" ProgID="Package" ShapeID="_x0000_i1025" DrawAspect="Content" ObjectID="_1000000001" r:id="rId9"/></w:object></w:r></w:p>"##;
let mut d = wordcraft_docx::read(&docx(vml, &doc_rels, &parts)).unwrap();
assert_eq!(spin(&paras(&d)[0].objects[0]), wordcraft_geom::Spin::new(30.0, true, false));
if let Some(f) = first_object(&mut d).float_mut() {
f.set_spin(wordcraft_geom::Spin::new(45.0, false, true));
}
let out = wordcraft_docx::write(&d).unwrap();
let body = text(&unzip(&out), "word/document.xml");
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:72pt;height:36pt;rotation:45;flip:y"]);
let back = wordcraft_docx::read(&out).unwrap();
assert!(matches!(&paras(&back)[0].objects[0], InlineObject::Image { ole: Some(_), .. }));
assert_eq!(spin(&paras(&back)[0].objects[0]), wordcraft_geom::Spin::new(45.0, false, true));
// DrawingML: the picture's `a:xfrm` and the drawing's effect extent follow the turn.
let dml = r##"<w:p><w:r><w:object w:dxaOrig="1440" w:dyaOrig="720"><w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="914400" cy="457200"/><wp:docPr id="7" name="Object 7"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="0" name=""/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="rId8"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm flipH="1"><a:off x="0" y="0"/><a:ext cx="914400" cy="457200"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing><o:OLEObject Type="Embed" ProgID="Package" DrawAspect="Content" ObjectID="_1000000002" r:id="rId9"/></w:object></w:r></w:p>"##;
let mut d = wordcraft_docx::read(&docx(dml, &doc_rels, &parts)).unwrap();
assert_eq!(spin(&paras(&d)[0].objects[0]), wordcraft_geom::Spin::new(0.0, true, false));
if let Some(f) = first_object(&mut d).float_mut() {
f.set_spin(wordcraft_geom::Spin::new(90.0, true, false));
}
let out = wordcraft_docx::write(&d).unwrap();
let body = text(&unzip(&out), "word/document.xml");
assert_eq!(attr_values(&body, "a:xfrm", "rot"), ["5400000"]);
assert_eq!(attr_values(&body, "a:xfrm", "flipH"), ["1"]);
// 72 × 36 pt turned 90°: 18 pt narrower each side, 18 pt taller each side.
assert_eq!(attr_values(&body, "wp:effectExtent", "l"), ["-228600"]);
assert_eq!(attr_values(&body, "wp:effectExtent", "t"), ["228600"]);
let back = wordcraft_docx::read(&out).unwrap();
let o = &paras(&back)[0].objects[0];
assert!(matches!(o, InlineObject::Image { ole: Some(_), .. }), "{o:?}");
assert_eq!(spin(o), wordcraft_geom::Spin::new(90.0, true, false));
assert_eq!(o.frame().unwrap().2.effect, [0.0; 4], "the rotated overhang is not kept as effects room");
}
/// Issue #41: tracked formatting changes (ECMA-376 §17.13.5) survive open and save — run,
/// paragraph mark, paragraph, numbering, table, row, cell and section — each written last in
/// its parent, holding the properties before the change.
#[test]
fn formatting_revisions_round_trip() {
let body = r#"
<w:p><w:r><w:t xml:space="preserve">Plain, </w:t></w:r><w:r><w:rPr><w:b/><w:rPrChange w:id="5" w:author="Alice" w:date="2026-10-01T00:00:00Z"><w:rPr/></w:rPrChange></w:rPr><w:t>bold by Alice</w:t></w:r><w:r><w:rPr><w:sz w:val="28"/><w:rPrChange w:id="99999999999" w:author="Bob"><w:rPr><w:i/><w:sz w:val="24"/><w:rPrChange w:id="1" w:author="Nested"><w:rPr/></w:rPrChange></w:rPr></w:rPrChange></w:rPr><w:t xml:space="preserve"> end.</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="0"/><w:numId w:val="1"/><w:numberingChange w:id="6" w:author="Alice" w:date="2026-10-01T00:00:00Z" w:original="1."/></w:numPr><w:jc w:val="center"/><w:rPr><w:i/><w:rPrChange w:id="7" w:author="Alice" w:date="2026-10-01T00:00:00Z"><w:rPr/></w:rPrChange></w:rPr><w:pPrChange w:id="8" w:author="Alice" w:date="2026-10-01T00:00:00Z"><w:pPr><w:ind w:left="720"/></w:pPr></w:pPrChange></w:pPr><w:r><w:t>Centred</w:t></w:r></w:p>
<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/><w:jc w:val="center"/><w:tblPrChange w:id="9" w:author="Bob"><w:tblPr><w:tblW w:w="0" w:type="auto"/></w:tblPr></w:tblPrChange></w:tblPr><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid>
<w:tr><w:trPr><w:cantSplit/><w:trPrChange w:id="10" w:author="Bob"><w:trPr/></w:trPrChange></w:trPr><w:tc><w:tcPr><w:tcW w:w="2000" w:type="dxa"/><w:shd w:val="clear" w:color="auto" w:fill="FFFF00"/><w:tcPrChange w:id="11" w:author="Bob"><w:tcPr><w:tcW w:w="2000" w:type="dxa"/></w:tcPr></w:tcPrChange></w:tcPr><w:p><w:r><w:t>cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl>
<w:p/>
<w:sectPr><w:pgSz w:w="15840" w:h="12240" w:orient="landscape"/><w:sectPrChange w:id="12" w:author="Carol" w:date="2026-10-02T00:00:00Z"><w:sectPr><w:pgSz w:w="12240" w:h="15840"/></w:sectPr></w:sectPrChange></w:sectPr>"#;
let d = read_body(body);
let rev = |i: u32| d.revisions.get(i as usize).map(|r| (r.kind, r.author.as_str(), r.date.as_str()));
let p = paras(&d);
let runs: Vec<_> = p[0].run_ranges().map(|(r, c)| (r, c.clone())).collect();
assert_eq!(runs.len(), 3);
let alice = runs[1].1.fmt_change.as_deref().expect("Alice's rPrChange");
assert_eq!(rev(alice.rev), Some((wordcraft_doc::RevisionKind::Format, "Alice", "2026-10-01T00:00:00Z")));
assert_eq!((runs[1].1.bold, alice.old.bold), (Some(true), None));
let bob = runs[2].1.fmt_change.as_deref().expect("Bob's rPrChange");
assert_eq!((bob.old.italic, bob.old.size, bob.old.fmt_change.is_none()), (Some(true), Some(12.0), true), "a nested change is dropped");
assert!(runs[0].1.fmt_change.is_none());
let pc = p[1].props.fmt_change.as_deref().expect("pPrChange");
assert_eq!((p[1].props.align, pc.old.align, pc.old.indent_left), (Some(Align::Center), None, Some(36.0)));
assert_eq!(p[1].props.num_change.as_deref().map(|n| n.original.as_str()), Some("1."));
assert!(p[1].mark.fmt_change.is_some());
let Some(Block::Table(t)) = d.body.get(2).map(|b| &**b) else { panic!("table") };
assert_eq!(t.props.fmt_change.as_deref().map(|c| c.old.align), Some(None));
assert_eq!(t.rows[0].props.fmt_change.as_deref().map(|c| c.old.cant_split), Some(false));
assert_eq!(t.rows[0].cells[0].props.fmt_change.as_deref().map(|c| c.old.shading), Some(None));
let sc = d.last_section.fmt_change.as_deref().expect("sectPrChange");
assert_eq!((d.last_section.landscape, sc.old.landscape), (true, false));
assert_eq!(rev(sc.rev).map(|r| r.1), Some("Carol"));
// Written back: each change is the last child of its parent, and reads back the same.
let bytes = wordcraft_docx::write(&d).unwrap();
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(&bytes)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut zip.by_name("word/document.xml").unwrap(), &mut xml).unwrap();
for (tag, parent) in [
("w:rPrChange", "</w:rPr></w:rPrChange></w:rPr>"),
("w:pPrChange", "</w:pPr></w:pPrChange></w:pPr>"),
("w:tblPrChange", "</w:tblPr></w:tblPrChange></w:tblPr>"),
("w:trPrChange", "</w:trPrChange></w:trPr>"),
("w:tcPrChange", "</w:tcPr></w:tcPrChange></w:tcPr>"),
("w:sectPrChange", "</w:sectPr></w:sectPrChange></w:sectPr>"),
] {
assert!(xml.contains(&format!("<{tag} w:id=")), "{tag}: {xml}");
assert!(xml.contains(parent), "{tag} last: {xml}");
}
assert!(xml.contains(r#"<w:numberingChange w:id="#) && xml.contains(r#"w:original="1."/></w:numPr>"#), "{xml}");
assert!(xml.contains(r#"<w:b/><w:rPrChange w:id="#), "{xml}");
assert!(!xml.contains("<w:bCs"), "Saving must not add an absent complex-script bold: {xml}");
let again = wordcraft_docx::read(&bytes).unwrap();
assert_eq!(again.body, d.body);
assert_eq!(again.last_section, d.last_section);
assert_eq!(again.revisions, d.revisions);
}
/// A package with `styles`, a theme (accent1 4472C4, accent2 ED7D31, major font "Major Face") and,
/// when given, `settings`.
fn themed_docx(body: &str, styles: &str, settings: Option<&str>) -> Document {
let theme = r#"<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="T"><a:themeElements><a:clrScheme name="T"><a:dk1><a:sysClr val="windowText" lastClr="000000"/></a:dk1><a:lt1><a:sysClr val="window" lastClr="FFFFFF"/></a:lt1><a:dk2><a:srgbClr val="44546A"/></a:dk2><a:lt2><a:srgbClr val="E7E6E6"/></a:lt2><a:accent1><a:srgbClr val="4472C4"/></a:accent1><a:accent2><a:srgbClr val="ED7D31"/></a:accent2><a:accent3><a:srgbClr val="A5A5A5"/></a:accent3><a:accent4><a:srgbClr val="FFC000"/></a:accent4><a:accent5><a:srgbClr val="5B9BD5"/></a:accent5><a:accent6><a:srgbClr val="70AD47"/></a:accent6><a:hlink><a:srgbClr val="0563C1"/></a:hlink><a:folHlink><a:srgbClr val="954F72"/></a:folHlink></a:clrScheme><a:fontScheme name="T"><a:majorFont><a:latin typeface="Major Face"/></a:majorFont><a:minorFont><a:latin typeface="Minor Face"/></a:minorFont></a:fontScheme></a:themeElements></a:theme>"#;
let styles = format!(
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:default="1" w:styleId="Normal"><w:name w:val="Normal"/></w:style>{styles}</w:styles>"#
);
let mut doc_rels = vec![("rId1", "styles", "styles.xml"), ("rId2", "theme", "theme/theme1.xml")];
let mut extra = vec![("word/styles.xml", styles.as_str()), ("word/theme/theme1.xml", theme)];
let settings = settings.map(|s| format!(r#"<w:settings {W_NS}>{s}</w:settings>"#));
if let Some(s) = &settings {
doc_rels.push(("rId3", "settings", "settings.xml"));
extra.push(("word/settings.xml", s.as_str()));
}
wordcraft_docx::read(&docx(body, &doc_rels, &extra)).unwrap()
}
/// Within one step per channel: Word's tint and shade round differently from ours in the last bit.
fn near(c: Option<TextColor>, want: Rgb) -> bool {
matches!(c, Some(TextColor::Rgb(Rgb(r, g, b))) if r.abs_diff(want.0) <= 1 && g.abs_diff(want.1) <= 1 && b.abs_diff(want.2) <= 1)
}
#[test]
fn theme_colour_wins_over_the_fixed_colour() {
// Heading 1 as Word writes it: the stale w:val is ignored, accent1 darkened 25% is 2F5496.
let styles = r#"<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:rPr><w:color w:val="FF0000" w:themeColor="accent1" w:themeShade="BF"/><w:u w:val="single" w:color="FF0000" w:themeColor="accent2"/></w:rPr></w:style>"#;
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>H</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeTint="99"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeTint="66"/></w:rPr><w:t>b</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeTint="33"/></w:rPr><w:t>c</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent1" w:themeShade="7F"/></w:rPr><w:t>d</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="accent2" w:themeShade="BF"/></w:rPr><w:t>e</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="text2"/></w:rPr><w:t>f</w:t></w:r><w:r><w:rPr><w:color w:val="auto" w:themeColor="hyperlink"/></w:rPr><w:t>g</w:t></w:r><w:r><w:rPr><w:shd w:val="clear" w:color="auto" w:fill="000000" w:themeFill="accent2" w:themeFillTint="33"/></w:rPr><w:t>h</w:t></w:r></w:p>"#;
let d = themed_docx(body, styles, None);
let h = d.styles.get("Heading1").unwrap();
assert_eq!(h.chr.color, Some(TextColor::Rgb(Rgb(0x2F, 0x54, 0x96))));
assert_eq!(h.chr.underline_color, Some(Rgb(0xED, 0x7D, 0x31)));
let p = paras(&d);
let at = |i: usize| p[1].props_of_char(i);
// Colours Word writes into w:val next to these theme colours (Grid Table and heading styles).
assert!(near(at(0).color, Rgb(0x8E, 0xAA, 0xDB)), "{:?}", at(0).color);
assert!(near(at(1).color, Rgb(0xB4, 0xC6, 0xE7)), "{:?}", at(1).color);
assert!(near(at(2).color, Rgb(0xD9, 0xE2, 0xF3)), "{:?}", at(2).color);
assert!(near(at(3).color, Rgb(0x1F, 0x37, 0x63)), "{:?}", at(3).color);
assert!(near(at(4).color, Rgb(0xC4, 0x59, 0x11)), "{:?}", at(4).color);
// text2 maps to dk2 without a w:clrSchemeMapping; hyperlink to hlink.
assert_eq!(at(5).color, Some(TextColor::Rgb(Rgb(0x44, 0x54, 0x6A))));
assert_eq!(at(6).color, Some(TextColor::Rgb(Rgb(0x05, 0x63, 0xC1))));
let fill = at(7).shading.unwrap();
assert!(near(Some(TextColor::Rgb(fill)), Rgb(0xFB, 0xE4, 0xD5)), "{fill:?}");
}
#[test]
fn theme_colours_follow_the_colour_scheme_mapping() {
// Styles are read before settings; the mapping still applies to them.
let styles =
r#"<w:style w:type="character" w:styleId="T1"><w:name w:val="T1"/><w:rPr><w:color w:val="000000" w:themeColor="text1"/></w:rPr></w:style>"#;
let body = r#"<w:p><w:r><w:rPr><w:rStyle w:val="T1"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="background1"/></w:rPr><w:t>b</w:t></w:r><w:r><w:rPr><w:color w:val="000000" w:themeColor="dark1"/></w:rPr><w:t>c</w:t></w:r></w:p>"#;
let mapping = r#"<w:clrSchemeMapping w:bg1="dark1" w:t1="accent2" w:bg2="light2" w:t2="dark2" w:accent1="accent1" w:accent2="accent2" w:accent3="accent3" w:accent4="accent4" w:accent5="accent5" w:accent6="accent6" w:hyperlink="hyperlink" w:followedHyperlink="followedHyperlink"/>"#;
let d = themed_docx(body, styles, Some(mapping));
assert_eq!(d.styles.get("T1").unwrap().chr.color, Some(TextColor::Rgb(Rgb(0xED, 0x7D, 0x31))));
let p = paras(&d);
assert_eq!(p[0].props_of_char(1).color, Some(TextColor::Rgb(Rgb(0, 0, 0))), "background1 → dark1");
assert_eq!(p[0].props_of_char(2).color, Some(TextColor::Rgb(Rgb(0, 0, 0))), "dark1 isn't remapped");
}
#[test]
fn theme_colour_without_a_theme_keeps_the_fixed_colour() {
let d = read_body(r#"<w:p><w:r><w:rPr><w:color w:val="00FF00" w:themeColor="accent1" w:themeShade="BF"/></w:rPr><w:t>a</w:t></w:r></w:p>"#);
assert_eq!(paras(&d)[0].props_of_char(0).color, Some(TextColor::Rgb(Rgb(0, 0xFF, 0))));
}
#[test]
fn theme_font_wins_over_the_fixed_font() {
let styles = r#"<w:style w:type="paragraph" w:styleId="Heading1"><w:name w:val="heading 1"/><w:rPr><w:rFonts w:ascii="Calibri Light" w:hAnsi="Calibri Light" w:asciiTheme="majorHAnsi" w:hAnsiTheme="majorHAnsi"/></w:rPr></w:style>"#;
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>H</w:t></w:r></w:p>
<w:p><w:r><w:rPr><w:rFonts w:hAnsi="Arial" w:hAnsiTheme="minorHAnsi"/></w:rPr><w:t>a</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Arial" w:asciiTheme="minorHAnsi"/></w:rPr><w:t>b</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Arial" w:hAnsiTheme="majorHAnsi"/></w:rPr><w:t>c</w:t></w:r></w:p>"#;
let d = themed_docx(body, styles, None);
assert_eq!(d.styles.get("Heading1").unwrap().chr.font.as_deref(), Some("Major Face"));
let p = paras(&d);
assert_eq!(p[1].props_of_char(0).font.as_deref(), Some("Minor Face"));
assert_eq!(p[1].props_of_char(1).font.as_deref(), Some("Minor Face"));
// An explicit ascii font still beats a theme font given only for hAnsi.
assert_eq!(p[1].props_of_char(2).font.as_deref(), Some("Arial"));
}
/// Issue #400: moved text (ECMA-376 §17.13.5.21–28). `w:moveFrom` / `w:moveTo` inside named
/// move ranges are the two ends of one move, paragraph marks included; the ranges are written
/// back around them. A move missing an end, or whose range never ends, is a plain deletion and
/// insertion.
#[test]
fn move_revisions_round_trip() {
let body = r#"
<w:p><w:pPr><w:rPr><w:moveTo w:id="1" w:author="Ana" w:date="2026-10-03T00:00:00Z"/></w:rPr></w:pPr><w:moveToRangeStart w:id="2" w:name="move1" w:author="Ana" w:date="2026-10-03T00:00:00Z"/><w:moveTo w:id="3" w:author="Ana" w:date="2026-10-03T00:00:00Z"><w:r><w:t>Moved here.</w:t></w:r></w:moveTo></w:p>
<w:moveToRangeEnd w:id="2"/>
<w:p><w:r><w:t xml:space="preserve">Stays. </w:t></w:r><w:moveFromRangeStart w:id="4" w:name="move1" w:author="Ana" w:date="2026-10-03T00:00:00Z"/><w:moveFrom w:id="5" w:author="Ana" w:date="2026-10-03T00:00:00Z"><w:r><w:t>Moved here.</w:t></w:r></w:moveFrom><w:moveFromRangeEnd w:id="4"/></w:p>
<w:p><w:moveFromRangeStart w:id="6" w:name="lonely" w:author="Bo"/><w:moveFrom w:id="7" w:author="Bo"><w:r><w:t>No destination</w:t></w:r></w:moveFrom><w:moveFromRangeEnd w:id="6"/><w:moveToRangeStart w:id="8" w:name="open" w:author="Bo"/><w:moveTo w:id="9" w:author="Bo"><w:r><w:t>never ends</w:t></w:r></w:moveTo><w:moveTo w:id="10" w:author="Cy"><w:r><w:t>no range</w:t></w:r></w:moveTo><w:moveFromRangeEnd w:id="404"/></w:p>"#;
let check = |d: &Document| {
let p = paras(d);
let name = |r: Option<u32>| d.move_name(r).map(str::to_string);
assert_eq!(name(p[0].mark.ins).as_deref(), Some("move1"), "a moved paragraph mark");
let to = p[0].runs[0].props.ins;
assert_eq!((name(to).as_deref(), d.revisions[to.unwrap() as usize].kind), (Some("move1"), RevisionKind::Insert));
let from = p[1].run_ranges().find(|(_, c)| c.del.is_some()).map(|(_, c)| c.del).unwrap();
assert_eq!((name(from).as_deref(), d.revisions[from.unwrap() as usize].kind), (Some("move1"), RevisionKind::Delete));
assert_eq!(d.revisions[from.unwrap() as usize].author, "Ana");
// Hostile ends: each is a plain revision, still tracked.
for (r, c) in p[2].run_ranges() {
assert!(c.ins.or(c.del).is_some(), "{r:?} keeps its revision");
assert_eq!(name(c.ins.or(c.del)), None, "{r:?} is no move");
}
};
let d = read_body(body);
check(&d);
let out = wordcraft_docx::write(&d).unwrap();
let xml = text(&unzip(&out), "word/document.xml");
assert_eq!(attr_values(&xml, "w:moveFromRangeStart", "w:name"), ["move1"]);
assert_eq!(attr_values(&xml, "w:moveToRangeStart", "w:name"), ["move1"]);
assert_eq!(attr_values(&xml, "w:moveToRangeStart", "w:author"), ["Ana"]);
assert_eq!(attr_values(&xml, "w:moveToRangeStart", "w:id"), attr_values(&xml, "w:moveToRangeEnd", "w:id"));
assert_eq!(attr_values(&xml, "w:moveFromRangeStart", "w:id"), attr_values(&xml, "w:moveFromRangeEnd", "w:id"));
assert!(xml.contains("<w:moveFrom ") && !xml.contains("<w:delText xml:space=\"preserve\">Moved"), "moved-away text stays in w:t");
// The range around the moved paragraph includes its mark: it ends in the next paragraph.
let to_start = xml.find("<w:moveToRangeStart").unwrap();
let to_end = xml.find("<w:moveToRangeEnd").unwrap();
assert!(xml[to_start..to_end].contains("</w:p>"), "{xml}");
check(&wordcraft_docx::read(&out).unwrap());
}
/// A document's own footnote separators and continuation notice are kept (ECMA-376 §17.11), and
/// written back as Word numbers them: -1, 0 and 1, the notes after them.
#[test]
fn note_separators_and_continuation_notice_round_trip() {
let footnotes = format!(
r#"<w:footnotes {W_NS}><w:footnote w:type="separator" w:id="-1"><w:p><w:pPr><w:spacing w:after="0"/></w:pPr><w:r><w:separator/></w:r></w:p></w:footnote><w:footnote w:type="continuationSeparator" w:id="0"><w:p><w:r><w:t xml:space="preserve">More: </w:t></w:r><w:r><w:continuationSeparator/></w:r></w:p></w:footnote><w:footnote w:type="continuationNotice" w:id="1"><w:p><w:pPr><w:jc w:val="right"/></w:pPr><w:r><w:t>(continued)</w:t></w:r></w:p></w:footnote><w:footnote w:id="2"><w:p><w:r><w:footnoteRef/></w:r><w:r><w:t xml:space="preserve"> The note.</w:t></w:r></w:p></w:footnote></w:footnotes>"#
);
let body = r#"<w:p><w:r><w:t>Text</w:t></w:r><w:r><w:footnoteReference w:id="2"/></w:r></w:p>"#;
let bytes = docx(body, &[("rId1", "footnotes", "footnotes.xml")], &[("word/footnotes.xml", &footnotes)]);
let check = |d: &Document| {
let s = &d.footnote_separators;
let first = |b: &Option<wordcraft_doc::Blocks>| b.as_ref().unwrap()[0].as_para().unwrap().clone();
let sep = first(&s.separator);
assert_eq!(sep.objects.iter().map(InlineObject::as_note_separator).collect::<Vec<_>>(), vec![Some(false)]);
assert_eq!(sep.props.space_after, Some(0.0));
let cont = first(&s.continuation_separator);
assert_eq!(cont.plain_text(), "More: ");
assert_eq!(cont.objects.iter().map(InlineObject::as_note_separator).collect::<Vec<_>>(), vec![Some(true)]);
let notice = first(&s.continuation_notice);
assert_eq!(notice.plain_text(), "(continued)");
assert_eq!(notice.props.align, Some(Align::Right));
assert!(d.endnote_separators.is_empty());
// The note itself is read as before.
let InlineObject::NoteRef { id, .. } = paras(d)[0].objects[0] else { panic!() };
assert_eq!(d.parts[&id].blocks[0].as_para().unwrap().plain_text(), " The note.");
assert_eq!(d.parts.len(), 1, "special notes aren't notes");
};
let d = wordcraft_docx::read(&bytes).unwrap();
check(&d);
let out = wordcraft_docx::write(&d).unwrap();
let part = |name: &str| {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(&out)).unwrap();
let mut xml = String::new();
std::io::Read::read_to_string(&mut z.by_name(name).unwrap(), &mut xml).unwrap();
xml
};
let notes = part("word/footnotes.xml");
for (kind, id) in [("separator", "-1"), ("continuationSeparator", "0"), ("continuationNotice", "1")] {
assert!(notes.contains(&format!(r#"<w:footnote w:type="{kind}" w:id="{id}">"#)), "{kind}: {notes}");
}
assert!(notes.contains(r#"<w:footnote w:id="2">"#), "the note follows the notice: {notes}");
assert!(part("word/document.xml").contains(r#"<w:footnoteReference w:id="2"/>"#));
let settings = part("word/settings.xml");
assert!(settings.contains(r#"<w:footnote w:id="-1"/><w:footnote w:id="0"/><w:footnote w:id="1"/>"#), "{settings}");
check(&wordcraft_docx::read(&out).unwrap());
}
/// Issue #534: East Asian number formats are read, labelled and written back as they were; a
/// value the format doesn't define is decimal, and a level without `w:ind` has no indent.
#[test]
fn east_asian_number_formats_read_label_and_round_trip() {
use wordcraft_doc::numbering::Counters;
use wordcraft_doc::section::NumFormat;
let lvl = |i: u8, fmt: &str, text: &str| {
format!(r#"<w:lvl w:ilvl="{i}"><w:start w:val="1"/><w:numFmt w:val="{fmt}"/><w:suff w:val="nothing"/><w:lvlText w:val="{text}"/></w:lvl>"#)
};
let levels = [
lvl(0, "chineseCounting", "%1、"),
lvl(1, "chineseCountingThousand", "第%2章"),
lvl(2, "japaneseLegal", "(%3)"),
lvl(3, "decimalEnclosedCircle", "%4"),
lvl(4, "hebrew1", "%5."),
lvl(5, "notAFormat", "%6."),
]
.concat();
let numbering = format!(
r#"<w:numbering {W_NS}><w:abstractNum w:abstractNumId="0">{levels}</w:abstractNum><w:num w:numId="1"><w:abstractNumId w:val="0"/></w:num></w:numbering>"#
);
let item = |lv: u8| format!(r#"<w:p><w:pPr><w:numPr><w:ilvl w:val="{lv}"/><w:numId w:val="1"/></w:numPr></w:pPr><w:r><w:t>x</w:t></w:r></w:p>"#);
let body: String = [0, 0, 0, 1, 2, 3, 4, 5].map(item).concat();
let bytes = docx(&body, &[("rId1", "numbering", "numbering.xml")], &[("word/numbering.xml", &numbering)]);
let d = wordcraft_docx::read(&bytes).unwrap();
let formats = |d: &Document| (0..6).map(|i| d.numbering.level(1, i).map(|l| l.format)).collect::<Vec<_>>();
let want = [
NumFormat::ChineseCounting,
NumFormat::ChineseCountingThousand,
NumFormat::JapaneseLegal,
NumFormat::DecimalEnclosedCircle,
NumFormat::Hebrew1,
NumFormat::Decimal,
]
.map(Some);
assert_eq!(formats(&d), want);
assert_eq!(d.numbering.level(1, 0).map(|l| (l.indent, l.hanging)), Some((0.0, 0.0)));
let mut c = Counters::default();
let labels: Vec<String> =
paras(&d).iter().filter_map(|p| p.props.numbering).filter_map(|n| c.next_label(&d.numbering, n.num, n.level)).map(|l| l.0).collect();
assert_eq!(labels, ["一、", "二、", "三、", "第一章", "(壱)", "①", "1.", "1."]);
// Saved and read again, every format is the one the file had (hebrew1 too, which shows as
// decimal for now).
let out = wordcraft_docx::write(&d).unwrap();
let back = wordcraft_docx::read(&out).unwrap();
assert_eq!(formats(&back), want);
}
/// #464: a bookmark whose end follows the document's final table keeps its end (it goes into
/// the table's last cell) and is saved as a matched pair.
#[test]
fn bookmark_around_final_table_keeps_its_end() {
let body = r#"<w:bookmarkStart w:id="7" w:name="TableRange"/><w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w="1440"/></w:tblGrid><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>Cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:bookmarkEnd w:id="7"/><w:sectPr/>"#;
let d = read_body(body);
let marks = |d: &Document| {
let Block::Table(t) = &*d.body[0] else { panic!("{:?}", d.body[0]) };
t.rows[0].cells[0].blocks[0].as_para().unwrap().objects.clone()
};
let want = vec![InlineObject::BookmarkStart { name: "TableRange".into() }, InlineObject::BookmarkEnd { name: "TableRange".into() }];
assert_eq!(marks(&d), want);
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(marks(&again), want);
assert_eq!(again.bookmarks().len(), 1);
}
/// #465: a field whose cached result is an embedded picture (`INCLUDEPICTURE`, simple, complex or
/// locked) keeps the picture through reading and saving; the linked file is never read.
#[test]
fn includepicture_keeps_its_embedded_picture() {
let png: &[u8] = b"\x89PNG\r\n\x1a\nstand-in";
let pic = r#"<w:r><w:drawing><wp:inline><wp:extent cx="914400" cy="914400"/><wp:docPr id="1" name="P"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip r:embed="rIdPic"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#;
let cases = [
(format!(r#"<w:fldSimple w:instr="INCLUDEPICTURE &quot;picture.png&quot;">{pic}</w:fldSimple>"#), false),
(format!(r#"<w:fldSimple w:instr="INCLUDEPICTURE &quot;picture.png&quot;" w:fldLock="1">{pic}</w:fldSimple>"#), true),
(
format!(
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve"> INCLUDEPICTURE "picture.png" </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{pic}<w:r><w:fldChar w:fldCharType="end"/></w:r>"#
),
false,
),
];
for (field, locked) in cases {
let bytes = docx(&format!("<w:p>{field}</w:p><w:sectPr/>"), &[("rIdPic", "image", "media/picture.png")], &[]);
let bytes = {
// `docx` takes text parts; add the picture bytes as a binary part.
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut v = Vec::new();
std::io::Read::read_to_end(&mut f, &mut v).unwrap();
entries.push((f.name().to_string(), v));
}
entries.push(("word/media/picture.png".into(), png.to_vec()));
zip(&entries.iter().map(|(n, v)| (n.as_str(), v.as_slice())).collect::<Vec<_>>())
};
let check = |d: &Document| {
let p = paras(d);
let objs = &p[0].objects;
assert_eq!(objs.len(), 3, "{objs:?}");
assert_eq!(objs[0], InlineObject::FieldStart { instr: "INCLUDEPICTURE \"picture.png\"".into(), locked });
let InlineObject::Image { media, .. } = &objs[1] else { panic!("{objs:?}") };
assert_eq!(d.media.get(media).unwrap().as_slice(), png);
assert_eq!(objs[2], InlineObject::FieldEnd);
};
let d = wordcraft_docx::read(&bytes).unwrap();
check(&d);
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
}
/// One anchored Word shape (`wps:wsp` inside `mc:AlternateContent`, as Word writes them) with
/// shape properties `sppr`, style `style` and extra `wsp` content.
fn anchored_wsp(name: &str, cx: u32, cy: u32, sppr: &str, style: &str, extra: &str) -> String {
format!(
r#"<w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing><wp:anchor distT="0" distB="0" distL="114300" distR="114300" simplePos="0" relativeHeight="1" behindDoc="0" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="column"><wp:posOffset>12700</wp:posOffset></wp:positionH><wp:positionV relativeFrom="paragraph"><wp:posOffset>25400</wp:posOffset></wp:positionV><wp:extent cx="{cx}" cy="{cy}"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="1" name="{name}"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingShape"><wps:wsp>{extra}<wps:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm>{sppr}</wps:spPr>{style}<wps:bodyPr/></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict><v:line from="0,0" to="1pt,2pt"/></w:pict></mc:Fallback></mc:AlternateContent></w:r>"#
)
}
/// #611: diagrams drawn with Word's shapes were mostly missing. Connectors and boxes take their
/// outline and fill from the theme through their style references, or name preset and scheme
/// colours; all of these are read, with line widths and arrowheads.
#[test]
fn theme_styled_connectors_and_callout_boxes_keep_their_paint() {
let style = |ln: u8, fill: u8, clr: &str| {
format!(
r#"<wps:style><a:lnRef idx="{ln}"><a:schemeClr val="{clr}"/></a:lnRef><a:fillRef idx="{fill}"><a:schemeClr val="{clr}"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="{clr}"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="tx1"/></a:fontRef></wps:style>"#
)
};
let body = [
// A connector with only a style: accent4, the theme's thinnest line, an arrowhead.
anchored_wsp("Arrow", 673100, 889000, r#"<a:prstGeom prst="straightConnector1"><a:avLst/></a:prstGeom><a:ln><a:tailEnd type="triangle"/></a:ln>"#, &style(1, 0, "accent4"), "<wps:cNvCnPr/>"),
// A callout text box: scheme fill, preset black outline.
anchored_wsp(
"Text Box",
3073400,
673100,
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:schemeClr val="lt1"/></a:solidFill><a:ln w="6350"><a:solidFill><a:prstClr val="black"/></a:solidFill></a:ln>"#,
"",
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Callout</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
),
// A filled rectangle from its style alone.
anchored_wsp("Rect", 292100, 647700, r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#, &style(2, 1, "accent1"), "<wps:cNvSpPr/>"),
// An elbow connector.
anchored_wsp("Elbow", 1270000, 635000, r#"<a:prstGeom prst="bentConnector3"><a:avLst/></a:prstGeom>"#, &style(1, 0, "accent2"), "<wps:cNvCnPr/>"),
// A rounded-rectangle callout.
anchored_wsp("Callout", 1270000, 635000, r#"<a:prstGeom prst="wedgeRoundRectCallout"><a:avLst/></a:prstGeom>"#, &style(2, 1, "accent1"), "<wps:cNvSpPr/>"),
]
.concat();
let d = read_body(&format!("<w:p>{body}</w:p>"));
let theme = &d.settings.theme_colors;
let objs = &paras(&d)[0].objects;
assert_eq!(objs.len(), 5);
let shape = |i: usize| match &objs[i] {
InlineObject::Shape { kind, fill, stroke, stroke_width, extra, story, .. } => {
(*kind, *fill, *stroke, *stroke_width, extra.arrow_end, story.is_some())
}
o => panic!("{o:?}"),
};
assert_eq!(shape(0), (ShapeKind::StraightConnector, None, Some(theme[7]), 0.5, true, false));
assert_eq!(shape(1), (ShapeKind::TextBox, Some(theme[1]), Some(Rgb(0, 0, 0)), 0.5, false, true));
assert_eq!(shape(2), (ShapeKind::Rectangle, Some(theme[4]), Some(theme[4]), 1.0, false, false));
assert_eq!(shape(3), (ShapeKind::ElbowConnector, None, Some(theme[5]), 0.5, false, false));
assert_eq!(shape(4).0, ShapeKind::RoundedRectangle);
// The themed outline and the arrowhead survive saving.
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert!(matches!(&paras(&again)[0].objects[0], InlineObject::Shape { stroke, extra, .. } if *stroke == Some(theme[7]) && extra.arrow_end));
}
/// #611: legacy VML lines and rectangles (documents without DrawingML).
#[test]
fn vml_lines_and_rectangles() {
let body = r##"<w:p><w:r><w:pict><v:line style="position:absolute;flip:y;z-index:1" from="10pt,20pt" to="40pt,60pt" strokecolor="#4472c4 [3204]" strokeweight=".5pt"><v:stroke endarrow="block"/></v:line></w:pict></w:r>
<w:r><w:pict><v:rect style="position:absolute;margin-left:5pt;margin-top:6pt;width:30pt;height:20pt" fillcolor="#ff0000" stroked="f"/></w:pict></w:r></w:p>"##;
let d = read_body(body);
let objs = &paras(&d)[0].objects;
assert_eq!(objs.len(), 2, "{objs:?}");
match &objs[0] {
InlineObject::Shape { kind: ShapeKind::Line, w, h, stroke, stroke_width, float, extra, .. } => {
assert_eq!((*w, *h, *stroke, *stroke_width), (30.0, 40.0, Some(Rgb(0x44, 0x72, 0xC4)), 0.5));
assert_eq!((float.x, float.y, float.flip_h, float.flip_v), (10.0, 20.0, false, true));
assert!(extra.arrow_end && !extra.arrow_start);
}
o => panic!("{o:?}"),
}
assert!(matches!(&objs[1], InlineObject::Shape { kind: ShapeKind::Rectangle, fill: Some(Rgb(255, 0, 0)), stroke: None, w: 30.0, h: 20.0, .. }));
}
/// #743: a cover page from Word: a group holding a rectangle filled through its style, a
/// freeform band and a title box, beside a box that resizes to fit its two lines and has no
/// internal margins. The fills, the band's outline, the margins and the resizing are all read
/// and survive saving.
#[test]
fn cover_page_group_fills_freeforms_and_text_box_margins() {
let style = r#"<wps:style><a:lnRef idx="2"><a:schemeClr val="accent1"><a:shade val="50000"/></a:schemeClr></a:lnRef><a:fillRef idx="1"><a:schemeClr val="accent1"/></a:fillRef><a:effectRef idx="0"><a:schemeClr val="accent1"/></a:effectRef><a:fontRef idx="minor"><a:schemeClr val="lt1"/></a:fontRef></wps:style>"#;
let member = |id: u32, txbox: bool, off: (u32, u32), ext: (u32, u32), geom: &str, paint: &str, inner: &str| {
let nv = if txbox { r#"<wps:cNvSpPr txBox="1"/>"# } else { "<wps:cNvSpPr/>" };
format!(
r#"<wps:wsp><wps:cNvPr id="{id}" name="Member {id}"/>{nv}<wps:spPr><a:xfrm><a:off x="{}" y="{}"/><a:ext cx="{}" cy="{}"/></a:xfrm>{geom}{paint}</wps:spPr>{style}{inner}</wps:wsp>"#,
off.0, off.1, ext.0, ext.1
)
};
let rect = r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#;
let band = r#"<a:custGeom><a:avLst/><a:gdLst/><a:ahLst/><a:cxnLst/><a:rect l="0" t="0" r="r" b="b"/><a:pathLst><a:path w="100" h="100"><a:moveTo><a:pt x="0" y="100"/></a:moveTo><a:lnTo><a:pt x="100" y="0"/></a:lnTo><a:lnTo><a:pt x="100" y="100"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom>"#;
let title = r#"<wps:txbx><w:txbxContent><w:p><w:r><w:t>Cover Title</w:t></w:r></w:p></w:txbxContent></wps:txbx><wps:bodyPr rot="0" vert="horz" wrap="square" lIns="0" tIns="0" rIns="0" bIns="0" anchor="t"><a:noAutofit/></wps:bodyPr>"#;
let members = [
member(11, false, (0, 0), (5486400, 5486400), rect, r#"<a:ln><a:noFill/></a:ln>"#, "<wps:bodyPr/>"),
member(
12,
false,
(914400, 4572000),
(4572000, 914400),
band,
r#"<a:solidFill><a:schemeClr val="bg1"/></a:solidFill><a:ln><a:noFill/></a:ln>"#,
"<wps:bodyPr/>",
),
member(13, true, (457200, 457200), (4572000, 1828800), rect, r#"<a:noFill/><a:ln><a:noFill/></a:ln>"#, title),
]
.concat();
let group = format!(
r##"<w:r><mc:AlternateContent><mc:Choice Requires="wpg"><w:drawing><wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="1" behindDoc="1" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="margin"><wp:align>center</wp:align></wp:positionH><wp:positionV relativeFrom="margin"><wp:align>top</wp:align></wp:positionV><wp:extent cx="5486400" cy="5486400"/><wp:effectExtent l="0" t="0" r="0" b="0"/><wp:wrapNone/><wp:docPr id="1" name="Group 1"/><a:graphic><a:graphicData uri="http://schemas.microsoft.com/office/word/2010/wordprocessingGroup"><wpg:wgp><wpg:cNvGrpSpPr/><wpg:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="5486400" cy="5486400"/><a:chOff x="0" y="0"/><a:chExt cx="5486400" cy="5486400"/></a:xfrm></wpg:grpSpPr>{members}</wpg:wgp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict><v:rect style="width:10pt;height:10pt" fillcolor="#156082 [3204]" stroked="f"/></w:pict></mc:Fallback></mc:AlternateContent></w:r>"##
);
let subtitle = anchored_wsp(
"Subtitle",
5753100,
420000,
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/><a:ln><a:noFill/></a:ln>"#,
"",
r#"<wps:cNvSpPr txBox="1"/><wps:txbx><w:txbxContent><w:p><w:r><w:t>Plan</w:t></w:r></w:p><w:p><w:r><w:t>Author</w:t></w:r></w:p></w:txbxContent></wps:txbx>"#,
)
// `anchored_wsp` writes an empty `wps:bodyPr`: give this one margins and Resize to fit.
.replace("<wps:bodyPr/>", r#"<wps:bodyPr lIns="0" tIns="12700" rIns="0" bIns="0"><a:spAutoFit/></wps:bodyPr>"#);
let check = |d: &Document| {
let theme = &d.settings.theme_colors;
let objs = &paras(d)[0].objects;
let InlineObject::Group { children, .. } = &objs[0] else { panic!("{:?}", objs[0]) };
assert_eq!(children.len(), 3);
let shape = |i: usize| match &children[i].obj {
InlineObject::Shape { kind, fill, freeform, story, .. } => (*kind, *fill, freeform.is_some(), *story),
o => panic!("{o:?}"),
};
let (kind, fill, free, _) = shape(0);
assert_eq!((kind, fill, free), (ShapeKind::Rectangle, Some(theme[4]), false), "the style's accent fill");
let (kind, fill, free, _) = shape(1);
assert_eq!((kind, fill, free), (ShapeKind::Freeform, Some(theme[1]), true), "a freeform in a group keeps its outline");
let (kind, _, _, story) = shape(2);
assert_eq!(kind, ShapeKind::TextBox);
let body = d.parts[&story.unwrap()].body;
assert_eq!((body.inset, body.auto_fit), (Some([0; 4]), false), "the title box has no margins");
let InlineObject::Shape { story: Some(sub), .. } = &objs[1] else { panic!("{:?}", objs[1]) };
let body = d.parts[sub].body;
assert_eq!((body.inset, body.auto_fit), (Some([0, 12700, 0, 0]), true), "the subtitle resizes to fit its text");
};
let d = read_body(&format!("<w:p>{group}{subtitle}</w:p>"));
check(&d);
check(&wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap());
}
#[test]
fn embedded_fonts_survive_a_round_trip() {
let table = r#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:font w:name="Roboto"><w:embedRegular r:id="rId1"/><w:embedBold r:id="rId2"/></w:font></w:fonts>"#;
let table_rels = rels(&[("rId1", "font", "fonts/Roboto-regular.ttf"), ("rId2", "font", "fonts/Roboto-bold.ttf")]);
let bytes = docx(
"<w:p><w:r><w:t>Hi</w:t></w:r></w:p>",
&[("rId9", "fontTable", "fontTable.xml")],
&[
("word/fontTable.xml", table),
("word/_rels/fontTable.xml.rels", &table_rels),
("word/fonts/Roboto-regular.ttf", "regular font bytes"),
("word/fonts/Roboto-bold.ttf", "bold font bytes"),
],
);
let out = wordcraft_docx::write(&wordcraft_docx::read(&bytes).unwrap()).unwrap();
let files = unzip(&out);
assert_eq!(text(&files, "word/fontTable.xml"), table);
assert_eq!(text(&files, "word/fonts/Roboto-regular.ttf"), "regular font bytes");
assert_eq!(text(&files, "word/fonts/Roboto-bold.ttf"), "bold font bytes");
let table_rels = text(&files, "word/_rels/fontTable.xml.rels");
assert!(table_rels.contains(r#"Id="rId1""#) && table_rels.contains("fonts/Roboto-regular.ttf"), "{table_rels}");
assert!(text(&files, "word/_rels/document.xml.rels").contains("fontTable.xml"));
// A second save keeps them too.
let again = unzip(&wordcraft_docx::write(&wordcraft_docx::read(&out).unwrap()).unwrap());
assert_eq!(text(&again, "word/fonts/Roboto-bold.ttf"), "bold font bytes");
}
// ---- native Word ink: opaque preservation, never re-encoded as WordCraft freeforms ----
const INK_NS: &str = "http://schemas.microsoft.com/office/word/2010/wordprocessingInk";
const INK_XML: &str = r##"<ink xmlns="http://www.w3.org/2003/InkML"><definitions><context xml:id="ctx"><inkSource xml:id="src"><traceFormat><channel name="X" type="integer" units="cm"/><channel name="Y" type="integer" units="cm"/></traceFormat></inkSource></context><brush xml:id="blue"><brushProperty name="color" value="#2563EB"/><brushProperty name="width" value="0.08" units="cm"/></brush></definitions><trace contextRef="#ctx" brushRef="#blue">0 0, 1 1, 2 0</trace></ink>"##;
const INK_TYPES: &str = r#"<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Override PartName="/word/ink/ink1.xml" ContentType="application/inkml+xml"/><Default Extension="png" ContentType="image/png"/></Types>"#;
fn ink_docx(fallback: bool, alias: bool) -> Vec<u8> {
let content = r#"<q:contentPart xmlns:q="http://schemas.microsoft.com/office/word/2010/wordml" r:id="rInk"><q:xfrm><a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/></q:xfrm></q:contentPart>"#;
let drawing = format!(
r#"<w:drawing><wp:inline><wp:extent cx="1828800" cy="914400"/><wp:docPr id="1" name="Study ink"/><a:graphic><a:graphicData uri="{INK_NS}">{content}</a:graphicData></a:graphic></wp:inline></w:drawing>"#
);
let prefix = if alias { "pen" } else { "wpi" };
let markup = if fallback {
format!(
r##"<mc:AlternateContent><mc:Choice xmlns:{prefix}="{INK_NS}" Requires="{prefix}">{drawing}</mc:Choice><mc:Fallback><w:pict><v:shape id="preview" style="width:144pt;height:72pt"><v:imagedata r:id="rPreview"/></v:shape></w:pict></mc:Fallback></mc:AlternateContent>"##
)
} else {
drawing
};
let body = format!("<w:p><w:r><w:t>Original</w:t></w:r></w:p><w:p><w:r>{markup}</w:r></w:p>");
let mut relationships = vec![("rInk", "customXml", "ink/ink1.xml")];
let mut extra = vec![("[Content_Types].xml", INK_TYPES), ("word/ink/ink1.xml", INK_XML)];
if fallback {
relationships.push(("rPreview", "image", "media/preview.png"));
extra.push(("word/media/preview.png", "original preview bytes"));
}
docx(&body, &relationships, &extra)
}
fn check_ink(files: &std::collections::BTreeMap<String, Vec<u8>>) -> String {
let body = text(files, "word/document.xml");
let [id] = attr_values(&body, "w14:contentPart", "r:id").try_into().unwrap_or_else(|_| panic!("ink reference lost: {body}"));
let (kind, target) = rel(&text(files, "word/_rels/document.xml.rels"), &id);
assert!(kind.ends_with("/customXml"));
let part = part_at("word", &target);
assert_eq!(text(files, &part), INK_XML);
assert!(text(files, "[Content_Types].xml").contains(&format!(r#"PartName="/{part}" ContentType="application/inkml+xml""#)));
part
}
#[test]
fn native_ink_survives_unrelated_edit_and_two_saves() {
let mut d = wordcraft_docx::read(&ink_docx(false, false)).unwrap();
let g = graphics(&d);
assert_eq!(g.len(), 1, "native ink must remain a model object");
let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() };
assert_eq!(graphic.kind.noun(), "ink");
assert!(graphic.source.is_some());
assert!(!graphic.items.is_empty(), "a missing preview must not silently leave a blank frame");
let p = std::sync::Arc::make_mut(&mut d.body[0]);
let Block::Para(p) = p else { panic!() };
p.delete(0, p.len()).unwrap();
p.insert_text(0, "Edited", &Default::default()).unwrap();
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
let files = unzip(&out);
check_ink(&files);
assert!(text(&files, "word/document.xml").contains("Edited"));
d = wordcraft_docx::read(&out).unwrap();
assert_eq!(graphics(&d).len(), 1);
}
}
#[test]
fn native_ink_keeps_alternate_content_and_preview_with_renamed_prefixes() {
for alias in [false, true] {
let mut d = wordcraft_docx::read(&ink_docx(true, alias)).unwrap();
let g = graphics(&d);
assert_eq!(g.len(), 1, "fallback must not replace native ink");
let InlineObject::Graphic { graphic, .. } = &g[0] else { panic!() };
assert!(
graphic.items.iter().any(|item| matches!(item, wordcraft_doc::graphic::GraphicItem::Image { media, .. } if d.media.contains_key(media)))
);
for _ in 0..2 {
let out = wordcraft_docx::write(&d).unwrap();
let files = unzip(&out);
check_ink(&files);
let body = text(&files, "word/document.xml");
assert!(body.contains("<mc:AlternateContent>") && body.contains("<mc:Fallback>"));
let [id] = attr_values(&body, "v:imagedata", "r:id").try_into().unwrap();
let (kind, target) = rel(&text(&files, "word/_rels/document.xml.rels"), &id);
assert!(kind.ends_with("/image"));
assert_eq!(text(&files, &part_at("word", &target)), "original preview bytes");
d = wordcraft_docx::read(&out).unwrap();
assert_eq!(graphics(&d).len(), 1);
}
}
}
#[test]
fn native_ink_missing_part_uses_available_fallback() {
let bytes = ink_docx(true, true);
let mut files = unzip(&bytes);
files.remove("word/ink/ink1.xml");
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
assert!(graphics(&d).is_empty());
assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. })));
let files = unzip(&wordcraft_docx::write(&d).unwrap());
assert!(!text(&files, "word/document.xml").contains("w14:contentPart"));
assert!(files.values().any(|bytes| bytes == b"original preview bytes"));
}
#[test]
fn native_ink_missing_optional_preview_keeps_native_part() {
let mut files = unzip(&ink_docx(true, false));
files.remove("word/media/preview.png");
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
assert_eq!(graphics(&d).len(), 1);
let files = unzip(&wordcraft_docx::write(&d).unwrap());
check_ink(&files);
assert!(!text(&files, "word/document.xml").contains("<mc:Fallback>"));
}
#[test]
fn native_ink_requires_the_bound_namespace_and_ink_content_type() {
for wrong_namespace in [true, false] {
let mut files = unzip(&ink_docx(true, false));
let name = if wrong_namespace { "word/document.xml" } else { "[Content_Types].xml" };
let old = text(&files, name);
let changed = if wrong_namespace {
old.replace(&format!(r#"xmlns:wpi="{INK_NS}""#), r#"xmlns:wpi="urn:future:unsupported""#)
} else {
old.replace("application/inkml+xml", "application/xml")
};
files.insert(name.into(), changed.into_bytes());
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
assert!(graphics(&d).is_empty());
assert!(paras(&d).iter().flat_map(|p| &p.objects).any(|o| matches!(o, InlineObject::Image { .. })));
}
}
#[test]
fn native_ink_copies_have_independent_parts_and_current_frames() {
let mut d = wordcraft_docx::read(&ink_docx(true, false)).unwrap();
let [mut object] = graphics(&d).try_into().unwrap();
let InlineObject::Graphic { w, h, float, .. } = &mut object else { panic!() };
(*w, *h) = (288.0, 144.0);
*float = wordcraft_doc::para::Float { wrap: Wrap::Square, h_rel: Anchor::Page, v_rel: Anchor::Page, x: 72.0, y: 36.0, ..Default::default() };
let mut p = Paragraph::new();
p.insert_object(0, object.clone(), &Default::default()).unwrap();
p.insert_object(p.len(), object, &Default::default()).unwrap();
d.body = vec![wordcraft_doc::para_block(p)];
let out = wordcraft_docx::write(&d).unwrap();
let files = unzip(&out);
let body = text(&files, "word/document.xml");
let ids = attr_values(&body, "w14:contentPart", "r:id");
assert_eq!(ids.len(), 2);
let parts: Vec<_> = ids.iter().map(|id| part_at("word", &rel(&text(&files, "word/_rels/document.xml.rels"), id).1)).collect();
assert_ne!(parts[0], parts[1]);
for part in parts {
assert_eq!(text(&files, &part), INK_XML);
}
assert_eq!(attr_values(&body, "wp:extent", "cx"), ["3657600", "3657600"]);
assert_eq!(attr_values(&body, "a:ext", "cx"), ["3657600", "3657600"]);
assert_eq!(attr_values(&body, "v:shape", "style"), ["width:288pt;height:144pt", "width:288pt;height:144pt"]);
assert_eq!(body.matches("<wp:posOffset>914400</wp:posOffset>").count(), 2);
let back = wordcraft_docx::read(&out).unwrap();
let g = graphics(&back);
assert_eq!(g.len(), 2);
for object in g {
let InlineObject::Graphic { w, h, float, .. } = object else { panic!() };
assert_eq!((w, h, float.x, float.y), (288.0, 144.0, 72.0, 36.0));
}
}
#[test]
fn native_ink_markup_limit_does_not_write_a_dangling_reference() {
let mut files = unzip(&ink_docx(false, false));
let body = text(&files, "word/document.xml").replace("<a:graphicData ", &format!(r#"<a:graphicData padding="{}" "#, "x".repeat(1024 * 1024 + 1)));
files.insert("word/document.xml".into(), body.into_bytes());
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
let d = wordcraft_docx::read(&zip(&entries)).unwrap();
let files = unzip(&wordcraft_docx::write(&d).unwrap());
assert!(!text(&files, "word/document.xml").contains("w14:contentPart"));
}
#[test]
fn native_ink_preserves_inner_transform_and_rotation_padding_on_unrelated_saves() {
let mut files = unzip(&ink_docx(false, false));
let body = text(&files, "word/document.xml")
.replace("<wp:docPr", r#"<wp:effectExtent l="55468" t="512668" r="55468" b="512668"/><wp:docPr"#)
.replace("<q:xfrm>", r#"<q:xfrm rot="2700000">"#)
.replace(r#"<a:off x="0" y="0"/><a:ext cx="1828800" cy="914400"/>"#, r#"<a:off x="127000" y="254000"/><a:ext cx="914400" cy="457200"/>"#);
files.insert("word/document.xml".into(), body.into_bytes());
let entries: Vec<_> = files.iter().map(|(name, bytes)| (name.as_str(), bytes.as_slice())).collect();
let mut d = wordcraft_docx::read(&zip(&entries)).unwrap();
for _ in 0..2 {
let [object] = graphics(&d).try_into().unwrap();
let InlineObject::Graphic { float, .. } = object else { panic!() };
assert_eq!(float.spin().deg, 45.0);
assert!(float.effect.iter().all(|v| v.abs() < 0.001), "rotation padding is not counted twice: {:?}", float.effect);
let out = wordcraft_docx::write(&d).unwrap();
let files = unzip(&out);
check_ink(&files);
let body = text(&files, "word/document.xml");
assert_eq!(attr_values(&body, "a:off", "x"), ["127000"]);
assert_eq!(attr_values(&body, "a:ext", "cx"), ["914400"]);
assert_eq!(attr_values(&body, "w14:xfrm", "rot"), ["2700000"]);
d = wordcraft_docx::read(&out).unwrap();
}
}
/// Word draws a table of contents' entry links without the Hyperlink character style (measured in
/// Word for Windows); the import drops the style from them, keeping the link. A Hyperlink-styled run
/// outside a hyperlink, and a hyperlink in another field, keep it.
#[test]
fn toc_entry_links_lose_the_hyperlink_style() {
let styles = format!(
r#"<w:styles {W_NS}><w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
<w:style w:type="character" w:styleId="Hyperlink"><w:name w:val="Hyperlink"/><w:rPr><w:color w:val="0066FF"/><w:u w:val="single"/></w:rPr></w:style></w:styles>"#
);
let field = |instr: &str, inner: &str| {
format!(
r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText xml:space="preserve">{instr}</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r>{inner}</w:p><w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#
)
};
let link =
|t: &str| format!(r#"<w:hyperlink w:anchor="_Toc1"><w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>{t}</w:t></w:r></w:hyperlink>"#);
let body = [
field(r#" TOC \o "1-3" \h \z \u "#, &link("entry")),
field(" TOC ", r#"<w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr><w:t>styled</w:t></w:r>"#),
field(" QUOTE x ", &link("quoted")),
format!("<w:p>{}</w:p>", link("plain")),
r#"<w:p><w:bookmarkStart w:id="1" w:name="_Toc1"/><w:r><w:t>Target</w:t></w:r><w:bookmarkEnd w:id="1"/></w:p>"#.to_string(),
]
.concat();
let d = wordcraft_docx::read(&docx(&body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let run = |word: &str| {
paras(&d).into_iter().find_map(|p| p.text.find(word).map(|at| p.props_of_char(at).clone())).unwrap_or_else(|| panic!("no run with {word}"))
};
let entry = run("entry");
assert_eq!((entry.style.as_deref(), entry.link.as_deref()), (None, Some("#_Toc1")), "a TOC entry: plain, still a link");
assert_eq!(run("styled").style.as_deref(), Some("Hyperlink"), "not in a hyperlink: kept");
assert_eq!(run("quoted").style.as_deref(), Some("Hyperlink"), "another field: kept");
assert_eq!(run("plain").style.as_deref(), Some("Hyperlink"), "outside fields: kept");
}
/// A style or paragraph whose `w:numPr` gives only a level (`w:ilvl`) takes the list from its
/// style chain (ECMA-376 §17.3.1.19): a Heading 3 based on a numbered Heading 2 is level 3 of the
/// same list. WordCraft dropped the level, so Heading 3s numbered as Heading 2s (6.15 where Word
/// shows 6.4.5 in NIH's monitoring-plan template).
#[test]
fn a_list_level_without_a_list_takes_the_style_s_list() {
let styles = format!(
r#"<w:styles {W_NS}>
<w:style w:type="paragraph" w:styleId="Normal" w:default="1"><w:name w:val="Normal"/></w:style>
<w:style w:type="paragraph" w:styleId="Heading2"><w:name w:val="heading 2"/><w:basedOn w:val="Normal"/><w:pPr><w:numPr><w:ilvl w:val="1"/><w:numId w:val="15"/></w:numPr></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading3"><w:name w:val="heading 3"/><w:basedOn w:val="Heading2"/><w:pPr><w:numPr><w:ilvl w:val="2"/></w:numPr></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Heading4"><w:name w:val="heading 4"/><w:basedOn w:val="Heading3"/><w:pPr><w:numPr><w:ilvl w:val="3"/></w:numPr></w:pPr></w:style>
<w:style w:type="paragraph" w:styleId="Loose"><w:name w:val="Loose"/><w:basedOn w:val="Normal"/><w:pPr><w:numPr><w:ilvl w:val="4"/></w:numPr></w:pPr></w:style>
</w:styles>"#
);
let body = r#"<w:p><w:pPr><w:pStyle w:val="Heading3"/></w:pPr><w:r><w:t>three</w:t></w:r></w:p>
<w:p><w:pPr><w:pStyle w:val="Heading2"/><w:numPr><w:ilvl w:val="2"/></w:numPr></w:pPr><w:r><w:t>direct level</w:t></w:r></w:p>
<w:p><w:pPr><w:numPr><w:ilvl w:val="1"/></w:numPr></w:pPr><w:r><w:t>no list anywhere</w:t></w:r></w:p>"#;
let d = wordcraft_docx::read(&docx(body, &[("rId1", "styles", "styles.xml")], &[("word/styles.xml", &styles)])).unwrap();
let num = |id: &str| d.styles.get(id).and_then(|s| s.para.numbering).map(|n| (n.num, n.level));
assert_eq!(num("Heading3"), Some((15, 2)), "Heading 3 is level 3 of Heading 2's list");
assert_eq!(num("Heading4"), Some((15, 3)), "through two level-only styles");
assert_eq!(num("Loose"), None, "no list in the chain: no numbering");
let p = paras(&d);
assert_eq!(d.styles.resolve_para(&p[0].props).numbering.map(|n| (n.num, n.level)), Some((15, 2)));
assert_eq!(p[1].props.numbering.map(|n| (n.num, n.level)), Some((15, 2)), "a paragraph's own level, its style's list");
assert_eq!(p[2].props.numbering, None);
// Saved and read again it is an ordinary list reference.
let again = wordcraft_docx::read(&wordcraft_docx::write(&d).unwrap()).unwrap();
assert_eq!(again.styles.get("Heading3").and_then(|s| s.para.numbering).map(|n| (n.num, n.level)), Some((15, 2)));
}