Skip to main content

docxide_template_derive/
lib.rs

1extern crate proc_macro;
2mod codegen;
3mod docx_extract;
4mod naming;
5mod placeholders;
6
7use docx_rs::read_docx;
8use proc_macro::TokenStream;
9use quote::quote;
10use std::{
11    collections::HashMap,
12    fs,
13    path::PathBuf,
14};
15
16use syn::{parse_str, LitStr};
17
18use codegen::generate_struct;
19use docx_extract::{
20    collect_text_from_document_children, collect_text_from_footer_children,
21    collect_text_from_header_children, extract_text_from_xml_tags, is_valid_docx_file,
22    print_docxide_message,
23};
24use naming::derive_type_name_from_filename;
25use placeholders::generate_struct_content;
26
27/// Scans a directory for `.docx` template files and generates a typed struct for each one.
28///
29/// Template paths are resolved as absolute paths at compile time, so binaries work
30/// regardless of working directory.
31///
32/// With the `embed` feature enabled, template bytes are baked into the binary via
33/// `include_bytes!`, making it fully self-contained with no runtime file dependencies.
34///
35/// # Usage
36///
37/// ```rust,ignore
38/// use docxide_template::generate_templates;
39///
40/// generate_templates!("path/to/templates");
41/// ```
42///
43/// For each `.docx` file, this generates a struct with:
44/// - A field for each `{placeholder}` found in the document text (converted to snake_case)
45/// - `new()` constructor taking all field values as `impl Into<String>`
46/// - `save(path)` to write a filled-in `.docx` to disk
47/// - `to_bytes()` to get the filled-in `.docx` as `Vec<u8>`
48#[proc_macro]
49pub fn generate_templates(input: TokenStream) -> TokenStream {
50    let embed = cfg!(feature = "embed");
51
52    let lit: LitStr = syn::parse(input).expect("expected a string literal, e.g. generate_templates!(\"path/to/templates\")");
53    let folder_path = lit.value();
54
55    let paths = fs::read_dir(&folder_path).unwrap_or_else(|e| panic!("Failed to read template directory {:?}: {}", folder_path, e));
56    let mut structs = Vec::new();
57    let mut seen_type_names: HashMap<String, PathBuf> = HashMap::new();
58
59    for path in paths {
60        let path = path.expect("Failed to read path").path();
61
62        if !is_valid_docx_file(&path) {
63            print_docxide_message("Invalid template file, skipping.", &path);
64            continue;
65        }
66
67        let type_name = match derive_type_name_from_filename(&path) {
68            Ok(name) if parse_str::<syn::Ident>(&name).is_ok() => name,
69            other => {
70                let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
71                if stem.starts_with(|c: char| c.is_ascii_digit()) {
72                    let attempted = other.unwrap_or_default();
73                    print_docxide_message(
74                        &format!(
75                            "Filename starts with a digit, which produces an invalid Rust type name `{}`. Skipping.",
76                            if attempted.is_empty() { stem.to_string() } else { attempted }
77                        ),
78                        &path,
79                    );
80                } else {
81                    print_docxide_message(
82                        "Unable to derive a valid Rust type name from file name. Skipping.",
83                        &path,
84                    );
85                }
86                continue;
87            }
88        };
89
90        if let Some(existing_path) = seen_type_names.get(&type_name) {
91            panic!(
92                "\n\n[Docxide-template] Type name collision: both {:?} and {:?} produce the struct name `{}`.\n\
93                Rename one of the files to avoid this conflict.\n",
94                existing_path, path, type_name
95            );
96        }
97        seen_type_names.insert(type_name.clone(), path.clone());
98
99        let type_ident = syn::Ident::new(type_name.as_str(), proc_macro::Span::call_site().into());
100
101        let buf = match fs::read(&path) {
102            Ok(buf) => buf,
103            Err(_) => {
104                print_docxide_message("Unable to read file content. Skipping.", &path);
105                continue;
106            }
107        };
108
109        let doc = match read_docx(&buf) {
110            Ok(doc) => doc,
111            Err(_) => {
112                print_docxide_message("Unable to read docx content. Skipping.", &path);
113                continue;
114            }
115        };
116
117        let mut corpus = collect_text_from_document_children(doc.document.children);
118
119        let section = &doc.document.section_property;
120        for (_, header) in section.get_headers() {
121            corpus.extend(collect_text_from_header_children(&header.children));
122        }
123        for (_, footer) in section.get_footers() {
124            corpus.extend(collect_text_from_footer_children(&footer.children));
125        }
126
127        corpus.extend(extract_text_from_xml_tags(&buf, &["<a:t", "<m:t"]));
128
129        let content = generate_struct_content(corpus);
130
131        let abs_path = path.canonicalize().expect("Failed to canonicalize template path");
132        let abs_path_str = abs_path.to_str().expect("Failed to convert path to string");
133
134        let template_struct = generate_struct(
135            type_ident,
136            abs_path_str,
137            &content.fields,
138            &content.replacement_placeholders,
139            &content.replacement_fields,
140            embed,
141        );
142
143        structs.push(template_struct)
144    }
145
146    let combined = quote! {
147        #(#structs)*
148    };
149
150    combined.into()
151}