Skip to main content

surf_parse/
render_typst.rs

1//! Typst markup renderer.
2//!
3//! Converts a `SurfDoc` block tree into valid Typst markup text. The output can
4//! be compiled by the Typst engine to produce PDF (or other formats).
5//!
6//! Markdown content within blocks is converted to Typst markup via the
7//! [`md_to_typst`] helper. All user content is escaped to prevent Typst
8//! injection.
9
10use crate::types::*;
11
12/// Base Typst template with page setup, colors, and reusable components.
13pub(crate) const SURFDOC_TEMPLATE: &str = include_str!("../assets/surfdoc.typ");
14
15// ───────────────────────────────────────────────────────────────────────────
16// Ambient image context (thread-local, mirrors citation::install_context)
17//
18// The Typst engine compiles from an in-memory source with NO filesystem or
19// network access, so a bare `image("src")` call for a doc-referenced asset
20// (e.g. `/images/{id}/file`) fails compilation. Callers that CAN supply the
21// bytes (the PDF pipeline via `PdfConfig::images`) install a src → virtual
22// file-path map here; every image emission consults it. Unresolved srcs
23// degrade to a deterministic placeholder — never an `image()` call that
24// would sink the whole compile.
25// ───────────────────────────────────────────────────────────────────────────
26
27use std::cell::RefCell;
28use std::collections::HashMap;
29
30thread_local! {
31    static ACTIVE_IMAGES: RefCell<Option<HashMap<String, String>>> = const { RefCell::new(None) };
32}
33
34/// RAII guard that clears the ambient image map when dropped.
35pub struct ImageScope {
36    _private: (),
37}
38
39impl Drop for ImageScope {
40    fn drop(&mut self) {
41        ACTIVE_IMAGES.with(|c| *c.borrow_mut() = None);
42    }
43}
44
45/// Install `map` (image src → resolvable virtual file path) as the ambient
46/// image context for the current thread for the lifetime of the returned
47/// guard. The PDF renderer installs this before [`to_typst`] after registering
48/// the mapped paths with the engine's static file resolver.
49pub fn install_image_context(map: HashMap<String, String>) -> ImageScope {
50    ACTIVE_IMAGES.with(|c| *c.borrow_mut() = Some(map));
51    ImageScope { _private: () }
52}
53
54/// The virtual file path registered for `src`, if the ambient context has one.
55fn resolved_image(src: &str) -> Option<String> {
56    ACTIVE_IMAGES.with(|c| c.borrow().as_ref().and_then(|m| m.get(src).cloned()))
57}
58
59/// Render a `SurfDoc` as a complete Typst document string.
60///
61/// The output includes the base template, front matter header, and all blocks
62/// mapped to their Typst equivalents. This string can be compiled directly by
63/// the Typst engine.
64pub fn to_typst(doc: &SurfDoc) -> String {
65    let format = doc.front_matter.as_ref().and_then(|fm| fm.format);
66    let doc_type = doc.front_matter.as_ref().and_then(|fm| fm.doc_type);
67    let _cite_scope =
68        crate::citation::install_context(crate::citation::build_context(&doc.blocks, format));
69
70    // Document-type render profile (Chunk 1) selects an academic template for
71    // papers/reports; everything else uses the generic SurfDoc layout.
72    match crate::types::render_profile(doc_type, format) {
73        RenderProfile::Paper(f) => render_paper(doc, f),
74        RenderProfile::Report(f) => render_report(doc, f),
75        _ => render_generic(doc),
76    }
77}
78
79/// Generic SurfDoc → Typst layout (the original, unchanged path).
80fn render_generic(doc: &SurfDoc) -> String {
81    let mut out = String::with_capacity(8192);
82
83    // Base template (page setup, colors, components)
84    out.push_str(SURFDOC_TEMPLATE);
85    out.push_str("\n\n");
86
87    // Front matter header
88    if let Some(ref fm) = doc.front_matter {
89        render_front_matter(fm, &mut out);
90    }
91
92    // Render each block
93    for block in &doc.blocks {
94        render_block(block, &mut out);
95        out.push('\n');
96    }
97
98    out
99}
100
101// ───────────────────────────────────────────────────────────────────────────
102// Academic templates (papers + reports) — selected by RenderProfile.
103//
104// Front-matter keys consumed:
105//   title                         — document title
106//   author / authors              — single string or YAML list; ';'-separated
107//   contributors                  — additional co-authors (typed field)
108//   date / created / updated      — publication / submission date
109//   abstract                      — abstract / summary text (papers, APA)
110//   instructor / professor        — report heading (MLA/Chicago title page)
111//   course / class                — report heading
112//   institution / affiliation /
113//     affiliations / university    — affiliation line / title-page institution
114//   running-head / running_head   — APA running head (optional)
115// ───────────────────────────────────────────────────────────────────────────
116
117/// Read the first present, non-empty value for `keys` from `extra`.
118/// Handles both scalar strings and YAML sequences (joined with `; `).
119fn fm_extra(fm: &FrontMatter, keys: &[&str]) -> Option<String> {
120    for k in keys {
121        if let Some(v) = fm.extra.get(*k) {
122            if let Some(s) = v.as_str() {
123                let t = s.trim();
124                if !t.is_empty() {
125                    return Some(t.to_string());
126                }
127            }
128            if let Some(seq) = v.as_sequence() {
129                let parts: Vec<String> = seq
130                    .iter()
131                    .filter_map(|x| x.as_str().map(|s| s.trim().to_string()))
132                    .filter(|s| !s.is_empty())
133                    .collect();
134                if !parts.is_empty() {
135                    return Some(parts.join("; "));
136                }
137            }
138        }
139    }
140    None
141}
142
143/// Document title from front matter (falls back to "Untitled").
144fn doc_title(doc: &SurfDoc) -> String {
145    doc.front_matter
146        .as_ref()
147        .and_then(|fm| fm.title.clone())
148        .filter(|t| !t.trim().is_empty())
149        .unwrap_or_else(|| "Untitled".to_string())
150}
151
152/// Ordered list of author display names (split on `;`), including contributors.
153fn doc_authors(doc: &SurfDoc) -> Vec<String> {
154    let mut names: Vec<String> = Vec::new();
155    if let Some(fm) = doc.front_matter.as_ref() {
156        let raw = fm_extra(fm, &["authors"]).or_else(|| fm.author.clone());
157        if let Some(raw) = raw {
158            for part in raw.split(';') {
159                let t = part.trim();
160                if !t.is_empty() && !names.iter().any(|n| n == t) {
161                    names.push(t.to_string());
162                }
163            }
164        }
165        if let Some(contribs) = fm.contributors.as_ref() {
166            for c in contribs {
167                let t = c.trim();
168                if !t.is_empty() && !names.iter().any(|n| n == t) {
169                    names.push(t.to_string());
170                }
171            }
172        }
173    }
174    names
175}
176
177/// Publication / submission date.
178fn doc_date(doc: &SurfDoc) -> Option<String> {
179    let fm = doc.front_matter.as_ref()?;
180    fm_extra(fm, &["date"])
181        .or_else(|| fm.created.clone())
182        .or_else(|| fm.updated.clone())
183}
184
185/// Substitute inline `[@key]` cites in a block's prose against the ambient
186/// context, returning a clone ready for `render_block`.
187fn substitute_block_cites(b: &Block) -> Block {
188    use crate::citation::substitute_text_cites as sub;
189    let mut b = b.clone();
190    match &mut b {
191        Block::Markdown { content, .. }
192        | Block::Callout { content, .. }
193        | Block::Summary { content, .. }
194        | Block::Quote { content, .. }
195        | Block::Details { content, .. } => {
196            *content = sub(content);
197        }
198        Block::Section {
199            content, children, ..
200        } => {
201            *content = sub(content);
202            for c in children.iter_mut() {
203                *c = substitute_block_cites(c);
204            }
205        }
206        Block::Page { children, .. } | Block::Slide { children, .. } => {
207            for c in children.iter_mut() {
208                *c = substitute_block_cites(c);
209            }
210        }
211        _ => {}
212    }
213    b
214}
215
216/// Render the document body for an academic template: skips metadata/cite-def
217/// blocks, substitutes inline cites, and renders `::bibliography` in the
218/// reference-list style for `style`.
219fn render_academic_body(blocks: &[Block], style: Format, out: &mut String) {
220    for b in blocks {
221        match b {
222            Block::Cite { .. }
223            | Block::Site { .. }
224            | Block::Style { .. }
225            | Block::Page { .. } => {}
226            Block::Bibliography { style: bstyle, .. } => {
227                render_academic_bibliography(bstyle.unwrap_or(style), out);
228            }
229            // Figures/charts: a src the ambient image context resolves (the
230            // PDF pipeline's `PdfConfig::images` map) renders for real; any
231            // other src degrades to a self-contained, deterministic
232            // placeholder (a numbered `#figure` / a captioned data table)
233            // instead of an `image()` call that would fail Typst compilation.
234            Block::Figure { src, caption, alt, .. } => match resolved_image(src) {
235                Some(path) => {
236                    out.push_str(&format!("#figure(\n  image(\"{}\")", escape_typst(&path)));
237                    if let Some(c) = caption.as_deref() {
238                        out.push_str(&format!(",\n  caption: [{}]", md_to_typst_inline(c)));
239                    }
240                    out.push_str("\n)\n\n");
241                    let _ = alt;
242                }
243                None => render_academic_figure(caption.as_deref(), alt.as_deref(), out),
244            },
245            Block::Chart { title, data, .. } => {
246                render_academic_chart(title.as_deref(), data.as_ref(), out);
247            }
248            _ => {
249                render_block(&substitute_block_cites(b), out);
250                out.push('\n');
251            }
252        }
253    }
254}
255
256/// A self-contained numbered figure placeholder (no external image file).
257fn render_academic_figure(caption: Option<&str>, alt: Option<&str>, out: &mut String) {
258    let label = alt.filter(|s| !s.is_empty()).unwrap_or("Figure");
259    out.push_str("#figure(\n");
260    out.push_str(&format!(
261        "  rect(width: 100%, height: 90pt, stroke: 0.5pt + luma(180), fill: luma(248), inset: 8pt)[#align(center + horizon)[#text(fill: luma(140))[{}]]]",
262        escape_typst(label)
263    ));
264    if let Some(c) = caption {
265        out.push_str(&format!(",\n  caption: [{}]", md_to_typst_inline(c)));
266    }
267    out.push_str("\n)\n\n");
268}
269
270/// A chart rendered as a captioned data table (deterministic, no SVG embed).
271fn render_academic_chart(title: Option<&str>, data: Option<&ChartData>, out: &mut String) {
272    let Some(d) = data else { return };
273    if let Some(t) = title {
274        out.push_str(&format!(
275            "#align(center)[#text(weight: \"bold\")[{}]]\n",
276            escape_typst(t)
277        ));
278    }
279    let mut headers = vec![String::new()];
280    headers.extend(d.series.iter().map(|s| s.name.clone()));
281    let rows: Vec<Vec<String>> = d
282        .categories
283        .iter()
284        .enumerate()
285        .map(|(i, cat)| {
286            let mut row = vec![cat.clone()];
287            for s in &d.series {
288                row.push(s.values.get(i).map(|v| format!("{v}")).unwrap_or_default());
289            }
290            row
291        })
292        .collect();
293    render_data_table(&headers, &rows, out);
294    out.push('\n');
295}
296
297/// Render an academic reference list with an *unnumbered* section heading
298/// (so it is excluded from the body's section numbering).
299fn render_academic_bibliography(style: Format, out: &mut String) {
300    crate::citation::with_active(|ctx| {
301        let refs = match ctx {
302            Some(c) if !c.references.is_empty() => {
303                if style == c.style {
304                    crate::citation::ordered_references(c)
305                } else {
306                    c.references.clone()
307                }
308            }
309            _ => return,
310        };
311        let heading = crate::citation::bibliography_heading(style);
312        out.push_str(&format!(
313            "\n#heading(numbering: none)[{}]\n\n",
314            escape_typst(heading)
315        ));
316        if crate::citation::is_numbered(style) {
317            for (i, line) in crate::citation::reference_list(&refs, style)
318                .iter()
319                .enumerate()
320            {
321                // Strip the leading "[n] " — Typst numbers the list itself.
322                let body = line.splitn(2, "] ").nth(1).unwrap_or(line.as_str());
323                out.push_str(&format!("{}. {}\n\n", i + 1, md_to_typst_inline(body)));
324            }
325        } else {
326            for line in crate::citation::reference_list(&refs, style) {
327                out.push_str(&md_to_typst_inline(&line));
328                out.push_str("\n\n");
329            }
330        }
331    });
332}
333
334/// Render a `paper` document (IEEE / ACM / generic article).
335fn render_paper(doc: &SurfDoc, format: Format) -> String {
336    let mut out = String::with_capacity(8192);
337    out.push_str(SURFDOC_TEMPLATE);
338    out.push_str("\n\n");
339
340    let title = doc_title(doc);
341    let authors = doc_authors(doc);
342    let affiliation = doc
343        .front_matter
344        .as_ref()
345        .and_then(|fm| fm_extra(fm, &["affiliation", "affiliations", "institution"]));
346    let abstract_text = doc
347        .front_matter
348        .as_ref()
349        .and_then(|fm| fm_extra(fm, &["abstract"]));
350
351    let two_column = matches!(format, Format::Ieee | Format::Acm);
352
353    // Page + type setup (academic overrides win over the template's defaults).
354    out.push_str("#set page(paper: \"us-letter\", margin: (x: 0.75in, y: 0.85in)");
355    if two_column {
356        out.push_str(", columns: 2");
357    }
358    out.push_str(")\n");
359    out.push_str("#set text(font: (\"Libertinus Serif\", \"Liberation Sans\"), size: 10pt, fill: rgb(\"#000000\"))\n");
360    out.push_str("#set par(justify: true, leading: 0.62em)\n");
361    out.push_str("#set heading(numbering: \"1.\")\n\n");
362
363    // Title block. For two-column papers it spans both columns via a parent-scope
364    // float; single-column just centers it.
365    let title_body = render_paper_title_block(&title, &authors, affiliation.as_deref(), abstract_text.as_deref());
366    if two_column {
367        out.push_str(&format!(
368            "#place(top + center, scope: \"parent\", float: true, clearance: 1.6em)[\n{title_body}]\n\n"
369        ));
370    } else {
371        out.push_str(&format!("{title_body}\n"));
372    }
373
374    render_academic_body(&doc.blocks, format, &mut out);
375    out
376}
377
378/// The centered title / authors / affiliation / abstract block shared by paper
379/// templates.
380fn render_paper_title_block(
381    title: &str,
382    authors: &[String],
383    affiliation: Option<&str>,
384    abstract_text: Option<&str>,
385) -> String {
386    let mut s = String::new();
387    s.push_str("  #align(center)[\n");
388    s.push_str(&format!(
389        "    #text(size: 17pt, weight: \"bold\")[{}]\n",
390        escape_typst(title)
391    ));
392    if !authors.is_empty() {
393        let names: Vec<String> = authors.iter().map(|a| escape_typst(a)).collect();
394        s.push_str(&format!(
395            "    #v(0.6em)\n    #text(size: 11pt)[{}]\n",
396            names.join(" #h(1.5em) ")
397        ));
398    }
399    if let Some(aff) = affiliation {
400        s.push_str(&format!(
401            "    #v(0.2em)\n    #text(size: 9.5pt, style: \"italic\")[{}]\n",
402            escape_typst(aff)
403        ));
404    }
405    s.push_str("  ]\n");
406    if let Some(abs) = abstract_text {
407        s.push_str("  #v(0.8em)\n");
408        s.push_str("  #block(width: 100%)[\n");
409        s.push_str(&format!(
410            "    #text(weight: \"bold\")[Abstract.] {}\n",
411            md_to_typst_inline(abs)
412        ));
413        s.push_str("  ]\n  #v(0.8em)\n");
414    }
415    s
416}
417
418/// Render a `report` document (MLA / APA / Chicago).
419fn render_report(doc: &SurfDoc, format: Format) -> String {
420    let mut out = String::with_capacity(8192);
421    out.push_str(SURFDOC_TEMPLATE);
422    out.push_str("\n\n");
423
424    let title = doc_title(doc);
425    let authors = doc_authors(doc);
426    let date = doc_date(doc);
427    let instructor = doc
428        .front_matter
429        .as_ref()
430        .and_then(|fm| fm_extra(fm, &["instructor", "professor"]));
431    let course = doc
432        .front_matter
433        .as_ref()
434        .and_then(|fm| fm_extra(fm, &["course", "class"]));
435    let institution = doc
436        .front_matter
437        .as_ref()
438        .and_then(|fm| fm_extra(fm, &["institution", "affiliation", "university"]));
439    let running_head = doc
440        .front_matter
441        .as_ref()
442        .and_then(|fm| fm_extra(fm, &["running-head", "running_head"]));
443
444    // Times-like serif, 1-inch margins, double-spaced for MLA/APA/Chicago.
445    out.push_str("#set page(paper: \"us-letter\", margin: 1in");
446    if matches!(format, Format::Apa) {
447        if let Some(rh) = &running_head {
448            out.push_str(&format!(
449                ", header: [#text(size: 10pt)[{} #h(1fr) #counter(page).display()]]",
450                escape_typst(&rh.to_uppercase())
451            ));
452        }
453    }
454    out.push_str(")\n");
455    out.push_str("#set text(font: (\"Libertinus Serif\", \"Liberation Sans\"), size: 12pt, fill: rgb(\"#000000\"))\n");
456    out.push_str("#set par(justify: false, leading: 1.5em, first-line-indent: 0.5in)\n");
457    out.push_str("#set heading(numbering: none)\n\n");
458
459    match format {
460        Format::Mla => {
461            // Top-left double-spaced heading block, then centered title.
462            for line in [
463                authors.join(", "),
464                instructor.clone().unwrap_or_default(),
465                course.clone().unwrap_or_default(),
466                date.clone().unwrap_or_default(),
467            ] {
468                if !line.is_empty() {
469                    out.push_str(&format!("{} \\\n", escape_typst(&line)));
470                }
471            }
472            out.push_str(&format!(
473                "#v(0.5em)\n#align(center)[{}]\n#v(0.5em)\n\n",
474                escape_typst(&title)
475            ));
476        }
477        _ => {
478            // APA / Chicago title page (centered, vertically spaced).
479            out.push_str("#align(center)[\n  #v(3.5in)\n");
480            out.push_str(&format!(
481                "  #text(weight: \"bold\")[{}]\n",
482                escape_typst(&title)
483            ));
484            if !authors.is_empty() {
485                out.push_str(&format!(
486                    "  #v(1em)\n  {}\n",
487                    escape_typst(&authors.join(", "))
488                ));
489            }
490            if let Some(inst) = &institution {
491                out.push_str(&format!("  #v(0.5em)\n  {}\n", escape_typst(inst)));
492            }
493            if let Some(c) = &course {
494                out.push_str(&format!("  #v(0.5em)\n  {}\n", escape_typst(c)));
495            }
496            if let Some(i) = &instructor {
497                out.push_str(&format!("  #v(0.5em)\n  {}\n", escape_typst(i)));
498            }
499            if let Some(d) = &date {
500                out.push_str(&format!("  #v(0.5em)\n  {}\n", escape_typst(d)));
501            }
502            out.push_str("]\n#pagebreak()\n");
503            out.push_str(&format!(
504                "#align(center)[#text(weight: \"bold\")[{}]]\n#v(0.5em)\n\n",
505                escape_typst(&title)
506            ));
507        }
508    }
509
510    render_academic_body(&doc.blocks, format, &mut out);
511    out
512}
513
514/// Render YAML front matter as a centered document header.
515fn render_front_matter(fm: &FrontMatter, out: &mut String) {
516    let has_title = fm.title.as_ref().is_some_and(|t| !t.is_empty());
517    let has_meta = fm.author.is_some()
518        || fm.created.is_some()
519        || fm.status.is_some()
520        || fm.tags.as_ref().is_some_and(|t| !t.is_empty());
521
522    if !has_title && !has_meta {
523        return;
524    }
525
526    out.push_str("#align(center)[\n");
527
528    if let Some(ref title) = fm.title {
529        out.push_str(&format!(
530            "  #text(size: 2em, weight: \"bold\")[{}]\n",
531            escape_typst(title)
532        ));
533    }
534
535    // Metadata line: author · date · status
536    let mut meta_parts: Vec<String> = Vec::new();
537    if let Some(ref author) = fm.author {
538        meta_parts.push(escape_typst(author));
539    }
540    if let Some(ref created) = fm.created {
541        meta_parts.push(escape_typst(created));
542    }
543    if let Some(ref status) = fm.status {
544        meta_parts.push(format!("{status:?}"));
545    }
546    if !meta_parts.is_empty() {
547        out.push_str("  #v(0.5em)\n");
548        out.push_str(&format!(
549            "  #text(fill: luma(100))[{}]\n",
550            meta_parts.join(" · ")
551        ));
552    }
553
554    // Tags
555    if let Some(ref tags) = fm.tags {
556        if !tags.is_empty() {
557            out.push_str("  #v(0.3em)\n");
558            let tag_str: Vec<String> = tags.iter().map(|t| escape_typst(t)).collect();
559            out.push_str(&format!(
560                "  #text(size: 0.9em, fill: luma(120))[{}]\n",
561                tag_str.join(" · ")
562            ));
563        }
564    }
565
566    out.push_str("]\n");
567    out.push_str("#v(1em)\n");
568    out.push_str("#line(length: 100%, stroke: 0.5pt + luma(200))\n");
569    out.push_str("#v(1em)\n\n");
570}
571
572/// Render a single block to Typst markup, appending to `out`.
573fn render_block(block: &Block, out: &mut String) {
574    match block {
575        Block::Markdown { content, .. } => {
576            out.push_str(&md_to_typst(content));
577            out.push('\n');
578        }
579
580        Block::Callout {
581            callout_type,
582            title,
583            content,
584            ..
585        } => {
586            let type_name = callout_type_str(*callout_type);
587            let title_arg = match title {
588                Some(t) => format!("\"{}\"", escape_typst(t)),
589                None => "none".to_string(),
590            };
591            out.push_str(&format!(
592                "#surfdoc-callout(\"{type_name}\", {title_arg})[\n{}\n]\n",
593                md_to_typst(content)
594            ));
595        }
596
597        Block::Data {
598            headers, rows, ..
599        } => {
600            render_data_table(headers, rows, out);
601        }
602
603        Block::Code {
604            lang, content, ..
605        } => {
606            let lang_str = match lang {
607                Some(l) if !l.is_empty() => l.as_str(),
608                _ => "",
609            };
610            out.push_str(&format!("```{}\n{}\n```\n", lang_str, content));
611        }
612
613        Block::Tasks { items, .. } => {
614            for item in items {
615                let marker = if item.done { "☑" } else { "☐" };
616                let assignee = match &item.assignee {
617                    Some(a) => format!(" #text(fill: luma(120))[\\@{}]", escape_typst(a)),
618                    None => String::new(),
619                };
620                out.push_str(&format!(
621                    "- {} {}{}\n",
622                    marker,
623                    md_to_typst_inline(&item.text),
624                    assignee
625                ));
626            }
627            out.push('\n');
628        }
629
630        Block::Decision {
631            status,
632            date,
633            deciders,
634            content,
635            ..
636        } => {
637            let status_str = decision_status_str(*status);
638            out.push_str(&format!("#surfdoc-decision-badge(\"{status_str}\")"));
639            if let Some(d) = date {
640                out.push_str(&format!(" #text(fill: luma(120))[{}]", escape_typst(d)));
641            }
642            if !deciders.is_empty() {
643                let names: Vec<String> = deciders.iter().map(|d| escape_typst(d)).collect();
644                out.push_str(&format!(
645                    " #text(fill: luma(120))[— {}]",
646                    names.join(", ")
647                ));
648            }
649            out.push('\n');
650            out.push_str(&md_to_typst(content));
651            out.push('\n');
652        }
653
654        Block::Metric {
655            label,
656            value,
657            trend,
658            unit,
659            ..
660        } => {
661            let trend_arg = match trend {
662                Some(t) => format!("\"{}\"", trend_str(*t)),
663                None => "none".to_string(),
664            };
665            let unit_arg = match unit {
666                Some(u) => format!(", unit: \"{}\"", escape_typst(u)),
667                None => String::new(),
668            };
669            out.push_str(&format!(
670                "#surfdoc-metric(\"{}\", \"{}\"{}, trend: {})\n",
671                escape_typst(label),
672                escape_typst(value),
673                unit_arg,
674                trend_arg
675            ));
676        }
677
678        Block::Summary { content, .. } => {
679            out.push_str("#block(fill: luma(245), inset: 12pt, radius: 4pt, width: 100%)[\n");
680            out.push_str("  *Summary* \\\n");
681            out.push_str(&format!("  {}\n", md_to_typst(content)));
682            out.push_str("]\n");
683        }
684
685        Block::Figure {
686            src,
687            caption,
688            alt,
689            width,
690            ..
691        } => {
692            // Only emit an `image()` call for a src the ambient image context
693            // can actually resolve to engine-registered bytes; anything else
694            // degrades to the deterministic placeholder (an unresolvable path
695            // would fail the whole Typst compile).
696            let Some(path) = resolved_image(src) else {
697                render_academic_figure(caption.as_deref(), alt.as_deref(), out);
698                return;
699            };
700            let width_attr = match width {
701                Some(w) => format!(", width: {}", w),
702                None => String::new(),
703            };
704            let alt_val = alt.as_deref().unwrap_or("");
705            out.push_str(&format!(
706                "#figure(\n  image(\"{}\"{}){}",
707                escape_typst(&path),
708                width_attr,
709                if !alt_val.is_empty() {
710                    format!(",\n  supplement: none")
711                } else {
712                    String::new()
713                }
714            ));
715            if let Some(cap) = caption {
716                out.push_str(&format!(",\n  caption: [{}]", md_to_typst_inline(cap)));
717            }
718            out.push_str("\n)\n");
719        }
720
721        Block::Quote {
722            content,
723            attribution,
724            ..
725        } => {
726            if let Some(attr) = attribution {
727                out.push_str(&format!(
728                    "#quote(attribution: [{}])[\n{}\n]\n",
729                    escape_typst(attr),
730                    md_to_typst(content)
731                ));
732            } else {
733                out.push_str(&format!(
734                    "#quote[\n{}\n]\n",
735                    md_to_typst(content)
736                ));
737            }
738        }
739
740        Block::Tabs { tabs, .. } => {
741            for tab in tabs {
742                out.push_str(&format!("=== {}\n\n", escape_typst(&tab.label)));
743                out.push_str(&md_to_typst(&tab.content));
744                out.push('\n');
745            }
746        }
747
748        Block::Columns { columns, .. } => {
749            let n = columns.len();
750            out.push_str(&format!("#grid(\n  columns: ({}),\n  gutter: 1em,\n", "1fr, ".repeat(n).trim_end_matches(", ")));
751            for col in columns {
752                out.push_str(&format!("  [\n    {}\n  ],\n", md_to_typst(&col.content)));
753            }
754            out.push_str(")\n");
755        }
756
757        Block::Section {
758            headline,
759            subtitle,
760            children,
761            content,
762            ..
763        } => {
764            if let Some(h) = headline {
765                out.push_str(&format!("== {}\n\n", escape_typst(h)));
766            }
767            if let Some(s) = subtitle {
768                out.push_str(&format!(
769                    "#text(fill: luma(100))[{}]\n\n",
770                    escape_typst(s)
771                ));
772            }
773            if children.is_empty() {
774                out.push_str(&md_to_typst(content));
775                out.push('\n');
776            } else {
777                for child in children {
778                    render_block(child, out);
779                    out.push('\n');
780                }
781            }
782        }
783
784        Block::Details {
785            title, content, ..
786        } => {
787            if let Some(t) = title {
788                out.push_str(&format!("*{}*\n\n", escape_typst(t)));
789            }
790            out.push_str(&md_to_typst(content));
791            out.push('\n');
792        }
793
794        Block::Divider { label, .. } => {
795            if let Some(l) = label {
796                out.push_str(&format!(
797                    "#v(0.5em)\n#align(center)[#text(fill: luma(150), size: 0.9em)[— {} —]]\n#v(0.5em)\n",
798                    escape_typst(l)
799                ));
800            } else {
801                out.push_str("#line(length: 100%, stroke: 0.5pt + luma(200))\n");
802            }
803        }
804
805        Block::Hero {
806            headline,
807            subtitle,
808            badge,
809            buttons,
810            content,
811            ..
812        } => {
813            out.push_str("#align(center)[\n");
814            if let Some(b) = badge {
815                out.push_str(&format!(
816                    "  #box(fill: luma(235), radius: 12pt, inset: (x: 10pt, y: 4pt))[#text(size: 0.85em)[{}]]\n  #v(0.5em)\n",
817                    escape_typst(b)
818                ));
819            }
820            if let Some(h) = headline {
821                out.push_str(&format!(
822                    "  #text(size: 2.5em, weight: \"bold\")[{}]\n",
823                    escape_typst(h)
824                ));
825            }
826            if let Some(s) = subtitle {
827                out.push_str(&format!(
828                    "  #v(0.3em)\n  #text(size: 1.2em, fill: luma(100))[{}]\n",
829                    escape_typst(s)
830                ));
831            }
832            if !buttons.is_empty() {
833                out.push_str("  #v(0.5em)\n");
834                for btn in buttons {
835                    if btn.primary {
836                        out.push_str(&format!(
837                            "  #box(fill: surfdoc-blue, radius: 4pt, inset: (x: 12pt, y: 6pt))[#link(\"{}\")[#text(fill: white, weight: \"bold\")[{}]]]\n",
838                            escape_typst(&btn.href),
839                            escape_typst(&btn.label)
840                        ));
841                    } else {
842                        out.push_str(&format!(
843                            "  #box(stroke: 1pt + luma(200), radius: 4pt, inset: (x: 12pt, y: 6pt))[#link(\"{}\")[{}]]\n",
844                            escape_typst(&btn.href),
845                            escape_typst(&btn.label)
846                        ));
847                    }
848                    out.push_str("  #h(0.5em)\n");
849                }
850            }
851            if !content.is_empty() {
852                out.push_str(&format!("  #v(0.5em)\n  {}\n", md_to_typst(content)));
853            }
854            out.push_str("]\n#v(1em)\n");
855        }
856
857        Block::Features { cards, cols, .. } => {
858            let ncols = cols.unwrap_or(3);
859            let cols_str = (0..ncols).map(|_| "1fr").collect::<Vec<_>>().join(", ");
860            out.push_str(&format!(
861                "#grid(\n  columns: ({}),\n  gutter: 1.5em,\n",
862                cols_str
863            ));
864            for card in cards {
865                out.push_str("  block(stroke: 0.5pt + luma(220), radius: 6pt, inset: 12pt, width: 100%)[\n");
866                out.push_str(&format!(
867                    "    *{}*\n",
868                    escape_typst(&card.title)
869                ));
870                if !card.body.is_empty() {
871                    out.push_str(&format!("    \\\n    {}\n", md_to_typst_inline(&card.body)));
872                }
873                if let (Some(label), Some(href)) = (&card.link_label, &card.link_href) {
874                    out.push_str(&format!(
875                        "    \\\n    #link(\"{}\")[{}]\n",
876                        escape_typst(href),
877                        escape_typst(label)
878                    ));
879                }
880                out.push_str("  ],\n");
881            }
882            out.push_str(")\n");
883        }
884
885        Block::Steps { steps, .. } => {
886            for (i, step) in steps.iter().enumerate() {
887                out.push_str(&format!(
888                    "+ *{}*",
889                    escape_typst(&step.title)
890                ));
891                if let Some(ref time) = step.time {
892                    out.push_str(&format!(
893                        " #text(fill: luma(120), size: 0.9em)[{}]",
894                        escape_typst(time)
895                    ));
896                }
897                if !step.body.is_empty() {
898                    out.push_str(&format!(" — {}", md_to_typst_inline(&step.body)));
899                }
900                out.push('\n');
901                if i < steps.len() - 1 {
902                    // spacing between steps
903                }
904            }
905            out.push('\n');
906        }
907
908        Block::Stats { items, .. } => {
909            let n = items.len();
910            let cols_str = (0..n).map(|_| "1fr").collect::<Vec<_>>().join(", ");
911            out.push_str(&format!(
912                "#grid(\n  columns: ({}),\n  gutter: 1em,\n",
913                cols_str
914            ));
915            for item in items {
916                out.push_str("  align(center)[\n");
917                out.push_str(&format!(
918                    "    #text(size: 2em, weight: \"bold\")[{}]\n    \\\n    {}\n",
919                    escape_typst(&item.value),
920                    escape_typst(&item.label)
921                ));
922                out.push_str("  ],\n");
923            }
924            out.push_str(")\n");
925        }
926
927        Block::Comparison {
928            headers,
929            rows,
930            highlight,
931            ..
932        } => {
933            render_comparison_table(headers, rows, highlight.as_deref(), out);
934        }
935
936        Block::Cta {
937            label, href, primary, ..
938        } => {
939            if *primary {
940                out.push_str(&format!(
941                    "#align(center)[#box(fill: surfdoc-blue, radius: 4pt, inset: (x: 14pt, y: 8pt))[#link(\"{}\")[#text(fill: white, weight: \"bold\")[{}]]]]\n",
942                    escape_typst(href),
943                    escape_typst(label)
944                ));
945            } else {
946                out.push_str(&format!(
947                    "#align(center)[#box(stroke: 1pt + surfdoc-blue, radius: 4pt, inset: (x: 14pt, y: 8pt))[#link(\"{}\")[#text(fill: surfdoc-blue)[{}]]]]\n",
948                    escape_typst(href),
949                    escape_typst(label)
950                ));
951            }
952        }
953
954        Block::Nav { items, .. } => {
955            let links: Vec<String> = items
956                .iter()
957                .map(|item| {
958                    format!(
959                        "#link(\"{}\")[{}]",
960                        escape_typst(&item.href),
961                        escape_typst(&item.label)
962                    )
963                })
964                .collect();
965            out.push_str(&format!(
966                "#text(size: 0.9em)[{}]\n",
967                links.join(" #h(1em) ")
968            ));
969        }
970
971        Block::Form { fields, .. } => {
972            out.push_str("#block(stroke: 0.5pt + luma(200), radius: 4pt, inset: 12pt, width: 100%)[\n");
973            out.push_str("  *Form Fields*\n");
974            for field in fields {
975                let required = if field.required { " \\*" } else { "" };
976                out.push_str(&format!(
977                    "  - *{}*{} #text(fill: luma(120))[({:?})]\n",
978                    escape_typst(&field.label),
979                    required,
980                    field.field_type
981                ));
982            }
983            out.push_str("]\n");
984        }
985
986        Block::Gallery { items, columns, .. } => {
987            let ncols = columns.unwrap_or(3);
988            let cols_str = (0..ncols).map(|_| "1fr").collect::<Vec<_>>().join(", ");
989            out.push_str(&format!(
990                "#grid(\n  columns: ({}),\n  gutter: 1em,\n",
991                cols_str
992            ));
993            for item in items {
994                // Resolved srcs render for real; anything else gets the same
995                // placeholder box the figure path uses (a bare `image()` call
996                // on an unregistered path fails the whole compile).
997                match resolved_image(&item.src) {
998                    Some(path) => out.push_str(&format!(
999                        "  figure(\n    image(\"{}\"),\n",
1000                        escape_typst(&path)
1001                    )),
1002                    None => out.push_str(
1003                        "  figure(\n    rect(width: 100%, height: 60pt, stroke: 0.5pt + luma(180), fill: luma(248)),\n",
1004                    ),
1005                }
1006                if let Some(ref cap) = item.caption {
1007                    out.push_str(&format!("    caption: [{}],\n", escape_typst(cap)));
1008                }
1009                out.push_str("  ),\n");
1010            }
1011            out.push_str(")\n");
1012        }
1013
1014        Block::Footer {
1015            sections,
1016            copyright,
1017            social,
1018            ..
1019        } => {
1020            out.push_str("#line(length: 100%, stroke: 0.5pt + luma(200))\n#v(0.5em)\n");
1021            if !sections.is_empty() {
1022                let n = sections.len();
1023                let cols_str = (0..n).map(|_| "1fr").collect::<Vec<_>>().join(", ");
1024                out.push_str(&format!(
1025                    "#grid(\n  columns: ({}),\n  gutter: 1em,\n",
1026                    cols_str
1027                ));
1028                for section in sections {
1029                    out.push_str(&format!(
1030                        "  [\n    *{}*\n",
1031                        escape_typst(&section.heading)
1032                    ));
1033                    for link in &section.links {
1034                        out.push_str(&format!(
1035                            "    - #link(\"{}\")[{}]\n",
1036                            escape_typst(&link.href),
1037                            escape_typst(&link.label)
1038                        ));
1039                    }
1040                    out.push_str("  ],\n");
1041                }
1042                out.push_str(")\n");
1043            }
1044            if let Some(cr) = copyright {
1045                out.push_str(&format!(
1046                    "#v(0.5em)\n#align(center)[#text(size: 0.85em, fill: luma(120))[{}]]\n",
1047                    escape_typst(cr)
1048                ));
1049            }
1050            if !social.is_empty() {
1051                let links: Vec<String> = social
1052                    .iter()
1053                    .map(|s| {
1054                        format!(
1055                            "#link(\"{}\")[{}]",
1056                            escape_typst(&s.href),
1057                            escape_typst(&s.platform)
1058                        )
1059                    })
1060                    .collect();
1061                out.push_str(&format!(
1062                    "#align(center)[#text(size: 0.85em)[{}]]\n",
1063                    links.join(" #h(0.5em) ")
1064                ));
1065            }
1066        }
1067
1068        Block::Testimonial {
1069            content,
1070            author,
1071            role,
1072            company,
1073            ..
1074        } => {
1075            let mut attr_parts: Vec<String> = Vec::new();
1076            if let Some(a) = author {
1077                attr_parts.push(escape_typst(a));
1078            }
1079            if let Some(r) = role {
1080                attr_parts.push(escape_typst(r));
1081            }
1082            if let Some(c) = company {
1083                attr_parts.push(escape_typst(c));
1084            }
1085            if attr_parts.is_empty() {
1086                out.push_str(&format!(
1087                    "#quote[\n{}\n]\n",
1088                    md_to_typst(content)
1089                ));
1090            } else {
1091                out.push_str(&format!(
1092                    "#quote(attribution: [{}])[\n{}\n]\n",
1093                    attr_parts.join(", "),
1094                    md_to_typst(content)
1095                ));
1096            }
1097        }
1098
1099        Block::PricingTable {
1100            headers, rows, ..
1101        } => {
1102            render_data_table(headers, rows, out);
1103        }
1104
1105        Block::Faq { items, .. } => {
1106            for item in items {
1107                out.push_str(&format!(
1108                    "*Q: {}*\n\n{}\n\n",
1109                    escape_typst(&item.question),
1110                    md_to_typst(&item.answer)
1111                ));
1112            }
1113        }
1114
1115        Block::Embed {
1116            src, title, ..
1117        } => {
1118            let label = title
1119                .as_deref()
1120                .unwrap_or(src.as_str());
1121            out.push_str(&format!(
1122                "#text(fill: luma(120))[\\[Embedded: #link(\"{}\")[{}]\\]]\n",
1123                escape_typst(src),
1124                escape_typst(label)
1125            ));
1126        }
1127
1128        Block::BeforeAfter {
1129            before_items,
1130            after_items,
1131            transition,
1132            ..
1133        } => {
1134            out.push_str("*Before:*\n");
1135            for item in before_items {
1136                out.push_str(&format!(
1137                    "- *{}* — {}\n",
1138                    escape_typst(&item.label),
1139                    escape_typst(&item.detail)
1140                ));
1141            }
1142            let arrow = transition.as_deref().unwrap_or("↓");
1143            out.push_str(&format!("\n#align(center)[#text(size: 1.5em)[{}]]\n\n", escape_typst(arrow)));
1144            out.push_str("*After:*\n");
1145            for item in after_items {
1146                out.push_str(&format!(
1147                    "- *{}* — {}\n",
1148                    escape_typst(&item.label),
1149                    escape_typst(&item.detail)
1150                ));
1151            }
1152            out.push('\n');
1153        }
1154
1155        Block::Pipeline { steps, .. } => {
1156            let labels: Vec<String> = steps
1157                .iter()
1158                .map(|s| {
1159                    let desc = match &s.description {
1160                        Some(d) => format!(" #text(fill: luma(120), size: 0.9em)[{}]", escape_typst(d)),
1161                        None => String::new(),
1162                    };
1163                    format!("*{}*{}", escape_typst(&s.label), desc)
1164                })
1165                .collect();
1166            out.push_str(&format!(
1167                "#align(center)[{}]\n",
1168                labels.join(" #h(0.3em) → #h(0.3em) ")
1169            ));
1170        }
1171
1172        Block::ProductCard {
1173            title,
1174            subtitle,
1175            badge,
1176            body,
1177            features,
1178            cta_label,
1179            cta_href,
1180            ..
1181        } => {
1182            out.push_str("#block(stroke: 0.5pt + luma(200), radius: 6pt, inset: 14pt, width: 100%)[\n");
1183            if let Some(b) = badge {
1184                out.push_str(&format!(
1185                    "  #box(fill: surfdoc-blue, radius: 2pt, inset: (x: 6pt, y: 2pt))[#text(fill: white, size: 0.8em)[{}]]\n  #v(0.3em)\n",
1186                    escape_typst(b)
1187                ));
1188            }
1189            out.push_str(&format!(
1190                "  #text(size: 1.3em, weight: \"bold\")[{}]\n",
1191                escape_typst(title)
1192            ));
1193            if let Some(s) = subtitle {
1194                out.push_str(&format!(
1195                    "  \\\n  #text(fill: luma(100))[{}]\n",
1196                    escape_typst(s)
1197                ));
1198            }
1199            if !body.is_empty() {
1200                out.push_str(&format!("  \\\n  {}\n", md_to_typst_inline(body)));
1201            }
1202            if !features.is_empty() {
1203                out.push_str("  \\\n");
1204                for f in features {
1205                    out.push_str(&format!("  - {}\n", escape_typst(f)));
1206                }
1207            }
1208            if let (Some(label), Some(href)) = (cta_label, cta_href) {
1209                out.push_str(&format!(
1210                    "  #v(0.5em)\n  #link(\"{}\")[#text(fill: surfdoc-blue, weight: \"bold\")[{}]]\n",
1211                    escape_typst(href),
1212                    escape_typst(label)
1213                ));
1214            }
1215            out.push_str("]\n");
1216        }
1217
1218        Block::Logo { src, alt, size, .. } => {
1219            let width = size.map_or("60pt".to_string(), |s| format!("{s}pt"));
1220            match resolved_image(src) {
1221                Some(path) => out.push_str(&format!(
1222                    "#align(center)[#image(\"{}\", width: {})]\n",
1223                    escape_typst(&path),
1224                    width
1225                )),
1226                // Unresolvable logo: a centered muted label, never an
1227                // `image()` call that would fail the compile.
1228                None => out.push_str(&format!(
1229                    "#align(center)[#text(fill: luma(140))[{}]]\n",
1230                    escape_typst(alt.as_deref().filter(|s| !s.is_empty()).unwrap_or("Logo"))
1231                )),
1232            }
1233        }
1234
1235        Block::Toc { depth, .. } => {
1236            out.push_str(&format!("#outline(depth: {})\n", depth));
1237        }
1238
1239        Block::HeroImage { src, alt, .. } => {
1240            match resolved_image(src) {
1241                Some(path) => out.push_str(&format!(
1242                    "#image(\"{}\", width: 100%)\n",
1243                    escape_typst(&path)
1244                )),
1245                // Unresolvable hero: the figure placeholder box (alt as label).
1246                None => render_academic_figure(None, alt.as_deref(), out),
1247            }
1248        }
1249
1250        // Metadata blocks — skip (no visual representation in PDF)
1251        Block::Site { .. } | Block::Style { .. } | Block::Page { .. } => {}
1252
1253        // App description blocks — descriptive placeholders in PDF
1254        Block::List { source, .. } => {
1255            out.push_str(&format!(
1256                "#block(stroke: 0.5pt + luma(180), radius: 4pt, inset: 10pt, width: 100%)[\n  #text(fill: luma(120))[Data List — source: {}]\n]\n",
1257                escape_typst(source)
1258            ));
1259        }
1260        Block::Board { columns, .. } => {
1261            let cols = columns.join(", ");
1262            out.push_str(&format!(
1263                "#block(stroke: 0.5pt + luma(180), radius: 4pt, inset: 10pt, width: 100%)[\n  #text(fill: luma(120))[Board — columns: {}]\n]\n",
1264                escape_typst(&cols)
1265            ));
1266        }
1267        Block::Action { label, .. } => {
1268            out.push_str(&format!(
1269                "#block(stroke: 0.5pt + luma(180), radius: 4pt, inset: 10pt, width: 100%)[\n  #text(fill: luma(120))[Action: {}]\n]\n",
1270                escape_typst(label)
1271            ));
1272        }
1273        Block::FilterBar { .. } | Block::Search { .. } | Block::Dashboard { .. }
1274        | Block::ChatInput { .. } | Block::Feed { .. } | Block::Editor { .. }
1275        | Block::Chart { .. } | Block::SplitPane { .. } => {
1276            // Interactive widgets — no meaningful PDF representation
1277        }
1278
1279        Block::Diagram { title, content, .. } => {
1280            // No vector rendering in PDF yet — emit the bold title plus the
1281            // raw DSL in a raw block so diagrams aren't silently dropped.
1282            if let Some(t) = title {
1283                out.push_str(&format!("*{}* \\\n", escape_typst(t)));
1284            }
1285            out.push_str(&format!("```\n{}\n```\n", content));
1286        }
1287
1288        Block::Unknown { name, content, .. } => {
1289            out.push_str(&format!(
1290                "#block(stroke: 0.5pt + luma(180), radius: 4pt, inset: 10pt, width: 100%)[\n  #text(fill: luma(120), size: 0.9em)[Unknown block: {}]\n  \\\n  {}\n]\n",
1291                escape_typst(name),
1292                md_to_typst(content)
1293            ));
1294        }
1295
1296        // `::cite` is a definition only — renders nothing.
1297        Block::Cite { .. } => {}
1298
1299        Block::Bibliography { style, .. } => {
1300            let style = *style;
1301            crate::citation::with_active(|ctx| {
1302                let Some(ctx) = ctx else { return };
1303                if ctx.references.is_empty() {
1304                    return;
1305                }
1306                let style = style.unwrap_or(ctx.style);
1307                let refs = if style == ctx.style {
1308                    crate::citation::ordered_references(ctx)
1309                } else {
1310                    ctx.references.clone()
1311                };
1312                out.push_str(&format!(
1313                    "\n== {}\n\n",
1314                    escape_typst(crate::citation::bibliography_heading(style))
1315                ));
1316                for line in crate::citation::reference_list(&refs, style) {
1317                    out.push_str(&md_to_typst(&line));
1318                    out.push_str(" \\\n");
1319                }
1320                out.push('\n');
1321            });
1322        }
1323
1324        // Catch-all for newly added block types not yet rendered
1325        _ => {}
1326    }
1327}
1328
1329/// Render a data table (used by Data, PricingTable).
1330fn render_data_table(headers: &[String], rows: &[Vec<String>], out: &mut String) {
1331    let ncols = if !headers.is_empty() {
1332        headers.len()
1333    } else if let Some(first) = rows.first() {
1334        first.len()
1335    } else {
1336        return;
1337    };
1338
1339    out.push_str(&format!("#table(\n  columns: {},\n", ncols));
1340
1341    // Headers
1342    if !headers.is_empty() {
1343        for h in headers {
1344            out.push_str(&format!("  [*{}*],\n", escape_typst(h)));
1345        }
1346    }
1347
1348    // Rows
1349    for row in rows {
1350        for cell in row {
1351            out.push_str(&format!("  [{}],\n", md_to_typst_inline(cell)));
1352        }
1353    }
1354
1355    out.push_str(")\n");
1356}
1357
1358/// Render a comparison table with green/red markers for yes/no cells.
1359fn render_comparison_table(
1360    headers: &[String],
1361    rows: &[Vec<String>],
1362    highlight: Option<&str>,
1363    out: &mut String,
1364) {
1365    let ncols = if !headers.is_empty() {
1366        headers.len()
1367    } else if let Some(first) = rows.first() {
1368        first.len()
1369    } else {
1370        return;
1371    };
1372
1373    out.push_str(&format!("#table(\n  columns: {},\n", ncols));
1374
1375    // Headers (with optional highlight column)
1376    if !headers.is_empty() {
1377        for h in headers {
1378            let is_highlight = highlight.is_some_and(|hl| hl == h);
1379            if is_highlight {
1380                out.push_str(&format!(
1381                    "  [*#text(fill: surfdoc-blue)[{}]*],\n",
1382                    escape_typst(h)
1383                ));
1384            } else {
1385                out.push_str(&format!("  [*{}*],\n", escape_typst(h)));
1386            }
1387        }
1388    }
1389
1390    // Rows with yes/no marker conversion
1391    for row in rows {
1392        for cell in row {
1393            let trimmed = cell.trim().to_lowercase();
1394            match trimmed.as_str() {
1395                "yes" | "true" | "✓" | "✔" | "check" => {
1396                    out.push_str("  [#text(fill: surfdoc-green)[●]],\n");
1397                }
1398                "no" | "false" | "✗" | "✘" | "cross" | "-" | "—" => {
1399                    out.push_str("  [#text(fill: luma(200))[●]],\n");
1400                }
1401                _ => {
1402                    out.push_str(&format!("  [{}],\n", md_to_typst_inline(cell)));
1403                }
1404            }
1405        }
1406    }
1407
1408    out.push_str(")\n");
1409}
1410
1411// --- Markdown to Typst conversion ---
1412
1413/// Convert a markdown string to Typst markup (block-level).
1414///
1415/// Handles headings, bold, italic, links, images, code, lists, and tables.
1416pub fn md_to_typst(md: &str) -> String {
1417    let mut out = String::with_capacity(md.len());
1418    let mut in_code_block = false;
1419    let mut code_lang = String::new();
1420    let mut code_content = String::new();
1421    // Buffered GFM table rows; emitted as one `#table` when the run ends.
1422    let mut table_buf: Vec<Vec<String>> = Vec::new();
1423    let mut table_has_header = false;
1424
1425    for line in md.lines() {
1426        let table_trimmed = line.trim();
1427        let is_table_row = !in_code_block
1428            && table_trimmed.len() > 1
1429            && table_trimmed.starts_with('|')
1430            && table_trimmed.ends_with('|');
1431        if !is_table_row {
1432            flush_md_table(&mut out, &mut table_buf, &mut table_has_header);
1433        }
1434
1435        // Handle fenced code blocks
1436        if line.starts_with("```") {
1437            if in_code_block {
1438                // End code block
1439                out.push_str(&format!(
1440                    "```{}\n{}\n```\n",
1441                    code_lang,
1442                    code_content.trim_end()
1443                ));
1444                in_code_block = false;
1445                code_lang.clear();
1446                code_content.clear();
1447                continue;
1448            } else {
1449                // Start code block
1450                in_code_block = true;
1451                code_lang = line[3..].trim().to_string();
1452                continue;
1453            }
1454        }
1455
1456        if in_code_block {
1457            code_content.push_str(line);
1458            code_content.push('\n');
1459            continue;
1460        }
1461
1462        // Headings
1463        if let Some(rest) = line.strip_prefix("# ") {
1464            out.push_str(&format!("= {}\n", md_to_typst_inline(rest)));
1465            continue;
1466        }
1467        if let Some(rest) = line.strip_prefix("## ") {
1468            out.push_str(&format!("== {}\n", md_to_typst_inline(rest)));
1469            continue;
1470        }
1471        if let Some(rest) = line.strip_prefix("### ") {
1472            out.push_str(&format!("=== {}\n", md_to_typst_inline(rest)));
1473            continue;
1474        }
1475        if let Some(rest) = line.strip_prefix("#### ") {
1476            out.push_str(&format!("==== {}\n", md_to_typst_inline(rest)));
1477            continue;
1478        }
1479        if let Some(rest) = line.strip_prefix("##### ") {
1480            out.push_str(&format!("===== {}\n", md_to_typst_inline(rest)));
1481            continue;
1482        }
1483        if let Some(rest) = line.strip_prefix("###### ") {
1484            out.push_str(&format!("====== {}\n", md_to_typst_inline(rest)));
1485            continue;
1486        }
1487
1488        // Horizontal rule
1489        let trimmed = line.trim();
1490        if trimmed == "---" || trimmed == "***" || trimmed == "___" {
1491            out.push_str("#line(length: 100%, stroke: 0.5pt + luma(200))\n");
1492            continue;
1493        }
1494
1495        // Unordered list items
1496        if let Some(rest) = line.strip_prefix("- ") {
1497            out.push_str(&format!("- {}\n", md_to_typst_inline(rest)));
1498            continue;
1499        }
1500        if let Some(rest) = line.strip_prefix("* ") {
1501            out.push_str(&format!("- {}\n", md_to_typst_inline(rest)));
1502            continue;
1503        }
1504        // Nested list items (2-space or 4-space indent)
1505        if let Some(rest) = line.strip_prefix("  - ") {
1506            out.push_str(&format!("  - {}\n", md_to_typst_inline(rest)));
1507            continue;
1508        }
1509        if let Some(rest) = line.strip_prefix("    - ") {
1510            out.push_str(&format!("    - {}\n", md_to_typst_inline(rest)));
1511            continue;
1512        }
1513
1514        // Ordered list items
1515        if let Some((num_str, rest)) = split_ordered_list(line) {
1516            out.push_str(&format!("{}. {}\n", num_str, md_to_typst_inline(rest)));
1517            continue;
1518        }
1519
1520        // Block quotes
1521        if let Some(rest) = line.strip_prefix("> ") {
1522            out.push_str(&format!("#quote[{}]\n", md_to_typst_inline(rest)));
1523            continue;
1524        }
1525        if trimmed == ">" {
1526            // Empty blockquote line
1527            continue;
1528        }
1529
1530        // GFM table rows — buffer the run; flushed as a real `#table` by the
1531        // top-of-loop flush when a non-table line (or end of input) follows.
1532        if is_table_row {
1533            // Separator row (|---|---|) marks the buffered first row as header
1534            if trimmed.chars().all(|c| c == '|' || c == '-' || c == ':' || c == ' ') {
1535                if table_buf.len() == 1 {
1536                    table_has_header = true;
1537                }
1538                continue;
1539            }
1540            let cells: Vec<String> = trimmed
1541                .trim_matches('|')
1542                .split('|')
1543                .map(|c| c.trim().to_string())
1544                .collect();
1545            table_buf.push(cells);
1546            continue;
1547        }
1548
1549        // Empty line
1550        if trimmed.is_empty() {
1551            out.push('\n');
1552            continue;
1553        }
1554
1555        // Regular paragraph
1556        out.push_str(&md_to_typst_inline(line));
1557        out.push('\n');
1558    }
1559
1560    flush_md_table(&mut out, &mut table_buf, &mut table_has_header);
1561
1562    // Close unclosed code block
1563    if in_code_block {
1564        out.push_str(&format!(
1565            "```{}\n{}\n```\n",
1566            code_lang,
1567            code_content.trim_end()
1568        ));
1569    }
1570
1571    out
1572}
1573
1574/// Flush buffered GFM table rows as a `#table` call (no-op when empty).
1575/// Ragged rows are padded to the widest row so Typst's column count holds.
1576fn flush_md_table(out: &mut String, buf: &mut Vec<Vec<String>>, has_header: &mut bool) {
1577    if buf.is_empty() {
1578        *has_header = false;
1579        return;
1580    }
1581    let ncols = buf.iter().map(|r| r.len()).max().unwrap_or(0);
1582    if ncols == 0 {
1583        buf.clear();
1584        *has_header = false;
1585        return;
1586    }
1587    out.push_str(&format!("#table(\n  columns: {},\n", ncols));
1588    for (i, row) in buf.iter().enumerate() {
1589        for c in 0..ncols {
1590            let cell = row.get(c).map(String::as_str).unwrap_or("");
1591            let converted = md_to_typst_inline(cell);
1592            if i == 0 && *has_header {
1593                out.push_str(&format!("  [*{}*],\n", converted));
1594            } else {
1595                out.push_str(&format!("  [{}],\n", converted));
1596            }
1597        }
1598    }
1599    out.push_str(")\n");
1600    buf.clear();
1601    *has_header = false;
1602}
1603
1604/// Convert inline markdown to Typst inline markup.
1605///
1606/// Handles: `**bold**` → `*bold*`, `*italic*` → `_italic_`,
1607/// `` `code` `` → `` `code` ``, `[text](url)` → `#link("url")[text]`,
1608/// `![alt](src)` → `#image("src")`.
1609pub fn md_to_typst_inline(text: &str) -> String {
1610    let mut out = String::with_capacity(text.len());
1611    let chars: Vec<char> = text.chars().collect();
1612    let len = chars.len();
1613    let mut i = 0;
1614
1615    while i < len {
1616        // Escape backslash sequences
1617        if chars[i] == '\\' && i + 1 < len {
1618            out.push(chars[i + 1]);
1619            i += 2;
1620            continue;
1621        }
1622
1623        // Image: ![alt](src) — only a src the ambient image context resolves
1624        // becomes an `image()` call; anything else degrades to a muted alt
1625        // label (an unregistered path would fail the whole Typst compile).
1626        if chars[i] == '!' && i + 1 < len && chars[i + 1] == '[' {
1627            if let Some((alt, src, end)) = parse_link(&chars, i + 1) {
1628                match resolved_image(&src) {
1629                    Some(path) => {
1630                        out.push_str(&format!("#image(\"{}\")", escape_typst(&path)));
1631                    }
1632                    None => {
1633                        let label = if alt.trim().is_empty() { "image" } else { alt.trim() };
1634                        out.push_str(&format!(
1635                            "#text(fill: luma(140), style: \"italic\")[[{}]]",
1636                            escape_typst(label)
1637                        ));
1638                    }
1639                }
1640                i = end;
1641                continue;
1642            }
1643        }
1644
1645        // Link: [text](url)
1646        if chars[i] == '[' {
1647            if let Some((text_content, href, end)) = parse_link(&chars, i) {
1648                out.push_str(&format!(
1649                    "#link(\"{}\")[{}]",
1650                    escape_typst(&href),
1651                    escape_typst(&text_content)
1652                ));
1653                i = end;
1654                continue;
1655            }
1656        }
1657
1658        // Inline code: `code`
1659        if chars[i] == '`' {
1660            if let Some((code, end)) = parse_backtick_code(&chars, i) {
1661                out.push('`');
1662                out.push_str(&code);
1663                out.push('`');
1664                i = end;
1665                continue;
1666            }
1667        }
1668
1669        // Bold: **text** or __text__
1670        if i + 1 < len && chars[i] == '*' && chars[i + 1] == '*' {
1671            if let Some((content, end)) = parse_delimited(&chars, i, "**") {
1672                out.push('*');
1673                out.push_str(&md_to_typst_inline(&content));
1674                out.push('*');
1675                i = end;
1676                continue;
1677            }
1678        }
1679        if i + 1 < len && chars[i] == '_' && chars[i + 1] == '_' {
1680            if let Some((content, end)) = parse_delimited(&chars, i, "__") {
1681                out.push('*');
1682                out.push_str(&md_to_typst_inline(&content));
1683                out.push('*');
1684                i = end;
1685                continue;
1686            }
1687        }
1688
1689        // Italic: *text* or _text_
1690        if chars[i] == '*' && (i + 1 < len && chars[i + 1] != '*') {
1691            // Only treat as italic opener if left-flanking: next char is not whitespace
1692            let next_not_space = i + 1 < len && !chars[i + 1].is_whitespace();
1693            if next_not_space {
1694                if let Some((content, end)) = parse_delimited(&chars, i, "*") {
1695                    out.push('_');
1696                    out.push_str(&md_to_typst_inline(&content));
1697                    out.push('_');
1698                    i = end;
1699                    continue;
1700                }
1701            }
1702        }
1703        if chars[i] == '_' && (i + 1 < len && chars[i + 1] != '_') {
1704            // Only treat as italic if not in middle of word
1705            let prev_space = i == 0 || !chars[i - 1].is_alphanumeric();
1706            if prev_space {
1707                if let Some((content, end)) = parse_delimited(&chars, i, "_") {
1708                    out.push('_');
1709                    out.push_str(&md_to_typst_inline(&content));
1710                    out.push('_');
1711                    i = end;
1712                    continue;
1713                }
1714            }
1715        }
1716
1717        // Strikethrough: ~~text~~
1718        if i + 1 < len && chars[i] == '~' && chars[i + 1] == '~' {
1719            if let Some((content, end)) = parse_delimited(&chars, i, "~~") {
1720                out.push_str(&format!("#strike[{}]", md_to_typst_inline(&content)));
1721                i = end;
1722                continue;
1723            }
1724        }
1725
1726        // Escape Typst special characters that aren't part of markup
1727        match chars[i] {
1728            '#' => out.push_str("\\#"),
1729            '@' => out.push_str("\\@"),
1730            '<' => out.push_str("\\<"),
1731            '>' => out.push_str("\\>"),
1732            '$' => out.push_str("\\$"),
1733            '*' => out.push_str("\\*"),
1734            '_' => out.push_str("\\_"),
1735            c => out.push(c),
1736        }
1737
1738        i += 1;
1739    }
1740
1741    out
1742}
1743
1744/// Parse a markdown link starting at `[` position.
1745/// Returns (text, href, end_index) where end_index is past the closing `)`.
1746pub(crate) fn parse_link(chars: &[char], start: usize) -> Option<(String, String, usize)> {
1747    if start >= chars.len() || chars[start] != '[' {
1748        return None;
1749    }
1750
1751    // Find closing ]
1752    let mut depth = 0;
1753    let mut i = start;
1754    let mut text_end = None;
1755    while i < chars.len() {
1756        match chars[i] {
1757            '[' => depth += 1,
1758            ']' => {
1759                depth -= 1;
1760                if depth == 0 {
1761                    text_end = Some(i);
1762                    break;
1763                }
1764            }
1765            _ => {}
1766        }
1767        i += 1;
1768    }
1769
1770    let text_end = text_end?;
1771    let text: String = chars[start + 1..text_end].iter().collect();
1772
1773    // Expect ( immediately after ]
1774    if text_end + 1 >= chars.len() || chars[text_end + 1] != '(' {
1775        return None;
1776    }
1777
1778    // Find closing )
1779    let href_start = text_end + 2;
1780    let mut paren_depth = 1;
1781    let mut j = href_start;
1782    while j < chars.len() {
1783        match chars[j] {
1784            '(' => paren_depth += 1,
1785            ')' => {
1786                paren_depth -= 1;
1787                if paren_depth == 0 {
1788                    let href: String = chars[href_start..j].iter().collect();
1789                    return Some((text, href, j + 1));
1790                }
1791            }
1792            _ => {}
1793        }
1794        j += 1;
1795    }
1796
1797    None
1798}
1799
1800/// Parse backtick-delimited inline code.
1801pub(crate) fn parse_backtick_code(chars: &[char], start: usize) -> Option<(String, usize)> {
1802    if start >= chars.len() || chars[start] != '`' {
1803        return None;
1804    }
1805
1806    let mut i = start + 1;
1807    while i < chars.len() {
1808        if chars[i] == '`' {
1809            let code: String = chars[start + 1..i].iter().collect();
1810            return Some((code, i + 1));
1811        }
1812        i += 1;
1813    }
1814
1815    None
1816}
1817
1818/// Parse content delimited by a marker (e.g., `**`, `*`, `~~`).
1819pub(crate) fn parse_delimited(chars: &[char], start: usize, delim: &str) -> Option<(String, usize)> {
1820    let delim_chars: Vec<char> = delim.chars().collect();
1821    let dlen = delim_chars.len();
1822
1823    if start + dlen > chars.len() {
1824        return None;
1825    }
1826
1827    // Verify opening delimiter
1828    for (k, dc) in delim_chars.iter().enumerate() {
1829        if chars[start + k] != *dc {
1830            return None;
1831        }
1832    }
1833
1834    let content_start = start + dlen;
1835    let mut i = content_start;
1836
1837    while i + dlen <= chars.len() {
1838        // Check for closing delimiter
1839        let mut found = true;
1840        for (k, dc) in delim_chars.iter().enumerate() {
1841            if chars[i + k] != *dc {
1842                found = false;
1843                break;
1844            }
1845        }
1846        if found && i > content_start {
1847            let content: String = chars[content_start..i].iter().collect();
1848            return Some((content, i + dlen));
1849        }
1850        i += 1;
1851    }
1852
1853    None
1854}
1855
1856/// Try to parse a line as an ordered list item. Returns (number, rest).
1857pub(crate) fn split_ordered_list(line: &str) -> Option<(&str, &str)> {
1858    let bytes = line.as_bytes();
1859    let mut i = 0;
1860    // Skip leading whitespace
1861    while i < bytes.len() && bytes[i] == b' ' {
1862        i += 1;
1863    }
1864    let num_start = i;
1865    // Collect digits
1866    while i < bytes.len() && bytes[i].is_ascii_digit() {
1867        i += 1;
1868    }
1869    if i == num_start || i >= bytes.len() {
1870        return None;
1871    }
1872    // Expect `. ` after digits
1873    if bytes[i] == b'.' && i + 1 < bytes.len() && bytes[i + 1] == b' ' {
1874        let num = &line[num_start..i];
1875        let rest = &line[i + 2..];
1876        Some((num, rest))
1877    } else {
1878        None
1879    }
1880}
1881
1882// --- Helpers ---
1883
1884/// Escape Typst special characters in a string.
1885fn escape_typst(s: &str) -> String {
1886    let mut out = String::with_capacity(s.len());
1887    for c in s.chars() {
1888        match c {
1889            '#' => out.push_str("\\#"),
1890            '@' => out.push_str("\\@"),
1891            '<' => out.push_str("\\<"),
1892            '>' => out.push_str("\\>"),
1893            '$' => out.push_str("\\$"),
1894            '*' => out.push_str("\\*"),
1895            '_' => out.push_str("\\_"),
1896            '\\' => out.push_str("\\\\"),
1897            '"' if false => out.push_str("\\\""), // Only inside string literals
1898            _ => out.push(c),
1899        }
1900    }
1901    out
1902}
1903
1904fn callout_type_str(ct: CalloutType) -> &'static str {
1905    match ct {
1906        CalloutType::Info => "info",
1907        CalloutType::Warning => "warning",
1908        CalloutType::Danger => "danger",
1909        CalloutType::Tip => "tip",
1910        CalloutType::Note => "note",
1911        CalloutType::Success => "success",
1912        CalloutType::Context => "context",
1913    }
1914}
1915
1916fn decision_status_str(ds: DecisionStatus) -> &'static str {
1917    match ds {
1918        DecisionStatus::Proposed => "proposed",
1919        DecisionStatus::Accepted => "accepted",
1920        DecisionStatus::Rejected => "rejected",
1921        DecisionStatus::Superseded => "superseded",
1922    }
1923}
1924
1925fn trend_str(t: Trend) -> &'static str {
1926    match t {
1927        Trend::Up => "up",
1928        Trend::Down => "down",
1929        Trend::Flat => "flat",
1930    }
1931}
1932
1933#[cfg(test)]
1934mod tests {
1935    use super::*;
1936
1937    #[test]
1938    fn escape_typst_special_chars() {
1939        assert_eq!(escape_typst("hello #world"), "hello \\#world");
1940        assert_eq!(escape_typst("a@b"), "a\\@b");
1941        assert_eq!(escape_typst("x < y > z"), "x \\< y \\> z");
1942        assert_eq!(escape_typst("cost: $10"), "cost: \\$10");
1943        assert_eq!(escape_typst("A* search"), "A\\* search");
1944        assert_eq!(escape_typst("snake_case"), "snake\\_case");
1945    }
1946
1947    #[test]
1948    fn md_to_typst_inline_bold() {
1949        assert_eq!(md_to_typst_inline("**hello**"), "*hello*");
1950        assert_eq!(md_to_typst_inline("a **bold** word"), "a *bold* word");
1951    }
1952
1953    #[test]
1954    fn md_to_typst_inline_italic() {
1955        assert_eq!(md_to_typst_inline("*hello*"), "_hello_");
1956        assert_eq!(md_to_typst_inline("an *italic* word"), "an _italic_ word");
1957    }
1958
1959    #[test]
1960    fn md_to_typst_inline_code() {
1961        assert_eq!(md_to_typst_inline("`code`"), "`code`");
1962    }
1963
1964    #[test]
1965    fn md_to_typst_inline_link() {
1966        assert_eq!(
1967            md_to_typst_inline("[click](https://example.com)"),
1968            "#link(\"https://example.com\")[click]"
1969        );
1970    }
1971
1972    #[test]
1973    fn md_to_typst_inline_strikethrough() {
1974        assert_eq!(md_to_typst_inline("~~deleted~~"), "#strike[deleted]");
1975    }
1976
1977    #[test]
1978    fn md_to_typst_inline_literal_asterisk() {
1979        // A* search — the * is not emphasis, it's a literal asterisk
1980        let result = md_to_typst_inline("in A* search");
1981        assert!(result.contains("A\\*"), "A* should produce escaped asterisk, got: {}", result);
1982        assert!(!result.contains("A_"), "A* should not become A_ italic, got: {}", result);
1983    }
1984
1985    #[test]
1986    fn md_to_typst_inline_stray_star_escaped() {
1987        // Stray * that doesn't match emphasis should be escaped
1988        let result = md_to_typst_inline("cost is 5*");
1989        assert!(result.contains("5\\*"), "trailing * should be escaped, got: {}", result);
1990    }
1991
1992    #[test]
1993    fn md_to_typst_headings() {
1994        assert!(md_to_typst("# Title\n").contains("= Title"));
1995        assert!(md_to_typst("## Subtitle\n").contains("== Subtitle"));
1996        assert!(md_to_typst("### H3\n").contains("=== H3"));
1997    }
1998
1999    #[test]
2000    fn md_to_typst_lists() {
2001        let md = "- item 1\n- item 2\n";
2002        let result = md_to_typst(md);
2003        assert!(result.contains("- item 1"));
2004        assert!(result.contains("- item 2"));
2005    }
2006
2007    #[test]
2008    fn md_to_typst_ordered_lists() {
2009        let md = "1. first\n2. second\n";
2010        let result = md_to_typst(md);
2011        assert!(result.contains("1. first"));
2012        assert!(result.contains("2. second"));
2013    }
2014
2015    #[test]
2016    fn md_to_typst_code_block() {
2017        let md = "```rust\nfn main() {}\n```\n";
2018        let result = md_to_typst(md);
2019        assert!(result.contains("```rust"));
2020        assert!(result.contains("fn main() {}"));
2021    }
2022
2023    #[test]
2024    fn md_to_typst_horizontal_rule() {
2025        assert!(md_to_typst("---\n").contains("#line("));
2026    }
2027
2028    #[test]
2029    fn md_to_typst_gfm_table_becomes_typst_table() {
2030        let md = "| Name | Qty |\n| --- | --- |\n| Bolt | 4 |\n| Nut | 8 |\n";
2031        let result = md_to_typst(md);
2032        assert!(result.contains("#table(\n  columns: 2,"), "got: {result}");
2033        assert!(result.contains("[*Name*],"), "header row is bold: {result}");
2034        assert!(result.contains("[Bolt],"));
2035        assert!(result.contains("[8],"));
2036        assert!(!result.contains(" | "), "no pipe pass-through: {result}");
2037    }
2038
2039    #[test]
2040    fn md_to_typst_table_without_separator_has_no_header() {
2041        let md = "| a | b |\n| c | d |\n";
2042        let result = md_to_typst(md);
2043        assert!(result.contains("#table(\n  columns: 2,"));
2044        assert!(!result.contains("[*a*],"), "no bold header: {result}");
2045    }
2046
2047    #[test]
2048    fn md_to_typst_table_flushes_before_following_paragraph() {
2049        let md = "| a | b |\n| --- | --- |\n| c | d |\nAfter text\n";
2050        let result = md_to_typst(md);
2051        let table_pos = result.find("#table(").expect("table emitted");
2052        let text_pos = result.find("After text").expect("paragraph kept");
2053        assert!(table_pos < text_pos, "table renders before the paragraph");
2054    }
2055
2056    #[test]
2057    fn render_markdown_block() {
2058        let doc = SurfDoc {
2059            front_matter: None,
2060            blocks: vec![Block::Markdown {
2061                content: "Hello **world**".to_string(),
2062                span: Span::SYNTHETIC,
2063            }],
2064            source: String::new(),
2065        };
2066        let result = to_typst(&doc);
2067        assert!(result.contains("Hello *world*"));
2068    }
2069
2070    #[test]
2071    fn render_callout_block() {
2072        let doc = SurfDoc {
2073            front_matter: None,
2074            blocks: vec![Block::Callout {
2075                callout_type: CalloutType::Warning,
2076                title: Some("Heads up".to_string()),
2077                content: "Be careful".to_string(),
2078                span: Span::SYNTHETIC,
2079            }],
2080            source: String::new(),
2081        };
2082        let result = to_typst(&doc);
2083        assert!(result.contains("surfdoc-callout"));
2084        assert!(result.contains("warning"));
2085        assert!(result.contains("Heads up"));
2086        assert!(result.contains("Be careful"));
2087    }
2088
2089    #[test]
2090    fn render_data_block() {
2091        let doc = SurfDoc {
2092            front_matter: None,
2093            blocks: vec![Block::Data {
2094                id: None,
2095                format: DataFormat::Table,
2096                sortable: false,
2097                headers: vec!["Name".into(), "Value".into()],
2098                rows: vec![vec!["A".into(), "1".into()], vec!["B".into(), "2".into()]],
2099                raw_content: String::new(),
2100                span: Span::SYNTHETIC,
2101            }],
2102            source: String::new(),
2103        };
2104        let result = to_typst(&doc);
2105        assert!(result.contains("#table("));
2106        assert!(result.contains("[*Name*]"));
2107        assert!(result.contains("[*Value*]"));
2108        assert!(result.contains("[A]"));
2109    }
2110
2111    #[test]
2112    fn render_code_block() {
2113        let doc = SurfDoc {
2114            front_matter: None,
2115            blocks: vec![Block::Code {
2116                lang: Some("rust".to_string()),
2117                file: None,
2118                highlight: vec![],
2119                content: "fn main() {}".to_string(),
2120                span: Span::SYNTHETIC,
2121            }],
2122            source: String::new(),
2123        };
2124        let result = to_typst(&doc);
2125        assert!(result.contains("```rust"));
2126        assert!(result.contains("fn main() {}"));
2127    }
2128
2129    #[test]
2130    fn render_tasks_block() {
2131        let doc = SurfDoc {
2132            front_matter: None,
2133            blocks: vec![Block::Tasks {
2134                items: vec![
2135                    TaskItem { done: true, text: "Done task".to_string(), assignee: None },
2136                    TaskItem { done: false, text: "Open task".to_string(), assignee: Some("brady".to_string()) },
2137                ],
2138                span: Span::SYNTHETIC,
2139            }],
2140            source: String::new(),
2141        };
2142        let result = to_typst(&doc);
2143        assert!(result.contains("☑"));
2144        assert!(result.contains("☐"));
2145        assert!(result.contains("Done task"));
2146        assert!(result.contains("\\@brady"));
2147    }
2148
2149    #[test]
2150    fn render_decision_block() {
2151        let doc = SurfDoc {
2152            front_matter: None,
2153            blocks: vec![Block::Decision {
2154                status: DecisionStatus::Accepted,
2155                date: Some("2026-02-22".to_string()),
2156                deciders: vec!["Brady".to_string()],
2157                content: "We chose Typst".to_string(),
2158                span: Span::SYNTHETIC,
2159            }],
2160            source: String::new(),
2161        };
2162        let result = to_typst(&doc);
2163        assert!(result.contains("surfdoc-decision-badge"));
2164        assert!(result.contains("accepted"));
2165        assert!(result.contains("2026-02-22"));
2166    }
2167
2168    #[test]
2169    fn render_metric_block() {
2170        let doc = SurfDoc {
2171            front_matter: None,
2172            blocks: vec![Block::Metric {
2173                label: "Revenue".to_string(),
2174                value: "$12K".to_string(),
2175                trend: Some(Trend::Up),
2176                unit: Some("MRR".to_string()),
2177                span: Span::SYNTHETIC,
2178            }],
2179            source: String::new(),
2180        };
2181        let result = to_typst(&doc);
2182        assert!(result.contains("surfdoc-metric"));
2183        assert!(result.contains("Revenue"));
2184        assert!(result.contains("\\$12K"));
2185    }
2186
2187    #[test]
2188    fn render_summary_block() {
2189        let doc = SurfDoc {
2190            front_matter: None,
2191            blocks: vec![Block::Summary {
2192                content: "Key points here".to_string(),
2193                span: Span::SYNTHETIC,
2194            }],
2195            source: String::new(),
2196        };
2197        let result = to_typst(&doc);
2198        assert!(result.contains("*Summary*"));
2199        assert!(result.contains("Key points here"));
2200    }
2201
2202    #[test]
2203    fn render_quote_block() {
2204        let doc = SurfDoc {
2205            front_matter: None,
2206            blocks: vec![Block::Quote {
2207                content: "The best code is no code.".to_string(),
2208                attribution: Some("Someone".to_string()),
2209                cite: None,
2210                span: Span::SYNTHETIC,
2211            }],
2212            source: String::new(),
2213        };
2214        let result = to_typst(&doc);
2215        assert!(result.contains("#quote(attribution: [Someone])"));
2216        assert!(result.contains("The best code is no code."));
2217    }
2218
2219    #[test]
2220    fn render_divider_block() {
2221        let doc = SurfDoc {
2222            front_matter: None,
2223            blocks: vec![Block::Divider {
2224                label: None,
2225                span: Span::SYNTHETIC,
2226            }],
2227            source: String::new(),
2228        };
2229        let result = to_typst(&doc);
2230        assert!(result.contains("#line(length: 100%"));
2231    }
2232
2233    #[test]
2234    fn render_front_matter() {
2235        let doc = SurfDoc {
2236            front_matter: Some(FrontMatter {
2237                title: Some("My Document".to_string()),
2238                author: Some("Brady".to_string()),
2239                created: Some("2026-02-22".to_string()),
2240                tags: Some(vec!["strategy".to_string(), "pdf".to_string()]),
2241                ..FrontMatter::default()
2242            }),
2243            blocks: vec![],
2244            source: String::new(),
2245        };
2246        let result = to_typst(&doc);
2247        assert!(result.contains("My Document"));
2248        assert!(result.contains("Brady"));
2249        assert!(result.contains("2026-02-22"));
2250        assert!(result.contains("strategy"));
2251    }
2252
2253    #[test]
2254    fn render_hero_block() {
2255        let doc = SurfDoc {
2256            front_matter: None,
2257            blocks: vec![Block::Hero {
2258                headline: Some("Welcome".to_string()),
2259                subtitle: Some("To the future".to_string()),
2260                badge: None,
2261                align: "center".to_string(),
2262                image: None,
2263                image_alt: None,
2264                layout: None,
2265                transparent: false,
2266                buttons: vec![HeroButton {
2267                    label: "Get Started".to_string(),
2268                    href: "/start".to_string(),
2269                    primary: true,
2270                    external: false,
2271                }],
2272                content: String::new(),
2273                span: Span::SYNTHETIC,
2274            }],
2275            source: String::new(),
2276        };
2277        let result = to_typst(&doc);
2278        assert!(result.contains("Welcome"));
2279        assert!(result.contains("To the future"));
2280        assert!(result.contains("Get Started"));
2281    }
2282
2283    #[test]
2284    fn render_steps_block() {
2285        let doc = SurfDoc {
2286            front_matter: None,
2287            blocks: vec![Block::Steps {
2288                steps: vec![
2289                    StepItem { title: "Step 1".into(), time: None, body: "Do this".into() },
2290                    StepItem { title: "Step 2".into(), time: Some("5 min".into()), body: "Then this".into() },
2291                ],
2292                span: Span::SYNTHETIC,
2293            }],
2294            source: String::new(),
2295        };
2296        let result = to_typst(&doc);
2297        assert!(result.contains("*Step 1*"));
2298        assert!(result.contains("*Step 2*"));
2299        assert!(result.contains("5 min"));
2300    }
2301
2302    #[test]
2303    fn render_comparison_block() {
2304        let doc = SurfDoc {
2305            front_matter: None,
2306            blocks: vec![Block::Comparison {
2307                headers: vec!["Feature".into(), "Us".into(), "Them".into()],
2308                rows: vec![vec!["Speed".into(), "yes".into(), "no".into()]],
2309                highlight: Some("Us".into()),
2310                span: Span::SYNTHETIC,
2311            }],
2312            source: String::new(),
2313        };
2314        let result = to_typst(&doc);
2315        assert!(result.contains("#table("));
2316        assert!(result.contains("surfdoc-blue"));
2317        assert!(result.contains("surfdoc-green")); // yes marker
2318    }
2319
2320    #[test]
2321    fn render_pipeline_block() {
2322        let doc = SurfDoc {
2323            front_matter: None,
2324            blocks: vec![Block::Pipeline {
2325                steps: vec![
2326                    PipelineStep { label: "Parse".into(), description: None },
2327                    PipelineStep { label: "Compile".into(), description: Some("via Typst".into()) },
2328                    PipelineStep { label: "Output".into(), description: None },
2329                ],
2330                span: Span::SYNTHETIC,
2331            }],
2332            source: String::new(),
2333        };
2334        let result = to_typst(&doc);
2335        assert!(result.contains("*Parse*"));
2336        assert!(result.contains("→"));
2337        assert!(result.contains("*Compile*"));
2338        assert!(result.contains("via Typst"));
2339        assert!(result.contains("*Output*"));
2340    }
2341
2342    #[test]
2343    fn render_unknown_block() {
2344        let doc = SurfDoc {
2345            front_matter: None,
2346            blocks: vec![Block::Unknown {
2347                name: "widget".to_string(),
2348                attrs: Default::default(),
2349                content: "some content".to_string(),
2350                span: Span::SYNTHETIC,
2351            }],
2352            source: String::new(),
2353        };
2354        let result = to_typst(&doc);
2355        assert!(result.contains("Unknown block: widget"));
2356        assert!(result.contains("some content"));
2357    }
2358
2359    #[test]
2360    fn render_before_after_block() {
2361        let doc = SurfDoc {
2362            front_matter: None,
2363            blocks: vec![Block::BeforeAfter {
2364                before_items: vec![BeforeAfterItem { label: "Old".into(), detail: "Slow".into() }],
2365                after_items: vec![BeforeAfterItem { label: "New".into(), detail: "Fast".into() }],
2366                transition: None,
2367                span: Span::SYNTHETIC,
2368            }],
2369            source: String::new(),
2370        };
2371        let result = to_typst(&doc);
2372        assert!(result.contains("*Before:*"));
2373        assert!(result.contains("*After:*"));
2374        assert!(result.contains("*Old*"));
2375        assert!(result.contains("*New*"));
2376    }
2377
2378    #[test]
2379    fn render_product_card_block() {
2380        let doc = SurfDoc {
2381            front_matter: None,
2382            blocks: vec![Block::ProductCard {
2383                title: "WaveSite".to_string(),
2384                subtitle: Some("Build your site".into()),
2385                badge: Some("NEW".into()),
2386                badge_color: None,
2387                body: "Description here".into(),
2388                features: vec!["Fast".into(), "Free".into()],
2389                cta_label: Some("Try it".into()),
2390                cta_href: Some("https://wave.site".into()),
2391                span: Span::SYNTHETIC,
2392            }],
2393            source: String::new(),
2394        };
2395        let result = to_typst(&doc);
2396        assert!(result.contains("WaveSite"));
2397        assert!(result.contains("NEW"));
2398        assert!(result.contains("Build your site"));
2399        assert!(result.contains("Fast"));
2400        assert!(result.contains("Try it"));
2401    }
2402
2403    #[test]
2404    fn render_faq_block() {
2405        let doc = SurfDoc {
2406            front_matter: None,
2407            blocks: vec![Block::Faq {
2408                items: vec![FaqItem {
2409                    question: "What is SurfDoc?".into(),
2410                    answer: "A typed document format.".into(),
2411                }],
2412                span: Span::SYNTHETIC,
2413            }],
2414            source: String::new(),
2415        };
2416        let result = to_typst(&doc);
2417        assert!(result.contains("*Q: What is SurfDoc?*"));
2418        assert!(result.contains("A typed document format."));
2419    }
2420
2421    #[test]
2422    fn render_stats_block() {
2423        let doc = SurfDoc {
2424            front_matter: None,
2425            blocks: vec![Block::Stats {
2426                items: vec![
2427                    StatItem { value: "373".into(), label: "Tests".into(), color: None },
2428                    StatItem { value: "37".into(), label: "Block Types".into(), color: None },
2429                ],
2430                span: Span::SYNTHETIC,
2431            }],
2432            source: String::new(),
2433        };
2434        let result = to_typst(&doc);
2435        assert!(result.contains("#grid("));
2436        assert!(result.contains("373"));
2437        assert!(result.contains("Tests"));
2438    }
2439
2440    #[test]
2441    fn render_cta_block() {
2442        let doc = SurfDoc {
2443            front_matter: None,
2444            blocks: vec![Block::Cta {
2445                label: "Sign Up".into(),
2446                href: "/signup".into(),
2447                primary: true,
2448                icon: None,
2449                span: Span::SYNTHETIC,
2450            }],
2451            source: String::new(),
2452        };
2453        let result = to_typst(&doc);
2454        assert!(result.contains("Sign Up"));
2455        assert!(result.contains("/signup"));
2456        assert!(result.contains("surfdoc-blue"));
2457    }
2458
2459    #[test]
2460    fn render_nav_block() {
2461        let doc = SurfDoc {
2462            front_matter: None,
2463            blocks: vec![Block::Nav {
2464                items: vec![
2465                    NavItem { label: "Home".into(), href: "/".into(), icon: None, image: None, external: false },
2466                    NavItem { label: "About".into(), href: "/about".into(), icon: None, image: None, external: false },
2467                ],
2468                logo: None,
2469                groups: vec![], brand: None, brand_reg: false, cta: None, drawer: false, minimal: false,
2470                span: Span::SYNTHETIC,
2471            }],
2472            source: String::new(),
2473        };
2474        let result = to_typst(&doc);
2475        assert!(result.contains("Home"));
2476        assert!(result.contains("About"));
2477    }
2478
2479    #[test]
2480    fn site_and_style_blocks_skipped() {
2481        let doc = SurfDoc {
2482            front_matter: None,
2483            blocks: vec![
2484                Block::Site {
2485                    domain: Some("example.com".into()),
2486                    properties: vec![],
2487                    span: Span::SYNTHETIC,
2488                },
2489                Block::Style {
2490                    properties: vec![StyleProperty { key: "accent".into(), value: "#f00".into() }],
2491                    span: Span::SYNTHETIC,
2492                },
2493                Block::Markdown {
2494                    content: "visible".to_string(),
2495                    span: Span::SYNTHETIC,
2496                },
2497            ],
2498            source: String::new(),
2499        };
2500        let result = to_typst(&doc);
2501        assert!(!result.contains("example.com"));
2502        assert!(result.contains("visible"));
2503    }
2504
2505    // ── Academic templates (Chunk 6) ─────────────────────────────────────────
2506
2507    fn parse_doc(src: &str) -> SurfDoc {
2508        crate::parse(src).doc
2509    }
2510
2511    #[test]
2512    fn ieee_paper_is_two_column() {
2513        let src = "---\ntitle: A Study of Things\ntype: paper\nformat: ieee\nauthor: Jane Doe; John Roe\naffiliation: Acme Labs\nabstract: We study things.\n---\n\n# Introduction\n\nText here.\n";
2514        let ts = to_typst(&parse_doc(src));
2515        assert!(ts.contains("columns: 2"), "IEEE paper must be two-column: {ts}");
2516        assert!(ts.contains("A Study of Things"));
2517        assert!(ts.contains("Jane Doe"));
2518        assert!(ts.contains("Acme Labs"));
2519        assert!(ts.contains("Abstract."));
2520        // Numbered sections.
2521        assert!(ts.contains("#set heading(numbering: \"1.\")"));
2522    }
2523
2524    #[test]
2525    fn article_paper_is_single_column() {
2526        let src = "---\ntitle: T\ntype: paper\nformat: article\nauthor: A\n---\n\n# H\n\nBody.\n";
2527        let ts = to_typst(&parse_doc(src));
2528        assert!(!ts.contains("columns: 2"));
2529        assert!(ts.contains("#set heading(numbering: \"1.\")"));
2530    }
2531
2532    #[test]
2533    fn mla_report_has_works_cited() {
2534        let src = "---\ntitle: My Essay\ntype: report\nformat: mla\nauthor: Sam Student\ninstructor: Dr. Smith\ncourse: ENG 101\ndate: 2026-06-27\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";
2535        let ts = to_typst(&parse_doc(src));
2536        assert!(ts.contains("Works Cited"), "MLA report must have Works Cited: {ts}");
2537        assert!(ts.contains("Dr. Smith"));
2538        assert!(ts.contains("ENG 101"));
2539        // Double-spaced.
2540        assert!(ts.contains("leading: 1.5em"));
2541        // MLA in-text author-only.
2542        assert!(ts.contains("(Author)"));
2543    }
2544
2545    #[test]
2546    fn apa_report_has_references_and_title_page() {
2547        let src = "---\ntitle: My Report\ntype: report\nformat: apa\nauthor: A B\ninstitution: State University\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";
2548        let ts = to_typst(&parse_doc(src));
2549        assert!(ts.contains("References"), "APA report must have References: {ts}");
2550        assert!(ts.contains("#pagebreak()"));
2551        assert!(ts.contains("State University"));
2552    }
2553
2554    #[test]
2555    fn chicago_report_has_bibliography() {
2556        let src = "---\ntitle: R\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";
2557        let ts = to_typst(&parse_doc(src));
2558        assert!(ts.contains("Bibliography"), "Chicago report must have Bibliography: {ts}");
2559    }
2560
2561    #[test]
2562    fn typst_academic_is_deterministic() {
2563        let src = "---\ntitle: P\ntype: paper\nformat: ieee\nauthor: Jane Doe\nabstract: A.\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";
2564        let doc = parse_doc(src);
2565        assert_eq!(to_typst(&doc), to_typst(&doc));
2566    }
2567
2568    /// L3 drift: every Format produces both a Typst paper/report template AND a
2569    /// LaTeX template without panicking and with a non-trivial body.
2570    #[test]
2571    fn every_format_renders_paper_and_report() {
2572        for f in ["ieee", "acm", "article", "mla", "apa", "chicago"] {
2573            for ty in ["paper", "report"] {
2574                let src = format!(
2575                    "---\ntitle: T\ntype: {ty}\nformat: {f}\nauthor: A B\n---\n\n# Section\n\nBody text.\n"
2576                );
2577                let doc = parse_doc(&src);
2578                let ts = to_typst(&doc);
2579                let tex = crate::render_latex::to_latex(&doc);
2580                assert!(ts.len() > 100, "empty Typst for {ty}/{f}");
2581                assert!(tex.contains("\\begin{document}"), "no LaTeX doc for {ty}/{f}");
2582            }
2583        }
2584    }
2585}