Skip to main content

typst_html/
rules.rs

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