Skip to main content

surf_parse/
render_latex.rs

1//! LaTeX renderer.
2//!
3//! Emits real, compilable `.tex` from a [`SurfDoc`] block tree. The output
4//! target is selected by the document's render profile (Chunk 1):
5//!
6//! - **Papers** (`type: paper`): `IEEEtran` for `ieee`, two-column `article` for
7//!   `acm`, single-column `article` for the generic `article` format.
8//! - **Reports** (`type: report`): `article` configured per style — MLA
9//!   (`setspace` double-spacing + heading block + Works Cited), APA (title page +
10//!   References), Chicago (title page + Bibliography).
11//! - **Everything else**: a plain `article` document.
12//!
13//! Citations are formatted through the Chunk 4 citation engine: numbered styles
14//! (IEEE/ACM) emit `\cite{…}` + a `thebibliography` environment; author styles
15//! (MLA/APA/Chicago/article) emit the formatted in-text string inline and a
16//! hanging-indent reference list under a `\section*` heading.
17//!
18//! Pure / deterministic: same input → byte-identical `.tex`. No new heavy deps —
19//! the `.tex` is built as strings.
20
21use 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
28/// Render a [`SurfDoc`] to a complete LaTeX (`.tex`) document string.
29pub 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
42// ───────────────────────────────────────────────────────────────────────────
43// Front-matter helpers (mirror the Typst academic templates)
44// ───────────────────────────────────────────────────────────────────────────
45
46fn 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
109// ───────────────────────────────────────────────────────────────────────────
110// Paper templates
111// ───────────────────────────────────────────────────────────────────────────
112
113fn 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    // Title + authors.
141    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
175// ───────────────────────────────────────────────────────────────────────────
176// Report templates
177// ───────────────────────────────────────────────────────────────────────────
178
179fn 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    // Reports do not number their headings.
202    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            // Top-left double-spaced heading block, then a centered title.
209            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            // APA / Chicago title page.
231            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
258// ───────────────────────────────────────────────────────────────────────────
259// Generic (non paper/report) document
260// ───────────────────────────────────────────────────────────────────────────
261
262fn 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
292// ───────────────────────────────────────────────────────────────────────────
293// Body block rendering
294// ───────────────────────────────────────────────────────────────────────────
295
296fn 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            // Numeric width -> treat as a fraction of \linewidth.
365            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        // Metadata / interactive / web-only blocks: no academic representation.
464        _ => {}
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
489// ───────────────────────────────────────────────────────────────────────────
490// Bibliography
491// ───────────────────────────────────────────────────────────────────────────
492
493fn 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
533// ───────────────────────────────────────────────────────────────────────────
534// Markdown → LaTeX conversion
535// ───────────────────────────────────────────────────────────────────────────
536
537/// Convert markdown prose to LaTeX, first substituting inline `[@key]` cites
538/// (numbered styles → `\cite{…}`; author styles → the formatted in-text string).
539fn 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        // Splice citation placeholders that survive the markdown→LaTeX pass,
546        // then substitute the rendered cite commands back in.
547        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
587/// Block-level markdown → LaTeX (headings, lists, code fences, quotes, rules).
588fn 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        // Headings
619        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        // Horizontal rule
643        if trimmed == "---" || trimmed == "***" || trimmed == "___" {
644            out.push_str("\\par\\noindent\\rule{\\linewidth}{0.4pt}\\par\n");
645            i += 1;
646            continue;
647        }
648
649        // Unordered list group
650        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        // Ordered list group
662        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        // Blockquote group
677        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        // GFM table group
689        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                // Skip separator rows
697                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        // Blank line → paragraph break
715        if trimmed.is_empty() {
716            out.push('\n');
717            i += 1;
718            continue;
719        }
720
721        // Regular paragraph line
722        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
748/// Inline markdown → LaTeX (bold/italic/code/links/strikethrough), escaping the
749/// remainder.
750fn 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        // Image: ![alt](src)
763        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        // Link: [text](url)
774        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        // Inline code
786        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        // Bold
794        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        // Italic
809        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        // Strikethrough
828        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
843// ───────────────────────────────────────────────────────────────────────────
844// Escaping
845// ───────────────────────────────────────────────────────────────────────────
846
847/// Escape LaTeX special characters in a run of text.
848fn 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
872/// Escape a URL for use inside `\href{...}{}` (hyperref tolerates most chars; we
873/// guard the ones that would break the argument).
874fn 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
889/// Escape a file path for `\includegraphics{...}` (paths shouldn't contain TeX
890/// specials, but guard the common offenders).
891fn 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        // MLA in-text is author-only.
930        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}