Skip to main content

typst_html/
document.rs

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/// Produce an HTML document from content.
21///
22/// This first performs root-level realization and then turns the resulting
23/// elements into HTML.
24#[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/// The internal implementation of `html_document`.
43#[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    // Assigns HTML fragment IDs to linked-to elements.
68    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/// Produce an HTML document from content, as part of a bundle compilation
76/// process.
77#[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/// The internal implementation of `html_document_for_bundle`.
98#[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/// The shared, unmemoized implementation of `html_document` and
126/// `html_document_for_bundle`.
127#[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    // Create this upfront to make it as stable as possible.
151    let footnote_locator = locator.next(&());
152
153    // Mark the external styles as "outside" so that they are valid at the
154    // document level.
155    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    // Since `finalize_dom` might have inserted more DOM nodes that have styles,
189    // the styles must be resolved last.
190    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        // TODO: this becomes an error when html fragments are supported
205        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/// The introspectible output of HTML compilation.
221#[derive(Debug, Clone)]
222pub struct HtmlOutput {
223    nodes: EcoVec<HtmlNode>,
224    root_index: usize,
225}
226
227impl HtmlOutput {
228    /// All nodes.
229    pub fn nodes(&self) -> &[HtmlNode] {
230        &self.nodes
231    }
232
233    /// The root note.
234    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    /// The root note, mutably.
242    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    /// The document's root HTML element, in its containing node wrapper.
250    pub fn root_node(&self) -> &HtmlNode {
251        &self.nodes[self.root_index]
252    }
253}
254
255/// Wrap the user generated HTML in `<html>`, `<body>` or both if needed.
256///
257/// Returns a vector containing outer introspection tags and the HTML root element.
258/// A direct reference to the root element is also returned.
259fn 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
313/// Generate a `<head>` element.
314fn 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
364/// Fails with an error if there are footnotes.
365fn 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}