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
87 for i in 0..archive.len() {
88 let mut file = archive.by_index(i)?;
89 let file_name = file.name().to_string();
90
91 let mut contents = Vec::new();
92 file.read_to_end(&mut contents)?;
93
94 if file_name.ends_with(".xml") || file_name.ends_with(".rels") {
95 let xml = String::from_utf8(contents)?;
96 let replaced = replace_placeholders_in_xml(&xml, replacements);
97 contents = replaced.into_bytes();
98 }
99
100 zip_writer.start_file(&file_name, options)?;
101 zip_writer.write_all(&contents)?;
102 }
103
104 zip_writer.finish()?;
105 Ok(output_buf.into_inner())
106 }
107
108 pub fn save_docx_bytes(
109 template_bytes: &[u8],
110 output_path: &Path,
111 replacements: &[(&str, &str)],
112 ) -> Result<(), TemplateError> {
113 let bytes = build_docx_bytes(template_bytes, replacements)?;
114 if let Some(parent) = output_path.parent() {
115 std::fs::create_dir_all(parent)?;
116 }
117 std::fs::write(output_path, bytes)?;
118 Ok(())
119 }
120}
121
122fn escape_xml(s: &str) -> String {
123 s.replace('&', "&").replace('<', "<").replace('>', ">")
124}
125
126fn replace_placeholders_in_xml(xml: &str, replacements: &[(&str, &str)]) -> String {
127 let mut text_spans: Vec<(usize, usize, String)> = Vec::new();
128 let mut search_start = 0;
129 while let Some(tag_start) = xml[search_start..].find("<w:t") {
130 let tag_start = search_start + tag_start;
131 let content_start = match xml[tag_start..].find('>') {
132 Some(pos) => tag_start + pos + 1,
133 None => break,
134 };
135 let content_end = match xml[content_start..].find("</w:t>") {
136 Some(pos) => content_start + pos,
137 None => break,
138 };
139 let text = xml[content_start..content_end].to_string();
140 text_spans.push((content_start, content_end, text));
141 search_start = content_end + 6;
142 }
143
144 if text_spans.is_empty() {
145 return xml.to_string();
146 }
147
148 let concatenated: String = text_spans.iter().map(|(_, _, t)| t.as_str()).collect();
149
150 let offset_map: Vec<(usize, usize)> = text_spans
151 .iter()
152 .enumerate()
153 .flat_map(|(span_idx, (_, _, text))| {
154 (0..text.len()).map(move |char_offset| (span_idx, char_offset))
155 })
156 .collect();
157
158 let mut span_replacements: Vec<Vec<(usize, usize, String)>> = vec![Vec::new(); text_spans.len()];
159 for &(placeholder, value) in replacements {
160 let mut start = 0;
161 while let Some(found) = concatenated[start..].find(placeholder) {
162 let match_start = start + found;
163 let match_end = match_start + placeholder.len();
164 if match_start >= offset_map.len() || match_end > offset_map.len() {
165 break;
166 }
167
168 let (start_span, start_off) = offset_map[match_start];
169 let (end_span, _) = offset_map[match_end - 1];
170 let end_off_exclusive = offset_map[match_end - 1].1 + 1;
171
172 if start_span == end_span {
173 span_replacements[start_span].push((start_off, end_off_exclusive, escape_xml(value)));
174 } else {
175 let first_span_text = &text_spans[start_span].2;
176 span_replacements[start_span].push((start_off, first_span_text.len(), escape_xml(value)));
177 for mid in (start_span + 1)..end_span {
178 let mid_len = text_spans[mid].2.len();
179 span_replacements[mid].push((0, mid_len, String::new()));
180 }
181 span_replacements[end_span].push((0, end_off_exclusive, String::new()));
182 }
183 start = match_end;
184 }
185 }
186
187 let mut result = xml.to_string();
188 for (span_idx, (content_start, content_end, _)) in text_spans.iter().enumerate().rev() {
189 let mut span_text = result[*content_start..*content_end].to_string();
190 let mut reps = span_replacements[span_idx].clone();
191 reps.sort_by(|a, b| b.0.cmp(&a.0));
192 for (from, to, replacement) in reps {
193 let safe_to = to.min(span_text.len());
194 span_text = format!("{}{}{}", &span_text[..from], replacement, &span_text[safe_to..]);
195 }
196 result = format!("{}{}{}", &result[..*content_start], span_text, &result[*content_end..]);
197 }
198
199 result
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
207 fn replace_single_run_placeholder() {
208 let xml = r#"<w:t>{Name}</w:t>"#;
209 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
210 assert_eq!(result, r#"<w:t>Alice</w:t>"#);
211 }
212
213 #[test]
214 fn replace_placeholder_split_across_runs() {
215 let xml = r#"<w:t>{Na</w:t><w:t>me}</w:t>"#;
216 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
217 assert_eq!(result, r#"<w:t>Alice</w:t><w:t></w:t>"#);
218 }
219
220 #[test]
221 fn replace_placeholder_with_inner_whitespace() {
222 let xml = r#"<w:t>Hello { Name }!</w:t>"#;
223 let result = replace_placeholders_in_xml(xml, &[("{ Name }", "Alice")]);
224 assert_eq!(result, r#"<w:t>Hello Alice!</w:t>"#);
225 }
226
227 #[test]
228 fn replace_both_whitespace_variants() {
229 let xml = r#"<w:t>{Name} and { Name }</w:t>"#;
230 let result = replace_placeholders_in_xml(
231 xml,
232 &[("{Name}", "Alice"), ("{ Name }", "Alice")],
233 );
234 assert_eq!(result, r#"<w:t>Alice and Alice</w:t>"#);
235 }
236
237 #[test]
238 fn replace_multiple_placeholders() {
239 let xml = r#"<w:t>Hello {First} {Last}!</w:t>"#;
240 let result = replace_placeholders_in_xml(
241 xml,
242 &[("{First}", "Alice"), ("{Last}", "Smith")],
243 );
244 assert_eq!(result, r#"<w:t>Hello Alice Smith!</w:t>"#);
245 }
246
247 #[test]
248 fn no_placeholders_returns_unchanged() {
249 let xml = r#"<w:t>No placeholders here</w:t>"#;
250 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
251 assert_eq!(result, xml);
252 }
253
254 #[test]
255 fn no_wt_tags_returns_unchanged() {
256 let xml = r#"<w:p>plain paragraph</w:p>"#;
257 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
258 assert_eq!(result, xml);
259 }
260
261 #[test]
262 fn empty_replacements_returns_unchanged() {
263 let xml = r#"<w:t>{Name}</w:t>"#;
264 let result = replace_placeholders_in_xml(xml, &[]);
265 assert_eq!(result, xml);
266 }
267
268 #[test]
269 fn preserves_wt_attributes() {
270 let xml = r#"<w:t xml:space="preserve">{Name}</w:t>"#;
271 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
272 assert_eq!(result, r#"<w:t xml:space="preserve">Alice</w:t>"#);
273 }
274
275 #[test]
276 fn replace_whitespace_placeholder_split_across_runs() {
277 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>"#;
279 let result = replace_placeholders_in_xml(xml, &[("{ foo }", "bar")]);
280 assert!(
281 !result.contains("foo"),
282 "placeholder not replaced: {}",
283 result
284 );
285 assert!(result.contains("bar"), "value not present: {}", result);
286 }
287
288 #[test]
289 fn replace_whitespace_placeholder_with_prooferr_between_runs() {
290 let xml = concat!(
292 r#"<w:r><w:t>{foo}</w:t></w:r>"#,
293 r#"<w:r><w:t>{</w:t></w:r>"#,
294 r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
295 r#"<w:r><w:t>foo</w:t></w:r>"#,
296 r#"<w:proofErr w:type="gramEnd"/>"#,
297 r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
298 r#"<w:r><w:t>}</w:t></w:r>"#,
299 );
300 let result = replace_placeholders_in_xml(
301 xml,
302 &[("{foo}", "bar"), ("{ foo }", "bar")],
303 );
304 assert!(
306 !result.contains("foo"),
307 "placeholder not replaced: {}",
308 result
309 );
310 }
311
312 #[test]
313 fn replace_all_variants_in_full_document() {
314 let xml = concat!(
316 r#"<w:t>{header}</w:t>"#,
317 r#"<w:t>{header}</w:t>"#,
318 r#"<w:t>{foo}</w:t>"#,
319 r#"<w:t>{</w:t>"#,
321 r#"<w:t xml:space="preserve"> </w:t>"#,
322 r#"<w:t>foo</w:t>"#,
323 r#"<w:t xml:space="preserve"> </w:t>"#,
324 r#"<w:t>}</w:t>"#,
325 r#"<w:t>{</w:t>"#,
327 r#"<w:t xml:space="preserve"> </w:t>"#,
328 r#"<w:t xml:space="preserve"> </w:t>"#,
329 r#"<w:t>foo</w:t>"#,
330 r#"<w:t xml:space="preserve"> </w:t>"#,
331 r#"<w:t>}</w:t>"#,
332 );
333 let result = replace_placeholders_in_xml(
334 xml,
335 &[
336 ("{header}", "TITLE"),
337 ("{foo}", "BAR"),
338 ("{ foo }", "BAR"),
339 ("{ foo }", "BAR"),
340 ],
341 );
342 assert!(
343 !result.contains("header"),
344 "{{header}} not replaced: {}",
345 result,
346 );
347 assert!(
348 !result.contains("foo"),
349 "foo variant not replaced: {}",
350 result,
351 );
352 }
353
354 #[test]
355 fn duplicate_replacement_does_not_break_later_spans() {
356 let xml = concat!(
358 r#"<w:t>{header}</w:t>"#,
359 r#"<w:t>{header}</w:t>"#,
360 r#"<w:t>{foo}</w:t>"#,
361 r#"<w:t>{</w:t>"#,
362 r#"<w:t xml:space="preserve"> </w:t>"#,
363 r#"<w:t>foo</w:t>"#,
364 r#"<w:t xml:space="preserve"> </w:t>"#,
365 r#"<w:t>}</w:t>"#,
366 );
367 let result = replace_placeholders_in_xml(
368 xml,
369 &[
370 ("{header}", "TITLE"),
372 ("{header}", "TITLE"),
373 ("{foo}", "BAR"),
374 ("{ foo }", "BAR"),
375 ],
376 );
377 assert!(
379 !result.contains("foo"),
380 "foo not replaced when duplicate header present: {}",
381 result,
382 );
383 }
384
385 #[test]
386 fn replace_headfoottest_template() {
387 let template_path = Path::new("../test-crate/templates/HeadFootTest.docx");
388 if !template_path.exists() {
389 return;
390 }
391 let template_bytes = std::fs::read(template_path).unwrap();
392 let result = __private::build_docx_bytes(
393 &template_bytes,
394 &[
395 ("{header}", "TITLE"),
396 ("{foo}", "BAR"),
397 ("{ foo }", "BAR"),
398 ("{ foo }", "BAR"),
399 ("{top}", "TOP"),
400 ("{bottom}", "BOT"),
401 ],
402 )
403 .unwrap();
404
405 let cursor = Cursor::new(&result);
406 let mut archive = zip::ZipArchive::new(cursor).unwrap();
407 let mut doc_xml = String::new();
408 archive
409 .by_name("word/document.xml")
410 .unwrap()
411 .read_to_string(&mut doc_xml)
412 .unwrap();
413
414 assert!(!doc_xml.contains("{header}"), "header placeholder not replaced");
415 assert!(!doc_xml.contains("{foo}"), "foo placeholder not replaced");
416 assert!(!doc_xml.contains("{ foo }"), "spaced foo placeholder not replaced");
417 }
418
419 #[test]
420 fn build_docx_bytes_produces_valid_zip() {
421 let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
422 if !template_path.exists() {
423 return;
424 }
425 let template_bytes = std::fs::read(template_path).unwrap();
426 let result = __private::build_docx_bytes(
427 &template_bytes,
428 &[("{ firstName }", "Test"), ("{ productName }", "Lib")],
429 )
430 .unwrap();
431
432 assert!(!result.is_empty());
433 let cursor = Cursor::new(&result);
434 let archive = zip::ZipArchive::new(cursor).expect("output should be a valid zip");
435 assert!(archive.len() > 0);
436 }
437
438 #[test]
439 fn escape_xml_special_characters() {
440 let xml = r#"<w:t>{Name}</w:t>"#;
441 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice & Bob")]);
442 assert_eq!(result, r#"<w:t>Alice & Bob</w:t>"#);
443
444 let result = replace_placeholders_in_xml(xml, &[("{Name}", "<script>")]);
445 assert_eq!(result, r#"<w:t><script></w:t>"#);
446
447 let result = replace_placeholders_in_xml(xml, &[("{Name}", "a < b & c > d")]);
448 assert_eq!(result, r#"<w:t>a < b & c > d</w:t>"#);
449 }
450
451 #[test]
452 fn escape_xml_split_across_runs() {
453 let xml = r#"<w:t>{Na</w:t><w:t>me}</w:t>"#;
454 let result = replace_placeholders_in_xml(xml, &[("{Name}", "A&B")]);
455 assert_eq!(result, r#"<w:t>A&B</w:t><w:t></w:t>"#);
456 }
457
458 #[test]
459 fn escape_xml_in_headfoottest_template() {
460 let template_path = Path::new("../test-crate/templates/HeadFootTest.docx");
461 if !template_path.exists() {
462 return;
463 }
464 let template_bytes = std::fs::read(template_path).unwrap();
465 let result = __private::build_docx_bytes(
466 &template_bytes,
467 &[
468 ("{header}", "Tom & Jerry"),
469 ("{foo}", "x < y"),
470 ("{ foo }", "x < y"),
471 ("{ foo }", "x < y"),
472 ("{top}", "A > B"),
473 ("{bottom}", "C & D"),
474 ],
475 )
476 .unwrap();
477
478 let cursor = Cursor::new(&result);
479 let mut archive = zip::ZipArchive::new(cursor).unwrap();
480 let mut doc_xml = String::new();
481 archive
482 .by_name("word/document.xml")
483 .unwrap()
484 .read_to_string(&mut doc_xml)
485 .unwrap();
486
487 assert!(!doc_xml.contains("Tom & Jerry"), "raw ampersand should be escaped");
488 assert!(doc_xml.contains("Tom & Jerry"), "escaped value should be present");
489 assert!(!doc_xml.contains("x < y"), "raw less-than should be escaped");
490 }
491
492 #[test]
493 fn build_docx_bytes_replaces_content() {
494 let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
495 if !template_path.exists() {
496 return;
497 }
498 let template_bytes = std::fs::read(template_path).unwrap();
499 let result = __private::build_docx_bytes(
500 &template_bytes,
501 &[("{ firstName }", "Alice"), ("{ productName }", "Docxide")],
502 )
503 .unwrap();
504
505 let cursor = Cursor::new(&result);
506 let mut archive = zip::ZipArchive::new(cursor).unwrap();
507 let mut doc_xml = String::new();
508 archive
509 .by_name("word/document.xml")
510 .unwrap()
511 .read_to_string(&mut doc_xml)
512 .unwrap();
513 assert!(doc_xml.contains("Alice"));
514 assert!(doc_xml.contains("Docxide"));
515 assert!(!doc_xml.contains("firstName"));
516 assert!(!doc_xml.contains("productName"));
517 }
518}