Skip to main content

basalt_bedrock/render/
markdown.rs

1use std::{num::NonZero, str::FromStr};
2
3use comemo::Track;
4use ecow::EcoVec;
5use pulldown_cmark::{Alignment, CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
6use pulldown_cmark_ast::{Ast, Tree};
7use serde::{Deserialize, Serialize};
8use syntect::{html::ClassStyle, parsing::SyntaxSet, util::LinesWithEndings};
9use typst::{
10    diag::{EcoString, SourceDiagnostic},
11    foundations::{Content, Packed, Scope, Smart, Value},
12    layout::{Celled, Length, Ratio, Sizing, TrackSizings},
13    model::{
14        EnumElem, EnumItem, FigureElem, HeadingElem, LinkElem, LinkTarget, ListElem, ListItem,
15        ParbreakElem, TableCell, TableChild, TableElem, TableHeader, TableItem, Url,
16    },
17    syntax::Span,
18    text::{LinebreakElem, RawContent, RawElem, SpaceElem, StrikeElem, TextElem},
19    visualize::LineElem,
20    World,
21};
22
23use crate::render::typst::TypstWrapperWorld;
24
25#[derive(thiserror::Error, Debug, PartialEq, Eq)]
26pub enum RenderError {
27    #[error("Error while processing typst: {0:?}")]
28    TypstError(Vec<SourceDiagnostic>),
29    #[error("HTML tags are unsupported in Markdown")]
30    UnsupportedHtml,
31}
32
33type RenderResult<T> = Result<T, RenderError>;
34
35impl From<EcoVec<SourceDiagnostic>> for RenderError {
36    fn from(value: EcoVec<SourceDiagnostic>) -> Self {
37        Self::TypstError(value.to_vec())
38    }
39}
40
41impl From<RenderError> for std::io::Error {
42    fn from(val: RenderError) -> Self {
43        std::io::Error::other(format!("{}", val))
44    }
45}
46
47// For some reason, `Options::ENABLE_TABLES | Options::ENABLE_SMART_PUNCTUATION | ... ` is not const...
48const CMARK_OPTIONS: Options = Options::from_bits_truncate(
49    (1 << 1) // Options::ENABLE_TABLES
50    | (1 << 5) // Options::ENABLE_SMART_PUNCTUATION
51    | (1 << 3) // Options::ENABLE_STRIKETHROUGH
52    | (1 << 10), // Options::ENABLE_MATH
53);
54
55#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default)]
56#[repr(transparent)]
57#[serde(transparent)]
58pub struct MarkdownRenderable(String);
59
60impl From<String> for MarkdownRenderable {
61    fn from(value: String) -> Self {
62        Self(value)
63    }
64}
65
66impl From<&str> for MarkdownRenderable {
67    fn from(value: &str) -> Self {
68        Self(value.into())
69    }
70}
71
72impl FromStr for MarkdownRenderable {
73    type Err = std::convert::Infallible;
74
75    fn from_str(s: &str) -> Result<Self, Self::Err> {
76        Ok(Self::from(s))
77    }
78}
79
80impl MarkdownRenderable {
81    pub fn from_raw(raw: impl Into<String>) -> Self {
82        Self(raw.into())
83    }
84
85    pub fn raw(&self) -> &str {
86        &self.0
87    }
88
89    /// Renders the given string into HTML
90    ///
91    /// This uses typst to fill in the maths blocks.
92    pub fn html(&self) -> RenderResult<String> {
93        let parser = Parser::new_ext(self.raw(), CMARK_OPTIONS);
94        let mut errors = Vec::new();
95        let mut current_code = None;
96        let syntax_set = SyntaxSet::load_defaults_newlines();
97        let parser = parser.flat_map(|event| -> Box<dyn Iterator<Item = Event>> {
98            match event {
99                pulldown_cmark::Event::InlineMath(cow_str) => {
100                    // TODO: This should parse the cow_str into a Content and somehow convert that to a
101                    // page.
102                    let f = format!(
103                        "#set page(width: auto, height: auto, margin: 0em)
104                    ${}$",
105                        cow_str
106                    );
107                    let world = TypstWrapperWorld::new(f);
108                    match typst::compile(&world).output {
109                        Ok(doc) => {
110                            let svg = typst_svg::svg(&doc.pages[0]);
111                            Box::new(std::iter::once(Event::InlineHtml(svg.into())))
112                        }
113                        Err(err) => {
114                            errors.extend(err);
115                            Box::new(std::iter::once(Event::Text("".into())))
116                        }
117                    }
118                }
119                pulldown_cmark::Event::DisplayMath(cow_str) => {
120                    // TODO: This should parse the cow_str into a Content and somehow convert that to a
121                    // page.
122                    let f = format!(
123                        "
124                    #set page(width: auto, height: auto, margin: 0em)
125                    $ {} $
126                    ",
127                        cow_str
128                    );
129                    let world = TypstWrapperWorld::new(f);
130                    match typst::compile(&world).output {
131                        Ok(doc) => {
132                            let svg = typst_svg::svg(&doc.pages[0]);
133                            Box::new(std::iter::once(Event::Html(svg.into())))
134                        }
135                        Err(err) => {
136                            errors.extend(err);
137
138                            Box::new(std::iter::once(Event::Text("".into())))
139                        }
140                    }
141                }
142                pulldown_cmark::Event::Start(Tag::CodeBlock(kind)) => {
143                    let lang = match kind {
144                        CodeBlockKind::Indented => String::new(),
145                        CodeBlockKind::Fenced(cow_str) => cow_str.to_string(),
146                    };
147
148                    let syntax = syntax_set
149                        .find_syntax_by_name(&lang)
150                        .or_else(|| syntax_set.find_syntax_by_extension(&lang))
151                        .unwrap_or_else(|| syntax_set.find_syntax_plain_text());
152                    current_code = Some(syntect::html::ClassedHTMLGenerator::new_with_class_style(
153                        syntax,
154                        &syntax_set,
155                        ClassStyle::Spaced,
156                    ));
157                    Box::new(std::iter::empty())
158                }
159                pulldown_cmark::Event::Text(t) => {
160                    if let Some(ref mut code) = current_code {
161                        for line in LinesWithEndings::from(&t) {
162                            code.parse_html_for_line_which_includes_newline(line)
163                                .unwrap();
164                        }
165                        Box::new(std::iter::empty())
166                    } else {
167                        Box::new(std::iter::once(Event::Text(t)))
168                    }
169                }
170                pulldown_cmark::Event::End(TagEnd::CodeBlock) => {
171                    let code = current_code.take().expect("Can't have end without start");
172                    let out = code.finalize();
173                    Box::new(std::iter::once(Event::Html(
174                        format!("<pre>{}</pre>", out).into(),
175                    )))
176                }
177                e => Box::new(std::iter::once(e)),
178            }
179        });
180        let mut s = String::new();
181        pulldown_cmark::html::push_html(&mut s, parser);
182        if !errors.is_empty() {
183            Err(RenderError::TypstError(errors))?
184        } else {
185            Ok(s)
186        }
187    }
188
189    /// Renders the given string into typst content
190    pub fn content(&self, world: &impl World) -> RenderResult<Content> {
191        render_markdown(self.raw(), world)
192    }
193}
194
195fn map_align(a: &Alignment) -> Smart<typst::layout::Alignment> {
196    match a {
197        Alignment::None => Smart::Auto,
198        Alignment::Left => {
199            Smart::Custom(typst::layout::Alignment::H(typst::layout::HAlignment::Left))
200        }
201        Alignment::Center => Smart::Custom(typst::layout::Alignment::H(
202            typst::layout::HAlignment::Center,
203        )),
204        Alignment::Right => Smart::Custom(typst::layout::Alignment::H(
205            typst::layout::HAlignment::Right,
206        )),
207    }
208}
209
210struct TypstMarkdownRenderer<'a> {
211    world: &'a dyn World,
212}
213
214impl<'a> TypstMarkdownRenderer<'a> {
215    fn new(world: &'a dyn World) -> Self {
216        Self { world }
217    }
218
219    fn render_tree(&self, tree: Tree) -> RenderResult<Content> {
220        match tree {
221            Tree::Group(g) => match g.tag.item {
222                Tag::Paragraph => Ok(Content::sequence(
223                    std::iter::once(Ok(Content::new(ParbreakElem::new())))
224                        .chain(g.stream.0.into_iter().map(|t| self.render_tree(t)))
225                        .chain(std::iter::once(Ok(Content::new(ParbreakElem::new()))))
226                        .collect::<RenderResult<Vec<_>>>()?,
227                )),
228                Tag::Heading { level, .. } => Ok(Content::new(
229                    HeadingElem::new(self.render_ast(g.stream)?).with_level(
230                        typst::foundations::Smart::Custom(
231                            NonZero::new(level as usize).expect("1 <= level <= 6"),
232                        ),
233                    ),
234                )),
235                Tag::BlockQuote(_) => {
236                    // Blockquote ~ #figure()
237                    // TODO: use block quote kind somehow?
238                    let content = Content::sequence(
239                        std::iter::once(Ok(Content::new(ParbreakElem::new())))
240                            .chain(g.stream.0.into_iter().map(|t| self.render_tree(t)))
241                            .chain(std::iter::once(Ok(Content::new(ParbreakElem::new()))))
242                            .collect::<RenderResult<Vec<_>>>()?,
243                    );
244                    Ok(Content::new(FigureElem::new(content.aligned(
245                        typst::layout::Alignment::H(typst::layout::HAlignment::Left),
246                    ))))
247                }
248                Tag::CodeBlock(code_block_kind) => {
249                    let content = self.render_ast_to_text(g.stream);
250                    let elem = RawElem::new(RawContent::Text(content)).with_block(true);
251                    let elem = match code_block_kind {
252                        CodeBlockKind::Indented => elem,
253                        CodeBlockKind::Fenced(s) => {
254                            if s.is_empty() {
255                                elem
256                            } else {
257                                elem.with_lang(Some(s.as_ref().into()))
258                            }
259                        }
260                    };
261                    Ok(Content::new(FigureElem::new(Content::new(elem))))
262                }
263                Tag::HtmlBlock => Err(RenderError::UnsupportedHtml),
264                Tag::List(ord) => {
265                    if let Some(ord) = ord {
266                        let packed = g
267                            .stream
268                            .0
269                            .into_iter()
270                            .enumerate()
271                            .map(|(i, t)| -> RenderResult<_> {
272                                match t {
273                                    Tree::Group(group) => match group.tag.item {
274                                        Tag::Item => Ok(Packed::new(
275                                            EnumItem::new(self.render_ast(group.stream)?)
276                                                .with_number(Some(ord as usize + i)),
277                                        )),
278                                        _ => unreachable!(),
279                                    },
280                                    _ => unreachable!(),
281                                }
282                            })
283                            .collect::<RenderResult<Vec<_>>>()?;
284                        Ok(Content::new(EnumElem::new(packed)))
285                    } else {
286                        let packed = g
287                            .stream
288                            .0
289                            .into_iter()
290                            .map(|t| self.render_tree(t).map(|c| c.into_packed().unwrap()))
291                            .collect::<RenderResult<_>>()?;
292                        Ok(Content::new(ListElem::new(packed)))
293                    }
294                }
295                Tag::Item => Ok(Content::new(ListItem::new(self.render_ast(g.stream)?))),
296                Tag::FootnoteDefinition(_) => unreachable!("Feature is disabled"),
297                Tag::Table(align) => {
298                    let mut things = g.stream.0;
299                    let mut children = Vec::new();
300                    let header = match things.remove(0) {
301                        Tree::Group(hg) => match hg.tag.item {
302                            Tag::TableHead => hg.stream,
303                            _ => unreachable!(),
304                        },
305                        _ => unreachable!(),
306                    };
307
308                    let cols = header.0.len();
309
310                    children.push(TableChild::Header(Packed::new(TableHeader::new(
311                        header
312                            .0
313                            .into_iter()
314                            .map(|t| {
315                                self.render_tree(t)
316                                    .map(|c| c.into_packed().unwrap())
317                                    .map(TableItem::Cell)
318                            })
319                            .collect::<RenderResult<_>>()?,
320                    ))));
321
322                    for thing in things {
323                        let row = match thing {
324                            Tree::Group(hg) => match hg.tag.item {
325                                Tag::TableRow => hg.stream.0,
326                                _ => unreachable!(),
327                            },
328                            _ => unreachable!(),
329                        };
330                        children.extend_from_slice(
331                            &row.into_iter()
332                                .map(|t| {
333                                    self.render_tree(t)
334                                        .map(|c| c.into_packed().unwrap())
335                                        .map(TableItem::Cell)
336                                        .map(TableChild::Item)
337                                })
338                                .collect::<RenderResult<Vec<_>>>()?,
339                        );
340                    }
341
342                    let columns = (0..cols).map(|_| Sizing::Auto).collect::<Vec<_>>();
343
344                    Ok(Content::new(FigureElem::new(Content::new(
345                        TableElem::new(children)
346                            .with_columns(TrackSizings(columns.into()))
347                            .with_align(Celled::Array(align.iter().map(map_align).collect())),
348                    ))))
349                }
350                Tag::TableHead => {
351                    let items = g
352                        .stream
353                        .0
354                        .into_iter()
355                        .map(|t| {
356                            self.render_tree(t)
357                                .map(|c| c.into_packed().unwrap())
358                                .map(TableItem::Cell)
359                        })
360                        .collect::<RenderResult<_>>()?;
361                    Ok(Content::new(TableHeader::new(items)))
362                }
363                Tag::TableRow => g
364                    .stream
365                    .0
366                    .into_iter()
367                    .map(|t| {
368                        self.render_tree(t)
369                            .map(|c| c.into_packed().unwrap())
370                            .map(TableItem::Cell)
371                    })
372                    .collect::<RenderResult<_>>()
373                    .map(TableHeader::new)
374                    .map(Content::new),
375                Tag::TableCell => self
376                    .render_ast(g.stream)
377                    .map(TableCell::new)
378                    .map(Content::new),
379                Tag::Emphasis => self.render_ast(g.stream).map(Content::emph),
380                Tag::Strong => self.render_ast(g.stream).map(Content::strong),
381                Tag::Strikethrough => self
382                    .render_ast(g.stream)
383                    .map(StrikeElem::new)
384                    .map(Content::new),
385                Tag::Link { dest_url, .. } => Ok(Content::new(LinkElem::new(
386                    LinkTarget::Dest(typst::model::Destination::Url(
387                        Url::new(&*dest_url).unwrap(),
388                    )),
389                    self.render_ast(g.stream)?,
390                ))),
391                Tag::Image { .. } => todo!(),
392                Tag::MetadataBlock(_) => unreachable!("Feature is disabled"),
393            },
394            Tree::Text(spanned) => Ok(Content::new(TextElem::new(spanned.item.as_ref().into()))),
395            Tree::Code(spanned) => Ok(Content::new(RawElem::new(RawContent::Text(
396                spanned.item.as_ref().into(),
397            )))),
398            Tree::Html(_) => Err(RenderError::UnsupportedHtml),
399            Tree::InlineHtml(_) => Err(RenderError::UnsupportedHtml),
400            Tree::FootnoteReference(_) => unreachable!("Feature is disabled"),
401            Tree::SoftBreak(_) => Ok(Content::new(SpaceElem::new())),
402            Tree::HardBreak(_) => Ok(Content::new(LinebreakElem::new())),
403            Tree::Rule(_) => Ok(Content::new(LineElem::new().with_length(
404                typst::layout::Rel {
405                    rel: Ratio::new(1.),
406                    abs: Length::zero(),
407                },
408            ))),
409            Tree::TaskListMarker(_) => unreachable!("Feature is disabled"),
410            Tree::InlineMath(spanned) => {
411                let content = spanned.item;
412
413                let val = typst::eval::eval_string(
414                    self.world.track(),
415                    &content,
416                    Span::detached(),
417                    typst::eval::EvalMode::Math,
418                    Scope::new(),
419                )?;
420
421                match val {
422                    Value::Content(content) => Ok(content),
423                    _ => unreachable!(),
424                }
425            }
426            Tree::DisplayMath(spanned) => {
427                let content = spanned.item.trim();
428
429                let val = typst::eval::eval_string(
430                    self.world.track(),
431                    &format!("$ {} $", content),
432                    Span::detached(),
433                    typst::eval::EvalMode::Markup,
434                    self.world.library().math.scope().clone(),
435                )?;
436
437                match val {
438                    Value::Content(content) => Ok(content),
439                    _ => unreachable!(),
440                }
441            }
442        }
443    }
444
445    fn render_ast(&self, ast: Ast) -> RenderResult<Content> {
446        Ok(Content::sequence(
447            ast.0
448                .into_iter()
449                .map(|t| self.render_tree(t))
450                .collect::<RenderResult<Vec<_>>>()?,
451        ))
452    }
453
454    fn render_ast_to_text(&self, ast: Ast) -> EcoString {
455        let mut s = EcoString::new();
456        for t in ast.0 {
457            match t {
458                Tree::Text(spanned) => {
459                    s.push_str(&spanned.item);
460                }
461                s => unreachable!("need to impl {:?}", s),
462            }
463        }
464        s
465    }
466
467    fn render(&self, markdown: impl AsRef<str>) -> RenderResult<Content> {
468        let markdown = markdown.as_ref();
469        let ast = Ast::new_ext(markdown, CMARK_OPTIONS);
470        self.render_ast(ast)
471    }
472}
473
474pub fn render_markdown(markdown: impl AsRef<str>, world: &impl World) -> RenderResult<Content> {
475    TypstMarkdownRenderer::new(world).render(markdown)
476}