forked from bai/curriculum-project-hub
feat(checker): cph-typst — embed typst 0.15, driver-gen, compile, PDF (WU-4)
The heart of the pipeline. Embeds the typst 0.15 compiler as a library to compile-check a lesson and export a PDF, with diagnostics mapped back to source spans (the product's value). - LessonWorld: a typst World over the engineering-file directory tree. vpath = real relative path (ADR-0007); the per-target driver is mounted in-memory as `main` (.cph/driver-<target>.typ, never written to disk); @local/cph-render is resolved from render_dir (CPH_RENDER_DIR / with_render_dir), no package install or network. Fonts via typst-kit FontStore (system CJK). - Driver generation: from the manifest, per target — static root-relative `include` of each content file (⇒ module body content, ADR-0006), assembled into the frozen `display(info, target, parts)` call; optional lemma proof emitted only if proof.typ exists; example `source` scalar threaded through. - Diagnostics: typst SourceDiagnostic (errors + warnings) → cph_diag::Diagnostic. 0.15 DiagSpan handled via span.id() + WorldExt::range → file-relative SourceSpan with 1-based line/col; driver-internal spans re-pointed with a "generated driver" hint, never dropped. typst hints/trace carried through. - Engine API: new / with_render_dir / compile_check / build_pdf. - 6 tests incl. build_pdf_with_real_render — full World→driver→cph-render→PDF producing real PDF-1.7 output (student 42KB / teacher 54KB). clippy/fmt clean. Also fixes render/typst.toml `compiler = ">=0.15.0"` → `"0.15.0"`: typst 0.15's manifest parser rejects a `>=` comparator on the compiler field (the 0.14 CLU WU-2 tested against was lenient). This unblocks loading the real render package. Pinned: typst/typst-pdf/typst-syntax/typst-library/typst-layout/typst-kit =0.15.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
//! [`LessonWorld`] — a typst [`World`] over an engineering-file directory tree
|
||||
//! plus an in-memory generated driver and a disk-mounted `cph-render` package.
|
||||
//!
|
||||
//! ## File resolution
|
||||
//!
|
||||
//! - **The driver** (`main()`): mounted in-memory at `VirtualRoot::Project` +
|
||||
//! vpath `/.cph/driver-<target>.typ`. `source()` returns it from the field;
|
||||
//! it is never written to disk. Its root-relative `include "/segments/…"`
|
||||
//! paths therefore anchor at the lesson root.
|
||||
//! - **Project files**: any `FileId` with `VirtualRoot::Project` is read from
|
||||
//! `root.join(vpath.realize)`. Used for content `.typ`, images, `toml()`, etc.
|
||||
//! - **`@local/cph-render`**: a `FileId` whose root is the render package spec
|
||||
//! is read from `render_dir.join(vpath.realize)`. This avoids installing the
|
||||
//! package into the typst local-package directory (MVP: no environment
|
||||
//! coupling). Any *other* `@package` id yields a `NotFound` error (the MVP
|
||||
//! render package has no external `@preview` dependencies).
|
||||
//!
|
||||
//! Sources are cached in a `Mutex<HashMap<FileId, Source>>` so repeated
|
||||
//! accesses during one compilation are cheap, as the `World` contract expects.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use typst::diag::{FileError, FileResult};
|
||||
use typst::foundations::{Bytes, Datetime, Duration};
|
||||
use typst::syntax::package::{PackageSpec, PackageVersion};
|
||||
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
|
||||
use typst::text::{Font, FontBook};
|
||||
use typst::utils::LazyHash;
|
||||
use typst::{Library, LibraryExt, World};
|
||||
use typst_kit::fonts::FontStore;
|
||||
|
||||
use crate::driver::DRIVER_DIR;
|
||||
|
||||
/// The package spec the driver imports and the World mounts from `render_dir`.
|
||||
pub fn render_package_spec() -> PackageSpec {
|
||||
PackageSpec {
|
||||
namespace: "local".into(),
|
||||
name: "cph-render".into(),
|
||||
version: PackageVersion {
|
||||
major: 0,
|
||||
minor: 1,
|
||||
patch: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A typst `World` for one lesson + target compilation.
|
||||
pub struct LessonWorld {
|
||||
/// Engineering-file root (the typst project root).
|
||||
root: PathBuf,
|
||||
/// On-disk location of the `cph-render` package.
|
||||
render_dir: PathBuf,
|
||||
/// The render package spec (`@local/cph-render:0.1.0`).
|
||||
render_spec: PackageSpec,
|
||||
/// FileId of the in-memory driver entrypoint.
|
||||
main: FileId,
|
||||
/// The driver source, parsed once.
|
||||
driver_source: Source,
|
||||
/// Standard library (default configuration).
|
||||
library: LazyHash<Library>,
|
||||
/// Shared font store (book + lazily-loaded fonts).
|
||||
fonts: Arc<FontStore>,
|
||||
/// Source cache for on-disk files.
|
||||
sources: Mutex<HashMap<FileId, Source>>,
|
||||
}
|
||||
|
||||
impl LessonWorld {
|
||||
/// Build a world. `driver_src` is the generated entrypoint text; it is
|
||||
/// mounted in-memory at `/.cph/driver-<target>.typ` under the project root.
|
||||
pub fn new(
|
||||
root: PathBuf,
|
||||
render_dir: PathBuf,
|
||||
target: &str,
|
||||
driver_src: String,
|
||||
fonts: Arc<FontStore>,
|
||||
) -> Self {
|
||||
let driver_vpath = VirtualPath::new(format!("/{DRIVER_DIR}/driver-{target}.typ"))
|
||||
.expect("driver vpath is a valid virtual path");
|
||||
let main = FileId::new(RootedPath::new(VirtualRoot::Project, driver_vpath));
|
||||
let driver_source = Source::new(main, driver_src);
|
||||
|
||||
Self {
|
||||
root,
|
||||
render_dir,
|
||||
render_spec: render_package_spec(),
|
||||
main,
|
||||
driver_source,
|
||||
library: LazyHash::new(Library::default()),
|
||||
fonts,
|
||||
sources: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a `FileId` to its on-disk path, honoring the project / render-package
|
||||
/// roots. Returns a `FileError` for any other package root.
|
||||
fn resolve(&self, id: FileId) -> FileResult<PathBuf> {
|
||||
let vpath = id.vpath();
|
||||
match id.root() {
|
||||
VirtualRoot::Project => vpath.realize(&self.root).map_err(Into::into),
|
||||
VirtualRoot::Package(spec) if *spec == self.render_spec => {
|
||||
vpath.realize(&self.render_dir).map_err(Into::into)
|
||||
}
|
||||
VirtualRoot::Package(spec) => Err(FileError::Package(
|
||||
typst::diag::PackageError::NotFound(spec.clone()),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Read raw bytes for `id` from disk (driver excluded — handled by callers).
|
||||
fn read_bytes(&self, id: FileId) -> FileResult<Vec<u8>> {
|
||||
let path = self.resolve(id)?;
|
||||
std::fs::read(&path).map_err(|e| FileError::from_io(e, &path))
|
||||
}
|
||||
}
|
||||
|
||||
impl World for LessonWorld {
|
||||
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> {
|
||||
if id == self.main {
|
||||
return Ok(self.driver_source.clone());
|
||||
}
|
||||
// Cache hit?
|
||||
if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) {
|
||||
return Ok(src.clone());
|
||||
}
|
||||
let bytes = self.read_bytes(id)?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| FileError::InvalidUtf8)?;
|
||||
let source = Source::new(id, text);
|
||||
self.sources
|
||||
.lock()
|
||||
.expect("sources mutex")
|
||||
.insert(id, source.clone());
|
||||
Ok(source)
|
||||
}
|
||||
|
||||
fn file(&self, id: FileId) -> FileResult<Bytes> {
|
||||
if id == self.main {
|
||||
return Ok(Bytes::from_string(self.driver_source.text().to_string()));
|
||||
}
|
||||
let bytes = self.read_bytes(id)?;
|
||||
Ok(Bytes::new(bytes))
|
||||
}
|
||||
|
||||
fn font(&self, index: usize) -> Option<Font> {
|
||||
self.fonts.font(index)
|
||||
}
|
||||
|
||||
fn today(&self, _offset: Option<Duration>) -> Option<Datetime> {
|
||||
// No clock dependence needed; lessons are reproducible.
|
||||
None
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user