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
38pub fn register(rules: &mut NativeRuleMap) {
40 use Target::{Html, Paged};
41
42 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 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 rules.register(Html, IMAGE_RULE);
84
85 rules.register(Html, EQUATION_RULE);
87
88 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 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 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 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
175const 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 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 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 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 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 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 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 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 let mut items = items.peekable();
384 if items.peek().is_none() {
385 return Ok(Content::empty());
386 }
387
388 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 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 let prefix = sup
414 .styled(HtmlElem::role.set(Some("doc-backlink".into())))
415 .spanned(elem.span());
416
417 Ok(prefix + body)
423};
424
425const OUTLINE_RULE: ShowFn<OutlineElem> = |elem, engine, styles| {
426 fn convert_list(list: Vec<OutlineNode>) -> Content {
427 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 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
499const 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 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 let footer = grid.footer.as_ref().map(|ft| {
593 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 let header_range = |hd: &Header| {
603 if grid.has_gutter {
604 hd.range.start / 2..hd.range.end.div_ceil(2)
607 } else {
608 hd.range.clone()
609 }
610 };
611
612 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 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 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#[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
773const 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 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 if let Some(value) = typst_svg::convert_image_scaling(image.scaling()) {
797 css.push("image-rendering", value);
798 }
799
800 match elem.width.get(styles) {
802 Smart::Auto => {}
803 Smart::Custom(rel) => css.push("width", rel),
804 }
805
806 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#[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}