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