1use acroform::{AcroFormDocument, FieldValue};
8use quillmark_core::{
9 Artifact, Backend, Diagnostic, OutputFormat, Quill, RenderError, RenderOptions, RenderResult,
10 Severity,
11};
12use std::collections::HashMap;
13
14#[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 &[]
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 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 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 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 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 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 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 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 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 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 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 let result = env.render_str("{{enabled}}", &context).unwrap();
291 assert_eq!(result.trim().to_lowercase(), "true");
292
293 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 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 let result = env.render_str("{{count}}", &context).unwrap();
311 assert_eq!(result.trim().parse::<i32>().unwrap(), 42);
312
313 let result = env.render_str("{{negative}}", &context).unwrap();
315 assert_eq!(result.trim().parse::<i32>().unwrap(), -10);
316 }
317}