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
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
26use std::collections::HashMap;
27
28use chrono::{Datelike, Timelike};
29use dioxus::prelude::*;
30use typst::{
31    Feature, Library, LibraryExt, World,
32    diag::{FileError, FileResult, PackageError},
33    foundations::{Bytes, Datetime},
34    syntax::{FileId, Source, VirtualPath, package::PackageSpec},
35    text::{Font, FontBook},
36    utils::LazyHash,
37};
38use typst_html::HtmlDocument;
39
40/// Normalizes a path to ensure it starts with a leading slash.
41fn normalize_path(path: String) -> String {
42    if path.starts_with('/') {
43        path
44    } else {
45        format!("/{path}")
46    }
47}
48
49/// Options for configuring Typst compilation.
50///
51/// Use this to provide additional files (images, bibliographies, data files) and
52/// pre-loaded packages to the Typst compiler.
53///
54/// # Example
55///
56/// ```rust
57/// use dioxus_typst::CompileOptions;
58///
59/// let options = CompileOptions::new()
60///     .with_file("data.csv", csv_bytes)
61///     .with_file("logo.png", image_bytes);
62/// ```
63#[derive(Debug, Clone, Default, PartialEq)]
64pub struct CompileOptions {
65    /// Files available to the Typst document, keyed by their virtual path.
66    pub files: HashMap<String, Vec<u8>>,
67    /// Pre-loaded packages, keyed by their package specification.
68    pub packages: HashMap<PackageSpec, HashMap<String, Vec<u8>>>,
69}
70
71impl CompileOptions {
72    /// Creates a new empty `CompileOptions`.
73    pub fn new() -> Self {
74        Self::default()
75    }
76
77    /// Adds a file to the compilation environment.
78    ///
79    /// # Example
80    ///
81    /// ```rust
82    /// let options = CompileOptions::new()
83    ///     .with_file("figure.png", png_bytes)
84    ///     .with_file("data.csv", csv_bytes);
85    /// ```
86    #[must_use]
87    pub fn with_file(mut self, path: impl Into<String>, content: Vec<u8>) -> Self {
88        self.files.insert(normalize_path(path.into()), content);
89        self
90    }
91
92    /// Adds a pre-loaded package to the compilation environment.
93    ///
94    /// # Example
95    ///
96    /// ```rust
97    /// use typst::syntax::package::PackageSpec;
98    /// use std::str::FromStr;
99    ///
100    /// let options = CompileOptions::new()
101    ///     .with_package(
102    ///         PackageSpec::from_str("@preview/cetz:0.2.2").unwrap(),
103    ///         package_files,
104    ///     );
105    /// ```
106    #[must_use]
107    pub fn with_package(mut self, spec: PackageSpec, files: HashMap<String, Vec<u8>>) -> Self {
108        let files = files
109            .into_iter()
110            .map(|(path, content)| (normalize_path(path), content))
111            .collect();
112        self.packages.insert(spec, files);
113        self
114    }
115}
116
117/// Errors that can occur during Typst compilation.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum CompileError {
120    /// An error occurred during Typst compilation or HTML generation.
121    ///
122    /// The string contains one or more error messages joined by semicolons.
123    Typst(String),
124}
125
126impl std::fmt::Display for CompileError {
127    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128        match self {
129            CompileError::Typst(msg) => write!(f, "Typst compilation error: {msg}"),
130        }
131    }
132}
133
134impl std::error::Error for CompileError {}
135
136/// The compilation world that provides all resources to the Typst compiler.
137struct CompileWorld {
138    library: LazyHash<Library>,
139    book: LazyHash<FontBook>,
140    fonts: Vec<Font>,
141    main: Source,
142    files: HashMap<String, Bytes>,
143    packages: HashMap<PackageSpec, HashMap<String, Bytes>>,
144}
145
146impl CompileWorld {
147    /// Creates a new compilation world with the given source and options.
148    fn new(source: &str, options: &CompileOptions) -> Self {
149        let fonts = load_fonts();
150        let book = FontBook::from_fonts(&fonts);
151        let main_id = FileId::new(None, VirtualPath::new("/main.typ"));
152        let main = Source::new(main_id, source.to_string());
153
154        let files = options
155            .files
156            .iter()
157            .map(|(path, content)| (path.clone(), Bytes::new(content.clone())))
158            .collect();
159
160        let packages: HashMap<PackageSpec, HashMap<String, Bytes>> = options
161            .packages
162            .iter()
163            .map(
164                |(spec, pkg_files): (&PackageSpec, &HashMap<String, Vec<u8>>)| {
165                    let converted: HashMap<String, Bytes> = pkg_files
166                        .iter()
167                        .map(|(path, content)| (path.clone(), Bytes::new(content.clone())))
168                        .collect();
169                    (spec.clone(), converted)
170                },
171            )
172            .collect();
173
174        let library = Library::builder()
175            .with_features([Feature::Html].into_iter().collect())
176            .build();
177
178        Self {
179            library: LazyHash::new(library),
180            book: LazyHash::new(book),
181            fonts,
182            main,
183            files,
184            packages,
185        }
186    }
187
188    /// Retrieves a file from a package.
189    fn get_package_file(&self, package: &PackageSpec, path: &str) -> FileResult<Bytes> {
190        if let Some(pkg_files) = self.packages.get(package)
191            && let Some(content) = pkg_files.get(path)
192        {
193            return Ok(content.clone());
194        }
195
196        Err(FileError::Package(PackageError::NotFound(package.clone())))
197    }
198}
199
200impl World for CompileWorld {
201    fn library(&self) -> &LazyHash<Library> {
202        &self.library
203    }
204
205    fn book(&self) -> &LazyHash<FontBook> {
206        &self.book
207    }
208
209    fn main(&self) -> FileId {
210        self.main.id()
211    }
212
213    fn source(&self, id: FileId) -> FileResult<Source> {
214        if id == self.main.id() {
215            return Ok(self.main.clone());
216        }
217
218        if let Some(package) = id.package() {
219            let path = id.vpath().as_rooted_path().to_string_lossy();
220            let content = self.get_package_file(package, &path)?;
221            let text = String::from_utf8(content.to_vec()).map_err(|_| FileError::InvalidUtf8)?;
222            return Ok(Source::new(id, text));
223        }
224
225        Err(FileError::NotFound(id.vpath().as_rooted_path().into()))
226    }
227
228    fn file(&self, id: FileId) -> FileResult<Bytes> {
229        if let Some(package) = id.package() {
230            let path = id.vpath().as_rooted_path().to_string_lossy();
231            return self.get_package_file(package, &path);
232        }
233
234        let path = id.vpath().as_rooted_path().to_string_lossy();
235        self.files
236            .get(path.as_ref())
237            .cloned()
238            .ok_or_else(|| FileError::NotFound(id.vpath().as_rooted_path().into()))
239    }
240
241    fn font(&self, index: usize) -> Option<Font> {
242        self.fonts.get(index).cloned()
243    }
244
245    fn today(&self, offset: Option<i64>) -> Option<Datetime> {
246        let now = chrono::Local::now();
247        let now = match offset {
248            Some(hours) => {
249                let offset = chrono::FixedOffset::east_opt((hours * 3600) as i32)?;
250                now.with_timezone(&offset).naive_local()
251            }
252            None => now.naive_local(),
253        };
254        Datetime::from_ymd_hms(
255            now.year(),
256            now.month().try_into().ok()?,
257            now.day().try_into().ok()?,
258            now.hour().try_into().ok()?,
259            now.minute().try_into().ok()?,
260            now.second().try_into().ok()?,
261        )
262    }
263}
264
265/// Loads all available fonts.
266fn load_fonts() -> Vec<Font> {
267    Vec::new()
268}
269
270/// Compiles Typst source to HTML.
271fn compile(source: &str, options: &CompileOptions) -> Result<String, CompileError> {
272    let world = CompileWorld::new(source, options);
273    let warned = typst::compile::<HtmlDocument>(&world);
274    let document = warned.output.map_err(|errors| {
275        let messages: Vec<String> = errors.iter().map(|e| e.message.to_string()).collect();
276        CompileError::Typst(messages.join("; "))
277    })?;
278    typst_html::html(&document).map_err(|errors| {
279        let messages: Vec<String> = errors.iter().map(|e| e.message.to_string()).collect();
280        CompileError::Typst(messages.join("; "))
281    })
282}
283
284/// A Dioxus component that renders Typst markup as HTML.
285///
286/// This component compiles the provided Typst source at runtime and renders the
287/// resulting HTML. Compilation errors are displayed inline.
288///
289/// # Props
290///
291/// - `source`: The Typst source code to compile.
292/// - `options`: Optional [`CompileOptions`] providing additional files and packages.
293/// - `class`: CSS class for the wrapper div (defaults to `"typst-content"`).
294///
295/// # Example
296///
297/// ```rust
298/// use dioxus::prelude::*;
299/// use dioxus_typst::Typst;
300///
301/// #[component]
302/// fn App() -> Element {
303///     let content = r#"
304/// = Hello, Typst!
305///
306/// Some *formatted* text with math: $E = m c^2$
307/// "#;
308///
309///     rsx! {
310///         Typst { source: content.to_string() }
311///     }
312/// }
313/// ```
314///
315/// # Styling
316///
317/// The component outputs semantic HTML without styling. Apply CSS to the wrapper
318/// class to style headings, paragraphs, code blocks, and other elements.
319///
320/// # Errors
321///
322/// Compilation errors are rendered as a `<div class="typst-error">` containing
323/// the error message. Style this class to make errors visible during development.
324#[component]
325pub fn Typst(
326    source: String,
327    #[props(default)] options: CompileOptions,
328    #[props(default = "typst-content".to_string())] class: String,
329) -> Element {
330    match compile(&source, &options) {
331        Ok(html) => rsx! {
332            div { class, dangerous_inner_html: "{html}" }
333        },
334        Err(e) => rsx! {
335            div { class: "typst-error", "Error compiling Typst: {e}" }
336        },
337    }
338}