Skip to main content

dioxus_typst/
lib.rs

1//! A Dioxus component for rendering Typst documents as HTML.
2//!
3//! This crate provides a [`Typst`] component that compiles Typst markup to HTML
4//! at runtime, allowing you to embed rich typeset content in Dioxus applications.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use dioxus::prelude::*;
10//! use dioxus_typst::Typst;
11//!
12//! #[component]
13//! fn App() -> Element {
14//!     let content = r#"
15//! = Hello, Typst!
16//!
17//! Some *formatted* text with math: $E = m c^2$
18//! "#;
19//!
20//!     rsx! {
21//!         Typst { source: content.to_string() }
22//!     }
23//! }
24//! ```
25//!
26//! # Feature Flags
27//!
28//! - **`download-packages`**: Enables automatic downloading of Typst packages from
29//!   the package registry.
30//!
31//! # Limitations
32//!
33//! Typst's HTML export is experimental and may change between versions. Pin your
34//! `typst` dependency and test output carefully when upgrading.
35
36use std::collections::HashMap;
37use std::sync::{Arc, RwLock};
38
39use chrono::{Datelike, Timelike};
40use dioxus::prelude::*;
41use typst::{
42    Feature, Library, LibraryExt, World,
43    diag::{FileError, FileResult, PackageError},
44    foundations::{Bytes, Datetime},
45    syntax::{FileId, Source, VirtualPath, package::PackageSpec},
46    text::{Font, FontBook},
47    utils::LazyHash,
48};
49use typst_html::HtmlDocument;
50
51/// Normalizes a path to ensure it starts with a leading slash.
52fn normalize_path(path: String) -> String {
53    if path.starts_with('/') {
54        path
55    } else {
56        format!("/{path}")
57    }
58}
59
60/// Options for configuring Typst compilation.
61///
62/// Use this to provide additional files (images, bibliographies, data files) and
63/// pre-loaded packages to the Typst compiler.
64///
65/// # Example
66///
67/// ```rust,ignore
68/// use dioxus_typst::CompileOptions;
69///
70/// let options = CompileOptions::new()
71///     .with_file("/data.csv", csv_bytes)
72///     .with_file("/logo.png", image_bytes);
73/// ```
74#[derive(Debug, Clone, Default, PartialEq)]
75pub struct CompileOptions {
76    /// Files available to the Typst document, keyed by their virtual path.
77    pub files: HashMap<String, Vec<u8>>,
78    /// Pre-loaded packages, keyed by their package specification.
79    pub packages: HashMap<PackageSpec, HashMap<String, Vec<u8>>>,
80}
81
82impl CompileOptions {
83    /// Creates a new empty `CompileOptions`.
84    pub fn new() -> Self {
85        Self::default()
86    }
87
88    /// Adds a file to the compilation environment.
89    ///
90    /// The path will be normalized to start with `/`. Files added here can be
91    /// referenced in Typst source using their path (e.g., `#image("/logo.png")`).
92    ///
93    /// # Example
94    ///
95    /// ```rust,ignore
96    /// let options = CompileOptions::new()
97    ///     .with_file("/figure.png", png_bytes)
98    ///     .with_file("data.csv", csv_bytes); // Also normalized to "/data.csv"
99    /// ```
100    #[must_use]
101    pub fn with_file(mut self, path: impl Into<String>, content: Vec<u8>) -> Self {
102        self.files.insert(normalize_path(path.into()), content);
103        self
104    }
105
106    /// Adds a pre-loaded package to the compilation environment.
107    ///
108    /// Use this to provide package files without requiring network access. File
109    /// paths within the package will be normalized to start with `/`.
110    ///
111    /// # Example
112    ///
113    /// ```rust,ignore
114    /// use typst::syntax::package::PackageSpec;
115    /// use std::str::FromStr;
116    ///
117    /// let options = CompileOptions::new()
118    ///     .with_package(
119    ///         PackageSpec::from_str("@preview/cetz:0.2.2").unwrap(),
120    ///         package_files,
121    ///     );
122    /// ```
123    #[must_use]
124    pub fn with_package(mut self, spec: PackageSpec, files: HashMap<String, Vec<u8>>) -> Self {
125        let files = files
126            .into_iter()
127            .map(|(path, content)| (normalize_path(path), content))
128            .collect();
129        self.packages.insert(spec, files);
130        self
131    }
132}
133
134/// Errors that can occur during Typst compilation.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum CompileError {
137    /// An error occurred during Typst compilation or HTML generation.
138    ///
139    /// The string contains one or more error messages joined by semicolons.
140    Typst(String),
141    /// An error occurred while loading a package.
142    Package(String),
143}
144
145impl std::fmt::Display for CompileError {
146    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147        match self {
148            CompileError::Typst(msg) => write!(f, "Typst compilation error: {msg}"),
149            CompileError::Package(msg) => write!(f, "Package error: {msg}"),
150        }
151    }
152}
153
154impl std::error::Error for CompileError {}
155
156#[cfg(feature = "download-packages")]
157mod downloader {
158    use super::*;
159    use flate2::read::GzDecoder;
160    use std::io::Read;
161    use std::path::PathBuf;
162    use tar::Archive;
163    use typst::diag::eco_format;
164
165    fn cache_dir() -> Option<PathBuf> {
166        dirs::cache_dir().map(|p| p.join("typst").join("packages"))
167    }
168
169    fn package_dir(spec: &PackageSpec) -> Option<PathBuf> {
170        cache_dir().map(|p| {
171            p.join(spec.namespace.as_str())
172                .join(spec.name.as_str())
173                .join(spec.version.to_string())
174        })
175    }
176
177    pub fn download_package(spec: &PackageSpec) -> Result<HashMap<String, Vec<u8>>, PackageError> {
178        if let Some(dir) = package_dir(spec)
179            && dir.exists()
180        {
181            return read_package_dir(&dir);
182        }
183
184        let url = format!(
185            "https://packages.typst.org/preview/{}-{}.tar.gz",
186            spec.name, spec.version
187        );
188
189        let compressed = ureq::get(&url)
190            .call()
191            .map_err(|e| PackageError::NetworkFailed(Some(eco_format!("{e}"))))?
192            .into_body()
193            .read_to_vec()
194            .map_err(|e| PackageError::NetworkFailed(Some(eco_format!("{e}"))))?;
195
196        let decoder = GzDecoder::new(&compressed[..]);
197        let mut archive = Archive::new(decoder);
198        let mut files = HashMap::new();
199
200        let cache_path = package_dir(spec);
201        if let Some(ref path) = cache_path {
202            let _ = std::fs::create_dir_all(path);
203        }
204
205        for entry in archive
206            .entries()
207            .map_err(|e| PackageError::MalformedArchive(Some(eco_format!("{e}"))))?
208        {
209            let mut entry =
210                entry.map_err(|e| PackageError::MalformedArchive(Some(eco_format!("{e}"))))?;
211
212            let path = entry
213                .path()
214                .map_err(|e| PackageError::MalformedArchive(Some(eco_format!("{e}"))))?
215                .into_owned();
216
217            if entry.header().entry_type().is_file() {
218                let path_str = format!("/{}", path.to_string_lossy());
219                let mut content = Vec::new();
220                entry
221                    .read_to_end(&mut content)
222                    .map_err(|e| PackageError::MalformedArchive(Some(eco_format!("{e}"))))?;
223
224                if let Some(ref cache) = cache_path {
225                    let file_path = cache.join(&path);
226                    if let Some(parent) = file_path.parent() {
227                        let _ = std::fs::create_dir_all(parent);
228                    }
229                    let _ = std::fs::write(&file_path, &content);
230                }
231
232                files.insert(path_str, content);
233            }
234        }
235
236        Ok(files)
237    }
238
239    fn read_package_dir(dir: &PathBuf) -> Result<HashMap<String, Vec<u8>>, PackageError> {
240        let mut files = HashMap::new();
241        read_dir_recursive(dir, dir, &mut files)?;
242        Ok(files)
243    }
244
245    fn read_dir_recursive(
246        base: &PathBuf,
247        current: &PathBuf,
248        files: &mut HashMap<String, Vec<u8>>,
249    ) -> Result<(), PackageError> {
250        for entry in
251            std::fs::read_dir(current).map_err(|e| PackageError::Other(Some(eco_format!("{e}"))))?
252        {
253            let entry = entry.map_err(|e| PackageError::Other(Some(eco_format!("{e}"))))?;
254            let path = entry.path();
255
256            if path.is_dir() {
257                read_dir_recursive(base, &path, files)?;
258            } else {
259                let relative = path.strip_prefix(base).unwrap();
260                let key = format!("/{}", relative.to_string_lossy());
261                let content = std::fs::read(&path)
262                    .map_err(|e| PackageError::Other(Some(eco_format!("{e}"))))?;
263                files.insert(key, content);
264            }
265        }
266        Ok(())
267    }
268}
269
270/// The compilation world that provides all resources to the Typst compiler.
271struct CompileWorld {
272    library: LazyHash<Library>,
273    book: LazyHash<FontBook>,
274    fonts: Vec<Font>,
275    main: Source,
276    files: HashMap<String, Bytes>,
277    packages: Arc<RwLock<HashMap<PackageSpec, HashMap<String, Bytes>>>>,
278    #[cfg(feature = "download-packages")]
279    allow_downloads: bool,
280}
281
282impl CompileWorld {
283    /// Creates a new compilation world with the given source and options.
284    fn new(source: &str, options: &CompileOptions) -> Self {
285        let fonts = load_fonts();
286        let book = FontBook::from_fonts(&fonts);
287        let main_id = FileId::new(None, VirtualPath::new("/main.typ"));
288        let main = Source::new(main_id, source.to_string());
289
290        let files = options
291            .files
292            .iter()
293            .map(|(path, content)| (path.clone(), Bytes::new(content.clone())))
294            .collect();
295
296        let packages: HashMap<PackageSpec, HashMap<String, Bytes>> = options
297            .packages
298            .iter()
299            .map(
300                |(spec, pkg_files): (&PackageSpec, &HashMap<String, Vec<u8>>)| {
301                    let converted: HashMap<String, Bytes> = pkg_files
302                        .iter()
303                        .map(|(path, content)| (path.clone(), Bytes::new(content.clone())))
304                        .collect();
305                    (spec.clone(), converted)
306                },
307            )
308            .collect();
309
310        let library = Library::builder()
311            .with_features([Feature::Html].into_iter().collect())
312            .build();
313
314        Self {
315            library: LazyHash::new(library),
316            book: LazyHash::new(book),
317            fonts,
318            main,
319            files,
320            packages: Arc::new(RwLock::new(packages)),
321            #[cfg(feature = "download-packages")]
322            allow_downloads: true,
323        }
324    }
325
326    /// Configures whether automatic package downloads are allowed.
327    #[cfg(feature = "download-packages")]
328    #[allow(dead_code)]
329    fn with_downloads(mut self, allow: bool) -> Self {
330        self.allow_downloads = allow;
331        self
332    }
333
334    /// Retrieves a file from a package, downloading if necessary and allowed.
335    fn get_package_file(&self, package: &PackageSpec, path: &str) -> FileResult<Bytes> {
336        {
337            let packages = self.packages.read().unwrap();
338            if let Some(pkg_files) = packages.get(package)
339                && let Some(content) = pkg_files.get(path)
340            {
341                return Ok(content.clone());
342            }
343        }
344
345        #[cfg(feature = "download-packages")]
346        if self.allow_downloads {
347            let downloaded = downloader::download_package(package).map_err(FileError::Package)?;
348
349            let result = downloaded
350                .get(path)
351                .map(|c| Bytes::new(c.clone()))
352                .ok_or_else(|| FileError::NotFound(path.into()));
353
354            let mut packages = self.packages.write().unwrap();
355            let converted: HashMap<String, Bytes> = downloaded
356                .into_iter()
357                .map(|(p, c)| (p, Bytes::new(c)))
358                .collect();
359            packages.insert(package.clone(), converted);
360
361            return result;
362        }
363
364        Err(FileError::Package(PackageError::NotFound(package.clone())))
365    }
366}
367
368impl World for CompileWorld {
369    fn library(&self) -> &LazyHash<Library> {
370        &self.library
371    }
372
373    fn book(&self) -> &LazyHash<FontBook> {
374        &self.book
375    }
376
377    fn main(&self) -> FileId {
378        self.main.id()
379    }
380
381    fn source(&self, id: FileId) -> FileResult<Source> {
382        if id == self.main.id() {
383            return Ok(self.main.clone());
384        }
385
386        if let Some(package) = id.package() {
387            let path = id.vpath().as_rooted_path().to_string_lossy();
388            let content = self.get_package_file(package, &path)?;
389            let text = String::from_utf8(content.to_vec()).map_err(|_| FileError::InvalidUtf8)?;
390            return Ok(Source::new(id, text));
391        }
392
393        Err(FileError::NotFound(id.vpath().as_rooted_path().into()))
394    }
395
396    fn file(&self, id: FileId) -> FileResult<Bytes> {
397        if let Some(package) = id.package() {
398            let path = id.vpath().as_rooted_path().to_string_lossy();
399            return self.get_package_file(package, &path);
400        }
401
402        let path = id.vpath().as_rooted_path().to_string_lossy();
403        self.files
404            .get(path.as_ref())
405            .cloned()
406            .ok_or_else(|| FileError::NotFound(id.vpath().as_rooted_path().into()))
407    }
408
409    fn font(&self, index: usize) -> Option<Font> {
410        self.fonts.get(index).cloned()
411    }
412
413    fn today(&self, offset: Option<i64>) -> Option<Datetime> {
414        let now = chrono::Local::now();
415        let now = match offset {
416            Some(hours) => {
417                let offset = chrono::FixedOffset::east_opt((hours * 3600) as i32)?;
418                now.with_timezone(&offset).naive_local()
419            }
420            None => now.naive_local(),
421        };
422        Datetime::from_ymd_hms(
423            now.year(),
424            now.month().try_into().ok()?,
425            now.day().try_into().ok()?,
426            now.hour().try_into().ok()?,
427            now.minute().try_into().ok()?,
428            now.second().try_into().ok()?,
429        )
430    }
431}
432
433/// Loads all available fonts.
434fn load_fonts() -> Vec<Font> {
435    Vec::new()
436}
437
438/// Compiles Typst source to HTML.
439fn compile(source: &str, options: &CompileOptions) -> Result<String, CompileError> {
440    let world = CompileWorld::new(source, options);
441    let warned = typst::compile::<HtmlDocument>(&world);
442    let document = warned.output.map_err(|errors| {
443        let messages: Vec<String> = errors.iter().map(|e| e.message.to_string()).collect();
444        CompileError::Typst(messages.join("; "))
445    })?;
446    typst_html::html(&document).map_err(|errors| {
447        let messages: Vec<String> = errors.iter().map(|e| e.message.to_string()).collect();
448        CompileError::Typst(messages.join("; "))
449    })
450}
451
452/// A Dioxus component that renders Typst markup as HTML.
453///
454/// This component compiles the provided Typst source at runtime and renders the
455/// resulting HTML. Compilation errors are displayed inline.
456///
457/// # Props
458///
459/// - `source`: The Typst source code to compile.
460/// - `options`: Optional [`CompileOptions`] providing additional files and packages.
461/// - `class`: CSS class for the wrapper div (defaults to `"typst-content"`).
462///
463/// # Example
464///
465/// ```rust,ignore
466/// use dioxus::prelude::*;
467/// use dioxus_typst::{Typst, CompileOptions};
468///
469/// #[component]
470/// fn Document() -> Element {
471///     let source = r#"
472/// = Introduction
473///
474/// This is a *Typst* document with inline math: $integral_0^1 x^2 dif x$
475/// "#;
476///
477///     rsx! {
478///         Typst {
479///             source: source.to_string(),
480///             class: "prose".to_string(),
481///         }
482///     }
483/// }
484/// ```
485///
486/// # Styling
487///
488/// The component outputs semantic HTML without styling. Apply CSS to the wrapper
489/// class to style headings, paragraphs, code blocks, and other elements.
490///
491/// # Errors
492///
493/// Compilation errors are rendered as a `<div class="typst-error">` containing
494/// the error message. Style this class to make errors visible during development.
495#[component]
496pub fn Typst(
497    source: String,
498    #[props(default)] options: CompileOptions,
499    #[props(default = "typst-content".to_string())] class: String,
500) -> Element {
501    match compile(&source, &options) {
502        Ok(html) => rsx! {
503            div { class, dangerous_inner_html: "{html}" }
504        },
505        Err(e) => rsx! {
506            div { class: "typst-error", "Error compiling Typst: {e}" }
507        },
508    }
509}
510