Skip to main content

quillmark_acroform/
lib.rs

1//! AcroForm backend for Quillmark that fills PDF form fields with templated values.
2//!
3//! This backend reads PDF forms, renders field values using MiniJinja templates,
4//! and returns filled PDFs. Fields can be templated via their current values or
5//! via tooltip metadata in the format: `description__{{template}}`.
6
7use acroform::{AcroFormDocument, FieldValue};
8use quillmark_core::{
9    Artifact, Backend, Diagnostic, OutputFormat, Quill, RenderError, RenderOptions, RenderResult,
10    Severity,
11};
12use std::collections::HashMap;
13
14/// AcroForm backend implementation for Quillmark.
15#[derive(Default)]
16pub struct AcroformBackend;
17
18impl Backend for AcroformBackend {
19    fn id(&self) -> &'static str {
20        "acroform"
21    }
22
23    fn supported_formats(&self) -> &'static [OutputFormat] {
24        &[OutputFormat::Pdf]
25    }
26
27    fn plate_extension_types(&self) -> &'static [&'static str] {
28        // Acroform uses form.pdf instead of plate files
29        &[]
30    }
31
32    fn compile(
33        &self,
34        _plate_content: &str,
35        quill: &Quill,
36        opts: &RenderOptions,
37        json_data: &serde_json::Value,
38    ) -> Result<RenderResult, RenderError> {
39        let format = opts.output_format.unwrap_or(OutputFormat::Pdf);
40
41        if !self.supported_formats().contains(&format) {
42            return Err(RenderError::FormatNotSupported {
43                diag: Box::new(
44                    Diagnostic::new(
45                        Severity::Error,
46                        format!("{:?} not supported by {} backend", format, self.id()),
47                    )
48                    .with_code("backend::format_not_supported".to_string())
49                    .with_hint(format!("Supported formats: {:?}", self.supported_formats())),
50                ),
51            });
52        }
53        let mut context: serde_json::Value = json_data.clone();
54
55        // Replace all null values with empty strings
56        fn replace_nulls_with_empty(value: &mut serde_json::Value) {
57            match value {
58                serde_json::Value::Null => *value = serde_json::Value::String(String::new()),
59                serde_json::Value::Object(map) => {
60                    for v in map.values_mut() {
61                        replace_nulls_with_empty(v);
62                    }
63                }
64                serde_json::Value::Array(arr) => {
65                    for v in arr.iter_mut() {
66                        replace_nulls_with_empty(v);
67                    }
68                }
69                _ => {}
70            }
71        }
72
73        replace_nulls_with_empty(&mut context);
74
75        let form_pdf_bytes =
76            quill
77                .files
78                .get_file("form.pdf")
79                .ok_or_else(|| RenderError::EngineCreation {
80                    diag: Box::new(
81                        Diagnostic::new(
82                            Severity::Error,
83                            format!("form.pdf not found in quill '{}'", quill.name),
84                        )
85                        .with_code("acroform::missing_form".to_string())
86                        .with_hint("Ensure form.pdf exists in the quill directory".to_string()),
87                    ),
88                })?;
89
90        let mut doc = AcroFormDocument::from_bytes(form_pdf_bytes.to_vec()).map_err(|e| {
91            RenderError::EngineCreation {
92                diag: Box::new(
93                    Diagnostic::new(Severity::Error, format!("Failed to load PDF form: {}", e))
94                        .with_code("acroform::load_failed".to_string())
95                        .with_hint(
96                            "Check that form.pdf is a valid PDF with AcroForm fields".to_string(),
97                        ),
98                ),
99            }
100        })?;
101
102        let mut env = minijinja::Environment::new();
103        env.set_undefined_behavior(minijinja::UndefinedBehavior::Chainable);
104
105        let fields = doc.fields().map_err(|e| RenderError::EngineCreation {
106            diag: Box::new(
107                Diagnostic::new(
108                    Severity::Error,
109                    format!("Failed to get PDF form fields: {}", e),
110                )
111                .with_code("acroform::fields_failed".to_string()),
112            ),
113        })?;
114
115        let mut values_to_fill = HashMap::new();
116
117        for field in fields {
118            // Extract template from tooltip (format: "description__{{template}}")
119            let template_to_render = field.tooltip.as_ref().and_then(|tooltip| {
120                tooltip.find("__").and_then(|pos| {
121                    tooltip.get(pos + 2..).and_then(|template_part| {
122                        if template_part.trim().is_empty() {
123                            None
124                        } else {
125                            Some(template_part.to_string())
126                        }
127                    })
128                })
129            });
130
131            let using_tooltip_template = template_to_render.is_some();
132
133            // Determine what to render: tooltip template or field value
134            let render_processed = template_to_render.or_else(|| {
135                field
136                    .current_value
137                    .as_ref()
138                    .map(|field_value| match field_value {
139                        FieldValue::Text(s) => s.clone(),
140                        FieldValue::Boolean(b) => if *b { "true" } else { "false" }.to_string(),
141                        FieldValue::Choice(s) => s.clone(),
142                        FieldValue::Integer(i) => i.to_string(),
143                    })
144            });
145
146            if let Some(source) = &render_processed {
147                let rendered_value =
148                    env.render_str(source, &context)
149                        .map_err(|e| RenderError::TemplateFailed {
150                            diag: Box::new(
151                                Diagnostic::new(
152                                    Severity::Error,
153                                    format!("Failed to render template for field '{}'", field.name),
154                                )
155                                .with_code("acroform::template".to_string())
156                                .with_source(Box::new(e))
157                                .with_hint(format!("Template: {}", source)),
158                            ),
159                        })?;
160
161                // Normalize newlines to \n to ensure consistency across platforms (e.g. WASM vs Native)
162                let rendered_value = rendered_value.replace("\r\n", "\n").replace('\r', "\n");
163
164                let should_update = using_tooltip_template || &rendered_value != source;
165
166                if should_update {
167                    let new_value = match &field.current_value {
168                        Some(FieldValue::Text(_)) => FieldValue::Text(rendered_value),
169                        Some(FieldValue::Boolean(_)) => {
170                            let bool_val = rendered_value.trim().parse::<i32>().ok().map_or_else(
171                                || rendered_value.trim().to_lowercase() == "true",
172                                |num| num != 0,
173                            );
174                            FieldValue::Boolean(bool_val)
175                        }
176                        Some(FieldValue::Choice(_)) => {
177                            let choice_val = match rendered_value.trim().to_lowercase().as_str() {
178                                "true" => "1".to_string(),
179                                "false" => "0".to_string(),
180                                _ => rendered_value,
181                            };
182                            FieldValue::Choice(choice_val)
183                        }
184                        Some(FieldValue::Integer(_)) => {
185                            let int_val = match rendered_value.trim().to_lowercase().as_str() {
186                                "true" => 1,
187                                "false" => 0,
188                                _ => rendered_value.trim().parse::<i32>().unwrap_or(0),
189                            };
190                            FieldValue::Integer(int_val)
191                        }
192                        None => FieldValue::Text(rendered_value),
193                    };
194                    values_to_fill.insert(field.name.clone(), new_value);
195                }
196            }
197        }
198
199        let output_bytes =
200            doc.fill(values_to_fill)
201                .map_err(|e| RenderError::CompilationFailed {
202                    diags: vec![Diagnostic::new(
203                        Severity::Error,
204                        format!("Failed to fill PDF: {}", e),
205                    )
206                    .with_code("acroform::fill_failed".to_string())],
207                })?;
208
209        let artifacts = vec![Artifact {
210            bytes: output_bytes,
211            output_format: OutputFormat::Pdf,
212        }];
213
214        Ok(RenderResult::new(artifacts, OutputFormat::Pdf))
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn test_backend_info() {
224        let backend = AcroformBackend;
225        assert_eq!(backend.id(), "acroform");
226        let empty_string_arr: [&str; 0] = [];
227        assert_eq!(backend.plate_extension_types(), &empty_string_arr);
228        assert!(backend.supported_formats().contains(&OutputFormat::Pdf));
229    }
230
231    #[test]
232    fn test_undefined_behavior_with_minijinja() {
233        // Test that Chainable undefined behavior returns empty strings
234        let mut env = minijinja::Environment::new();
235        env.set_undefined_behavior(minijinja::UndefinedBehavior::Chainable);
236
237        let context = serde_json::json!({
238            "items": [
239                {"name": "first"},
240                {"name": "second"}
241            ],
242            "existing_key": "value"
243        });
244
245        // Test missing dictionary key
246        let result = env.render_str("{{missing_key}}", &context);
247        assert_eq!(
248            result.unwrap(),
249            "",
250            "Missing key should render as empty string"
251        );
252
253        // Test out-of-bounds array access
254        let result = env.render_str("{{items[10].name}}", &context);
255        assert_eq!(
256            result.unwrap(),
257            "",
258            "Out of bounds array access should render as empty string"
259        );
260
261        // Test nested missing property on undefined
262        let result = env.render_str("{{items[10].name.nested}}", &context);
263        assert_eq!(
264            result.unwrap(),
265            "",
266            "Chained access on undefined should render as empty string"
267        );
268
269        // Test valid access still works
270        let result = env.render_str("{{items[0].name}}", &context);
271        assert_eq!(
272            result.unwrap(),
273            "first",
274            "Valid access should work normally"
275        );
276    }
277
278    #[test]
279    fn test_boolean_parsing() {
280        // Test that boolean values are parsed correctly
281        let mut env = minijinja::Environment::new();
282        env.set_undefined_behavior(minijinja::UndefinedBehavior::Chainable);
283
284        let context = serde_json::json!({
285            "enabled": true,
286            "disabled": false
287        });
288
289        // Test true
290        let result = env.render_str("{{enabled}}", &context).unwrap();
291        assert_eq!(result.trim().to_lowercase(), "true");
292
293        // Test false
294        let result = env.render_str("{{disabled}}", &context).unwrap();
295        assert_eq!(result.trim().to_lowercase(), "false");
296    }
297
298    #[test]
299    fn test_integer_parsing() {
300        // Test that integer values are parsed correctly
301        let mut env = minijinja::Environment::new();
302        env.set_undefined_behavior(minijinja::UndefinedBehavior::Chainable);
303
304        let context = serde_json::json!({
305            "count": 42,
306            "negative": -10
307        });
308
309        // Test positive integer
310        let result = env.render_str("{{count}}", &context).unwrap();
311        assert_eq!(result.trim().parse::<i32>().unwrap(), 42);
312
313        // Test negative integer
314        let result = env.render_str("{{negative}}", &context).unwrap();
315        assert_eq!(result.trim().parse::<i32>().unwrap(), -10);
316    }
317}