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, Smart},
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)]
66pub struct CompileOptions {
67 pub files: HashMap<String, Vec<u8>>,
69 pub packages: HashMap<PackageSpec, HashMap<String, Vec<u8>>>,
71}
72
73impl CompileOptions {
74 pub fn new() -> Self {
76 Self::default()
77 }
78
79 #[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 #[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#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum CompileError {
130 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
146struct 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 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 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
275fn load_fonts() -> Vec<Font> {
277 Vec::new()
278}
279
280fn 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#[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#[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
360pub 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}