forked from EduCraft/curriculum-project-hub
bd1699a4c0
Integrate the WU-B (structured targets) and WU-C (numbly, config) changes. cph-typst: - World resolves any @preview/* package from the in-repo vendored dir (<render_dir>/vendor/typst-packages/preview/<name>/<version>/), fully offline — no typst-kit download. Proven: build_pdf_with_real_render compiles the real render package (which imports @preview/numbly:0.1.0) through the embedded engine, PDF out. - Driver threads each target's presentation config: looks up the TargetConfig by name, emits `config: (numbering: (heading: (...)))` (escaped) when an override is present, else `config: (:)` (render default). - target_precheck guards artifact/target: FileTree → blocking diagnostic (MVP is single-file, deferred per ADR-0009); --target not declared → blocking diagnostic; no-targets lesson still defaults through. cph-check: - Migrated to Lesson.targets: Vec<TargetConfig> (target_names() / target.name at the 6 sites). Pipeline unchanged (already matches ADR-0010 phases). Documented that !has_errors() implements Spec.Courseware.Legal (no error-level diagnostic). Workspace: fmt + clippy -D warnings + all tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
191 lines
7.6 KiB
Rust
191 lines
7.6 KiB
Rust
//! `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-<target>.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<FontStore>,
|
|
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 `<repo>/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<Diagnostic> {
|
|
if let Some(blocking) = target_precheck(lesson, target) {
|
|
return blocking;
|
|
}
|
|
let world = self.world_for(lesson, target);
|
|
let warned = typst::compile::<PagedDocument>(&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<u8>, Vec<Diagnostic>> {
|
|
if let Some(blocking) = target_precheck(lesson, target) {
|
|
return Err(blocking);
|
|
}
|
|
let world = self.world_for(lesson, target);
|
|
let warned = typst::compile::<PagedDocument>(&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<Vec<Diagnostic>> {
|
|
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<Diagnostic> {
|
|
diags
|
|
.iter()
|
|
.map(|d| diag::map_diagnostic(world, d))
|
|
.collect()
|
|
}
|
|
|
|
/// `<crate>/../../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")
|
|
}
|