1pub use docxide_template_derive::generate_templates;
8
9use std::io::{Cursor, Read, Write};
10use std::path::Path;
11
12pub trait DocxTemplate {
17 fn template_path(&self) -> &Path;
19 fn replacements(&self) -> Vec<(&str, &str)>;
21}
22
23pub fn save_docx<T: DocxTemplate, P: AsRef<Path>>(
25 template: &T,
26 output_path: P,
27) -> Result<(), Box<dyn std::error::Error>> {
28 save_docx_from_file(template.template_path(), output_path.as_ref(), &template.replacements())
29}
30
31pub fn save_docx_from_file(
33 template_path: &Path,
34 output_path: &Path,
35 replacements: &[(&str, &str)],
36) -> Result<(), Box<dyn std::error::Error>> {
37 let template_bytes = std::fs::read(template_path)?;
38 save_docx_bytes(&template_bytes, output_path, replacements)
39}
40
41pub fn build_docx_bytes(
47 template_bytes: &[u8],
48 replacements: &[(&str, &str)],
49) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
50 let cursor = Cursor::new(template_bytes);
51 let mut archive = zip::read::ZipArchive::new(cursor)?;
52
53 let mut output_buf = Cursor::new(Vec::new());
54 let mut zip_writer = zip::write::ZipWriter::new(&mut output_buf);
55 let options = zip::write::SimpleFileOptions::default();
56
57 for i in 0..archive.len() {
58 let mut file = archive.by_index(i)?;
59 let file_name: String = file.name().to_string();
60
61 let mut contents = Vec::new();
62 file.read_to_end(&mut contents)?;
63
64 if file_name.ends_with(".xml") || file_name.ends_with(".rels") {
65 let xml = String::from_utf8(contents)?;
66 let replaced = replace_placeholders_in_xml(&xml, replacements);
67 contents = replaced.into_bytes();
68 }
69
70 zip_writer.start_file(&file_name, options)?;
71 zip_writer.write_all(&contents)?;
72 }
73
74 zip_writer.finish()?;
75 Ok(output_buf.into_inner())
76}
77
78pub fn save_docx_bytes(
80 template_bytes: &[u8],
81 output_path: &Path,
82 replacements: &[(&str, &str)],
83) -> Result<(), Box<dyn std::error::Error>> {
84 let bytes = build_docx_bytes(template_bytes, replacements)?;
85 if let Some(parent) = output_path.parent() {
86 std::fs::create_dir_all(parent)?;
87 }
88 std::fs::write(output_path, bytes)?;
89 Ok(())
90}
91
92pub fn replace_placeholders_in_xml(xml: &str, replacements: &[(&str, &str)]) -> String {
97 let mut text_spans: Vec<(usize, usize, String)> = Vec::new();
98 let mut search_start = 0;
99 while let Some(tag_start) = xml[search_start..].find("<w:t") {
100 let tag_start = search_start + tag_start;
101 let content_start = match xml[tag_start..].find('>') {
102 Some(pos) => tag_start + pos + 1,
103 None => break,
104 };
105 let content_end = match xml[content_start..].find("</w:t>") {
106 Some(pos) => content_start + pos,
107 None => break,
108 };
109 let text = xml[content_start..content_end].to_string();
110 text_spans.push((content_start, content_end, text));
111 search_start = content_end + 6;
112 }
113
114 if text_spans.is_empty() {
115 return xml.to_string();
116 }
117
118 let concatenated: String = text_spans.iter().map(|(_, _, t)| t.as_str()).collect();
119
120 let mut offset_map: Vec<(usize, usize)> = Vec::new();
121 for (span_idx, (_, _, text)) in text_spans.iter().enumerate() {
122 for char_offset in 0..text.len() {
123 offset_map.push((span_idx, char_offset));
124 }
125 }
126
127 let mut span_replacements: Vec<Vec<(usize, usize, String)>> = vec![Vec::new(); text_spans.len()];
128 for &(placeholder, value) in replacements {
129 let mut start = 0;
130 while let Some(found) = concatenated[start..].find(placeholder) {
131 let match_start = start + found;
132 let match_end = match_start + placeholder.len();
133 if match_start >= offset_map.len() || match_end > offset_map.len() {
134 break;
135 }
136
137 let (start_span, start_off) = offset_map[match_start];
138 let (end_span, _) = offset_map[match_end - 1];
139 let end_off_exclusive = offset_map[match_end - 1].1 + 1;
140
141 if start_span == end_span {
142 span_replacements[start_span].push((start_off, end_off_exclusive, value.to_string()));
143 } else {
144 let first_span_text = &text_spans[start_span].2;
145 span_replacements[start_span].push((start_off, first_span_text.len(), value.to_string()));
146 for mid in (start_span + 1)..end_span {
147 let mid_len = text_spans[mid].2.len();
148 span_replacements[mid].push((0, mid_len, String::new()));
149 }
150 span_replacements[end_span].push((0, end_off_exclusive, String::new()));
151 }
152 start = match_end;
153 }
154 }
155
156 let mut result = xml.to_string();
157 for (span_idx, (content_start, content_end, _)) in text_spans.iter().enumerate().rev() {
158 let mut span_text = result[*content_start..*content_end].to_string();
159 let mut reps = span_replacements[span_idx].clone();
160 reps.sort_by(|a, b| b.0.cmp(&a.0));
161 for (from, to, replacement) in reps {
162 let safe_to = to.min(span_text.len());
163 span_text = format!("{}{}{}", &span_text[..from], replacement, &span_text[safe_to..]);
164 }
165 result = format!("{}{}{}", &result[..*content_start], span_text, &result[*content_end..]);
166 }
167
168 result
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174
175 #[test]
176 fn replace_single_run_placeholder() {
177 let xml = r#"<w:t>{Name}</w:t>"#;
178 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
179 assert_eq!(result, r#"<w:t>Alice</w:t>"#);
180 }
181
182 #[test]
183 fn replace_placeholder_split_across_runs() {
184 let xml = r#"<w:t>{Na</w:t><w:t>me}</w:t>"#;
185 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
186 assert_eq!(result, r#"<w:t>Alice</w:t><w:t></w:t>"#);
187 }
188
189 #[test]
190 fn replace_placeholder_with_inner_whitespace() {
191 let xml = r#"<w:t>Hello { Name }!</w:t>"#;
192 let result = replace_placeholders_in_xml(xml, &[("{ Name }", "Alice")]);
193 assert_eq!(result, r#"<w:t>Hello Alice!</w:t>"#);
194 }
195
196 #[test]
197 fn replace_both_whitespace_variants() {
198 let xml = r#"<w:t>{Name} and { Name }</w:t>"#;
199 let result = replace_placeholders_in_xml(
200 xml,
201 &[("{Name}", "Alice"), ("{ Name }", "Alice")],
202 );
203 assert_eq!(result, r#"<w:t>Alice and Alice</w:t>"#);
204 }
205
206 #[test]
207 fn replace_multiple_placeholders() {
208 let xml = r#"<w:t>Hello {First} {Last}!</w:t>"#;
209 let result = replace_placeholders_in_xml(
210 xml,
211 &[("{First}", "Alice"), ("{Last}", "Smith")],
212 );
213 assert_eq!(result, r#"<w:t>Hello Alice Smith!</w:t>"#);
214 }
215
216 #[test]
217 fn no_placeholders_returns_unchanged() {
218 let xml = r#"<w:t>No placeholders here</w:t>"#;
219 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
220 assert_eq!(result, xml);
221 }
222
223 #[test]
224 fn no_wt_tags_returns_unchanged() {
225 let xml = r#"<w:p>plain paragraph</w:p>"#;
226 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
227 assert_eq!(result, xml);
228 }
229
230 #[test]
231 fn empty_replacements_returns_unchanged() {
232 let xml = r#"<w:t>{Name}</w:t>"#;
233 let result = replace_placeholders_in_xml(xml, &[]);
234 assert_eq!(result, xml);
235 }
236
237 #[test]
238 fn preserves_wt_attributes() {
239 let xml = r#"<w:t xml:space="preserve">{Name}</w:t>"#;
240 let result = replace_placeholders_in_xml(xml, &[("{Name}", "Alice")]);
241 assert_eq!(result, r#"<w:t xml:space="preserve">Alice</w:t>"#);
242 }
243
244 #[test]
245 fn build_docx_bytes_produces_valid_zip() {
246 let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
247 if !template_path.exists() {
248 return;
249 }
250 let template_bytes = std::fs::read(template_path).unwrap();
251 let result = build_docx_bytes(
252 &template_bytes,
253 &[("{ firstName }", "Test"), ("{ productName }", "Lib")],
254 )
255 .unwrap();
256
257 assert!(!result.is_empty());
258 let cursor = Cursor::new(&result);
259 let archive = zip::ZipArchive::new(cursor).expect("output should be a valid zip");
260 assert!(archive.len() > 0);
261 }
262
263 #[test]
264 fn build_docx_bytes_replaces_content() {
265 let template_path = Path::new("../test-crate/templates/HelloWorld.docx");
266 if !template_path.exists() {
267 return;
268 }
269 let template_bytes = std::fs::read(template_path).unwrap();
270 let result = build_docx_bytes(
271 &template_bytes,
272 &[("{ firstName }", "Alice"), ("{ productName }", "Docxide")],
273 )
274 .unwrap();
275
276 let cursor = Cursor::new(&result);
277 let mut archive = zip::ZipArchive::new(cursor).unwrap();
278 let mut doc_xml = String::new();
279 archive
280 .by_name("word/document.xml")
281 .unwrap()
282 .read_to_string(&mut doc_xml)
283 .unwrap();
284 assert!(doc_xml.contains("Alice"));
285 assert!(doc_xml.contains("Docxide"));
286 assert!(!doc_xml.contains("firstName"));
287 assert!(!doc_xml.contains("productName"));
288 }
289}