zng_wgt_markdown/
lib.rs

1#![doc(html_favicon_url = "https://zng-ui.github.io/res/zng-logo-icon.png")]
2#![doc(html_logo_url = "https://zng-ui.github.io/res/zng-logo.png")]
3//!
4//! Markdown widget, properties and nodes.
5//!
6//! # Crate
7//!
8#![doc = include_str!(concat!("../", std::env!("CARGO_PKG_README")))]
9#![warn(unused_extern_crates)]
10#![warn(missing_docs)]
11
12zng_wgt::enable_widget_macros!();
13
14use std::mem;
15
16pub use pulldown_cmark::HeadingLevel;
17
18use zng_wgt::prelude::*;
19use zng_wgt_input::{CursorIcon, cursor};
20
21#[doc(hidden)]
22pub use zng_wgt_text::__formatx;
23
24use zng_wgt_text as text;
25
26mod resolvers;
27mod view_fn;
28
29pub use resolvers::*;
30pub use view_fn::*;
31
32/// Render markdown styled text.
33#[widget($crate::Markdown {
34    ($txt:literal) => {
35        txt = $crate::__formatx!($txt);
36    };
37    ($txt:expr) => {
38        txt = $txt;
39    };
40    ($txt:tt, $($format:tt)*) => {
41        txt = $crate::__formatx!($txt, $($format)*);
42    };
43})]
44#[rustfmt::skip]
45pub struct Markdown(
46    text::FontMix<
47    text::TextSpacingMix<
48    text::ParagraphMix<
49    text::LangMix<
50    WidgetBase
51    >>>>
52);
53impl Markdown {
54    fn widget_intrinsic(&mut self) {
55        widget_set! {
56            self;
57            on_link = hn!(|args| {
58                try_default_link_action(args);
59            });
60            zng_wgt_text::rich_text = true;
61
62            when #txt_selectable {
63                cursor = CursorIcon::Text;
64            }
65        };
66
67        self.widget_builder().push_build_action(|wgt| {
68            let md = wgt.capture_var_or_default(property_id!(text::txt));
69            let child = markdown_node(md);
70            wgt.set_child(child);
71        });
72    }
73
74    widget_impl! {
75        /// Markdown text.
76        pub text::txt(txt: impl IntoVar<Txt>);
77
78        /// Enable text selection, copy.
79        ///
80        /// Note that the copy is only in plain text, without any style.
81        pub zng_wgt_text::txt_selectable(enabled: impl IntoVar<bool>);
82    }
83}
84
85/// Implements the markdown parsing and view generation, configured by contextual properties.
86pub fn markdown_node(md: impl IntoVar<Txt>) -> UiNode {
87    let md = md.into_var();
88    match_node(UiNode::nil(), move |c, op| match op {
89        UiNodeOp::Init => {
90            WIDGET
91                .sub_var(&md)
92                .sub_var(&TEXT_FN_VAR)
93                .sub_var(&LINK_FN_VAR)
94                .sub_var(&CODE_INLINE_FN_VAR)
95                .sub_var(&CODE_BLOCK_FN_VAR)
96                .sub_var(&PARAGRAPH_FN_VAR)
97                .sub_var(&HEADING_FN_VAR)
98                .sub_var(&LIST_FN_VAR)
99                .sub_var(&LIST_ITEM_BULLET_FN_VAR)
100                .sub_var(&LIST_ITEM_FN_VAR)
101                .sub_var(&IMAGE_FN_VAR)
102                .sub_var(&RULE_FN_VAR)
103                .sub_var(&BLOCK_QUOTE_FN_VAR)
104                .sub_var(&TABLE_FN_VAR)
105                .sub_var(&TABLE_CELL_FN_VAR)
106                .sub_var(&PANEL_FN_VAR)
107                .sub_var(&IMAGE_RESOLVER_VAR)
108                .sub_var(&LINK_RESOLVER_VAR);
109
110            *c.node() = md.with(|md| markdown_view_fn(md.as_str()));
111        }
112        UiNodeOp::Deinit => {
113            c.deinit();
114            *c.node() = UiNode::nil();
115        }
116        UiNodeOp::Info { info } => {
117            info.flag_meta(*MARKDOWN_INFO_ID);
118        }
119        UiNodeOp::Update { .. } => {
120            use resolvers::*;
121            use view_fn::*;
122
123            if md.is_new()
124                || TEXT_FN_VAR.is_new()
125                || LINK_FN_VAR.is_new()
126                || CODE_INLINE_FN_VAR.is_new()
127                || CODE_BLOCK_FN_VAR.is_new()
128                || PARAGRAPH_FN_VAR.is_new()
129                || HEADING_FN_VAR.is_new()
130                || LIST_FN_VAR.is_new()
131                || LIST_ITEM_BULLET_FN_VAR.is_new()
132                || LIST_ITEM_FN_VAR.is_new()
133                || IMAGE_FN_VAR.is_new()
134                || RULE_FN_VAR.is_new()
135                || BLOCK_QUOTE_FN_VAR.is_new()
136                || TABLE_FN_VAR.is_new()
137                || TABLE_CELL_FN_VAR.is_new()
138                || PANEL_FN_VAR.is_new()
139                || IMAGE_RESOLVER_VAR.is_new()
140                || LINK_RESOLVER_VAR.is_new()
141            {
142                c.delegated();
143                c.node().deinit();
144                *c.node() = md.with(|md| markdown_view_fn(md.as_str()));
145                c.node().init();
146                WIDGET.update_info().layout().render();
147            }
148        }
149        _ => {}
150    })
151}
152
153/// Parse markdown, with pre-processing, merge texts, collapse white spaces across inline items
154fn markdown_parser<'a>(md: &'a str, mut next_event: impl FnMut(pulldown_cmark::Event<'a>)) {
155    use pulldown_cmark::*;
156
157    let parse_options = Options::ENABLE_TABLES
158        | Options::ENABLE_FOOTNOTES
159        | Options::ENABLE_STRIKETHROUGH
160        | Options::ENABLE_TASKLISTS
161        | Options::ENABLE_SMART_PUNCTUATION
162        | Options::ENABLE_DEFINITION_LIST
163        | Options::ENABLE_SUBSCRIPT
164        | Options::ENABLE_SUPERSCRIPT;
165
166    let mut broken_link_handler = |b: BrokenLink<'a>| Some((b.reference, "".into()));
167    let parser = Parser::new_with_broken_link_callback(md, parse_options, Some(&mut broken_link_handler));
168
169    enum Str<'a> {
170        Md(CowStr<'a>),
171        Buf(String),
172    }
173    impl<'a> Str<'a> {
174        fn buf(&mut self) -> &mut String {
175            if let Str::Md(s) = self {
176                *self = Str::Buf(mem::replace(s, CowStr::Borrowed("")).into_string());
177            }
178            match self {
179                Str::Buf(b) => b,
180                _ => unreachable!(),
181            }
182        }
183
184        fn md(self) -> CowStr<'a> {
185            match self {
186                Str::Md(cow_str) => cow_str,
187                Str::Buf(b) => b.into(),
188            }
189        }
190    }
191    let mut pending_txt: Option<Str<'a>> = None;
192    let mut trim_start = false;
193
194    for event in parser {
195        // resolve breaks
196        let event = match event {
197            Event::SoftBreak => Event::Text(CowStr::Borrowed(" ")),
198            Event::HardBreak => Event::Text(CowStr::Borrowed("\n")),
199            ev => ev,
200        };
201        match event {
202            // merge texts
203            Event::Text(txt) => {
204                if let Some(p) = &mut pending_txt {
205                    p.buf().push_str(&txt);
206                } else if mem::take(&mut trim_start) && txt.starts_with(' ') {
207                    // merge spaces across inline items
208                    pending_txt = Some(match txt {
209                        CowStr::Borrowed(s) => Str::Md(CowStr::Borrowed(s.trim_start())),
210                        CowStr::Boxed(s) => Str::Buf(s.trim_start().to_owned()),
211                        CowStr::Inlined(s) => Str::Buf(s.trim_start().to_owned()),
212                    });
213                } else {
214                    pending_txt = Some(Str::Md(txt));
215                }
216            }
217            // items that don't merge spaces with siblings
218            e @ Event::End(_)
219            | e @ Event::Start(
220                Tag::Paragraph
221                | Tag::Heading { .. }
222                | Tag::Image { .. }
223                | Tag::Item
224                | Tag::List(_)
225                | Tag::CodeBlock(_)
226                | Tag::Table(_)
227                | Tag::TableHead
228                | Tag::TableRow
229                | Tag::TableCell
230                | Tag::BlockQuote(_)
231                | Tag::FootnoteDefinition(_)
232                | Tag::DefinitionList
233                | Tag::DefinitionListTitle
234                | Tag::DefinitionListDefinition
235                | Tag::HtmlBlock
236                | Tag::MetadataBlock(_),
237            )
238            | e @ Event::Code(_)
239            | e @ Event::Rule
240            | e @ Event::TaskListMarker(_)
241            | e @ Event::InlineMath(_)
242            | e @ Event::DisplayMath(_)
243            | e @ Event::Html(_)
244            | e @ Event::InlineHtml(_) => {
245                if let Some(txt) = pending_txt.take() {
246                    next_event(Event::Text(txt.md()));
247                }
248                next_event(e)
249            }
250            // inline items that merge spaces with siblings
251            Event::FootnoteReference(s) => {
252                if let Some(txt) = pending_txt.take() {
253                    let txt = txt.md();
254                    trim_start = txt.ends_with(' ');
255                    next_event(Event::Text(txt));
256                }
257                if mem::take(&mut trim_start) && s.starts_with(' ') {
258                    let s = match s {
259                        CowStr::Borrowed(s) => CowStr::Borrowed(s.trim_start()),
260                        CowStr::Boxed(s) => CowStr::Boxed(s.trim_start().to_owned().into()),
261                        CowStr::Inlined(s) => CowStr::Boxed(s.trim_start().to_owned().into()),
262                    };
263                    next_event(Event::FootnoteReference(s))
264                } else {
265                    next_event(Event::FootnoteReference(s))
266                }
267            }
268            Event::Start(tag) => match tag {
269                t @ Tag::Emphasis
270                | t @ Tag::Strong
271                | t @ Tag::Strikethrough
272                | t @ Tag::Superscript
273                | t @ Tag::Subscript
274                | t @ Tag::Link { .. } => {
275                    if let Some(txt) = pending_txt.take() {
276                        let txt = txt.md();
277                        trim_start = txt.ends_with(' ');
278                        next_event(Event::Text(txt));
279                    }
280                    next_event(Event::Start(t))
281                }
282                t => tracing::error!("unexpected start tag {t:?}"),
283            },
284            // handled early
285            Event::HardBreak | Event::SoftBreak => unreachable!(),
286        }
287        if let Some(txt) = pending_txt.take() {
288            next_event(Event::Text(txt.md()));
289        }
290    }
291}
292
293fn markdown_view_fn(md: &str) -> UiNode {
294    use pulldown_cmark::*;
295    use resolvers::*;
296    use view_fn::*;
297
298    let text_view = TEXT_FN_VAR.get();
299    let link_view = LINK_FN_VAR.get();
300    let code_inline_view = CODE_INLINE_FN_VAR.get();
301    let code_block_view = CODE_BLOCK_FN_VAR.get();
302    let heading_view = HEADING_FN_VAR.get();
303    let paragraph_view = PARAGRAPH_FN_VAR.get();
304    let list_view = LIST_FN_VAR.get();
305    let definition_list_view = DEF_LIST_FN_VAR.get();
306    let list_item_bullet_view = LIST_ITEM_BULLET_FN_VAR.get();
307    let list_item_view = LIST_ITEM_FN_VAR.get();
308    let image_view = IMAGE_FN_VAR.get();
309    let rule_view = RULE_FN_VAR.get();
310    let block_quote_view = BLOCK_QUOTE_FN_VAR.get();
311    let footnote_ref_view = FOOTNOTE_REF_FN_VAR.get();
312    let footnote_def_view = FOOTNOTE_DEF_FN_VAR.get();
313    let def_list_item_title_view = DEF_LIST_ITEM_TITLE_FN_VAR.get();
314    let def_list_item_definition_view = DEF_LIST_ITEM_DEFINITION_FN_VAR.get();
315    let table_view = TABLE_FN_VAR.get();
316    let table_cell_view = TABLE_CELL_FN_VAR.get();
317
318    let image_resolver = IMAGE_RESOLVER_VAR.get();
319    let link_resolver = LINK_RESOLVER_VAR.get();
320
321    #[derive(Default)]
322    struct StyleBuilder {
323        strong: usize,
324        emphasis: usize,
325        strikethrough: usize,
326        superscript: usize,
327        subscript: usize,
328    }
329    impl StyleBuilder {
330        fn build(&self) -> MarkdownStyle {
331            MarkdownStyle {
332                strong: self.strong > 0,
333                emphasis: self.emphasis > 0,
334                strikethrough: self.strikethrough > 0,
335                subscript: self.subscript > self.superscript,
336                superscript: self.superscript > self.subscript,
337            }
338        }
339    }
340    struct ListInfo {
341        block_start: usize,
342        inline_start: usize,
343        first_num: Option<u64>,
344        item_num: Option<u64>,
345        item_checked: Option<bool>,
346    }
347    let mut blocks = vec![];
348    let mut inlines = vec![];
349    let mut txt_style = StyleBuilder::default();
350    let mut link = None;
351    let mut list_info = vec![];
352    let mut list_items = vec![];
353    let mut block_quote_start = vec![];
354    let mut code_block = None;
355    let mut html_block = None;
356    let mut image = None;
357    let mut heading_anchor_txt = None;
358    let mut footnote_def = None;
359    let mut table_cells = vec![];
360    let mut table_cols = vec![];
361    let mut table_col = 0;
362    let mut table_head = false;
363
364    markdown_parser(md, |event| match event {
365        Event::Start(tag) => match tag {
366            Tag::Paragraph => txt_style = StyleBuilder::default(),
367            Tag::Heading { .. } => {
368                txt_style = StyleBuilder::default();
369                heading_anchor_txt = Some(String::new());
370            }
371            Tag::BlockQuote(_) => {
372                txt_style = StyleBuilder::default();
373                block_quote_start.push(blocks.len());
374            }
375            Tag::CodeBlock(kind) => {
376                txt_style = StyleBuilder::default();
377                code_block = Some((String::new(), kind));
378            }
379            Tag::HtmlBlock => {
380                txt_style = StyleBuilder::default();
381                html_block = Some(String::new());
382            }
383            Tag::List(n) => {
384                txt_style = StyleBuilder::default();
385                list_info.push(ListInfo {
386                    block_start: blocks.len(),
387                    inline_start: inlines.len(),
388                    first_num: n,
389                    item_num: n,
390                    item_checked: None,
391                });
392            }
393            Tag::DefinitionList => {
394                txt_style = StyleBuilder::default();
395                list_info.push(ListInfo {
396                    block_start: blocks.len(),
397                    inline_start: inlines.len(),
398                    first_num: None,
399                    item_num: None,
400                    item_checked: None,
401                });
402            }
403            Tag::Item | Tag::DefinitionListTitle | Tag::DefinitionListDefinition => {
404                txt_style = StyleBuilder::default();
405                if let Some(list) = list_info.last_mut() {
406                    list.block_start = blocks.len();
407                }
408            }
409            Tag::FootnoteDefinition(label) => {
410                txt_style = StyleBuilder::default();
411                footnote_def = Some((blocks.len(), label));
412            }
413            Tag::Table(columns) => {
414                txt_style = StyleBuilder::default();
415                table_cols = columns
416                    .into_iter()
417                    .map(|c| match c {
418                        Alignment::None => Align::START,
419                        Alignment::Left => Align::LEFT,
420                        Alignment::Center => Align::CENTER,
421                        Alignment::Right => Align::RIGHT,
422                    })
423                    .collect()
424            }
425            Tag::TableHead => {
426                txt_style = StyleBuilder::default();
427                table_head = true;
428                table_col = 0;
429            }
430            Tag::TableRow => {
431                txt_style = StyleBuilder::default();
432                table_col = 0;
433            }
434            Tag::TableCell => {
435                txt_style = StyleBuilder::default();
436            }
437            Tag::Emphasis => {
438                txt_style.emphasis += 1;
439            }
440            Tag::Strong => {
441                txt_style.strong += 1;
442            }
443            Tag::Strikethrough => {
444                txt_style.strong += 1;
445            }
446            Tag::Superscript => {
447                txt_style.superscript += 1;
448            }
449            Tag::Subscript => {
450                txt_style.subscript += 1;
451            }
452            Tag::Link {
453                link_type,
454                dest_url,
455                title,
456                id,
457            } => {
458                link = Some((inlines.len(), link_type, dest_url, title, id));
459            }
460            Tag::Image { dest_url, title, .. } => {
461                image = Some((String::new(), dest_url, title));
462            }
463            Tag::MetadataBlock(_) => unreachable!(), // not enabled
464        },
465        Event::End(tag_end) => match tag_end {
466            TagEnd::Paragraph => {
467                if !inlines.is_empty() {
468                    blocks.push(paragraph_view(ParagraphFnArgs {
469                        index: blocks.len() as u32,
470                        items: mem::take(&mut inlines).into(),
471                    }));
472                }
473            }
474            TagEnd::Heading(level) => {
475                if !inlines.is_empty() {
476                    blocks.push(heading_view(HeadingFnArgs {
477                        level,
478                        anchor: heading_anchor(heading_anchor_txt.take().unwrap_or_default().as_str()),
479                        items: mem::take(&mut inlines).into(),
480                    }));
481                }
482            }
483            TagEnd::BlockQuote(_) => {
484                if let Some(start) = block_quote_start.pop() {
485                    let items: UiVec = blocks.drain(start..).collect();
486                    if !items.is_empty() {
487                        blocks.push(block_quote_view(BlockQuoteFnArgs {
488                            level: block_quote_start.len() as u32,
489                            items,
490                        }));
491                    }
492                }
493            }
494            TagEnd::CodeBlock => {
495                let (mut txt, kind) = code_block.take().unwrap();
496                if txt.ends_with('\n') {
497                    txt.pop();
498                }
499                blocks.push(code_block_view(CodeBlockFnArgs {
500                    lang: match kind {
501                        CodeBlockKind::Indented => Txt::from_str(""),
502                        CodeBlockKind::Fenced(l) => l.to_txt(),
503                    },
504                    txt: txt.into(),
505                }))
506            }
507            TagEnd::HtmlBlock => {
508                // TODO
509                let _html = html_block.take().unwrap();
510            }
511            TagEnd::List(_) => {
512                if let Some(list) = list_info.pop() {
513                    blocks.push(list_view(ListFnArgs {
514                        depth: list_info.len() as u32,
515                        first_num: list.first_num,
516                        items: mem::take(&mut list_items).into(),
517                    }));
518                }
519            }
520            TagEnd::DefinitionList => {
521                if list_info.pop().is_some() {
522                    blocks.push(definition_list_view(DefListArgs {
523                        items: mem::take(&mut list_items).into(),
524                    }));
525                }
526            }
527            TagEnd::Item => {
528                let depth = list_info.len().saturating_sub(1);
529                if let Some(list) = list_info.last_mut() {
530                    let num = match &mut list.item_num {
531                        Some(n) => {
532                            let r = *n;
533                            *n += 1;
534                            Some(r)
535                        }
536                        None => None,
537                    };
538
539                    let bullet_args = ListItemBulletFnArgs {
540                        depth: depth as u32,
541                        num,
542                        checked: list.item_checked.take(),
543                    };
544                    list_items.push(list_item_bullet_view(bullet_args));
545                    list_items.push(list_item_view(ListItemFnArgs {
546                        bullet: bullet_args,
547                        items: inlines.drain(list.inline_start..).collect(),
548                        blocks: blocks.drain(list.block_start..).collect(),
549                    }));
550                }
551            }
552            TagEnd::DefinitionListTitle => {
553                if let Some(list) = list_info.last_mut() {
554                    list_items.push(def_list_item_title_view(DefListItemTitleArgs {
555                        items: inlines.drain(list.inline_start..).collect(),
556                    }));
557                }
558            }
559            TagEnd::DefinitionListDefinition => {
560                if let Some(list) = list_info.last_mut() {
561                    list_items.push(def_list_item_definition_view(DefListItemDefinitionArgs {
562                        items: inlines.drain(list.inline_start..).collect(),
563                    }));
564                }
565            }
566            TagEnd::FootnoteDefinition => {
567                if let Some((i, label)) = footnote_def.take() {
568                    let label = html_escape::decode_html_entities(label.as_ref());
569                    let items = blocks.drain(i..).collect();
570                    blocks.push(footnote_def_view(FootnoteDefFnArgs {
571                        label: label.to_txt(),
572                        items,
573                    }));
574                }
575            }
576            TagEnd::Table => {
577                if !table_cells.is_empty() {
578                    blocks.push(table_view(TableFnArgs {
579                        columns: mem::take(&mut table_cols),
580                        cells: mem::take(&mut table_cells).into(),
581                    }));
582                }
583            }
584            TagEnd::TableHead => {
585                table_head = false;
586            }
587            TagEnd::TableRow => {}
588            TagEnd::TableCell => {
589                table_cells.push(table_cell_view(TableCellFnArgs {
590                    is_heading: table_head,
591                    col_align: table_cols[table_col],
592                    items: mem::take(&mut inlines).into(),
593                }));
594                table_col += 1;
595            }
596            TagEnd::Emphasis => {
597                txt_style.emphasis -= 1;
598            }
599            TagEnd::Strong => {
600                txt_style.strong -= 1;
601            }
602            TagEnd::Strikethrough => {
603                txt_style.strikethrough -= 1;
604            }
605            TagEnd::Superscript => {
606                txt_style.superscript -= 1;
607            }
608            TagEnd::Subscript => txt_style.subscript -= 1,
609            TagEnd::Link => {
610                let (inlines_start, kind, url, title, _id) = link.take().unwrap();
611                let title = html_escape::decode_html_entities(title.as_ref());
612                let url = link_resolver.resolve(url.as_ref());
613                match kind {
614                    LinkType::Autolink | LinkType::Email => {
615                        let url = html_escape::decode_html_entities(&url);
616                        if let Some(txt) = text_view.call_checked(TextFnArgs {
617                            txt: url.to_txt(),
618                            style: txt_style.build(),
619                        }) {
620                            inlines.push(txt);
621                        }
622                    }
623                    LinkType::Inline => {}
624                    LinkType::Reference => {}
625                    LinkType::ReferenceUnknown => {}
626                    LinkType::Collapsed => {}
627                    LinkType::CollapsedUnknown => {}
628                    LinkType::Shortcut => {}
629                    LinkType::ShortcutUnknown => {}
630                    LinkType::WikiLink { .. } => {}
631                }
632                if !inlines.is_empty() {
633                    let items = inlines.drain(inlines_start..).collect();
634                    if let Some(lnk) = link_view.call_checked(LinkFnArgs {
635                        url,
636                        title: title.to_txt(),
637                        items,
638                    }) {
639                        inlines.push(lnk);
640                    }
641                }
642            }
643            TagEnd::Image => {
644                let (alt_txt, url, title) = image.take().unwrap();
645                let title = html_escape::decode_html_entities(title.as_ref());
646                blocks.push(image_view(ImageFnArgs {
647                    source: image_resolver.resolve(&url),
648                    title: title.to_txt(),
649                    alt_items: mem::take(&mut inlines).into(),
650                    alt_txt: alt_txt.into(),
651                }));
652            }
653            TagEnd::MetadataBlock(_) => unreachable!(),
654        },
655        Event::Text(txt) => {
656            if let Some(html) = &mut html_block {
657                html.push_str(&txt);
658            } else {
659                let txt = html_escape::decode_html_entities(txt.as_ref());
660                if let Some((code, _)) = &mut code_block {
661                    code.push_str(&txt);
662                } else if !txt.is_empty() {
663                    if let Some(anchor_txt) = &mut heading_anchor_txt {
664                        anchor_txt.push_str(&txt);
665                    }
666                    if let Some((alt_txt, _, _)) = &mut image {
667                        alt_txt.push_str(&txt);
668                    }
669                    if let Some(txt) = text_view.call_checked(TextFnArgs {
670                        txt: Txt::from_str(&txt),
671                        style: txt_style.build(),
672                    }) {
673                        inlines.push(txt);
674                    }
675                }
676            }
677        }
678        Event::Code(txt) => {
679            let txt = html_escape::decode_html_entities(txt.as_ref());
680            if let Some(txt) = code_inline_view.call_checked(CodeInlineFnArgs {
681                txt: txt.to_txt(),
682                style: txt_style.build(),
683            }) {
684                inlines.push(txt);
685            }
686        }
687        Event::Html(h) => {
688            if let Some(html) = &mut html_block {
689                html.push_str(&h);
690            }
691        }
692        Event::InlineHtml(tag) => match tag.as_ref() {
693            "<b>" => txt_style.strong += 1,
694            "</b>" => txt_style.strong -= 1,
695            "<em>" => txt_style.emphasis += 1,
696            "</em>" => txt_style.emphasis -= 1,
697            "<s>" => txt_style.strikethrough += 1,
698            "</s>" => txt_style.strikethrough -= 1,
699            _ => {}
700        },
701        Event::FootnoteReference(label) => {
702            let label = html_escape::decode_html_entities(label.as_ref());
703            if let Some(txt) = footnote_ref_view.call_checked(FootnoteRefFnArgs { label: label.to_txt() }) {
704                inlines.push(txt);
705            }
706        }
707        Event::Rule => {
708            blocks.push(rule_view(RuleFnArgs {}));
709        }
710        Event::TaskListMarker(c) => {
711            if let Some(l) = &mut list_info.last_mut() {
712                l.item_checked = Some(c);
713            }
714        }
715
716        Event::InlineMath(_) => {} // TODO
717        Event::DisplayMath(_) => {}
718        Event::SoftBreak | Event::HardBreak => unreachable!(),
719    });
720
721    PANEL_FN_VAR.get()(PanelFnArgs { items: blocks.into() })
722}