1pub use docxide_template_derive::generate_templates;
8
9use std::io::{Cursor, Read, Write};
10use std::path::Path;
11
12#[derive(Debug)]
14pub enum TemplateError {
15 Io(std::io::Error),
17 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
56pub trait DocxTemplate {
68 fn template_path(&self) -> &Path;
70
71 fn replacements(&self) -> Vec<(&str, &str)>;
73
74 fn to_bytes(&self) -> Result<Vec<u8>, TemplateError>;
76
77 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('&', "&")
132 .replace('<', "<")
133 .replace('>', ">")
134 .replace('"', """)
135 .replace('\'', "'")
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 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 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 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 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 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 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 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 ("{header}", "TITLE"),
395 ("{header}", "TITLE"),
396 ("{foo}", "BAR"),
397 ("{ foo }", "BAR"),
398 ],
399 );
400 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 & Bob</w:t>"#);
466
467 let result = replace_placeholders_in_xml(xml, &[("{Name}", "<script>")]);
468 assert_eq!(result, r#"<w:t><script></w:t>"#);
469
470 let result = replace_placeholders_in_xml(xml, &[("{Name}", "a < b & c > d")]);
471 assert_eq!(result, r#"<w:t>a < b & c > 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 "hello"</w:t>"#);
475
476 let result = replace_placeholders_in_xml(xml, &[("{Name}", "it's")]);
477 assert_eq!(result, r#"<w:t>it'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&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 & 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 & 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 #[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}