1use 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
40fn normalize_path(path: String) -> String {
42 if path.starts_with('/') {
43 path
44 } else {
45 format!("/{path}")
46 }
47}
48
49#[derive(Debug, Clone, Default, PartialEq)]
64pub struct CompileOptions {
65 pub files: HashMap<String, Vec<u8>>,
67 pub packages: HashMap<PackageSpec, HashMap<String, Vec<u8>>>,
69}
70
71impl CompileOptions {
72 pub fn new() -> Self {
74 Self::default()
75 }
76
77 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum CompileError {
120 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
136struct 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 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 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
265fn load_fonts() -> Vec<Font> {
267 Vec::new()
268}
269
270fn 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#[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}