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