1use crate::citation::{
22 bibliography_heading, format_in_text, is_numbered, ordered_references, reference_list,
23 with_active, CiteContext, CiteRef,
24};
25use crate::render_typst::{parse_backtick_code, parse_delimited, parse_link, split_ordered_list};
26use crate::types::*;
27
28pub fn to_latex(doc: &SurfDoc) -> String {
30 let format = doc.front_matter.as_ref().and_then(|fm| fm.format);
31 let doc_type = doc.front_matter.as_ref().and_then(|fm| fm.doc_type);
32 let _cite_scope =
33 crate::citation::install_context(crate::citation::build_context(&doc.blocks, format));
34
35 match crate::types::render_profile(doc_type, format) {
36 RenderProfile::Paper(f) => latex_paper(doc, f),
37 RenderProfile::Report(f) => latex_report(doc, f),
38 _ => latex_generic(doc),
39 }
40}
41
42fn fm_extra(fm: &FrontMatter, keys: &[&str]) -> Option<String> {
47 for k in keys {
48 if let Some(v) = fm.extra.get(*k) {
49 if let Some(s) = v.as_str() {
50 let t = s.trim();
51 if !t.is_empty() {
52 return Some(t.to_string());
53 }
54 }
55 if let Some(seq) = v.as_sequence() {
56 let parts: Vec<String> = seq
57 .iter()
58 .filter_map(|x| x.as_str().map(|s| s.trim().to_string()))
59 .filter(|s| !s.is_empty())
60 .collect();
61 if !parts.is_empty() {
62 return Some(parts.join("; "));
63 }
64 }
65 }
66 }
67 None
68}
69
70fn doc_title(doc: &SurfDoc) -> String {
71 doc.front_matter
72 .as_ref()
73 .and_then(|fm| fm.title.clone())
74 .filter(|t| !t.trim().is_empty())
75 .unwrap_or_else(|| "Untitled".to_string())
76}
77
78fn doc_authors(doc: &SurfDoc) -> Vec<String> {
79 let mut names: Vec<String> = Vec::new();
80 if let Some(fm) = doc.front_matter.as_ref() {
81 let raw = fm_extra(fm, &["authors"]).or_else(|| fm.author.clone());
82 if let Some(raw) = raw {
83 for part in raw.split(';') {
84 let t = part.trim();
85 if !t.is_empty() && !names.iter().any(|n| n == t) {
86 names.push(t.to_string());
87 }
88 }
89 }
90 if let Some(contribs) = fm.contributors.as_ref() {
91 for c in contribs {
92 let t = c.trim();
93 if !t.is_empty() && !names.iter().any(|n| n == t) {
94 names.push(t.to_string());
95 }
96 }
97 }
98 }
99 names
100}
101
102fn doc_date(doc: &SurfDoc) -> Option<String> {
103 let fm = doc.front_matter.as_ref()?;
104 fm_extra(fm, &["date"])
105 .or_else(|| fm.created.clone())
106 .or_else(|| fm.updated.clone())
107}
108
109fn latex_paper(doc: &SurfDoc, format: Format) -> String {
114 let title = doc_title(doc);
115 let authors = doc_authors(doc);
116 let affiliation = doc
117 .front_matter
118 .as_ref()
119 .and_then(|fm| fm_extra(fm, &["affiliation", "affiliations", "institution"]));
120 let abstract_text = doc
121 .front_matter
122 .as_ref()
123 .and_then(|fm| fm_extra(fm, &["abstract"]));
124
125 let mut out = String::with_capacity(8192);
126
127 match format {
128 Format::Ieee => {
129 out.push_str("\\documentclass[conference]{IEEEtran}\n");
130 }
131 Format::Acm => {
132 out.push_str("\\documentclass[twocolumn]{article}\n");
133 }
134 _ => {
135 out.push_str("\\documentclass[11pt]{article}\n");
136 }
137 }
138 push_common_preamble(&mut out);
139
140 out.push_str(&format!("\\title{{{}}}\n", escape_latex(&title)));
142 if matches!(format, Format::Ieee) {
143 out.push_str("\\author{\\IEEEauthorblockN{");
144 out.push_str(&escape_latex(&authors.join(", ")));
145 out.push('}');
146 if let Some(aff) = &affiliation {
147 out.push_str(&format!("\\IEEEauthorblockA{{{}}}", escape_latex(aff)));
148 }
149 out.push_str("}\n");
150 } else {
151 let mut a = escape_latex(&authors.join(", "));
152 if let Some(aff) = &affiliation {
153 a.push_str(&format!("\\\\ {}", escape_latex(aff)));
154 }
155 out.push_str(&format!("\\author{{{}}}\n", a));
156 }
157 if let Some(d) = doc_date(doc) {
158 out.push_str(&format!("\\date{{{}}}\n", escape_latex(&d)));
159 }
160
161 out.push_str("\n\\begin{document}\n\\maketitle\n");
162
163 if let Some(abs) = &abstract_text {
164 out.push_str("\\begin{abstract}\n");
165 out.push_str(&render_prose_latex(abs));
166 out.push_str("\n\\end{abstract}\n\n");
167 }
168
169 latex_body(&doc.blocks, format, &mut out);
170
171 out.push_str("\n\\end{document}\n");
172 out
173}
174
175fn latex_report(doc: &SurfDoc, format: Format) -> String {
180 let title = doc_title(doc);
181 let authors = doc_authors(doc);
182 let date = doc_date(doc);
183 let instructor = doc
184 .front_matter
185 .as_ref()
186 .and_then(|fm| fm_extra(fm, &["instructor", "professor"]));
187 let course = doc
188 .front_matter
189 .as_ref()
190 .and_then(|fm| fm_extra(fm, &["course", "class"]));
191 let institution = doc
192 .front_matter
193 .as_ref()
194 .and_then(|fm| fm_extra(fm, &["institution", "affiliation", "university"]));
195
196 let mut out = String::with_capacity(8192);
197 out.push_str("\\documentclass[12pt]{article}\n");
198 out.push_str("\\usepackage[margin=1in]{geometry}\n");
199 out.push_str("\\usepackage{setspace}\n");
200 push_common_preamble(&mut out);
201 out.push_str("\\setcounter{secnumdepth}{0}\n");
203
204 out.push_str("\n\\begin{document}\n\\doublespacing\n");
205
206 match format {
207 Format::Mla => {
208 let mut lines: Vec<String> = Vec::new();
210 if !authors.is_empty() {
211 lines.push(authors.join(", "));
212 }
213 if let Some(i) = &instructor {
214 lines.push(i.clone());
215 }
216 if let Some(c) = &course {
217 lines.push(c.clone());
218 }
219 if let Some(d) = &date {
220 lines.push(d.clone());
221 }
222 let block: Vec<String> = lines.iter().map(|l| escape_latex(l)).collect();
223 out.push_str(&format!("\\noindent {}\n\n", block.join("\\\\\n")));
224 out.push_str(&format!(
225 "\\begin{{center}}{}\\end{{center}}\n\n",
226 escape_latex(&title)
227 ));
228 }
229 _ => {
230 out.push_str("\\begin{titlepage}\n\\centering\n\\vspace*{3in}\n");
232 out.push_str(&format!("{{\\bfseries {}}}\\\\[2em]\n", escape_latex(&title)));
233 if !authors.is_empty() {
234 out.push_str(&format!("{}\\\\[1em]\n", escape_latex(&authors.join(", "))));
235 }
236 if let Some(inst) = &institution {
237 out.push_str(&format!("{}\\\\[1em]\n", escape_latex(inst)));
238 }
239 if let Some(c) = &course {
240 out.push_str(&format!("{}\\\\[1em]\n", escape_latex(c)));
241 }
242 if let Some(i) = &instructor {
243 out.push_str(&format!("{}\\\\[1em]\n", escape_latex(i)));
244 }
245 if let Some(d) = &date {
246 out.push_str(&format!("{}\\\\\n", escape_latex(d)));
247 }
248 out.push_str("\\end{titlepage}\n\n");
249 }
250 }
251
252 latex_body(&doc.blocks, format, &mut out);
253
254 out.push_str("\n\\end{document}\n");
255 out
256}
257
258fn latex_generic(doc: &SurfDoc) -> String {
263 let title = doc_title(doc);
264 let authors = doc_authors(doc);
265
266 let mut out = String::with_capacity(8192);
267 out.push_str("\\documentclass[11pt]{article}\n");
268 push_common_preamble(&mut out);
269 out.push_str(&format!("\\title{{{}}}\n", escape_latex(&title)));
270 if !authors.is_empty() {
271 out.push_str(&format!("\\author{{{}}}\n", escape_latex(&authors.join(", "))));
272 }
273 if let Some(d) = doc_date(doc) {
274 out.push_str(&format!("\\date{{{}}}\n", escape_latex(&d)));
275 }
276 out.push_str("\n\\begin{document}\n\\maketitle\n");
277 let style = crate::citation::active_style(doc.front_matter.as_ref().and_then(|fm| fm.format));
278 latex_body(&doc.blocks, style, &mut out);
279 out.push_str("\n\\end{document}\n");
280 out
281}
282
283fn push_common_preamble(out: &mut String) {
284 out.push_str("\\usepackage[utf8]{inputenc}\n");
285 out.push_str("\\usepackage[T1]{fontenc}\n");
286 out.push_str("\\usepackage{graphicx}\n");
287 out.push_str("\\usepackage[normalem]{ulem}\n");
288 out.push_str("\\usepackage{hyperref}\n");
289 out.push_str("\\usepackage{cite}\n");
290}
291
292fn latex_body(blocks: &[Block], style: Format, out: &mut String) {
297 for b in blocks {
298 match b {
299 Block::Cite { .. } | Block::Site { .. } | Block::Style { .. } => {}
300 Block::Bibliography { style: bstyle, .. } => {
301 latex_bibliography(bstyle.unwrap_or(style), out);
302 }
303 _ => latex_block(b, out),
304 }
305 }
306}
307
308fn latex_block(b: &Block, out: &mut String) {
309 match b {
310 Block::Markdown { content, .. } => {
311 out.push_str(&render_prose_latex(content));
312 out.push_str("\n\n");
313 }
314 Block::Code { lang, content, .. } => {
315 let _ = lang;
316 out.push_str("\\begin{verbatim}\n");
317 out.push_str(content);
318 out.push_str("\n\\end{verbatim}\n\n");
319 }
320 Block::Quote {
321 content,
322 attribution,
323 ..
324 } => {
325 out.push_str("\\begin{quote}\n");
326 out.push_str(&render_prose_latex(content));
327 if let Some(a) = attribution {
328 out.push_str(&format!("\n\n\\hfill --- {}", escape_latex(a)));
329 }
330 out.push_str("\n\\end{quote}\n\n");
331 }
332 Block::Callout {
333 title, content, ..
334 } => {
335 out.push_str("\\begin{quote}\n");
336 if let Some(t) = title {
337 out.push_str(&format!("\\textbf{{{}}}\\\\\n", escape_latex(t)));
338 }
339 out.push_str(&render_prose_latex(content));
340 out.push_str("\n\\end{quote}\n\n");
341 }
342 Block::Summary { content, .. } => {
343 out.push_str("\\begin{quote}\n\\textbf{Summary}\\\\\n");
344 out.push_str(&render_prose_latex(content));
345 out.push_str("\n\\end{quote}\n\n");
346 }
347 Block::Data { headers, rows, .. } | Block::PricingTable { headers, rows, .. } => {
348 latex_table(headers, rows, out);
349 }
350 Block::Comparison { headers, rows, .. } => {
351 latex_table(headers, rows, out);
352 }
353 Block::Figure {
354 src,
355 caption,
356 width,
357 ..
358 } => {
359 out.push_str("\\begin{figure}[h]\n\\centering\n");
360 let w = width
361 .as_deref()
362 .filter(|w| !w.is_empty())
363 .unwrap_or("0.8\\linewidth");
364 let wspec = if w.chars().all(|c| c.is_ascii_digit() || c == '.') {
366 format!("{}\\linewidth", w)
367 } else if w.ends_with("%") {
368 let frac = w.trim_end_matches('%').trim();
369 format!("{}\\linewidth", frac.parse::<f64>().unwrap_or(80.0) / 100.0)
370 } else {
371 w.to_string()
372 };
373 out.push_str(&format!(
374 "\\includegraphics[width={}]{{{}}}\n",
375 wspec,
376 escape_latex_path(src)
377 ));
378 if let Some(c) = caption {
379 out.push_str(&format!("\\caption{{{}}}\n", md_inline_to_latex(c)));
380 }
381 out.push_str("\\end{figure}\n\n");
382 }
383 Block::Section {
384 headline,
385 subtitle,
386 children,
387 content,
388 ..
389 } => {
390 if let Some(h) = headline {
391 out.push_str(&format!("\\section{{{}}}\n", escape_latex(h)));
392 }
393 if let Some(s) = subtitle {
394 out.push_str(&format!("\\textit{{{}}}\n\n", escape_latex(s)));
395 }
396 if children.is_empty() {
397 out.push_str(&render_prose_latex(content));
398 out.push_str("\n\n");
399 } else {
400 for c in children {
401 latex_block(c, out);
402 }
403 }
404 }
405 Block::Chart {
406 chart_type,
407 title,
408 data,
409 ..
410 } => {
411 out.push_str("\\begin{figure}[h]\n\\centering\n");
412 if let Some(d) = data {
413 let mut headers = vec![String::from("")];
414 headers.extend(d.series.iter().map(|s| s.name.clone()));
415 let rows: Vec<Vec<String>> = d
416 .categories
417 .iter()
418 .enumerate()
419 .map(|(i, cat)| {
420 let mut row = vec![cat.clone()];
421 for s in &d.series {
422 row.push(
423 s.values
424 .get(i)
425 .map(|v| format!("{}", v))
426 .unwrap_or_default(),
427 );
428 }
429 row
430 })
431 .collect();
432 latex_table(&headers, &rows, out);
433 } else {
434 out.push_str(&format!(
435 "\\fbox{{[{} chart]}}\n",
436 escape_latex(crate::render_html::chart_type_str(*chart_type))
437 ));
438 }
439 if let Some(t) = title {
440 out.push_str(&format!("\\caption{{{}}}\n", escape_latex(t)));
441 }
442 out.push_str("\\end{figure}\n\n");
443 }
444 Block::Diagram { title, content, .. } => {
445 out.push_str("\\begin{figure}[h]\n\\centering\n\\begin{verbatim}\n");
446 out.push_str(content);
447 out.push_str("\n\\end{verbatim}\n");
448 if let Some(t) = title {
449 out.push_str(&format!("\\caption{{{}}}\n", escape_latex(t)));
450 }
451 out.push_str("\\end{figure}\n\n");
452 }
453 Block::Divider { .. } => {
454 out.push_str("\\par\\noindent\\rule{\\linewidth}{0.4pt}\\par\n\n");
455 }
456 Block::Details { title, content, .. } => {
457 if let Some(t) = title {
458 out.push_str(&format!("\\textbf{{{}}}\\\\\n", escape_latex(t)));
459 }
460 out.push_str(&render_prose_latex(content));
461 out.push_str("\n\n");
462 }
463 _ => {}
465 }
466}
467
468fn latex_table(headers: &[String], rows: &[Vec<String>], out: &mut String) {
469 let ncols = if !headers.is_empty() {
470 headers.len()
471 } else if let Some(r) = rows.first() {
472 r.len()
473 } else {
474 return;
475 };
476 let colspec = "l".repeat(ncols);
477 out.push_str(&format!("\\begin{{tabular}}{{{}}}\n\\hline\n", colspec));
478 if !headers.is_empty() {
479 let cells: Vec<String> = headers.iter().map(|h| format!("\\textbf{{{}}}", escape_latex(h))).collect();
480 out.push_str(&format!("{} \\\\\n\\hline\n", cells.join(" & ")));
481 }
482 for row in rows {
483 let cells: Vec<String> = row.iter().map(|c| md_inline_to_latex(c)).collect();
484 out.push_str(&format!("{} \\\\\n", cells.join(" & ")));
485 }
486 out.push_str("\\hline\n\\end{tabular}\n\n");
487}
488
489fn latex_bibliography(style: Format, out: &mut String) {
494 with_active(|ctx| {
495 let refs = match ctx {
496 Some(c) if !c.references.is_empty() => {
497 if style == c.style {
498 ordered_references(c)
499 } else {
500 c.references.clone()
501 }
502 }
503 _ => return,
504 };
505 if is_numbered(style) {
506 out.push_str("\\begin{thebibliography}{99}\n");
507 for (i, line) in reference_list(&refs, style).iter().enumerate() {
508 let body = line.splitn(2, "] ").nth(1).unwrap_or(line.as_str());
509 let key = refs.get(i).map(|r| r.key.as_str()).unwrap_or("ref");
510 out.push_str(&format!(
511 "\\bibitem{{{}}} {}\n",
512 key,
513 md_inline_to_latex(body)
514 ));
515 }
516 out.push_str("\\end{thebibliography}\n");
517 } else {
518 out.push_str(&format!(
519 "\\section*{{{}}}\n",
520 escape_latex(bibliography_heading(style))
521 ));
522 for line in reference_list(&refs, style) {
523 out.push_str(&format!(
524 "\\noindent\\hangindent=0.5in\\hangafter=1 {}\\par\\medskip\n",
525 md_inline_to_latex(&line)
526 ));
527 }
528 }
529 out.push('\n');
530 });
531}
532
533fn render_prose_latex(text: &str) -> String {
540 with_active(|ctx| {
541 let cites = crate::inline::find_inline_cites(text);
542 if cites.is_empty() {
543 return md_block_to_latex(text);
544 }
545 let mut prepared = String::with_capacity(text.len());
548 let mut replacements: Vec<(String, String)> = Vec::new();
549 let mut last = 0;
550 for (idx, (s, e, cr)) in cites.iter().enumerate() {
551 prepared.push_str(&text[last..*s]);
552 let token = format!("\u{1}CITE{idx}\u{2}");
553 let rep = match ctx {
554 Some(c) => latex_in_text(cr, c),
555 None => String::new(),
556 };
557 prepared.push_str(&token);
558 replacements.push((token, rep));
559 last = *e;
560 }
561 prepared.push_str(&text[last..]);
562 let mut converted = md_block_to_latex(&prepared);
563 for (tok, rep) in replacements {
564 converted = converted.replace(&tok, &rep);
565 }
566 converted
567 })
568}
569
570fn latex_in_text(cr: &CiteRef, ctx: &CiteContext) -> String {
571 if is_numbered(ctx.style) {
572 if cr.items.len() == 1 {
573 let it = &cr.items[0];
574 match &it.locator {
575 Some(loc) => format!("\\cite[{}]{{{}}}", escape_latex(loc), it.key),
576 None => format!("\\cite{{{}}}", it.key),
577 }
578 } else {
579 let keys: Vec<String> = cr.items.iter().map(|i| i.key.clone()).collect();
580 format!("\\cite{{{}}}", keys.join(","))
581 }
582 } else {
583 escape_latex(&format_in_text(&ctx.references, cr, ctx.style, &ctx.numbers))
584 }
585}
586
587fn md_block_to_latex(md: &str) -> String {
589 let lines: Vec<&str> = md.lines().collect();
590 let mut out = String::with_capacity(md.len());
591 let mut i = 0;
592 let mut in_code = false;
593 let mut code_buf = String::new();
594
595 while i < lines.len() {
596 let line = lines[i];
597
598 if line.starts_with("```") {
599 if in_code {
600 out.push_str("\\begin{verbatim}\n");
601 out.push_str(code_buf.trim_end_matches('\n'));
602 out.push_str("\n\\end{verbatim}\n");
603 code_buf.clear();
604 in_code = false;
605 } else {
606 in_code = true;
607 }
608 i += 1;
609 continue;
610 }
611 if in_code {
612 code_buf.push_str(line);
613 code_buf.push('\n');
614 i += 1;
615 continue;
616 }
617
618 if let Some(rest) = line.strip_prefix("#### ") {
620 out.push_str(&format!("\\paragraph{{{}}} ", md_inline_to_latex(rest)));
621 i += 1;
622 continue;
623 }
624 if let Some(rest) = line.strip_prefix("### ") {
625 out.push_str(&format!("\\subsubsection{{{}}}\n", md_inline_to_latex(rest)));
626 i += 1;
627 continue;
628 }
629 if let Some(rest) = line.strip_prefix("## ") {
630 out.push_str(&format!("\\subsection{{{}}}\n", md_inline_to_latex(rest)));
631 i += 1;
632 continue;
633 }
634 if let Some(rest) = line.strip_prefix("# ") {
635 out.push_str(&format!("\\section{{{}}}\n", md_inline_to_latex(rest)));
636 i += 1;
637 continue;
638 }
639
640 let trimmed = line.trim();
641
642 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
644 out.push_str("\\par\\noindent\\rule{\\linewidth}{0.4pt}\\par\n");
645 i += 1;
646 continue;
647 }
648
649 if is_ul_item(line) {
651 out.push_str("\\begin{itemize}\n");
652 while i < lines.len() && is_ul_item(lines[i]) {
653 let item = ul_item_text(lines[i]);
654 out.push_str(&format!(" \\item {}\n", md_inline_to_latex(item)));
655 i += 1;
656 }
657 out.push_str("\\end{itemize}\n");
658 continue;
659 }
660
661 if split_ordered_list(line).is_some() {
663 out.push_str("\\begin{enumerate}\n");
664 while i < lines.len() {
665 if let Some((_, rest)) = split_ordered_list(lines[i]) {
666 out.push_str(&format!(" \\item {}\n", md_inline_to_latex(rest)));
667 i += 1;
668 } else {
669 break;
670 }
671 }
672 out.push_str("\\end{enumerate}\n");
673 continue;
674 }
675
676 if trimmed.starts_with('>') {
678 out.push_str("\\begin{quote}\n");
679 while i < lines.len() && lines[i].trim().starts_with('>') {
680 let q = lines[i].trim().trim_start_matches('>').trim_start();
681 out.push_str(&format!("{}\n", md_inline_to_latex(q)));
682 i += 1;
683 }
684 out.push_str("\\end{quote}\n");
685 continue;
686 }
687
688 if trimmed.starts_with('|') && trimmed.ends_with('|') {
690 let mut tbl_rows: Vec<Vec<String>> = Vec::new();
691 while i < lines.len() {
692 let t = lines[i].trim();
693 if !(t.starts_with('|') && t.ends_with('|')) {
694 break;
695 }
696 if !t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')) {
698 let cells: Vec<String> = t
699 .trim_matches('|')
700 .split('|')
701 .map(|c| c.trim().to_string())
702 .collect();
703 tbl_rows.push(cells);
704 }
705 i += 1;
706 }
707 if !tbl_rows.is_empty() {
708 let headers = tbl_rows.remove(0);
709 latex_table(&headers, &tbl_rows, &mut out);
710 }
711 continue;
712 }
713
714 if trimmed.is_empty() {
716 out.push('\n');
717 i += 1;
718 continue;
719 }
720
721 out.push_str(&md_inline_to_latex(line));
723 out.push('\n');
724 i += 1;
725 }
726
727 if in_code {
728 out.push_str("\\begin{verbatim}\n");
729 out.push_str(code_buf.trim_end_matches('\n'));
730 out.push_str("\n\\end{verbatim}\n");
731 }
732
733 out
734}
735
736fn is_ul_item(line: &str) -> bool {
737 let t = line.trim_start();
738 t.starts_with("- ") || t.starts_with("* ")
739}
740
741fn ul_item_text(line: &str) -> &str {
742 let t = line.trim_start();
743 t.strip_prefix("- ")
744 .or_else(|| t.strip_prefix("* "))
745 .unwrap_or(t)
746}
747
748fn md_inline_to_latex(text: &str) -> String {
751 let chars: Vec<char> = text.chars().collect();
752 let len = chars.len();
753 let mut out = String::with_capacity(len);
754 let mut i = 0;
755
756 while i < len {
757 if chars[i] == '\\' && i + 1 < len {
758 out.push_str(&escape_latex_char(chars[i + 1]));
759 i += 2;
760 continue;
761 }
762 if chars[i] == '!' && i + 1 < len && chars[i + 1] == '[' {
764 if let Some((_, src, end)) = parse_link(&chars, i + 1) {
765 out.push_str(&format!(
766 "\\includegraphics[width=0.8\\linewidth]{{{}}}",
767 escape_latex_path(&src)
768 ));
769 i = end;
770 continue;
771 }
772 }
773 if chars[i] == '[' {
775 if let Some((t, href, end)) = parse_link(&chars, i) {
776 out.push_str(&format!(
777 "\\href{{{}}}{{{}}}",
778 escape_url(&href),
779 md_inline_to_latex(&t)
780 ));
781 i = end;
782 continue;
783 }
784 }
785 if chars[i] == '`' {
787 if let Some((code, end)) = parse_backtick_code(&chars, i) {
788 out.push_str(&format!("\\texttt{{{}}}", escape_latex(&code)));
789 i = end;
790 continue;
791 }
792 }
793 if i + 1 < len && chars[i] == '*' && chars[i + 1] == '*' {
795 if let Some((c, end)) = parse_delimited(&chars, i, "**") {
796 out.push_str(&format!("\\textbf{{{}}}", md_inline_to_latex(&c)));
797 i = end;
798 continue;
799 }
800 }
801 if i + 1 < len && chars[i] == '_' && chars[i + 1] == '_' {
802 if let Some((c, end)) = parse_delimited(&chars, i, "__") {
803 out.push_str(&format!("\\textbf{{{}}}", md_inline_to_latex(&c)));
804 i = end;
805 continue;
806 }
807 }
808 if chars[i] == '*' && i + 1 < len && chars[i + 1] != '*' && !chars[i + 1].is_whitespace() {
810 if let Some((c, end)) = parse_delimited(&chars, i, "*") {
811 out.push_str(&format!("\\textit{{{}}}", md_inline_to_latex(&c)));
812 i = end;
813 continue;
814 }
815 }
816 if chars[i] == '_'
817 && i + 1 < len
818 && chars[i + 1] != '_'
819 && (i == 0 || !chars[i - 1].is_alphanumeric())
820 {
821 if let Some((c, end)) = parse_delimited(&chars, i, "_") {
822 out.push_str(&format!("\\textit{{{}}}", md_inline_to_latex(&c)));
823 i = end;
824 continue;
825 }
826 }
827 if i + 1 < len && chars[i] == '~' && chars[i + 1] == '~' {
829 if let Some((c, end)) = parse_delimited(&chars, i, "~~") {
830 out.push_str(&format!("\\sout{{{}}}", md_inline_to_latex(&c)));
831 i = end;
832 continue;
833 }
834 }
835
836 out.push_str(&escape_latex_char(chars[i]));
837 i += 1;
838 }
839
840 out
841}
842
843fn escape_latex(s: &str) -> String {
849 let mut out = String::with_capacity(s.len());
850 for c in s.chars() {
851 out.push_str(&escape_latex_char(c));
852 }
853 out
854}
855
856fn escape_latex_char(c: char) -> String {
857 match c {
858 '\\' => "\\textbackslash{}".to_string(),
859 '&' => "\\&".to_string(),
860 '%' => "\\%".to_string(),
861 '$' => "\\$".to_string(),
862 '#' => "\\#".to_string(),
863 '_' => "\\_".to_string(),
864 '{' => "\\{".to_string(),
865 '}' => "\\}".to_string(),
866 '~' => "\\textasciitilde{}".to_string(),
867 '^' => "\\textasciicircum{}".to_string(),
868 other => other.to_string(),
869 }
870}
871
872fn escape_url(s: &str) -> String {
875 let mut out = String::with_capacity(s.len());
876 for c in s.chars() {
877 match c {
878 '#' => out.push_str("\\#"),
879 '%' => out.push_str("\\%"),
880 '&' => out.push_str("\\&"),
881 '{' => out.push_str("\\{"),
882 '}' => out.push_str("\\}"),
883 other => out.push(other),
884 }
885 }
886 out
887}
888
889fn escape_latex_path(s: &str) -> String {
892 s.replace('\\', "/")
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 fn parse(src: &str) -> SurfDoc {
900 crate::parse(src).doc
901 }
902
903 #[test]
904 fn escapes_special_chars() {
905 assert_eq!(escape_latex("a & b % c"), "a \\& b \\% c");
906 assert_eq!(escape_latex("snake_case"), "snake\\_case");
907 assert_eq!(escape_latex("100% #1"), "100\\% \\#1");
908 assert_eq!(escape_latex("a$b"), "a\\$b");
909 }
910
911 #[test]
912 fn ieee_paper_uses_ieeetran() {
913 let src = "---\ntitle: A Study\ntype: paper\nformat: ieee\nauthor: Jane Doe\n---\n\n# Intro\n\nText.\n";
914 let tex = to_latex(&parse(src));
915 assert!(tex.contains("{IEEEtran}"), "missing IEEEtran class: {tex}");
916 assert!(tex.contains("\\IEEEauthorblockN"));
917 assert!(tex.contains("\\begin{document}"));
918 assert!(tex.contains("\\maketitle"));
919 }
920
921 #[test]
922 fn mla_report_double_spaced_works_cited() {
923 let src = "---\ntitle: My Essay\ntype: report\nformat: mla\nauthor: Sam Student\ninstructor: Dr. Smith\ncourse: ENG 101\n---\n\n# Body\n\nClaim [@a].\n\n::cite[key=a type=book]\nauthor = Author, One\ntitle = A Book\npublisher = Press\nyear = 2020\n::\n\n::bibliography\n::\n";
924 let tex = to_latex(&parse(src));
925 assert!(tex.contains("\\usepackage{setspace}"));
926 assert!(tex.contains("\\doublespacing"));
927 assert!(tex.contains("Dr. Smith"));
928 assert!(tex.contains("\\section*{Works Cited}"), "missing Works Cited: {tex}");
929 assert!(tex.contains("(Author)"));
931 }
932
933 #[test]
934 fn apa_report_references_heading() {
935 let src = "---\ntitle: Report\ntype: report\nformat: apa\nauthor: A B\n---\n\n# Body\n\nText [@a].\n\n::cite[key=a type=article]\nauthor = Q, R\ntitle = T\nyear = 2020\n::\n\n::bibliography\n::\n";
936 let tex = to_latex(&parse(src));
937 assert!(tex.contains("\\section*{References}"), "missing References: {tex}");
938 assert!(tex.contains("\\begin{titlepage}"));
939 }
940
941 #[test]
942 fn chicago_report_bibliography_heading() {
943 let src = "---\ntitle: Report\ntype: report\nformat: chicago\nauthor: A B\n---\n\n# Body\n\nText [@a].\n\n::cite[key=a type=book]\nauthor = Q, R\ntitle = T\npublisher = P\nyear = 2020\n::\n\n::bibliography\n::\n";
944 let tex = to_latex(&parse(src));
945 assert!(tex.contains("\\section*{Bibliography}"), "missing Bibliography: {tex}");
946 }
947
948 #[test]
949 fn ieee_numbered_cite_and_thebibliography() {
950 let src = "---\ntitle: P\ntype: paper\nformat: ieee\nauthor: X\n---\n\n# Body\n\nClaim [@a].\n\n::cite[key=a type=article]\nauthor = Q, R\ntitle = T\njournal = J\nyear = 2020\n::\n\n::bibliography\n::\n";
951 let tex = to_latex(&parse(src));
952 assert!(tex.contains("\\cite{a}"), "missing \\cite: {tex}");
953 assert!(tex.contains("\\begin{thebibliography}"));
954 assert!(tex.contains("\\bibitem{a}"));
955 }
956
957 #[test]
958 fn to_latex_is_deterministic() {
959 let src = "---\ntitle: P\ntype: paper\nformat: ieee\nauthor: Jane Doe; John Roe\nabstract: A short abstract.\n---\n\n# Intro\n\nWork [@a].\n\n::cite[key=a type=article]\nauthor = Q, R\ntitle = T\njournal = J\nyear = 2020\n::\n\n::bibliography\n::\n";
960 let doc = parse(src);
961 assert_eq!(to_latex(&doc), to_latex(&doc));
962 }
963
964 #[test]
965 fn escapes_in_body_paragraph() {
966 let src = "---\ntype: paper\nformat: article\n---\n\nCost is 50% of #1 in the_field.\n";
967 let tex = to_latex(&parse(src));
968 assert!(tex.contains("50\\%"));
969 assert!(tex.contains("\\#1"));
970 assert!(tex.contains("the\\_field"));
971 }
972}