diff options
| author | Laurenz <laurmaedje@gmail.com> | 2025-07-09 10:16:36 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-07-09 08:16:36 +0000 |
| commit | e5e1dcd9c01341d2cd3473ac94a70223d5966086 (patch) | |
| tree | d648efad9463cb10270d55ba35210eeb1e91ee22 /crates/typst-html/src | |
| parent | 0a3c6939dd274f40672484695d909c2cc0d0d755 (diff) | |
Target-specific native show rules (#6569)
Diffstat (limited to 'crates/typst-html/src')
| -rw-r--r-- | crates/typst-html/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/typst-html/src/rules.rs | 411 |
2 files changed, 413 insertions, 0 deletions
diff --git a/crates/typst-html/src/lib.rs b/crates/typst-html/src/lib.rs index 60ffa78e..19eb1446 100644 --- a/crates/typst-html/src/lib.rs +++ b/crates/typst-html/src/lib.rs @@ -1,8 +1,10 @@ //! Typst's HTML exporter. mod encode; +mod rules; pub use self::encode::html; +pub use self::rules::register; use comemo::{Track, Tracked, TrackedMut}; use typst_library::diag::{bail, warning, At, SourceResult}; diff --git a/crates/typst-html/src/rules.rs b/crates/typst-html/src/rules.rs new file mode 100644 index 00000000..f361bfbb --- /dev/null +++ b/crates/typst-html/src/rules.rs @@ -0,0 +1,411 @@ +use std::num::NonZeroUsize; + +use ecow::{eco_format, EcoVec}; +use typst_library::diag::warning; +use typst_library::foundations::{ + Content, NativeElement, NativeRuleMap, ShowFn, StyleChain, Target, +}; +use typst_library::html::{attr, tag, HtmlAttrs, HtmlElem, HtmlTag}; +use typst_library::introspection::{Counter, Locator}; +use typst_library::layout::resolve::{table_to_cellgrid, Cell, CellGrid, Entry}; +use typst_library::layout::OuterVAlignment; +use typst_library::model::{ + Attribution, CiteElem, CiteGroup, Destination, EmphElem, EnumElem, FigureCaption, + FigureElem, HeadingElem, LinkElem, LinkTarget, ListElem, ParbreakElem, QuoteElem, + RefElem, StrongElem, TableCell, TableElem, TermsElem, +}; +use typst_library::text::{ + HighlightElem, LinebreakElem, OverlineElem, RawElem, RawLine, SpaceElem, StrikeElem, + SubElem, SuperElem, UnderlineElem, +}; + +/// Register show rules for the [HTML target](Target::Html). +pub fn register(rules: &mut NativeRuleMap) { + use Target::Html; + + // Model. + rules.register(Html, STRONG_RULE); + rules.register(Html, EMPH_RULE); + rules.register(Html, LIST_RULE); + rules.register(Html, ENUM_RULE); + rules.register(Html, TERMS_RULE); + rules.register(Html, LINK_RULE); + rules.register(Html, HEADING_RULE); + rules.register(Html, FIGURE_RULE); + rules.register(Html, FIGURE_CAPTION_RULE); + rules.register(Html, QUOTE_RULE); + rules.register(Html, REF_RULE); + rules.register(Html, CITE_GROUP_RULE); + rules.register(Html, TABLE_RULE); + + // Text. + rules.register(Html, SUB_RULE); + rules.register(Html, SUPER_RULE); + rules.register(Html, UNDERLINE_RULE); + rules.register(Html, OVERLINE_RULE); + rules.register(Html, STRIKE_RULE); + rules.register(Html, HIGHLIGHT_RULE); + rules.register(Html, RAW_RULE); + rules.register(Html, RAW_LINE_RULE); +} + +const STRONG_RULE: ShowFn<StrongElem> = |elem, _, _| { + Ok(HtmlElem::new(tag::strong) + .with_body(Some(elem.body.clone())) + .pack() + .spanned(elem.span())) +}; + +const EMPH_RULE: ShowFn<EmphElem> = |elem, _, _| { + Ok(HtmlElem::new(tag::em) + .with_body(Some(elem.body.clone())) + .pack() + .spanned(elem.span())) +}; + +const LIST_RULE: ShowFn<ListElem> = |elem, _, styles| { + Ok(HtmlElem::new(tag::ul) + .with_body(Some(Content::sequence(elem.children.iter().map(|item| { + // Text in wide lists shall always turn into paragraphs. + let mut body = item.body.clone(); + if !elem.tight.get(styles) { + body += ParbreakElem::shared(); + } + HtmlElem::new(tag::li) + .with_body(Some(body)) + .pack() + .spanned(item.span()) + })))) + .pack() + .spanned(elem.span())) +}; + +const ENUM_RULE: ShowFn<EnumElem> = |elem, _, styles| { + let mut ol = HtmlElem::new(tag::ol); + + if elem.reversed.get(styles) { + ol = ol.with_attr(attr::reversed, "reversed"); + } + + if let Some(n) = elem.start.get(styles).custom() { + ol = ol.with_attr(attr::start, eco_format!("{n}")); + } + + let body = Content::sequence(elem.children.iter().map(|item| { + let mut li = HtmlElem::new(tag::li); + if let Some(nr) = item.number.get(styles) { + li = li.with_attr(attr::value, eco_format!("{nr}")); + } + // Text in wide enums shall always turn into paragraphs. + let mut body = item.body.clone(); + if !elem.tight.get(styles) { + body += ParbreakElem::shared(); + } + li.with_body(Some(body)).pack().spanned(item.span()) + })); + + Ok(ol.with_body(Some(body)).pack().spanned(elem.span())) +}; + +const TERMS_RULE: ShowFn<TermsElem> = |elem, _, styles| { + Ok(HtmlElem::new(tag::dl) + .with_body(Some(Content::sequence(elem.children.iter().flat_map(|item| { + // Text in wide term lists shall always turn into paragraphs. + let mut description = item.description.clone(); + if !elem.tight.get(styles) { + description += ParbreakElem::shared(); + } + + [ + HtmlElem::new(tag::dt) + .with_body(Some(item.term.clone())) + .pack() + .spanned(item.term.span()), + HtmlElem::new(tag::dd) + .with_body(Some(description)) + .pack() + .spanned(item.description.span()), + ] + })))) + .pack()) +}; + +const LINK_RULE: ShowFn<LinkElem> = |elem, engine, _| { + let body = elem.body.clone(); + Ok(if let LinkTarget::Dest(Destination::Url(url)) = &elem.dest { + HtmlElem::new(tag::a) + .with_attr(attr::href, url.clone().into_inner()) + .with_body(Some(body)) + .pack() + .spanned(elem.span()) + } else { + engine.sink.warn(warning!( + elem.span(), + "non-URL links are not yet supported by HTML export" + )); + body + }) +}; + +const HEADING_RULE: ShowFn<HeadingElem> = |elem, engine, styles| { + let span = elem.span(); + + let mut realized = elem.body.clone(); + if let Some(numbering) = elem.numbering.get_ref(styles).as_ref() { + let location = elem.location().unwrap(); + let numbering = Counter::of(HeadingElem::ELEM) + .display_at_loc(engine, location, styles, numbering)? + .spanned(span); + realized = numbering + SpaceElem::shared().clone() + realized; + } + + // HTML's h1 is closer to a title element. There should only be one. + // Meanwhile, a level 1 Typst heading is a section heading. For this + // reason, levels are offset by one: A Typst level 1 heading becomes + // a `<h2>`. + let level = elem.resolve_level(styles).get(); + Ok(if level >= 6 { + engine.sink.warn(warning!( + span, + "heading of level {} was transformed to \ + <div role=\"heading\" aria-level=\"{}\">, which is not \ + supported by all assistive technology", + level, level + 1; + hint: "HTML only supports <h1> to <h6>, not <h{}>", level + 1; + hint: "you may want to restructure your document so that \ + it doesn't contain deep headings" + )); + HtmlElem::new(tag::div) + .with_body(Some(realized)) + .with_attr(attr::role, "heading") + .with_attr(attr::aria_level, eco_format!("{}", level + 1)) + .pack() + .spanned(span) + } else { + let t = [tag::h2, tag::h3, tag::h4, tag::h5, tag::h6][level - 1]; + HtmlElem::new(t).with_body(Some(realized)).pack().spanned(span) + }) +}; + +const FIGURE_RULE: ShowFn<FigureElem> = |elem, _, styles| { + let span = elem.span(); + let mut realized = elem.body.clone(); + + // Build the caption, if any. + if let Some(caption) = elem.caption.get_cloned(styles) { + realized = match caption.position.get(styles) { + OuterVAlignment::Top => caption.pack() + realized, + OuterVAlignment::Bottom => realized + caption.pack(), + }; + } + + // Ensure that the body is considered a paragraph. + realized += ParbreakElem::shared().clone().spanned(span); + + Ok(HtmlElem::new(tag::figure) + .with_body(Some(realized)) + .pack() + .spanned(span)) +}; + +const FIGURE_CAPTION_RULE: ShowFn<FigureCaption> = |elem, engine, styles| { + Ok(HtmlElem::new(tag::figcaption) + .with_body(Some(elem.realize(engine, styles)?)) + .pack() + .spanned(elem.span())) +}; + +const QUOTE_RULE: ShowFn<QuoteElem> = |elem, _, styles| { + let span = elem.span(); + let block = elem.block.get(styles); + + let mut realized = elem.body.clone(); + + if elem.quotes.get(styles).unwrap_or(!block) { + realized = QuoteElem::quoted(realized, styles); + } + + let attribution = elem.attribution.get_ref(styles); + + if block { + let mut blockquote = HtmlElem::new(tag::blockquote).with_body(Some(realized)); + if let Some(Attribution::Content(attribution)) = attribution { + if let Some(link) = attribution.to_packed::<LinkElem>() { + if let LinkTarget::Dest(Destination::Url(url)) = &link.dest { + blockquote = + blockquote.with_attr(attr::cite, url.clone().into_inner()); + } + } + } + + realized = blockquote.pack().spanned(span); + + if let Some(attribution) = attribution.as_ref() { + realized += attribution.realize(span); + } + } else if let Some(Attribution::Label(label)) = attribution { + realized += SpaceElem::shared().clone(); + realized += CiteElem::new(*label).pack().spanned(span); + } + + Ok(realized) +}; + +const REF_RULE: ShowFn<RefElem> = |elem, engine, styles| elem.realize(engine, styles); + +const CITE_GROUP_RULE: ShowFn<CiteGroup> = |elem, engine, _| elem.realize(engine); + +const TABLE_RULE: ShowFn<TableElem> = |elem, engine, styles| { + // The locator is not used by HTML export, so we can just fabricate one. + let locator = Locator::root(); + Ok(show_cellgrid(table_to_cellgrid(elem, engine, locator, styles)?, styles)) +}; + +fn show_cellgrid(grid: CellGrid, styles: StyleChain) -> Content { + let elem = |tag, body| HtmlElem::new(tag).with_body(Some(body)).pack(); + let mut rows: Vec<_> = grid.entries.chunks(grid.non_gutter_column_count()).collect(); + + let tr = |tag, row: &[Entry]| { + let row = row + .iter() + .flat_map(|entry| entry.as_cell()) + .map(|cell| show_cell(tag, cell, styles)); + elem(tag::tr, Content::sequence(row)) + }; + + // TODO(subfooters): similarly to headers, take consecutive footers from + // the end for 'tfoot'. + let footer = grid.footer.map(|ft| { + let rows = rows.drain(ft.start..); + elem(tag::tfoot, Content::sequence(rows.map(|row| tr(tag::td, row)))) + }); + + // Store all consecutive headers at the start in 'thead'. All remaining + // headers are just 'th' rows across the table body. + let mut consecutive_header_end = 0; + let first_mid_table_header = grid + .headers + .iter() + .take_while(|hd| { + let is_consecutive = hd.range.start == consecutive_header_end; + consecutive_header_end = hd.range.end; + is_consecutive + }) + .count(); + + let (y_offset, header) = if first_mid_table_header > 0 { + let removed_header_rows = + grid.headers.get(first_mid_table_header - 1).unwrap().range.end; + let rows = rows.drain(..removed_header_rows); + + ( + removed_header_rows, + Some(elem(tag::thead, Content::sequence(rows.map(|row| tr(tag::th, row))))), + ) + } else { + (0, None) + }; + + // TODO: Consider improving accessibility properties of multi-level headers + // inside tables in the future, e.g. indicating which columns they are + // relative to and so on. See also: + // https://www.w3.org/WAI/tutorials/tables/multi-level/ + let mut next_header = first_mid_table_header; + let mut body = + Content::sequence(rows.into_iter().enumerate().map(|(relative_y, row)| { + let y = relative_y + y_offset; + if let Some(current_header) = + grid.headers.get(next_header).filter(|h| h.range.contains(&y)) + { + if y + 1 == current_header.range.end { + next_header += 1; + } + + tr(tag::th, row) + } else { + tr(tag::td, row) + } + })); + + if header.is_some() || footer.is_some() { + body = elem(tag::tbody, body); + } + + let content = header.into_iter().chain(core::iter::once(body)).chain(footer); + elem(tag::table, Content::sequence(content)) +} + +fn show_cell(tag: HtmlTag, cell: &Cell, styles: StyleChain) -> Content { + let cell = cell.body.clone(); + let Some(cell) = cell.to_packed::<TableCell>() else { return cell }; + let mut attrs = HtmlAttrs::default(); + let span = |n: NonZeroUsize| (n != NonZeroUsize::MIN).then(|| n.to_string()); + if let Some(colspan) = span(cell.colspan.get(styles)) { + attrs.push(attr::colspan, colspan); + } + if let Some(rowspan) = span(cell.rowspan.get(styles)) { + attrs.push(attr::rowspan, rowspan); + } + HtmlElem::new(tag) + .with_body(Some(cell.body.clone())) + .with_attrs(attrs) + .pack() + .spanned(cell.span()) +} + +const SUB_RULE: ShowFn<SubElem> = |elem, _, _| { + Ok(HtmlElem::new(tag::sub) + .with_body(Some(elem.body.clone())) + .pack() + .spanned(elem.span())) +}; + +const SUPER_RULE: ShowFn<SuperElem> = |elem, _, _| { + Ok(HtmlElem::new(tag::sup) + .with_body(Some(elem.body.clone())) + .pack() + .spanned(elem.span())) +}; + +const UNDERLINE_RULE: ShowFn<UnderlineElem> = |elem, _, _| { + // Note: In modern HTML, `<u>` is not the underline element, but + // rather an "Unarticulated Annotation" element (see HTML spec + // 4.5.22). Using `text-decoration` instead is recommended by MDN. + Ok(HtmlElem::new(tag::span) + .with_attr(attr::style, "text-decoration: underline") + .with_body(Some(elem.body.clone())) + .pack()) +}; + +const OVERLINE_RULE: ShowFn<OverlineElem> = |elem, _, _| { + Ok(HtmlElem::new(tag::span) + .with_attr(attr::style, "text-decoration: overline") + .with_body(Some(elem.body.clone())) + .pack()) +}; + +const STRIKE_RULE: ShowFn<StrikeElem> = + |elem, _, _| Ok(HtmlElem::new(tag::s).with_body(Some(elem.body.clone())).pack()); + +const HIGHLIGHT_RULE: ShowFn<HighlightElem> = + |elem, _, _| Ok(HtmlElem::new(tag::mark).with_body(Some(elem.body.clone())).pack()); + +const RAW_RULE: ShowFn<RawElem> = |elem, _, styles| { + let lines = elem.lines.as_deref().unwrap_or_default(); + + let mut seq = EcoVec::with_capacity((2 * lines.len()).saturating_sub(1)); + for (i, line) in lines.iter().enumerate() { + if i != 0 { + seq.push(LinebreakElem::shared().clone()); + } + + seq.push(line.clone().pack()); + } + + Ok(HtmlElem::new(if elem.block.get(styles) { tag::pre } else { tag::code }) + .with_body(Some(Content::sequence(seq))) + .pack() + .spanned(elem.span())) +}; + +const RAW_LINE_RULE: ShowFn<RawLine> = |elem, _, _| Ok(elem.body.clone()); |
