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::Quill;
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(
quill: &Quill,
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();
#[cfg(feature = "embed-default-font")]
{
fn load_embedded(byte_array: &[u8], book: &mut FontBook, fonts: &mut Vec<Font>) {
let bytes = Bytes::new(byte_array.to_vec());
for font in Font::iter(bytes) {
book.push(font.info().clone());
fonts.push(font);
}
}
const ROBOTO_BYTES: &[u8] =
include_bytes!("../assets/RobotoCondensed-VariableFont_wght.ttf");
load_embedded(ROBOTO_BYTES, &mut book, &mut fonts);
const DEJAVU_SANS_MONO_BYTES: &[u8] = include_bytes!("../assets/DejaVuSansMono.ttf");
load_embedded(DEJAVU_SANS_MONO_BYTES, &mut book, &mut fonts);
const DEJAVU_SANS_MONO_BOLD_BYTES: &[u8] =
include_bytes!("../assets/DejaVuSansMono-Bold.ttf");
load_embedded(DEJAVU_SANS_MONO_BOLD_BYTES, &mut book, &mut fonts);
}
let font_data_list = Self::load_fonts_from_quill(quill)?;
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(quill, &mut binaries)?;
Self::load_packages_from_quill(quill, &mut sources, &mut binaries)?;
#[cfg(feature = "native")]
Self::download_and_load_external_packages(quill, &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(
quill: &Quill,
main: &str,
json_data: &str,
) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
let mut world = Self::new(quill, 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(
quill: &Quill,
) -> Result<Vec<Vec<u8>>, Box<dyn std::error::Error + Send + Sync>> {
let mut font_data = Vec::new();
let fonts_paths = quill.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) = quill.get_file(&font_path) {
font_data.push(contents.to_vec());
}
}
}
}
let package_font_paths = quill.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) = quill.get_file(&font_path) {
font_data.push(contents.to_vec());
}
}
}
}
let asset_paths = quill.find_files("assets/*");
for asset_path in asset_paths {
if let Some(ext) = asset_path.extension() {
if matches!(
ext.to_string_lossy().to_lowercase().as_str(),
"ttf" | "otf" | "woff" | "woff2"
) {
if let Some(contents) = quill.get_file(&asset_path) {
font_data.push(contents.to_vec());
}
}
}
}
Ok(font_data)
}
fn load_assets_from_quill(
quill: &Quill,
binaries: &mut HashMap<FileId, Bytes>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let asset_paths = quill.find_files("assets/*");
for asset_path in asset_paths {
if let Some(contents) = quill.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(())
}
#[cfg(feature = "native")]
fn download_and_load_external_packages(
quill: &Quill,
sources: &mut HashMap<FileId, Source>,
binaries: &mut HashMap<FileId, Bytes>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use typst_kit::download::{Downloader, ProgressSink};
use typst_kit::package::{PackageStorage, DEFAULT_PACKAGES_SUBDIR};
let packages_list = quill.typst_packages();
if packages_list.is_empty() {
return Ok(());
}
let downloader = Downloader::new("quillmark/0.1.0");
let cache_dir = dirs::cache_dir().map(|d| d.join(DEFAULT_PACKAGES_SUBDIR));
let data_dir = dirs::data_dir().map(|d| d.join(DEFAULT_PACKAGES_SUBDIR));
let storage = PackageStorage::new(cache_dir, data_dir, downloader);
for package_str in packages_list {
match package_str.parse::<PackageSpec>() {
Ok(spec) => {
let mut progress = ProgressSink;
match storage.prepare_package(&spec, &mut progress) {
Ok(package_dir) => {
Self::load_package_from_filesystem(
&package_dir,
sources,
binaries,
spec,
)?;
}
Err(e) => {
eprintln!("Warning: Failed to download package {}: {}", package_str, e);
}
}
}
Err(e) => {
eprintln!(
"Warning: Failed to parse package spec '{}': {}",
package_str, e
);
}
}
}
Ok(())
}
#[cfg(feature = "native")]
fn load_package_from_filesystem(
package_dir: &Path,
sources: &mut HashMap<FileId, Source>,
binaries: &mut HashMap<FileId, Bytes>,
spec: PackageSpec,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use std::fs;
let toml_path = package_dir.join("typst.toml");
let entrypoint = if toml_path.exists() {
let toml_content = fs::read_to_string(&toml_path)?;
match parse_package_toml(&toml_content) {
Ok(info) => info.entrypoint,
Err(_) => "lib.typ".to_string(),
}
} else {
"lib.typ".to_string()
};
Self::load_package_files_recursive(package_dir, package_dir, sources, binaries, &spec)?;
let entrypoint_path = VirtualPath::new(&entrypoint);
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, spec.name, spec.version
);
}
Ok(())
}
#[cfg(feature = "native")]
fn load_package_files_recursive(
current_dir: &Path,
package_root: &Path,
sources: &mut HashMap<FileId, Source>,
binaries: &mut HashMap<FileId, Bytes>,
spec: &PackageSpec,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use std::fs;
for entry in fs::read_dir(current_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_file() {
let relative_path = path
.strip_prefix(package_root)
.map_err(|e| format!("Failed to strip prefix: {}", e))?;
let virtual_path = VirtualPath::new(relative_path.to_string_lossy().as_ref());
let file_id = FileId::new(Some(spec.clone()), virtual_path);
let contents = fs::read(&path)?;
if let Some(ext) = path.extension() {
if ext == "typ" {
let text = String::from_utf8_lossy(&contents).to_string();
sources.insert(file_id, Source::new(file_id, text));
} else {
binaries.insert(file_id, Bytes::new(contents));
}
} else {
binaries.insert(file_id, Bytes::new(contents));
}
} else if path.is_dir() {
Self::load_package_files_recursive(&path, package_root, sources, binaries, spec)?;
}
}
Ok(())
}
fn load_packages_from_quill(
quill: &Quill,
sources: &mut HashMap<FileId, Source>,
binaries: &mut HashMap<FileId, Bytes>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let package_dirs = quill.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) = quill.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(
quill,
&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(
quill,
&package_dir,
sources,
binaries,
Some(spec),
None,
)?;
}
}
Ok(())
}
fn load_package_files_from_quill(
quill: &Quill,
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 = quill.find_files(&package_pattern);
for file_path in package_files {
if let Some(contents) = quill.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 quillmark_core::Quill;
use std::path::Path;
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 quill = Quill::from_path(&quill_path).unwrap();
let world = QuillWorld::new(&quill, "// 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);
}
}
}