1use std::collections::HashMap;
2
3use chrono::{Datelike, Timelike};
4use dioxus::prelude::*;
5use typst::{
6 Feature, Library, LibraryExt, World,
7 diag::{FileError, FileResult},
8 foundations::{Bytes, Datetime},
9 syntax::{FileId, Source, VirtualPath},
10 text::{Font, FontBook},
11 utils::LazyHash,
12};
13use typst_html::HtmlDocument;
14
15#[derive(Debug, Clone, Default, PartialEq)]
16pub struct CompileOptions {
17 pub files: HashMap<String, Vec<u8>>,
18}
19
20impl CompileOptions {
21 pub fn new() -> Self {
22 Self::default()
23 }
24
25 pub fn with_file(mut self, path: impl Into<String>, content: Vec<u8>) -> Self {
26 self.files.insert(path.into(), content);
27 self
28 }
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum CompileError {
33 Typst(String),
34}
35
36impl std::fmt::Display for CompileError {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 CompileError::Typst(msg) => write!(f, "Typst compilation error: {msg}"),
40 }
41 }
42}
43
44impl std::error::Error for CompileError {}
45
46struct CompileWorld {
47 library: LazyHash<Library>,
48 book: LazyHash<FontBook>,
49 fonts: Vec<Font>,
50 main: Source,
51 files: HashMap<String, Bytes>,
52}
53
54impl CompileWorld {
55 fn new(source: &str, options: &CompileOptions) -> Self {
56 let fonts = load_fonts();
57 let book = FontBook::from_fonts(&fonts);
58 let main_id = FileId::new(None, VirtualPath::new("/main.typ"));
59 let main = Source::new(main_id, source.to_string());
60 let files = options
61 .files
62 .iter()
63 .map(|(path, content)| (path.clone(), Bytes::new(content.clone())))
64 .collect();
65 let library = Library::builder()
66 .with_features([Feature::Html].into_iter().collect())
67 .build();
68
69 Self {
70 library: LazyHash::new(library),
71 book: LazyHash::new(book),
72 fonts,
73 main,
74 files,
75 }
76 }
77}
78
79impl World for CompileWorld {
80 fn library(&self) -> &LazyHash<Library> {
81 &self.library
82 }
83
84 fn book(&self) -> &LazyHash<FontBook> {
85 &self.book
86 }
87
88 fn main(&self) -> FileId {
89 self.main.id()
90 }
91
92 fn source(&self, id: FileId) -> FileResult<Source> {
93 if id == self.main.id() {
94 Ok(self.main.clone())
95 } else {
96 Err(FileError::NotFound(id.vpath().as_rooted_path().into()))
97 }
98 }
99
100 fn file(&self, id: FileId) -> FileResult<Bytes> {
101 let path = id.vpath().as_rooted_path().to_string_lossy();
102 self.files
103 .get(path.as_ref())
104 .cloned()
105 .ok_or_else(|| FileError::NotFound(id.vpath().as_rooted_path().into()))
106 }
107
108 fn font(&self, index: usize) -> Option<Font> {
109 self.fonts.get(index).cloned()
110 }
111
112 fn today(&self, offset: Option<i64>) -> Option<Datetime> {
113 let now = chrono::Local::now();
114 let now = match offset {
115 Some(hours) => {
116 let offset = chrono::FixedOffset::east_opt((hours * 3600) as i32)?;
117 now.with_timezone(&offset).naive_local()
118 }
119 None => now.naive_local(),
120 };
121 Datetime::from_ymd_hms(
122 now.year(),
123 now.month().try_into().ok()?,
124 now.day().try_into().ok()?,
125 now.hour().try_into().ok()?,
126 now.minute().try_into().ok()?,
127 now.second().try_into().ok()?,
128 )
129 }
130}
131
132fn load_fonts() -> Vec<Font> {
133 let mut fonts = Vec::new();
134 #[cfg(feature = "fonts")]
135 for data in typst_assets::fonts() {
136 for font in Font::iter(Bytes::new(data)) {
137 fonts.push(font);
138 }
139 }
140 fonts
141}
142
143fn compile(source: &str, options: &CompileOptions) -> Result<String, CompileError> {
144 let world = CompileWorld::new(source, options);
145 let warned = typst::compile::<HtmlDocument>(&world);
146 let document = warned.output.map_err(|errors| {
147 let messages: Vec<String> = errors.iter().map(|e| e.message.to_string()).collect();
148 CompileError::Typst(messages.join("; "))
149 })?;
150 typst_html::html(&document).map_err(|errors| {
151 let messages: Vec<String> = errors.iter().map(|e| e.message.to_string()).collect();
152 CompileError::Typst(messages.join("; "))
153 })
154}
155
156#[component]
157pub fn Typst(
158 source: String,
159 #[props(default)] options: CompileOptions,
160 #[props(default = "typst-content".to_string())] class: String,
161) -> Element {
162 match compile(&source, &options) {
163 Ok(html) => rsx! {
164 div { class, dangerous_inner_html: "{html}" }
165 },
166 Err(e) => rsx! {
167 div { class: "typst-error", "Error compiling Typst: {e}" }
168 },
169 }
170}