Skip to main content

typst_bundle/
lib.rs

1//! Multi-file output for Typst.
2
3#[path = "export.rs"]
4mod export_;
5mod introspect;
6mod link;
7
8use crate::introspect::BundleIntrospector;
9
10pub use self::export_::{BundleOptions, VirtualFs, export};
11
12use std::collections::hash_map::Entry;
13use std::sync::Arc;
14
15use comemo::{Tracked, TrackedMut};
16use ecow::{EcoString, EcoVec};
17use indexmap::IndexMap;
18use rustc_hash::{FxBuildHasher, FxHashMap};
19use typst_html::HtmlDocument;
20use typst_layout::PagedDocument;
21use typst_library::diag::{At, CollectCombinedResult, SourceResult, bail, error};
22use typst_library::engine::{Engine, Route, Sink, Traced};
23use typst_library::foundations::{
24    Bytes, Content, Output, Packed, StyleChain, Target, TargetElem,
25};
26use typst_library::introspection::{
27    Introspector, Location, Locator, SplitLocator, Tag, TagElem,
28};
29use typst_library::model::{
30    AssetElem, Document, DocumentElem, DocumentFormat, DocumentInfo, PagedFormat,
31};
32use typst_library::routines::{Arenas, Pair, RealizationKind};
33use typst_library::{Feature, Library, World};
34use typst_syntax::VirtualPath;
35use typst_utils::{LazyHash, Protected};
36
37/// A collection of files resulting from compilation.
38///
39/// In the `bundle` target, Typst can emit multiple documents and assets from a
40/// single Typst project.
41///
42/// The `Bundle` is the output of compilation and is to the `bundle` output
43/// format what the `PagedDocument` is to `pdf`, `png`, and `svg` outputs.
44#[derive(Debug, Clone)]
45pub struct Bundle {
46    /// The files in the bundle.
47    pub files: Arc<IndexMap<VirtualPath, BundleFile, FxBuildHasher>>,
48    /// An introspector for the whole bundle.
49    ///
50    /// The whole bundle is subject to one large introspection loop (as opposed
51    /// to each document iterating separately). They can introspect each other
52    /// and all contribute to this one introspector.
53    pub introspector: Arc<BundleIntrospector>,
54}
55
56impl Output for Bundle {
57    fn introspector(&self) -> &dyn Introspector {
58        self.introspector.as_ref()
59    }
60
61    fn target() -> Target {
62        Target::Bundle
63    }
64
65    fn create(
66        engine: &mut Engine,
67        content: &Content,
68        styles: StyleChain,
69    ) -> SourceResult<Self> {
70        bundle(engine, content, styles)
71    }
72}
73
74/// A single file in the bundle.
75#[derive(Debug, Clone)]
76pub enum BundleFile {
77    /// A document in one of the supported output formats, resulting from a
78    /// `document` element.
79    Document(BundleDocument),
80    /// Raw file data, resulting from an `asset` element.
81    Asset(Bytes),
82}
83
84/// A document in one of the supported output formats, resulting from a
85/// `document` element.
86#[derive(Debug, Clone)]
87pub enum BundleDocument {
88    /// A document in one of the paged formats.
89    Paged(Box<PagedDocument>, PagedExtras),
90    /// A document in the HTML format.
91    Html(Box<HtmlDocument>),
92}
93
94impl Document for BundleDocument {
95    fn info(&self) -> &DocumentInfo {
96        match self {
97            BundleDocument::Paged(doc, _) => doc.info(),
98            BundleDocument::Html(doc) => doc.info(),
99        }
100    }
101}
102
103/// Extra data relevant for exporting a paged document in a bundle.
104#[derive(Debug, Clone, Hash)]
105pub struct PagedExtras {
106    /// The format to export in.
107    pub format: PagedFormat,
108    /// Named anchors that should be exported, so that cross-document links can
109    /// jump to a precise location.
110    ///
111    /// Not all export targets support this (e.g. PNG), in which case it can
112    /// simply be ignored.
113    pub anchors: Vec<(Location, EcoString)>,
114}
115
116/// Produces a bundle from content.
117///
118/// This first performs root-level bundle realization and then compiles the
119/// individual documents (in parallel).
120#[typst_macros::time]
121pub fn bundle(
122    engine: &mut Engine,
123    content: &Content,
124    styles: StyleChain,
125) -> SourceResult<Bundle> {
126    bundle_impl(
127        engine.world,
128        engine.library,
129        engine.introspector.into_raw(),
130        engine.traced,
131        TrackedMut::reborrow_mut(&mut engine.sink),
132        engine.route.track(),
133        content,
134        styles,
135    )
136}
137
138/// The internal implementation of `bundle`.
139#[comemo::memoize]
140#[allow(clippy::too_many_arguments)]
141fn bundle_impl(
142    world: Tracked<dyn World + '_>,
143    library: &LazyHash<Library>,
144    introspector: Tracked<dyn Introspector + '_>,
145    traced: Tracked<Traced>,
146    sink: TrackedMut<Sink>,
147    route: Tracked<Route>,
148    content: &Content,
149    styles: StyleChain,
150) -> SourceResult<Bundle> {
151    let introspector = Protected::from_raw(introspector);
152    let mut locator = Locator::root().split();
153    let mut engine = Engine {
154        library,
155        world,
156        introspector,
157        traced,
158        sink,
159        route: Route::extend(route).unnested(),
160    };
161
162    // Mark the external styles as "outside" so that they are valid at the page
163    // level.
164    let styles = styles.to_map().outside();
165    let styles = StyleChain::new(&styles);
166
167    let arenas = Arenas::default();
168    let children = (engine.library.routines.realize)(
169        RealizationKind::Bundle,
170        &mut engine,
171        &mut locator,
172        &arenas,
173        content,
174        styles,
175    )?;
176
177    let children = collect(&children, &mut engine, &mut locator)?;
178
179    let mut items = engine
180        .parallelize(children, |engine, child| -> SourceResult<_> {
181            Ok(match child {
182                Child::Tag(tag) => Item::Tag(tag.clone()),
183                Child::Asset(asset) => Item::Asset(
184                    asset.path.clone().into_inner(),
185                    asset.data.0.clone(),
186                    asset.location().unwrap(),
187                ),
188                Child::Document(document, styles, locator) => Item::Document(
189                    document.path.clone().into_inner(),
190                    compile_document(engine, document, styles, locator)?,
191                    document.location().unwrap(),
192                ),
193            })
194        })
195        .collect_combined_result::<Vec<_>>()?;
196
197    let mut introspector = BundleIntrospector::new(&items);
198    let targets = introspector.link_targets();
199    let anchors = crate::link::create_link_anchors(&mut items, &targets);
200    introspector.set_anchors(anchors);
201
202    let mut files = IndexMap::default();
203    for item in items {
204        match item {
205            Item::Tag(_) => {}
206            Item::Asset(path, bytes, _) => {
207                files.insert(path, BundleFile::Asset(bytes));
208            }
209            Item::Document(path, doc, _) => {
210                files.insert(path, BundleFile::Document(doc));
211            }
212        }
213    }
214
215    Ok(Bundle {
216        files: Arc::new(files),
217        introspector: Arc::new(introspector),
218    })
219}
220
221/// Something that can result from bundle realization.
222enum Child<'a> {
223    Tag(&'a Tag),
224    Asset(&'a Packed<AssetElem>),
225    Document(&'a Packed<DocumentElem>, StyleChain<'a>, Locator<'a>),
226}
227
228/// The processed version of a [`Child`].
229enum Item {
230    Tag(Tag),
231    Asset(VirtualPath, Bytes, Location),
232    Document(VirtualPath, BundleDocument, Location),
233}
234
235/// Collects all documents and assets in the bundle.
236fn collect<'a>(
237    children: &'a [Pair<'a>],
238    engine: &mut Engine,
239    locator: &mut SplitLocator<'a>,
240) -> SourceResult<Vec<Child<'a>>> {
241    let mut items = Vec::new();
242    let mut errors = EcoVec::new();
243    let mut seen = FxHashMap::default();
244
245    for (elem, styles) in children {
246        let path = if let Some(elem) = elem.to_packed::<TagElem>() {
247            items.push(Child::Tag(&elem.tag));
248            continue;
249        } else if let Some(elem) = elem.to_packed::<AssetElem>() {
250            items.push(Child::Asset(elem));
251            elem.path.as_ref()
252        } else if let Some(elem) = elem.to_packed::<DocumentElem>() {
253            items.push(Child::Document(elem, *styles, locator.next(&elem.span())));
254            elem.path.as_ref()
255        } else {
256            errors.push(error!(
257                elem.span(), "{} is not allowed at the top-level in bundle export",
258                elem.func().name();
259                hint: "try wrapping the content in a `document` instead";
260            ));
261            continue;
262        };
263
264        match seen.entry(path) {
265            Entry::Vacant(entry) => {
266                entry.insert(elem.span());
267            }
268            Entry::Occupied(entry) => {
269                engine.sink.delayed_error(error!(
270                    elem.span(), "path `{}` occurs multiple times in the bundle",
271                    path.get_without_slash();
272                    hint: "{} paths must be unique in the bundle",
273                    elem.func().name();
274                    hint[*entry.get()]: "path is already in use here";
275                ));
276            }
277        }
278    }
279
280    if !errors.is_empty() {
281        return Err(errors);
282    }
283
284    Ok(items)
285}
286
287/// Compiles a single document.
288fn compile_document<'a>(
289    engine: &mut Engine,
290    document: &'a Packed<DocumentElem>,
291    styles: StyleChain<'a>,
292    locator: Locator<'a>,
293) -> SourceResult<BundleDocument> {
294    let format = document.determine_format(styles).at(document.span())?;
295    let target = TargetElem::target.set(format.target()).wrap();
296    let styles = styles.chain(&target);
297    Ok(match format {
298        DocumentFormat::Paged(format) => {
299            let doc = typst_layout::layout_document_for_bundle(
300                engine,
301                &document.body,
302                locator,
303                styles,
304            )?;
305
306            let num_pages = doc.pages().len();
307            if num_pages != 1 && matches!(format, PagedFormat::Png | PagedFormat::Svg) {
308                bail!(
309                    document.span(),
310                    "expected document to have a single page";
311                    hint: "the document resulted in {num_pages} pages";
312                    hint: "documents exported to an image format only support a single page";
313                );
314            }
315
316            BundleDocument::Paged(
317                Box::new(doc),
318                PagedExtras { format, anchors: Vec::new() },
319            )
320        }
321        DocumentFormat::Html => {
322            if !engine.library.features.is_enabled(Feature::Html) {
323                bail!(
324                    document.span(),
325                    "html export is only available when the `html` feature is enabled";
326                    hint: "html export is under active development and incomplete";
327                    hint: "to enable both bundle and html export, pass `--features bundle,html`";
328                );
329            }
330
331            let doc = typst_html::html_document_for_bundle(
332                engine,
333                &document.body,
334                locator,
335                styles,
336            )?;
337            BundleDocument::Html(Box::new(doc))
338        }
339    })
340}