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::{Frame, FrameItem, PagedDocument};
use typst::syntax::{FileId, Source, VirtualPath};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Feature, Library, LibraryExt, World};
use typst_html::HtmlDocument;
use typst_pdf::PdfOptions;
use crate::theme::{OwnedTheme, ResolvedTheme, 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)
}
pub fn compile_text(theme: &Theme, data: &Value) -> Result<String, RenderError> {
let world = FerrocvWorld::from_theme(theme, data);
render_world_to_text(&world)
}
pub fn compile_html(theme: &Theme, data: &Value) -> Result<String, RenderError> {
let world = FerrocvWorld::from_theme(theme, data);
render_world_to_html(&world)
}
pub fn compile_theme_resolved(theme: &ResolvedTheme, data: &Value) -> Result<Vec<u8>, RenderError> {
let world = FerrocvWorld::from_bundle(theme, data);
render_world(&world)
}
pub fn compile_text_resolved(theme: &ResolvedTheme, data: &Value) -> Result<String, RenderError> {
let world = FerrocvWorld::from_bundle(theme, data);
render_world_to_text(&world)
}
pub fn compile_html_resolved(theme: &ResolvedTheme, data: &Value) -> Result<String, RenderError> {
let world = FerrocvWorld::from_bundle(theme, data);
render_world_to_html(&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 render_world_to_text(world: &FerrocvWorld) -> Result<String, RenderError> {
let Warned {
output,
warnings: _,
} = typst::compile::<PagedDocument>(world);
let document = output.map_err(diagnostics_to_error)?;
Ok(extract_text(&document))
}
fn render_world_to_html(world: &FerrocvWorld) -> Result<String, RenderError> {
let Warned {
output,
warnings: _,
} = typst::compile::<HtmlDocument>(world);
let document = output.map_err(diagnostics_to_error)?;
typst_html::html(&document).map_err(diagnostics_to_error)
}
const LINE_TOLERANCE_PT: f64 = 1.0;
const PARAGRAPH_GAP_PT: f64 = 8.0;
struct TextItemPosition {
y_pt: f64,
x_pt: f64,
text: String,
}
fn extract_text(document: &PagedDocument) -> String {
let mut pages: Vec<Vec<TextItemPosition>> = Vec::with_capacity(document.pages.len());
for page in &document.pages {
let mut page_items: Vec<TextItemPosition> = Vec::new();
collect_from_frame(&page.frame, 0.0, 0.0, &mut page_items);
pages.push(page_items);
}
let mut page_strings: Vec<String> = Vec::with_capacity(pages.len());
for mut items in pages {
items.sort_by(|a, b| {
a.y_pt
.partial_cmp(&b.y_pt)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
a.x_pt
.partial_cmp(&b.x_pt)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
let lines = group_into_lines(&items);
page_strings.push(join_lines_with_paragraph_breaks(&lines));
}
let joined = page_strings.join("\n\n");
normalize_text(&joined)
}
fn collect_from_frame(
frame: &Frame,
offset_x_pt: f64,
offset_y_pt: f64,
out: &mut Vec<TextItemPosition>,
) {
for (point, item) in frame.items() {
let item_x = offset_x_pt + point.x.to_pt();
let item_y = offset_y_pt + point.y.to_pt();
match item {
FrameItem::Text(text_item) => {
out.push(TextItemPosition {
y_pt: item_y,
x_pt: item_x,
text: text_item.text.to_string(),
});
}
FrameItem::Group(group) => {
collect_from_frame(&group.frame, item_x, item_y, out);
}
_ => {}
}
}
}
fn group_into_lines(items: &[TextItemPosition]) -> Vec<(f64, String)> {
let mut lines: Vec<(f64, String)> = Vec::new();
for item in items {
match lines.last_mut() {
Some((anchor_y, text)) if (item.y_pt - *anchor_y).abs() <= LINE_TOLERANCE_PT => {
if !text.is_empty() && !item.text.is_empty() {
text.push(' ');
}
text.push_str(&item.text);
}
_ => {
lines.push((item.y_pt, item.text.clone()));
}
}
}
lines
}
fn join_lines_with_paragraph_breaks(lines: &[(f64, String)]) -> String {
let mut out = String::new();
let mut prev_y: Option<f64> = None;
for (y, text) in lines {
if let Some(prev) = prev_y {
let gap = y - prev;
if gap > PARAGRAPH_GAP_PT {
out.push_str("\n\n");
} else {
out.push('\n');
}
}
out.push_str(text);
prev_y = Some(*y);
}
out
}
fn normalize_text(raw: &str) -> String {
let trimmed: Vec<String> = raw.lines().map(|l| l.trim_end().to_string()).collect();
let mut collapsed: Vec<String> = Vec::with_capacity(trimmed.len());
let mut prev_blank = false;
for line in trimmed {
let is_blank = line.is_empty();
if is_blank && prev_blank {
continue;
}
collapsed.push(line);
prev_blank = is_blank;
}
while collapsed.first().is_some_and(|s| s.is_empty()) {
collapsed.remove(0);
}
while collapsed.last().is_some_and(|s| s.is_empty()) {
collapsed.pop();
}
if collapsed.is_empty() {
return String::new();
}
let mut out = collapsed.join("\n");
out.push('\n');
out
}
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()
}
}
pub(crate) trait ThemeBundle {
fn entrypoint(&self) -> &str;
fn files(&self) -> Box<dyn Iterator<Item = (&str, &[u8])> + '_>;
}
impl ThemeBundle for Theme {
fn entrypoint(&self) -> &str {
self.entrypoint
}
fn files(&self) -> Box<dyn Iterator<Item = (&str, &[u8])> + '_> {
Box::new(self.files.iter().map(|(p, b)| (*p, *b as &[u8])))
}
}
impl ThemeBundle for OwnedTheme {
fn entrypoint(&self) -> &str {
&self.entrypoint
}
fn files(&self) -> Box<dyn Iterator<Item = (&str, &[u8])> + '_> {
Box::new(self.files.iter().map(|(p, b)| (p.as_str(), b.as_slice())))
}
}
impl ThemeBundle for ResolvedTheme {
fn entrypoint(&self) -> &str {
ResolvedTheme::entrypoint(self)
}
fn files(&self) -> Box<dyn Iterator<Item = (&str, &[u8])> + '_> {
ResolvedTheme::files(self)
}
}
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 {
Self::from_bundle(theme, data)
}
fn from_bundle<B: ThemeBundle + ?Sized>(bundle: &B, data: &Value) -> Self {
let entrypoint = FileId::new(None, VirtualPath::new(bundle.entrypoint()));
let mut theme_files: HashMap<FileId, Bytes> = HashMap::new();
let mut entrypoint_text: Option<String> = None;
for (path, bytes) in bundle.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("bundle entrypoint must appear as a key in bundle 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(|| {
let features: typst::Features = [Feature::Html].into_iter().collect();
LazyHash::new(Library::builder().with_features(features).build())
})
}
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
}
}
#[cfg(test)]
mod tests {
use super::*;
fn compile_text_from_source(source: &str, data: &Value) -> Result<String, RenderError> {
let world = FerrocvWorld::from_single_source(source, data);
render_world_to_text(&world)
}
#[test]
fn extract_text_single_line() {
let out = compile_text_from_source("Hello, world.", &Value::Object(Default::default()))
.expect("trivial source must compile");
assert!(
out.contains("Hello, world."),
"extracted text must contain the source text; got: {out:?}"
);
assert!(
out.ends_with('\n'),
"extracted text must end with exactly one newline; got: {out:?}"
);
assert!(
!out.ends_with("\n\n"),
"extracted text must not end with multiple newlines; got: {out:?}"
);
}
#[test]
fn extract_text_paragraph_break_inserts_blank_line() {
let source = "First paragraph.\n\nSecond paragraph.";
let out = compile_text_from_source(source, &Value::Object(Default::default()))
.expect("two-paragraph source must compile");
assert!(
out.contains("First paragraph."),
"missing first paragraph; got: {out:?}"
);
assert!(
out.contains("Second paragraph."),
"missing second paragraph; got: {out:?}"
);
assert!(
out.contains("\n\n"),
"expected a blank line between paragraphs; got: {out:?}"
);
}
#[test]
fn extract_text_empty_document_is_empty_string() {
let out = compile_text_from_source("", &Value::Object(Default::default()))
.expect("empty source must compile");
assert_eq!(
out, "",
"empty document must extract to empty string; got: {out:?}"
);
}
}