rwml 0.1.0

Native Rust toolkit for Microsoft Word — read, write, edit, and render legacy .doc (Word 97-2003, [MS-DOC]) and modern .docx (OOXML): one document model, package-preserving edits, field evaluation, Markdown/HTML export, PDF preview
Documentation
#![cfg(feature = "docx")]

use std::io::Write;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

fn docx_fixture(parts: &[(&str, &str)]) -> Vec<u8> {
    let mut out = Vec::new();
    {
        let cursor = std::io::Cursor::new(&mut out);
        let mut zip = zip::ZipWriter::new(cursor);
        let opt = zip::write::SimpleFileOptions::default();
        for (name, body) in parts {
            zip.start_file(*name, opt).unwrap();
            zip.write_all(body.as_bytes()).unwrap();
        }
        zip.finish().unwrap();
    }
    out
}

fn plain_docx() -> Vec<u8> {
    docx_fixture(&[
        (
            "[Content_Types].xml",
            r#"<?xml version="1.0"?><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"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>"#,
        ),
        (
            "_rels/.rels",
            r#"<?xml version="1.0"?><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>"#,
        ),
        (
            "word/document.xml",
            r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:r><w:t>Hello CLI</w:t></w:r></w:p><w:p><w:r><w:t>Second line</w:t></w:r></w:p></w:body></w:document>"#,
        ),
    ])
}

fn write_temp_docx(name: &str, bytes: &[u8]) -> std::path::PathBuf {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    let path = std::env::temp_dir().join(format!(
        "rwml-cli-{name}-{}-{stamp}.docx",
        std::process::id()
    ));
    std::fs::write(&path, bytes).unwrap();
    path
}

fn temp_output_path(name: &str) -> std::path::PathBuf {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    std::env::temp_dir().join(format!(
        "rwml-cli-{name}-{}-{stamp}.docx",
        std::process::id()
    ))
}

#[cfg(feature = "render")]
fn temp_output_path_with_ext(name: &str, ext: &str) -> std::path::PathBuf {
    let stamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_nanos();
    std::env::temp_dir().join(format!(
        "rwml-cli-{name}-{}-{stamp}.{ext}",
        std::process::id()
    ))
}

fn run_rwml(args: &[&str]) -> std::process::Output {
    Command::new(env!("CARGO_BIN_EXE_rwml"))
        .args(args)
        .output()
        .expect("run rwml cli")
}

#[cfg(feature = "render")]
fn run_rwml_render(args: &[&str]) -> std::process::Output {
    let out = run_rwml(args);
    if out.status.success()
        || !String::from_utf8_lossy(&out.stderr).contains("requires the render feature")
    {
        return out;
    }

    Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string()))
        .current_dir(env!("CARGO_MANIFEST_DIR"))
        .args([
            "run",
            "--quiet",
            "--features",
            "render",
            "--bin",
            "rwml",
            "--",
        ])
        .args(args)
        .output()
        .expect("run render-enabled rwml cli")
}

#[test]
fn cli_extract_prints_plain_text() {
    let path = write_temp_docx("extract", &plain_docx());

    let out = run_rwml(&["extract", path.to_str().unwrap()]);

    assert!(
        out.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    assert_eq!(
        String::from_utf8(out.stdout).unwrap(),
        "Hello CLI\nSecond line"
    );
}

#[test]
fn cli_diagnose_prints_report_json() {
    let path = write_temp_docx("diagnose", &plain_docx());

    let out = run_rwml(&["diagnose", path.to_str().unwrap()]);

    assert!(
        out.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    let json = String::from_utf8(out.stdout).unwrap();
    assert!(json.starts_with(r#"{"format":"docx","#), "{json}");
    assert!(json.contains(r#""package_preserving":true"#), "{json}");
    assert!(json.ends_with("]}\n"), "{json}");
}

#[test]
fn cli_convert_supports_txt_markdown_and_html() {
    let path = write_temp_docx("convert", &plain_docx());
    let path = path.to_str().unwrap();

    let txt = run_rwml(&["convert", path, "txt"]);
    assert!(
        txt.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&txt.stderr)
    );
    assert_eq!(
        String::from_utf8(txt.stdout).unwrap(),
        "Hello CLI\nSecond line\n"
    );

    let md = run_rwml(&["convert", path, "md"]);
    assert!(
        md.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&md.stderr)
    );
    assert_eq!(
        String::from_utf8(md.stdout).unwrap(),
        "Hello CLI\n\nSecond line\n"
    );

    let html = run_rwml(&["convert", path, "html"]);
    assert!(
        html.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&html.stderr)
    );
    let html = String::from_utf8(html.stdout).unwrap();
    assert!(html.contains("<p>Hello CLI</p>"), "{html}");
    assert!(html.contains("<p>Second line</p>"), "{html}");
}

#[test]
fn cli_to_docx_writes_reopenable_docx() {
    let input = write_temp_docx("to-docx-input", &plain_docx());
    let output = temp_output_path("to-docx-output");

    let out = run_rwml(&["to-docx", input.to_str().unwrap(), output.to_str().unwrap()]);

    assert!(
        out.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    let converted = std::fs::read(&output).expect("converted output exists");
    let doc = rwml::Document::open(&converted).expect("converted docx reopens");
    assert_eq!(doc.text(), "Hello CLI\nSecond line");
}

#[cfg(feature = "render")]
#[test]
fn cli_to_pdf_writes_pdf_and_report_json() {
    let input = write_temp_docx("to-pdf-input", &plain_docx());
    let output = temp_output_path_with_ext("to-pdf-output", "pdf");
    let report = temp_output_path_with_ext("to-pdf-report", "json");

    let out = run_rwml_render(&[
        "to-pdf",
        input.to_str().unwrap(),
        output.to_str().unwrap(),
        "--report-json",
        report.to_str().unwrap(),
    ]);

    assert!(
        out.status.success(),
        "stderr={}",
        String::from_utf8_lossy(&out.stderr)
    );
    let pdf = std::fs::read(&output).expect("pdf output exists");
    assert!(
        pdf.starts_with(b"%PDF"),
        "pdf output must start with PDF header"
    );

    let json = std::fs::read_to_string(&report).expect("report output exists");
    assert!(json.contains(r#""pages":"#), "{json}");
    assert!(json.contains(r#""warnings":"#), "{json}");
}