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            .compression_method(zip::CompressionMethod::Deflated)
87            .compression_level(Some(6));
88
89        for i in 0..archive.len() {
90            let mut file = archive.by_index(i)?;
91            let file_name = file.name().to_string();
92
93            let mut contents = Vec::new();
94            file.read_to_end(&mut contents)?;
95
96            if file_name.ends_with(".xml") || file_name.ends_with(".rels") {
97                let xml = String::from_utf8(contents)?;
98                let replaced = replace_placeholders_in_xml(&xml, replacements);
99                contents = replaced.into_bytes();
100            }
101
102            zip_writer.start_file(&file_name, options)?;
103            zip_writer.write_all(&contents)?;
104        }
105
106        zip_writer.finish()?;
107        Ok(output_buf.into_inner())
108    }
109
110    pub fn save_docx_bytes(
111        template_bytes: &[u8],
112        output_path: &Path,
113        replacements: &[(&str, &str)],
114    ) -> Result<(), TemplateError> {
115        let bytes = build_docx_bytes(template_bytes, replacements)?;
116        if let Some(parent) = output_path.parent() {
117            std::fs::create_dir_all(parent)?;
118        }
119        std::fs::write(output_path, bytes)?;
120        Ok(())
121    }
122}
123
124fn escape_xml(s: &str) -> String {
125    s.replace('&', "&amp;")
126        .replace('<', "&lt;")
127        .replace('>', "&gt;")
128        .replace('"', "&quot;")
129        .replace('\'', "&apos;")
130}
131
132fn replace_placeholders_in_xml(xml: &str, replacements: &[(&str, &str)]) -> String {
133    let mut text_spans: Vec<(usize, usize, String)> = Vec::new();
134    let mut search_start = 0;
135    while let Some(tag_start) = xml[search_start..].find("<w:t") {
136        let tag_start = search_start + tag_start;
137        let content_start = match xml[tag_start..].find('>') {
138            Some(pos) => tag_start + pos + 1,
139            None => break,
140        };
141        let content_end = match xml[content_start..].find("</w:t>") {
142            Some(pos) => content_start + pos,
143            None => break,
144        };
145        let text = xml[content_start..content_end].to_string();
146        text_spans.push((content_start, content_end, text));
147        search_start = content_end + 6;
148    }
149
150    if text_spans.is_empty() {
151        return xml.to_string();
152    }
153
154    let concatenated: String = text_spans.iter().map(|(_, _, t)| t.as_str()).collect();
155
156    let offset_map: Vec<(usize, usize)> = text_spans
157        .iter()
158        .enumerate()
159        .flat_map(|(span_idx, (_, _, text))| {
160            (0..text.len()).map(move |char_offset| (span_idx, char_offset))
161        })
162        .collect();
163
164    let mut span_replacements: Vec<Vec<(usize, usize, String)>> = vec![Vec::new(); text_spans.len()];
165    for &(placeholder, value) in replacements {
166        let mut start = 0;
167        while let Some(found) = concatenated[start..].find(placeholder) {
168            let match_start = start + found;
169            let match_end = match_start + placeholder.len();
170            if match_start >= offset_map.len() || match_end > offset_map.len() {
171                break;
172            }
173
174            let (start_span, start_off) = offset_map[match_start];
175            let (end_span, _) = offset_map[match_end - 1];
176            let end_off_exclusive = offset_map[match_end - 1].1 + 1;
177
178            if start_span == end_span {
179                span_replacements[start_span].push((start_off, end_off_exclusive, escape_xml(value)));
180            } else {
181                let first_span_text = &text_spans[start_span].2;
182                span_replacements[start_span].push((start_off, first_span_text.len(), escape_xml(value)));
183                for mid in (start_span + 1)..end_span {
184                    let mid_len = text_spans[mid].2.len();
185                    span_replacements[mid].push((0, mid_len, String::new()));
186                }
187                span_replacements[end_span].push((0, end_off_exclusive, String::new()));
188            }
189            start = match_end;
190        }
191    }
192
193    let mut result = xml.to_string();
194    for (span_idx, (content_start, content_end, _)) in text_spans.iter().enumerate().rev() {
195        let mut span_text = result[*content_start..*content_end].to_string();
196        let mut reps = span_replacements[span_idx].clone();
197        reps.sort_by(|a, b| b.0.cmp(&a.0));
198        for (from, to, replacement) in reps {
199            let safe_to = to.min(span_text.len());
200            span_text = format!("{}{}{}", &span_text[..from], replacement, &span_text[safe_to..]);
201        }
202        result = format!("{}{}{}", &result[..*content_start], span_text, &result[*content_end..]);
203    }
204
205    result
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211
212    #[test]
213    fn replace_single_run_placeholder() {
214        let xml = r#"<w:t>{Name}</w:t>"#;
215        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
216        assert_eq!(result, r#"<w:t>Alice</w:t>"#);
217    }
218
219    #[test]
220    fn replace_placeholder_split_across_runs() {
221        let xml = r#"<w:t>{Na</w:t><w:t>me}</w:t>"#;
222        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
223        assert_eq!(result, r#"<w:t>Alice</w:t><w:t></w:t>"#);
224    }
225
226    #[test]
227    fn replace_placeholder_with_inner_whitespace() {
228        let xml = r#"<w:t>Hello { Name }!</w:t>"#;
229        let result = replace_placeholders_in_xml(xml, &[("{ Name }", "Alice")]);
230        assert_eq!(result, r#"<w:t>Hello Alice!</w:t>"#);
231    }
232
233    #[test]
234    fn replace_both_whitespace_variants() {
235        let xml = r#"<w:t>{Name} and { Name }</w:t>"#;
236        let result = replace_placeholders_in_xml(
237            xml,
238            &[("{Name}", "Alice"), ("{ Name }", "Alice")],
239        );
240        assert_eq!(result, r#"<w:t>Alice and Alice</w:t>"#);
241    }
242
243    #[test]
244    fn replace_multiple_placeholders() {
245        let xml = r#"<w:t>Hello {First} {Last}!</w:t>"#;
246        let result = replace_placeholders_in_xml(
247            xml,
248            &[("{First}", "Alice"), ("{Last}", "Smith")],
249        );
250        assert_eq!(result, r#"<w:t>Hello Alice Smith!</w:t>"#);
251    }
252
253    #[test]
254    fn no_placeholders_returns_unchanged() {
255        let xml = r#"<w:t>No placeholders here</w:t>"#;
256        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
257        assert_eq!(result, xml);
258    }
259
260    #[test]
261    fn no_wt_tags_returns_unchanged() {
262        let xml = r#"<w:p>plain paragraph</w:p>"#;
263        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
264        assert_eq!(result, xml);
265    }
266
267    #[test]
268    fn empty_replacements_returns_unchanged() {
269        let xml = r#"<w:t>{Name}</w:t>"#;
270        let result = replace_placeholders_in_xml(xml, &[]);
271        assert_eq!(result, xml);
272    }
273
274    #[test]
275    fn preserves_wt_attributes() {
276        let xml = r#"<w:t xml:space="preserve">{Name}</w:t>"#;
277        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
278        assert_eq!(result, r#"<w:t xml:space="preserve">Alice</w:t>"#);
279    }
280
281    #[test]
282    fn replace_whitespace_placeholder_split_across_runs() {
283        // Mimics Word splitting "{ foo }" across 5 <w:t> tags
284        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>"#;
285        let result = replace_placeholders_in_xml(xml, &[("{ foo }", "bar")]);
286        assert!(
287            !result.contains("foo"),
288            "placeholder not replaced: {}",
289            result
290        );
291        assert!(result.contains("bar"), "value not present: {}", result);
292    }
293
294    #[test]
295    fn replace_whitespace_placeholder_with_prooferr_between_runs() {
296        // Exact XML from Word: proofErr tag sits between <w:t> runs
297        let xml = concat!(
298            r#"<w:r><w:t>{foo}</w:t></w:r>"#,
299            r#"<w:r><w:t>{</w:t></w:r>"#,
300            r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
301            r#"<w:r><w:t>foo</w:t></w:r>"#,
302            r#"<w:proofErr w:type="gramEnd"/>"#,
303            r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
304            r#"<w:r><w:t>}</w:t></w:r>"#,
305        );
306        let result = replace_placeholders_in_xml(
307            xml,
308            &[("{foo}", "bar"), ("{ foo }", "bar")],
309        );
310        // Both {foo} and { foo } should be replaced
311        assert!(
312            !result.contains("foo"),
313            "placeholder not replaced: {}",
314            result
315        );
316    }
317
318    #[test]
319    fn replace_all_variants_in_full_document() {
320        // Mimics HeadFootTest.docx: {header} x2, {foo}, { foo } split, {  foo  } split
321        let xml = concat!(
322            r#"<w:t>{header}</w:t>"#,
323            r#"<w:t>{header}</w:t>"#,
324            r#"<w:t>{foo}</w:t>"#,
325            // { foo } split across 5 runs
326            r#"<w:t>{</w:t>"#,
327            r#"<w:t xml:space="preserve"> </w:t>"#,
328            r#"<w:t>foo</w:t>"#,
329            r#"<w:t xml:space="preserve"> </w:t>"#,
330            r#"<w:t>}</w:t>"#,
331            // {  foo  } split across 6 runs
332            r#"<w:t>{</w:t>"#,
333            r#"<w:t xml:space="preserve"> </w:t>"#,
334            r#"<w:t xml:space="preserve"> </w:t>"#,
335            r#"<w:t>foo</w:t>"#,
336            r#"<w:t xml:space="preserve">  </w:t>"#,
337            r#"<w:t>}</w:t>"#,
338        );
339        let result = replace_placeholders_in_xml(
340            xml,
341            &[
342                ("{header}", "TITLE"),
343                ("{foo}", "BAR"),
344                ("{ foo }", "BAR"),
345                ("{  foo  }", "BAR"),
346            ],
347        );
348        assert!(
349            !result.contains("header"),
350            "{{header}} not replaced: {}",
351            result,
352        );
353        assert!(
354            !result.contains("foo"),
355            "foo variant not replaced: {}",
356            result,
357        );
358    }
359
360    #[test]
361    fn duplicate_replacement_does_not_break_later_spans() {
362        // Simulates the pre-dedup bug: {header} appears twice in replacements
363        let xml = concat!(
364            r#"<w:t>{header}</w:t>"#,
365            r#"<w:t>{header}</w:t>"#,
366            r#"<w:t>{foo}</w:t>"#,
367            r#"<w:t>{</w:t>"#,
368            r#"<w:t xml:space="preserve"> </w:t>"#,
369            r#"<w:t>foo</w:t>"#,
370            r#"<w:t xml:space="preserve"> </w:t>"#,
371            r#"<w:t>}</w:t>"#,
372        );
373        let result = replace_placeholders_in_xml(
374            xml,
375            &[
376                // duplicate {header} — the old bug
377                ("{header}", "TITLE"),
378                ("{header}", "TITLE"),
379                ("{foo}", "BAR"),
380                ("{ foo }", "BAR"),
381            ],
382        );
383        // Check if { foo } was replaced despite the duplicate
384        assert!(
385            !result.contains("foo"),
386            "foo not replaced when duplicate header present: {}",
387            result,
388        );
389    }
390
391    #[test]
392    fn replace_headfoottest_template() {
393        let template_path = Path::new("../test-crate/templates/HeadFootTest.docx");
394        if !template_path.exists() {
395            return;
396        }
397        let template_bytes = std::fs::read(template_path).unwrap();
398        let result = __private::build_docx_bytes(
399            &template_bytes,
400            &[
401                ("{header}", "TITLE"),
402                ("{foo}", "BAR"),
403                ("{ foo }", "BAR"),
404                ("{  foo  }", "BAR"),
405                ("{top}", "TOP"),
406                ("{bottom}", "BOT"),
407            ],
408        )
409        .unwrap();
410
411        let cursor = Cursor::new(&result);
412        let mut archive = zip::ZipArchive::new(cursor).unwrap();
413        let mut doc_xml = String::new();
414        archive
415            .by_name("word/document.xml")
416            .unwrap()
417            .read_to_string(&mut doc_xml)
418            .unwrap();
419
420        assert!(!doc_xml.contains("{header}"), "header placeholder not replaced");
421        assert!(!doc_xml.contains("{foo}"), "foo placeholder not replaced");
422        assert!(!doc_xml.contains("{ foo }"), "spaced foo placeholder not replaced");
423    }
424
425    #[test]
426    fn build_docx_bytes_produces_valid_zip() {
427        let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
428        if !template_path.exists() {
429            return;
430        }
431        let template_bytes = std::fs::read(template_path).unwrap();
432        let result = __private::build_docx_bytes(
433            &template_bytes,
434            &[("{ firstName }", "Test"), ("{ productName }", "Lib")],
435        )
436        .unwrap();
437
438        assert!(!result.is_empty());
439        let cursor = Cursor::new(&result);
440        let archive = zip::ZipArchive::new(cursor).expect("output should be a valid zip");
441        assert!(archive.len() > 0);
442    }
443
444    #[test]
445    fn escape_xml_special_characters() {
446        let xml = r#"<w:t>{Name}</w:t>"#;
447        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice & Bob")]);
448        assert_eq!(result, r#"<w:t>Alice &amp; Bob</w:t>"#);
449
450        let result = replace_placeholders_in_xml(xml, &[("{Name}", "<script>")]);
451        assert_eq!(result, r#"<w:t>&lt;script&gt;</w:t>"#);
452
453        let result = replace_placeholders_in_xml(xml, &[("{Name}", "a < b & c > d")]);
454        assert_eq!(result, r#"<w:t>a &lt; b &amp; c &gt; d</w:t>"#);
455
456        let result = replace_placeholders_in_xml(xml, &[("{Name}", r#"She said "hello""#)]);
457        assert_eq!(result, r#"<w:t>She said &quot;hello&quot;</w:t>"#);
458
459        let result = replace_placeholders_in_xml(xml, &[("{Name}", "it's")]);
460        assert_eq!(result, r#"<w:t>it&apos;s</w:t>"#);
461    }
462
463    #[test]
464    fn escape_xml_split_across_runs() {
465        let xml = r#"<w:t>{Na</w:t><w:t>me}</w:t>"#;
466        let result = replace_placeholders_in_xml(xml, &[("{Name}", "A&B")]);
467        assert_eq!(result, r#"<w:t>A&amp;B</w:t><w:t></w:t>"#);
468    }
469
470    #[test]
471    fn escape_xml_in_headfoottest_template() {
472        let template_path = Path::new("../test-crate/templates/HeadFootTest.docx");
473        if !template_path.exists() {
474            return;
475        }
476        let template_bytes = std::fs::read(template_path).unwrap();
477        let result = __private::build_docx_bytes(
478            &template_bytes,
479            &[
480                ("{header}", "Tom & Jerry"),
481                ("{foo}", "x < y"),
482                ("{ foo }", "x < y"),
483                ("{  foo  }", "x < y"),
484                ("{top}", "A > B"),
485                ("{bottom}", "C & D"),
486            ],
487        )
488        .unwrap();
489
490        let cursor = Cursor::new(&result);
491        let mut archive = zip::ZipArchive::new(cursor).unwrap();
492        let mut doc_xml = String::new();
493        archive
494            .by_name("word/document.xml")
495            .unwrap()
496            .read_to_string(&mut doc_xml)
497            .unwrap();
498
499        assert!(!doc_xml.contains("Tom & Jerry"), "raw ampersand should be escaped");
500        assert!(doc_xml.contains("Tom &amp; Jerry"), "escaped value should be present");
501        assert!(!doc_xml.contains("x < y"), "raw less-than should be escaped");
502    }
503
504    #[test]
505    fn build_docx_bytes_replaces_content() {
506        let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
507        if !template_path.exists() {
508            return;
509        }
510        let template_bytes = std::fs::read(template_path).unwrap();
511        let result = __private::build_docx_bytes(
512            &template_bytes,
513            &[("{ firstName }", "Alice"), ("{ productName }", "Docxide")],
514        )
515        .unwrap();
516
517        let cursor = Cursor::new(&result);
518        let mut archive = zip::ZipArchive::new(cursor).unwrap();
519        let mut doc_xml = String::new();
520        archive
521            .by_name("word/document.xml")
522            .unwrap()
523            .read_to_string(&mut doc_xml)
524            .unwrap();
525        assert!(doc_xml.contains("Alice"));
526        assert!(doc_xml.contains("Docxide"));
527        assert!(!doc_xml.contains("firstName"));
528        assert!(!doc_xml.contains("productName"));
529    }
530}