1use comemo::{Track, Tracked, TrackedMut};
2use ecow::{EcoVec, eco_vec};
3use typst_library::diag::{SourceResult, bail, error};
4use typst_library::engine::{Engine, Route, Sink, Traced};
5use typst_library::foundations::{Content, NativeElement, StyleChain, Styles};
6use typst_library::introspection::{
7 Introspector, Locator, LocatorLink, QueryIntrospection,
8};
9use typst_library::math::EquationElem;
10use typst_library::model::{DocumentInfo, FootnoteContainer, FootnoteMarker};
11use typst_library::routines::{Arenas, RealizationKind};
12use typst_library::{Library, World};
13use typst_syntax::Span;
14use typst_utils::{LazyHash, Protected};
15
16use crate::convert::{ConversionLevel, Whitespace};
17use crate::mathml::EQUATION_CSS_STYLES;
18use crate::{HtmlDocument, HtmlElement, HtmlNode, attr, css, tag};
19
20#[typst_macros::time(name = "html document")]
25pub fn html_document(
26 engine: &mut Engine,
27 content: &Content,
28 styles: StyleChain,
29) -> SourceResult<HtmlDocument> {
30 html_document_impl(
31 engine.world,
32 engine.library,
33 engine.introspector.into_raw(),
34 engine.traced,
35 TrackedMut::reborrow_mut(&mut engine.sink),
36 engine.route.track(),
37 content,
38 styles,
39 )
40}
41
42#[comemo::memoize]
44#[allow(clippy::too_many_arguments)]
45fn html_document_impl(
46 world: Tracked<dyn World + '_>,
47 library: &LazyHash<Library>,
48 introspector: Tracked<dyn Introspector + '_>,
49 traced: Tracked<Traced>,
50 sink: TrackedMut<Sink>,
51 route: Tracked<Route>,
52 content: &Content,
53 styles: StyleChain,
54) -> SourceResult<HtmlDocument> {
55 let mut document = html_document_common(
56 world,
57 library,
58 introspector,
59 traced,
60 sink,
61 route,
62 content,
63 Locator::root(),
64 styles,
65 )?;
66
67 let targets = document.introspector().link_targets();
69 let anchors = crate::link::create_link_anchors(&mut document, &targets);
70 document.introspector_mut().set_anchors(anchors);
71
72 Ok(document)
73}
74
75#[typst_macros::time(name = "html document")]
78pub fn html_document_for_bundle(
79 engine: &mut Engine,
80 content: &Content,
81 locator: Locator,
82 styles: StyleChain,
83) -> SourceResult<HtmlDocument> {
84 html_document_for_bundle_impl(
85 engine.world,
86 engine.library,
87 engine.introspector.into_raw(),
88 engine.traced,
89 TrackedMut::reborrow_mut(&mut engine.sink),
90 engine.route.track(),
91 content,
92 locator.track(),
93 styles,
94 )
95}
96
97#[comemo::memoize]
99#[allow(clippy::too_many_arguments)]
100fn html_document_for_bundle_impl(
101 world: Tracked<dyn World + '_>,
102 library: &LazyHash<Library>,
103 introspector: Tracked<dyn Introspector + '_>,
104 traced: Tracked<Traced>,
105 sink: TrackedMut<Sink>,
106 route: Tracked<Route>,
107 content: &Content,
108 locator: Tracked<Locator>,
109 styles: StyleChain,
110) -> SourceResult<HtmlDocument> {
111 let link = LocatorLink::new(locator);
112 html_document_common(
113 world,
114 library,
115 introspector,
116 traced,
117 sink,
118 route,
119 content,
120 Locator::link(&link),
121 styles,
122 )
123}
124
125#[allow(clippy::too_many_arguments)]
128fn html_document_common(
129 world: Tracked<dyn World + '_>,
130 library: &LazyHash<Library>,
131 introspector: Tracked<dyn Introspector + '_>,
132 traced: Tracked<Traced>,
133 sink: TrackedMut<Sink>,
134 route: Tracked<Route>,
135 content: &Content,
136 locator: Locator,
137 styles: StyleChain,
138) -> SourceResult<HtmlDocument> {
139 let introspector = Protected::from_raw(introspector);
140 let mut locator = locator.split();
141 let mut engine = Engine {
142 library,
143 world,
144 introspector,
145 traced,
146 sink,
147 route: Route::extend(route).unnested(),
148 };
149
150 let footnote_locator = locator.next(&());
152
153 let styles = styles.to_map().outside();
156 let styles = StyleChain::new(&styles);
157 let arenas = Arenas::default();
158
159 let mut info = DocumentInfo::default();
160 info.populate(styles);
161 info.populate_locale(styles);
162
163 let children = (engine.library.routines.realize)(
164 RealizationKind::Document { info: &mut info },
165 &mut engine,
166 &mut locator,
167 &arenas,
168 content,
169 styles,
170 )?;
171
172 let nodes = crate::convert::convert_to_nodes(
173 &mut engine,
174 &mut locator,
175 children.iter().copied(),
176 ConversionLevel::Block,
177 Whitespace::Normal,
178 )?;
179
180 let mut output = finalize_dom(
181 &mut engine,
182 nodes,
183 &info,
184 footnote_locator,
185 StyleChain::new(&Styles::root(&children, styles)),
186 )?;
187
188 css::resolve_inline_styles(output.root_mut());
191
192 let has_equations = !engine
193 .introspect(QueryIntrospection(EquationElem::ELEM.select(), Span::detached()))
194 .is_empty();
195
196 if has_equations {
197 let root = output.root_mut();
198
199 let head = root.children.make_mut().iter_mut().find_map(|node| match node {
200 HtmlNode::Element(elem) if elem.tag == tag::head => Some(elem),
201 _ => None,
202 });
203
204 let head = head.expect("head to be present in document output");
206
207 head.children.push(
208 HtmlElement::new(tag::style)
209 .with_children(eco_vec![HtmlNode::Text(
210 EQUATION_CSS_STYLES.clone(),
211 Span::detached(),
212 )])
213 .into(),
214 );
215 }
216
217 Ok(HtmlDocument::new(output, info))
218}
219
220#[derive(Debug, Clone)]
222pub struct HtmlOutput {
223 nodes: EcoVec<HtmlNode>,
224 root_index: usize,
225}
226
227impl HtmlOutput {
228 pub fn nodes(&self) -> &[HtmlNode] {
230 &self.nodes
231 }
232
233 pub fn root(&self) -> &HtmlElement {
235 match &self.nodes[self.root_index] {
236 HtmlNode::Element(root) => root,
237 _ => panic!("expected HTML element"),
238 }
239 }
240
241 pub fn root_mut(&mut self) -> &mut HtmlElement {
243 match &mut self.nodes.make_mut()[self.root_index] {
244 HtmlNode::Element(root) => root,
245 _ => panic!("expected HTML element"),
246 }
247 }
248
249 pub fn root_node(&self) -> &HtmlNode {
251 &self.nodes[self.root_index]
252 }
253}
254
255fn finalize_dom(
260 engine: &mut Engine,
261 nodes: EcoVec<HtmlNode>,
262 info: &DocumentInfo,
263 footnote_locator: Locator<'_>,
264 footnote_styles: StyleChain<'_>,
265) -> SourceResult<HtmlOutput> {
266 let count = nodes.iter().filter(|node| !matches!(node, HtmlNode::Tag(_))).count();
267
268 let mut needs_body = true;
269 for (idx, node) in nodes.iter().enumerate() {
270 let HtmlNode::Element(elem) = node else { continue };
271 let tag = elem.tag;
272 match (tag, count) {
273 (tag::html, 1) => {
274 footnotes_unsupported_with_custom_dom(engine)?;
275 return Ok(HtmlOutput { nodes, root_index: idx });
276 }
277 (tag::body, 1) => {
278 footnotes_unsupported_with_custom_dom(engine)?;
279 needs_body = false;
280 }
281 (tag::html | tag::body, _) => bail!(
282 elem.span,
283 "`{}` element must be the only element in the document",
284 elem.tag,
285 ),
286 _ => {}
287 }
288 }
289
290 let body = if needs_body {
291 let mut body = HtmlElement::new(tag::body).with_children(nodes);
292 let footnotes = crate::fragment::html_block_fragment(
293 engine,
294 FootnoteContainer::shared(),
295 footnote_locator,
296 footnote_styles,
297 Whitespace::Normal,
298 )?;
299 body.children.extend(footnotes);
300 eco_vec![body.into()]
301 } else {
302 nodes
303 };
304
305 let mut html = HtmlElement::new(tag::html)
306 .with_attr(attr::lang, info.locale.unwrap_or_default().rfc_3066());
307 let head = head_element(info);
308 html.children.push(head.into());
309 html.children.extend(body);
310 Ok(HtmlOutput { nodes: eco_vec![html.into()], root_index: 0 })
311}
312
313fn head_element(info: &DocumentInfo) -> HtmlElement {
315 let mut children = EcoVec::new();
316
317 children.push(HtmlElement::new(tag::meta).with_attr(attr::charset, "utf-8").into());
318
319 children.push(
320 HtmlElement::new(tag::meta)
321 .with_attr(attr::name, "viewport")
322 .with_attr(attr::content, "width=device-width, initial-scale=1")
323 .into(),
324 );
325
326 if let Some(title) = &info.title {
327 children.push(
328 HtmlElement::new(tag::title)
329 .with_children(eco_vec![HtmlNode::Text(title.clone(), Span::detached())])
330 .into(),
331 );
332 }
333
334 if let Some(description) = &info.description {
335 children.push(
336 HtmlElement::new(tag::meta)
337 .with_attr(attr::name, "description")
338 .with_attr(attr::content, description.clone())
339 .into(),
340 );
341 }
342
343 if !info.author.is_empty() {
344 children.push(
345 HtmlElement::new(tag::meta)
346 .with_attr(attr::name, "authors")
347 .with_attr(attr::content, info.author.join(", "))
348 .into(),
349 )
350 }
351
352 if !info.keywords.is_empty() {
353 children.push(
354 HtmlElement::new(tag::meta)
355 .with_attr(attr::name, "keywords")
356 .with_attr(attr::content, info.keywords.join(", "))
357 .into(),
358 )
359 }
360
361 HtmlElement::new(tag::head).with_children(children)
362}
363
364fn footnotes_unsupported_with_custom_dom(engine: &mut Engine) -> SourceResult<()> {
366 let markers = engine
367 .introspect(QueryIntrospection(FootnoteMarker::ELEM.select(), Span::detached()));
368
369 if markers.is_empty() {
370 return Ok(());
371 }
372
373 Err(markers
374 .iter()
375 .map(|marker| {
376 error!(
377 marker.span(),
378 "footnotes are not currently supported in combination \
379 with a custom `<html>` or `<body>` element";
380 hint: "you can still use footnotes with a custom footnote show rule";
381 )
382 })
383 .collect())
384}