Skip to main content

docxide_template_derive/
lib.rs

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/// Scans a directory for `.docx` template files and generates a typed struct for each one.
22///
23/// Template paths are resolved as absolute paths at compile time, so binaries work
24/// regardless of working directory.
25///
26/// With the `embed` feature enabled, template bytes are baked into the binary via
27/// `include_bytes!`, making it fully self-contained with no runtime file dependencies.
28///
29/// # Usage
30///
31/// ```rust,ignore
32/// use docxide_template::generate_templates;
33///
34/// generate_templates!("path/to/templates");
35/// ```
36///
37/// For each `.docx` file, this generates a struct with:
38/// - A field for each `{placeholder}` found in the document text (converted to snake_case)
39/// - `new()` constructor taking all field values as `impl Into<String>`
40/// - `save(path)` to write a filled-in `.docx` to disk
41/// - `to_bytes()` to get the filled-in `.docx` as `Vec<u8>`
42#[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        //todo: maybe recursive traversal?
55        let path = path.expect("Failed to read path").path();
56
57        // TOOD: Move all validation into function
58        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    /// Unique fields for the struct definition and constructor.
241    fields: Vec<proc_macro2::Ident>,
242    /// All placeholder/field pairs for replacements (may have multiple
243    /// placeholder strings mapping to the same field, e.g. `{name}` and `{ name }`).
244    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}