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 {
58    /// Returns the path to the original `.docx` template file.
59    fn template_path(&self) -> &Path;
60    /// Returns placeholder/value pairs for substitution.
61    fn replacements(&self) -> Vec<(&str, &str)>;
62}
63
64#[doc(hidden)]
65pub fn save_docx<T: DocxTemplate, P: AsRef<Path>>(
66    template: &T,
67    output_path: P,
68) -> Result<(), TemplateError> {
69    save_docx_from_file(template.template_path(), output_path.as_ref(), &template.replacements())
70}
71
72fn save_docx_from_file(
73    template_path: &Path,
74    output_path: &Path,
75    replacements: &[(&str, &str)],
76) -> Result<(), TemplateError> {
77    let template_bytes = std::fs::read(template_path)?;
78    save_docx_bytes(&template_bytes, output_path, replacements)
79}
80
81#[doc(hidden)]
82pub fn build_docx_bytes(
83    template_bytes: &[u8],
84    replacements: &[(&str, &str)],
85) -> Result<Vec<u8>, TemplateError> {
86    let cursor = Cursor::new(template_bytes);
87    let mut archive = zip::read::ZipArchive::new(cursor)?;
88
89    let mut output_buf = Cursor::new(Vec::new());
90    let mut zip_writer = zip::write::ZipWriter::new(&mut output_buf);
91    let options = zip::write::SimpleFileOptions::default();
92
93    for i in 0..archive.len() {
94        let mut file = archive.by_index(i)?;
95        let file_name = file.name().to_string();
96
97        let mut contents = Vec::new();
98        file.read_to_end(&mut contents)?;
99
100        if file_name.ends_with(".xml") || file_name.ends_with(".rels") {
101            let xml = String::from_utf8(contents)?;
102            let replaced = replace_placeholders_in_xml(&xml, replacements);
103            contents = replaced.into_bytes();
104        }
105
106        zip_writer.start_file(&file_name, options)?;
107        zip_writer.write_all(&contents)?;
108    }
109
110    zip_writer.finish()?;
111    Ok(output_buf.into_inner())
112}
113
114#[doc(hidden)]
115pub fn save_docx_bytes(
116    template_bytes: &[u8],
117    output_path: &Path,
118    replacements: &[(&str, &str)],
119) -> Result<(), TemplateError> {
120    let bytes = build_docx_bytes(template_bytes, replacements)?;
121    if let Some(parent) = output_path.parent() {
122        std::fs::create_dir_all(parent)?;
123    }
124    std::fs::write(output_path, bytes)?;
125    Ok(())
126}
127
128fn escape_xml(s: &str) -> String {
129    s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
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 = 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 = 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
457    #[test]
458    fn escape_xml_split_across_runs() {
459        let xml = r#"<w:t>{Na</w:t><w:t>me}</w:t>"#;
460        let result = replace_placeholders_in_xml(xml, &[("{Name}", "A&B")]);
461        assert_eq!(result, r#"<w:t>A&amp;B</w:t><w:t></w:t>"#);
462    }
463
464    #[test]
465    fn escape_xml_in_headfoottest_template() {
466        let template_path = Path::new("../test-crate/templates/HeadFootTest.docx");
467        if !template_path.exists() {
468            return;
469        }
470        let template_bytes = std::fs::read(template_path).unwrap();
471        let result = build_docx_bytes(
472            &template_bytes,
473            &[
474                ("{header}", "Tom & Jerry"),
475                ("{foo}", "x < y"),
476                ("{ foo }", "x < y"),
477                ("{  foo  }", "x < y"),
478                ("{top}", "A > B"),
479                ("{bottom}", "C & D"),
480            ],
481        )
482        .unwrap();
483
484        let cursor = Cursor::new(&result);
485        let mut archive = zip::ZipArchive::new(cursor).unwrap();
486        let mut doc_xml = String::new();
487        archive
488            .by_name("word/document.xml")
489            .unwrap()
490            .read_to_string(&mut doc_xml)
491            .unwrap();
492
493        assert!(!doc_xml.contains("Tom & Jerry"), "raw ampersand should be escaped");
494        assert!(doc_xml.contains("Tom &amp; Jerry"), "escaped value should be present");
495        assert!(!doc_xml.contains("x < y"), "raw less-than should be escaped");
496    }
497
498    #[test]
499    fn build_docx_bytes_replaces_content() {
500        let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
501        if !template_path.exists() {
502            return;
503        }
504        let template_bytes = std::fs::read(template_path).unwrap();
505        let result = build_docx_bytes(
506            &template_bytes,
507            &[("{ firstName }", "Alice"), ("{ productName }", "Docxide")],
508        )
509        .unwrap();
510
511        let cursor = Cursor::new(&result);
512        let mut archive = zip::ZipArchive::new(cursor).unwrap();
513        let mut doc_xml = String::new();
514        archive
515            .by_name("word/document.xml")
516            .unwrap()
517            .read_to_string(&mut doc_xml)
518            .unwrap();
519        assert!(doc_xml.contains("Alice"));
520        assert!(doc_xml.contains("Docxide"));
521        assert!(!doc_xml.contains("firstName"));
522        assert!(!doc_xml.contains("productName"));
523    }
524}