rust-docx 0.2.11

Rust parser, document model, and serializer for DOCX and legacy Word documents
Documentation
use rust_docx::{
    build_doc_model, extract_document_blocks_with_options, serialize_doc_model, DocNode,
    ExtractDocumentBlocksOptions, OoxmlPackage, OoxmlPart, ParagraphChildNode,
};
use std::collections::HashMap;

fn package(body: &str, styles: &str) -> OoxmlPackage {
    let xml = format!(
        r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>{body}</w:body></w:document>"#
    );
    let parts = [
        ("word/document.xml", xml.as_str()),
        ("word/styles.xml", styles),
    ]
    .into_iter()
    .map(|(name, content)| {
        (
            name.into(),
            OoxmlPart {
                name: name.into(),
                content: content.into(),
            },
        )
    })
    .collect();
    OoxmlPackage {
        parts,
        binary_assets: HashMap::new(),
        warnings: Vec::new(),
    }
}

#[test]
fn scale_retains_content_word_ranges_and_structured_style() {
    let source = package(
        r#"<w:p><w:r><w:rPr><w:w w:val="150"/></w:rPr><w:t>a😀b</w:t></w:r></w:p>"#,
        "",
    );
    let model = build_doc_model(&source);
    let output = extract_document_blocks_with_options(
        &model,
        ExtractDocumentBlocksOptions {
            include_words: true,
            ..Default::default()
        },
    );
    let block = &output.blocks[0];
    let details = block.details.to_json_value();
    assert_eq!(block.content, "a😀b");
    assert_eq!(details["inlines"][0]["end"], 4);
    assert_eq!(details["inlines"][0]["style"]["characterScalePercent"], 150);
    let units: Vec<_> = block.content.encode_utf16().collect();
    for word in block.words.as_ref().unwrap() {
        let reference = &word.source_refs[0];
        assert_eq!(
            String::from_utf16(&units[reference.start.unwrap()..reference.end.unwrap()]).unwrap(),
            word.content
        );
    }
    let mut normal = model.clone();
    let DocNode::Paragraph(p) = &mut normal.nodes[0] else {
        panic!()
    };
    let ParagraphChildNode::Text(run) = &mut p.children[0] else {
        panic!()
    };
    run.style.as_mut().unwrap().character_scale_percent = Some(100);
    let normal_output = extract_document_blocks_with_options(
        &normal,
        ExtractDocumentBlocksOptions {
            include_words: true,
            ..Default::default()
        },
    );
    assert_eq!(normal_output.blocks[0].words, block.words);
    assert_eq!(output.geometry_status, "requires_layout");
}

#[test]
fn scale_inheritance_and_explicit_reset_survive_rebuild() {
    let source = package(
        r#"<w:p><w:pPr><w:pStyle w:val="Wide"/></w:pPr><w:r><w:t>Inherited</w:t></w:r><w:r><w:rPr><w:w w:val="100"/></w:rPr><w:t>Reset</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="Wide"><w:name w:val="Wide"/><w:rPr><w:w w:val="150"/></w:rPr></w:style></w:styles>"#,
    );
    let mut model = build_doc_model(&source);
    let output =
        extract_document_blocks_with_options(&model, ExtractDocumentBlocksOptions::default());
    let details = output.blocks[0].details.to_json_value();
    assert_eq!(details["inlines"][0]["style"]["characterScalePercent"], 150);
    assert_eq!(details["inlines"][1]["style"]["characterScalePercent"], 100);
    let DocNode::Paragraph(p) = &mut model.nodes[0] else {
        panic!()
    };
    p.source_xml = None;
    let rebuilt = build_doc_model(&serialize_doc_model(&model, Some(&source)));
    assert_eq!(
        extract_document_blocks_with_options(&rebuilt, ExtractDocumentBlocksOptions::default())
            .blocks[0]
            .details
            .to_json_value()["inlines"],
        details["inlines"]
    );
}

#[test]
fn paragraph_mark_scale_edit_replaces_the_source_property() {
    let source = package(
        r#"<w:p><w:pPr><w:rPr><w:w w:val="150"/></w:rPr></w:pPr></w:p>"#,
        "",
    );
    let mut model = build_doc_model(&source);
    let DocNode::Paragraph(p) = &mut model.nodes[0] else {
        panic!()
    };
    p.paragraph_mark_style
        .as_mut()
        .unwrap()
        .character_scale_percent = Some(75);
    let rebuilt = build_doc_model(&serialize_doc_model(&model, Some(&source)));
    let DocNode::Paragraph(p) = &rebuilt.nodes[0] else {
        panic!()
    };
    assert_eq!(
        p.paragraph_mark_style
            .as_ref()
            .unwrap()
            .character_scale_percent,
        Some(75)
    );
}

#[test]
fn standard_scale_range_and_lexical_forms_are_preserved() {
    for (value, expected) in [
        ("1", Some(1)),
        ("100", Some(100)),
        ("600", Some(600)),
        ("0", Some(0)),
        ("0%", Some(0)),
        ("150%", Some(150)),
        ("+150", Some(150)),
        ("-0", Some(0)),
        ("000000000000000000000150%", Some(150)),
        ("601", None),
        ("1.5", None),
        ("150.5%", None),
        ("+150%", None),
    ] {
        let source = package(
            &format!(
                r#"<w:p><w:r><w:rPr><w:w w:val="{value}"/></w:rPr><w:t>Text</w:t></w:r></w:p>"#
            ),
            "",
        );
        let model = build_doc_model(&source);
        let DocNode::Paragraph(p) = &model.nodes[0] else {
            panic!()
        };
        let ParagraphChildNode::Text(run) = &p.children[0] else {
            panic!()
        };
        assert_eq!(
            run.style
                .as_ref()
                .and_then(|style| style.character_scale_percent),
            expected
        );
        assert_eq!(
            run.style
                .as_ref()
                .and_then(|style| style.source_character_scale.as_deref()),
            Some(value)
        );
    }
}

#[test]
fn raw_scale_survives_reconstruction_until_explicitly_replaced() {
    for value in ["0", "150%", "000150", "601", "1.5"] {
        let source = package(
            &format!(
                r#"<w:p><w:r><w:rPr><w:w w:val="{value}"/></w:rPr><w:t>Text</w:t></w:r></w:p>"#
            ),
            "",
        );
        let mut model = build_doc_model(&source);
        let DocNode::Paragraph(p) = &mut model.nodes[0] else {
            panic!()
        };
        p.source_xml = None;
        let serialized = serialize_doc_model(&model, Some(&source));
        assert!(serialized.parts["word/document.xml"]
            .content
            .contains(&format!(r#"<w:w w:val="{value}"/>"#)));
        let DocNode::Paragraph(p) = &mut model.nodes[0] else {
            panic!()
        };
        let ParagraphChildNode::Text(run) = &mut p.children[0] else {
            panic!()
        };
        run.style.as_mut().unwrap().character_scale_percent = Some(75);
        let serialized = serialize_doc_model(&model, Some(&source));
        assert!(serialized.parts["word/document.xml"]
            .content
            .contains(r#"<w:w w:val="75"/>"#));
    }
}

#[test]
fn missing_attribute_has_standard_default_without_overriding_omitted_property() {
    let source = package(
        r#"<w:p><w:pPr><w:pStyle w:val="Wide"/></w:pPr><w:r><w:t>Inherited</w:t></w:r><w:r><w:rPr><w:w/></w:rPr><w:t>Reset</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="Wide"><w:rPr><w:w w:val="150"/></w:rPr></w:style></w:styles>"#,
    );
    let model = build_doc_model(&source);
    let DocNode::Paragraph(p) = &model.nodes[0] else {
        panic!()
    };
    for (index, expected) in [(0, 150), (1, 100)] {
        let ParagraphChildNode::Text(run) = &p.children[index] else {
            panic!()
        };
        assert_eq!(
            run.style
                .as_ref()
                .and_then(|style| style.character_scale_percent),
            Some(expected)
        );
    }
}