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, Smart},
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 csv_bytes: Vec<u8> = vec![];
60/// # let image_bytes: Vec<u8> = vec![];
61/// let options = CompileOptions::new()
62///     .with_file("data.csv", csv_bytes)
63///     .with_file("logo.png", image_bytes);
64/// ```
65#[derive(Debug, Clone, Default, PartialEq)]
66pub struct CompileOptions {
67    /// Files available to the Typst document, keyed by their virtual path.
68    pub files: HashMap<String, Vec<u8>>,
69    /// Pre-loaded packages, keyed by their package specification.
70    pub packages: HashMap<PackageSpec, HashMap<String, Vec<u8>>>,
71}
72
73impl CompileOptions {
74    /// Creates a new empty `CompileOptions`.
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Adds a file to the compilation environment.
80    ///
81    /// # Example
82    ///
83    /// ```rust
84    /// use dioxus_typst::CompileOptions;
85    ///
86    /// # let png_bytes: Vec<u8> = vec![];
87    /// # let csv_bytes: Vec<u8> = vec![];
88    /// let options = CompileOptions::new()
89    ///     .with_file("figure.png", png_bytes)
90    ///     .with_file("data.csv", csv_bytes);
91    /// # assert!(options.files.contains_key("/figure.png"));
92    /// # assert!(options.files.contains_key("/data.csv"));
93    /// ```
94    #[must_use]
95    pub fn with_file(mut self, path: impl Into<String>, content: Vec<u8>) -> Self {
96        self.files.insert(normalize_path(path.into()), content);
97        self
98    }
99
100    /// Adds a pre-loaded package to the compilation environment.
101    ///
102    /// # Example
103    ///
104    /// ```rust
105    /// use typst::syntax::package::PackageSpec;
106    /// use std::str::FromStr;
107    /// use dioxus_typst::CompileOptions;
108    ///
109    /// # let mut package_files = std::collections::HashMap::new();
110    /// let options = CompileOptions::new()
111    ///     .with_package(
112    ///         PackageSpec::from_str("@preview/cetz:0.2.2").unwrap(),
113    ///         package_files,
114    ///     );
115    /// ```
116    #[must_use]
117    pub fn with_package(mut self, spec: PackageSpec, files: HashMap<String, Vec<u8>>) -> Self {
118        let files = files
119            .into_iter()
120            .map(|(path, content)| (normalize_path(path), content))
121            .collect();
122        self.packages.insert(spec, files);
123        self
124    }
125}
126
127/// Errors that can occur during Typst compilation.
128#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum CompileError {
130    /// An error occurred during Typst compilation or HTML generation.
131    ///
132    /// The string contains one or more error messages joined by semicolons.
133    Typst(String),
134}
135
136impl std::fmt::Display for CompileError {
137    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138        match self {
139            CompileError::Typst(msg) => write!(f, "Typst compilation error: {msg}"),
140        }
141    }
142}
143
144impl std::error::Error for CompileError {}
145
146/// The compilation world that provides all resources to the Typst compiler.
147struct CompileWorld {
148    library: LazyHash<Library>,
149    book: LazyHash<FontBook>,
150    fonts: Vec<Font>,
151    main: Source,
152    files: HashMap<String, Bytes>,
153    packages: HashMap<PackageSpec, HashMap<String, Bytes>>,
154}
155
156impl CompileWorld {
157    /// Creates a new compilation world with the given source and options.
158    fn new(source: &str, options: &CompileOptions) -> Self {
159        let fonts = load_fonts();
160        let book = FontBook::from_fonts(&fonts);
161        let main_id = FileId::new(None, VirtualPath::new("/main.typ"));
162        let main = Source::new(main_id, source.to_string());
163
164        let files = options
165            .files
166            .iter()
167            .map(|(path, content)| (path.clone(), Bytes::new(content.clone())))
168            .collect();
169
170        let packages: HashMap<PackageSpec, HashMap<String, Bytes>> = options
171            .packages
172            .iter()
173            .map(
174                |(spec, pkg_files): (&PackageSpec, &HashMap<String, Vec<u8>>)| {
175                    let converted: HashMap<String, Bytes> = pkg_files
176                        .iter()
177                        .map(|(path, content)| (path.clone(), Bytes::new(content.clone())))
178                        .collect();
179                    (spec.clone(), converted)
180                },
181            )
182            .collect();
183
184        let library = Library::builder()
185            .with_features([Feature::Html].into_iter().collect())
186            .build();
187
188        Self {
189            library: LazyHash::new(library),
190            book: LazyHash::new(book),
191            fonts,
192            main,
193            files,
194            packages,
195        }
196    }
197
198    /// Retrieves a file from a package.
199    fn get_package_file(&self, package: &PackageSpec, path: &str) -> FileResult<Bytes> {
200        if let Some(pkg_files) = self.packages.get(package)
201            && let Some(content) = pkg_files.get(path)
202        {
203            return Ok(content.clone());
204        }
205
206        Err(FileError::Package(PackageError::NotFound(package.clone())))
207    }
208}
209
210impl World for CompileWorld {
211    fn library(&self) -> &LazyHash<Library> {
212        &self.library
213    }
214
215    fn book(&self) -> &LazyHash<FontBook> {
216        &self.book
217    }
218
219    fn main(&self) -> FileId {
220        self.main.id()
221    }
222
223    fn source(&self, id: FileId) -> FileResult<Source> {
224        if id == self.main.id() {
225            return Ok(self.main.clone());
226        }
227
228        if let Some(package) = id.package() {
229            let path = id.vpath().as_rooted_path().to_string_lossy();
230            let content = self.get_package_file(package, &path)?;
231            let text = String::from_utf8(content.to_vec()).map_err(|_| FileError::InvalidUtf8)?;
232            return Ok(Source::new(id, text));
233        }
234
235        Err(FileError::NotFound(id.vpath().as_rooted_path().into()))
236    }
237
238    fn file(&self, id: FileId) -> FileResult<Bytes> {
239        if let Some(package) = id.package() {
240            let path = id.vpath().as_rooted_path().to_string_lossy();
241            return self.get_package_file(package, &path);
242        }
243
244        let path = id.vpath().as_rooted_path().to_string_lossy();
245        self.files
246            .get(path.as_ref())
247            .cloned()
248            .ok_or_else(|| FileError::NotFound(id.vpath().as_rooted_path().into()))
249    }
250
251    fn font(&self, index: usize) -> Option<Font> {
252        self.fonts.get(index).cloned()
253    }
254
255    fn today(&self, offset: Option<i64>) -> Option<Datetime> {
256        let now = chrono::Local::now();
257        let now = match offset {
258            Some(hours) => {
259                let offset = chrono::FixedOffset::east_opt((hours * 3600) as i32)?;
260                now.with_timezone(&offset).naive_local()
261            }
262            None => now.naive_local(),
263        };
264        Datetime::from_ymd_hms(
265            now.year(),
266            now.month().try_into().ok()?,
267            now.day().try_into().ok()?,
268            now.hour().try_into().ok()?,
269            now.minute().try_into().ok()?,
270            now.second().try_into().ok()?,
271        )
272    }
273}
274
275/// Loads all available fonts.
276fn load_fonts() -> Vec<Font> {
277    Vec::new()
278}
279
280/// Compiles Typst source to HTML.
281fn compile(source: &str, options: &CompileOptions) -> Result<String, CompileError> {
282    let world = CompileWorld::new(source, options);
283    let warned = typst::compile::<HtmlDocument>(&world);
284    let document = warned.output.map_err(|errors| {
285        let messages: Vec<String> = errors.iter().map(|e| e.message.to_string()).collect();
286        CompileError::Typst(messages.join("; "))
287    })?;
288    typst_html::html(&document).map_err(|errors| {
289        let messages: Vec<String> = errors.iter().map(|e| e.message.to_string()).collect();
290        CompileError::Typst(messages.join("; "))
291    })
292}
293
294/// A Dioxus component that renders Typst markup as HTML.
295///
296/// This component compiles the provided Typst source at runtime and renders the
297/// resulting HTML. Compilation errors are displayed inline.
298///
299/// # Props
300///
301/// - `source`: The Typst source code to compile.
302/// - `options`: Optional [`CompileOptions`] providing additional files and packages.
303/// - `class`: CSS class for the wrapper div (defaults to `"typst-content"`).
304///
305/// # Example
306///
307/// ```rust
308/// use dioxus::prelude::*;
309/// use dioxus_typst::Typst;
310///
311/// #[component]
312/// fn App() -> Element {
313///     let content = r#"
314/// = Hello, Typst!
315///
316/// Some *formatted* text with math: $E = m c^2$
317/// "#;
318///
319///     rsx! {
320///         Typst { source: content.to_string() }
321///     }
322/// }
323/// ```
324///
325/// # Styling
326///
327/// The component outputs semantic HTML without styling. Apply CSS to the wrapper
328/// class to style headings, paragraphs, code blocks, and other elements.
329///
330/// # Errors
331///
332/// Compilation errors are rendered as a `<div class="typst-error">` containing
333/// the error message. Style this class to make errors visible during development.
334#[component]
335pub fn Typst(
336    source: String,
337    #[props(default)] options: CompileOptions,
338    #[props(default = "typst-content".to_string())] class: String,
339) -> Element {
340    match compile(&source, &options) {
341        Ok(html) => rsx! {
342            div { class, dangerous_inner_html: "{html}" }
343        },
344        Err(e) => rsx! {
345            div { class: "typst-error", "Error compiling Typst: {e}" }
346        },
347    }
348}
349
350/// Metadata extracted from a Typst document.
351#[derive(Debug, Clone, PartialEq)]
352pub struct DocumentMetadata {
353    pub title: Option<String>,
354    pub authors: Vec<String>,
355    pub description: Option<String>,
356    pub keywords: Vec<String>,
357    pub date: Option<chrono::NaiveDate>,
358}
359
360/// Compiles Typst source and extracts document metadata.
361///
362/// Still compiles the document, so errors may occur during this process
363///
364/// # Example:
365///
366/// ```rust
367/// use dioxus_typst::{extract_metadata, CompileOptions};
368///
369/// let source = r#"
370/// #set document(
371///     title: "Sample Document",
372///     author: "John Doe",
373///     description: "A brief description of the document.",
374///     keywords: ["typst", "rust", "dioxus"],
375///     date: datetime(year: 2026, month: 1, day: 1),
376/// )
377/// let metadata = extract_metadata(source, &CompileOptions::new()).unwrap();
378/// # assert_eq!(metadata.title.unwrap(), "Sample Document");
379/// # assert_eq!(metadata.authors, vec!["John Doe"]);
380/// # assert_eq!(metadata.description.unwrap(), "A brief description of the document.");
381/// # assert_eq!(metadata.keywords, vec!["typst", "rust", "dioxus"]);
382/// # assert_eq!(metadata.date.unwrap().to_string(), "2026-01-01");
383/// "#;
384/// ```
385pub fn extract_metadata(
386    source: &str,
387    options: &CompileOptions,
388) -> Result<DocumentMetadata, CompileError> {
389    let world = CompileWorld::new(source, options);
390    let warned = typst::compile::<HtmlDocument>(&world);
391    let document = warned.output.map_err(|errors| {
392        let messages: Vec<String> = errors.iter().map(|e| e.message.to_string()).collect();
393        CompileError::Typst(messages.join("; "))
394    })?;
395
396    let doc_info = &document.info;
397
398    let title = doc_info.title.as_ref().map(|t| t.to_string());
399
400    let authors = doc_info.author.iter().map(|a| a.to_string()).collect();
401
402    let description = doc_info.description.as_ref().map(|d| d.to_string());
403
404    let keywords = doc_info.keywords.iter().map(|k| k.to_string()).collect();
405
406    let date = match &doc_info.date {
407        Smart::Custom(Some(datetime)) => chrono::NaiveDate::from_ymd_opt(
408            datetime.year().unwrap_or(chrono::Local::now().year()),
409            datetime
410                .month()
411                .unwrap_or(chrono::Local::now().month() as u8)
412                .into(),
413            datetime
414                .day()
415                .unwrap_or(chrono::Local::now().day() as u8)
416                .into(),
417        ),
418        _ => None,
419    };
420
421    Ok(DocumentMetadata {
422        title,
423        authors,
424        description,
425        keywords,
426        date,
427    })
428}