use std::collections::HashMap;
use std::fmt;
use std::sync::OnceLock;
use serde_json::Value;
use typst::diag::{FileError, FileResult, PackageError, Warned};
use typst::foundations::{Bytes, Datetime};
use typst::layout::PagedDocument;
use typst::syntax::{FileId, Source, VirtualPath};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Library, World};
use typst_pdf::PdfOptions;
use crate::theme::Theme;
const MAIN_PATH: &str = "/main.typ";
const RESUME_JSON_PATH: &str = "/resume.json";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderDiagnostic {
pub message: String,
}
impl fmt::Display for RenderDiagnostic {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "error: {}", self.message)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderError {
diagnostics: Vec<RenderDiagnostic>,
}
impl RenderError {
pub fn diagnostics(&self) -> &[RenderDiagnostic] {
&self.diagnostics
}
}
impl fmt::Display for RenderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if self.diagnostics.is_empty() {
return write!(f, "error: render failed without a diagnostic");
}
for (i, diag) in self.diagnostics.iter().enumerate() {
if i > 0 {
writeln!(f)?;
}
write!(f, "{diag}")?;
}
Ok(())
}
}
impl std::error::Error for RenderError {}
pub fn compile_pdf(source: &str, data: &Value) -> Result<Vec<u8>, RenderError> {
let world = FerrocvWorld::from_single_source(source, data);
render_world(&world)
}
pub fn compile_theme(theme: &Theme, data: &Value) -> Result<Vec<u8>, RenderError> {
let world = FerrocvWorld::from_theme(theme, data);
render_world(&world)
}
fn render_world(world: &FerrocvWorld) -> Result<Vec<u8>, RenderError> {
let Warned {
output,
warnings: _,
} = typst::compile::<PagedDocument>(world);
let document = output.map_err(diagnostics_to_error)?;
typst_pdf::pdf(&document, &PdfOptions::default()).map_err(diagnostics_to_error)
}
fn diagnostics_to_error<I>(diags: I) -> RenderError
where
I: IntoIterator,
I::Item: AsSourceDiagnostic,
{
let diagnostics: Vec<RenderDiagnostic> = diags
.into_iter()
.map(|d| RenderDiagnostic {
message: d.message_string(),
})
.collect();
RenderError { diagnostics }
}
trait AsSourceDiagnostic {
fn message_string(&self) -> String;
}
impl AsSourceDiagnostic for typst::diag::SourceDiagnostic {
fn message_string(&self) -> String {
self.message.to_string()
}
}
struct FerrocvWorld {
entrypoint: FileId,
entrypoint_source: Source,
resume_id: FileId,
resume_bytes: Bytes,
theme_files: HashMap<FileId, Bytes>,
}
impl FerrocvWorld {
fn from_single_source(source_text: &str, data: &Value) -> Self {
let entrypoint = FileId::new(None, VirtualPath::new(MAIN_PATH));
let entrypoint_source = Source::new(entrypoint, source_text.to_owned());
Self::assemble(entrypoint, entrypoint_source, HashMap::new(), data)
}
fn from_theme(theme: &Theme, data: &Value) -> Self {
let entrypoint = FileId::new(None, VirtualPath::new(theme.entrypoint));
let mut theme_files: HashMap<FileId, Bytes> = HashMap::with_capacity(theme.files.len());
let mut entrypoint_text: Option<String> = None;
for (path, bytes) in theme.files {
let id = FileId::new(None, VirtualPath::new(path));
if id == entrypoint {
let text = std::str::from_utf8(bytes)
.expect("theme entrypoint must be valid UTF-8 Typst source")
.to_owned();
entrypoint_text = Some(text);
}
theme_files.insert(id, Bytes::new(bytes.to_vec()));
}
let text = entrypoint_text.expect("Theme.entrypoint must appear as a key in Theme.files");
let entrypoint_source = Source::new(entrypoint, text);
Self::assemble(entrypoint, entrypoint_source, theme_files, data)
}
fn assemble(
entrypoint: FileId,
entrypoint_source: Source,
theme_files: HashMap<FileId, Bytes>,
data: &Value,
) -> Self {
let resume_id = FileId::new(None, VirtualPath::new(RESUME_JSON_PATH));
let bytes =
serde_json::to_vec(data).expect("serde_json::Value must always serialize to bytes");
let resume_bytes = Bytes::new(bytes);
Self {
entrypoint,
entrypoint_source,
resume_id,
resume_bytes,
theme_files,
}
}
}
fn shared_library() -> &'static LazyHash<Library> {
static LIBRARY: OnceLock<LazyHash<Library>> = OnceLock::new();
LIBRARY.get_or_init(|| LazyHash::new(Library::default()))
}
fn shared_fonts() -> &'static (LazyHash<FontBook>, Vec<Font>) {
static FONTS: OnceLock<(LazyHash<FontBook>, Vec<Font>)> = OnceLock::new();
FONTS.get_or_init(|| {
let fonts: Vec<Font> = typst_assets::fonts()
.flat_map(|data| Font::iter(Bytes::new(data)))
.collect();
let book = FontBook::from_fonts(&fonts);
(LazyHash::new(book), fonts)
})
}
impl World for FerrocvWorld {
fn library(&self) -> &LazyHash<Library> {
shared_library()
}
fn book(&self) -> &LazyHash<FontBook> {
&shared_fonts().0
}
fn main(&self) -> FileId {
self.entrypoint
}
fn source(&self, id: FileId) -> FileResult<Source> {
if id == self.entrypoint {
return Ok(self.entrypoint_source.clone());
}
if let Some(spec) = id.package() {
return Err(FileError::Package(PackageError::NotFound(spec.clone())));
}
if let Some(bytes) = self.theme_files.get(&id) {
let text = std::str::from_utf8(bytes.as_slice())
.map_err(|_| FileError::NotFound(id.vpath().as_rootless_path().into()))?;
return Ok(Source::new(id, text.to_owned()));
}
Err(FileError::NotFound(id.vpath().as_rootless_path().into()))
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
if id == self.resume_id {
return Ok(self.resume_bytes.clone());
}
if let Some(spec) = id.package() {
return Err(FileError::Package(PackageError::NotFound(spec.clone())));
}
if let Some(bytes) = self.theme_files.get(&id) {
return Ok(bytes.clone());
}
Err(FileError::NotFound(id.vpath().as_rootless_path().into()))
}
fn font(&self, index: usize) -> Option<Font> {
shared_fonts().1.get(index).cloned()
}
fn today(&self, _offset: Option<i64>) -> Option<Datetime> {
None
}
}