1extern crate proc_macro;
2mod templates;
3
4use docx_rs::{
5 read_docx, DocumentChild, FooterChild, HeaderChild, StructuredDataTagChild, Table,
6 TableCellContent, TableChild, TableRowChild,
7};
8use file_format::FileFormat;
9use proc_macro::TokenStream;
10use quote::quote;
11use regex::Regex;
12use std::{
13 collections::HashMap,
14 fs,
15 path::{Path, PathBuf},
16};
17
18use syn::{parse_str, LitStr};
19use templates::{derive_type_name_from_filename, placeholder_to_field_name};
20
21#[proc_macro]
43pub fn generate_templates(input: TokenStream) -> TokenStream {
44 let embed = cfg!(feature = "embed");
45
46 let lit: LitStr = syn::parse(input).expect("expected a string literal, e.g. generate_templates!(\"path/to/templates\")");
47 let folder_path = lit.value();
48
49 let paths = fs::read_dir(&folder_path).unwrap_or_else(|e| panic!("Failed to read template directory {:?}: {}", folder_path, e));
50 let mut structs = Vec::new();
51 let mut seen_type_names: HashMap<String, PathBuf> = HashMap::new();
52
53 for path in paths {
54 let path = path.expect("Failed to read path").path();
56
57 if !is_valid_docx_file(&path) {
59 print_docxide_message("Invalid template file, skipping.", &path);
60 continue;
61 }
62
63 let type_name = match derive_type_name_from_filename(&path) {
64 Ok(name) if parse_str::<syn::Ident>(&name).is_ok() => name,
65 other => {
66 let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
67 if stem.starts_with(|c: char| c.is_ascii_digit()) {
68 let attempted = other.unwrap_or_default();
69 print_docxide_message(
70 &format!(
71 "Filename starts with a digit, which produces an invalid Rust type name `{}`. Skipping.",
72 if attempted.is_empty() { stem.to_string() } else { attempted }
73 ),
74 &path,
75 );
76 } else {
77 print_docxide_message(
78 "Unable to derive a valid Rust type name from file name. Skipping.",
79 &path,
80 );
81 }
82 continue;
83 }
84 };
85
86 if let Some(existing_path) = seen_type_names.get(&type_name) {
87 panic!(
88 "\n\n[Docxide-template] Type name collision: both {:?} and {:?} produce the struct name `{}`.\n\
89 Rename one of the files to avoid this conflict.\n",
90 existing_path, path, type_name
91 );
92 }
93 seen_type_names.insert(type_name.clone(), path.clone());
94
95 let type_ident = syn::Ident::new(type_name.as_str(), proc_macro::Span::call_site().into());
96
97 let buf = match fs::read(&path) {
98 Ok(buf) => buf,
99 Err(_) => {
100 print_docxide_message("Unable to read file content. Skipping.", &path);
101 continue;
102 }
103 };
104
105 let doc = match read_docx(&buf) {
106 Ok(doc) => doc,
107 Err(_) => {
108 print_docxide_message("Unable to read docx content. Skipping.", &path);
109 continue;
110 }
111 };
112
113 let mut corpus = collect_text_from_document_children(doc.document.children);
114
115 let section = &doc.document.section_property;
116 for (_, header) in section.get_headers() {
117 corpus.extend(collect_text_from_header_children(&header.children));
118 }
119 for (_, footer) in section.get_footers() {
120 corpus.extend(collect_text_from_footer_children(&footer.children));
121 }
122
123 let content = generate_struct_content(corpus);
124
125 let abs_path = path.canonicalize().expect("Failed to canonicalize template path");
126 let abs_path_str = abs_path.to_str().expect("Failed to convert path to string");
127
128 let template_struct = generate_struct(
129 type_ident,
130 abs_path_str,
131 &content.fields,
132 &content.replacement_placeholders,
133 &content.replacement_fields,
134 embed,
135 );
136
137 structs.push(template_struct)
138 }
139
140 let combined = quote! {
141 #(#structs)*
142 };
143
144 combined.into()
145}
146
147fn generate_struct(
148 type_ident: syn::Ident,
149 abs_path: &str,
150 fields: &[syn::Ident],
151 replacement_placeholders: &[syn::LitStr],
152 replacement_fields: &[syn::Ident],
153 embed: bool,
154) -> proc_macro2::TokenStream {
155 let has_fields = !fields.is_empty();
156 let abs_path_lit = syn::LitStr::new(abs_path, proc_macro::Span::call_site().into());
157
158 let save_and_bytes = if embed {
159 quote! {
160 const TEMPLATE_BYTES: &'static [u8] = include_bytes!(#abs_path_lit);
161
162 pub fn save<P: AsRef<std::path::Path>>(&self, path: P) -> Result<(), docxide_template::TemplateError> {
163 use docxide_template::DocxTemplate;
164 docxide_template::save_docx_bytes(
165 Self::TEMPLATE_BYTES,
166 path.as_ref().with_extension("docx").as_path(),
167 &self.replacements(),
168 )
169 }
170
171 pub fn to_bytes(&self) -> Result<Vec<u8>, docxide_template::TemplateError> {
172 use docxide_template::DocxTemplate;
173 docxide_template::build_docx_bytes(Self::TEMPLATE_BYTES, &self.replacements())
174 }
175 }
176 } else {
177 quote! {
178 pub fn save<P: AsRef<std::path::Path>>(&self, path: P) -> Result<(), docxide_template::TemplateError> {
179 docxide_template::save_docx(self, path.as_ref().with_extension("docx"))
180 }
181
182 pub fn to_bytes(&self) -> Result<Vec<u8>, docxide_template::TemplateError> {
183 use docxide_template::DocxTemplate;
184 let template_bytes = std::fs::read(self.template_path())?;
185 docxide_template::build_docx_bytes(&template_bytes, &self.replacements())
186 }
187 }
188 };
189
190 if has_fields {
191 quote! {
192 #[derive(Debug)]
193 pub struct #type_ident {
194 #(pub #fields: String,)*
195 }
196
197 impl #type_ident {
198 pub fn new(#(#fields: impl Into<String>),*) -> Self {
199 Self {
200 #(#fields: #fields.into()),*
201 }
202 }
203
204 #save_and_bytes
205 }
206
207 impl docxide_template::DocxTemplate for #type_ident {
208 fn template_path(&self) -> &std::path::Path {
209 std::path::Path::new(#abs_path_lit)
210 }
211
212 fn replacements(&self) -> Vec<(&str, &str)> {
213 vec![#( (#replacement_placeholders, self.#replacement_fields.as_str()), )*]
214 }
215 }
216 }
217 } else {
218 quote! {
219 #[derive(Debug)]
220 pub struct #type_ident;
221
222 impl #type_ident {
223 #save_and_bytes
224 }
225
226 impl docxide_template::DocxTemplate for #type_ident {
227 fn template_path(&self) -> &std::path::Path {
228 std::path::Path::new(#abs_path_lit)
229 }
230
231 fn replacements(&self) -> Vec<(&str, &str)> {
232 vec![]
233 }
234 }
235 }
236 }
237}
238
239struct StructContent {
240 fields: Vec<proc_macro2::Ident>,
242 replacement_placeholders: Vec<LitStr>,
245 replacement_fields: Vec<proc_macro2::Ident>,
246}
247
248fn generate_struct_content(corpus: Vec<String>) -> StructContent {
249 let re = Regex::new(r"(\{\s*[^}]+\s*\})").unwrap();
250 let mut seen_fields = std::collections::HashSet::new();
251 let mut seen_placeholders = std::collections::HashSet::new();
252 let mut fields = Vec::new();
253 let mut replacement_placeholders = Vec::new();
254 let mut replacement_fields = Vec::new();
255 let span = proc_macro::Span::call_site().into();
256
257 for text in &corpus {
258 for cap in re.captures_iter(text) {
259 let placeholder = cap[1].to_string();
260 let cleaned =
261 placeholder.trim_matches(|c: char| c == '{' || c == '}' || c.is_whitespace());
262 let field_name = placeholder_to_field_name(cleaned);
263
264 if syn::parse_str::<syn::Ident>(&field_name).is_err() {
265 println!(
266 "\x1b[34m[Docxide-template]\x1b[0m Invalid placeholder name in file: {}",
267 placeholder
268 );
269 continue;
270 }
271
272 let ident = syn::Ident::new(&field_name, span);
273 if seen_fields.insert(field_name) {
274 fields.push(ident.clone());
275 }
276 if seen_placeholders.insert(placeholder.clone()) {
277 replacement_placeholders.push(syn::LitStr::new(&placeholder, span));
278 replacement_fields.push(ident);
279 }
280 }
281 }
282
283 StructContent {
284 fields,
285 replacement_placeholders,
286 replacement_fields,
287 }
288}
289
290fn collect_text_from_document_children(children: Vec<DocumentChild>) -> Vec<String> {
291 let mut texts = Vec::new();
292 for child in children {
293 match child {
294 DocumentChild::Paragraph(p) => texts.push(p.raw_text()),
295 DocumentChild::Table(t) => texts.extend(collect_text_from_table(&t)),
296 DocumentChild::StructuredDataTag(sdt) => {
297 texts.extend(collect_text_from_sdt_children(&sdt.children));
298 }
299 _ => {}
300 }
301 }
302 texts
303}
304
305fn collect_text_from_table(table: &Table) -> Vec<String> {
306 let mut texts = Vec::new();
307 for row in &table.rows {
308 let TableChild::TableRow(ref row) = row;
309 for cell in &row.cells {
310 let TableRowChild::TableCell(ref cell) = cell;
311 for content in &cell.children {
312 match content {
313 TableCellContent::Paragraph(p) => texts.push(p.raw_text()),
314 TableCellContent::Table(t) => texts.extend(collect_text_from_table(t)),
315 _ => {}
316 }
317 }
318 }
319 }
320 texts
321}
322
323fn collect_text_from_sdt_children(children: &[StructuredDataTagChild]) -> Vec<String> {
324 let mut texts = Vec::new();
325 for child in children {
326 match child {
327 StructuredDataTagChild::Paragraph(p) => texts.push(p.raw_text()),
328 StructuredDataTagChild::Table(t) => texts.extend(collect_text_from_table(t)),
329 StructuredDataTagChild::StructuredDataTag(sdt) => {
330 texts.extend(collect_text_from_sdt_children(&sdt.children));
331 }
332 _ => {}
333 }
334 }
335 texts
336}
337
338fn collect_text_from_header_children(children: &[HeaderChild]) -> Vec<String> {
339 let mut texts = Vec::new();
340 for child in children {
341 match child {
342 HeaderChild::Paragraph(p) => texts.push(p.raw_text()),
343 HeaderChild::Table(t) => texts.extend(collect_text_from_table(t)),
344 HeaderChild::StructuredDataTag(sdt) => {
345 texts.extend(collect_text_from_sdt_children(&sdt.children));
346 }
347 }
348 }
349 texts
350}
351
352fn collect_text_from_footer_children(children: &[FooterChild]) -> Vec<String> {
353 let mut texts = Vec::new();
354 for child in children {
355 match child {
356 FooterChild::Paragraph(p) => texts.push(p.raw_text()),
357 FooterChild::Table(t) => texts.extend(collect_text_from_table(t)),
358 FooterChild::StructuredDataTag(sdt) => {
359 texts.extend(collect_text_from_sdt_children(&sdt.children));
360 }
361 }
362 }
363 texts
364}
365
366fn print_docxide_message(message: &str, path: &Path) {
367 println!("\x1b[34m[Docxide-template]\x1b[0m {} {:?}", message, path);
368}
369
370fn is_valid_docx_file(path: &Path) -> bool {
371 if !path.is_file() {
372 return false;
373 }
374
375 matches!(FileFormat::from_file(path), Ok(fmt) if fmt.extension() == "docx")
376}