1use 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
51fn normalize_path(path: String) -> String {
53 if path.starts_with('/') {
54 path
55 } else {
56 format!("/{path}")
57 }
58}
59
60#[derive(Debug, Clone, Default, PartialEq)]
75pub struct CompileOptions {
76 pub files: HashMap<String, Vec<u8>>,
78 pub packages: HashMap<PackageSpec, HashMap<String, Vec<u8>>>,
80}
81
82impl CompileOptions {
83 pub fn new() -> Self {
85 Self::default()
86 }
87
88 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum CompileError {
137 Typst(String),
141 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
270struct 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 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 #[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 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
433fn load_fonts() -> Vec<Font> {
435 Vec::new()
436}
437
438fn 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#[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