wordcraft/crates/docx/tests/fixtures.rs

1078 lines
72 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};
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()
}
fn paras(d: &Document) -> Vec<&Paragraph> {
d.body.iter().filter_map(|b| b.as_para()).collect()
}
#[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::Field { instr: r#"TOC \o "1-3" \h \z \u"#.into(), result: String::new(), locked: false });
assert_eq!(p[0].objects[1], InlineObject::Field { instr: r#"PAGEREF _Toc1 \h"#.into(), result: "1".into(), locked: 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].text, "");
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 });
// 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);
assert_eq!(p[0].text, "siteXABC\u{F04A}\u{2011}\u{AD}\n\u{C}\u{E} e&T");
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].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:numFmt w:val="lowerRoman"/></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);
// 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 }]);
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) } 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, *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:bCs/><w:rPrChange w:id="#), "{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);
}