undocx 0.5.2

DOCX to Markdown converter written in Rust
Documentation
use undocx::{ConvertOptions, DocxToMarkdown, Error};
use rs_docx::document::{
    BodyContent, Comment, CommentReference, Comments, EndNote, EndNotes, EndnoteReference,
    FootNote, FootNotes, FootnoteReference, Paragraph, Run, RunContent,
};
use rs_docx::Docx;
use std::borrow::Cow;
use std::collections::HashSet;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

#[derive(Debug, Clone)]
struct Lcg(u64);

impl Lcg {
    fn new(seed: u64) -> Self {
        Self(seed)
    }

    fn next_u64(&mut self) -> u64 {
        self.0 = self
            .0
            .wrapping_mul(6364136223846793005)
            .wrapping_add(1442695040888963407);
        self.0
    }

    fn next_bool(&mut self) -> bool {
        (self.next_u64() & 1) == 1
    }

    fn next_inclusive_i32(&mut self, low: i32, high: i32) -> i32 {
        let span = (high - low + 1) as u64;
        low + (self.next_u64() % span) as i32
    }
}

fn temp_docx_path(prefix: &str) -> PathBuf {
    let nanos = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system time must be after UNIX_EPOCH")
        .as_nanos();
    std::env::temp_dir().join(format!(
        "undocx_invariant_{}_{}_{}.docx",
        prefix,
        std::process::id(),
        nanos
    ))
}

fn mk_comment(id: i32, text: String) -> Comment<'static> {
    Comment {
        id: Some(id as isize),
        author: Cow::Borrowed("invariant"),
        content: Paragraph::default().push_text(text),
    }
}

fn mk_footnote(id: i32, text: String) -> FootNote<'static> {
    FootNote {
        id: Some(id as isize),
        content: vec![BodyContent::Paragraph(Paragraph::default().push_text(text))],
        ..Default::default()
    }
}

fn mk_endnote(id: i32, text: String) -> EndNote<'static> {
    EndNote {
        id: Some(id as isize),
        content: vec![BodyContent::Paragraph(Paragraph::default().push_text(text))],
        ..Default::default()
    }
}

#[test]
fn randomized_reference_validation_invariants() {
    let mut rng = Lcg::new(0xD02C_2026_0214);
    let iterations = 24;

    for i in 0..iterations {
        let mut available_footnotes = HashSet::new();
        let mut available_comments = HashSet::new();
        let mut available_endnotes = HashSet::new();

        for id in 1..=3 {
            if rng.next_bool() {
                available_footnotes.insert(id);
            }
            if rng.next_bool() {
                available_comments.insert(id);
            }
            if rng.next_bool() {
                available_endnotes.insert(id);
            }
        }

        let mut docx = Docx::default();
        if !available_footnotes.is_empty() {
            let mut content = available_footnotes
                .iter()
                .map(|id| mk_footnote(*id, format!("fn-{}", id)))
                .collect::<Vec<_>>();
            content.sort_by_key(|n| n.id);
            docx.footnotes = Some(FootNotes { content });
        }
        if !available_comments.is_empty() {
            let mut comments = available_comments
                .iter()
                .map(|id| mk_comment(*id, format!("comment-{}", id)))
                .collect::<Vec<_>>();
            comments.sort_by_key(|n| n.id);
            docx.comments = Some(Comments { comments });
        }
        if !available_endnotes.is_empty() {
            let mut content = available_endnotes
                .iter()
                .map(|id| mk_endnote(*id, format!("en-{}", id)))
                .collect::<Vec<_>>();
            content.sort_by_key(|n| n.id);
            docx.endnotes = Some(EndNotes { content });
        }

        let mut expected_missing = false;
        for p in 0..5 {
            let mut para = Paragraph::default().push_text(format!("case-{}-p{}", i, p));

            let fn_id = rng.next_inclusive_i32(1, 4);
            let comment_id = rng.next_inclusive_i32(1, 4);
            let en_id = rng.next_inclusive_i32(1, 4);

            if rng.next_bool() {
                if !available_footnotes.contains(&fn_id) {
                    expected_missing = true;
                }
                para = para.push(Run::default().push(RunContent::FootnoteReference(
                    FootnoteReference {
                        id: Some(Cow::Owned(fn_id.to_string())),
                        ..Default::default()
                    },
                )));
            }

            if rng.next_bool() {
                if !available_comments.contains(&comment_id) {
                    expected_missing = true;
                }
                para = para.push(Run::default().push(RunContent::CommentReference(
                    CommentReference {
                        id: Some(Cow::Owned(comment_id.to_string())),
                    },
                )));
            }

            if rng.next_bool() {
                if !available_endnotes.contains(&en_id) {
                    expected_missing = true;
                }
                para = para.push(Run::default().push(RunContent::EndnoteReference(
                    EndnoteReference {
                        id: Some(Cow::Owned(en_id.to_string())),
                        ..Default::default()
                    },
                )));
            }

            docx.document.push(para);
        }

        let path = temp_docx_path(&format!("case_{}", i));
        docx.write_file(&path)
            .expect("failed to write invariant test docx");

        let converter_relaxed = DocxToMarkdown::new(ConvertOptions::default());
        let relaxed = converter_relaxed.convert(&path);
        assert!(
            relaxed.is_ok(),
            "relaxed conversion must not fail on iteration {}: {:?}",
            i,
            relaxed.err()
        );

        let strict_converter = DocxToMarkdown::new(ConvertOptions {
            strict_reference_validation: true,
            ..Default::default()
        });
        let strict = strict_converter.convert(&path);
        let _ = std::fs::remove_file(&path);

        match (expected_missing, strict) {
            (true, Err(Error::MissingReference(_))) => {}
            (true, other) => panic!(
                "expected MissingReference on iteration {i}, got {:?}",
                other
            ),
            (false, Ok(_)) => {}
            (false, other) => panic!("expected strict success on iteration {i}, got {:?}", other),
        }
    }
}