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_for_tag(xml: &str, replacements: &[(&str, &str)], open_prefix: &str, close_tag: &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(open_prefix) {
136        let tag_start = search_start + tag_start;
137        let after_prefix = tag_start + open_prefix.len();
138        if after_prefix < xml.len() && !matches!(xml.as_bytes()[after_prefix], b'>' | b' ') {
139            search_start = after_prefix;
140            continue;
141        }
142        let content_start = match xml[tag_start..].find('>') {
143            Some(pos) => tag_start + pos + 1,
144            None => break,
145        };
146        let content_end = match xml[content_start..].find(close_tag) {
147            Some(pos) => content_start + pos,
148            None => break,
149        };
150        let text = xml[content_start..content_end].to_string();
151        text_spans.push((content_start, content_end, text));
152        search_start = content_end + close_tag.len();
153    }
154
155    if text_spans.is_empty() {
156        return xml.to_string();
157    }
158
159    let concatenated: String = text_spans.iter().map(|(_, _, t)| t.as_str()).collect();
160
161    let offset_map: Vec<(usize, usize)> = text_spans
162        .iter()
163        .enumerate()
164        .flat_map(|(span_idx, (_, _, text))| {
165            (0..text.len()).map(move |char_offset| (span_idx, char_offset))
166        })
167        .collect();
168
169    let mut span_replacements: Vec<Vec<(usize, usize, String)>> = vec![Vec::new(); text_spans.len()];
170    for &(placeholder, value) in replacements {
171        let mut start = 0;
172        while let Some(found) = concatenated[start..].find(placeholder) {
173            let match_start = start + found;
174            let match_end = match_start + placeholder.len();
175            if match_start >= offset_map.len() || match_end > offset_map.len() {
176                break;
177            }
178
179            let (start_span, start_off) = offset_map[match_start];
180            let (end_span, _) = offset_map[match_end - 1];
181            let end_off_exclusive = offset_map[match_end - 1].1 + 1;
182
183            if start_span == end_span {
184                span_replacements[start_span].push((start_off, end_off_exclusive, escape_xml(value)));
185            } else {
186                let first_span_text = &text_spans[start_span].2;
187                span_replacements[start_span].push((start_off, first_span_text.len(), escape_xml(value)));
188                for mid in (start_span + 1)..end_span {
189                    let mid_len = text_spans[mid].2.len();
190                    span_replacements[mid].push((0, mid_len, String::new()));
191                }
192                span_replacements[end_span].push((0, end_off_exclusive, String::new()));
193            }
194            start = match_end;
195        }
196    }
197
198    let mut result = xml.to_string();
199    for (span_idx, (content_start, content_end, _)) in text_spans.iter().enumerate().rev() {
200        let mut span_text = result[*content_start..*content_end].to_string();
201        let mut reps = span_replacements[span_idx].clone();
202        reps.sort_by(|a, b| b.0.cmp(&a.0));
203        for (from, to, replacement) in reps {
204            let safe_to = to.min(span_text.len());
205            span_text = format!("{}{}{}", &span_text[..from], replacement, &span_text[safe_to..]);
206        }
207        result = format!("{}{}{}", &result[..*content_start], span_text, &result[*content_end..]);
208    }
209
210    result
211}
212
213fn replace_placeholders_in_xml(xml: &str, replacements: &[(&str, &str)]) -> String {
214    let result = replace_for_tag(xml, replacements, "<w:t", "</w:t>");
215    let result = replace_for_tag(&result, replacements, "<a:t", "</a:t>");
216    replace_for_tag(&result, replacements, "<m:t", "</m:t>")
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn replace_single_run_placeholder() {
225        let xml = r#"<w:t>{Name}</w:t>"#;
226        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
227        assert_eq!(result, r#"<w:t>Alice</w:t>"#);
228    }
229
230    #[test]
231    fn replace_placeholder_split_across_runs() {
232        let xml = r#"<w:t>{Na</w:t><w:t>me}</w:t>"#;
233        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
234        assert_eq!(result, r#"<w:t>Alice</w:t><w:t></w:t>"#);
235    }
236
237    #[test]
238    fn replace_placeholder_with_inner_whitespace() {
239        let xml = r#"<w:t>Hello { Name }!</w:t>"#;
240        let result = replace_placeholders_in_xml(xml, &[("{ Name }", "Alice")]);
241        assert_eq!(result, r#"<w:t>Hello Alice!</w:t>"#);
242    }
243
244    #[test]
245    fn replace_both_whitespace_variants() {
246        let xml = r#"<w:t>{Name} and { Name }</w:t>"#;
247        let result = replace_placeholders_in_xml(
248            xml,
249            &[("{Name}", "Alice"), ("{ Name }", "Alice")],
250        );
251        assert_eq!(result, r#"<w:t>Alice and Alice</w:t>"#);
252    }
253
254    #[test]
255    fn replace_multiple_placeholders() {
256        let xml = r#"<w:t>Hello {First} {Last}!</w:t>"#;
257        let result = replace_placeholders_in_xml(
258            xml,
259            &[("{First}", "Alice"), ("{Last}", "Smith")],
260        );
261        assert_eq!(result, r#"<w:t>Hello Alice Smith!</w:t>"#);
262    }
263
264    #[test]
265    fn no_placeholders_returns_unchanged() {
266        let xml = r#"<w:t>No placeholders here</w:t>"#;
267        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
268        assert_eq!(result, xml);
269    }
270
271    #[test]
272    fn no_wt_tags_returns_unchanged() {
273        let xml = r#"<w:p>plain paragraph</w:p>"#;
274        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
275        assert_eq!(result, xml);
276    }
277
278    #[test]
279    fn empty_replacements_returns_unchanged() {
280        let xml = r#"<w:t>{Name}</w:t>"#;
281        let result = replace_placeholders_in_xml(xml, &[]);
282        assert_eq!(result, xml);
283    }
284
285    #[test]
286    fn preserves_wt_attributes() {
287        let xml = r#"<w:t xml:space="preserve">{Name}</w:t>"#;
288        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
289        assert_eq!(result, r#"<w:t xml:space="preserve">Alice</w:t>"#);
290    }
291
292    #[test]
293    fn replace_whitespace_placeholder_split_across_runs() {
294        // Mimics Word splitting "{ foo }" across 5 <w:t> tags
295        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>"#;
296        let result = replace_placeholders_in_xml(xml, &[("{ foo }", "bar")]);
297        assert!(
298            !result.contains("foo"),
299            "placeholder not replaced: {}",
300            result
301        );
302        assert!(result.contains("bar"), "value not present: {}", result);
303    }
304
305    #[test]
306    fn replace_whitespace_placeholder_with_prooferr_between_runs() {
307        // Exact XML from Word: proofErr tag sits between <w:t> runs
308        let xml = concat!(
309            r#"<w:r><w:t>{foo}</w:t></w:r>"#,
310            r#"<w:r><w:t>{</w:t></w:r>"#,
311            r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
312            r#"<w:r><w:t>foo</w:t></w:r>"#,
313            r#"<w:proofErr w:type="gramEnd"/>"#,
314            r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
315            r#"<w:r><w:t>}</w:t></w:r>"#,
316        );
317        let result = replace_placeholders_in_xml(
318            xml,
319            &[("{foo}", "bar"), ("{ foo }", "bar")],
320        );
321        // Both {foo} and { foo } should be replaced
322        assert!(
323            !result.contains("foo"),
324            "placeholder not replaced: {}",
325            result
326        );
327    }
328
329    #[test]
330    fn replace_all_variants_in_full_document() {
331        // Mimics HeadFootTest.docx: {header} x2, {foo}, { foo } split, {  foo  } split
332        let xml = concat!(
333            r#"<w:t>{header}</w:t>"#,
334            r#"<w:t>{header}</w:t>"#,
335            r#"<w:t>{foo}</w:t>"#,
336            // { foo } split across 5 runs
337            r#"<w:t>{</w:t>"#,
338            r#"<w:t xml:space="preserve"> </w:t>"#,
339            r#"<w:t>foo</w:t>"#,
340            r#"<w:t xml:space="preserve"> </w:t>"#,
341            r#"<w:t>}</w:t>"#,
342            // {  foo  } split across 6 runs
343            r#"<w:t>{</w:t>"#,
344            r#"<w:t xml:space="preserve"> </w:t>"#,
345            r#"<w:t xml:space="preserve"> </w:t>"#,
346            r#"<w:t>foo</w:t>"#,
347            r#"<w:t xml:space="preserve">  </w:t>"#,
348            r#"<w:t>}</w:t>"#,
349        );
350        let result = replace_placeholders_in_xml(
351            xml,
352            &[
353                ("{header}", "TITLE"),
354                ("{foo}", "BAR"),
355                ("{ foo }", "BAR"),
356                ("{  foo  }", "BAR"),
357            ],
358        );
359        assert!(
360            !result.contains("header"),
361            "{{header}} not replaced: {}",
362            result,
363        );
364        assert!(
365            !result.contains("foo"),
366            "foo variant not replaced: {}",
367            result,
368        );
369    }
370
371    #[test]
372    fn duplicate_replacement_does_not_break_later_spans() {
373        // {header} appears twice in replacements
374        let xml = concat!(
375            r#"<w:t>{header}</w:t>"#,
376            r#"<w:t>{header}</w:t>"#,
377            r#"<w:t>{foo}</w:t>"#,
378            r#"<w:t>{</w:t>"#,
379            r#"<w:t xml:space="preserve"> </w:t>"#,
380            r#"<w:t>foo</w:t>"#,
381            r#"<w:t xml:space="preserve"> </w:t>"#,
382            r#"<w:t>}</w:t>"#,
383        );
384        let result = replace_placeholders_in_xml(
385            xml,
386            &[
387                // duplicate {header}
388                ("{header}", "TITLE"),
389                ("{header}", "TITLE"),
390                ("{foo}", "BAR"),
391                ("{ foo }", "BAR"),
392            ],
393        );
394        // Check if { foo } was replaced despite the duplicate
395        assert!(
396            !result.contains("foo"),
397            "foo not replaced when duplicate header present: {}",
398            result,
399        );
400    }
401
402    #[test]
403    fn replace_headfoottest_template() {
404        let template_path = Path::new("../test-crate/templates/HeadFootTest.docx");
405        if !template_path.exists() {
406            return;
407        }
408        let template_bytes = std::fs::read(template_path).unwrap();
409        let result = __private::build_docx_bytes(
410            &template_bytes,
411            &[
412                ("{header}", "TITLE"),
413                ("{foo}", "BAR"),
414                ("{ foo }", "BAR"),
415                ("{  foo  }", "BAR"),
416                ("{top}", "TOP"),
417                ("{bottom}", "BOT"),
418            ],
419        )
420        .unwrap();
421
422        let cursor = Cursor::new(&result);
423        let mut archive = zip::ZipArchive::new(cursor).unwrap();
424        let mut doc_xml = String::new();
425        archive
426            .by_name("word/document.xml")
427            .unwrap()
428            .read_to_string(&mut doc_xml)
429            .unwrap();
430
431        assert!(!doc_xml.contains("{header}"), "header placeholder not replaced");
432        assert!(!doc_xml.contains("{foo}"), "foo placeholder not replaced");
433        assert!(!doc_xml.contains("{ foo }"), "spaced foo placeholder not replaced");
434    }
435
436    #[test]
437    fn build_docx_bytes_produces_valid_zip() {
438        let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
439        if !template_path.exists() {
440            return;
441        }
442        let template_bytes = std::fs::read(template_path).unwrap();
443        let result = __private::build_docx_bytes(
444            &template_bytes,
445            &[("{ firstName }", "Test"), ("{ productName }", "Lib")],
446        )
447        .unwrap();
448
449        assert!(!result.is_empty());
450        let cursor = Cursor::new(&result);
451        let archive = zip::ZipArchive::new(cursor).expect("output should be a valid zip");
452        assert!(archive.len() > 0);
453    }
454
455    #[test]
456    fn escape_xml_special_characters() {
457        let xml = r#"<w:t>{Name}</w:t>"#;
458        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice & Bob")]);
459        assert_eq!(result, r#"<w:t>Alice &amp; Bob</w:t>"#);
460
461        let result = replace_placeholders_in_xml(xml, &[("{Name}", "<script>")]);
462        assert_eq!(result, r#"<w:t>&lt;script&gt;</w:t>"#);
463
464        let result = replace_placeholders_in_xml(xml, &[("{Name}", "a < b & c > d")]);
465        assert_eq!(result, r#"<w:t>a &lt; b &amp; c &gt; d</w:t>"#);
466
467        let result = replace_placeholders_in_xml(xml, &[("{Name}", r#"She said "hello""#)]);
468        assert_eq!(result, r#"<w:t>She said &quot;hello&quot;</w:t>"#);
469
470        let result = replace_placeholders_in_xml(xml, &[("{Name}", "it's")]);
471        assert_eq!(result, r#"<w:t>it&apos;s</w:t>"#);
472    }
473
474    #[test]
475    fn escape_xml_split_across_runs() {
476        let xml = r#"<w:t>{Na</w:t><w:t>me}</w:t>"#;
477        let result = replace_placeholders_in_xml(xml, &[("{Name}", "A&B")]);
478        assert_eq!(result, r#"<w:t>A&amp;B</w:t><w:t></w:t>"#);
479    }
480
481    #[test]
482    fn escape_xml_in_headfoottest_template() {
483        let template_path = Path::new("../test-crate/templates/HeadFootTest.docx");
484        if !template_path.exists() {
485            return;
486        }
487        let template_bytes = std::fs::read(template_path).unwrap();
488        let result = __private::build_docx_bytes(
489            &template_bytes,
490            &[
491                ("{header}", "Tom & Jerry"),
492                ("{foo}", "x < y"),
493                ("{ foo }", "x < y"),
494                ("{  foo  }", "x < y"),
495                ("{top}", "A > B"),
496                ("{bottom}", "C & D"),
497            ],
498        )
499        .unwrap();
500
501        let cursor = Cursor::new(&result);
502        let mut archive = zip::ZipArchive::new(cursor).unwrap();
503        let mut doc_xml = String::new();
504        archive
505            .by_name("word/document.xml")
506            .unwrap()
507            .read_to_string(&mut doc_xml)
508            .unwrap();
509
510        assert!(!doc_xml.contains("Tom & Jerry"), "raw ampersand should be escaped");
511        assert!(doc_xml.contains("Tom &amp; Jerry"), "escaped value should be present");
512        assert!(!doc_xml.contains("x < y"), "raw less-than should be escaped");
513    }
514
515    #[test]
516    fn replace_in_table_cell_xml() {
517        let xml = concat!(
518            r#"<w:tbl><w:tr><w:tc>"#,
519            r#"<w:tcPr><w:tcW w:w="4680" w:type="dxa"/></w:tcPr>"#,
520            r#"<w:p><w:r><w:t>{Name}</w:t></w:r></w:p>"#,
521            r#"</w:tc></w:tr></w:tbl>"#,
522        );
523        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
524        assert!(result.contains("Alice"), "placeholder in table cell not replaced: {}", result);
525        assert!(!result.contains("{Name}"), "placeholder still present: {}", result);
526    }
527
528    #[test]
529    fn replace_in_nested_table_xml() {
530        let xml = concat!(
531            r#"<w:tbl><w:tr><w:tc>"#,
532            r#"<w:tbl><w:tr><w:tc>"#,
533            r#"<w:p><w:r><w:t>{Inner}</w:t></w:r></w:p>"#,
534            r#"</w:tc></w:tr></w:tbl>"#,
535            r#"</w:tc></w:tr></w:tbl>"#,
536        );
537        let result = replace_placeholders_in_xml(xml, &[("{Inner}", "Nested")]);
538        assert!(result.contains("Nested"), "placeholder in nested table not replaced: {}", result);
539        assert!(!result.contains("{Inner}"), "placeholder still present: {}", result);
540    }
541
542    #[test]
543    fn replace_multiple_cells_same_row() {
544        let xml = concat!(
545            r#"<w:tbl><w:tr>"#,
546            r#"<w:tc><w:p><w:r><w:t>{First}</w:t></w:r></w:p></w:tc>"#,
547            r#"<w:tc><w:p><w:r><w:t>{Last}</w:t></w:r></w:p></w:tc>"#,
548            r#"<w:tc><w:p><w:r><w:t>{Age}</w:t></w:r></w:p></w:tc>"#,
549            r#"</w:tr></w:tbl>"#,
550        );
551        let result = replace_placeholders_in_xml(
552            xml,
553            &[("{First}", "Alice"), ("{Last}", "Smith"), ("{Age}", "30")],
554        );
555        assert!(result.contains("Alice"), "First not replaced: {}", result);
556        assert!(result.contains("Smith"), "Last not replaced: {}", result);
557        assert!(result.contains("30"), "Age not replaced: {}", result);
558        assert!(!result.contains("{First}") && !result.contains("{Last}") && !result.contains("{Age}"),
559            "placeholders still present: {}", result);
560    }
561
562    #[test]
563    fn replace_in_footnote_xml() {
564        let xml = concat!(
565            r#"<w:footnotes>"#,
566            r#"<w:footnote w:type="normal" w:id="1">"#,
567            r#"<w:p><w:pPr><w:pStyle w:val="FootnoteText"/></w:pPr>"#,
568            r#"<w:r><w:rPr><w:rStyle w:val="FootnoteReference"/></w:rPr><w:footnoteRef/></w:r>"#,
569            r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
570            r#"<w:r><w:t>{Source}</w:t></w:r>"#,
571            r#"</w:p>"#,
572            r#"</w:footnote>"#,
573            r#"</w:footnotes>"#,
574        );
575        let result = replace_placeholders_in_xml(xml, &[("{Source}", "Wikipedia")]);
576        assert!(result.contains("Wikipedia"), "placeholder in footnote not replaced: {}", result);
577        assert!(!result.contains("{Source}"), "placeholder still present: {}", result);
578    }
579
580    #[test]
581    fn replace_in_endnote_xml() {
582        let xml = concat!(
583            r#"<w:endnotes>"#,
584            r#"<w:endnote w:type="normal" w:id="1">"#,
585            r#"<w:p><w:pPr><w:pStyle w:val="EndnoteText"/></w:pPr>"#,
586            r#"<w:r><w:rPr><w:rStyle w:val="EndnoteReference"/></w:rPr><w:endnoteRef/></w:r>"#,
587            r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
588            r#"<w:r><w:t>{Citation}</w:t></w:r>"#,
589            r#"</w:p>"#,
590            r#"</w:endnote>"#,
591            r#"</w:endnotes>"#,
592        );
593        let result = replace_placeholders_in_xml(xml, &[("{Citation}", "Doe, 2024")]);
594        assert!(result.contains("Doe, 2024"), "placeholder in endnote not replaced: {}", result);
595        assert!(!result.contains("{Citation}"), "placeholder still present: {}", result);
596    }
597
598    #[test]
599    fn replace_in_comment_xml() {
600        let xml = concat!(
601            r#"<w:comments>"#,
602            r#"<w:comment w:id="0" w:author="Author" w:date="2024-01-01T00:00:00Z">"#,
603            r#"<w:p><w:pPr><w:pStyle w:val="CommentText"/></w:pPr>"#,
604            r#"<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:annotationRef/></w:r>"#,
605            r#"<w:r><w:t>{ReviewNote}</w:t></w:r>"#,
606            r#"</w:p>"#,
607            r#"</w:comment>"#,
608            r#"</w:comments>"#,
609        );
610        let result = replace_placeholders_in_xml(xml, &[("{ReviewNote}", "Approved")]);
611        assert!(result.contains("Approved"), "placeholder in comment not replaced: {}", result);
612        assert!(!result.contains("{ReviewNote}"), "placeholder still present: {}", result);
613    }
614
615    #[test]
616    fn replace_in_sdt_xml() {
617        let xml = concat!(
618            r#"<w:sdt>"#,
619            r#"<w:sdtPr><w:alias w:val="Title"/></w:sdtPr>"#,
620            r#"<w:sdtContent>"#,
621            r#"<w:p><w:r><w:t>{Title}</w:t></w:r></w:p>"#,
622            r#"</w:sdtContent>"#,
623            r#"</w:sdt>"#,
624        );
625        let result = replace_placeholders_in_xml(xml, &[("{Title}", "Report")]);
626        assert!(result.contains("Report"), "placeholder in sdt not replaced: {}", result);
627        assert!(!result.contains("{Title}"), "placeholder still present: {}", result);
628    }
629
630    #[test]
631    fn replace_in_hyperlink_display_text() {
632        let xml = concat!(
633            r#"<w:p>"#,
634            r#"<w:hyperlink r:id="rId5" w:history="1">"#,
635            r#"<w:r><w:rPr><w:rStyle w:val="Hyperlink"/></w:rPr>"#,
636            r#"<w:t>{LinkText}</w:t></w:r>"#,
637            r#"</w:hyperlink>"#,
638            r#"</w:p>"#,
639        );
640        let result = replace_placeholders_in_xml(xml, &[("{LinkText}", "Click here")]);
641        assert!(result.contains("Click here"), "placeholder in hyperlink not replaced: {}", result);
642        assert!(!result.contains("{LinkText}"), "placeholder still present: {}", result);
643    }
644
645    #[test]
646    fn replace_in_textbox_xml() {
647        let xml = concat!(
648            r#"<wps:txbx>"#,
649            r#"<w:txbxContent>"#,
650            r#"<w:p><w:pPr><w:jc w:val="center"/></w:pPr>"#,
651            r#"<w:r><w:rPr><w:b/></w:rPr><w:t>{BoxTitle}</w:t></w:r>"#,
652            r#"</w:p>"#,
653            r#"</w:txbxContent>"#,
654            r#"</wps:txbx>"#,
655        );
656        let result = replace_placeholders_in_xml(xml, &[("{BoxTitle}", "Important")]);
657        assert!(result.contains("Important"), "placeholder in textbox not replaced: {}", result);
658        assert!(!result.contains("{BoxTitle}"), "placeholder still present: {}", result);
659    }
660
661    #[test]
662    fn replace_placeholder_split_across_three_runs() {
663        let xml = concat!(
664            r#"<w:r><w:t>{pl</w:t></w:r>"#,
665            r#"<w:r><w:t>ace</w:t></w:r>"#,
666            r#"<w:r><w:t>holder}</w:t></w:r>"#,
667        );
668        let result = replace_placeholders_in_xml(xml, &[("{placeholder}", "value")]);
669        assert!(result.contains("value"), "placeholder split across 3 runs not replaced: {}", result);
670        assert!(!result.contains("{pl"), "leftover fragment: {}", result);
671        assert!(!result.contains("holder}"), "leftover fragment: {}", result);
672    }
673
674    #[test]
675    fn replace_placeholder_split_across_four_runs() {
676        let xml = concat!(
677            r#"<w:r><w:t>{p</w:t></w:r>"#,
678            r#"<w:r><w:t>la</w:t></w:r>"#,
679            r#"<w:r><w:t>ceh</w:t></w:r>"#,
680            r#"<w:r><w:t>older}</w:t></w:r>"#,
681        );
682        let result = replace_placeholders_in_xml(xml, &[("{placeholder}", "value")]);
683        assert!(result.contains("value"), "placeholder split across 4 runs not replaced: {}", result);
684        assert!(!result.contains("placeholder"), "leftover fragment: {}", result);
685    }
686
687    #[test]
688    fn replace_adjacent_placeholders_no_space() {
689        let xml = r#"<w:r><w:t>{first}{last}</w:t></w:r>"#;
690        let result = replace_placeholders_in_xml(xml, &[("{first}", "Alice"), ("{last}", "Smith")]);
691        assert_eq!(result, r#"<w:r><w:t>AliceSmith</w:t></w:r>"#);
692    }
693
694    #[test]
695    fn replace_with_bookmark_markers_between_runs() {
696        let xml = concat!(
697            r#"<w:r><w:t>{Na</w:t></w:r>"#,
698            r#"<w:bookmarkStart w:id="0" w:name="bookmark1"/>"#,
699            r#"<w:r><w:t>me}</w:t></w:r>"#,
700            r#"<w:bookmarkEnd w:id="0"/>"#,
701        );
702        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
703        assert!(result.contains("Alice"), "placeholder with bookmark between runs not replaced: {}", result);
704        assert!(!result.contains("{Na"), "leftover fragment: {}", result);
705        assert!(result.contains("w:bookmarkStart"), "bookmark markers should be preserved: {}", result);
706    }
707
708    #[test]
709    fn replace_with_comment_markers_between_runs() {
710        let xml = concat!(
711            r#"<w:r><w:t>{Na</w:t></w:r>"#,
712            r#"<w:commentRangeStart w:id="1"/>"#,
713            r#"<w:r><w:t>me}</w:t></w:r>"#,
714            r#"<w:commentRangeEnd w:id="1"/>"#,
715        );
716        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
717        assert!(result.contains("Alice"), "placeholder with comment markers between runs not replaced: {}", result);
718        assert!(!result.contains("{Na"), "leftover fragment: {}", result);
719        assert!(result.contains("w:commentRangeStart"), "comment markers should be preserved: {}", result);
720    }
721
722    #[test]
723    fn replace_with_formatting_props_between_runs() {
724        let xml = concat!(
725            r#"<w:r><w:rPr><w:b/></w:rPr><w:t>{Na</w:t></w:r>"#,
726            r#"<w:r><w:rPr><w:i/></w:rPr><w:t>me}</w:t></w:r>"#,
727        );
728        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
729        assert!(result.contains("Alice"), "placeholder with rPr between runs not replaced: {}", result);
730        assert!(!result.contains("{Na"), "leftover fragment: {}", result);
731        assert!(result.contains("<w:rPr><w:b/></w:rPr>"), "formatting should be preserved: {}", result);
732        assert!(result.contains("<w:rPr><w:i/></w:rPr>"), "formatting should be preserved: {}", result);
733    }
734
735    #[test]
736    fn replace_with_empty_value() {
737        let xml = r#"<w:p><w:r><w:t>Hello {Name}!</w:t></w:r></w:p>"#;
738        let result = replace_placeholders_in_xml(xml, &[("{Name}", "")]);
739        assert_eq!(result, r#"<w:p><w:r><w:t>Hello !</w:t></w:r></w:p>"#);
740    }
741
742    #[test]
743    fn replace_value_containing_curly_braces() {
744        let xml = r#"<w:r><w:t>{Name}</w:t></w:r>"#;
745        let result = replace_placeholders_in_xml(xml, &[("{Name}", "{Alice}")]);
746        assert_eq!(result, r#"<w:r><w:t>{Alice}</w:t></w:r>"#);
747
748        let result = replace_placeholders_in_xml(xml, &[("{Name}", "a}b{c")]);
749        assert_eq!(result, r#"<w:r><w:t>a}b{c</w:t></w:r>"#);
750    }
751
752    #[test]
753    fn replace_with_multiline_value() {
754        let xml = r#"<w:r><w:t>{Name}</w:t></w:r>"#;
755        let result = replace_placeholders_in_xml(xml, &[("{Name}", "line1\nline2\nline3")]);
756        assert_eq!(result, r#"<w:r><w:t>line1
757line2
758line3</w:t></w:r>"#);
759    }
760
761    #[test]
762    fn replace_same_placeholder_many_occurrences() {
763        let xml = concat!(
764            r#"<w:r><w:t>{x}</w:t></w:r>"#,
765            r#"<w:r><w:t>{x}</w:t></w:r>"#,
766            r#"<w:r><w:t>{x}</w:t></w:r>"#,
767            r#"<w:r><w:t>{x}</w:t></w:r>"#,
768            r#"<w:r><w:t>{x}</w:t></w:r>"#,
769        );
770        let result = replace_placeholders_in_xml(xml, &[("{x}", "V")]);
771        assert!(!result.contains("{x}"), "not all occurrences replaced: {}", result);
772        assert_eq!(result.matches("V").count(), 5, "expected 5 replacements: {}", result);
773    }
774
775    #[test]
776    fn drawingml_a_t_tags_are_replaced() {
777        let xml = r#"<a:p><a:r><a:t>{placeholder}</a:t></a:r></a:p>"#;
778        let result = replace_placeholders_in_xml(xml, &[("{placeholder}", "replaced")]);
779        assert!(
780            result.contains("replaced"),
781            "DrawingML <a:t> tags should be replaced: {}",
782            result
783        );
784        assert!(
785            !result.contains("{placeholder}"),
786            "DrawingML <a:t> placeholder should not remain: {}",
787            result
788        );
789    }
790
791    #[test]
792    fn drawingml_a_t_split_across_runs() {
793        let xml = r#"<a:r><a:t>{Na</a:t></a:r><a:r><a:t>me}</a:t></a:r>"#;
794        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
795        assert!(result.contains("Alice"), "split <a:t> placeholder not replaced: {}", result);
796        assert!(!result.contains("{Na"), "leftover fragment: {}", result);
797    }
798
799    #[test]
800    fn drawingml_a_t_escapes_xml() {
801        let xml = r#"<a:t>{Name}</a:t>"#;
802        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice & Bob")]);
803        assert_eq!(result, r#"<a:t>Alice &amp; Bob</a:t>"#);
804    }
805
806    #[test]
807    fn wt_and_at_processed_independently() {
808        let xml = r#"<w:r><w:t>{wt_val}</w:t></w:r><a:r><a:t>{at_val}</a:t></a:r>"#;
809        let result = replace_placeholders_in_xml(
810            xml,
811            &[("{wt_val}", "Word"), ("{at_val}", "Drawing")],
812        );
813        assert!(result.contains("Word"), "w:t not replaced: {}", result);
814        assert!(result.contains("Drawing"), "a:t not replaced: {}", result);
815        assert!(!result.contains("{wt_val}"), "w:t placeholder remains: {}", result);
816        assert!(!result.contains("{at_val}"), "a:t placeholder remains: {}", result);
817    }
818
819    #[test]
820    fn math_m_t_tags_replaced() {
821        let xml = r#"<m:r><m:t>{formula}</m:t></m:r>"#;
822        let result = replace_placeholders_in_xml(xml, &[("{formula}", "x+1")]);
823        assert_eq!(result, r#"<m:r><m:t>x+1</m:t></m:r>"#);
824    }
825
826    #[test]
827    fn drawingml_a_t_with_attributes() {
828        let xml = r#"<a:t xml:space="preserve">{placeholder}</a:t>"#;
829        let result = replace_placeholders_in_xml(xml, &[("{placeholder}", "value")]);
830        assert_eq!(result, r#"<a:t xml:space="preserve">value</a:t>"#);
831    }
832
833    // -- Tag boundary validation tests --
834    // Ensures <w:t, <a:t, <m:t prefixes don't false-match longer tag names
835
836    #[test]
837    fn wt_prefix_does_not_match_w_tab() {
838        let xml = r#"<w:r><w:tab/><w:t>{Name}</w:t></w:r>"#;
839        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
840        assert_eq!(result, r#"<w:r><w:tab/><w:t>Alice</w:t></w:r>"#);
841    }
842
843    #[test]
844    fn wt_prefix_does_not_match_w_tbl() {
845        let xml = r#"<w:tbl><w:tr><w:tc><w:p><w:r><w:t>{Val}</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"#;
846        let result = replace_placeholders_in_xml(xml, &[("{Val}", "OK")]);
847        assert!(result.contains("OK"), "placeholder not replaced: {}", result);
848        assert!(!result.contains("{Val}"), "placeholder remains: {}", result);
849    }
850
851    #[test]
852    fn at_prefix_does_not_match_a_tab() {
853        let xml = r#"<a:p><a:r><a:tab/><a:t>{Name}</a:t></a:r></a:p>"#;
854        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
855        assert!(result.contains("<a:tab/>"), "a:tab should be untouched: {}", result);
856        assert!(result.contains("Alice"), "placeholder not replaced: {}", result);
857    }
858
859    #[test]
860    fn at_prefix_does_not_match_a_tbl_or_a_tc() {
861        let xml = concat!(
862            r#"<a:tbl><a:tr><a:tc><a:txBody>"#,
863            r#"<a:p><a:r><a:t>{Cell}</a:t></a:r></a:p>"#,
864            r#"</a:txBody></a:tc></a:tr></a:tbl>"#,
865        );
866        let result = replace_placeholders_in_xml(xml, &[("{Cell}", "Data")]);
867        assert!(result.contains("Data"), "placeholder not replaced: {}", result);
868        assert!(!result.contains("{Cell}"), "placeholder remains: {}", result);
869    }
870
871    #[test]
872    fn self_closing_tags_are_skipped() {
873        let xml = r#"<a:t/><a:t>{Name}</a:t>"#;
874        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
875        assert!(result.contains("<a:t/>"), "self-closing tag should be untouched: {}", result);
876        assert!(result.contains("Alice"), "placeholder not replaced: {}", result);
877    }
878
879    #[test]
880    fn mt_prefix_does_not_match_longer_math_tags() {
881        let xml = r#"<m:type>ignored</m:type><m:r><m:t>{X}</m:t></m:r>"#;
882        let result = replace_placeholders_in_xml(xml, &[("{X}", "42")]);
883        assert!(result.contains("ignored"), "m:type content should be untouched: {}", result);
884        assert!(result.contains("42"), "placeholder not replaced: {}", result);
885    }
886
887    #[test]
888    fn mixed_similar_tags_only_replaces_correct_ones() {
889        let xml = concat!(
890            r#"<w:tab/>"#,
891            r#"<w:tbl><w:tr><w:tc></w:tc></w:tr></w:tbl>"#,
892            r#"<w:r><w:t>{word}</w:t></w:r>"#,
893            r#"<a:tab/>"#,
894            r#"<a:tbl><a:tr><a:tc></a:tc></a:tr></a:tbl>"#,
895            r#"<a:r><a:t>{draw}</a:t></a:r>"#,
896            r#"<m:r><m:t>{math}</m:t></m:r>"#,
897        );
898        let result = replace_placeholders_in_xml(
899            xml,
900            &[("{word}", "W"), ("{draw}", "D"), ("{math}", "M")],
901        );
902        assert!(result.contains("<w:tab/>"), "w:tab modified");
903        assert!(result.contains("<a:tab/>"), "a:tab modified");
904        assert_eq!(result.matches("W").count(), 1);
905        assert_eq!(result.matches("D").count(), 1);
906        assert_eq!(result.matches("M").count(), 1);
907        assert!(!result.contains("{word}"));
908        assert!(!result.contains("{draw}"));
909        assert!(!result.contains("{math}"));
910    }
911
912    #[test]
913    fn prefix_at_end_of_string_does_not_panic() {
914        let xml = "some text<a:t";
915        let result = replace_placeholders_in_xml(xml, &[("{x}", "y")]);
916        assert_eq!(result, xml);
917    }
918
919    #[test]
920    fn w_t_with_space_preserve_attribute() {
921        let xml = r#"<w:r><w:t xml:space="preserve"> {Name} </w:t></w:r>"#;
922        let result = replace_placeholders_in_xml(xml, &[("{Name}", "Bob")]);
923        assert!(result.contains("Bob"), "placeholder not replaced: {}", result);
924    }
925
926    fn create_test_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
927        let mut buf = Cursor::new(Vec::new());
928        {
929            let mut zip = zip::write::ZipWriter::new(&mut buf);
930            let options = zip::write::SimpleFileOptions::default()
931                .compression_method(zip::CompressionMethod::Deflated);
932            for &(name, content) in files {
933                zip.start_file(name, options).unwrap();
934                zip.write_all(content).unwrap();
935            }
936            zip.finish().unwrap();
937        }
938        buf.into_inner()
939    }
940
941    #[test]
942    fn build_docx_replaces_in_footnotes_xml() {
943        let footnotes_xml = concat!(
944            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
945            r#"<w:footnotes>"#,
946            r#"<w:footnote w:id="1"><w:p><w:r><w:t>{Source}</w:t></w:r></w:p></w:footnote>"#,
947            r#"</w:footnotes>"#,
948        );
949        let doc_xml = r#"<?xml version="1.0" encoding="UTF-8"?><w:document><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body></w:document>"#;
950        let template = create_test_zip(&[
951            ("word/document.xml", doc_xml.as_bytes()),
952            ("word/footnotes.xml", footnotes_xml.as_bytes()),
953        ]);
954        let result = __private::build_docx_bytes(&template, &[("{Source}", "Wikipedia")]).unwrap();
955        let cursor = Cursor::new(&result);
956        let mut archive = zip::ZipArchive::new(cursor).unwrap();
957        let mut xml = String::new();
958        archive.by_name("word/footnotes.xml").unwrap().read_to_string(&mut xml).unwrap();
959        assert!(xml.contains("Wikipedia"), "placeholder in footnotes.xml not replaced: {}", xml);
960        assert!(!xml.contains("{Source}"), "placeholder still present: {}", xml);
961    }
962
963    #[test]
964    fn build_docx_replaces_in_endnotes_xml() {
965        let endnotes_xml = concat!(
966            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
967            r#"<w:endnotes>"#,
968            r#"<w:endnote w:id="1"><w:p><w:r><w:t>{Citation}</w:t></w:r></w:p></w:endnote>"#,
969            r#"</w:endnotes>"#,
970        );
971        let doc_xml = r#"<?xml version="1.0" encoding="UTF-8"?><w:document><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body></w:document>"#;
972        let template = create_test_zip(&[
973            ("word/document.xml", doc_xml.as_bytes()),
974            ("word/endnotes.xml", endnotes_xml.as_bytes()),
975        ]);
976        let result = __private::build_docx_bytes(&template, &[("{Citation}", "Doe 2024")]).unwrap();
977        let cursor = Cursor::new(&result);
978        let mut archive = zip::ZipArchive::new(cursor).unwrap();
979        let mut xml = String::new();
980        archive.by_name("word/endnotes.xml").unwrap().read_to_string(&mut xml).unwrap();
981        assert!(xml.contains("Doe 2024"), "placeholder in endnotes.xml not replaced: {}", xml);
982        assert!(!xml.contains("{Citation}"), "placeholder still present: {}", xml);
983    }
984
985    #[test]
986    fn build_docx_replaces_in_comments_xml() {
987        let comments_xml = concat!(
988            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
989            r#"<w:comments>"#,
990            r#"<w:comment w:id="0"><w:p><w:r><w:t>{Note}</w:t></w:r></w:p></w:comment>"#,
991            r#"</w:comments>"#,
992        );
993        let doc_xml = r#"<?xml version="1.0" encoding="UTF-8"?><w:document><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body></w:document>"#;
994        let template = create_test_zip(&[
995            ("word/document.xml", doc_xml.as_bytes()),
996            ("word/comments.xml", comments_xml.as_bytes()),
997        ]);
998        let result = __private::build_docx_bytes(&template, &[("{Note}", "Approved")]).unwrap();
999        let cursor = Cursor::new(&result);
1000        let mut archive = zip::ZipArchive::new(cursor).unwrap();
1001        let mut xml = String::new();
1002        archive.by_name("word/comments.xml").unwrap().read_to_string(&mut xml).unwrap();
1003        assert!(xml.contains("Approved"), "placeholder in comments.xml not replaced: {}", xml);
1004        assert!(!xml.contains("{Note}"), "placeholder still present: {}", xml);
1005    }
1006
1007    #[test]
1008    fn build_docx_replaces_across_multiple_xml_files() {
1009        let doc_xml = r#"<?xml version="1.0"?><w:document><w:body><w:p><w:r><w:t>{Body}</w:t></w:r></w:p></w:body></w:document>"#;
1010        let header_xml = r#"<?xml version="1.0"?><w:hdr><w:p><w:r><w:t>{Header}</w:t></w:r></w:p></w:hdr>"#;
1011        let footer_xml = r#"<?xml version="1.0"?><w:ftr><w:p><w:r><w:t>{Footer}</w:t></w:r></w:p></w:ftr>"#;
1012        let footnotes_xml = r#"<?xml version="1.0"?><w:footnotes><w:footnote w:id="1"><w:p><w:r><w:t>{FNote}</w:t></w:r></w:p></w:footnote></w:footnotes>"#;
1013        let template = create_test_zip(&[
1014            ("word/document.xml", doc_xml.as_bytes()),
1015            ("word/header1.xml", header_xml.as_bytes()),
1016            ("word/footer1.xml", footer_xml.as_bytes()),
1017            ("word/footnotes.xml", footnotes_xml.as_bytes()),
1018        ]);
1019        let result = __private::build_docx_bytes(
1020            &template,
1021            &[("{Body}", "Main"), ("{Header}", "Top"), ("{Footer}", "Bottom"), ("{FNote}", "Ref1")],
1022        ).unwrap();
1023        let cursor = Cursor::new(&result);
1024        let mut archive = zip::ZipArchive::new(cursor).unwrap();
1025        for (file, expected, placeholder) in [
1026            ("word/document.xml", "Main", "{Body}"),
1027            ("word/header1.xml", "Top", "{Header}"),
1028            ("word/footer1.xml", "Bottom", "{Footer}"),
1029            ("word/footnotes.xml", "Ref1", "{FNote}"),
1030        ] {
1031            let mut xml = String::new();
1032            archive.by_name(file).unwrap().read_to_string(&mut xml).unwrap();
1033            assert!(xml.contains(expected), "{} not replaced in {}: {}", placeholder, file, xml);
1034            assert!(!xml.contains(placeholder), "{} still present in {}: {}", placeholder, file, xml);
1035        }
1036    }
1037
1038    #[test]
1039    fn build_docx_preserves_non_xml_files() {
1040        let doc_xml = r#"<w:document><w:body><w:p><w:r><w:t>Hi</w:t></w:r></w:p></w:body></w:document>"#;
1041        let image_bytes: &[u8] = &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0xFF, 0xFE];
1042        let template = create_test_zip(&[
1043            ("word/document.xml", doc_xml.as_bytes()),
1044            ("word/media/image1.png", image_bytes),
1045        ]);
1046        let result = __private::build_docx_bytes(&template, &[]).unwrap();
1047        let cursor = Cursor::new(&result);
1048        let mut archive = zip::ZipArchive::new(cursor).unwrap();
1049        let mut output_image = Vec::new();
1050        archive.by_name("word/media/image1.png").unwrap().read_to_end(&mut output_image).unwrap();
1051        assert_eq!(output_image, image_bytes, "binary content should be preserved unchanged");
1052    }
1053
1054    #[test]
1055    fn build_docx_does_not_replace_in_non_xml() {
1056        let doc_xml = r#"<w:document><w:body><w:p><w:r><w:t>Hi</w:t></w:r></w:p></w:body></w:document>"#;
1057        let bin_content = b"some binary with {Name} placeholder text";
1058        let template = create_test_zip(&[
1059            ("word/document.xml", doc_xml.as_bytes()),
1060            ("word/embeddings/data.bin", bin_content),
1061        ]);
1062        let result = __private::build_docx_bytes(&template, &[("{Name}", "Alice")]).unwrap();
1063        let cursor = Cursor::new(&result);
1064        let mut archive = zip::ZipArchive::new(cursor).unwrap();
1065        let mut output_bin = Vec::new();
1066        archive.by_name("word/embeddings/data.bin").unwrap().read_to_end(&mut output_bin).unwrap();
1067        assert_eq!(output_bin, bin_content.as_slice(), ".bin file should not have replacements applied");
1068    }
1069
1070    #[test]
1071    fn build_docx_replaces_in_drawingml_xml() {
1072        let diagram_xml = concat!(
1073            r#"<?xml version="1.0" encoding="UTF-8"?>"#,
1074            r#"<dgm:dataModel>"#,
1075            r#"<dgm:ptLst><dgm:pt><dgm:t><a:bodyPr/><a:p><a:r><a:t>{shape_text}</a:t></a:r></a:p></dgm:t></dgm:pt></dgm:ptLst>"#,
1076            r#"</dgm:dataModel>"#,
1077        );
1078        let doc_xml = r#"<?xml version="1.0"?><w:document><w:body><w:p><w:r><w:t>Body</w:t></w:r></w:p></w:body></w:document>"#;
1079        let template = create_test_zip(&[
1080            ("word/document.xml", doc_xml.as_bytes()),
1081            ("word/diagrams/data1.xml", diagram_xml.as_bytes()),
1082        ]);
1083        let result = __private::build_docx_bytes(&template, &[("{shape_text}", "Replaced!")]).unwrap();
1084        let cursor = Cursor::new(&result);
1085        let mut archive = zip::ZipArchive::new(cursor).unwrap();
1086        let mut xml = String::new();
1087        archive.by_name("word/diagrams/data1.xml").unwrap().read_to_string(&mut xml).unwrap();
1088        assert!(xml.contains("Replaced!"), "placeholder in DrawingML data1.xml not replaced: {}", xml);
1089        assert!(!xml.contains("{shape_text}"), "placeholder still present: {}", xml);
1090    }
1091
1092    #[test]
1093    fn build_docx_bytes_replaces_content() {
1094        let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
1095        if !template_path.exists() {
1096            return;
1097        }
1098        let template_bytes = std::fs::read(template_path).unwrap();
1099        let result = __private::build_docx_bytes(
1100            &template_bytes,
1101            &[("{ firstName }", "Alice"), ("{ productName }", "Docxide")],
1102        )
1103        .unwrap();
1104
1105        let cursor = Cursor::new(&result);
1106        let mut archive = zip::ZipArchive::new(cursor).unwrap();
1107        let mut doc_xml = String::new();
1108        archive
1109            .by_name("word/document.xml")
1110            .unwrap()
1111            .read_to_string(&mut doc_xml)
1112            .unwrap();
1113        assert!(doc_xml.contains("Alice"));
1114        assert!(doc_xml.contains("Docxide"));
1115        assert!(!doc_xml.contains("firstName"));
1116        assert!(!doc_xml.contains("productName"));
1117    }
1118}