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