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 replace_placeholders_in_xml(xml: &str, replacements: &[(&str, &str)]) -> String {
129 let mut text_spans: Vec<(usize, usize, String)> = Vec::new();
130 let mut search_start = 0;
131 while let Some(tag_start) = xml[search_start..].find("<w:t") {
132 let tag_start = search_start + tag_start;
133 let content_start = match xml[tag_start..].find('>') {
134 Some(pos) => tag_start + pos + 1,
135 None => break,
136 };
137 let content_end = match xml[content_start..].find("</w:t>") {
138 Some(pos) => content_start + pos,
139 None => break,
140 };
141 let text = xml[content_start..content_end].to_string();
142 text_spans.push((content_start, content_end, text));
143 search_start = content_end + 6;
144 }
145
146 if text_spans.is_empty() {
147 return xml.to_string();
148 }
149
150 let concatenated: String = text_spans.iter().map(|(_, _, t)| t.as_str()).collect();
151
152 let offset_map: Vec<(usize, usize)> = text_spans
153 .iter()
154 .enumerate()
155 .flat_map(|(span_idx, (_, _, text))| {
156 (0..text.len()).map(move |char_offset| (span_idx, char_offset))
157 })
158 .collect();
159
160 let mut span_replacements: Vec<Vec<(usize, usize, String)>> = vec![Vec::new(); text_spans.len()];
161 for &(placeholder, value) in replacements {
162 let mut start = 0;
163 while let Some(found) = concatenated[start..].find(placeholder) {
164 let match_start = start + found;
165 let match_end = match_start + placeholder.len();
166 if match_start >= offset_map.len() || match_end > offset_map.len() {
167 break;
168 }
169
170 let (start_span, start_off) = offset_map[match_start];
171 let (end_span, _) = offset_map[match_end - 1];
172 let end_off_exclusive = offset_map[match_end - 1].1 + 1;
173
174 if start_span == end_span {
175 span_replacements[start_span].push((start_off, end_off_exclusive, value.to_string()));
176 } else {
177 let first_span_text = &text_spans[start_span].2;
178 span_replacements[start_span].push((start_off, first_span_text.len(), value.to_string()));
179 for mid in (start_span + 1)..end_span {
180 let mid_len = text_spans[mid].2.len();
181 span_replacements[mid].push((0, mid_len, String::new()));
182 }
183 span_replacements[end_span].push((0, end_off_exclusive, String::new()));
184 }
185 start = match_end;
186 }
187 }
188
189 let mut result = xml.to_string();
190 for (span_idx, (content_start, content_end, _)) in text_spans.iter().enumerate().rev() {
191 let mut span_text = result[*content_start..*content_end].to_string();
192 let mut reps = span_replacements[span_idx].clone();
193 reps.sort_by(|a, b| b.0.cmp(&a.0));
194 for (from, to, replacement) in reps {
195 let safe_to = to.min(span_text.len());
196 span_text = format!("{}{}{}", &span_text[..from], replacement, &span_text[safe_to..]);
197 }
198 result = format!("{}{}{}", &result[..*content_start], span_text, &result[*content_end..]);
199 }
200
201 result
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207
208 #[test]
209 fn replace_single_run_placeholder() {
210 let xml = r#"<w:t>{Name}</w:t>"#;
211 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
212 assert_eq!(result, r#"<w:t>Alice</w:t>"#);
213 }
214
215 #[test]
216 fn replace_placeholder_split_across_runs() {
217 let xml = r#"<w:t>{Na</w:t><w:t>me}</w:t>"#;
218 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
219 assert_eq!(result, r#"<w:t>Alice</w:t><w:t></w:t>"#);
220 }
221
222 #[test]
223 fn replace_placeholder_with_inner_whitespace() {
224 let xml = r#"<w:t>Hello { Name }!</w:t>"#;
225 let result = replace_placeholders_in_xml(xml, &[("{ Name }", "Alice")]);
226 assert_eq!(result, r#"<w:t>Hello Alice!</w:t>"#);
227 }
228
229 #[test]
230 fn replace_both_whitespace_variants() {
231 let xml = r#"<w:t>{Name} and { Name }</w:t>"#;
232 let result = replace_placeholders_in_xml(
233 xml,
234 &[("{Name}", "Alice"), ("{ Name }", "Alice")],
235 );
236 assert_eq!(result, r#"<w:t>Alice and Alice</w:t>"#);
237 }
238
239 #[test]
240 fn replace_multiple_placeholders() {
241 let xml = r#"<w:t>Hello {First} {Last}!</w:t>"#;
242 let result = replace_placeholders_in_xml(
243 xml,
244 &[("{First}", "Alice"), ("{Last}", "Smith")],
245 );
246 assert_eq!(result, r#"<w:t>Hello Alice Smith!</w:t>"#);
247 }
248
249 #[test]
250 fn no_placeholders_returns_unchanged() {
251 let xml = r#"<w:t>No placeholders here</w:t>"#;
252 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
253 assert_eq!(result, xml);
254 }
255
256 #[test]
257 fn no_wt_tags_returns_unchanged() {
258 let xml = r#"<w:p>plain paragraph</w:p>"#;
259 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
260 assert_eq!(result, xml);
261 }
262
263 #[test]
264 fn empty_replacements_returns_unchanged() {
265 let xml = r#"<w:t>{Name}</w:t>"#;
266 let result = replace_placeholders_in_xml(xml, &[]);
267 assert_eq!(result, xml);
268 }
269
270 #[test]
271 fn preserves_wt_attributes() {
272 let xml = r#"<w:t xml:space="preserve">{Name}</w:t>"#;
273 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
274 assert_eq!(result, r#"<w:t xml:space="preserve">Alice</w:t>"#);
275 }
276
277 #[test]
278 fn replace_whitespace_placeholder_split_across_runs() {
279 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>"#;
281 let result = replace_placeholders_in_xml(xml, &[("{ foo }", "bar")]);
282 assert!(
283 !result.contains("foo"),
284 "placeholder not replaced: {}",
285 result
286 );
287 assert!(result.contains("bar"), "value not present: {}", result);
288 }
289
290 #[test]
291 fn replace_whitespace_placeholder_with_prooferr_between_runs() {
292 let xml = concat!(
294 r#"<w:r><w:t>{foo}</w:t></w:r>"#,
295 r#"<w:r><w:t>{</w:t></w:r>"#,
296 r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
297 r#"<w:r><w:t>foo</w:t></w:r>"#,
298 r#"<w:proofErr w:type="gramEnd"/>"#,
299 r#"<w:r><w:t xml:space="preserve"> </w:t></w:r>"#,
300 r#"<w:r><w:t>}</w:t></w:r>"#,
301 );
302 let result = replace_placeholders_in_xml(
303 xml,
304 &[("{foo}", "bar"), ("{ foo }", "bar")],
305 );
306 assert!(
308 !result.contains("foo"),
309 "placeholder not replaced: {}",
310 result
311 );
312 }
313
314 #[test]
315 fn replace_all_variants_in_full_document() {
316 let xml = concat!(
318 r#"<w:t>{header}</w:t>"#,
319 r#"<w:t>{header}</w:t>"#,
320 r#"<w:t>{foo}</w:t>"#,
321 r#"<w:t>{</w:t>"#,
323 r#"<w:t xml:space="preserve"> </w:t>"#,
324 r#"<w:t>foo</w:t>"#,
325 r#"<w:t xml:space="preserve"> </w:t>"#,
326 r#"<w:t>}</w:t>"#,
327 r#"<w:t>{</w:t>"#,
329 r#"<w:t xml:space="preserve"> </w:t>"#,
330 r#"<w:t xml:space="preserve"> </w:t>"#,
331 r#"<w:t>foo</w:t>"#,
332 r#"<w:t xml:space="preserve"> </w:t>"#,
333 r#"<w:t>}</w:t>"#,
334 );
335 let result = replace_placeholders_in_xml(
336 xml,
337 &[
338 ("{header}", "TITLE"),
339 ("{foo}", "BAR"),
340 ("{ foo }", "BAR"),
341 ("{ foo }", "BAR"),
342 ],
343 );
344 assert!(
345 !result.contains("header"),
346 "{{header}} not replaced: {}",
347 result,
348 );
349 assert!(
350 !result.contains("foo"),
351 "foo variant not replaced: {}",
352 result,
353 );
354 }
355
356 #[test]
357 fn duplicate_replacement_does_not_break_later_spans() {
358 let xml = concat!(
360 r#"<w:t>{header}</w:t>"#,
361 r#"<w:t>{header}</w:t>"#,
362 r#"<w:t>{foo}</w:t>"#,
363 r#"<w:t>{</w:t>"#,
364 r#"<w:t xml:space="preserve"> </w:t>"#,
365 r#"<w:t>foo</w:t>"#,
366 r#"<w:t xml:space="preserve"> </w:t>"#,
367 r#"<w:t>}</w:t>"#,
368 );
369 let result = replace_placeholders_in_xml(
370 xml,
371 &[
372 ("{header}", "TITLE"),
374 ("{header}", "TITLE"),
375 ("{foo}", "BAR"),
376 ("{ foo }", "BAR"),
377 ],
378 );
379 assert!(
381 !result.contains("foo"),
382 "foo not replaced when duplicate header present: {}",
383 result,
384 );
385 }
386
387 #[test]
388 fn replace_headfoottest_template() {
389 let template_path = Path::new("../test-crate/templates/HeadFootTest.docx");
390 if !template_path.exists() {
391 return;
392 }
393 let template_bytes = std::fs::read(template_path).unwrap();
394 let result = build_docx_bytes(
395 &template_bytes,
396 &[
397 ("{header}", "TITLE"),
398 ("{foo}", "BAR"),
399 ("{ foo }", "BAR"),
400 ("{ foo }", "BAR"),
401 ("{top}", "TOP"),
402 ("{bottom}", "BOT"),
403 ],
404 )
405 .unwrap();
406
407 let cursor = Cursor::new(&result);
408 let mut archive = zip::ZipArchive::new(cursor).unwrap();
409 let mut doc_xml = String::new();
410 archive
411 .by_name("word/document.xml")
412 .unwrap()
413 .read_to_string(&mut doc_xml)
414 .unwrap();
415
416 assert!(!doc_xml.contains("{header}"), "header placeholder not replaced");
417 assert!(!doc_xml.contains("{foo}"), "foo placeholder not replaced");
418 assert!(!doc_xml.contains("{ foo }"), "spaced foo placeholder not replaced");
419 }
420
421 #[test]
422 fn build_docx_bytes_produces_valid_zip() {
423 let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
424 if !template_path.exists() {
425 return;
426 }
427 let template_bytes = std::fs::read(template_path).unwrap();
428 let result = build_docx_bytes(
429 &template_bytes,
430 &[("{ firstName }", "Test"), ("{ productName }", "Lib")],
431 )
432 .unwrap();
433
434 assert!(!result.is_empty());
435 let cursor = Cursor::new(&result);
436 let archive = zip::ZipArchive::new(cursor).expect("output should be a valid zip");
437 assert!(archive.len() > 0);
438 }
439
440 #[test]
441 fn build_docx_bytes_replaces_content() {
442 let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
443 if !template_path.exists() {
444 return;
445 }
446 let template_bytes = std::fs::read(template_path).unwrap();
447 let result = build_docx_bytes(
448 &template_bytes,
449 &[("{ firstName }", "Alice"), ("{ productName }", "Docxide")],
450 )
451 .unwrap();
452
453 let cursor = Cursor::new(&result);
454 let mut archive = zip::ZipArchive::new(cursor).unwrap();
455 let mut doc_xml = String::new();
456 archive
457 .by_name("word/document.xml")
458 .unwrap()
459 .read_to_string(&mut doc_xml)
460 .unwrap();
461 assert!(doc_xml.contains("Alice"));
462 assert!(doc_xml.contains("Docxide"));
463 assert!(!doc_xml.contains("firstName"));
464 assert!(!doc_xml.contains("productName"));
465 }
466}