rust-docx 0.2.4

Rust parser, document model, and serializer for DOCX and legacy Word documents
Documentation
use std::collections::HashMap;

use rust_docx::{
    build_doc_model, extract_document_blocks, serialize_doc_model, DocNode, OoxmlPackage,
    OoxmlPart, ParagraphChildNode,
};

fn package(body: &str, styles: &str) -> OoxmlPackage {
    let document = format!(
        r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body>{body}</w:body></w:document>"#
    );
    let mut parts = HashMap::new();
    for (name, content) in [
        ("word/document.xml", document.as_str()),
        ("word/styles.xml", styles),
        (
            "word/_rels/document.xml.rels",
            r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="link" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.org/doc#old" TargetMode="External"/></Relationships>"#,
        ),
    ] {
        parts.insert(
            name.into(),
            OoxmlPart {
                name: name.into(),
                content: content.into(),
            },
        );
    }
    OoxmlPackage {
        parts,
        binary_assets: HashMap::new(),
        warnings: Vec::new(),
    }
}

#[test]
fn capitalization_preserves_text_and_survives_rebuild() {
    let source = package(
        r#"<w:p><w:r><w:rPr><w:caps/></w:rPr><w:t>aß😀</w:t></w:r><w:r><w:rPr><w:smallCaps/></w:rPr><w:t>Mixed</w:t></w:r></w:p>"#,
        "",
    );
    let mut model = build_doc_model(&source);
    let blocks = extract_document_blocks(&model);
    let details = blocks.blocks[0].details.to_json_value();
    assert_eq!(blocks.blocks[0].content, "aß😀Mixed");
    assert_eq!(details["inlines"][0]["end"], 4);
    assert_eq!(details["inlines"][0]["style"]["allCaps"], true);
    assert_eq!(details["inlines"][1]["style"]["smallCaps"], true);
    let DocNode::Paragraph(p) = &mut model.nodes[0] else {
        panic!()
    };
    p.source_xml = None;
    let reparsed = build_doc_model(&serialize_doc_model(&model, Some(&source)));
    assert_eq!(
        extract_document_blocks(&reparsed).blocks[0]
            .details
            .to_json_value()["inlines"],
        details["inlines"]
    );
}

#[test]
fn capitalization_direct_false_overrides_inherited_true() {
    let source = package(
        r#"<w:p><w:pPr><w:pStyle w:val="Upper"/></w:pPr><w:r><w:t>Inherited</w:t></w:r><w:r><w:rPr><w:caps w:val="0"/><w:smallCaps w:val="0"/></w:rPr><w:t>Direct</w:t></w:r></w:p>"#,
        r#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="paragraph" w:styleId="Upper"><w:name w:val="Upper"/><w:rPr><w:caps/><w:smallCaps/></w:rPr></w:style></w:styles>"#,
    );
    let mut model = build_doc_model(&source);
    let blocks = extract_document_blocks(&model);
    let details = blocks.blocks[0].details.to_json_value();
    assert_eq!(details["inlines"][0]["style"]["allCaps"], true);
    assert_eq!(details["inlines"][1]["style"]["allCaps"], false);
    assert_eq!(details["inlines"][1]["style"]["smallCaps"], false);
    let DocNode::Paragraph(p) = &mut model.nodes[0] else {
        panic!()
    };
    p.source_xml = None;
    let reparsed = build_doc_model(&serialize_doc_model(&model, Some(&source)));
    assert_eq!(
        extract_document_blocks(&reparsed).blocks[0]
            .details
            .to_json_value()["inlines"][1]["style"]["allCaps"],
        false
    );
}

#[test]
fn paragraph_mark_capitalization_edits_replace_source_properties() {
    let source = package(
        r#"<w:p><w:pPr><w:rPr><w:caps/><w:smallCaps/></w:rPr></w:pPr></w:p>"#,
        "",
    );
    let mut model = build_doc_model(&source);
    let DocNode::Paragraph(p) = &mut model.nodes[0] else {
        panic!()
    };
    let style = p.paragraph_mark_style.as_mut().unwrap();
    style.all_caps = Some(false);
    style.small_caps = Some(false);
    let serialized = serialize_doc_model(&model, Some(&source));
    let xml = &serialized.parts["word/document.xml"].content;
    assert_eq!(xml.matches("<w:caps").count(), 1);
    assert!(!xml.contains("<w:caps/>"));
    assert!(!xml.contains("<w:smallCaps/>"));
    let model = build_doc_model(&serialized);
    let DocNode::Paragraph(p) = &model.nodes[0] else {
        panic!()
    };
    assert_eq!(
        p.paragraph_mark_style.as_ref().unwrap().all_caps,
        Some(false)
    );
    assert_eq!(
        p.paragraph_mark_style.as_ref().unwrap().small_caps,
        Some(false)
    );
}

#[test]
fn hyperlink_anchors_decode_xml_entities_once() {
    for (encoded, decoded) in [
        ("plain_é", "plain_é"),
        ("named_&amp;_&lt;_&gt;_&quot;_&apos;", "named_&_<_>_\"_'"),
        ("numeric_&#38;_&#x1F600;", "numeric_&_😀"),
        ("literal_&amp;amp;_&#38;lt;_&amp;#38;", "literal_&amp;_&lt;_&#38;"),
    ] {
        let body = format!(
            r#"<w:p><w:bookmarkStart w:id="0" w:name="{encoded}"/><w:r><w:t>Target</w:t></w:r><w:bookmarkEnd w:id="0"/></w:p><w:p><w:hyperlink w:anchor="{encoded}"><w:r><w:t>Jump</w:t></w:r></w:hyperlink></w:p>"#
        );
        let source = package(&body, "");
        let mut model = build_doc_model(&source);
        let DocNode::Paragraph(paragraph) = &mut model.nodes[1] else {
            panic!()
        };
        let ParagraphChildNode::Text(run) = &paragraph.children[0] else {
            panic!()
        };
        let expected = format!("#{decoded}");
        assert_eq!(run.link.as_deref(), Some(expected.as_str()));
        paragraph.source_xml = None;
        let reparsed = build_doc_model(&serialize_doc_model(&model, Some(&source)));
        let DocNode::Paragraph(paragraph) = &reparsed.nodes[1] else {
            panic!()
        };
        let ParagraphChildNode::Text(run) = &paragraph.children[0] else {
            panic!()
        };
        assert_eq!(run.link.as_deref(), Some(expected.as_str()));
    }
}

#[test]
fn hyperlink_anchors_preserve_decoded_edge_whitespace() {
    let source = package(
        r#"<w:p><w:bookmarkStart w:id="0" w:name="&#32;A&#32;"/><w:r><w:t>Target</w:t></w:r><w:bookmarkEnd w:id="0"/></w:p><w:p><w:hyperlink w:anchor="&#32;A&#32;"><w:r><w:t>Jump</w:t></w:r></w:hyperlink></w:p>"#,
        "",
    );
    let model = build_doc_model(&source);
    let DocNode::Paragraph(paragraph) = &model.nodes[1] else {
        panic!()
    };
    let ParagraphChildNode::Text(run) = &paragraph.children[0] else {
        panic!()
    };
    assert_eq!(run.link.as_deref(), Some("# A "));
}

#[test]
fn hyperlink_anchors_replace_only_the_external_fragment() {
    let mut source = package(
        r#"<w:p><w:hyperlink r:id="link"><w:r><w:t>Original</w:t></w:r></w:hyperlink><w:hyperlink r:id="link" w:anchor="new&amp;section&#38;literal&amp;amp;"><w:r><w:t>Replaced</w:t></w:r></w:hyperlink></w:p>"#,
        "",
    );
    source.parts.get_mut("word/_rels/document.xml.rels").unwrap().content =
        r#"<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="link" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink" Target="https://example.org/doc?first=1&amp;literal=&amp;amp;#old" TargetMode="External"/></Relationships>"#.into();
    let model = build_doc_model(&source);
    let DocNode::Paragraph(paragraph) = &model.nodes[0] else {
        panic!()
    };
    let links: Vec<_> = paragraph.children.iter().filter_map(|child| {
        if let ParagraphChildNode::Text(run) = child {
            run.link.as_deref()
        } else {
            None
        }
    }).collect();
    assert_eq!(links, vec![
        "https://example.org/doc?first=1&literal=&amp;#old",
        "https://example.org/doc?first=1&literal=&amp;#new&section&literal&amp;",
    ]);
}

#[test]
fn hyperlink_fields_keep_nested_scopes_and_concatenate_instructions() {
    let source = package(
        r#"<w:p><w:hyperlink r:id="link" w:anchor="new"><w:r><w:t>A</w:t></w:r></w:hyperlink><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>HYPER</w:instrText></w:r><w:r><w:instrText>LINK \l bookmark</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>B</w:t></w:r><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>HYPERLINK "https://example.org/inner"</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>C</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r><w:r><w:t>D</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r><w:r><w:t>E</w:t></w:r></w:p>"#,
        "",
    );
    let model = build_doc_model(&source);
    let DocNode::Paragraph(p) = &model.nodes[0] else {
        panic!()
    };
    let runs: Vec<_> = p
        .children
        .iter()
        .filter_map(|child| {
            if let ParagraphChildNode::Text(run) = child {
                Some((run.text.as_str(), run.link.as_deref()))
            } else {
                None
            }
        })
        .collect();
    assert_eq!(
        runs,
        vec![
            ("A", Some("https://example.org/doc#new")),
            ("B", Some("#bookmark")),
            ("C", Some("https://example.org/inner")),
            ("D", Some("#bookmark")),
            ("E", None)
        ]
    );
}

#[test]
fn field_link_scopes_can_change_inside_one_run() {
    let source = package(
        r#"<w:p><w:r><w:t/><w:footnoteReference w:id="-1"/><w:t>A</w:t><w:fldChar w:fldCharType="begin"/><w:instrText>HYPERLINK "https://example.org/one"</w:instrText><w:fldChar w:fldCharType="separate"/><w:t/><w:cr/><w:noBreakHyphen/><w:softHyphen/><w:t>B</w:t><w:fldChar w:fldCharType="end"/><w:t>C</w:t><w:fldChar w:fldCharType="begin"/><w:instrText>HYPERLINK \l second</w:instrText><w:fldChar w:fldCharType="separate"/><w:t>D</w:t><w:fldChar w:fldCharType="end"/><w:t>E</w:t></w:r></w:p>"#,
        "",
    );
    let model = build_doc_model(&source);
    let DocNode::Paragraph(p) = &model.nodes[0] else {
        panic!()
    };
    let links: Vec<_> = p
        .children
        .iter()
        .filter_map(|child| {
            if let ParagraphChildNode::Text(run) = child {
                Some(run.link.as_deref())
            } else {
                None
            }
        })
        .collect();
    assert_eq!(
        links,
        vec![
            None,
            Some("https://example.org/one"),
            Some("https://example.org/one"),
            Some("https://example.org/one"),
            Some("https://example.org/one"),
            None,
            Some("#second"),
            None
        ]
    );
}

#[test]
fn deleted_rows_do_not_enter_visible_table_or_merge_spans() {
    let source = package(
        r#"<w:tbl><w:tblGrid><w:gridCol w:w="2000"/></w:tblGrid><w:tr><w:tc><w:tcPr><w:vMerge w:val="restart"/></w:tcPr><w:p><w:r><w:t>Visible</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:trPr><w:del w:id="1"/></w:trPr><w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p><w:r><w:t>Removed</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:tcPr><w:vMerge/></w:tcPr><w:p/></w:tc></w:tr></w:tbl>"#,
        "",
    );
    let model = build_doc_model(&source);
    let DocNode::Table(table) = &model.nodes[0] else {
        panic!()
    };
    assert_eq!(table.rows.len(), 2);
    assert_eq!(
        table.rows[0].cells[0].style.as_ref().unwrap().row_span,
        Some(2)
    );
    let block = &extract_document_blocks(&model).blocks[0];
    assert!(!block.content.contains("Removed"));
    assert!(source.parts["word/document.xml"]
        .content
        .contains("Removed"));
}

#[test]
fn images_retain_interleaved_order_and_repeated_occurrences() {
    let drawing = r#"<w:pict><v:shape style="width:20pt;height:20pt"><v:imagedata r:id="image"/></v:shape></w:pict>"#;
    let mut source = package(&format!("<w:p><w:r><w:t>Before</w:t>{drawing}<w:t>Between</w:t>{drawing}<w:t>After</w:t></w:r></w:p>"), "");
    let relationships = &mut source
        .parts
        .get_mut("word/_rels/document.xml.rels")
        .unwrap()
        .content;
    *relationships = relationships.replace("</Relationships>", r#"<Relationship Id="image" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/a.png"/></Relationships>"#);
    source.binary_assets.insert(
        "word/media/a.png".into(),
        vec![137, 80, 78, 71, 13, 10, 26, 10],
    );
    let model = build_doc_model(&source);
    let DocNode::Paragraph(p) = &model.nodes[0] else {
        panic!()
    };
    let order: Vec<_> = p
        .children
        .iter()
        .map(|child| match child {
            ParagraphChildNode::Text(run) => run.text.as_str(),
            ParagraphChildNode::Image(_) => "image",
            _ => "control",
        })
        .collect();
    assert_eq!(order, vec!["Before", "image", "Between", "image", "After"]);
    let output = extract_document_blocks(&model);
    let block = &output.blocks[0];
    let details = block.details.to_json_value();
    assert_eq!(block.children.len(), 2);
    assert_ne!(block.children[0].id, block.children[1].id);
    assert_eq!(details["inlines"][1]["offset"], 6);
    assert_eq!(details["inlines"][3]["offset"], 13);
}

#[test]
fn field_ranges_survive_unicode_textboxes_and_alternate_content() {
    let picture = r#"<w:pict><v:shape style="width:20pt;height:20pt"><v:imagedata r:id="image"/></v:shape></w:pict>"#;
    let textbox = r#"<w:pict><v:shape style="width:100pt;height:30pt"><v:textbox><w:txbxContent><w:p><w:r><w:t>é😀</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape></w:pict>"#;
    let alternate = format!(
        r#"<mc:AlternateContent><mc:Choice Requires="v">{picture}</mc:Choice><mc:Fallback>{picture}</mc:Fallback></mc:AlternateContent>"#
    );
    let mut source = package(
        &format!(
            r#"<w:p><w:r><w:t>Start</w:t>{textbox}<w:fldChar w:fldCharType="begin"/><w:instrText>HYPERLINK \l target</w:instrText><w:fldChar w:fldCharType="separate"/><w:t>Après😀</w:t>{alternate}<w:t>Linked</w:t><w:fldChar w:fldCharType="end"/><w:t>End</w:t></w:r></w:p>"#
        ),
        "",
    );
    let relationships = &mut source
        .parts
        .get_mut("word/_rels/document.xml.rels")
        .unwrap()
        .content;
    *relationships = relationships.replace("</Relationships>", r#"<Relationship Id="image" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/a.png"/></Relationships>"#);
    source.binary_assets.insert(
        "word/media/a.png".into(),
        vec![137, 80, 78, 71, 13, 10, 26, 10],
    );
    let model = build_doc_model(&source);
    let DocNode::Paragraph(p) = &model.nodes[0] else {
        panic!()
    };
    let text: Vec<_> = p
        .children
        .iter()
        .filter_map(|child| {
            if let ParagraphChildNode::Text(run) = child {
                Some((run.text.as_str(), run.link.as_deref()))
            } else {
                None
            }
        })
        .collect();
    assert_eq!(
        text,
        vec![
            ("Start", None),
            ("Après😀", Some("#target")),
            ("Linked", Some("#target")),
            ("End", None)
        ]
    );
    assert_eq!(
        p.children
            .iter()
            .filter(|child| matches!(child, ParagraphChildNode::Image(_)))
            .count(),
        2
    );
    let output = extract_document_blocks(&model);
    let details = output.blocks[0].details.to_json_value();
    assert_eq!(details["inlines"][2]["start"], 5);
    assert_eq!(details["inlines"][2]["end"], 12);
}