#![cfg(feature = "fs")]
use std::collections::HashSet;
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use ecow::EcoVec;
use typst::diag::{FileError, FileResult, SourceDiagnostic};
use typst::foundations::{Bytes, Datetime, Dict, Duration};
use typst::syntax::package::PackageSpec;
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Feature, Library, LibraryExt, World};
use typst_kit::files::{FileLoader, FileStore};
use typst_kit::fonts::FontStore;
use crate::creation::{
DiscoverySpecification, PackCreationError, PackCreationInput, PackCreationOutcome, create,
};
use crate::domain::{DocumentTime, TypstTarget};
use crate::font_catalog::FontDisposition;
use crate::fs_fonts::{FilesystemFontLimits, FilesystemFontSource, read_filesystem_fonts};
use crate::fs_packages::{
FilesystemPackageAuthority, FilesystemPackageAuthorityReadError, FilesystemPackageLimits,
ReadPackages,
};
use crate::fs_project;
use crate::manifest::PackMetadata;
use crate::pack::Pack;
use crate::package_catalog::{PackageCatalog, PackageCatalogError, PackageDisposition};
use crate::package_failure::PackageReadFailures;
use crate::project_snapshot::ProjectSnapshot;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct FilesystemPackAssemblyProfile {
project: fs_project::FilesystemProjectLimits,
packages: FilesystemPackageLimits,
fonts: FilesystemFontLimits,
#[cfg(feature = "egress")]
package_expansion: crate::PackageExpansionLimits,
}
impl FilesystemPackAssemblyProfile {
pub const fn reference_v1() -> Self {
Self {
project: fs_project::FilesystemProjectLimits::reference_v1(),
packages: FilesystemPackageLimits::reference_v1(),
fonts: FilesystemFontLimits::reference_v1(),
#[cfg(feature = "egress")]
package_expansion: crate::PackageExpansionLimits::reference_v1(),
}
}
pub const fn project(&self) -> fs_project::FilesystemProjectLimits {
self.project
}
pub const fn packages(&self) -> FilesystemPackageLimits {
self.packages
}
pub const fn fonts(&self) -> FilesystemFontLimits {
self.fonts
}
#[cfg(feature = "egress")]
pub const fn package_expansion(&self) -> crate::PackageExpansionLimits {
self.package_expansion
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[non_exhaustive]
pub enum FilesystemPackAssemblyClock {
#[default]
System,
Fixed(DocumentTime),
}
#[derive(Debug)]
pub struct FilesystemPackAssemblerConfig {
font_paths: Vec<PathBuf>,
system_fonts: bool,
typst_embedded_fonts: bool,
package_path: Option<PathBuf>,
package_cache_path: Option<PathBuf>,
offline: bool,
#[cfg(feature = "egress")]
certificate: Option<PathBuf>,
clock: FilesystemPackAssemblyClock,
profile: FilesystemPackAssemblyProfile,
}
impl FilesystemPackAssemblerConfig {
pub fn new() -> Self {
Self {
font_paths: Vec::new(),
system_fonts: true,
typst_embedded_fonts: true,
package_path: None,
package_cache_path: None,
offline: false,
#[cfg(feature = "egress")]
certificate: None,
clock: FilesystemPackAssemblyClock::System,
profile: FilesystemPackAssemblyProfile::reference_v1(),
}
}
pub fn profile(mut self, profile: FilesystemPackAssemblyProfile) -> Self {
self.profile = profile;
self
}
pub fn clock(mut self, clock: FilesystemPackAssemblyClock) -> Self {
self.clock = clock;
self
}
pub fn font_path(mut self, path: impl Into<PathBuf>) -> Self {
self.font_paths.push(path.into());
self
}
pub fn system_fonts(mut self, system: bool) -> Self {
self.system_fonts = system;
self
}
pub fn typst_embedded_fonts(mut self, include: bool) -> Self {
self.typst_embedded_fonts = include;
self
}
pub fn package_path(mut self, path: impl Into<PathBuf>) -> Self {
self.package_path = Some(path.into());
self
}
pub fn package_cache_path(mut self, path: impl Into<PathBuf>) -> Self {
self.package_cache_path = Some(path.into());
self
}
pub fn offline(mut self, offline: bool) -> Self {
self.offline = offline;
self
}
#[cfg(feature = "egress")]
pub fn certificate(mut self, path: Option<PathBuf>) -> Self {
self.certificate = path;
self
}
}
impl Default for FilesystemPackAssemblerConfig {
fn default() -> Self {
Self::new()
}
}
pub struct FilesystemPackAssemblyRequest<'a> {
root: &'a Path,
entrypoint: &'a Path,
vendor_packages: bool,
embed_fonts: bool,
include_typst_embedded_fonts: bool,
inputs: Dict,
features: Vec<Feature>,
target: TypstTarget,
document_time: Option<DocumentTime>,
timings: Option<PathBuf>,
metadata: Option<PackMetadata>,
}
impl<'a> FilesystemPackAssemblyRequest<'a> {
pub fn new(root: &'a Path, entrypoint: &'a Path) -> Self {
Self {
root,
entrypoint,
vendor_packages: true,
embed_fonts: false,
include_typst_embedded_fonts: false,
inputs: Dict::new(),
features: Vec::new(),
target: TypstTarget::Paged,
document_time: None,
timings: None,
metadata: None,
}
}
pub fn vendor_packages(mut self, vendor: bool) -> Self {
self.vendor_packages = vendor;
self
}
pub fn embed_fonts(mut self, embed: bool) -> Self {
self.embed_fonts = embed;
self
}
pub fn include_typst_embedded_fonts(mut self, include: bool) -> Self {
self.include_typst_embedded_fonts = include;
self
}
pub fn inputs(mut self, inputs: Dict) -> Self {
self.inputs = inputs;
self
}
pub fn feature(mut self, feature: Feature) -> Self {
self.features.push(feature);
self
}
pub fn target(mut self, target: TypstTarget) -> Self {
self.target = target;
self
}
pub fn document_time(mut self, document_time: DocumentTime) -> Self {
self.document_time = Some(document_time);
self
}
pub fn timings(mut self, path: Option<PathBuf>) -> Self {
self.timings = path;
self
}
pub fn metadata(mut self, metadata: PackMetadata) -> Self {
self.metadata = Some(metadata);
self
}
}
pub struct FilesystemPackAssembler {
authority: FilesystemPackageAuthority,
font_paths: Vec<PathBuf>,
system_fonts: bool,
typst_embedded_fonts: bool,
clock: FilesystemPackAssemblyClock,
profile: FilesystemPackAssemblyProfile,
#[cfg(test)]
after_creation_hook: Option<Box<dyn Fn()>>,
}
impl FilesystemPackAssembler {
pub fn new(config: FilesystemPackAssemblerConfig) -> Self {
let authority = FilesystemPackageAuthority::with_limits(
config.package_path.as_deref(),
config.package_cache_path.as_deref(),
config.offline,
config.profile.packages,
#[cfg(feature = "egress")]
config.profile.package_expansion,
);
#[cfg(feature = "egress")]
let authority = authority.certificate(config.certificate);
Self {
authority,
font_paths: config.font_paths,
system_fonts: config.system_fonts,
typst_embedded_fonts: config.typst_embedded_fonts,
clock: config.clock,
profile: config.profile,
#[cfg(test)]
after_creation_hook: None,
}
}
#[cfg(test)]
pub(crate) fn after_creation_hook(mut self, hook: impl Fn() + 'static) -> Self {
self.after_creation_hook = Some(Box::new(hook));
self
}
pub fn assemble(
&self,
request: FilesystemPackAssemblyRequest<'_>,
) -> Result<PackAssemblyReport, FilesystemPackAssemblyError> {
let (result, timing_error) = self.assemble_with_timing(request);
timing_error.map_or(result, Err)
}
#[doc(hidden)]
pub fn assemble_with_timing(
&self,
request: FilesystemPackAssemblyRequest<'_>,
) -> (
Result<PackAssemblyReport, FilesystemPackAssemblyError>,
Option<FilesystemPackAssemblyError>,
) {
let mut timing_error = None;
let result = self.assemble_inner(request, &mut timing_error);
(result, timing_error)
}
fn assemble_inner(
&self,
request: FilesystemPackAssemblyRequest<'_>,
timing_error: &mut Option<FilesystemPackAssemblyError>,
) -> Result<PackAssemblyReport, FilesystemPackAssemblyError> {
let root = request.root.canonicalize().map_err(|err| {
FilesystemPackAssemblyError::io("failed to resolve project root", err)
})?;
let entrypoint_abs = if request.entrypoint.is_absolute() {
request.entrypoint.to_owned()
} else {
root.join(request.entrypoint)
};
let entrypoint_abs = entrypoint_abs
.canonicalize()
.map_err(|err| FilesystemPackAssemblyError::io("failed to resolve entrypoint", err))?;
let entrypoint = VirtualPath::virtualize(&root, &entrypoint_abs)
.map_err(|_| FilesystemPackAssemblyError::OutsideRoot(entrypoint_abs.clone()))?;
let snapshot = Arc::new(fs_project::read_filesystem_project(
&root,
entrypoint.get_without_slash(),
self.profile.project,
)?);
let packages = Arc::new(ReadPackages::new());
let scanned_disposition = FontDisposition::embedded_if(request.embed_fonts);
let mut font_sources = Vec::new();
if self.system_fonts {
font_sources.push(FilesystemFontSource::system(scanned_disposition));
}
#[cfg(feature = "embedded-fonts")]
if self.typst_embedded_fonts {
font_sources.push(FilesystemFontSource::typst_embedded(
FontDisposition::embedded_if(
request.embed_fonts && request.include_typst_embedded_fonts,
),
));
}
#[cfg(not(feature = "embedded-fonts"))]
let _ = (
self.typst_embedded_fonts,
request.include_typst_embedded_fonts,
);
font_sources.extend(
self.font_paths
.iter()
.map(|path| FilesystemFontSource::directory(path, scanned_disposition)),
);
let font_catalog = read_filesystem_fonts(font_sources, self.profile.fonts)?;
let document_time = request
.document_time
.unwrap_or_else(|| self.clock.document_time());
let discovery = DiscoverySpecification::new(
request.target,
request.inputs,
document_time,
request.features,
)
.map_err(|source| {
FilesystemPackAssemblyError::DiscoverySpecification(
FilesystemPackAssemblyDiscoveryError { source },
)
})?;
let mut world = ReadWorld {
root: root.clone(),
#[cfg(feature = "diagnostics")]
workdir: std::env::current_dir()
.ok()
.map(|path| path.canonicalize().unwrap_or(path)),
library: LazyHash::new(Library::builder().build()),
main: RootedPath::new(VirtualRoot::Project, entrypoint).intern(),
files: FileStore::new(ReadLoader {
project: Arc::clone(&snapshot),
packages: Arc::clone(&packages),
}),
fonts: FontStore::new(),
};
let disposition = if request.vendor_packages {
PackageDisposition::Embedded
} else {
PackageDisposition::External
};
let mut timer = typst_kit::timer::Timer::new_or_placeholder(request.timings);
let mut creation = None;
let timings = timer.record(&mut world, |_| {
creation = Some(resolve_and_create(
&snapshot,
&font_catalog,
&discovery,
request.metadata.as_ref(),
&self.authority,
&packages,
disposition,
));
});
let Some(creation) = creation else {
return Err(FilesystemPackAssemblyError::Timings(
timings
.expect_err("timer did not execute creation")
.to_string(),
));
};
*timing_error = timings
.err()
.map(|error| FilesystemPackAssemblyError::Timings(error.to_string()));
let (pack, warnings) = match creation {
Ok(created) => created,
Err(error) => return Err(error.into_assembly_error(world)),
};
#[cfg(test)]
if let Some(hook) = &self.after_creation_hook {
hook();
}
Ok(PackAssemblyReport {
pack,
warnings,
#[cfg(feature = "diagnostics")]
world,
})
}
}
impl FilesystemPackAssemblyClock {
fn document_time(self) -> DocumentTime {
match self {
Self::System => {
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |duration| duration.as_secs() as i64);
DocumentTime::UnixTimestamp(timestamp)
}
Self::Fixed(document_time) => document_time,
}
}
}
fn resolve_and_create(
project: &ProjectSnapshot,
fonts: &crate::font_catalog::FontCatalog,
discovery: &DiscoverySpecification,
metadata: Option<&PackMetadata>,
authority: &FilesystemPackageAuthority,
packages: &ReadPackages,
disposition: PackageDisposition,
) -> Result<(Pack, EcoVec<SourceDiagnostic>), CreationFailure> {
let mut attempted_specs: HashSet<String> = HashSet::new();
let mut package_failures = Vec::new();
let mut read_failures = PackageReadFailures::new();
let mut catalog = PackageCatalog::new();
loop {
let outcome = create(PackCreationInput {
project,
packages: &catalog,
fonts,
package_failures: &read_failures,
discovery,
metadata,
})
.map_err(|error| CreationFailure::Core {
error,
package_failures: std::mem::take(&mut package_failures),
})?;
match outcome {
PackCreationOutcome::Created { pack, warnings } => return Ok((pack, warnings)),
PackCreationOutcome::MissingPackageSpecifications(missing) => {
for spec in missing {
if !attempted_specs.insert(spec.to_string()) {
return Err(CreationFailure::Adapter(
FilesystemPackAssemblyError::Package {
message: "the representative creation compile did not accept the \
resolved package tree"
.to_owned(),
spec,
},
));
}
match authority.read(&spec) {
Ok(read) => {
let (tree, _) = read.into_parts();
packages.record(spec.clone(), tree.clone());
read_failures.remove(&spec);
catalog
.insert(spec.clone(), tree, disposition)
.map_err(FilesystemPackAssemblyError::InvalidPackageCatalog)?;
}
Err(error) => {
read_failures.insert(error.failure().clone());
package_failures.push(error);
}
}
}
}
}
}
}
enum CreationFailure {
Core {
error: PackCreationError,
package_failures: Vec<FilesystemPackageAuthorityReadError>,
},
Adapter(FilesystemPackAssemblyError),
}
impl CreationFailure {
fn into_assembly_error(self, world: ReadWorld) -> FilesystemPackAssemblyError {
match self {
Self::Adapter(error) => error,
Self::Core {
error,
package_failures,
} => FilesystemPackAssemblyError::Creation(FilesystemPackAssemblyCreationError {
context: Box::new(PackAssemblyDiagnosticContext { world }),
error,
package_failures,
}),
}
}
}
impl From<FilesystemPackAssemblyError> for CreationFailure {
fn from(error: FilesystemPackAssemblyError) -> Self {
Self::Adapter(error)
}
}
pub struct PackAssemblyReport {
pack: Pack,
warnings: EcoVec<SourceDiagnostic>,
#[cfg(feature = "diagnostics")]
pub(crate) world: ReadWorld,
}
impl PackAssemblyReport {
pub fn pack(&self) -> &Pack {
&self.pack
}
pub fn warnings(&self) -> &[SourceDiagnostic] {
&self.warnings
}
pub fn into_parts(self) -> (Pack, EcoVec<SourceDiagnostic>) {
(self.pack, self.warnings)
}
}
#[derive(Debug)]
pub struct PackAssemblyDiagnosticContext {
#[cfg_attr(not(feature = "diagnostics"), allow(dead_code))]
pub(crate) world: ReadWorld,
}
#[derive(Debug, thiserror::Error)]
#[error("{error}")]
pub struct FilesystemPackAssemblyCreationError {
context: Box<PackAssemblyDiagnosticContext>,
#[source]
error: PackCreationError,
package_failures: Vec<FilesystemPackageAuthorityReadError>,
}
impl FilesystemPackAssemblyCreationError {
pub fn context(&self) -> &PackAssemblyDiagnosticContext {
&self.context
}
pub fn error(&self) -> &PackCreationError {
&self.error
}
pub fn package_failures(&self) -> &[FilesystemPackageAuthorityReadError] {
&self.package_failures
}
pub fn into_parts(
self,
) -> (
Box<PackAssemblyDiagnosticContext>,
PackCreationError,
Vec<FilesystemPackageAuthorityReadError>,
) {
(self.context, self.error, self.package_failures)
}
}
#[derive(Debug, thiserror::Error)]
#[error("invalid Discovery Specification: {source}")]
pub struct FilesystemPackAssemblyDiscoveryError {
#[source]
source: crate::creation::DiscoverySpecificationError,
}
impl FilesystemPackAssemblyDiscoveryError {
pub fn source_error(&self) -> &crate::creation::DiscoverySpecificationError {
&self.source
}
pub fn into_source(self) -> crate::creation::DiscoverySpecificationError {
self.source
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum FilesystemPackAssemblyError {
#[error("{message}: {source}")]
Io {
message: String,
#[source]
source: std::io::Error,
},
#[error("`{0}` is outside the project root and cannot be packed")]
OutsideRoot(PathBuf),
#[error(transparent)]
Creation(FilesystemPackAssemblyCreationError),
#[error(transparent)]
DiscoverySpecification(FilesystemPackAssemblyDiscoveryError),
#[error("failed to write creation timings: {0}")]
Timings(String),
#[error("failed to load package {spec}: {message}")]
Package { spec: PackageSpec, message: String },
#[error(transparent)]
InvalidPackageCatalog(PackageCatalogError),
#[error(transparent)]
ProjectRead(#[from] fs_project::FilesystemProjectReadError),
#[error(transparent)]
FontRead(#[from] crate::fs_fonts::FilesystemFontReadError),
}
impl FilesystemPackAssemblyError {
pub(crate) fn io(message: &str, source: std::io::Error) -> Self {
Self::Io {
message: message.to_owned(),
source,
}
}
}
pub(crate) struct ReadWorld {
root: PathBuf,
#[cfg(feature = "diagnostics")]
workdir: Option<PathBuf>,
library: LazyHash<Library>,
main: FileId,
files: FileStore<ReadLoader>,
fonts: FontStore,
}
impl ReadWorld {
#[cfg(feature = "diagnostics")]
fn root(&self) -> &Path {
&self.root
}
#[cfg(feature = "diagnostics")]
fn workdir(&self) -> Option<&Path> {
self.workdir.as_deref()
}
}
impl fmt::Debug for ReadWorld {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReadWorld")
.field("root", &self.root)
.finish_non_exhaustive()
}
}
impl World for ReadWorld {
fn library(&self) -> &LazyHash<Library> {
&self.library
}
fn book(&self) -> &LazyHash<FontBook> {
self.fonts.book()
}
fn main(&self) -> FileId {
self.main
}
fn source(&self, id: FileId) -> FileResult<Source> {
self.files.source(id)
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
self.files.file(id)
}
fn font(&self, _index: usize) -> Option<Font> {
None
}
fn today(&self, _offset: Option<Duration>) -> Option<Datetime> {
None
}
}
#[cfg(feature = "diagnostics")]
impl typst_kit::diagnostics::DiagnosticWorld for ReadWorld {
fn name(&self, id: FileId) -> String {
match id.root() {
VirtualRoot::Project => id
.vpath()
.realize(self.root())
.ok()
.and_then(|path| relative_path(&path, self.workdir()?))
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_else(|| display_file_id(id)),
VirtualRoot::Package(_) => display_file_id(id),
}
}
}
#[cfg(feature = "diagnostics")]
fn display_file_id(id: FileId) -> String {
match id.root() {
VirtualRoot::Project => id.vpath().get_without_slash().to_owned(),
VirtualRoot::Package(spec) => format!("{spec}{}", id.vpath().get_with_slash()),
}
}
#[cfg(feature = "diagnostics")]
fn relative_path(path: &Path, base: &Path) -> Option<PathBuf> {
if path.is_absolute() != base.is_absolute() {
return path.is_absolute().then(|| path.to_path_buf());
}
let mut path_components = path.components();
let mut base_components = base.components();
let mut relative = Vec::new();
loop {
match (path_components.next(), base_components.next()) {
(None, None) => break,
(Some(component), None) => {
relative.push(component);
relative.extend(path_components.by_ref());
break;
}
(None, Some(_)) => relative.push(std::path::Component::ParentDir),
(Some(path), Some(base)) if relative.is_empty() && path == base => {}
(Some(path), Some(std::path::Component::CurDir)) => relative.push(path),
(Some(_), Some(std::path::Component::ParentDir)) => return None,
(Some(std::path::Component::Prefix(_) | std::path::Component::RootDir), Some(_))
| (Some(_), Some(std::path::Component::Prefix(_) | std::path::Component::RootDir)) => {
return path.is_absolute().then(|| path.to_path_buf());
}
(Some(path), Some(_)) => {
relative.push(std::path::Component::ParentDir);
relative.extend(base_components.map(|_| std::path::Component::ParentDir));
relative.push(path);
relative.extend(path_components.by_ref());
break;
}
}
}
Some(relative.iter().map(|part| part.as_os_str()).collect())
}
struct ReadLoader {
project: Arc<ProjectSnapshot>,
packages: Arc<ReadPackages>,
}
impl FileLoader for ReadLoader {
fn load(&self, id: FileId) -> FileResult<Bytes> {
let path = id.vpath().get_without_slash();
match id.root() {
VirtualRoot::Project => self.project.shared_file(path).map(|data| data.to_typst()),
VirtualRoot::Package(spec) => self.packages.file(spec, path),
}
.ok_or_else(|| FileError::NotFound(PathBuf::from(path)))
}
}