Skip to main content

rdocx_html/
lib.rs

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