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 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('&', "&")
135 .replace('<', "<")
136 .replace('>', ">")
137 .replace('"', """)
138 .replace('\'', "'")
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
228fn 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 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 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 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 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 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 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 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 ("{header}", "TITLE"),
410 ("{header}", "TITLE"),
411 ("{foo}", "BAR"),
412 ("{ foo }", "BAR"),
413 ],
414 );
415 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 & Bob</w:t>"#);
481
482 let result = replace_placeholders_in_xml(xml, &[("{Name}", "<script>")]);
483 assert_eq!(result, r#"<w:t><script></w:t>"#);
484
485 let result = replace_placeholders_in_xml(xml, &[("{Name}", "a < b & c > d")]);
486 assert_eq!(result, r#"<w:t>a < b & c > 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 "hello"</w:t>"#);
490
491 let result = replace_placeholders_in_xml(xml, &[("{Name}", "it's")]);
492 assert_eq!(result, r#"<w:t>it'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&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 & 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 & 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 #[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}