1use elicitor::SurveyDefinition;
4
5fn 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
19fn 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
30fn shade_percent(indent_level: usize) -> usize {
33 (5 + indent_level * 5).min(25)
34}
35
36pub fn to_latex_form(survey: &SurveyDefinition) -> String {
38 let mut latex = String::new();
39
40 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 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 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 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 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 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 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 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 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 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 }
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
330fn 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 }
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 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 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 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 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}