use std::collections::HashMap;
use std::path::Path;
use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime};
use typst::syntax::{package::PackageSpec, FileId, Source, VirtualPath};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Library, World};
use crate::helper;
use quillmark_core::QuillSource;
pub struct QuillWorld {
library: LazyHash<Library>,
book: LazyHash<FontBook>,
fonts: Vec<Font>, source: Source,
sources: HashMap<FileId, Source>,
binaries: HashMap<FileId, Bytes>,
}
impl QuillWorld {
pub fn new(
source: &QuillSource,
main: &str,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let mut sources = HashMap::new();
let mut binaries = HashMap::new();
let mut book = FontBook::new();
let mut fonts = Vec::new();
let font_data_list = Self::load_fonts_from_quill(source)?;
for font_data in font_data_list {
let font_bytes = Bytes::new(font_data);
for font in Font::iter(font_bytes) {
book.push(font.info().clone());
fonts.push(font);
}
}
if fonts.is_empty() {
return Err(format!("No fonts found: asset_faces={}", fonts.len()).into());
}
Self::load_assets_from_quill(source, &mut binaries)?;
Self::load_packages_from_quill(source, &mut sources, &mut binaries)?;
let main_id = FileId::new(None, VirtualPath::new("main.typ"));
let source = Source::new(main_id, main.to_string());
Ok(Self {
library: LazyHash::new(<Library as typst::LibraryExt>::default()),
book: LazyHash::new(book),
fonts,
source,
sources,
binaries,
})
}
pub fn new_with_data(
source: &QuillSource,
main: &str,
json_data: &str,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let mut world = Self::new(source, main)?;
world.inject_helper_package(json_data);
Ok(world)
}
fn inject_helper_package(&mut self, json_data: &str) {
let spec = PackageSpec {
namespace: helper::HELPER_NAMESPACE.into(),
name: helper::HELPER_NAME.into(),
version: helper::HELPER_VERSION
.parse()
.expect("Invalid helper version"),
};
let lib_content = helper::generate_lib_typ(json_data);
let lib_path = VirtualPath::new("lib.typ");
let lib_id = FileId::new(Some(spec.clone()), lib_path);
self.sources
.insert(lib_id, Source::new(lib_id, lib_content));
let toml_content = helper::generate_typst_toml();
let toml_path = VirtualPath::new("typst.toml");
let toml_id = FileId::new(Some(spec), toml_path);
self.binaries
.insert(toml_id, Bytes::new(toml_content.into_bytes()));
}
fn load_fonts_from_quill(
source: &QuillSource,
) -> Result<Vec<Vec<u8>>, Box<dyn std::error::Error + Send + Sync>> {
let mut font_data = Vec::new();
let fonts_paths = source.find_files("assets/fonts/*");
for font_path in fonts_paths {
if let Some(ext) = font_path.extension() {
if matches!(
ext.to_string_lossy().to_lowercase().as_str(),
"ttf" | "otf" | "woff" | "woff2"
) {
if let Some(contents) = source.get_file(&font_path) {
font_data.push(contents.to_vec());
}
}
}
}
let package_font_paths = source.find_files("packages/**");
for font_path in package_font_paths {
if let Some(ext) = font_path.extension() {
if matches!(
ext.to_string_lossy().to_lowercase().as_str(),
"ttf" | "otf" | "woff" | "woff2"
) {
if let Some(contents) = source.get_file(&font_path) {
font_data.push(contents.to_vec());
}
}
}
}
Ok(font_data)
}
fn load_assets_from_quill(
source: &QuillSource,
binaries: &mut HashMap<FileId, Bytes>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let asset_paths = source.find_files("assets/*");
for asset_path in asset_paths {
if let Some(contents) = source.get_file(&asset_path) {
let virtual_path = VirtualPath::new(asset_path.to_string_lossy().as_ref());
let file_id = FileId::new(None, virtual_path);
binaries.insert(file_id, Bytes::new(contents.to_vec()));
}
}
Ok(())
}
fn load_packages_from_quill(
source: &QuillSource,
sources: &mut HashMap<FileId, Source>,
binaries: &mut HashMap<FileId, Bytes>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let package_dirs = source.list_directories("packages");
for package_dir in package_dirs {
let package_name = package_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let toml_path = package_dir.join("typst.toml");
if let Some(toml_contents) = source.get_file(&toml_path) {
let toml_content = String::from_utf8_lossy(toml_contents);
match parse_package_toml(&toml_content) {
Ok(package_info) => {
let spec = PackageSpec {
namespace: package_info.namespace.clone().into(),
name: package_info.name.clone().into(),
version: package_info.version.parse().map_err(|_| {
format!("Invalid version format: {}", package_info.version)
})?,
};
Self::load_package_files_from_quill(
source,
&package_dir,
sources,
binaries,
Some(spec),
Some(&package_info.entrypoint),
)?;
}
Err(e) => {
eprintln!(
"Warning: Failed to parse typst.toml for {}: {}",
package_name, e
);
}
}
} else {
let spec = PackageSpec {
namespace: "local".into(),
name: package_name.into(),
version: "0.1.0".parse().map_err(|_| "Invalid version format")?,
};
Self::load_package_files_from_quill(
source,
&package_dir,
sources,
binaries,
Some(spec),
None,
)?;
}
}
Ok(())
}
fn load_package_files_from_quill(
source: &QuillSource,
package_dir: &Path,
sources: &mut HashMap<FileId, Source>,
binaries: &mut HashMap<FileId, Bytes>,
package_spec: Option<PackageSpec>,
entrypoint: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let package_pattern = format!("{}/*", package_dir.to_string_lossy());
let package_files = source.find_files(&package_pattern);
for file_path in package_files {
if let Some(contents) = source.get_file(&file_path) {
let relative_path = file_path.strip_prefix(package_dir).map_err(|_| {
format!("Failed to get relative path for {}", file_path.display())
})?;
let virtual_path = VirtualPath::new(relative_path.to_string_lossy().as_ref());
let file_id = FileId::new(package_spec.clone(), virtual_path);
if let Some(ext) = file_path.extension() {
if ext == "typ" {
let source_content = String::from_utf8_lossy(contents);
let source = Source::new(file_id, source_content.to_string());
sources.insert(file_id, source);
} else {
binaries.insert(file_id, Bytes::new(contents.to_vec()));
}
} else {
binaries.insert(file_id, Bytes::new(contents.to_vec()));
}
}
}
if let (Some(spec), Some(entrypoint_name)) = (&package_spec, entrypoint) {
let entrypoint_path = VirtualPath::new(entrypoint_name);
let entrypoint_file_id = FileId::new(Some(spec.clone()), entrypoint_path);
if !sources.contains_key(&entrypoint_file_id) {
eprintln!(
"Warning: Entrypoint {} not found for package {}",
entrypoint_name, spec.name
);
}
}
Ok(())
}
}
impl World for QuillWorld {
fn library(&self) -> &LazyHash<Library> {
&self.library
}
fn book(&self) -> &LazyHash<FontBook> {
&self.book
}
fn main(&self) -> FileId {
self.source.id()
}
fn source(&self, id: FileId) -> FileResult<Source> {
if id == self.source.id() {
Ok(self.source.clone())
} else if let Some(source) = self.sources.get(&id) {
Ok(source.clone())
} else {
Err(FileError::NotFound(
id.vpath().as_rootless_path().to_owned(),
))
}
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
if let Some(bytes) = self.binaries.get(&id) {
Ok(bytes.clone())
} else {
Err(FileError::NotFound(
id.vpath().as_rootless_path().to_owned(),
))
}
}
fn font(&self, index: usize) -> Option<Font> {
if let Some(font) = self.fonts.get(index) {
return Some(font.clone());
}
None
}
fn today(&self, offset: Option<i64>) -> Option<Datetime> {
#[cfg(not(target_arch = "wasm32"))]
{
use time::{Duration, OffsetDateTime};
let now = OffsetDateTime::now_utc();
let adjusted = if let Some(hours) = offset {
now + Duration::hours(hours)
} else {
now
};
let date = adjusted.date();
Datetime::from_ymd(date.year(), date.month() as u8, date.day())
}
#[cfg(target_arch = "wasm32")]
{
use js_sys::Date;
use wasm_bindgen::JsValue;
let d = Date::new_0();
let year = d.get_utc_full_year() as i32;
let month = (d.get_utc_month() as u8).saturating_add(1);
let day = d.get_utc_date() as u8;
if let Some(hours) = offset {
let millis = d.get_time() + (hours as f64) * 3_600_000.0;
let d2 = Date::new(&JsValue::from_f64(millis));
let year = d2.get_utc_full_year() as i32;
let month = (d2.get_utc_month() as u8).saturating_add(1);
let day = d2.get_utc_date() as u8;
return Datetime::from_ymd(year, month, day);
}
Datetime::from_ymd(year, month, day)
}
}
}
#[derive(Debug, Clone)]
struct PackageInfo {
namespace: String,
name: String,
version: String,
entrypoint: String,
}
fn parse_package_toml(
content: &str,
) -> Result<PackageInfo, Box<dyn std::error::Error + Send + Sync>> {
let value: toml::Value = toml::from_str(content)?;
let package_section = value
.get("package")
.ok_or("Missing [package] section in typst.toml")?;
let namespace = package_section
.get("namespace")
.and_then(|v| v.as_str())
.unwrap_or("local")
.to_string();
let name = package_section
.get("name")
.and_then(|v| v.as_str())
.ok_or("Package name is required in typst.toml")?
.to_string();
let version = package_section
.get("version")
.and_then(|v| v.as_str())
.unwrap_or("0.1.0")
.to_string();
let entrypoint = package_section
.get("entrypoint")
.and_then(|v| v.as_str())
.unwrap_or("lib.typ")
.to_string();
Ok(PackageInfo {
namespace,
name,
version,
entrypoint,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_package_toml() {
let toml_content = r#"
[package]
name = "test-package"
version = "1.0.0"
namespace = "preview"
entrypoint = "src/lib.typ"
"#;
let package_info = parse_package_toml(toml_content).unwrap();
assert_eq!(package_info.name, "test-package");
assert_eq!(package_info.version, "1.0.0");
assert_eq!(package_info.namespace, "preview");
assert_eq!(package_info.entrypoint, "src/lib.typ");
}
#[test]
fn test_parse_package_toml_defaults() {
let toml_content = r#"
[package]
name = "minimal-package"
"#;
let package_info = parse_package_toml(toml_content).unwrap();
assert_eq!(package_info.name, "minimal-package");
assert_eq!(package_info.version, "0.1.0");
assert_eq!(package_info.namespace, "local");
assert_eq!(package_info.entrypoint, "lib.typ");
}
#[test]
fn test_asset_fonts_have_priority() {
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use quillmark_core::{FileTreeNode, QuillSource};
fn walk(dir: &Path, base: &Path) -> std::io::Result<FileTreeNode> {
let mut files = HashMap::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let p: PathBuf = entry.path();
let name = p.file_name().unwrap().to_string_lossy().into_owned();
if p.is_file() {
files.insert(
name,
FileTreeNode::File {
contents: fs::read(&p)?,
},
);
} else if p.is_dir() {
files.insert(name, walk(&p, base)?);
}
}
Ok(FileTreeNode::Directory { files })
}
let quill_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.parent()
.unwrap()
.join("fixtures")
.join("resources")
.join("quills")
.join("usaf_memo")
.join("0.1.0");
if !quill_path.exists() {
return;
}
let tree = walk(&quill_path, &quill_path).expect("walk fixture");
let source = QuillSource::from_tree(tree).expect("load source");
let world = QuillWorld::new(&source, "// Test").unwrap();
assert!(!world.fonts.is_empty(), "Should have asset fonts loaded");
for i in 0..world.fonts.len() {
let font = world.font(i);
assert!(font.is_some(), "Font at index {} should be available", i);
}
}
}