Skip to main content

docxide_template/
lib.rs

1//! Type-safe `.docx` template engine.
2//!
3//! Use [`generate_templates!`] to scan a directory of `.docx` files at compile time
4//! and generate a struct per template. See the [README](https://github.com/sverrejb/docxide-template)
5//! for full usage instructions.
6
7pub use docxide_template_derive::generate_templates;
8
9use std::io::{Cursor, Read, Write};
10use std::path::Path;
11
12/// Error type returned by template `save()` and `to_bytes()` methods.
13#[derive(Debug)]
14pub enum TemplateError {
15    /// An I/O error (reading template, writing output, creating directories).
16    Io(std::io::Error),
17    /// The `.docx` template is malformed (bad zip archive, invalid XML encoding).
18    InvalidTemplate(String),
19}
20
21impl std::fmt::Display for TemplateError {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        match self {
24            Self::Io(e) => write!(f, "{}", e),
25            Self::InvalidTemplate(msg) => write!(f, "invalid template: {}", msg),
26        }
27    }
28}
29
30impl std::error::Error for TemplateError {
31    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
32        match self {
33            Self::Io(e) => Some(e),
34            Self::InvalidTemplate(_) => None,
35        }
36    }
37}
38
39impl From<std::io::Error> for TemplateError {
40    fn from(e: std::io::Error) -> Self { Self::Io(e) }
41}
42
43impl From<zip::result::ZipError> for TemplateError {
44    fn from(e: zip::result::ZipError) -> Self {
45        match e {
46            zip::result::ZipError::Io(io_err) => Self::Io(io_err),
47            other => Self::InvalidTemplate(other.to_string()),
48        }
49    }
50}
51
52impl From<std::string::FromUtf8Error> for TemplateError {
53    fn from(e: std::string::FromUtf8Error) -> Self { Self::InvalidTemplate(e.to_string()) }
54}
55
56#[doc(hidden)]
57pub trait DocxTemplate: __private::Sealed {
58    fn template_path(&self) -> &Path;
59    fn replacements(&self) -> Vec<(&str, &str)>;
60}
61
62#[doc(hidden)]
63pub mod __private {
64    use super::*;
65
66    pub trait Sealed {}
67
68    pub fn save_docx<T: DocxTemplate, P: AsRef<Path>>(
69        template: &T,
70        output_path: P,
71    ) -> Result<(), TemplateError> {
72        let template_bytes = std::fs::read(template.template_path())?;
73        save_docx_bytes(&template_bytes, output_path.as_ref(), &template.replacements())
74    }
75
76    pub fn build_docx_bytes(
77        template_bytes: &[u8],
78        replacements: &[(&str, &str)],
79    ) -> Result<Vec<u8>, TemplateError> {
80        let cursor = Cursor::new(template_bytes);
81        let mut archive = zip::read::ZipArchive::new(cursor)?;
82
83        let mut output_buf = Cursor::new(Vec::new());
84        let mut zip_writer = zip::write::ZipWriter::new(&mut output_buf);
85        let options = zip::write::SimpleFileOptions::default();
86
87        for i in 0..archive.len() {
88            let mut file = archive.by_index(i)?;
89            let file_name = file.name().to_string();
90
91            let mut contents = Vec::new();
92            file.read_to_end(&mut contents)?;
93
94            if file_name.ends_with(".xml") || file_name.ends_with(".rels") {
95                let xml = String::from_utf8(contents)?;
96                let replaced = replace_placeholders_in_xml(&xml, replacements);
97                contents = replaced.into_bytes();
98            }
99
100            zip_writer.start_file(&file_name, options)?;
101            zip_writer.write_all(&contents)?;
102        }
103
104        zip_writer.finish()?;
105        Ok(output_buf.into_inner())
106    }
107
108    pub fn save_docx_bytes(
109        template_bytes: &[u8],
110        output_path: &Path,
111        replacements: &[(&str, &str)],
112    ) -> Result<(), TemplateError> {
113        let bytes = build_docx_bytes(template_bytes, replacements)?;
114        if let Some(parent) = output_path.parent() {
115            std::fs::create_dir_all(parent)?;
116        }
117        std::fs::write(output_path, bytes)?;
118        Ok(())
119    }
120}
121
122fn escape_xml(s: &str) -> String {
123    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
124}
125
126fn replace_placeholders_in_xml(xml: &str, replacements: &[(&str, &str)]) -> String {
127    let mut text_spans: Vec<(usize, usize, String)> = Vec::new();
128    let mut search_start = 0;
129    while let Some(tag_start) = xml[search_start..].find("<w:t") {
130        let tag_start = search_start + tag_start;
131        let content_start = match xml[tag_start..].find('>') {
132            Some(pos) => tag_start + pos + 1,
133            None => break,
134        };
135        let content_end = match xml[content_start..].find("</w:t>") {
136            Some(pos) => content_start + pos,
137            None => break,
138        };
139        let text = xml[content_start..content_end].to_string();
140        text_spans.push((content_start, content_end, text));
141        search_start = content_end + 6;
142    }
143
144    if text_spans.is_empty() {
145        return xml.to_string();
146    }
147
148    let concatenated: String = text_spans.iter().map(|(_, _, t)| t.as_str()).collect();
149
150    let offset_map: Vec<(usize, usize)> = text_spans
151        .iter()
152        .enumerate()
153        .flat_map(|(span_idx, (_, _, text))| {
154            (0..text.len()).map(move |char_offset| (span_idx, char_offset))
155        })
156        .collect();
157
158    let mut span_replacements: Vec<Vec<(usize, usize, String)>> = vec![Vec::new(); text_spans.len()];
159    for &(placeholder, value) in replacements {
160        let mut start = 0;
161        while let Some(found) = concatenated[start..].find(placeholder) {
162            let match_start = start + found;
163            let match_end = match_start + placeholder.len();
164            if match_start >= offset_map.len() || match_end > offset_map.len() {
165                break;
166            }
167
168            let (start_span, start_off) = offset_map[match_start];
169            let (end_span, _) = offset_map[match_end - 1];
170            let end_off_exclusive = offset_map[match_end - 1].1 + 1;
171
172            if start_span == end_span {
173                span_replacements[start_span].push((start_off, end_off_exclusive, escape_xml(value)));
174            } else {
175                let first_span_text = &text_spans[start_span].2;
176                span_replacements[start_span].push((start_off, first_span_text.len(), escape_xml(value)));
177                for mid in (start_span + 1)..end_span {
178                    let mid_len = text_spans[mid].2.len();
179                    span_replacements[mid].push((0, mid_len, String::new()));
180                }
181                span_replacements[end_span].push((0, end_off_exclusive, String::new()));
182            }
183            start = match_end;
184        }
185    }
186
187    let mut result = xml.to_string();
188    for (span_idx, (content_start, content_end, _)) in text_spans.iter().enumerate().rev() {
189        let mut span_text = result[*content_start..*content_end].to_string();
190        let mut reps = span_replacements[span_idx].clone();
191        reps.sort_by(|a, b| b.0.cmp(&a.0));
192        for (from, to, replacement) in reps {
193            let safe_to = to.min(span_text.len());
194            span_text = format!("{}{}{}", &span_text[..from], replacement, &span_text[safe_to..]);
195        }
196        result = format!("{}{}{}", &result[..*content_start], span_text, &result[*content_end..]);
197    }
198
199    result
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn replace_single_run_placeholder() {
208        let xml = r#"<w:t>{Name}</w:t>"#;
209        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
210        assert_eq!(result, r#"<w:t>Alice</w:t>"#);
211    }
212
213    #[test]
214    fn replace_placeholder_split_across_runs() {
215        let xml = r#"<w:t>{Na</w:t><w:t>me}</w:t>"#;
216        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
217        assert_eq!(result, r#"<w:t>Alice</w:t><w:t></w:t>"#);
218    }
219
220    #[test]
221    fn replace_placeholder_with_inner_whitespace() {
222        let xml = r#"<w:t>Hello { Name }!</w:t>"#;
223        let result = replace_placeholders_in_xml(xml, &[("{ Name }", "Alice")]);
224        assert_eq!(result, r#"<w:t>Hello Alice!</w:t>"#);
225    }
226
227    #[test]
228    fn replace_both_whitespace_variants() {
229        let xml = r#"<w:t>{Name} and { Name }</w:t>"#;
230        let result = replace_placeholders_in_xml(
231            xml,
232            &[("{Name}", "Alice"), ("{ Name }", "Alice")],
233        );
234        assert_eq!(result, r#"<w:t>Alice and Alice</w:t>"#);
235    }
236
237    #[test]
238    fn replace_multiple_placeholders() {
239        let xml = r#"<w:t>Hello {First} {Last}!</w:t>"#;
240        let result = replace_placeholders_in_xml(
241            xml,
242            &[("{First}", "Alice"), ("{Last}", "Smith")],
243        );
244        assert_eq!(result, r#"<w:t>Hello Alice Smith!</w:t>"#);
245    }
246
247    #[test]
248    fn no_placeholders_returns_unchanged() {
249        let xml = r#"<w:t>No placeholders here</w:t>"#;
250        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
251        assert_eq!(result, xml);
252    }
253
254    #[test]
255    fn no_wt_tags_returns_unchanged() {
256        let xml = r#"<w:p>plain paragraph</w:p>"#;
257        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
258        assert_eq!(result, xml);
259    }
260
261    #[test]
262    fn empty_replacements_returns_unchanged() {
263        let xml = r#"<w:t>{Name}</w:t>"#;
264        let result = replace_placeholders_in_xml(xml, &[]);
265        assert_eq!(result, xml);
266    }
267
268    #[test]
269    fn preserves_wt_attributes() {
270        let xml = r#"<w:t xml:space="preserve">{Name}</w:t>"#;
271        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
272        assert_eq!(result, r#"<w:t xml:space="preserve">Alice</w:t>"#);
273    }
274
275    #[test]
276    fn replace_whitespace_placeholder_split_across_runs() {
277        // Mimics Word splitting "{ foo }" across 5 <w:t> tags
278        let xml = r#"<w:t>{</w:t><w:t xml:space="preserve"> </w:t><w:t>foo</w:t><w:t xml:space="preserve"> </w:t><w:t>}</w:t>"#;
279        let result = replace_placeholders_in_xml(xml, &[("{ foo }", "bar")]);
280        assert!(
281            !result.contains("foo"),
282            "placeholder not replaced: {}",
283            result
284        );
285        assert!(result.contains("bar"), "value not present: {}", result);
286    }
287
288    #[test]
289    fn replace_whitespace_placeholder_with_prooferr_between_runs() {
290        // Exact XML from Word: proofErr tag sits between <w:t> runs
291        let xml = concat!(
292            r#"<w:r><w:t>{foo}</w:t></w:r>"#,
293            r#"<w:r><w:t>{</w:t></w:r>"#,
294            r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
295            r#"<w:r><w:t>foo</w:t></w:r>"#,
296            r#"<w:proofErr w:type="gramEnd"/>"#,
297            r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
298            r#"<w:r><w:t>}</w:t></w:r>"#,
299        );
300        let result = replace_placeholders_in_xml(
301            xml,
302            &[("{foo}", "bar"), ("{ foo }", "bar")],
303        );
304        // Both {foo} and { foo } should be replaced
305        assert!(
306            !result.contains("foo"),
307            "placeholder not replaced: {}",
308            result
309        );
310    }
311
312    #[test]
313    fn replace_all_variants_in_full_document() {
314        // Mimics HeadFootTest.docx: {header} x2, {foo}, { foo } split, {  foo  } split
315        let xml = concat!(
316            r#"<w:t>{header}</w:t>"#,
317            r#"<w:t>{header}</w:t>"#,
318            r#"<w:t>{foo}</w:t>"#,
319            // { foo } split across 5 runs
320            r#"<w:t>{</w:t>"#,
321            r#"<w:t xml:space="preserve"> </w:t>"#,
322            r#"<w:t>foo</w:t>"#,
323            r#"<w:t xml:space="preserve"> </w:t>"#,
324            r#"<w:t>}</w:t>"#,
325            // {  foo  } split across 6 runs
326            r#"<w:t>{</w:t>"#,
327            r#"<w:t xml:space="preserve"> </w:t>"#,
328            r#"<w:t xml:space="preserve"> </w:t>"#,
329            r#"<w:t>foo</w:t>"#,
330            r#"<w:t xml:space="preserve">  </w:t>"#,
331            r#"<w:t>}</w:t>"#,
332        );
333        let result = replace_placeholders_in_xml(
334            xml,
335            &[
336                ("{header}", "TITLE"),
337                ("{foo}", "BAR"),
338                ("{ foo }", "BAR"),
339                ("{  foo  }", "BAR"),
340            ],
341        );
342        assert!(
343            !result.contains("header"),
344            "{{header}} not replaced: {}",
345            result,
346        );
347        assert!(
348            !result.contains("foo"),
349            "foo variant not replaced: {}",
350            result,
351        );
352    }
353
354    #[test]
355    fn duplicate_replacement_does_not_break_later_spans() {
356        // Simulates the pre-dedup bug: {header} appears twice in replacements
357        let xml = concat!(
358            r#"<w:t>{header}</w:t>"#,
359            r#"<w:t>{header}</w:t>"#,
360            r#"<w:t>{foo}</w:t>"#,
361            r#"<w:t>{</w:t>"#,
362            r#"<w:t xml:space="preserve"> </w:t>"#,
363            r#"<w:t>foo</w:t>"#,
364            r#"<w:t xml:space="preserve"> </w:t>"#,
365            r#"<w:t>}</w:t>"#,
366        );
367        let result = replace_placeholders_in_xml(
368            xml,
369            &[
370                // duplicate {header} — the old bug
371                ("{header}", "TITLE"),
372                ("{header}", "TITLE"),
373                ("{foo}", "BAR"),
374                ("{ foo }", "BAR"),
375            ],
376        );
377        // Check if { foo } was replaced despite the duplicate
378        assert!(
379            !result.contains("foo"),
380            "foo not replaced when duplicate header present: {}",
381            result,
382        );
383    }
384
385    #[test]
386    fn replace_headfoottest_template() {
387        let template_path = Path::new("../test-crate/templates/HeadFootTest.docx");
388        if !template_path.exists() {
389            return;
390        }
391        let template_bytes = std::fs::read(template_path).unwrap();
392        let result = __private::build_docx_bytes(
393            &template_bytes,
394            &[
395                ("{header}", "TITLE"),
396                ("{foo}", "BAR"),
397                ("{ foo }", "BAR"),
398                ("{  foo  }", "BAR"),
399                ("{top}", "TOP"),
400                ("{bottom}", "BOT"),
401            ],
402        )
403        .unwrap();
404
405        let cursor = Cursor::new(&result);
406        let mut archive = zip::ZipArchive::new(cursor).unwrap();
407        let mut doc_xml = String::new();
408        archive
409            .by_name("word/document.xml")
410            .unwrap()
411            .read_to_string(&mut doc_xml)
412            .unwrap();
413
414        assert!(!doc_xml.contains("{header}"), "header placeholder not replaced");
415        assert!(!doc_xml.contains("{foo}"), "foo placeholder not replaced");
416        assert!(!doc_xml.contains("{ foo }"), "spaced foo placeholder not replaced");
417    }
418
419    #[test]
420    fn build_docx_bytes_produces_valid_zip() {
421        let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
422        if !template_path.exists() {
423            return;
424        }
425        let template_bytes = std::fs::read(template_path).unwrap();
426        let result = __private::build_docx_bytes(
427            &template_bytes,
428            &[("{ firstName }", "Test"), ("{ productName }", "Lib")],
429        )
430        .unwrap();
431
432        assert!(!result.is_empty());
433        let cursor = Cursor::new(&result);
434        let archive = zip::ZipArchive::new(cursor).expect("output should be a valid zip");
435        assert!(archive.len() > 0);
436    }
437
438    #[test]
439    fn escape_xml_special_characters() {
440        let xml = r#"<w:t>{Name}</w:t>"#;
441        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice & Bob")]);
442        assert_eq!(result, r#"<w:t>Alice &amp; Bob</w:t>"#);
443
444        let result = replace_placeholders_in_xml(xml, &[("{Name}", "<script>")]);
445        assert_eq!(result, r#"<w:t>&lt;script&gt;</w:t>"#);
446
447        let result = replace_placeholders_in_xml(xml, &[("{Name}", "a < b & c > d")]);
448        assert_eq!(result, r#"<w:t>a &lt; b &amp; c &gt; d</w:t>"#);
449    }
450
451    #[test]
452    fn escape_xml_split_across_runs() {
453        let xml = r#"<w:t>{Na</w:t><w:t>me}</w:t>"#;
454        let result = replace_placeholders_in_xml(xml, &[("{Name}", "A&B")]);
455        assert_eq!(result, r#"<w:t>A&amp;B</w:t><w:t></w:t>"#);
456    }
457
458    #[test]
459    fn escape_xml_in_headfoottest_template() {
460        let template_path = Path::new("../test-crate/templates/HeadFootTest.docx");
461        if !template_path.exists() {
462            return;
463        }
464        let template_bytes = std::fs::read(template_path).unwrap();
465        let result = __private::build_docx_bytes(
466            &template_bytes,
467            &[
468                ("{header}", "Tom & Jerry"),
469                ("{foo}", "x < y"),
470                ("{ foo }", "x < y"),
471                ("{  foo  }", "x < y"),
472                ("{top}", "A > B"),
473                ("{bottom}", "C & D"),
474            ],
475        )
476        .unwrap();
477
478        let cursor = Cursor::new(&result);
479        let mut archive = zip::ZipArchive::new(cursor).unwrap();
480        let mut doc_xml = String::new();
481        archive
482            .by_name("word/document.xml")
483            .unwrap()
484            .read_to_string(&mut doc_xml)
485            .unwrap();
486
487        assert!(!doc_xml.contains("Tom & Jerry"), "raw ampersand should be escaped");
488        assert!(doc_xml.contains("Tom &amp; Jerry"), "escaped value should be present");
489        assert!(!doc_xml.contains("x < y"), "raw less-than should be escaped");
490    }
491
492    #[test]
493    fn build_docx_bytes_replaces_content() {
494        let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
495        if !template_path.exists() {
496            return;
497        }
498        let template_bytes = std::fs::read(template_path).unwrap();
499        let result = __private::build_docx_bytes(
500            &template_bytes,
501            &[("{ firstName }", "Alice"), ("{ productName }", "Docxide")],
502        )
503        .unwrap();
504
505        let cursor = Cursor::new(&result);
506        let mut archive = zip::ZipArchive::new(cursor).unwrap();
507        let mut doc_xml = String::new();
508        archive
509            .by_name("word/document.xml")
510            .unwrap()
511            .read_to_string(&mut doc_xml)
512            .unwrap();
513        assert!(doc_xml.contains("Alice"));
514        assert!(doc_xml.contains("Docxide"));
515        assert!(!doc_xml.contains("firstName"));
516        assert!(!doc_xml.contains("productName"));
517    }
518}