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:
2026-06-22 01:49:15 +08:00
parent c73a2c903f
commit 7e76482a31
19 changed files with 3812 additions and 7 deletions
+110
View File
@@ -0,0 +1,110 @@
//! Map typst [`SourceDiagnostic`]s to [`cph_diag::Diagnostic`]s.
//!
//! ## Span mapping (typst 0.15 specifics)
//!
//! In 0.15 a diagnostic's `span` is a [`typst::syntax::DiagSpan`] (not the bare
//! `Span` of 0.14). To locate it we:
//! 1. `span.id() -> Option<FileId>` for the owning file (`None` ⇒ detached, so
//! no span is emitted);
//! 2. `WorldExt::range(span) -> Option<Range<usize>>` for the byte range — this
//! handles every `DiagSpanKind` variant internally (numbered source spans
//! *and* raw external byte ranges), so we never match the enum by hand;
//! 3. compute 1-based line/col from the file's [`typst::syntax::Source`] via
//! `lines().byte_to_line_column` (0-based ⇒ `+1`).
//!
//! A span whose `FileId` is the generated **driver** is re-pointed: its `file`
//! is set to the driver vpath and a hint flags it as driver-internal (a
//! driver-gen bug indicator) rather than dropped or mis-attributed to a user
//! file. Spans in the `cph-render` package are reported with an
//! `@local/cph-render:…` pseudo-path.
use std::path::PathBuf;
use cph_diag::{DiagCode, Diagnostic, Severity, SourceSpan};
use typst::diag::{Severity as TypstSeverity, SourceDiagnostic};
use typst::syntax::{FileId, VirtualRoot};
use typst::{World, WorldExt};
use crate::world::LessonWorld;
/// Convert one typst diagnostic into a `cph-diag` [`Diagnostic`], resolving its
/// span against `world`.
pub fn map_diagnostic(world: &LessonWorld, d: &SourceDiagnostic) -> Diagnostic {
let severity = match d.severity {
TypstSeverity::Error => Severity::Error,
TypstSeverity::Warning => Severity::Warning,
};
// Flatten the typst call trace into the message tail so call-site context
// isn't lost (the primary span points at the innermost node).
let mut message = d.message.to_string();
for tp in &d.trace {
message.push_str(&format!("\n in {}", tp.v));
}
let mut diag = Diagnostic {
severity,
code: DiagCode::TypstCompile,
message,
span: None,
hint: None,
};
// typst's own hints are the product's core value — carry them through.
let mut hints: Vec<String> = d.hints.iter().map(|h| h.v.to_string()).collect();
if let Some(id) = d.span.id() {
if let Some(file) = file_for(world, id) {
if id == world.main() {
hints.push(
"this span is inside the generated driver (.cph/driver-*.typ), \
not a lesson source file — likely a driver-generation issue"
.to_string(),
);
}
// Byte range via WorldExt::range (DiagSpan is Copy).
let byte_range = world.range(d.span).unwrap_or(0..0);
let (line, col) = line_col(world, id, byte_range.start);
diag.span = Some(SourceSpan {
file,
byte_range,
line,
col,
});
}
}
if !hints.is_empty() {
diag.hint = Some(hints.join("; "));
}
diag
}
/// 1-based (line, col) for a byte offset in file `id`, defaulting to `(1, 1)`
/// when the source can't be read or the offset is out of range.
fn line_col(world: &LessonWorld, id: FileId, byte: usize) -> (u32, u32) {
let Ok(source) = world.source(id) else {
return (1, 1);
};
match source.lines().byte_to_line_column(byte) {
// byte_to_line_column is 0-based; editors are 1-based.
Some((line, col)) => (line as u32 + 1, col as u32 + 1),
None => (1, 1),
}
}
/// File path for a `FileId`. Project files are relative to the lesson root (no
/// leading slash); package files use an `@namespace/name:version/path` form.
fn file_for(world: &LessonWorld, id: FileId) -> Option<PathBuf> {
let _ = world;
match id.root() {
VirtualRoot::Project => Some(PathBuf::from(id.vpath().get_without_slash())),
VirtualRoot::Package(spec) => Some(PathBuf::from(format!(
"@{}/{}:{}{}",
spec.namespace,
spec.name,
spec.version,
id.vpath().get_with_slash()
))),
}
}