1use std::collections::{BTreeMap, BTreeSet};
10use std::sync::Mutex;
11
12use ecow::EcoVec;
13use typst::diag::{FileError, FileResult, PackageError, SourceDiagnostic, Warned};
14use typst::foundations::{Bytes, Datetime, Dict, Duration};
15use typst::syntax::package::PackageSpec;
16use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
17use typst::text::{Font, FontBook};
18use typst::utils::LazyHash;
19use typst::{Feature, Library, LibraryExt, World};
20use typst_kit::datetime::Time;
21use typst_kit::files::{FileLoader, FileStore};
22
23use crate::domain::{DocumentTime, TypstTarget};
24use crate::embedded::EmbeddedTypst;
25use crate::font_catalog::{CatalogFonts, FontCatalog, FontDisposition};
26use crate::manifest::PackMetadata;
27use crate::pack::{Pack, PackBuildError, PackInvariantError};
28use crate::package_catalog::PackageCatalog;
29use crate::package_failure::{PackageReadFailure, PackageReadFailureReason, PackageReadFailures};
30use crate::payload::SharedBytes;
31use crate::project_snapshot::ProjectSnapshot;
32
33#[derive(Clone, Debug)]
38pub struct DiscoverySpecification {
39 target: TypstTarget,
40 inputs: Dict,
41 document_time: DocumentTime,
42 features: Vec<Feature>,
43}
44
45impl DiscoverySpecification {
46 pub fn new(
48 target: TypstTarget,
49 inputs: Dict,
50 document_time: DocumentTime,
51 features: impl IntoIterator<Item = Feature>,
52 ) -> Result<Self, DiscoverySpecificationError> {
53 if let DocumentTime::UnixTimestamp(timestamp) = document_time
54 && Time::fixed_timestamp(timestamp).is_err()
55 {
56 return Err(DiscoverySpecificationError::InvalidDocumentTimestamp);
57 }
58 Ok(Self {
59 target,
60 inputs,
61 document_time,
62 features: features.into_iter().collect(),
63 })
64 }
65
66 pub fn target(&self) -> TypstTarget {
68 self.target
69 }
70
71 pub fn inputs(&self) -> &Dict {
73 &self.inputs
74 }
75
76 pub fn document_time(&self) -> DocumentTime {
78 self.document_time
79 }
80
81 pub fn features(&self) -> &[Feature] {
83 &self.features
84 }
85}
86
87#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
89#[non_exhaustive]
90pub enum DiscoverySpecificationError {
91 #[error("the discovery document-time UNIX timestamp is out of range")]
92 InvalidDocumentTimestamp,
93}
94
95#[derive(Clone, Copy, Debug)]
97pub struct PackCreationInput<'a> {
98 pub project: &'a ProjectSnapshot,
100 pub packages: &'a PackageCatalog,
102 pub fonts: &'a FontCatalog,
104 pub package_failures: &'a PackageReadFailures,
106 pub discovery: &'a DiscoverySpecification,
108 pub metadata: Option<&'a PackMetadata>,
110}
111
112#[derive(Debug)]
114#[allow(clippy::large_enum_variant)] pub enum PackCreationOutcome {
116 Created {
119 pack: Pack,
120 warnings: EcoVec<SourceDiagnostic>,
121 },
122 MissingPackageSpecifications(Vec<PackageSpec>),
126}
127
128#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct DependencyDiscoveryRejection {
131 diagnostics: EcoVec<SourceDiagnostic>,
132 warnings: EcoVec<SourceDiagnostic>,
133}
134
135impl DependencyDiscoveryRejection {
136 pub fn diagnostics(&self) -> &[SourceDiagnostic] {
138 &self.diagnostics
139 }
140
141 pub fn warnings(&self) -> &[SourceDiagnostic] {
143 &self.warnings
144 }
145
146 pub fn into_parts(self) -> (EcoVec<SourceDiagnostic>, EcoVec<SourceDiagnostic>) {
148 (self.diagnostics, self.warnings)
149 }
150}
151
152#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
154#[non_exhaustive]
155pub enum PackCreationError {
156 #[error(
159 "dependency discovery was rejected with {} diagnostic(s)",
160 .0.diagnostics.len()
161 )]
162 DependencyDiscoveryRejected(DependencyDiscoveryRejection),
163 #[error(transparent)]
165 InvalidPack(#[from] PackInvariantError),
166}
167
168pub fn create(input: PackCreationInput<'_>) -> Result<PackCreationOutcome, PackCreationError> {
232 let entrypoint = VirtualPath::new(input.project.entrypoint())
233 .expect("Project Snapshot entrypoint invariant violated");
234
235 let mut world = SuppliedWorld {
236 library: LazyHash::new(
237 Library::builder()
238 .with_inputs(input.discovery.inputs.clone())
239 .with_features(input.discovery.features.iter().copied().collect())
240 .build(),
241 ),
242 main: RootedPath::new(VirtualRoot::Project, entrypoint).intern(),
243 files: FileStore::new(SuppliedLoader {
244 project: input.project,
245 packages: input.packages,
246 package_failures: input.package_failures,
247 }),
248 fonts: input.fonts.expand(),
249 used_font_indices: Mutex::new(BTreeSet::new()),
250 clock: DiscoveryClock::new(input.discovery.document_time),
251 };
252
253 let Warned { output, warnings } = compile_creation_target(&world, input.discovery.target);
254 let observed = world.observed_packages();
255
256 if !observed.missing.is_empty() {
260 return Ok(PackCreationOutcome::MissingPackageSpecifications(
261 observed.missing,
262 ));
263 }
264
265 if let Err(diagnostics) = output {
266 return Err(PackCreationError::DependencyDiscoveryRejected(
267 DependencyDiscoveryRejection {
268 diagnostics,
269 warnings,
270 },
271 ));
272 }
273
274 let mut builder = Pack::builder(input.project.entrypoint());
275 for (path, data) in input.project.shared_files() {
276 builder = map_build(builder.shared_file(path, data.clone()))?;
277 }
278
279 let loader = world.files.loader();
282 for spec in observed.supplied {
283 let entry = loader
284 .packages
285 .get(&spec)
286 .expect("observed package was partitioned as supplied");
287 for (path, data) in entry.tree().shared_files() {
288 builder = if entry.disposition().is_embedded() {
289 map_build(builder.shared_package_file(spec.clone(), path, data.clone()))?
290 } else {
291 map_build(builder.shared_external_package_file(spec.clone(), path, data.clone()))?
292 };
293 }
294 }
295
296 for (font, disposition) in world.used_fonts() {
299 builder =
300 if disposition.is_embedded() {
301 map_build(
302 builder.shared_font(SharedBytes::from_typst(font.data().clone()), font.index()),
303 )?
304 } else {
305 map_build(builder.shared_external_font(
306 SharedBytes::from_typst(font.data().clone()),
307 font.index(),
308 ))?
309 };
310 }
311
312 if let Some(metadata) = input.metadata {
313 builder = builder.metadata(metadata.clone());
314 }
315
316 Ok(PackCreationOutcome::Created {
317 pack: map_build(builder.build())?,
318 warnings,
319 })
320}
321
322fn map_build<T>(result: Result<T, PackBuildError>) -> Result<T, PackCreationError> {
323 match result {
324 Ok(value) => Ok(value),
325 Err(PackBuildError::Invariant(error)) => Err(PackCreationError::InvalidPack(error)),
326 }
327}
328
329#[derive(Default)]
332struct ObservedPackages {
333 supplied: Vec<PackageSpec>,
336 missing: Vec<PackageSpec>,
339}
340
341struct SuppliedWorld<'a> {
344 library: LazyHash<Library>,
345 main: FileId,
346 files: FileStore<SuppliedLoader<'a>>,
347 fonts: CatalogFonts,
348 used_font_indices: Mutex<BTreeSet<usize>>,
349 clock: DiscoveryClock,
350}
351
352impl SuppliedWorld<'_> {
353 fn observed_packages(&mut self) -> ObservedPackages {
360 let mut specs: BTreeMap<String, PackageSpec> = BTreeMap::new();
361 let (loader, dependencies) = self.files.dependencies();
362 for id in dependencies {
363 if let VirtualRoot::Package(spec) = id.root() {
364 specs.insert(spec.to_string(), spec.clone());
365 }
366 }
367
368 let mut observed = ObservedPackages::default();
369 for spec in specs.into_values() {
370 if loader.packages.get(&spec).is_some() {
371 observed.supplied.push(spec);
372 } else if loader.package_failures.get(&spec).is_none() {
373 observed.missing.push(spec);
374 }
375 }
380 observed
381 }
382
383 fn used_fonts(&self) -> Vec<(Font, FontDisposition)> {
386 self.used_font_indices
387 .lock()
388 .expect("used font index lock poisoned")
389 .iter()
390 .filter_map(|index| Some((self.fonts.font(*index)?, self.fonts.disposition(*index)?)))
391 .collect()
392 }
393}
394
395impl World for SuppliedWorld<'_> {
396 fn library(&self) -> &LazyHash<Library> {
397 &self.library
398 }
399
400 fn book(&self) -> &LazyHash<FontBook> {
401 self.fonts.book()
402 }
403
404 fn main(&self) -> FileId {
405 self.main
406 }
407
408 fn source(&self, id: FileId) -> FileResult<Source> {
409 self.files.source(id)
410 }
411
412 fn file(&self, id: FileId) -> FileResult<Bytes> {
413 self.files.file(id)
414 }
415
416 fn font(&self, index: usize) -> Option<Font> {
417 let font = self.fonts.font(index);
418 if font.is_some() {
419 self.used_font_indices
420 .lock()
421 .expect("used font index lock poisoned")
422 .insert(index);
423 }
424 font
425 }
426
427 fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
428 self.clock.today(offset)
429 }
430}
431
432enum DiscoveryClock {
433 None,
434 Fixed(Datetime),
435 Timestamp(Time),
436}
437
438impl DiscoveryClock {
439 fn new(document_time: DocumentTime) -> Self {
440 match document_time {
441 DocumentTime::Absent => Self::None,
442 DocumentTime::Fixed(datetime) => Self::Fixed(datetime),
443 DocumentTime::UnixTimestamp(timestamp) => Self::Timestamp(
444 Time::fixed_timestamp(timestamp)
445 .expect("Discovery Specification validated its Document Time"),
446 ),
447 }
448 }
449
450 fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
451 match self {
452 Self::None => None,
453 Self::Fixed(datetime) => Some(*datetime),
454 Self::Timestamp(time) => time.today(offset),
455 }
456 }
457}
458
459struct SuppliedLoader<'a> {
461 project: &'a ProjectSnapshot,
462 packages: &'a PackageCatalog,
463 package_failures: &'a PackageReadFailures,
464}
465
466impl FileLoader for SuppliedLoader<'_> {
467 fn load(&self, id: FileId) -> FileResult<Bytes> {
468 let path = id.vpath().get_without_slash();
469 match id.root() {
470 VirtualRoot::Project => self
471 .project
472 .shared_file(path)
473 .map(|data| data.to_typst())
474 .ok_or_else(|| FileError::NotFound(path.into())),
475 VirtualRoot::Package(spec) => {
476 let Some(entry) = self.packages.get(spec) else {
477 return Err(FileError::Package(
480 self.package_failures
481 .get(spec)
482 .map(package_failure_for_discovery)
483 .unwrap_or_else(|| PackageError::NotFound(spec.clone())),
484 ));
485 };
486 entry
487 .tree()
488 .shared_file(path)
489 .map(SharedBytes::to_typst)
490 .ok_or_else(|| FileError::NotFound(path.into()))
491 }
492 }
493 }
494}
495
496fn package_failure_for_discovery(failure: &PackageReadFailure) -> PackageError {
497 let spec = failure.spec().clone();
498 match failure.reason() {
499 PackageReadFailureReason::NotFound => PackageError::NotFound(spec),
500 PackageReadFailureReason::VersionNotFound { latest } => {
501 PackageError::VersionNotFound(spec, *latest)
502 }
503 PackageReadFailureReason::NetworkFailed { detail } => {
504 PackageError::NetworkFailed(detail.clone().map(Into::into))
505 }
506 PackageReadFailureReason::MalformedArchive { detail } => {
507 PackageError::MalformedArchive(detail.clone().map(Into::into))
508 }
509 PackageReadFailureReason::Other { detail } => {
510 PackageError::Other(detail.clone().map(Into::into))
511 }
512 }
513}
514
515fn compile_creation_target(
518 world: &dyn World,
519 target: TypstTarget,
520) -> Warned<Result<(), EcoVec<SourceDiagnostic>>> {
521 match target {
522 TypstTarget::Paged => {
523 let Warned { output, warnings } = EmbeddedTypst::compile_paged(world);
524 Warned {
525 output: output.map(|_| ()),
526 warnings,
527 }
528 }
529 TypstTarget::Html => {
530 let Warned { output, warnings } = EmbeddedTypst::compile_html(world);
531 Warned {
532 output: output.map(|_| ()),
533 warnings,
534 }
535 }
536 }
537}