Skip to main content

adocers_typst/
lib.rs

1//! The PDF back end: a document becomes Typst markup, and Typst makes the PDF.
2//!
3//! [Typst](https://typst.app) is a typesetting system written in Rust, so it
4//! compiles in beside everything else and a PDF needs no LaTeX installation, no
5//! headless browser and nothing fetched. Its fonts are the ones Typst itself
6//! embeds, so the result does not depend on what the machine happens to have.
7//!
8//! This is a second back end rather than a variation on the first. The HTML one
9//! walks the block tree emitting markup, and so does this, but Typst is not
10//! HTML and almost nothing is shared between them — the exception being inline
11//! content, which the parser hands over as HTML whatever is going to be done
12//! with it. `inline` translates that. What the two back ends genuinely agree
13//! on — section numbers, header metadata, admonition icons, mermaid diagrams —
14//! lives in [`adocers_render_core`], which neither of them owns.
15//!
16//! What it covers is the shape of an ordinary document: headings, paragraphs,
17//! lists, tables, listings, quotes, admonitions, images, diagrams, footnotes
18//! and breaks. A block it has no rendering for becomes its own text rather than
19//! a gap.
20//!
21//! Equations are typeset rather than shown: LaTeX through
22//! [`mitex`](https://crates.io/crates/mitex), `AsciiMath` rewritten into
23//! Typst's own notation. When one will not lay out, every equation in the
24//! document is shown as its source instead — a page of equations as their
25//! source being a better answer than no page at all — and [`Pdf::fallback`]
26//! says so.
27//!
28//! Three things stop short of the page and are documented as such: a table cell
29//! written as AsciiDoc comes out empty; a passthrough is dropped, being HTML;
30//! and a cross reference to anything but a section keeps its words and loses
31//! its link, because Typst will not lay out a document that points at a label
32//! it cannot find.
33
34#![warn(clippy::print_stderr, clippy::print_stdout)]
35
36#[cfg(feature = "math")]
37mod ascii;
38mod inline;
39mod math;
40
41use std::{
42    fmt::Write as _,
43    path::{
44        Path,
45        PathBuf,
46    },
47};
48
49use anyhow::{
50    Context as _,
51    Result,
52};
53use asciidoc_parser::{
54    Document,
55    blocks::{
56        Block,
57        BreakType,
58        CompoundDelimitedContext,
59        FindBlocks,
60        IsBlock,
61        ListType,
62        QuoteType,
63        SectionType,
64    },
65    document::TocMode,
66};
67
68use adocers_render_core::numbering::Numbering;
69
70use crate::inline::{
71    string,
72    typst as inline_markup,
73};
74
75/// What a PDF shows beyond the document's own content.
76///
77/// These are the switches the HTML back end reads too, minus the ones that only
78/// a web page has: a PDF has no stylesheet to embed and is never a fragment of
79/// something larger.
80#[derive(Clone, Copy, Debug, Default)]
81#[expect(
82    clippy::struct_excessive_bools,
83    reason = "these mirror command line flags, and one field per flag is what reads clearly"
84)]
85pub struct Options {
86    /// Whether an admonition is marked with an icon rather than its label, and
87    /// a callout with a circled number rather than the parser's `(1)`.
88    pub icons: bool,
89
90    /// Whether a source block is syntax highlighted, by Typst's own
91    /// highlighter.
92    pub highlight: bool,
93
94    /// Whether a mermaid block is drawn as a diagram, or left as the listing
95    /// block it was written as.
96    pub mermaid: bool,
97
98    /// Whether an equation is typeset, or shown as the notation it was written
99    /// in.
100    pub math: bool,
101}
102
103/// A typeset PDF, and anything the caller ought to be told about how it was
104/// made.
105#[derive(Clone, Debug)]
106pub struct Pdf {
107    /// The PDF itself.
108    pub bytes: Vec<u8>,
109
110    /// Why the document was not typeset the way it was asked for, if it was
111    /// not.
112    ///
113    /// Set when an equation could not be typeset and every equation was shown
114    /// as its source instead — a page of equations as their source being a far
115    /// better answer than no page at all. A caller with somewhere to put a
116    /// diagnostic should say so; one without can drop it.
117    pub fallback: Option<String>,
118}
119
120/// Render `document` as a PDF.
121///
122/// `base` is the directory the document was read from, which is where the
123/// images it names are looked for.
124pub fn pdf(document: &Document<'_>, base: &Path, options: &Options) -> Result<Pdf> {
125    let (source, images) = markup(document, base, options);
126
127    let error = match compile(&source, &images) {
128        Ok(bytes) => {
129            return Ok(Pdf {
130                bytes,
131                fallback: None,
132            });
133        }
134
135        Err(error) => error,
136    };
137
138    // Converted mathematics is the one part of this that can fail on content
139    // rather than on a mistake here: `mitex` covers a great deal of LaTeX but
140    // not all of it, and a command it renders as a handler this does not define
141    // stops the whole document. A page of equations shown as their source is a
142    // far better answer than no page at all.
143    if !options.math {
144        return Err(error);
145    }
146
147    let plain = Options {
148        math: false,
149        ..*options
150    };
151
152    let (source, images) = markup(document, base, &plain);
153
154    match compile(&source, &images) {
155        Ok(bytes) => Ok(Pdf {
156            bytes,
157            fallback: Some(format!(
158                "an equation could not be typeset, so every equation is shown as its source \
159                 ({error:#})"
160            )),
161        }),
162
163        // The mathematics was not the trouble; report what actually went wrong.
164        Err(_) => Err(error),
165    }
166}
167
168/// The Typst markup for a document, and the image files it refers to.
169pub fn markup(
170    document: &Document<'_>,
171    base: &Path,
172    options: &Options,
173) -> (String, Vec<(String, Vec<u8>)>) {
174    let mut out = Preamble::new(document, *options).to_string();
175
176    let mut emitter = Emitter {
177        out: String::new(),
178        base: base.to_path_buf(),
179        images_dir: attribute(document, "imagesdir"),
180        images: Vec::new(),
181        toc: Toc::of(document),
182        options: *options,
183        stem: attribute(document, "stem"),
184        figure: attribute(document, "figure-caption"),
185        figures: 0,
186        numbering: Numbering::of(document),
187    };
188
189    // An outline placed above the content goes between the title block and the
190    // first thing the author wrote, which is where `:toc:` puts it on the page.
191    if emitter.toc.above() {
192        emitter.outline(emitter.toc.levels, &emitter.toc.title.clone());
193    }
194
195    emitter.blocks(document.child_blocks());
196
197    if emitter.toc.mode == Some(TocMode::Bottom) {
198        emitter.outline(emitter.toc.levels, &emitter.toc.title.clone());
199    }
200
201    out.push_str(&prune_links(&emitter.out));
202
203    (out, emitter.images)
204}
205
206/// Compile Typst markup to a PDF.
207fn compile(source: &str, images: &[(String, Vec<u8>)]) -> Result<Vec<u8>> {
208    use typst_as_lib::{
209        TypstEngine,
210        typst_kit_options::TypstKitFontOptions,
211    };
212
213    let engine = TypstEngine::builder()
214        .main_file(source.to_string())
215        // Typst's own fonts and no others: scanning the machine's font
216        // directories costs half a second and makes the output depend on what
217        // happens to be installed.
218        .search_fonts_with(TypstKitFontOptions::default().include_system_fonts(false))
219        // Typst has no file system of its own here, so every picture the
220        // document names is handed over as bytes.
221        .with_static_file_resolver(
222            images
223                .iter()
224                .map(|(path, bytes)| (path.as_str(), bytes.as_slice())),
225        )
226        .build();
227
228    let document = engine
229        .compile()
230        .output
231        .map_err(|error| anyhow::anyhow!("{error}"))
232        .context("laying the document out")?;
233
234    typst_pdf::pdf(&document, &typst_pdf::PdfOptions::default())
235        .map_err(|errors| anyhow::anyhow!(describe(&errors)))
236        .context("writing the PDF")
237}
238
239/// Turn Typst's diagnostics into one message.
240fn describe(errors: &[typst::diag::SourceDiagnostic]) -> String {
241    errors
242        .iter()
243        .map(|error| error.message.to_string())
244        .collect::<Vec<_>>()
245        .join("; ")
246}
247
248/// Scale a picture down to the column when it is wider, and leave it alone
249/// when it is not.
250///
251/// An AsciiDoc image is measured in pixels and a page is measured in
252/// millimetres, so a figure drawn for a browser is usually wider than the text
253/// it sits in. Typst does not fit a picture to its container by itself: it
254/// draws it at its natural size and lets it run off the page.
255const FITTED: &str = "#let fitted(body) = layout(size => {\n\x20 let natural = \
256                      measure(body).width\n\x20 let factor = size.width / natural * 100%\n\x20 if \
257                      natural > size.width {\n\x20   scale(body, x: factor, y: factor, reflow: \
258                      true, origin: top + left)\n\x20 } else { body }\n})";
259
260/// Mark a callout list's item the way the listing above it is marked.
261///
262/// The same circled characters, so an item and the line it annotates read as
263/// the same mark; above twenty there is none, and the plain number stands.
264const CALLOUT: &str = "#let adoccallout(n) = if n <= 10 { str.from-unicode(0x2775 + n) } else if \
265                       n <= 20 { str.from-unicode(0x24E0 + n) } else { numbering(\"(1)\", n) \
266                       }\n#show regex(\"[\\u{2776}-\\u{277F}\\u{24EB}-\\u{24F4}]\"): it => \
267                       text(fill: rgb(\"#1565a8\"), size: 1.35em, baseline: 0.12em)[#it]";
268
269/// The `footnote:[]` definitions, and the helper that places one.
270///
271/// A reference is rendered by the parser as its mark alone, with the text left
272/// in the document's catalogue, so the texts are written out once here and each
273/// reference calls for the one it wants. Typst then puts it at the foot of
274/// whichever page the reference landed on and numbers it itself.
275fn footnotes(document: &Document<'_>, options: Options) -> String {
276    let texts: Vec<String> = document
277        .catalog()
278        .footnotes()
279        .iter()
280        .map(|footnote| format!("[{}]", prose(&footnote.text, options.math)))
281        .collect();
282
283    // One item needs the trailing comma that tells an array from a parenthesis,
284    // and none needs no comma at all.
285    let array = match texts.len() {
286        0 => String::new(),
287        1 => format!("{},", texts[0]),
288        _ => texts.join(", "),
289    };
290
291    // A reference with no definition would be a lookup past the end, which
292    // Typst reports rather than ignores, so it is guarded rather than trusted.
293    format!(
294        "#let adocfootnotes = ({array})\n#let adocfootnote(n) = if n <= adocfootnotes.len() {{ \
295         footnote(adocfootnotes.at(n - 1)) }}"
296    )
297}
298
299/// What the document says about itself, as the properties a PDF carries.
300///
301/// A reader's viewer shows these in its document-properties panel, and a search
302/// index reads them, so the header's facts are worth writing down there as well
303/// as on the first page.
304fn properties(document: &Document<'_>, title: &str) -> String {
305    let mut out = format!("title: {}", string(title));
306
307    let authors: Vec<String> = document
308        .authors()
309        .iter()
310        .map(|author| string(author.name()))
311        .collect();
312
313    if !authors.is_empty() {
314        let _ = write!(out, ", author: ({},)", authors.join(", "));
315    }
316
317    if let Some(description) = attribute(document, "description") {
318        let _ = write!(out, ", description: {}", string(&description));
319    }
320
321    let keywords: Vec<String> = ["keywords", "page-tags"]
322        .iter()
323        .filter_map(|name| attribute(document, name))
324        .flat_map(|value| {
325            value
326                .split(',')
327                .map(str::trim)
328                .filter(|word| !word.is_empty())
329                .map(string)
330                .collect::<Vec<_>>()
331        })
332        .collect();
333
334    if !keywords.is_empty() {
335        let _ = write!(out, ", keywords: ({},)", keywords.join(", "));
336    }
337
338    // A date Typst cannot read is left out rather than guessed at: `auto` would
339    // silently stamp the PDF with the day it was built.
340    match attribute(document, "revdate").as_deref().and_then(date) {
341        Some(date) => {
342            let _ = write!(out, ", date: {date}");
343        }
344
345        None => out.push_str(", date: none"),
346    }
347
348    out
349}
350
351/// The language of `#set text`, from the document's `:lang:`.
352///
353/// Typst reads the language for more than the record: it picks the hyphenation
354/// patterns from it, and the glyphs `smartquote` turns a quotation mark into.
355/// Left unset it assumes English, so a German document came out hyphenated and
356/// quoted as though it were English while every label on the page read German.
357///
358/// AsciiDoc writes one BCP 47 tag where Typst takes the two halves apart, so
359/// the tag is split: the first subtag is the language, and a later two-letter
360/// one is the region — which is what a two-letter subtag after the language can
361/// only be, a script being four letters and a variant five or more. A tag Typst
362/// would not recognise is left out rather than passed on, since English is what
363/// it falls back to either way.
364fn locale(document: &Document<'_>) -> String {
365    let Some(tag) = attribute(document, "lang") else {
366        return String::new();
367    };
368
369    let mut subtags = tag.trim().split('-');
370
371    let Some(language) = subtags.next().filter(|subtag| alphabetic(subtag, 2..=3)) else {
372        return String::new();
373    };
374
375    let mut out = format!(", lang: {}", string(&language.to_lowercase()));
376
377    if let Some(region) = subtags.find(|subtag| alphabetic(subtag, 2..=2)) {
378        let _ = write!(out, ", region: {}", string(&region.to_uppercase()));
379    }
380
381    out
382}
383
384/// Whether a subtag is `length` letters and nothing else.
385fn alphabetic(subtag: &str, length: std::ops::RangeInclusive<usize>) -> bool {
386    length.contains(&subtag.chars().count()) && subtag.chars().all(char::is_alphabetic)
387}
388
389/// A `YYYY-MM-DD` date, as a Typst `datetime`.
390fn date(value: &str) -> Option<String> {
391    let mut parts = value.trim().splitn(3, '-');
392
393    let year: i32 = parts.next()?.parse().ok()?;
394    let month: u8 = parts.next()?.parse().ok()?;
395    let day: u8 = parts.next()?.trim().parse().ok()?;
396
397    (1..=12).contains(&month).then_some(())?;
398    (1..=31).contains(&day).then_some(())?;
399
400    Some(format!(
401        "datetime(year: {year}, month: {month}, day: {day})"
402    ))
403}
404
405/// One of the document header's attributes, if it was set to something.
406fn attribute(document: &Document<'_>, name: &str) -> Option<String> {
407    match document.attribute_value(name) {
408        asciidoc_parser::document::InterpretedValue::Value(value) if !value.is_empty() => {
409            Some(value.clone())
410        }
411
412        _ => None,
413    }
414}
415
416/// The labelled lines under the title: who wrote it, which revision it is, and
417/// whatever else the header says about the document.
418///
419/// The same facts the page shows, read the same way — including Antora's
420/// `page-` namespace — so the two headers cannot drift apart.
421fn details(document: &Document<'_>) -> String {
422    let mut out = String::new();
423
424    let authors: Vec<String> = document
425        .authors()
426        .iter()
427        .map(|author| match author.email() {
428            // The angle brackets are the convention a reader knows from a
429            // commit or a mail header, escaped because Typst reads a bare pair
430            // of them as a label.
431            Some(email) => format!(
432                "{} #link(\"mailto:{email}\")[\\<{}\\>]",
433                inline_markup(author.name()),
434                inline_markup(email)
435            ),
436
437            None => inline_markup(author.name()),
438        })
439        .collect();
440
441    if !authors.is_empty() {
442        let label = if authors.len() == 1 {
443            "Author"
444        } else {
445            "Authors"
446        };
447
448        let _ = writeln!(out, "*{label}:* {}\\", authors.join(", "));
449    }
450
451    for (name, label) in [("revnumber", "Version"), ("revdate", "Date")] {
452        if let Some(value) = attribute(document, name) {
453            let _ = writeln!(out, "*{label}:* {}\\", inline_markup(&value));
454        }
455    }
456
457    for attribute in document.header().attributes() {
458        let name = attribute.name().data();
459
460        let Some(shown) = adocers_render_core::metadata::displayed_as(name) else {
461            continue;
462        };
463
464        let asciidoc_parser::document::InterpretedValue::Value(value) = attribute.value() else {
465            continue;
466        };
467
468        if value.is_empty() {
469            continue;
470        }
471
472        let _ = writeln!(
473            out,
474            "*{}:* {}\\",
475            adocers_render_core::metadata::label_for(shown),
476            inline_markup(value)
477        );
478    }
479
480    let mut out = out.trim_end().trim_end_matches('\\').to_string();
481
482    // The remark is a sentence about the revision rather than another value
483    // belonging to it, so it is set apart below the labels rather than given
484    // one of its own — italic, as the page sets it, and not in the same face as
485    // the labels or it reads as a label with the value left off.
486    if let Some(remark) = attribute(document, "revremark") {
487        let _ = write!(
488            out,
489            "\n#v(0.4em)\n#text(style: \"italic\")[{}]",
490            inline_markup(&remark)
491        );
492    }
493
494    out
495}
496
497/// Inline content, with any equation in it typeset rather than written out.
498///
499/// The parser hands an inline `stem:[…]` to the page as the delimiters
500/// `MathJax` used to look for — `\(…\)` for LaTeX, `\$…\$` for `AsciiMath`.
501/// Nothing looks for them here either, so a line would otherwise carry
502/// `\(x^2\)` as written.
503///
504/// LaTeX is typeset; `AsciiMath` is shown as its source, without the
505/// delimiters, for the reason [`math`] gives.
506fn prose(html: &str, math: bool) -> String {
507    let mut out = String::with_capacity(html.len());
508    let mut rest = html;
509
510    while let Some((at, open, close, latex)) = equation(rest) {
511        out.push_str(&inline_markup(&rest[..at]));
512
513        let body = &rest[at + open.len()..];
514
515        // An opening delimiter with no closing one is not an equation.
516        let Some(end) = body.find(close) else {
517            out.push_str(&inline_markup(&rest[at..at + open.len()]));
518            rest = &rest[at + open.len()..];
519
520            continue;
521        };
522
523        let source = inline::unescape(&body[..end]);
524
525        match typeset(&source, math, latex) {
526            Some(converted) => out.push_str(&converted),
527            None => {
528                let _ = write!(out, "#raw({})", string(source.trim()));
529            }
530        }
531
532        rest = &body[end + close.len()..];
533    }
534
535    out.push_str(&inline_markup(rest));
536    out
537}
538
539/// The next delimited equation, and whether it is written in LaTeX.
540fn equation(html: &str) -> Option<(usize, &'static str, &'static str, bool)> {
541    let latex = html.find("\\(").map(|at| (at, "\\(", "\\)", true));
542    let ascii = html.find("\\$").map(|at| (at, "\\$", "\\$", false));
543
544    match (latex, ascii) {
545        (Some(l), Some(a)) if a.0 < l.0 => Some(a),
546        (Some(l), _) => Some(l),
547        (None, found) => found,
548    }
549}
550
551/// One equation, as the Typst mathematics of a line of text.
552fn typeset(source: &str, math: bool, latex: bool) -> Option<String> {
553    math.then(|| converted(source, latex))
554        .flatten()
555        .map(|converted| format!("${converted}$"))
556}
557
558/// One equation as Typst mathematics, whichever notation it is written in.
559fn converted(source: &str, latex: bool) -> Option<String> {
560    if latex {
561        return math::typst(source);
562    }
563
564    asciimath(source)
565}
566
567/// `AsciiMath`, which is converted by the same crate the page's `MathML` comes
568/// from and so needs the feature that brings it.
569#[cfg(feature = "math")]
570fn asciimath(source: &str) -> Option<String> {
571    ascii::typst(source)
572}
573
574/// Never converts: no parser for it is compiled in.
575#[cfg(not(feature = "math"))]
576fn asciimath(_source: &str) -> Option<String> {
577    None
578}
579
580/// The page setup and title block that every rendered document opens with.
581struct Preamble(String);
582
583impl Preamble {
584    fn new(document: &Document<'_>, options: Options) -> Self {
585        let mut out = String::new();
586
587        let title = document
588            .doctitle_sanitized()
589            .unwrap_or_else(|| "Untitled".to_string());
590
591        let _ = writeln!(
592            out,
593            "#set document({})\n#set page(paper: \"a4\", margin: 2.2cm, numbering: \"1\")\n#set \
594             text(size: 10.5pt{})\n#set par(justify: true, leading: 0.62em)\n#show heading: it => \
595             block(above: 1.4em, below: 0.7em, it)\n#show link: it => text(fill: \
596             rgb(\"#1565a8\"), it)\n#show raw.where(block: true): it => block(\n\x20 width: 100%, \
597             fill: rgb(\"#f5f6f8\"), inset: 8pt, radius: 3pt, it,\n)\n{}\n{CALLOUT}\n{}\n{}\n",
598            properties(document, &title),
599            locale(document),
600            FITTED,
601            footnotes(document, options),
602            math::PRELUDE
603        );
604
605        if document.doctitle().is_some() {
606            // A title split at a colon is set in two, the way a title page is:
607            // the title on its own line and the subtitle beneath it, smaller
608            // and lighter in weight but in the same ink. On the page the two
609            // share a line, because a page has no title page to give them.
610            let (heading, subtitle) = match (document.header().main_title(), document.subtitle()) {
611                (Some(main), Some(subtitle)) => {
612                    (inline_markup(main), Some(inline_markup(subtitle)))
613                }
614
615                _ => (inline_markup(&title), None),
616            };
617
618            let _ = writeln!(
619                out,
620                "#align(center)[#text(size: 20pt, weight: \"bold\")[{heading}]]"
621            );
622
623            if let Some(subtitle) = subtitle {
624                let _ = writeln!(
625                    out,
626                    "#v(-0.6em)\n#align(center)[#text(size: 14pt)[{subtitle}]]"
627                );
628            }
629
630            let details = details(document);
631
632            if !details.is_empty() {
633                // The same labelled lines the page carries, set small and grey
634                // so the document's own first words are what the eye lands on.
635                let _ = writeln!(
636                    out,
637                    "#v(0.4em)\n#align(center)[#block(width: 80%)[#set text(size: 9pt, fill: \
638                     rgb(\"#656d77\"))\n#set align(center)\n{details}]]"
639                );
640            }
641
642            out.push('\n');
643        }
644
645        Self(out)
646    }
647}
648
649impl std::fmt::Display for Preamble {
650    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
651        f.write_str(&self.0)
652    }
653}
654
655/// Walks the block tree, appending Typst markup as it goes.
656struct Emitter {
657    /// Markup accumulated so far.
658    out: String,
659
660    /// The directory the document came from, which images are relative to.
661    base: PathBuf,
662
663    /// What `:imagesdir:` was set to, which a block's picture is named
664    /// relative to.
665    images_dir: Option<String>,
666
667    /// The images referred to, with their contents, for the compiler.
668    images: Vec<(String, Vec<u8>)>,
669
670    /// How the document asked for its outline, and whether one has been placed.
671    toc: Toc,
672
673    /// What the command line asked for. The flags that say what a document
674    /// carries — icons, highlighting, diagrams — mean the same here as they do
675    /// on a page; the ones about the page itself mean nothing.
676    options: Options,
677
678    /// What `:stem:` was set to, which says which notation a plain `[stem]`
679    /// block is written in.
680    stem: Option<String>,
681
682    /// What a figure's caption is labelled with, and how many have been
683    /// captioned so far.
684    figure: Option<String>,
685    figures: usize,
686
687    /// What each section shows in front of its title, so a PDF and a page
688    /// number the same document the same way.
689    numbering: Numbering,
690}
691
692/// The table of contents a document asked for.
693#[derive(Clone, Debug)]
694struct Toc {
695    /// Where it goes, or `None` when the document wants none.
696    mode: Option<TocMode>,
697
698    /// The heading above it, empty for no heading at all.
699    title: String,
700
701    /// How many levels of section it lists.
702    levels: usize,
703
704    /// Whether one has been placed, so a second `toc::[]` is ignored the way
705    /// Asciidoctor ignores it.
706    placed: bool,
707}
708
709impl Toc {
710    /// What a document asked for, read the way the page reads it.
711    fn of(document: &Document<'_>) -> Self {
712        let mode = document.toc_mode();
713
714        Self {
715            mode: (mode != TocMode::Disabled).then_some(mode),
716            title: document.toc_title().to_string(),
717            levels: document.toc_levels(),
718            placed: false,
719        }
720    }
721
722    /// Whether the outline goes above the document's content.
723    ///
724    /// The three side placements are a page's idea — a column beside the text —
725    /// and a page of paper has no room for one, so all of them become an
726    /// outline at the front.
727    fn above(&self) -> bool {
728        matches!(
729            self.mode,
730            Some(TocMode::Auto | TocMode::Left | TocMode::Right | TocMode::Top)
731        )
732    }
733}
734
735impl Emitter {
736    /// Inline content, with its equations typeset and its pictures placed.
737    fn prose(&mut self, html: &str) -> String {
738        let converted = prose(html, self.options.math);
739
740        self.pictures(&converted)
741    }
742
743    /// Put the pictures a line of text carries into the document.
744    ///
745    /// `inline` leaves a marker where it found one, because placing a picture
746    /// needs the directory the document was read from and a place to keep the
747    /// bytes, and neither reaches a function that only knows about markup.
748    ///
749    /// A picture that cannot be read — a URL, a path that does not resolve —
750    /// keeps the words the author gave it instead, which is what the marker
751    /// carries along with the name.
752    fn pictures(&mut self, markup: &str) -> String {
753        let mut out = String::with_capacity(markup.len());
754        let mut rest = markup;
755
756        while let Some(at) = rest.find(inline::PICTURE) {
757            out.push_str(&rest[..at]);
758            rest = &rest[at + inline::PICTURE.len()..];
759
760            let Some((marker, tail)) = rest.split_once(inline::PICTURE) else {
761                out.push_str(inline::PICTURE);
762                break;
763            };
764
765            rest = tail;
766
767            // The marker is the source, the height it asked for and the words
768            // to fall back on, in that order.
769            let mut parts = marker.splitn(3, inline::FIELD);
770            let (Some(target), Some(height), Some(words)) =
771                (parts.next(), parts.next(), parts.next())
772            else {
773                continue;
774            };
775
776            match self.picture(target, height) {
777                Some(placed) => out.push_str(&placed),
778                None => out.push_str(words),
779            }
780        }
781
782        out.push_str(rest);
783        out
784    }
785
786    /// One picture in a line of text, if it can be read from disk.
787    fn picture(&mut self, target: &str, height: &str) -> Option<String> {
788        let name = format!("/{target}");
789
790        if !self.images.iter().any(|(known, _)| *known == name) {
791            let bytes = std::fs::read(self.base.join(target)).ok()?;
792
793            self.images.push((name.clone(), bytes));
794        }
795
796        // A picture in a line of text is an icon or a mark, so it is set to the
797        // height of the line unless the author asked for another — theirs is in
798        // pixels, which is three quarters of a point.
799        let height = height.parse::<f64>().map_or_else(
800            |_| "1em".to_string(),
801            |pixels| format!("{}pt", pixels * 0.75),
802        );
803
804        Some(format!("#box(image({}, height: {height}))", string(&name)))
805    }
806
807    /// Write the outline, once.
808    ///
809    /// Typst builds it from the headings themselves and makes every entry a
810    /// link, so this is the whole of it.
811    fn outline(&mut self, levels: usize, title: &str) {
812        if self.toc.placed {
813            return;
814        }
815
816        self.toc.placed = true;
817
818        let title = if title.is_empty() {
819            "none".to_string()
820        } else {
821            format!("[{}]", inline_markup(title))
822        };
823
824        let _ = writeln!(
825            self.out,
826            "#outline(title: {title}, depth: {levels})\n#v(0.5em)\n"
827        );
828    }
829
830    /// Render a sequence of blocks.
831    fn blocks<'src>(&mut self, blocks: impl Iterator<Item = &'src Block<'src>>) {
832        for block in blocks {
833            self.block(block);
834        }
835    }
836
837    /// Render one block and everything beneath it.
838    fn block(&mut self, block: &Block<'_>) {
839        // A section's title is its heading, and a picture's is its caption and
840        // is set beneath it; every other kind carries its own above it.
841        if !matches!(block, Block::Section(_) | Block::Media(_)) && !is_diagram(block) {
842            self.title(block);
843        }
844
845        match block {
846            Block::Section(section) => {
847                let level = section.level().clamp(1, 6);
848
849                // The number, if the document numbers its sections, comes from
850                // the same place the page's does — so an appendix is captioned
851                // and the two agree on every number.
852                let title = format!(
853                    "{}{}",
854                    self.prose(&self.numbering.prefix(section)),
855                    self.prose(section.section_title())
856                );
857
858                // The label goes after the heading, not before it: Typst
859                // attaches one to the element in front of it, so a label
860                // written above a heading belongs to whatever came before —
861                // which is where a reference to the section would have landed.
862                let label = section.id().map(anchor).unwrap_or_default();
863
864                // A discrete heading is styled like one but is not a section,
865                // so it does not belong in the outline.
866                if section.section_type() == SectionType::Discrete {
867                    let _ = writeln!(
868                        self.out,
869                        "#heading(level: {level}, outlined: false)[{title}]{label}\n"
870                    );
871                } else {
872                    let _ = writeln!(self.out, "{} {title}{label}\n", "=".repeat(level));
873                }
874
875                self.blocks(section.child_blocks());
876            }
877
878            Block::Simple(simple) => {
879                let text = self.prose(simple.content().rendered_html());
880
881                if !text.trim().is_empty() {
882                    let _ = writeln!(self.out, "{text}\n");
883                }
884            }
885
886            Block::List(list) => self.list(list),
887            Block::Table(table) => self.table(table),
888            Block::Preamble(preamble) => {
889                self.blocks(preamble.child_blocks());
890
891                if self.toc.mode == Some(TocMode::Preamble) {
892                    self.outline(self.toc.levels, &self.toc.title.clone());
893                }
894            }
895
896            // `toc::[]` places the outline where it stands, and may carry a
897            // title and a depth of its own.
898            Block::Toc(toc) => {
899                if self.toc.mode == Some(TocMode::Macro) {
900                    let attrlist = toc.macro_attrlist();
901
902                    let named = |name: &str| {
903                        attrlist
904                            .named_attribute(name)
905                            .map(asciidoc_parser::attributes::ElementAttribute::value)
906                            .filter(|value| !value.is_empty())
907                    };
908
909                    let title =
910                        named("title").map_or_else(|| self.toc.title.clone(), str::to_string);
911                    let levels = named("levels")
912                        .and_then(|levels| levels.parse().ok())
913                        .unwrap_or(self.toc.levels);
914
915                    self.outline(levels, &title);
916                }
917            }
918            Block::Admonition(admonition) => self.admonition(admonition),
919            Block::Quote(quote) => self.quote(quote),
920            Block::Media(media) => self.media(media),
921            Block::RawDelimited(raw) => self.raw(raw),
922            Block::CompoundDelimited(compound) => self.compound(compound),
923            // A page break is only a hint to a print stylesheet on a page; here
924            // it is the one place it can be taken literally.
925            Block::Break(r#break) => match r#break.type_() {
926                BreakType::Thematic => self.out.push_str("#line(length: 100%)\n\n"),
927                BreakType::Page => self.out.push_str("#pagebreak()\n\n"),
928            },
929
930            // A list item is rendered by its list; an attribute entry has
931            // already been applied by the parser.
932            _ => {}
933        }
934    }
935
936    /// The caption for the next figure, and the number that goes in it.
937    ///
938    /// A picture and a drawn diagram share one sequence, which the parser
939    /// cannot count: to it a diagram is a listing. The rules are the ones it
940    /// follows for a picture — only a titled figure is numbered, and
941    /// `:figure-caption!:` leaves the title to stand on its own — so both
942    /// outputs number the same figures the same way.
943    fn figure_caption(&mut self, titled: bool) -> String {
944        if !titled {
945            return String::new();
946        }
947
948        let Some(label) = self.figure.clone().filter(|label| !label.is_empty()) else {
949            return String::new();
950        };
951
952        self.figures += 1;
953
954        format!("{label} {}. ", self.figures)
955    }
956
957    /// The `.A title` line a block can carry, with the caption the document
958    /// numbers it with.
959    fn title(&mut self, block: &Block<'_>) {
960        let Some(title) = block.title() else {
961            return;
962        };
963
964        let caption = block.caption().unwrap_or_default();
965
966        let caption = self.prose(caption);
967        let title = self.prose(title);
968
969        let _ = writeln!(
970            self.out,
971            "#block(above: 1em, below: 0.5em)[#text(weight: \"bold\", size: \
972             9.5pt)[{caption}{title}]]\n"
973        );
974    }
975
976    /// A verbatim block: a listing, a literal, or something with no rendering
977    /// of its own.
978    fn raw(&mut self, raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>) {
979        // Taken as text rather than unescaped, because a listing's callouts
980        // reach here as `<b class="conum">` and would otherwise be shown.
981        let content = inline::text(&self.marks(raw.content().rendered_html()));
982
983        if self.diagram(raw, &content) {
984            return;
985        }
986
987        match raw.raw_context().as_ref() {
988            // A comment reaches the page as nothing at all, and so does a
989            // passthrough, which is HTML and has no meaning here.
990            "comment" | "pass" => {}
991
992            "stem" => self.equation(raw, content.trim()),
993
994            // A listing names its language, which Typst highlights with the
995            // syntaxes it carries; a literal block names none and is set plain.
996            _ => {
997                let language = (raw.raw_context().as_ref() == "listing")
998                    .then(|| source_language(raw))
999                    .flatten();
1000
1001                self.verbatim_as(&content, language.as_deref());
1002            }
1003        }
1004    }
1005
1006    /// A displayed equation, typeset if it can be and shown as written if not.
1007    ///
1008    /// Only LaTeX is translated; see [`math`] for why `AsciiMath` is not, and
1009    /// `--no-math` translates neither.
1010    fn equation(&mut self, raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>, source: &str) {
1011        if let Some(typeset) = self.mathematics(raw, source) {
1012            let _ = writeln!(self.out, "$ {typeset} $\n");
1013
1014            return;
1015        }
1016
1017        let _ = writeln!(self.out, "#align(center)[#raw({})]\n", string(source));
1018    }
1019
1020    /// One equation as Typst mathematics, if this run converts them.
1021    fn mathematics(
1022        &self,
1023        raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>,
1024        source: &str,
1025    ) -> Option<String> {
1026        self.options
1027            .math
1028            .then(|| converted(source, self.is_latex(raw)))
1029            .flatten()
1030    }
1031
1032    /// Whether an equation is written in LaTeX.
1033    ///
1034    /// `[latexmath]` says so outright; a plain `[stem]` takes whatever `:stem:`
1035    /// was set to, which is `AsciiMath` unless it says otherwise. Read the way
1036    /// the page reads it, so one block cannot be two notations.
1037    fn is_latex(&self, raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>) -> bool {
1038        match raw.declared_style() {
1039            Some("latexmath") => true,
1040            Some("asciimath") => false,
1041            _ => self.stem.as_deref() == Some("latexmath"),
1042        }
1043    }
1044
1045    /// Draw a mermaid block as a diagram, saying whether it was one.
1046    ///
1047    /// `merman` produces SVG, which Typst places as a vector image, so a
1048    /// diagram is drawn in the PDF at whatever resolution it is read or printed
1049    /// at. A diagram `merman` declines falls through to being shown as source,
1050    /// which is what it looked like before this could draw at all.
1051    #[cfg(feature = "mermaid")]
1052    fn diagram(
1053        &mut self,
1054        raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>,
1055        content: &str,
1056    ) -> bool {
1057        if !is_mermaid(raw) {
1058            return false;
1059        }
1060
1061        let Some(svg) = adocers_render_core::mermaid::printable(content) else {
1062            return false;
1063        };
1064
1065        let name = format!("/diagram-{}.svg", self.images.len());
1066        self.images.push((name.clone(), svg.into_bytes()));
1067
1068        // A diagram is drawn to the size it needs and no other.
1069        self.figure(&name, raw, "");
1070
1071        true
1072    }
1073
1074    /// Never draws: `merman` is not compiled in.
1075    #[cfg(not(feature = "mermaid"))]
1076    #[expect(
1077        clippy::unused_self,
1078        reason = "the other half of this pair collects the drawing, and both are called the same \
1079                  way"
1080    )]
1081    fn diagram(
1082        &mut self,
1083        _raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>,
1084        _content: &str,
1085    ) -> bool {
1086        false
1087    }
1088
1089    /// A block of text set exactly as written.
1090    fn verbatim(&mut self, content: &str) {
1091        self.verbatim_as(content, None);
1092    }
1093
1094    /// The same, highlighted as `language` when one is named.
1095    ///
1096    /// Typst carries syntect and its syntaxes, so highlighting a listing costs
1097    /// nothing but naming the language. The colours are therefore Typst's
1098    /// rather than the tree-sitter ones the page uses — the same code, read by
1099    /// a different highlighter — and a language neither knows is simply set
1100    /// plain.
1101    fn verbatim_as(&mut self, content: &str, language: Option<&str>) {
1102        let language = match language {
1103            Some(language) => format!(", lang: {}", string(language)),
1104            None => String::new(),
1105        };
1106
1107        let _ = writeln!(
1108            self.out,
1109            "#raw({}, block: true{language})\n",
1110            string(content)
1111        );
1112    }
1113
1114    /// `====` example, `****` sidebar and `--` open blocks.
1115    fn compound(&mut self, compound: &asciidoc_parser::blocks::CompoundDelimitedBlock<'_>) {
1116        // An abstract is the summary of a document rather than a block of its
1117        // own, and is set apart the way a quotation is — which is also what the
1118        // page does with it.
1119        let abstracted = compound.declared_style() == Some("abstract");
1120
1121        let framed = !abstracted
1122            && matches!(
1123                compound.context_kind(),
1124                CompoundDelimitedContext::Example | CompoundDelimitedContext::Sidebar
1125            );
1126
1127        if framed {
1128            self.out.push_str(
1129                "#block(width: 100%, fill: rgb(\"#f2f4f7\"), inset: 10pt, radius: 3pt)[\n",
1130            );
1131        }
1132
1133        if abstracted {
1134            self.out
1135                .push_str("#block(inset: (left: 12pt), stroke: (left: 2pt + rgb(\"#dcdfe4\")))[\n");
1136        }
1137
1138        self.blocks(compound.child_blocks());
1139
1140        if framed || abstracted {
1141            self.out.push_str("]\n\n");
1142        }
1143    }
1144
1145    /// A note, tip, warning and so on, set beside its icon.
1146    ///
1147    /// Laid out the way the page lays it out: the mark in a column of its own,
1148    /// and the text beside it behind a rule.
1149    fn admonition(&mut self, admonition: &asciidoc_parser::blocks::AdmonitionBlock<'_>) {
1150        let name = admonition.name().to_string();
1151        let label = admonition.label().to_string().to_uppercase();
1152        let colour = admonition_colour(&name);
1153
1154        let mark = match self.icon(&name, &label) {
1155            Some(path) => format!("#image({}, width: 20pt)", string(&path)),
1156
1157            // A variant with no icon of its own, or a build asked for none,
1158            // falls back to the label rather than to an empty column.
1159            None => format!("#text(weight: \"bold\", size: 8pt, fill: rgb(\"{colour}\"))[{label}]"),
1160        };
1161
1162        let _ = write!(
1163            self.out,
1164            "#grid(\n  columns: (34pt, 1fr),\n  align: (center + horizon, left + horizon),\n  \
1165             inset: (x, y) => if x == 1 {{ (left: 11pt, y: 2pt) }} else {{ (right: 10pt) }},\n  \
1166             stroke: (x, y) => if x == 1 {{ (left: 1pt + rgb(\"#dcdfe4\")) }} else {{ none }},\n  \
1167             [{mark}],\n  [\n"
1168        );
1169
1170        // The rule is the grid's own rather than a border on the text: a line
1171        // drawn around the content is as short as the content, and beside a
1172        // one-line note that is shorter than the mark next to it.
1173        //
1174        // The paragraph form — `TIP: text` — carries its text directly and has
1175        // no child blocks at all, so asking only for the children loses it.
1176        match admonition.content() {
1177            Some(content) => {
1178                let converted = self.prose(content.rendered_html());
1179                self.out.push_str(&converted);
1180                self.out.push_str("\n\n");
1181            }
1182
1183            None => self.blocks(admonition.child_blocks()),
1184        }
1185
1186        self.out.push_str("],\n)\n\n");
1187    }
1188
1189    /// Draw a listing's callout markers, if this run draws marks at all.
1190    ///
1191    /// A circled number is a character here rather than something drawn, which
1192    /// means it sits in the line of code exactly where the marker was without
1193    /// anything being laid over the text. Above twenty there is no such
1194    /// character, and the `(21)` the parser wrote stands.
1195    fn marks(&self, html: &str) -> String {
1196        if !self.options.icons {
1197            return html.to_string();
1198        }
1199
1200        let mut out = String::with_capacity(html.len());
1201        let mut rest = html;
1202
1203        while let Some(at) = rest.find(CONUM) {
1204            out.push_str(&rest[..at]);
1205            rest = &rest[at + CONUM.len()..];
1206
1207            let number: String = rest.chars().take_while(char::is_ascii_digit).collect();
1208
1209            let Some(mark) = number.parse().ok().and_then(circled) else {
1210                out.push_str(CONUM);
1211                continue;
1212            };
1213
1214            let Some(end) = rest.find("</b>") else {
1215                out.push_str(CONUM);
1216                continue;
1217            };
1218
1219            out.push(mark);
1220            rest = &rest[end + "</b>".len()..];
1221        }
1222
1223        out.push_str(rest);
1224        out
1225    }
1226
1227    /// The icon for an admonition, as a file the compiler can place.
1228    ///
1229    /// The page's own SVG, with its colour written in: it is drawn in
1230    /// `currentColor` so that one rule themes it and it follows the reader's
1231    /// colour scheme, and a PDF has neither a rule nor a reader.
1232    fn icon(&mut self, name: &str, label: &str) -> Option<String> {
1233        if !self.options.icons {
1234            return None;
1235        }
1236
1237        let path = format!("/icon-{name}.svg");
1238
1239        if !self.images.iter().any(|(known, _)| *known == path) {
1240            let svg = adocers_render_core::icons::admonition(name, label)?
1241                .replace("currentColor", admonition_colour(name));
1242
1243            self.images.push((path.clone(), svg.into_bytes()));
1244        }
1245
1246        Some(path)
1247    }
1248
1249    /// A quotation, with its attribution below.
1250    fn quote(&mut self, quote: &asciidoc_parser::blocks::QuoteBlock<'_>) {
1251        self.out
1252            .push_str("#block(inset: (left: 12pt), stroke: (left: 2pt + rgb(\"#dcdfe4\")))[\n");
1253
1254        match quote.content() {
1255            // A verse is written in lines and has to keep them; ordinary quoted
1256            // prose flows like any other paragraph.
1257            Some(content) if quote.type_() == QuoteType::Verse => {
1258                self.verbatim(&inline::text(content.rendered_html()));
1259            }
1260
1261            Some(content) => {
1262                let converted = self.prose(content.rendered_html());
1263                self.out.push_str(&converted);
1264                self.out.push_str("\n\n");
1265            }
1266
1267            None => self.blocks(quote.child_blocks()),
1268        }
1269
1270        if let Some(attribution) = quote.attribution() {
1271            let attribution = self.prose(attribution);
1272
1273            let _ = writeln!(
1274                self.out,
1275                "#text(size: 9pt, fill: rgb(\"#656d77\"))[— {attribution}]"
1276            );
1277        }
1278
1279        self.out.push_str("]\n\n");
1280    }
1281
1282    /// Place a media target inside `:imagesdir:`, the way the page does.
1283    ///
1284    /// A document keeps its pictures in one directory and names the rest
1285    /// relative to it. The parser has already done this to a picture in a line
1286    /// of text, which reaches here as the `src` of an `<img>`, but it leaves a
1287    /// block's target as the author wrote it — so a block's is resolved here.
1288    /// A target that says where it lives already, a URL or a `data:` URI or an
1289    /// absolute path, is left alone.
1290    fn in_images_dir(&self, target: &str) -> String {
1291        let Some(dir) = self.images_dir.as_deref() else {
1292            return target.to_string();
1293        };
1294
1295        if target.starts_with('/') || target.starts_with("data:") || target.contains("://") {
1296            return target.to_string();
1297        }
1298
1299        format!("{}/{target}", dir.trim_end_matches('/'))
1300    }
1301
1302    /// An image, or a video and audio reference that a page cannot play.
1303    fn media(&mut self, media: &asciidoc_parser::blocks::MediaBlock<'_>) {
1304        let target = self.in_images_dir(media.resolved_target());
1305
1306        // A picture has to be readable from disk to be placed. One that is not
1307        // — a URL, or a video, or a path that does not resolve — is named
1308        // instead, so the reader knows what was meant.
1309        let Ok(bytes) = std::fs::read(self.base.join(&target)) else {
1310            let _ = writeln!(
1311                self.out,
1312                "#text(style: \"italic\", fill: rgb(\"#656d77\"))[[{}]]\n",
1313                inline_markup(&target)
1314            );
1315
1316            return;
1317        };
1318
1319        self.images.push((format!("/{target}"), bytes));
1320
1321        // The size the author asked for, in the pixels AsciiDoc measures in.
1322        // A picture that says nothing is placed at its own size, and either way
1323        // one wider than the column is brought down to fit.
1324        let width = attrlist_size(media, "width", 2);
1325        let height = attrlist_size(media, "height", 3);
1326
1327        self.figure(&format!("/{target}"), media, &size(width, height));
1328    }
1329
1330    /// A picture, with its caption beneath it and held on the same page.
1331    fn figure<'src>(&mut self, name: &str, block: &impl IsBlock<'src>, size: &str) {
1332        let caption = match block.title() {
1333            Some(title) => {
1334                let label = self.figure_caption(block.title().is_some());
1335
1336                format!(
1337                    ", caption: [{}{}], numbering: none",
1338                    self.prose(&label),
1339                    self.prose(title)
1340                )
1341            }
1342
1343            None => String::new(),
1344        };
1345
1346        let _ = writeln!(
1347            self.out,
1348            "#block(breakable: false, width: 100%)[#figure(fitted(image({}{size})){caption})]\n",
1349            string(name)
1350        );
1351    }
1352
1353    /// Any of the list kinds.
1354    fn list(&mut self, list: &asciidoc_parser::blocks::ListBlock<'_>) {
1355        let items: Vec<_> = list
1356            .child_blocks()
1357            .filter_map(|block| match block {
1358                Block::ListItem(item) => Some(item),
1359                _ => None,
1360            })
1361            .collect();
1362
1363        // A checklist writes a box in place of a bullet, the way the page does.
1364        // The set rule is scoped to a block of its own rather than put back
1365        // afterwards: Typst's bullet changes with the depth of the list, and
1366        // there is no one value to restore.
1367        let checklist = list.type_() == ListType::Unordered && list.is_checklist();
1368
1369        if checklist {
1370            self.out.push_str("#[\n#set list(marker: none)\n");
1371        }
1372
1373        // A numbered list that is lettered, starts somewhere other than one, or
1374        // counts down says so once before its items and puts it back after.
1375        let numbered = list.type_() == ListType::Ordered;
1376        let start = numbered.then(|| list.start()).flatten();
1377        let reversed = numbered && list.has_option("reversed");
1378
1379        let pattern = numbered
1380            .then(|| numbering(list))
1381            .flatten()
1382            .filter(|pattern| *pattern != ARABIC);
1383
1384        if let Some(pattern) = pattern {
1385            let _ = writeln!(self.out, "#set enum(numbering: {})", string(pattern));
1386        }
1387
1388        // A callout list is marked the way the listing above it is, so an item
1389        // and the line it annotates carry the same mark.
1390        let marked = list.type_() == ListType::Callout && self.options.icons;
1391
1392        if marked {
1393            self.out
1394                .push_str("#set enum(numbering: n => adoccallout(n))\n");
1395        }
1396
1397        if let Some(start) = start {
1398            let _ = writeln!(self.out, "#set enum(start: {start})");
1399        }
1400
1401        if reversed {
1402            // Counting down means the first item carries the highest number,
1403            // which is wherever the list starts plus one for every item after
1404            // the first. Typst has no reversed enumeration, so the numbering is
1405            // written out instead.
1406            let highest = start.unwrap_or(1) + i64::try_from(items.len()).unwrap_or(1) - 1;
1407
1408            let _ = writeln!(
1409                self.out,
1410                "#set enum(numbering: n => numbering({}, {highest} - n + 1))",
1411                string(pattern.unwrap_or(ARABIC))
1412            );
1413        }
1414
1415        for item in items {
1416            let bullet = match list.type_() {
1417                ListType::Ordered | ListType::Callout => "+",
1418                ListType::Description => "/",
1419                ListType::Unordered => "-",
1420            };
1421
1422            // A term list writes the term before the colon; every other kind
1423            // has only the item.
1424            let term = match (list.type_(), item.list_item_marker()) {
1425                (
1426                    ListType::Description,
1427                    asciidoc_parser::blocks::ListItemMarker::DefinedTerm { term, .. },
1428                ) => format!("{}: ", self.prose(term.rendered_html())),
1429
1430                (ListType::Description, _) => ": ".to_string(),
1431                _ => String::new(),
1432            };
1433
1434            let body = self.item(item);
1435            let _ = writeln!(self.out, "{bullet} {term}{}{body}", box_of(item, checklist));
1436        }
1437
1438        if start.is_some() {
1439            self.out.push_str("#set enum(start: 1)\n");
1440        }
1441
1442        if reversed || marked || pattern.is_some() {
1443            let _ = writeln!(self.out, "#set enum(numbering: {})", string(ARABIC));
1444        }
1445
1446        if checklist {
1447            self.out.push_str("]\n");
1448        }
1449
1450        self.out.push('\n');
1451    }
1452
1453    /// Everything one list item holds, as markup that can follow its marker.
1454    ///
1455    /// An item is a paragraph, and may be several blocks — a nested list, a
1456    /// listing, a note. Typst reads a line that starts in the first column as
1457    /// the end of the item, so everything after the first line is indented to
1458    /// stay part of it.
1459    fn item(&mut self, item: &asciidoc_parser::blocks::ListItem<'_>) -> String {
1460        let taken = std::mem::take(&mut self.out);
1461        self.blocks(item.child_blocks());
1462        let body = std::mem::replace(&mut self.out, taken);
1463
1464        let mut lines = body.trim_end().lines();
1465        let mut out = lines.next().unwrap_or_default().to_string();
1466
1467        for line in lines {
1468            out.push('\n');
1469
1470            if !line.is_empty() {
1471                out.push_str("  ");
1472            }
1473
1474            out.push_str(line);
1475        }
1476
1477        out
1478    }
1479
1480    /// A table, as Typst's own.
1481    fn table(&mut self, table: &asciidoc_parser::blocks::TableBlock<'_>) {
1482        let columns = table.columns().len().max(1);
1483
1484        let _ = writeln!(
1485            self.out,
1486            "#table(\n  columns: {columns},\n  stroke: 0.5pt + rgb(\"#dcdfe4\"),"
1487        );
1488
1489        if let Some(header) = table.header_row() {
1490            self.out.push_str("  table.header(");
1491            self.cells(header, true);
1492            self.out.push_str("),\n");
1493        }
1494
1495        for row in table.body_rows() {
1496            self.out.push_str("  ");
1497            self.cells(row, false);
1498            self.out.push('\n');
1499        }
1500
1501        // A footer is set like a header, because it repeats the header's
1502        // headings and is read the same way.
1503        if let Some(footer) = table.footer_row() {
1504            self.out.push_str("  table.footer(");
1505            self.cells(footer, true);
1506            self.out.push_str("),\n");
1507        }
1508
1509        self.out.push_str(")\n\n");
1510    }
1511
1512    /// One row's cells, as Typst content blocks.
1513    fn cells(&mut self, row: &asciidoc_parser::blocks::TableRow<'_>, heading: bool) {
1514        for cell in row.cells() {
1515            let text = match cell.content() {
1516                asciidoc_parser::blocks::TableCellContent::Simple(content) => {
1517                    self.prose(content.rendered_html())
1518                }
1519
1520                // A cell holding whole blocks is reduced to its text: a table
1521                // within a table is more structure than a page wants.
1522                asciidoc_parser::blocks::TableCellContent::AsciiDoc(_) => String::new(),
1523            };
1524
1525            let body = if heading {
1526                format!("[#text(weight: \"bold\")[{text}]]")
1527            } else {
1528                format!("[{text}]")
1529            };
1530
1531            // A cell that reaches across columns or down rows has to say so,
1532            // or the grid puts everything after it in the wrong place.
1533            let (columns, rows) = (cell.colspan(), cell.rowspan());
1534
1535            if columns > 1 || rows > 1 {
1536                let _ = write!(
1537                    self.out,
1538                    "table.cell(colspan: {columns}, rowspan: {rows}){body}, "
1539                );
1540
1541                continue;
1542            }
1543
1544            let _ = write!(self.out, "{body}, ");
1545        }
1546    }
1547}
1548
1549/// Every label the markup defines, as against the ones it refers to.
1550///
1551/// A definition is written either as `<name>` after a heading or as a
1552/// `#label("name")` call; a reference is always `#link(label("name"))`, which
1553/// is what tells the two apart.
1554fn defined(markup: &str) -> std::collections::HashSet<String> {
1555    let mut labels = std::collections::HashSet::new();
1556    let mut rest = markup;
1557
1558    while let Some(at) = rest.find("#label(\"") {
1559        let before = &rest[..at];
1560        let after = &rest[at + "#label(\"".len()..];
1561
1562        if let Some(quote) = after.find("\")")
1563            && !before.ends_with("#link(")
1564        {
1565            labels.insert(after[..quote].to_string());
1566        }
1567
1568        rest = after;
1569    }
1570
1571    // A heading's own label, which is the last thing on its line.
1572    for line in markup.lines() {
1573        let Some(name) = line
1574            .strip_suffix('>')
1575            .and_then(|line| line.rsplit_once(" <"))
1576        else {
1577            continue;
1578        };
1579
1580        // `\>` is an angle bracket the author wrote, not a label.
1581        if !name.0.ends_with('\\') {
1582            labels.insert(name.1.to_string());
1583        }
1584    }
1585
1586    labels
1587}
1588
1589/// Turn a reference to a label that was never emitted back into plain text.
1590///
1591/// The HTML back end can point at anything, because a browser simply does
1592/// nothing with a broken fragment. Typst refuses to lay out a document that
1593/// links to a label it cannot find, so a cross reference to a section survives
1594/// and everything else — a footnote's mark, an anchor on a paragraph — becomes
1595/// the words it was written as.
1596fn prune_links(markup: &str) -> String {
1597    let labels = defined(markup);
1598
1599    let mut out = String::with_capacity(markup.len());
1600    let mut rest = markup;
1601
1602    while let Some(at) = rest.find("#link(label(\"") {
1603        out.push_str(&rest[..at]);
1604
1605        let after = &rest[at + "#link(label(\"".len()..];
1606
1607        let Some(quote) = after.find("\")") else {
1608            out.push_str(&rest[at..]);
1609            return out;
1610        };
1611
1612        let name = &after[..quote];
1613        let body = &after[quote + "\")".len()..];
1614
1615        if labels.contains(name) {
1616            // Keep it whole; the label is there to be reached.
1617            out.push_str(&rest[at..at + "#link(label(\"".len() + quote + "\")".len()]);
1618            rest = body;
1619            continue;
1620        }
1621
1622        // Keep the text, drop the link around it — including the paren that
1623        // closed the `#link(` this is unwrapping.
1624        let body = body.strip_prefix(')').unwrap_or(body);
1625
1626        match (body.strip_prefix('['), body.find(']')) {
1627            (Some(_), Some(end)) => {
1628                out.push_str(&body[1..end]);
1629                rest = &body[end + 1..];
1630            }
1631
1632            _ => {
1633                rest = body;
1634            }
1635        }
1636    }
1637
1638    out.push_str(rest);
1639    out
1640}
1641
1642/// Whether a block is a diagram that will be drawn, and so captioned beneath.
1643#[cfg(feature = "mermaid")]
1644fn is_diagram(block: &Block<'_>) -> bool {
1645    matches!(block, Block::RawDelimited(raw) if is_mermaid(raw))
1646}
1647
1648/// Never: no block is drawn as a diagram in a build without `merman`.
1649#[cfg(not(feature = "mermaid"))]
1650fn is_diagram(_block: &Block<'_>) -> bool {
1651    false
1652}
1653
1654/// One of a picture's size attributes, named or in its place in the macro.
1655fn attrlist_size(
1656    media: &asciidoc_parser::blocks::MediaBlock<'_>,
1657    name: &str,
1658    index: usize,
1659) -> Option<f64> {
1660    let attrlist = media.macro_attrlist();
1661
1662    let value = attrlist
1663        .named_attribute(name)
1664        .or_else(|| {
1665            attrlist
1666                .nth_attribute(index)
1667                .filter(|attribute| attribute.name().is_none())
1668        })
1669        .map(asciidoc_parser::attributes::ElementAttribute::value)
1670        .filter(|value| !value.is_empty())?;
1671
1672    value.trim().trim_end_matches('%').parse().ok()
1673}
1674
1675/// What a picture's declared size is as an argument to Typst's `image`.
1676///
1677/// AsciiDoc measures in pixels and Typst in points, which are three quarters as
1678/// many. A picture that declares neither is placed at its own size.
1679fn size(width: Option<f64>, height: Option<f64>) -> String {
1680    let mut out = String::new();
1681
1682    if let Some(width) = width {
1683        let _ = write!(out, ", width: {}pt", width * 0.75);
1684    }
1685
1686    if let Some(height) = height {
1687        let _ = write!(out, ", height: {}pt", height * 0.75);
1688    }
1689
1690    out
1691}
1692
1693/// A label, written so that Typst attaches it to what comes before it.
1694///
1695/// The angle form is the one Typst reads as a section's own name, which is
1696/// what puts "Section" in front of a reference in a PDF viewer's own display
1697/// of the link. An id it cannot read that way keeps its label all the same,
1698/// written as a call.
1699fn anchor(id: &str) -> String {
1700    let readable = !id.is_empty()
1701        && id
1702            .chars()
1703            .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.' | ':'));
1704
1705    if readable {
1706        return format!(" <{id}>");
1707    }
1708
1709    format!(" #label({})", string(id))
1710}
1711
1712/// The box an item of a checklist is marked with.
1713///
1714/// The characters the page uses, so the two read alike. Only an item that
1715/// carries a checkbox gets one: an item written without is an ordinary item
1716/// that happens to share the list, and marking it unfinished would say
1717/// something its author did not.
1718fn box_of(item: &asciidoc_parser::blocks::ListItem<'_>, checklist: bool) -> &'static str {
1719    if !checklist {
1720        return "";
1721    }
1722
1723    match item.checkbox() {
1724        Some(true) => "\u{2713} ",
1725        Some(false) => "\u{274f} ",
1726        None => "",
1727    }
1728}
1729
1730/// The markup the parser renders a callout marker as, up to its number.
1731const CONUM: &str = "<b class=\"conum\">(";
1732
1733/// A number in a filled circle, for the callouts a listing and its list share.
1734///
1735/// The character is the mark: a disc with the numeral knocked out of it, which
1736/// is the page's mark as well — there a circle drawn by the stylesheet, here a
1737/// glyph, and both blue with the paper showing through the number.
1738///
1739/// Unicode has these for one to twenty and no further, in two runs, so anything
1740/// past twenty has no mark and keeps the number the parser wrote.
1741fn circled(number: u32) -> Option<char> {
1742    let point = match number {
1743        1..=10 => 0x2775 + number,
1744        11..=20 => 0x24e0 + number,
1745        _ => return None,
1746    };
1747
1748    char::from_u32(point)
1749}
1750
1751/// The colour an admonition's mark is drawn in.
1752///
1753/// Asciidoctor's own, which is where the page's palette takes them from; the
1754/// light values, a printed page being light.
1755fn admonition_colour(name: &str) -> &'static str {
1756    match name {
1757        "tip" => "#b58900",
1758        "important" => "#bf6900",
1759        "caution" => "#bf3400",
1760        "warning" => "#bf0000",
1761        _ => "#19407c",
1762    }
1763}
1764
1765/// Typst's pattern for ordinary numbering, which is also what a list is put
1766/// back to once one that numbered itself differently has ended.
1767const ARABIC: &str = "1.";
1768
1769/// How an ordered list is numbered, as a Typst numbering pattern.
1770///
1771/// A declared style says outright; otherwise the marker's depth decides, which
1772/// is how AsciiDoc gives a nested list letters without being told to. Both are
1773/// read the way the HTML back end reads them, so a list is numbered the same
1774/// way in both.
1775fn numbering(list: &asciidoc_parser::blocks::ListBlock<'_>) -> Option<&'static str> {
1776    let style = list
1777        .declared_style()
1778        .filter(|style| {
1779            matches!(
1780                *style,
1781                "arabic"
1782                    | "decimal"
1783                    | "loweralpha"
1784                    | "upperalpha"
1785                    | "lowerroman"
1786                    | "upperroman"
1787                    | "lowergreek"
1788            )
1789        })
1790        .or_else(|| list.marker_style())?;
1791
1792    let pattern = match style {
1793        "loweralpha" => "a.",
1794        "upperalpha" => "A.",
1795        "lowerroman" => "i.",
1796        "upperroman" => "I.",
1797
1798        // `lowergreek` has no pattern of its own here, and numbers rather than
1799        // the wrong alphabet is the better of the two wrong answers.
1800        _ => ARABIC,
1801    };
1802
1803    Some(pattern)
1804}
1805
1806/// The language a listing declares, under the name Typst's highlighter knows
1807/// it by.
1808///
1809/// The aliases are the ones `render::highlight` resolves, so a `[source,rs]`
1810/// block is highlighted here as well as on the page. A name neither of them
1811/// knows is passed through: Typst matches on syntax names and file extensions
1812/// too, and anything it cannot place is simply set plain.
1813fn source_language(raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>) -> Option<String> {
1814    if raw.declared_style()? != "source" {
1815        return None;
1816    }
1817
1818    let declared = raw
1819        .attrlist()?
1820        .named_attribute("language")
1821        .or_else(|| raw.attrlist()?.nth_attribute(2))
1822        .map(asciidoc_parser::attributes::ElementAttribute::value)
1823        .filter(|language| !language.is_empty())?
1824        .to_lowercase();
1825
1826    let resolved = match declared.as_str() {
1827        "sh" | "shell" | "zsh" | "console" | "terminal" => "bash",
1828        "js" | "mjs" | "cjs" | "node" => "javascript",
1829        "ts" => "typescript",
1830        "py" | "python3" => "python",
1831        "rs" => "rust",
1832        "golang" => "go",
1833        "yml" => "yaml",
1834        "htm" | "xhtml" => "html",
1835        "jsonc" => "json",
1836        _ => return Some(declared),
1837    };
1838
1839    Some(resolved.to_string())
1840}
1841
1842/// Whether a verbatim block was written as a mermaid diagram.
1843///
1844/// The same two spellings the page recognises: `[mermaid]`, which
1845/// `asciidoctor-diagram` uses, and `[source,mermaid]`, which GitHub draws.
1846#[cfg(feature = "mermaid")]
1847fn is_mermaid(raw: &asciidoc_parser::blocks::RawDelimitedBlock<'_>) -> bool {
1848    match raw.declared_style() {
1849        Some(style) if style.to_lowercase() == "mermaid" => true,
1850
1851        Some(style) if style.to_lowercase() == "source" => raw
1852            .attrlist()
1853            .and_then(|attrlist| {
1854                attrlist
1855                    .named_attribute("language")
1856                    .or_else(|| attrlist.nth_attribute(2))
1857            })
1858            .is_some_and(|language| language.value().to_lowercase() == "mermaid"),
1859
1860        _ => false,
1861    }
1862}
1863
1864#[cfg(test)]
1865mod tests {
1866    use super::*;
1867    use asciidoc_parser::Parser;
1868
1869    /// The defaults a render runs with: marks drawn, diagrams drawn.
1870    fn options() -> Options {
1871        Options {
1872            icons: true,
1873            highlight: true,
1874            mermaid: true,
1875            math: true,
1876        }
1877    }
1878
1879    fn render(source: &str) -> String {
1880        let mut parser = Parser::default();
1881        let document = parser.parse(source);
1882
1883        markup(&document, Path::new("."), &options()).0
1884    }
1885
1886    #[test]
1887    fn emits_a_heading_and_a_paragraph() {
1888        let out = render("= Title\n\n== A section\n\nSome *bold* prose.\n");
1889
1890        assert!(out.contains("= A section"), "{out}");
1891        assert!(out.contains("#strong[bold]"), "{out}");
1892    }
1893
1894    #[test]
1895    fn emits_a_list() {
1896        let out = render("= T\n\n* one\n* two\n");
1897
1898        assert!(out.contains("- one"), "{out}");
1899        assert!(out.contains("- two"), "{out}");
1900    }
1901
1902    #[test]
1903    fn emits_a_table() {
1904        let out = render("= T\n\n|===\n|a |b\n|===\n");
1905
1906        assert!(out.contains("#table("), "{out}");
1907        assert!(out.contains("columns: 2"), "{out}");
1908    }
1909
1910    #[test]
1911    fn hands_a_listing_its_language_to_highlight() {
1912        let out = render("= T\n\n[source,rs]\n----\nfn main() {}\n----\n");
1913
1914        assert!(out.contains("lang: \"rust\""), "{out}");
1915    }
1916
1917    #[test]
1918    fn numbers_a_nested_list_the_way_its_depth_says() {
1919        let out = render("= T\n\n. one\n.. two\n");
1920
1921        assert!(out.contains("#set enum(numbering: \"a.\")"), "{out}");
1922    }
1923
1924    #[test]
1925    fn places_an_outline_when_the_document_asks_for_one() {
1926        let out = render("= T\n:toc:\n:toclevels: 3\n\n== A section\n");
1927
1928        assert!(
1929            out.contains("#outline(title: [Table of Contents], depth: 3)"),
1930            "{out}"
1931        );
1932    }
1933
1934    #[test]
1935    fn hands_typst_the_language_the_document_is_written_in() {
1936        let out = render("= T\n:lang: de\n\nText.\n");
1937
1938        assert!(
1939            out.contains("#set text(size: 10.5pt, lang: \"de\")"),
1940            "{out}"
1941        );
1942    }
1943
1944    #[test]
1945    fn splits_a_region_off_the_language_tag() {
1946        let out = render("= T\n:lang: pt-BR\n\nText.\n");
1947
1948        assert!(
1949            out.contains("#set text(size: 10.5pt, lang: \"pt\", region: \"BR\")"),
1950            "{out}"
1951        );
1952    }
1953
1954    #[test]
1955    fn reads_the_region_past_a_script_subtag() {
1956        let out = render("= T\n:lang: zh-Hans-CN\n\nText.\n");
1957
1958        assert!(
1959            out.contains("#set text(size: 10.5pt, lang: \"zh\", region: \"CN\")"),
1960            "{out}"
1961        );
1962    }
1963
1964    #[test]
1965    fn leaves_typst_its_own_default_when_the_document_names_no_language() {
1966        let out = render("= T\n\nText.\n");
1967
1968        assert!(out.contains("#set text(size: 10.5pt)"), "{out}");
1969    }
1970
1971    #[test]
1972    fn drops_a_language_tag_typst_could_not_read() {
1973        for tag in ["deutsch", "d", "1234", "de_DE"] {
1974            let out = render(&format!("= T\n:lang: {tag}\n\nText.\n"));
1975
1976            assert!(out.contains("#set text(size: 10.5pt)"), "{tag}: {out}");
1977        }
1978    }
1979
1980    #[test]
1981    fn places_no_outline_when_it_was_not_asked_for() {
1982        let out = render("= T\n\n== A section\n");
1983
1984        assert!(!out.contains("#outline"), "{out}");
1985    }
1986
1987    #[test]
1988    fn keeps_a_discrete_heading_out_of_the_outline() {
1989        let out = render("= T\n:toc:\n\n[discrete]\n== Aside\n\n== A section\n");
1990
1991        assert!(out.contains("outlined: false"), "{out}");
1992    }
1993
1994    #[test]
1995    fn keeps_an_items_own_blocks_inside_it() {
1996        let out = render("= T\n\n* one\n+\nMore about one.\n* two\n");
1997
1998        // Typst reads a line in the first column as the end of the item, so
1999        // everything after the first line has to be indented.
2000        assert!(out.contains("- one\n\n  More about one."), "{out}");
2001    }
2002
2003    #[test]
2004    fn numbers_a_list_from_where_it_says() {
2005        let out = render("= T\n\n[start=7]\n. seven\n. eight\n");
2006
2007        assert!(out.contains("#set enum(start: 7)"), "{out}");
2008        assert!(
2009            out.contains("#set enum(start: 1)"),
2010            "should be put back: {out}"
2011        );
2012    }
2013
2014    #[test]
2015    fn keeps_the_text_of_a_paragraph_admonition() {
2016        let out = render("= T\n\nTIP: Worth knowing.\n");
2017
2018        assert!(out.contains("/icon-tip.svg"), "{out}");
2019        assert!(out.contains("Worth knowing."), "{out}");
2020    }
2021
2022    #[test]
2023    fn labels_an_admonition_when_it_draws_no_icon() {
2024        let mut parser = Parser::default();
2025        let document = parser.parse("= T\n\nTIP: Worth knowing.\n");
2026
2027        let plain = Options {
2028            icons: false,
2029            ..options()
2030        };
2031
2032        let out = markup(&document, Path::new("."), &plain).0;
2033
2034        assert!(out.contains("TIP"), "{out}");
2035        assert!(!out.contains("icon-tip"), "{out}");
2036    }
2037
2038    #[test]
2039    fn marks_a_callout_and_its_item_the_same_way() {
2040        let out = render("= T\n\n----\ncode <1>\n----\n<1> Why.\n");
2041
2042        assert!(
2043            out.contains('\u{2776}'),
2044            "the listing keeps its mark: {out}"
2045        );
2046        assert!(out.contains("adoccallout"), "and so does its list: {out}");
2047    }
2048
2049    #[test]
2050    fn a_block_picture_is_looked_for_inside_imagesdir() {
2051        // A picture that was not found is named as it was looked for, which is
2052        // the directory `:imagesdir:` names rather than the document's own.
2053        let out = render("= T\n:imagesdir: pics\n\nimage::figure.png[]\n");
2054
2055        assert!(out.contains("[pics\\/figure.png]"), "{out}");
2056    }
2057
2058    #[test]
2059    fn a_picture_that_says_where_it_lives_keeps_it() {
2060        let out = render("= T\n:imagesdir: pics\n\nimage::https://example.com/f.png[]\n");
2061
2062        assert!(out.contains("[https:\\/\\/example.com\\/f.png]"), "{out}");
2063    }
2064
2065    #[test]
2066    fn takes_a_page_break_literally() {
2067        let out = render("= T\n\none\n\n<<<\n\ntwo\n");
2068
2069        assert!(out.contains("#pagebreak()"), "{out}");
2070    }
2071
2072    #[test]
2073    fn keeps_a_cross_reference_and_unwraps_a_mark() {
2074        let out = render("= T\n\n[[here]]\n== Here\n\nSee <<here>>, and a note.footnote:[Why.]\n");
2075
2076        assert!(out.contains("#link(label(\"here\"))"), "{out}");
2077
2078        // The footnote's mark points at a definition that is not a label here,
2079        // so it keeps its number and loses the link.
2080        assert!(!out.contains("_footnotedef_"), "{out}");
2081        assert!(out.contains("Why."), "{out}");
2082    }
2083
2084    #[test]
2085    fn a_sections_label_belongs_to_its_heading() {
2086        // Typst attaches a label to what comes before it, so one written above
2087        // a heading names whatever preceded the heading instead.
2088        let out = render("= T\n\nProse.\n\n[[here]]\n== Here\n");
2089
2090        assert!(out.contains("= Here <here>"), "{out}");
2091        assert!(!out.contains("#label(\"here\")\n= Here"), "{out}");
2092    }
2093
2094    #[test]
2095    fn an_anchored_term_can_be_reached() {
2096        let out =
2097            render("= T\n\nSee <<block,a block>>.\n\n[glossary]\n[[block]]block:: A shape.\n");
2098
2099        assert!(out.contains("#box[]#label(\"block\")"), "the anchor: {out}");
2100        assert!(
2101            out.contains("#link(label(\"block\"))[a block]"),
2102            "and the reference to it: {out}"
2103        );
2104    }
2105
2106    #[test]
2107    fn a_reference_to_nothing_keeps_its_words() {
2108        let out = render("= T\n\nSee <<nowhere>>.\n");
2109
2110        assert!(!out.contains("#link"), "{out}");
2111        assert!(out.contains("nowhere"), "{out}");
2112    }
2113
2114    #[test]
2115    fn typesets_both_notations() {
2116        let out = render("= T\n:stem: latexmath\n\n[stem]\n++++\n\\frac{a}{b}\n++++\n");
2117
2118        assert!(out.contains("$ frac(a ,b ) $"), "{out}");
2119
2120        // `AsciiMath` needs the parser the `math` feature brings; without it
2121        // the equation is shown as it was written.
2122        let ascii = render("= T\n\n[stem]\n++++\nsqrt(4)\n++++\n");
2123
2124        if cfg!(feature = "math") {
2125            assert!(ascii.contains("$ sqrt(4) $"), "{ascii}");
2126        } else {
2127            assert!(ascii.contains("#raw(\"sqrt(4)\")"), "{ascii}");
2128        }
2129    }
2130
2131    #[test]
2132    fn typesets_an_equation_in_a_line_of_text() {
2133        let out = render("= T\n:stem: latexmath\n\nA line with stem:[x^2] in it.\n");
2134
2135        assert!(out.contains("$x ^(2 )$"), "{out}");
2136    }
2137
2138    #[test]
2139    fn falls_back_to_the_source_when_an_equation_will_not_typeset() {
2140        // `\hbar` converts to a symbol this Typst no longer carries, so the
2141        // document only lays out once the equations are shown as written.
2142        let mut parser = Parser::default();
2143        let document = parser.parse("= T\n:stem: latexmath\n\n[stem]\n++++\n\\hbar\\omega\n++++\n");
2144
2145        let pdf = pdf(&document, Path::new("."), &options()).expect("still makes a PDF");
2146
2147        assert!(pdf.bytes.starts_with(b"%PDF"), "should be a PDF");
2148
2149        // The caller has to be able to tell that it did not get what it asked
2150        // for; this back end has nowhere to say so but its answer.
2151        assert!(
2152            pdf.fallback.is_some_and(|said| said.contains("equation")),
2153            "the fallback should be reported"
2154        );
2155    }
2156
2157    #[test]
2158    fn compiles_to_a_pdf() {
2159        let mut parser = Parser::default();
2160        let document = parser.parse("= Title\n\n== Section\n\nProse with *bold*.\n\n* a\n* b\n");
2161
2162        let pdf = pdf(&document, Path::new("."), &options()).expect("compiles");
2163
2164        assert!(pdf.bytes.starts_with(b"%PDF"), "should be a PDF");
2165        assert!(pdf.bytes.len() > 1000, "should have content");
2166        assert!(pdf.fallback.is_none(), "nothing should have been given up");
2167    }
2168}