//! `cph-typst` — typst `World`, driver generation, compile, PDF, span mapping. //! //! Owned by **WU-4**. Embeds the typst 0.15 compiler as a library to: //! - **compile-check** a [`cph_model::Lesson`] (collect typst errors+warnings as //! [`cph_diag::Diagnostic`]s), and //! - **export a PDF** for a render target. //! //! The lesson's engineering-file directory tree (ADR-0007) is mounted as the //! typst project root; a generated *driver* entrypoint (one per target) lives //! in-memory under `.cph/driver-.typ` and `include`s each part's //! content files (ADR-0006: a `content` value denotes the module **body**, so //! the driver uses `include`, never `import`). The driver hands an ordered //! `parts` array to the `@local/cph-render` package's `display` entry. //! //! ## typst version //! //! Pinned to the `typst` family `=0.15.0`. The 0.15 path/file API differs from //! 0.14: `FileId::new(RootedPath)` where `RootedPath::new(VirtualRoot, //! VirtualPath)`, and a diagnostic span is a `DiagSpan` (not a bare `Span`). mod diag; mod driver; mod world; use std::path::PathBuf; use cph_diag::{DiagCode, Diagnostic}; use cph_model::{ArtifactKind, Lesson}; use typst_kit::fonts::{self, FontStore}; use typst_layout::PagedDocument; use typst_pdf::PdfOptions; pub use driver::{generate_driver, DRIVER_DIR}; pub use world::{render_package_spec, LessonWorld}; /// The compile/PDF engine: holds the shared font store and the on-disk location /// of the `cph-render` package. /// /// Fonts are discovered once (system + embedded) and shared across every /// compilation via an [`std::sync::Arc`] inside the per-compile [`LessonWorld`]. pub struct Engine { fonts: std::sync::Arc, render_dir: PathBuf, } impl Engine { /// Construct an engine, discovering system + embedded fonts. The render /// package directory is taken from the `CPH_RENDER_DIR` environment /// variable when set, otherwise defaults to `/render` resolved /// relative to this crate (`CARGO_MANIFEST_DIR/../../render`). pub fn new() -> Self { Self::with_render_dir(default_render_dir()) } /// Construct an engine pointing at an explicit `cph-render` package /// directory (the folder containing `lib.typ` / `typst.toml`). pub fn with_render_dir(render_dir: PathBuf) -> Self { let mut store = FontStore::new(); // System fonts first (so the host's CJK fonts are preferred), then the // embedded fallbacks. `scan-fonts` / `embedded-fonts` features gate // these iterators. store.extend(fonts::system()); store.extend(fonts::embedded()); Self { fonts: std::sync::Arc::new(store), render_dir, } } /// The directory this engine resolves `@local/cph-render` from. pub fn render_dir(&self) -> &std::path::Path { &self.render_dir } /// Compile-check `lesson` for `target`, returning every typst diagnostic /// (errors **and** warnings) mapped to [`Diagnostic`]s. An empty `Vec` means /// a clean compile. Spans are resolved to files relative to `lesson.root`; /// spans landing in the generated driver are re-pointed with a hint marking /// them as driver-internal (a driver-gen bug indicator) rather than dropped. pub fn compile_check(&self, lesson: &Lesson, target: &str) -> Vec { if let Some(blocking) = target_precheck(lesson, target) { return blocking; } let world = self.world_for(lesson, target); let warned = typst::compile::(&world); let mut out = Vec::new(); if let Err(errors) = &warned.output { out.extend(map_all(&world, errors)); } out.extend(map_all(&world, &warned.warnings)); out } /// Build a PDF for `lesson` / `target`. On a clean compile returns the PDF /// bytes; on fatal errors returns the mapped diagnostics. Compile warnings /// are not surfaced here (use [`Engine::compile_check`] for those). pub fn build_pdf(&self, lesson: &Lesson, target: &str) -> Result, Vec> { if let Some(blocking) = target_precheck(lesson, target) { return Err(blocking); } let world = self.world_for(lesson, target); let warned = typst::compile::(&world); let doc = match warned.output { Ok(doc) => doc, Err(errors) => return Err(map_all(&world, &errors)), }; typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors)) } fn world_for(&self, lesson: &Lesson, target: &str) -> LessonWorld { let driver_src = generate_driver(lesson, target); LessonWorld::new( lesson.root.clone(), self.render_dir.clone(), target, driver_src, self.fonts.clone(), ) } } impl Default for Engine { fn default() -> Self { Self::new() } } /// Validate a `(lesson, target)` request before compiling. Returns /// `Some(blocking_diagnostics)` when the request cannot be honored, or `None` /// when it is fine to proceed: /// /// - **Unknown target** (the `--target` name isn't in `lesson.targets`): a /// `SchemaViolation` error — a target must be declared in the manifest to be /// built (ADR-0009: an export target is a declared build). /// - **`FileTree` artifact**: deferred for MVP. ADR-0009 makes the artifact kind /// decide assembly; only `SingleFile` (one bundled PDF) is implemented, so a /// `FileTree` target returns a clear `SchemaViolation` rather than wrong /// output. `SingleFile` proceeds to the normal one-PDF path. /// /// A target with **no** declared `[targets.*]` config at all is *not* an error /// here: callers (e.g. `cph-check`) may compile-check a defaulted `"student"` /// target that the lesson never declared. Only a name that *is* declared but is /// `FileTree` is blocked; an undeclared name is the "unknown target" error. fn target_precheck(lesson: &Lesson, target: &str) -> Option> { match lesson.targets.iter().find(|t| t.name == target) { None if lesson.targets.is_empty() => { // Lesson declares no targets; the orchestrator compiles a defaulted // target. Proceed with render-package defaults (SingleFile). None } None => Some(vec![Diagnostic::error( DiagCode::SchemaViolation, format!("target '{target}' not declared in manifest"), ) .with_hint(format!( "add a `[targets.{target}]` table to manifest.toml, or build a declared target" ))]), Some(tc) => match tc.artifact { ArtifactKind::SingleFile => None, ArtifactKind::FileTree => Some(vec![Diagnostic::error( DiagCode::SchemaViolation, "file-tree artifact not yet implemented (MVP supports single-file)", ) .with_hint(format!( "set `[targets.{target}].artifact` to \"single-file\" for now" ))]), }, } } /// Map a batch of typst diagnostics to `cph-diag` ones against `world`. fn map_all(world: &LessonWorld, diags: &[typst::diag::SourceDiagnostic]) -> Vec { diags .iter() .map(|d| diag::map_diagnostic(world, d)) .collect() } /// `/../../render` — the repo's render package, used when `CPH_RENDER_DIR` /// is unset. fn default_render_dir() -> PathBuf { if let Ok(dir) = std::env::var("CPH_RENDER_DIR") { return PathBuf::from(dir); } PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") .join("..") .join("render") }