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