typst_html/
rules.rs

1use std::num::NonZeroUsize;
2
3use comemo::Track;
4use ecow::{EcoVec, eco_format};
5use typst_library::diag::{At, SourceResult, bail, error, warning};
6use typst_library::engine::Engine;
7use typst_library::foundations::{
8    Content, Context, NativeElement, NativeRuleMap, ShowFn, Smart, StyleChain, Target,
9};
10use typst_library::introspection::Counter;
11use typst_library::layout::resolve::{Cell, CellGrid, Entry, Header};
12use typst_library::layout::{
13    BlockBody, BlockElem, BoxElem, HElem, OuterVAlignment, Sizing,
14};
15use typst_library::model::{
16    Attribution, BibliographyElem, CiteElem, CiteGroup, CslIndentElem, CslLightElem,
17    Destination, DirectLinkElem, EmphElem, EnumElem, FigureCaption, FigureElem,
18    FootnoteElem, FootnoteEntry, FootnoteMarker, HeadingElem, LinkElem, LinkTarget,
19    ListElem, OutlineElem, OutlineEntry, OutlineNode, ParElem, ParbreakElem, QuoteElem,
20    RefElem, StrongElem, TableCell, TableElem, TermsElem, TitleElem, Works,
21};
22use typst_library::text::{
23    HighlightElem, LinebreakElem, OverlineElem, RawElem, RawLine, SmallcapsElem,
24    SpaceElem, StrikeElem, SubElem, SuperElem, UnderlineElem,
25};
26use typst_library::visualize::{Color, ImageElem};
27use typst_macros::elem;
28use typst_utils::singleton;
29
30use crate::{FrameElem, HtmlAttr, HtmlAttrs, HtmlElem, HtmlTag, attr, css, tag};
31
32/// Registers show rules for the [HTML target](Target::Html).
33pub fn register(rules: &mut NativeRuleMap) {
34    use Target::{Html, Paged};
35
36    // Model.
37    rules.register(Html, PAR_RULE);
38    rules.register(Html, STRONG_RULE);
39    rules.register(Html, EMPH_RULE);
40    rules.register(Html, LIST_RULE);
41    rules.register(Html, ENUM_RULE);
42    rules.register(Html, TERMS_RULE);
43    rules.register(Html, LINK_RULE);
44    rules.register(Html, DIRECT_LINK_RULE);
45    rules.register(Html, TITLE_RULE);
46    rules.register(Html, HEADING_RULE);
47    rules.register(Html, FIGURE_RULE);
48    rules.register(Html, FIGURE_CAPTION_RULE);
49    rules.register(Html, QUOTE_RULE);
50    rules.register(Html, FOOTNOTE_RULE);
51    rules.register(Html, FOOTNOTE_MARKER_RULE);
52    rules.register(Html, FOOTNOTE_CONTAINER_RULE);
53    rules.register(Html, FOOTNOTE_ENTRY_RULE);
54    rules.register(Html, OUTLINE_RULE);
55    rules.register(Html, OUTLINE_ENTRY_RULE);
56    rules.register(Html, REF_RULE);
57    rules.register(Html, CITE_GROUP_RULE);
58    rules.register(Html, BIBLIOGRAPHY_RULE);
59    rules.register(Html, CSL_LIGHT_RULE);
60    rules.register(Html, CSL_INDENT_RULE);
61    rules.register(Html, TABLE_RULE);
62
63    // Text.
64    rules.register(Html, SUB_RULE);
65    rules.register(Html, SUPER_RULE);
66    rules.register(Html, UNDERLINE_RULE);
67    rules.register(Html, OVERLINE_RULE);
68    rules.register(Html, STRIKE_RULE);
69    rules.register(Html, HIGHLIGHT_RULE);
70    rules.register(Html, SMALLCAPS_RULE);
71    rules.register(Html, RAW_RULE);
72    rules.register(Html, RAW_LINE_RULE);
73
74    // Layout.
75    rules.register(Html, BLOCK_RULE);
76    rules.register(Html, BOX_RULE);
77
78    // Visualize.
79    rules.register(Html, IMAGE_RULE);
80
81    // For the HTML target, `html.frame` is a primitive. In the laid-out target,
82    // it should be a no-op so that nested frames don't break (things like `show
83    // math.equation: html.frame` can result in nested ones).
84    rules.register::<FrameElem>(Paged, |elem, _, _| Ok(elem.body.clone()));
85}
86
87const PAR_RULE: ShowFn<ParElem> =
88    |elem, _, _| Ok(HtmlElem::new(tag::p).with_body(Some(elem.body.clone())).pack());
89
90const STRONG_RULE: ShowFn<StrongElem> =
91    |elem, _, _| Ok(HtmlElem::new(tag::strong).with_body(Some(elem.body.clone())).pack());
92
93const EMPH_RULE: ShowFn<EmphElem> =
94    |elem, _, _| Ok(HtmlElem::new(tag::em).with_body(Some(elem.body.clone())).pack());
95
96const LIST_RULE: ShowFn<ListElem> = |elem, _, styles| {
97    Ok(HtmlElem::new(tag::ul)
98        .with_body(Some(Content::sequence(elem.children.iter().map(|item| {
99            // Text in wide lists shall always turn into paragraphs.
100            let mut body = item.body.clone();
101            if !elem.tight.get(styles) {
102                body += ParbreakElem::shared();
103            }
104            HtmlElem::new(tag::li)
105                .with_body(Some(body))
106                .pack()
107                .spanned(item.span())
108        }))))
109        .pack())
110};
111
112const ENUM_RULE: ShowFn<EnumElem> = |elem, _, styles| {
113    let mut ol = HtmlElem::new(tag::ol);
114
115    if elem.reversed.get(styles) {
116        ol = ol.with_attr(attr::reversed, "reversed");
117    }
118
119    if let Some(n) = elem.start.get(styles).custom() {
120        ol = ol.with_attr(attr::start, eco_format!("{n}"));
121    }
122
123    let body = Content::sequence(elem.children.iter().map(|item| {
124        let mut li = HtmlElem::new(tag::li);
125        if let Smart::Custom(nr) = item.number.get(styles) {
126            li = li.with_attr(attr::value, eco_format!("{nr}"));
127        }
128        // Text in wide enums shall always turn into paragraphs.
129        let mut body = item.body.clone();
130        if !elem.tight.get(styles) {
131            body += ParbreakElem::shared();
132        }
133        li.with_body(Some(body)).pack().spanned(item.span())
134    }));
135
136    Ok(ol.with_body(Some(body)).pack())
137};
138
139const TERMS_RULE: ShowFn<TermsElem> = |elem, _, styles| {
140    Ok(HtmlElem::new(tag::dl)
141        .with_body(Some(Content::sequence(elem.children.iter().flat_map(|item| {
142            // Text in wide term lists shall always turn into paragraphs.
143            let mut description = item.description.clone();
144            if !elem.tight.get(styles) {
145                description += ParbreakElem::shared();
146            }
147
148            [
149                HtmlElem::new(tag::dt)
150                    .with_body(Some(item.term.clone()))
151                    .pack()
152                    .spanned(item.term.span()),
153                HtmlElem::new(tag::dd)
154                    .with_body(Some(description))
155                    .pack()
156                    .spanned(item.description.span()),
157            ]
158        }))))
159        .pack())
160};
161
162const LINK_RULE: ShowFn<LinkElem> = |elem, engine, _| {
163    let dest = elem.dest.resolve(engine.introspector).at(elem.span())?;
164
165    let href = match dest {
166        Destination::Url(url) => Some(url.clone().into_inner()),
167        Destination::Location(location) => {
168            let id = engine
169                .introspector
170                .html_id(location)
171                .cloned()
172                .ok_or("failed to determine link anchor")
173                .at(elem.span())?;
174            Some(eco_format!("#{id}"))
175        }
176        Destination::Position(_) => {
177            engine.sink.warn(warning!(
178                elem.span(),
179                "positional link was ignored during HTML export"
180            ));
181            None
182        }
183    };
184
185    Ok(HtmlElem::new(tag::a)
186        .with_optional_attr(attr::href, href)
187        .with_body(Some(elem.body.clone()))
188        .pack())
189};
190
191const DIRECT_LINK_RULE: ShowFn<DirectLinkElem> = |elem, _, _| {
192    Ok(LinkElem::new(
193        LinkTarget::Dest(Destination::Location(elem.loc)),
194        elem.body.clone(),
195    )
196    .pack())
197};
198
199const TITLE_RULE: ShowFn<TitleElem> = |elem, _, styles| {
200    Ok(HtmlElem::new(tag::h1)
201        .with_body(Some(elem.resolve_body(styles).at(elem.span())?))
202        .pack())
203};
204
205const HEADING_RULE: ShowFn<HeadingElem> = |elem, engine, styles| {
206    let span = elem.span();
207
208    let mut realized = elem.body.clone();
209    if let Some(numbering) = elem.numbering.get_ref(styles).as_ref() {
210        let location = elem.location().unwrap();
211        let numbering = Counter::of(HeadingElem::ELEM)
212            .display_at_loc(engine, location, styles, numbering)?
213            .spanned(span);
214        realized = numbering + SpaceElem::shared().clone() + realized;
215    }
216
217    // HTML's h1 is closer to a title element. There should only be one.
218    // Meanwhile, a level 1 Typst heading is a section heading. For this
219    // reason, levels are offset by one: A Typst level 1 heading becomes
220    // a `<h2>`.
221    let level = elem.resolve_level(styles).get();
222    Ok(if level >= 6 {
223        engine.sink.warn(warning!(
224            span,
225            "heading of level {} was transformed to \
226             <div role=\"heading\" aria-level=\"{}\">, which is not \
227             supported by all assistive technology",
228            level, level + 1;
229            hint: "HTML only supports <h1> to <h6>, not <h{}>", level + 1;
230            hint: "you may want to restructure your document so that \
231                   it doesn't contain deep headings"
232        ));
233        HtmlElem::new(tag::div)
234            .with_body(Some(realized))
235            .with_attr(attr::role, "heading")
236            .with_attr(attr::aria_level, eco_format!("{}", level + 1))
237            .pack()
238    } else {
239        let t = [tag::h2, tag::h3, tag::h4, tag::h5, tag::h6][level - 1];
240        HtmlElem::new(t).with_body(Some(realized)).pack()
241    })
242};
243
244const FIGURE_RULE: ShowFn<FigureElem> = |elem, _, styles| {
245    let span = elem.span();
246    let mut realized = elem.body.clone();
247
248    // Build the caption, if any.
249    if let Some(caption) = elem.caption.get_cloned(styles) {
250        realized = match caption.position.get(styles) {
251            OuterVAlignment::Top => caption.pack() + realized,
252            OuterVAlignment::Bottom => realized + caption.pack(),
253        };
254    }
255
256    // Ensure that the body is considered a paragraph.
257    realized += ParbreakElem::shared().clone().spanned(span);
258
259    Ok(HtmlElem::new(tag::figure).with_body(Some(realized)).pack())
260};
261
262const FIGURE_CAPTION_RULE: ShowFn<FigureCaption> = |elem, engine, styles| {
263    Ok(HtmlElem::new(tag::figcaption)
264        .with_body(Some(elem.realize(engine, styles)?))
265        .pack())
266};
267
268const QUOTE_RULE: ShowFn<QuoteElem> = |elem, _, styles| {
269    let span = elem.span();
270    let block = elem.block.get(styles);
271
272    let mut realized = elem.body.clone();
273
274    if elem.quotes.get(styles).unwrap_or(!block) {
275        realized = QuoteElem::quoted(realized, styles);
276    }
277
278    let attribution = elem.attribution.get_ref(styles);
279
280    if block {
281        let mut blockquote = HtmlElem::new(tag::blockquote).with_body(Some(realized));
282        if let Some(Attribution::Content(attribution)) = attribution
283            && let Some(link) = attribution.to_packed::<LinkElem>()
284            && let LinkTarget::Dest(Destination::Url(url)) = &link.dest
285        {
286            blockquote = blockquote.with_attr(attr::cite, url.clone().into_inner());
287        }
288
289        realized = blockquote.pack().spanned(span);
290
291        if let Some(attribution) = attribution.as_ref() {
292            realized += attribution.realize(span);
293        }
294    } else if let Some(Attribution::Label(label)) = attribution {
295        realized += SpaceElem::shared().clone();
296        realized += CiteElem::new(*label).pack().spanned(span);
297    }
298
299    Ok(realized)
300};
301
302const FOOTNOTE_RULE: ShowFn<FootnoteElem> = |elem, engine, styles| {
303    let span = elem.span();
304    let (dest, num) = elem.realize(engine, styles)?;
305    let sup = SuperElem::new(num).pack().spanned(span);
306
307    // Link to the footnote entry.
308    let link = LinkElem::new(dest.into(), sup)
309        .pack()
310        .styled(HtmlElem::role.set(Some("doc-noteref".into())));
311
312    // Indicates the presence of a default footnote rule to emit an error when
313    // no footnote container is available.
314    let marker = FootnoteMarker::new().pack().spanned(span);
315
316    Ok(HElem::hole().clone() + link + marker)
317};
318
319/// This is inserted at the end of the body to display footnotes. In the future,
320/// we can expose this to allow customizing where the footnotes appear. It could
321/// also be exposed for paged export.
322#[elem]
323pub struct FootnoteContainer {}
324
325impl FootnoteContainer {
326    /// Get the globally shared footnote container element.
327    pub fn shared() -> &'static Content {
328        singleton!(Content, FootnoteContainer::new().pack())
329    }
330
331    /// Fails with an error if there are footnotes.
332    pub fn unsupported_with_custom_dom(engine: &Engine) -> SourceResult<()> {
333        let markers = engine.introspector.query(&FootnoteMarker::ELEM.select());
334        if markers.is_empty() {
335            return Ok(());
336        }
337
338        Err(markers
339            .iter()
340            .map(|marker| {
341                error!(
342                    marker.span(),
343                    "footnotes are not currently supported in combination \
344                     with a custom `<html>` or `<body>` element";
345                    hint: "you can still use footnotes with a custom footnote show rule"
346                )
347            })
348            .collect())
349    }
350}
351
352const FOOTNOTE_MARKER_RULE: ShowFn<FootnoteMarker> = |_, _, _| Ok(Content::empty());
353
354const FOOTNOTE_CONTAINER_RULE: ShowFn<FootnoteContainer> = |_, engine, _| {
355    let notes = engine.introspector.query(&FootnoteElem::ELEM.select());
356    if notes.is_empty() {
357        return Ok(Content::empty());
358    }
359
360    // Create entries for all footnotes in the document.
361    let items = notes.into_iter().filter_map(|note| {
362        let note = note.into_packed::<FootnoteElem>().unwrap();
363        if note.is_ref() {
364            return None;
365        }
366
367        let loc = note.location().unwrap();
368        let span = note.span();
369        Some(
370            HtmlElem::new(tag::li)
371                .with_body(Some(FootnoteEntry::new(note).pack().spanned(span)))
372                .with_parent(loc)
373                .pack()
374                .located(loc.variant(1))
375                .spanned(span),
376        )
377    });
378
379    // There can be multiple footnotes in a container, so they semantically
380    // represent an ordered list. However, the list is already numbered with the
381    // footnote superscripts in the DOM, so we turn off CSS' list enumeration.
382    let list = HtmlElem::new(tag::ol)
383        .with_styles(css::Properties::new().with("list-style-type", "none"))
384        .with_body(Some(Content::sequence(items)))
385        .pack();
386
387    // The user may want to style the whole footnote element so we wrap it in an
388    // additional selectable container. This is also how it's done in the ARIA
389    // spec (although there, the section also contains an additional heading).
390    Ok(HtmlElem::new(tag::section)
391        .with_attr(attr::role, "doc-endnotes")
392        .with_body(Some(list))
393        .pack())
394};
395
396const FOOTNOTE_ENTRY_RULE: ShowFn<FootnoteEntry> = |elem, engine, styles| {
397    let (prefix, body) = elem.realize(engine, styles)?;
398
399    // The prefix is a link back to the first footnote reference, so
400    // `doc-backlink` is the appropriate ARIA role.
401    let backlink = prefix.styled(HtmlElem::role.set(Some("doc-backlink".into())));
402
403    // We do not use the ARIA role `doc-footnote` because it "is only for
404    // representing individual notes that occur within the body of a work" (see
405    // <https://www.w3.org/TR/dpub-aria-1.1/#doc-footnote>). Our footnotes more
406    // appropriately modelled as ARIA endnotes. This is also in line with how
407    // Pandoc handles footnotes.
408    Ok(backlink + body)
409};
410
411const OUTLINE_RULE: ShowFn<OutlineElem> = |elem, engine, styles| {
412    fn convert_list(list: Vec<OutlineNode>) -> Content {
413        // The Digital Publishing ARIA spec also proposed to add
414        // `role="directory"` to the `<ol>` element, but this role is
415        // deprecated, so we don't do that. The elements are already easily
416        // selectable via `nav[role="doc-toc"] ol`.
417        HtmlElem::new(tag::ol)
418            .with_styles(css::Properties::new().with("list-style-type", "none"))
419            .with_body(Some(Content::sequence(list.into_iter().map(convert_node))))
420            .pack()
421    }
422
423    fn convert_node(node: OutlineNode) -> Content {
424        let body = if !node.children.is_empty() {
425            // The `<div>` is not technically necessary, but otherwise it
426            // auto-wraps in a `<p>`, which results in bad spacing. Perhaps, we
427            // can remove this in the future. See also:
428            // <https://github.com/typst/typst/issues/5907>
429            HtmlElem::new(tag::div).with_body(Some(node.entry.pack())).pack()
430                + convert_list(node.children)
431        } else {
432            node.entry.pack()
433        };
434        HtmlElem::new(tag::li).with_body(Some(body)).pack()
435    }
436
437    let title = elem.realize_title(styles);
438    let tree = elem.realize_tree(engine, styles)?;
439    let list = convert_list(tree);
440
441    Ok(HtmlElem::new(tag::nav)
442        .with_attr(attr::role, "doc-toc")
443        .with_body(Some(title.unwrap_or_default() + list))
444        .pack())
445};
446
447const OUTLINE_ENTRY_RULE: ShowFn<OutlineEntry> = |elem, engine, styles| {
448    let span = elem.span();
449    let context = Context::new(None, Some(styles));
450
451    let mut realized = elem.body().at(span)?;
452
453    if let Some(prefix) = elem.prefix(engine, context.track(), span)? {
454        let wrapped = HtmlElem::new(tag::span)
455            .with_attr(attr::class, "prefix")
456            .with_body(Some(prefix))
457            .pack()
458            .spanned(span);
459
460        let separator = match elem.element.to_packed::<FigureElem>() {
461            Some(elem) => elem.resolve_separator(styles),
462            None => SpaceElem::shared().clone(),
463        };
464
465        realized = Content::sequence([wrapped, separator, realized]);
466    }
467
468    let loc = elem.element_location().at(span)?;
469    let dest = Destination::Location(loc);
470
471    Ok(LinkElem::new(dest.into(), realized).pack())
472};
473
474const REF_RULE: ShowFn<RefElem> = |elem, engine, styles| elem.realize(engine, styles);
475
476const CITE_GROUP_RULE: ShowFn<CiteGroup> = |elem, engine, _| {
477    Ok(elem
478        .realize(engine)?
479        .styled(HtmlElem::role.set(Some("doc-biblioref".into()))))
480};
481
482// For the bibliography, we have a few elements that should be styled (e.g.
483// indent), but inline styles are not apprioriate because they couldn't be
484// properly overridden. For those, we currently emit classes so that a user can
485// style them with CSS, but do not emit any styles ourselves.
486const BIBLIOGRAPHY_RULE: ShowFn<BibliographyElem> = |elem, engine, styles| {
487    let span = elem.span();
488    let works = Works::generate(engine).at(span)?;
489    let references = works.references(elem, styles)?;
490
491    let items = references.iter().map(|(prefix, reference, loc)| {
492        let mut realized = reference.clone();
493
494        if let Some(mut prefix) = prefix.clone() {
495            // If we have a link back to the first citation referencing this
496            // entry, attach the appropriate role.
497            if prefix.is::<DirectLinkElem>() {
498                prefix = prefix.set(HtmlElem::role, Some("doc-backlink".into()));
499            }
500
501            let wrapped = HtmlElem::new(tag::span)
502                .with_attr(attr::class, "prefix")
503                .with_body(Some(prefix))
504                .pack()
505                .spanned(span);
506
507            let separator = SpaceElem::shared().clone();
508            realized = Content::sequence([wrapped, separator, realized]);
509        }
510
511        HtmlElem::new(tag::li)
512            .with_body(Some(realized))
513            .pack()
514            .located(*loc)
515            .spanned(span)
516    });
517
518    let title = elem.realize_title(styles);
519    let list = HtmlElem::new(tag::ul)
520        .with_styles(css::Properties::new().with("list-style-type", "none"))
521        .with_body(Some(Content::sequence(items)))
522        .pack()
523        .spanned(span);
524
525    Ok(HtmlElem::new(tag::section)
526        .with_attr(attr::role, "doc-bibliography")
527        .with_optional_attr(attr::class, works.hanging_indent.then_some("hanging-indent"))
528        .with_body(Some(title.unwrap_or_default() + list))
529        .pack())
530};
531
532const CSL_LIGHT_RULE: ShowFn<CslLightElem> = |elem, _, _| {
533    Ok(HtmlElem::new(tag::span)
534        .with_attr(attr::class, "light")
535        .with_body(Some(elem.body.clone()))
536        .pack())
537};
538
539const CSL_INDENT_RULE: ShowFn<CslIndentElem> = |elem, _, _| {
540    Ok(HtmlElem::new(tag::div)
541        .with_attr(attr::class, "indent")
542        .with_body(Some(elem.body.clone()))
543        .pack())
544};
545
546const TABLE_RULE: ShowFn<TableElem> = |elem, _, styles| {
547    let grid = elem.grid.as_ref().unwrap();
548    Ok(show_cellgrid(grid, styles))
549};
550
551fn show_cellgrid(grid: &CellGrid, styles: StyleChain) -> Content {
552    let elem = |tag, body| HtmlElem::new(tag).with_body(Some(body)).pack();
553    let mut rows: Vec<_> = grid.entries.chunks(grid.non_gutter_column_count()).collect();
554
555    let tr = |tag, row: &[Entry]| {
556        let row = row
557            .iter()
558            .flat_map(|entry| entry.as_cell())
559            .map(|cell| show_cell(tag, cell, styles));
560        elem(tag::tr, Content::sequence(row))
561    };
562
563    // TODO(subfooters): similarly to headers, take consecutive footers from
564    // the end for 'tfoot'.
565    let footer = grid.footer.as_ref().map(|ft| {
566        // Convert from gutter to non-gutter coordinates. Use ceil as it might
567        // include the previous gutter row
568        // (cf. typst-library/layout/grid/resolve.rs).
569        let footer_start = if grid.has_gutter { ft.start.div_ceil(2) } else { ft.start };
570        let rows = rows.drain(footer_start..);
571        elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row))))
572    });
573
574    // Header range converting from gutter (doubled) to non-gutter coordinates.
575    let header_range = |hd: &Header| {
576        if grid.has_gutter {
577            // Use ceil as it might be `2 * row_amount - 1` if the header is at
578            // the end (cf. typst-library/layout/grid/resolve.rs).
579            hd.range.start / 2..hd.range.end.div_ceil(2)
580        } else {
581            hd.range.clone()
582        }
583    };
584
585    // Store all consecutive headers at the start in 'thead'. All remaining
586    // headers are just 'th' rows across the table body.
587    let mut consecutive_header_end = 0;
588    let first_mid_table_header = grid
589        .headers
590        .iter()
591        .take_while(|hd| {
592            let range = header_range(hd);
593            let is_consecutive = range.start == consecutive_header_end;
594            consecutive_header_end = range.end;
595            is_consecutive
596        })
597        .count();
598
599    let (y_offset, header) = if first_mid_table_header > 0 {
600        let removed_header_rows =
601            header_range(grid.headers.get(first_mid_table_header - 1).unwrap()).end;
602        let rows = rows.drain(..removed_header_rows);
603
604        (
605            removed_header_rows,
606            Some(elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row))))),
607        )
608    } else {
609        (0, None)
610    };
611
612    // TODO: Consider improving accessibility properties of multi-level headers
613    // inside tables in the future, e.g. indicating which columns they are
614    // relative to and so on. See also:
615    // https://www.w3.org/WAI/tutorials/tables/multi-level/
616    let mut next_header = first_mid_table_header;
617    let mut body =
618        Content::sequence(rows.into_iter().enumerate().map(|(relative_y, row)| {
619            let y = relative_y + y_offset;
620            if let Some(current_header_range) =
621                grid.headers.get(next_header).map(|h| header_range(h))
622                && current_header_range.contains(&y)
623            {
624                if y + 1 == current_header_range.end {
625                    next_header += 1;
626                }
627
628                tr(tag::th, row)
629            } else {
630                tr(tag::td, row)
631            }
632        }));
633
634    if header.is_some() || footer.is_some() {
635        body = elem(tag::tbody, body);
636    }
637
638    let content = header.into_iter().chain(core::iter::once(body)).chain(footer);
639    elem(tag::table, Content::sequence(content))
640}
641
642fn show_cell(tag: HtmlTag, cell: &Cell, styles: StyleChain) -> Content {
643    let cell = cell.body.clone();
644    let Some(cell) = cell.to_packed::<TableCell>() else { return cell };
645    let mut attrs = HtmlAttrs::new();
646    let span = |n: NonZeroUsize| (n != NonZeroUsize::MIN).then(|| n.to_string());
647    if let Some(colspan) = span(cell.colspan.get(styles)) {
648        attrs.push(attr::colspan, colspan);
649    }
650    if let Some(rowspan) = span(cell.rowspan.get(styles)) {
651        attrs.push(attr::rowspan, rowspan);
652    }
653    HtmlElem::new(tag)
654        .with_body(Some(cell.body.clone()))
655        .with_attrs(attrs)
656        .pack()
657        .spanned(cell.span())
658}
659
660const SUB_RULE: ShowFn<SubElem> =
661    |elem, _, _| Ok(HtmlElem::new(tag::sub).with_body(Some(elem.body.clone())).pack());
662
663const SUPER_RULE: ShowFn<SuperElem> =
664    |elem, _, _| Ok(HtmlElem::new(tag::sup).with_body(Some(elem.body.clone())).pack());
665
666const UNDERLINE_RULE: ShowFn<UnderlineElem> = |elem, _, _| {
667    // Note: In modern HTML, `<u>` is not the underline element, but
668    // rather an "Unarticulated Annotation" element (see HTML spec
669    // 4.5.22). Using `text-decoration` instead is recommended by MDN.
670    Ok(HtmlElem::new(tag::span)
671        .with_attr(attr::style, "text-decoration: underline")
672        .with_body(Some(elem.body.clone()))
673        .pack())
674};
675
676const OVERLINE_RULE: ShowFn<OverlineElem> = |elem, _, _| {
677    Ok(HtmlElem::new(tag::span)
678        .with_attr(attr::style, "text-decoration: overline")
679        .with_body(Some(elem.body.clone()))
680        .pack())
681};
682
683const STRIKE_RULE: ShowFn<StrikeElem> =
684    |elem, _, _| Ok(HtmlElem::new(tag::s).with_body(Some(elem.body.clone())).pack());
685
686const HIGHLIGHT_RULE: ShowFn<HighlightElem> =
687    |elem, _, _| Ok(HtmlElem::new(tag::mark).with_body(Some(elem.body.clone())).pack());
688
689const SMALLCAPS_RULE: ShowFn<SmallcapsElem> = |elem, _, styles| {
690    Ok(HtmlElem::new(tag::span)
691        .with_attr(
692            attr::style,
693            if elem.all.get(styles) {
694                "font-variant-caps: all-small-caps"
695            } else {
696                "font-variant-caps: small-caps"
697            },
698        )
699        .with_body(Some(elem.body.clone()))
700        .pack())
701};
702
703const RAW_RULE: ShowFn<RawElem> = |elem, _, styles| {
704    let lines = elem.lines.as_deref().unwrap_or_default();
705
706    let mut seq = EcoVec::with_capacity((2 * lines.len()).saturating_sub(1));
707    for (i, line) in lines.iter().enumerate() {
708        if i != 0 {
709            seq.push(LinebreakElem::shared().clone());
710        }
711
712        seq.push(line.clone().pack());
713    }
714
715    let lang = elem.lang.get_ref(styles);
716    let code = HtmlElem::new(tag::code)
717        .with_optional_attr(const { HtmlAttr::constant("data-lang") }, lang.clone())
718        .with_body(Some(Content::sequence(seq)))
719        .pack()
720        .spanned(elem.span());
721
722    Ok(if elem.block.get(styles) {
723        HtmlElem::new(tag::pre).with_body(Some(code)).pack()
724    } else {
725        code
726    })
727};
728
729/// This is used by `RawElem::synthesize` through a routine.
730///
731/// It's a temporary workaround until `TextElem::fill` is supported in HTML
732/// export.
733#[doc(hidden)]
734pub fn html_span_filled(content: Content, color: Color) -> Content {
735    let span = content.span();
736    HtmlElem::new(tag::span)
737        .with_styles(css::Properties::new().with("color", css::color(color)))
738        .with_body(Some(content))
739        .pack()
740        .spanned(span)
741}
742
743const RAW_LINE_RULE: ShowFn<RawLine> = |elem, _, _| Ok(elem.body.clone());
744
745// TODO: This is rather incomplete.
746const BLOCK_RULE: ShowFn<BlockElem> = |elem, _, styles| {
747    let body = match elem.body.get_cloned(styles) {
748        None => None,
749        Some(BlockBody::Content(body)) => Some(body),
750        // These are only generated by native `typst-layout` show rules.
751        Some(BlockBody::SingleLayouter(_) | BlockBody::MultiLayouter(_)) => {
752            bail!(
753                elem.span(),
754                "blocks with layout routines should not occur in \
755                 HTML export – this is a bug"
756            )
757        }
758    };
759
760    Ok(HtmlElem::new(tag::div).with_body(body).pack())
761};
762
763// TODO: This is rather incomplete.
764const BOX_RULE: ShowFn<BoxElem> = |elem, _, styles| {
765    Ok(HtmlElem::new(tag::span)
766        .with_styles(css::Properties::new().with("display", "inline-block"))
767        .with_body(elem.body.get_cloned(styles))
768        .pack())
769};
770
771const IMAGE_RULE: ShowFn<ImageElem> = |elem, engine, styles| {
772    let image = elem.decode(engine, styles)?;
773
774    let mut attrs = HtmlAttrs::new();
775    attrs.push(attr::src, typst_svg::convert_image_to_base64_url(&image));
776
777    if let Some(alt) = elem.alt.get_cloned(styles) {
778        attrs.push(attr::alt, alt);
779    }
780
781    let mut inline = css::Properties::new();
782
783    // TODO: Exclude in semantic profile.
784    if let Some(value) = typst_svg::convert_image_scaling(image.scaling()) {
785        inline.push("image-rendering", value);
786    }
787
788    // TODO: Exclude in semantic profile?
789    match elem.width.get(styles) {
790        Smart::Auto => {}
791        Smart::Custom(rel) => inline.push("width", css::rel(rel)),
792    }
793
794    // TODO: Exclude in semantic profile?
795    match elem.height.get(styles) {
796        Sizing::Auto => {}
797        Sizing::Rel(rel) => inline.push("height", css::rel(rel)),
798        Sizing::Fr(_) => {}
799    }
800
801    Ok(HtmlElem::new(tag::img).with_attrs(attrs).with_styles(inline).pack())
802};