elicitor_doc_latex/
lib.rs

1//! LaTeX backend for derive-survey: generates fillable PDF forms from SurveyDefinition.
2
3use elicitor::SurveyDefinition;
4
5/// Escape special LaTeX characters in text content.
6fn escape_latex(s: &str) -> String {
7    s.replace('\\', "\\textbackslash{}")
8        .replace('&', "\\&")
9        .replace('%', "\\%")
10        .replace('$', "\\$")
11        .replace('#', "\\#")
12        .replace('_', "\\_")
13        .replace('{', "\\{")
14        .replace('}', "\\}")
15        .replace('~', "\\textasciitilde{}")
16        .replace('^', "\\textasciicircum{}")
17}
18
19/// Sanitize a field name for use in PDF form field names.
20/// PDF field names should not contain special characters.
21fn sanitize_field_name(s: &str) -> String {
22    s.chars()
23        .map(|c| match c {
24            'a'..='z' | 'A'..='Z' | '0'..='9' => c,
25            _ => '-',
26        })
27        .collect()
28}
29
30/// Calculate shade percentage based on nesting depth.
31/// Starts at 5% and increases by 5% per level, capped at 25%.
32fn shade_percent(indent_level: usize) -> usize {
33    (5 + indent_level * 5).min(25)
34}
35
36/// Generate a LaTeX document (as a String) for a fillable form from a SurveyDefinition.
37pub fn to_latex_form(survey: &SurveyDefinition) -> String {
38    let mut latex = String::new();
39
40    // Document preamble
41    latex.push_str(
42        r#"\documentclass[11pt]{article}
43\usepackage[utf8]{inputenc}
44\usepackage[T1]{fontenc}
45\usepackage[sfdefault]{cabin}
46\usepackage[pdftex]{hyperref}
47\usepackage{geometry}
48\usepackage{xcolor}
49\usepackage{tcolorbox}
50
51\geometry{margin=1in}
52\hypersetup{
53    colorlinks=true,
54    linkcolor=blue,
55    pdfborder={0 0 0}
56}
57
58% Force consistent checkbox appearance
59\renewcommand{\LayoutCheckField}[2]{\makebox[12pt][l]{#2}}
60
61% Shaded blocks for nested content with varying depth and rounded corners
62\newtcolorbox{shadedblock}[1][5]{
63    colback=black!#1,
64    colframe=black!#1,
65    arc=3pt,
66    boxrule=0pt,
67    left=0.3em,
68    right=0.3em,
69    top=0.3em,
70    bottom=0.3em,
71    boxsep=0pt
72}
73
74
75
76\begin{document}
77"#,
78    );
79
80    // Prelude
81    if let Some(prelude) = &survey.prelude {
82        latex.push_str("\n\\noindent ");
83        latex.push_str(&escape_latex(prelude));
84        latex.push_str("\n\n\\vspace{1em}\n");
85    }
86
87    latex.push_str("\n\\begin{Form}\n");
88
89    for (i, q) in survey.questions.iter().enumerate() {
90        if i > 0 {
91            latex.push_str("\n\\vspace{1.5em}\n");
92        }
93        latex.push_str(&render_question(q, 0));
94    }
95
96    latex.push_str("\n\\end{Form}\n");
97
98    // Epilogue
99    if let Some(epilogue) = &survey.epilogue {
100        latex.push_str("\n\\vspace{2em}\n\\noindent ");
101        latex.push_str(&escape_latex(epilogue));
102        latex.push_str("\n");
103    }
104
105    latex.push_str("\n\\end{document}\n");
106    latex
107}
108
109fn render_question(q: &elicitor::Question, indent_level: usize) -> String {
110    render_question_with_path(q, indent_level, None)
111}
112
113fn render_question_with_path(
114    q: &elicitor::Question,
115    indent_level: usize,
116    parent_path: Option<&str>,
117) -> String {
118    use elicitor::QuestionKind;
119
120    let mut s = String::new();
121    let indent = "  ".repeat(indent_level);
122    let ask = q.ask();
123
124    // Build the full path - combine parent path with question's path
125    let path_str = q.path().as_str();
126    let full_path = match (parent_path, path_str.is_empty()) {
127        (Some(parent), true) => parent.to_string(),
128        (Some(parent), false) => format!("{}.{}", parent, path_str),
129        (None, _) => path_str.to_string(),
130    };
131    let field_name = sanitize_field_name(&full_path);
132
133    // Render the question text if present
134    if !ask.is_empty() {
135        s.push_str(&indent);
136        s.push_str("\\noindent\\textbf{");
137        s.push_str(&escape_latex(ask));
138        s.push_str("}\n\n");
139        s.push_str(&indent);
140        s.push_str("\\smallskip\n");
141    }
142
143    match q.kind() {
144        QuestionKind::Input(_) => {
145            s.push_str(&indent);
146            s.push_str("\\noindent\\TextField[name=");
147            s.push_str(&field_name);
148            s.push_str(",width=4in,bordercolor={0.5 0.5 0.5}]{}\n");
149            s.push_str(&indent);
150            s.push_str("\\par\\medskip\n");
151        }
152        QuestionKind::Int(int_q) => {
153            s.push_str(&indent);
154            s.push_str("\\noindent\\TextField[name=");
155            s.push_str(&field_name);
156            s.push_str(",width=1.5in,bordercolor={0.5 0.5 0.5}]{}");
157
158            // Add range hint if available
159            if int_q.min.is_some() || int_q.max.is_some() {
160                s.push_str(" \\textit{\\small(");
161                match (int_q.min, int_q.max) {
162                    (Some(min), Some(max)) => s.push_str(&format!("{} -- {}", min, max)),
163                    (Some(min), None) => s.push_str(&format!("min: {}", min)),
164                    (None, Some(max)) => s.push_str(&format!("max: {}", max)),
165                    (None, None) => {}
166                }
167                s.push_str(")}");
168            }
169            s.push_str("\n");
170            s.push_str(&indent);
171            s.push_str("\\par\\medskip\n");
172        }
173        QuestionKind::Float(float_q) => {
174            s.push_str(&indent);
175            s.push_str("\\noindent\\TextField[name=");
176            s.push_str(&field_name);
177            s.push_str(",width=1.5in,bordercolor={0.5 0.5 0.5}]{}");
178
179            // Add range hint if available
180            if float_q.min.is_some() || float_q.max.is_some() {
181                s.push_str(" \\textit{\\small(");
182                match (float_q.min, float_q.max) {
183                    (Some(min), Some(max)) => s.push_str(&format!("{} -- {}", min, max)),
184                    (Some(min), None) => s.push_str(&format!("min: {}", min)),
185                    (None, Some(max)) => s.push_str(&format!("max: {}", max)),
186                    (None, None) => {}
187                }
188                s.push_str(")}");
189            }
190            s.push_str("\n");
191            s.push_str(&indent);
192            s.push_str("\\par\\medskip\n");
193        }
194        QuestionKind::Confirm(_) => {
195            s.push_str(&indent);
196            s.push_str("\\noindent\\CheckBox[name=");
197            s.push_str(&field_name);
198            s.push_str(
199                ",width=10pt,height=10pt,borderwidth=1pt,bordercolor={0.4 0.4 0.4}]{} Yes\n\n",
200            );
201        }
202        QuestionKind::OneOf(oneof) => {
203            s.push_str(&indent);
204            s.push_str("\\noindent\\ChoiceMenu[combo,name=");
205            s.push_str(&field_name);
206            s.push_str(",width=3in,bordercolor={0.5 0.5 0.5}]{}{");
207            let options: Vec<String> = oneof
208                .variants
209                .iter()
210                .map(|v| escape_latex(&v.name))
211                .collect();
212            s.push_str(&options.join(","));
213            s.push_str("}\n");
214
215            // Render follow-up fields for variants that have nested questions
216            for variant in &oneof.variants {
217                if !matches!(variant.kind, elicitor::QuestionKind::Unit) {
218                    s.push_str("\n");
219                    s.push_str(&indent);
220                    s.push_str("\\vspace{0.5em}\n");
221                    s.push_str(&indent);
222                    s.push_str("\\textit{If ");
223                    s.push_str(&escape_latex(&variant.name));
224                    s.push_str(":}\n\n");
225                    s.push_str(&indent);
226                    s.push_str(&format!(
227                        "\\begin{{shadedblock}}[{}]\n",
228                        shade_percent(indent_level + 1)
229                    ));
230                    s.push_str(&render_variant_fields(
231                        &variant.kind,
232                        &full_path,
233                        indent_level + 1,
234                    ));
235                    s.push_str(&indent);
236                    s.push_str("\\end{shadedblock}\n");
237                }
238            }
239        }
240        QuestionKind::AnyOf(anyof) => {
241            for variant in &anyof.variants {
242                let checkbox_name =
243                    format!("{}-{}", field_name, sanitize_field_name(&variant.name));
244                s.push_str(&indent);
245                s.push_str("\\CheckBox[name=");
246                s.push_str(&checkbox_name);
247                s.push_str(",width=10pt,height=10pt,borderwidth=1pt,bordercolor={0.4 0.4 0.4}]{} ");
248                s.push_str(&escape_latex(&variant.name));
249                s.push_str("\n\n");
250                s.push_str(&indent);
251                s.push_str("\\vspace{0.3em}\n");
252            }
253
254            // Render follow-up fields for variants that have nested questions
255            for variant in &anyof.variants {
256                if !matches!(variant.kind, elicitor::QuestionKind::Unit) {
257                    s.push_str("\n");
258                    s.push_str(&indent);
259                    s.push_str("\\vspace{0.5em}\n");
260                    s.push_str(&indent);
261                    s.push_str("\\textit{If ");
262                    s.push_str(&escape_latex(&variant.name));
263                    s.push_str(":}\n\n");
264                    s.push_str(&indent);
265                    s.push_str(&format!(
266                        "\\begin{{shadedblock}}[{}]\n",
267                        shade_percent(indent_level + 1)
268                    ));
269                    s.push_str(&render_variant_fields(
270                        &variant.kind,
271                        &full_path,
272                        indent_level + 1,
273                    ));
274                    s.push_str(&indent);
275                    s.push_str("\\end{shadedblock}\n");
276                }
277            }
278        }
279        QuestionKind::AllOf(allof) => {
280            // Nested questions - render with increased indent, passing current path as parent
281            let parent = if full_path.is_empty() {
282                None
283            } else {
284                Some(full_path.as_str())
285            };
286            s.push_str(&indent);
287            s.push_str(&format!(
288                "\\begin{{shadedblock}}[{}]\n",
289                shade_percent(indent_level + 1)
290            ));
291            for (i, sub) in allof.questions.iter().enumerate() {
292                if i > 0 {
293                    s.push_str("\n");
294                    s.push_str(&indent);
295                    s.push_str("\\vspace{0.8em}\n");
296                }
297                s.push_str(&render_question_with_path(sub, indent_level + 1, parent));
298            }
299            s.push_str(&indent);
300            s.push_str("\\end{shadedblock}\n");
301        }
302        QuestionKind::Multiline(_) => {
303            s.push_str(&indent);
304            s.push_str("\\noindent\\TextField[name=");
305            s.push_str(&field_name);
306            s.push_str(",multiline=true,width=4in,height=1.2in,bordercolor={0.5 0.5 0.5}]{}\n\n");
307        }
308        QuestionKind::Unit => {
309            // No input needed for unit types
310        }
311        QuestionKind::Masked(_) => {
312            s.push_str(&indent);
313            s.push_str("\\noindent\\TextField[name=");
314            s.push_str(&field_name);
315            s.push_str(",password=true,width=3in,bordercolor={0.5 0.5 0.5}]{}\n\n");
316        }
317        QuestionKind::List(_) => {
318            s.push_str(&indent);
319            s.push_str("\\noindent\\TextField[name=");
320            s.push_str(&field_name);
321            s.push_str(
322                ",width=4in,bordercolor={0.5 0.5 0.5}]{} \\textit{\\small(comma-separated)}\n\n",
323            );
324        }
325    }
326
327    s
328}
329
330/// Render the fields for a variant's nested QuestionKind
331fn render_variant_fields(
332    kind: &elicitor::QuestionKind,
333    parent_path: &str,
334    indent_level: usize,
335) -> String {
336    use elicitor::QuestionKind;
337
338    let indent = "  ".repeat(indent_level);
339    let mut s = String::new();
340
341    match kind {
342        QuestionKind::Unit => {
343            // No fields for unit variants
344        }
345        QuestionKind::Input(input_q) => {
346            let field_name = sanitize_field_name(parent_path);
347            s.push_str(&indent);
348            s.push_str("\\noindent\\TextField[name=");
349            s.push_str(&field_name);
350            s.push_str("-value,width=4in,bordercolor={0.5 0.5 0.5}]{}");
351            if let Some(default) = &input_q.default {
352                s.push_str(" \\textit{\\small(default: ");
353                s.push_str(&escape_latex(default));
354                s.push_str(")}");
355            }
356            s.push_str("\n\n");
357        }
358        QuestionKind::Int(int_q) => {
359            let field_name = sanitize_field_name(parent_path);
360            s.push_str(&indent);
361            s.push_str("\\noindent\\TextField[name=");
362            s.push_str(&field_name);
363            s.push_str("-value,width=1.5in,bordercolor={0.5 0.5 0.5}]{}");
364            if int_q.min.is_some() || int_q.max.is_some() {
365                s.push_str(" \\textit{\\small(");
366                match (int_q.min, int_q.max) {
367                    (Some(min), Some(max)) => s.push_str(&format!("{} -- {}", min, max)),
368                    (Some(min), None) => s.push_str(&format!("min: {}", min)),
369                    (None, Some(max)) => s.push_str(&format!("max: {}", max)),
370                    (None, None) => {}
371                }
372                s.push_str(")}");
373            }
374            s.push_str("\n\n");
375        }
376        QuestionKind::Float(float_q) => {
377            let field_name = sanitize_field_name(parent_path);
378            s.push_str(&indent);
379            s.push_str("\\noindent\\TextField[name=");
380            s.push_str(&field_name);
381            s.push_str("-value,width=1.5in,bordercolor={0.5 0.5 0.5}]{}");
382            if float_q.min.is_some() || float_q.max.is_some() {
383                s.push_str(" \\textit{\\small(");
384                match (float_q.min, float_q.max) {
385                    (Some(min), Some(max)) => s.push_str(&format!("{} -- {}", min, max)),
386                    (Some(min), None) => s.push_str(&format!("min: {}", min)),
387                    (None, Some(max)) => s.push_str(&format!("max: {}", max)),
388                    (None, None) => {}
389                }
390                s.push_str(")}");
391            }
392            s.push_str("\n\n");
393        }
394        QuestionKind::Confirm(_) => {
395            let field_name = sanitize_field_name(parent_path);
396            s.push_str(&indent);
397            s.push_str("\\noindent\\CheckBox[name=");
398            s.push_str(&field_name);
399            s.push_str(
400                "-value,width=10pt,height=10pt,borderwidth=1pt,bordercolor={0.4 0.4 0.4}]{} Yes\n\n",
401            );
402        }
403        QuestionKind::Multiline(_) => {
404            let field_name = sanitize_field_name(parent_path);
405            s.push_str(&indent);
406            s.push_str("\\noindent\\TextField[name=");
407            s.push_str(&field_name);
408            s.push_str(
409                "-value,multiline=true,width=4in,height=1.2in,bordercolor={0.5 0.5 0.5}]{}\n\n",
410            );
411        }
412        QuestionKind::AllOf(allof) => {
413            // Struct variant - render all nested questions
414            for (i, sub) in allof.questions.iter().enumerate() {
415                if i > 0 {
416                    s.push_str(&indent);
417                    s.push_str("\\vspace{0.5em}\n");
418                }
419                s.push_str(&render_question_with_path(
420                    sub,
421                    indent_level,
422                    Some(parent_path),
423                ));
424            }
425        }
426        QuestionKind::OneOf(oneof) => {
427            // Nested enum - render as choice menu with its own follow-ups
428            let field_name = sanitize_field_name(parent_path);
429            s.push_str(&indent);
430            s.push_str("\\noindent\\ChoiceMenu[combo,name=");
431            s.push_str(&field_name);
432            s.push_str("-value,width=3in,bordercolor={0.5 0.5 0.5}]{}{");
433            let options: Vec<String> = oneof
434                .variants
435                .iter()
436                .map(|v| escape_latex(&v.name))
437                .collect();
438            s.push_str(&options.join(","));
439            s.push_str("}\n");
440
441            // Recursively render nested variant fields
442            for variant in &oneof.variants {
443                if !matches!(variant.kind, QuestionKind::Unit) {
444                    s.push_str("\n");
445                    s.push_str(&indent);
446                    s.push_str("\\vspace{0.3em}\n");
447                    s.push_str(&indent);
448                    s.push_str("\\textit{\\small If ");
449                    s.push_str(&escape_latex(&variant.name));
450                    s.push_str(":}\n\n");
451                    s.push_str(&indent);
452                    s.push_str(&format!(
453                        "\\begin{{shadedblock}}[{}]\n",
454                        shade_percent(indent_level + 1)
455                    ));
456                    let nested_path =
457                        format!("{}-{}", parent_path, sanitize_field_name(&variant.name));
458                    s.push_str(&render_variant_fields(
459                        &variant.kind,
460                        &nested_path,
461                        indent_level + 1,
462                    ));
463                    s.push_str(&indent);
464                    s.push_str("\\end{shadedblock}\n");
465                }
466            }
467        }
468        QuestionKind::AnyOf(anyof) => {
469            // Multi-select within a variant
470            for variant in &anyof.variants {
471                let checkbox_name = format!(
472                    "{}-{}",
473                    sanitize_field_name(parent_path),
474                    sanitize_field_name(&variant.name)
475                );
476                s.push_str(&indent);
477                s.push_str("\\CheckBox[name=");
478                s.push_str(&checkbox_name);
479                s.push_str(",width=10pt,height=10pt,borderwidth=1pt,bordercolor={0.4 0.4 0.4}]{} ");
480                s.push_str(&escape_latex(&variant.name));
481                s.push_str("\n\n");
482                s.push_str(&indent);
483                s.push_str("\\vspace{0.3em}\n");
484            }
485        }
486        QuestionKind::Masked(_) => {
487            let field_name = sanitize_field_name(parent_path);
488            s.push_str(&indent);
489            s.push_str("\\noindent\\TextField[name=");
490            s.push_str(&field_name);
491            s.push_str("-value,password=true,width=3in,bordercolor={0.5 0.5 0.5}]{}\n");
492        }
493        QuestionKind::List(_) => {
494            let field_name = sanitize_field_name(parent_path);
495            s.push_str(&indent);
496            s.push_str("\\noindent\\TextField[name=");
497            s.push_str(&field_name);
498            s.push_str("-value,width=4in,bordercolor={0.5 0.5 0.5}]{} \\textit{\\small(comma-separated)}\n");
499        }
500    }
501
502    s
503}