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/// Trait implemented by all generated template structs.
13///
14/// You won't typically call these methods directly — use the generated
15/// `save()` and `to_bytes()` methods on the struct instead.
16pub trait DocxTemplate {
17    /// Returns the path to the original `.docx` template file.
18    fn template_path(&self) -> &Path;
19    /// Returns placeholder/value pairs for substitution.
20    fn replacements(&self) -> Vec<(&str, &str)>;
21}
22
23/// Saves a filled-in template to a `.docx` file. Used internally by generated `save()` methods.
24pub fn save_docx<T: DocxTemplate, P: AsRef<Path>>(
25    template: &T,
26    output_path: P,
27) -> Result<(), Box<dyn std::error::Error>> {
28    save_docx_from_file(template.template_path(), output_path.as_ref(), &template.replacements())
29}
30
31/// Reads a template from disk and saves the filled-in result to `output_path`.
32pub fn save_docx_from_file(
33    template_path: &Path,
34    output_path: &Path,
35    replacements: &[(&str, &str)],
36) -> Result<(), Box<dyn std::error::Error>> {
37    let template_bytes = std::fs::read(template_path)?;
38    save_docx_bytes(&template_bytes, output_path, replacements)
39}
40
41/// Applies placeholder replacements to a `.docx` template and returns the result as bytes.
42///
43/// This is the core replacement engine. It opens the template as a zip archive,
44/// performs placeholder substitution in all XML and `.rels` files, and writes
45/// the result to a `Vec<u8>`.
46pub fn build_docx_bytes(
47    template_bytes: &[u8],
48    replacements: &[(&str, &str)],
49) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
50    let cursor = Cursor::new(template_bytes);
51    let mut archive = zip::read::ZipArchive::new(cursor)?;
52
53    let mut output_buf = Cursor::new(Vec::new());
54    let mut zip_writer = zip::write::ZipWriter::new(&mut output_buf);
55    let options = zip::write::SimpleFileOptions::default();
56
57    for i in 0..archive.len() {
58        let mut file = archive.by_index(i)?;
59        let file_name: String = file.name().to_string();
60
61        let mut contents = Vec::new();
62        file.read_to_end(&mut contents)?;
63
64        if file_name.ends_with(".xml") || file_name.ends_with(".rels") {
65            let xml = String::from_utf8(contents)?;
66            let replaced = replace_placeholders_in_xml(&xml, replacements);
67            contents = replaced.into_bytes();
68        }
69
70        zip_writer.start_file(&file_name, options)?;
71        zip_writer.write_all(&contents)?;
72    }
73
74    zip_writer.finish()?;
75    Ok(output_buf.into_inner())
76}
77
78/// Like [`build_docx_bytes`], but writes the result directly to a file at `output_path`.
79pub fn save_docx_bytes(
80    template_bytes: &[u8],
81    output_path: &Path,
82    replacements: &[(&str, &str)],
83) -> Result<(), Box<dyn std::error::Error>> {
84    let bytes = build_docx_bytes(template_bytes, replacements)?;
85    if let Some(parent) = output_path.parent() {
86        std::fs::create_dir_all(parent)?;
87    }
88    std::fs::write(output_path, bytes)?;
89    Ok(())
90}
91
92/// Replaces `{placeholder}` patterns in raw Office Open XML.
93///
94/// Handles placeholders that Word may split across multiple `<w:t>` runs
95/// due to formatting or spell-check boundaries.
96pub fn replace_placeholders_in_xml(xml: &str, replacements: &[(&str, &str)]) -> String {
97    let mut text_spans: Vec<(usize, usize, String)> = Vec::new();
98    let mut search_start = 0;
99    while let Some(tag_start) = xml[search_start..].find("<w:t") {
100        let tag_start = search_start + tag_start;
101        let content_start = match xml[tag_start..].find('>') {
102            Some(pos) => tag_start + pos + 1,
103            None => break,
104        };
105        let content_end = match xml[content_start..].find("</w:t>") {
106            Some(pos) => content_start + pos,
107            None => break,
108        };
109        let text = xml[content_start..content_end].to_string();
110        text_spans.push((content_start, content_end, text));
111        search_start = content_end + 6;
112    }
113
114    if text_spans.is_empty() {
115        return xml.to_string();
116    }
117
118    let concatenated: String = text_spans.iter().map(|(_, _, t)| t.as_str()).collect();
119
120    let mut offset_map: Vec<(usize, usize)> = Vec::new();
121    for (span_idx, (_, _, text)) in text_spans.iter().enumerate() {
122        for char_offset in 0..text.len() {
123            offset_map.push((span_idx, char_offset));
124        }
125    }
126
127    let mut span_replacements: Vec<Vec<(usize, usize, String)>> = vec![Vec::new(); text_spans.len()];
128    for &(placeholder, value) in replacements {
129        let mut start = 0;
130        while let Some(found) = concatenated[start..].find(placeholder) {
131            let match_start = start + found;
132            let match_end = match_start + placeholder.len();
133            if match_start >= offset_map.len() || match_end > offset_map.len() {
134                break;
135            }
136
137            let (start_span, start_off) = offset_map[match_start];
138            let (end_span, _) = offset_map[match_end - 1];
139            let end_off_exclusive = offset_map[match_end - 1].1 + 1;
140
141            if start_span == end_span {
142                span_replacements[start_span].push((start_off, end_off_exclusive, value.to_string()));
143            } else {
144                let first_span_text = &text_spans[start_span].2;
145                span_replacements[start_span].push((start_off, first_span_text.len(), value.to_string()));
146                for mid in (start_span + 1)..end_span {
147                    let mid_len = text_spans[mid].2.len();
148                    span_replacements[mid].push((0, mid_len, String::new()));
149                }
150                span_replacements[end_span].push((0, end_off_exclusive, String::new()));
151            }
152            start = match_end;
153        }
154    }
155
156    let mut result = xml.to_string();
157    for (span_idx, (content_start, content_end, _)) in text_spans.iter().enumerate().rev() {
158        let mut span_text = result[*content_start..*content_end].to_string();
159        let mut reps = span_replacements[span_idx].clone();
160        reps.sort_by(|a, b| b.0.cmp(&a.0));
161        for (from, to, replacement) in reps {
162            let safe_to = to.min(span_text.len());
163            span_text = format!("{}{}{}", &span_text[..from], replacement, &span_text[safe_to..]);
164        }
165        result = format!("{}{}{}", &result[..*content_start], span_text, &result[*content_end..]);
166    }
167
168    result
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn replace_single_run_placeholder() {
177        let xml = r#"<w:t>{Name}</w:t>"#;
178        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
179        assert_eq!(result, r#"<w:t>Alice</w:t>"#);
180    }
181
182    #[test]
183    fn replace_placeholder_split_across_runs() {
184        let xml = r#"<w:t>{Na</w:t><w:t>me}</w:t>"#;
185        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
186        assert_eq!(result, r#"<w:t>Alice</w:t><w:t></w:t>"#);
187    }
188
189    #[test]
190    fn replace_placeholder_with_inner_whitespace() {
191        let xml = r#"<w:t>Hello { Name }!</w:t>"#;
192        let result = replace_placeholders_in_xml(xml, &[("{ Name }", "Alice")]);
193        assert_eq!(result, r#"<w:t>Hello Alice!</w:t>"#);
194    }
195
196    #[test]
197    fn replace_both_whitespace_variants() {
198        let xml = r#"<w:t>{Name} and { Name }</w:t>"#;
199        let result = replace_placeholders_in_xml(
200            xml,
201            &[("{Name}", "Alice"), ("{ Name }", "Alice")],
202        );
203        assert_eq!(result, r#"<w:t>Alice and Alice</w:t>"#);
204    }
205
206    #[test]
207    fn replace_multiple_placeholders() {
208        let xml = r#"<w:t>Hello {First} {Last}!</w:t>"#;
209        let result = replace_placeholders_in_xml(
210            xml,
211            &[("{First}", "Alice"), ("{Last}", "Smith")],
212        );
213        assert_eq!(result, r#"<w:t>Hello Alice Smith!</w:t>"#);
214    }
215
216    #[test]
217    fn no_placeholders_returns_unchanged() {
218        let xml = r#"<w:t>No placeholders here</w:t>"#;
219        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
220        assert_eq!(result, xml);
221    }
222
223    #[test]
224    fn no_wt_tags_returns_unchanged() {
225        let xml = r#"<w:p>plain paragraph</w:p>"#;
226        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
227        assert_eq!(result, xml);
228    }
229
230    #[test]
231    fn empty_replacements_returns_unchanged() {
232        let xml = r#"<w:t>{Name}</w:t>"#;
233        let result = replace_placeholders_in_xml(xml, &[]);
234        assert_eq!(result, xml);
235    }
236
237    #[test]
238    fn preserves_wt_attributes() {
239        let xml = r#"<w:t xml:space="preserve">{Name}</w:t>"#;
240        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
241        assert_eq!(result, r#"<w:t xml:space="preserve">Alice</w:t>"#);
242    }
243
244    #[test]
245    fn build_docx_bytes_produces_valid_zip() {
246        let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
247        if !template_path.exists() {
248            return;
249        }
250        let template_bytes = std::fs::read(template_path).unwrap();
251        let result = build_docx_bytes(
252            &template_bytes,
253            &[("{ firstName }", "Test"), ("{ productName }", "Lib")],
254        )
255        .unwrap();
256
257        assert!(!result.is_empty());
258        let cursor = Cursor::new(&result);
259        let archive = zip::ZipArchive::new(cursor).expect("output should be a valid zip");
260        assert!(archive.len() > 0);
261    }
262
263    #[test]
264    fn build_docx_bytes_replaces_content() {
265        let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
266        if !template_path.exists() {
267            return;
268        }
269        let template_bytes = std::fs::read(template_path).unwrap();
270        let result = build_docx_bytes(
271            &template_bytes,
272            &[("{ firstName }", "Alice"), ("{ productName }", "Docxide")],
273        )
274        .unwrap();
275
276        let cursor = Cursor::new(&result);
277        let mut archive = zip::ZipArchive::new(cursor).unwrap();
278        let mut doc_xml = String::new();
279        archive
280            .by_name("word/document.xml")
281            .unwrap()
282            .read_to_string(&mut doc_xml)
283            .unwrap();
284        assert!(doc_xml.contains("Alice"));
285        assert!(doc_xml.contains("Docxide"));
286        assert!(!doc_xml.contains("firstName"));
287        assert!(!doc_xml.contains("productName"));
288    }
289}