Skip to main content

rdocx_html/
lib.rs

1//! DOCX-to-HTML and DOCX-to-Markdown conversion.
2//!
3//! Works directly from semantic OXML types — no layout engine needed.
4
5mod css;
6mod emitter;
7mod markdown;
8mod sanitize;
9
10use std::collections::HashMap;
11
12use rdocx_oxml::document::CT_Document;
13use rdocx_oxml::numbering::CT_Numbering;
14use rdocx_oxml::styles::CT_Styles;
15
16/// Options for HTML conversion.
17#[derive(Debug, Clone)]
18pub struct HtmlOptions {
19    /// Whether to inline images as base64 data URIs (default: true).
20    pub inline_images: bool,
21}
22
23impl Default for HtmlOptions {
24    fn default() -> Self {
25        Self {
26            inline_images: true,
27        }
28    }
29}
30
31/// Input for HTML conversion.
32pub struct HtmlInput {
33    pub document: CT_Document,
34    pub styles: CT_Styles,
35    pub numbering: Option<CT_Numbering>,
36    /// Images keyed by embed/relationship ID.
37    pub images: HashMap<String, ImageData>,
38    /// Hyperlink URLs keyed by relationship ID.
39    pub hyperlink_urls: HashMap<String, String>,
40}
41
42/// Image data for HTML embedding.
43pub struct ImageData {
44    pub data: Vec<u8>,
45    pub content_type: String,
46}
47
48/// Convert a DOCX document to a complete HTML document string.
49pub fn to_html_document(input: &HtmlInput, options: &HtmlOptions) -> String {
50    let body = to_html_fragment(input, options);
51    let css = css::generate_base_css();
52    format!(
53        "<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"UTF-8\">\n<style>\n{css}\n</style>\n</head>\n<body>\n{body}\n</body>\n</html>"
54    )
55}
56
57/// Convert a DOCX document to an HTML fragment (body content only).
58pub fn to_html_fragment(input: &HtmlInput, options: &HtmlOptions) -> String {
59    emitter::emit_body(
60        &input.document.body,
61        &input.styles,
62        input.numbering.as_ref(),
63        &input.images,
64        &input.hyperlink_urls,
65        options,
66    )
67}
68
69/// Convert a DOCX document to Markdown.
70pub fn to_markdown(input: &HtmlInput) -> String {
71    markdown::emit_markdown(
72        &input.document.body,
73        &input.styles,
74        input.numbering.as_ref(),
75        &input.hyperlink_urls,
76    )
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82    use rdocx_oxml::document::{BodyContent, CT_Document};
83    use rdocx_oxml::styles::CT_Styles;
84    use rdocx_oxml::text::CT_P;
85
86    fn simple_input(text: &str) -> HtmlInput {
87        let mut doc = CT_Document::new();
88        let mut p = CT_P::new();
89        p.add_run(text);
90        doc.body.add_paragraph(p);
91
92        HtmlInput {
93            document: doc,
94            styles: CT_Styles::new_default(),
95            numbering: None,
96            images: HashMap::new(),
97            hyperlink_urls: HashMap::new(),
98        }
99    }
100
101    #[test]
102    fn html_document_basic() {
103        let input = simple_input("Hello, World!");
104        let html = to_html_document(&input, &HtmlOptions::default());
105        assert!(html.contains("<!DOCTYPE html>"));
106        assert!(html.contains("Hello, World!"));
107        assert!(html.contains("<p"));
108    }
109
110    #[test]
111    fn html_fragment_basic() {
112        let input = simple_input("Test paragraph");
113        let html = to_html_fragment(&input, &HtmlOptions::default());
114        assert!(html.contains("Test paragraph"));
115        assert!(html.contains("<p"));
116        assert!(!html.contains("<!DOCTYPE"));
117    }
118
119    #[test]
120    fn markdown_basic() {
121        let input = simple_input("Test paragraph");
122        let md = to_markdown(&input);
123        assert!(md.contains("Test paragraph"));
124    }
125
126    #[test]
127    fn html_heading() {
128        let mut doc = CT_Document::new();
129        let mut p = CT_P::new();
130        p.add_run("Chapter 1");
131        p.properties = Some(rdocx_oxml::properties::CT_PPr {
132            style_id: Some("Heading1".to_string()),
133            ..Default::default()
134        });
135        doc.body.add_paragraph(p);
136
137        let input = HtmlInput {
138            document: doc,
139            styles: CT_Styles::new_default(),
140            numbering: None,
141            images: HashMap::new(),
142            hyperlink_urls: HashMap::new(),
143        };
144
145        let html = to_html_fragment(&input, &HtmlOptions::default());
146        assert!(html.contains("<h1"));
147        assert!(html.contains("Chapter 1"));
148    }
149
150    #[test]
151    fn html_table() {
152        let mut doc = CT_Document::new();
153        let mut tbl = rdocx_oxml::table::CT_Tbl::new();
154        let mut row = rdocx_oxml::table::CT_Row::new();
155        let mut cell = rdocx_oxml::table::CT_Tc::new();
156        let mut p = CT_P::new();
157        p.add_run("Cell text");
158        cell.content = vec![rdocx_oxml::table::CellContent::Paragraph(p)];
159        row.cells.push(cell);
160        tbl.rows.push(row);
161        doc.body.content.push(BodyContent::Table(tbl));
162
163        let input = HtmlInput {
164            document: doc,
165            styles: CT_Styles::new_default(),
166            numbering: None,
167            images: HashMap::new(),
168            hyperlink_urls: HashMap::new(),
169        };
170
171        let html = to_html_fragment(&input, &HtmlOptions::default());
172        assert!(html.contains("<table"));
173        assert!(html.contains("<td"));
174        assert!(html.contains("Cell text"));
175    }
176
177    #[test]
178    fn markdown_heading() {
179        let mut doc = CT_Document::new();
180        let mut p = CT_P::new();
181        p.add_run("Title");
182        p.properties = Some(rdocx_oxml::properties::CT_PPr {
183            style_id: Some("Heading1".to_string()),
184            ..Default::default()
185        });
186        doc.body.add_paragraph(p);
187
188        let input = HtmlInput {
189            document: doc,
190            styles: CT_Styles::new_default(),
191            numbering: None,
192            images: HashMap::new(),
193            hyperlink_urls: HashMap::new(),
194        };
195
196        let md = to_markdown(&input);
197        assert!(md.contains("# Title"));
198    }
199
200    #[test]
201    fn html_bold_italic() {
202        let mut doc = CT_Document::new();
203        let mut p = CT_P::new();
204        let mut r = rdocx_oxml::text::CT_R::new("bold text");
205        r.properties = Some(rdocx_oxml::properties::CT_RPr {
206            bold: Some(true),
207            italic: Some(true),
208            ..Default::default()
209        });
210        p.runs.push(r);
211        doc.body.add_paragraph(p);
212
213        let input = HtmlInput {
214            document: doc,
215            styles: CT_Styles::new_default(),
216            numbering: None,
217            images: HashMap::new(),
218            hyperlink_urls: HashMap::new(),
219        };
220
221        let html = to_html_fragment(&input, &HtmlOptions::default());
222        assert!(html.contains("<strong"));
223        assert!(html.contains("<em"));
224        assert!(html.contains("bold text"));
225    }
226}