typst_html/
lib.rs

1//! Typst's HTML exporter.
2
3mod encode;
4
5pub use self::encode::html;
6
7use comemo::{Track, Tracked, TrackedMut};
8use typst_library::diag::{bail, warning, At, SourceResult};
9use typst_library::engine::{Engine, Route, Sink, Traced};
10use typst_library::foundations::{Content, StyleChain, Target, TargetElem};
11use typst_library::html::{
12    attr, tag, FrameElem, HtmlDocument, HtmlElem, HtmlElement, HtmlNode,
13};
14use typst_library::introspection::{
15    Introspector, Locator, LocatorLink, SplitLocator, TagElem,
16};
17use typst_library::layout::{Abs, Axes, BlockBody, BlockElem, BoxElem, Region, Size};
18use typst_library::model::{DocumentInfo, ParElem};
19use typst_library::routines::{Arenas, FragmentKind, Pair, RealizationKind, Routines};
20use typst_library::text::{LinebreakElem, SmartQuoteElem, SpaceElem, TextElem};
21use typst_library::World;
22use typst_syntax::Span;
23
24/// Produce an HTML document from content.
25///
26/// This first performs root-level realization and then turns the resulting
27/// elements into HTML.
28#[typst_macros::time(name = "html document")]
29pub fn html_document(
30    engine: &mut Engine,
31    content: &Content,
32    styles: StyleChain,
33) -> SourceResult<HtmlDocument> {
34    html_document_impl(
35        engine.routines,
36        engine.world,
37        engine.introspector,
38        engine.traced,
39        TrackedMut::reborrow_mut(&mut engine.sink),
40        engine.route.track(),
41        content,
42        styles,
43    )
44}
45
46/// The internal implementation of `html_document`.
47#[comemo::memoize]
48#[allow(clippy::too_many_arguments)]
49fn html_document_impl(
50    routines: &Routines,
51    world: Tracked<dyn World + '_>,
52    introspector: Tracked<Introspector>,
53    traced: Tracked<Traced>,
54    sink: TrackedMut<Sink>,
55    route: Tracked<Route>,
56    content: &Content,
57    styles: StyleChain,
58) -> SourceResult<HtmlDocument> {
59    let mut locator = Locator::root().split();
60    let mut engine = Engine {
61        routines,
62        world,
63        introspector,
64        traced,
65        sink,
66        route: Route::extend(route).unnested(),
67    };
68
69    // Mark the external styles as "outside" so that they are valid at the page
70    // level.
71    let styles = styles.to_map().outside();
72    let styles = StyleChain::new(&styles);
73
74    let arenas = Arenas::default();
75    let mut info = DocumentInfo::default();
76    let children = (engine.routines.realize)(
77        RealizationKind::HtmlDocument(&mut info),
78        &mut engine,
79        &mut locator,
80        &arenas,
81        content,
82        styles,
83    )?;
84
85    let output = handle_list(&mut engine, &mut locator, children.iter().copied())?;
86    let introspector = Introspector::html(&output);
87    let root = root_element(output, &info)?;
88
89    Ok(HtmlDocument { info, root, introspector })
90}
91
92/// Produce HTML nodes from content.
93#[typst_macros::time(name = "html fragment")]
94pub fn html_fragment(
95    engine: &mut Engine,
96    content: &Content,
97    locator: Locator,
98    styles: StyleChain,
99) -> SourceResult<Vec<HtmlNode>> {
100    html_fragment_impl(
101        engine.routines,
102        engine.world,
103        engine.introspector,
104        engine.traced,
105        TrackedMut::reborrow_mut(&mut engine.sink),
106        engine.route.track(),
107        content,
108        locator.track(),
109        styles,
110    )
111}
112
113/// The cached, internal implementation of [`html_fragment`].
114#[comemo::memoize]
115#[allow(clippy::too_many_arguments)]
116fn html_fragment_impl(
117    routines: &Routines,
118    world: Tracked<dyn World + '_>,
119    introspector: Tracked<Introspector>,
120    traced: Tracked<Traced>,
121    sink: TrackedMut<Sink>,
122    route: Tracked<Route>,
123    content: &Content,
124    locator: Tracked<Locator>,
125    styles: StyleChain,
126) -> SourceResult<Vec<HtmlNode>> {
127    let link = LocatorLink::new(locator);
128    let mut locator = Locator::link(&link).split();
129    let mut engine = Engine {
130        routines,
131        world,
132        introspector,
133        traced,
134        sink,
135        route: Route::extend(route),
136    };
137
138    engine.route.check_html_depth().at(content.span())?;
139
140    let arenas = Arenas::default();
141    let children = (engine.routines.realize)(
142        // No need to know about the `FragmentKind` because we handle both
143        // uniformly.
144        RealizationKind::HtmlFragment(&mut FragmentKind::Block),
145        &mut engine,
146        &mut locator,
147        &arenas,
148        content,
149        styles,
150    )?;
151
152    handle_list(&mut engine, &mut locator, children.iter().copied())
153}
154
155/// Convert children into HTML nodes.
156fn handle_list<'a>(
157    engine: &mut Engine,
158    locator: &mut SplitLocator,
159    children: impl IntoIterator<Item = Pair<'a>>,
160) -> SourceResult<Vec<HtmlNode>> {
161    let mut output = Vec::new();
162    for (child, styles) in children {
163        handle(engine, child, locator, styles, &mut output)?;
164    }
165    Ok(output)
166}
167
168/// Convert a child into HTML node(s).
169fn handle(
170    engine: &mut Engine,
171    child: &Content,
172    locator: &mut SplitLocator,
173    styles: StyleChain,
174    output: &mut Vec<HtmlNode>,
175) -> SourceResult<()> {
176    if let Some(elem) = child.to_packed::<TagElem>() {
177        output.push(HtmlNode::Tag(elem.tag.clone()));
178    } else if let Some(elem) = child.to_packed::<HtmlElem>() {
179        let mut children = vec![];
180        if let Some(body) = elem.body(styles) {
181            children = html_fragment(engine, body, locator.next(&elem.span()), styles)?;
182        }
183        if tag::is_void(elem.tag) && !children.is_empty() {
184            bail!(elem.span(), "HTML void elements may not have children");
185        }
186        let element = HtmlElement {
187            tag: elem.tag,
188            attrs: elem.attrs(styles).clone(),
189            children,
190            span: elem.span(),
191        };
192        output.push(element.into());
193    } else if let Some(elem) = child.to_packed::<ParElem>() {
194        let children =
195            html_fragment(engine, &elem.body, locator.next(&elem.span()), styles)?;
196        output.push(
197            HtmlElement::new(tag::p)
198                .with_children(children)
199                .spanned(elem.span())
200                .into(),
201        );
202    } else if let Some(elem) = child.to_packed::<BoxElem>() {
203        // TODO: This is rather incomplete.
204        if let Some(body) = elem.body(styles) {
205            let children =
206                html_fragment(engine, body, locator.next(&elem.span()), styles)?;
207            output.push(
208                HtmlElement::new(tag::span)
209                    .with_attr(attr::style, "display: inline-block;")
210                    .with_children(children)
211                    .spanned(elem.span())
212                    .into(),
213            )
214        }
215    } else if let Some((elem, body)) =
216        child
217            .to_packed::<BlockElem>()
218            .and_then(|elem| match elem.body(styles) {
219                Some(BlockBody::Content(body)) => Some((elem, body)),
220                _ => None,
221            })
222    {
223        // TODO: This is rather incomplete.
224        let children = html_fragment(engine, body, locator.next(&elem.span()), styles)?;
225        output.push(
226            HtmlElement::new(tag::div)
227                .with_children(children)
228                .spanned(elem.span())
229                .into(),
230        );
231    } else if child.is::<SpaceElem>() {
232        output.push(HtmlNode::text(' ', child.span()));
233    } else if let Some(elem) = child.to_packed::<TextElem>() {
234        output.push(HtmlNode::text(elem.text.clone(), elem.span()));
235    } else if let Some(elem) = child.to_packed::<LinebreakElem>() {
236        output.push(HtmlElement::new(tag::br).spanned(elem.span()).into());
237    } else if let Some(elem) = child.to_packed::<SmartQuoteElem>() {
238        output.push(HtmlNode::text(
239            if elem.double(styles) { '"' } else { '\'' },
240            child.span(),
241        ));
242    } else if let Some(elem) = child.to_packed::<FrameElem>() {
243        let locator = locator.next(&elem.span());
244        let style = TargetElem::set_target(Target::Paged).wrap();
245        let frame = (engine.routines.layout_frame)(
246            engine,
247            &elem.body,
248            locator,
249            styles.chain(&style),
250            Region::new(Size::splat(Abs::inf()), Axes::splat(false)),
251        )?;
252        output.push(HtmlNode::Frame(frame));
253    } else {
254        engine.sink.warn(warning!(
255            child.span(),
256            "{} was ignored during HTML export",
257            child.elem().name()
258        ));
259    }
260    Ok(())
261}
262
263/// Wrap the nodes in `<html>` and `<body>` if they are not yet rooted,
264/// supplying a suitable `<head>`.
265fn root_element(output: Vec<HtmlNode>, info: &DocumentInfo) -> SourceResult<HtmlElement> {
266    let body = match classify_output(output)? {
267        OutputKind::Html(element) => return Ok(element),
268        OutputKind::Body(body) => body,
269        OutputKind::Leafs(leafs) => HtmlElement::new(tag::body).with_children(leafs),
270    };
271    Ok(HtmlElement::new(tag::html)
272        .with_children(vec![head_element(info).into(), body.into()]))
273}
274
275/// Generate a `<head>` element.
276fn head_element(info: &DocumentInfo) -> HtmlElement {
277    let mut children = vec![];
278
279    children.push(HtmlElement::new(tag::meta).with_attr(attr::charset, "utf-8").into());
280
281    children.push(
282        HtmlElement::new(tag::meta)
283            .with_attr(attr::name, "viewport")
284            .with_attr(attr::content, "width=device-width, initial-scale=1")
285            .into(),
286    );
287
288    if let Some(title) = &info.title {
289        children.push(
290            HtmlElement::new(tag::title)
291                .with_children(vec![HtmlNode::Text(title.clone(), Span::detached())])
292                .into(),
293        );
294    }
295
296    if let Some(description) = &info.description {
297        children.push(
298            HtmlElement::new(tag::meta)
299                .with_attr(attr::name, "description")
300                .with_attr(attr::content, description.clone())
301                .into(),
302        );
303    }
304
305    HtmlElement::new(tag::head).with_children(children)
306}
307
308/// Determine which kind of output the user generated.
309fn classify_output(mut output: Vec<HtmlNode>) -> SourceResult<OutputKind> {
310    let count = output.iter().filter(|node| !matches!(node, HtmlNode::Tag(_))).count();
311    for node in &mut output {
312        let HtmlNode::Element(elem) = node else { continue };
313        let tag = elem.tag;
314        let mut take = || std::mem::replace(elem, HtmlElement::new(tag::html));
315        match (tag, count) {
316            (tag::html, 1) => return Ok(OutputKind::Html(take())),
317            (tag::body, 1) => return Ok(OutputKind::Body(take())),
318            (tag::html | tag::body, _) => bail!(
319                elem.span,
320                "`{}` element must be the only element in the document",
321                elem.tag,
322            ),
323            _ => {}
324        }
325    }
326    Ok(OutputKind::Leafs(output))
327}
328
329/// What kinds of output the user generated.
330enum OutputKind {
331    /// The user generated their own `<html>` element. We do not need to supply
332    /// one.
333    Html(HtmlElement),
334    /// The user generate their own `<body>` element. We do not need to supply
335    /// one, but need supply the `<html>` element.
336    Body(HtmlElement),
337    /// The user generated leafs which we wrap in a `<body>` and `<html>`.
338    Leafs(Vec<HtmlNode>),
339}