forked from EduCraft/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:
Generated
+2918
File diff suppressed because it is too large
Load Diff
@@ -5,3 +5,20 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cph-diag = { workspace = true }
|
||||
cph-model = { workspace = true }
|
||||
toml = { workspace = true }
|
||||
|
||||
typst = "=0.15.0"
|
||||
typst-layout = "=0.15.0"
|
||||
typst-pdf = "=0.15.0"
|
||||
typst-syntax = "=0.15.0"
|
||||
typst-library = "=0.15.0"
|
||||
typst-kit = { version = "=0.15.0", features = [
|
||||
"scan-fonts",
|
||||
"embedded-fonts",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
cph-model = { workspace = true }
|
||||
tempfile = "3"
|
||||
|
||||
@@ -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()
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Driver generation: turn a [`Lesson`] + target into a typst entrypoint that
|
||||
//! `include`s each part's content files and calls `cph-render`'s `display`.
|
||||
//!
|
||||
//! ## Decisions
|
||||
//!
|
||||
//! - **Include-path style: root-relative (`/segments/…`).** The lesson root is
|
||||
//! mounted as the typst project root ([`crate::world::LessonWorld`] uses
|
||||
//! `VirtualRoot::Project`), so a leading-`/` path resolves against the root
|
||||
//! regardless of where the driver itself sits. This keeps include paths
|
||||
//! identical to the part `path`s recorded in the manifest, independent of the
|
||||
//! driver's `.cph/` location.
|
||||
//! - **Content via `include`, not `import`** (ADR-0006): a `content` field's
|
||||
//! value is the module *body*, which `include` yields (`import` would expose
|
||||
//! only `#let` exports).
|
||||
//! - **`@local/cph-render`** is referenced by the standard package import; the
|
||||
//! World resolves it from the render directory (see `crate::world`).
|
||||
//! - **Optional `proof`** (lemma) is emitted only when `<part.dir>/proof.typ`
|
||||
//! exists on disk.
|
||||
//! - **Scalars** (example `source`) come from `part.descriptor.scalars` and are
|
||||
//! emitted as escaped typst string literals.
|
||||
|
||||
use cph_model::{Lesson, Part};
|
||||
|
||||
/// In-memory directory the generated driver is mounted under (relative to the
|
||||
/// lesson root). Not written to disk.
|
||||
pub const DRIVER_DIR: &str = ".cph";
|
||||
|
||||
/// The package import line the driver opens with.
|
||||
const RENDER_IMPORT: &str = "#import \"@local/cph-render:0.1.0\": display";
|
||||
|
||||
/// The content fields of each kind, in render-contract order. The `bool` is
|
||||
/// whether the field is optional (only emitted if its `<field>.typ` exists).
|
||||
fn content_fields(kind: &str) -> &'static [(&'static str, bool)] {
|
||||
match kind {
|
||||
"segment" => &[("textbook", false)],
|
||||
"example" => &[("problem", false), ("solution", false)],
|
||||
"lemma" => &[("stmt", false), ("proof", true)],
|
||||
"sop" => &[("sop", false)],
|
||||
// Unknown kind: emit nothing for it; the render package surfaces the
|
||||
// unknown kind visibly, and cph-model/cph-schema report it upstream.
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The optional **scalar** string fields per kind (read from `element.toml`).
|
||||
fn scalar_fields(kind: &str) -> &'static [&'static str] {
|
||||
match kind {
|
||||
"example" => &["source"],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate the driver source for `lesson` under `target`.
|
||||
///
|
||||
/// The result is deterministic and order-preserving (parts emitted in
|
||||
/// `lesson.parts` order, matching the render contract). It is pure text
|
||||
/// generation — it touches the filesystem only to test for optional content
|
||||
/// files (e.g. lemma `proof.typ`).
|
||||
pub fn generate_driver(lesson: &Lesson, target: &str) -> String {
|
||||
let mut s = String::new();
|
||||
s.push_str(RENDER_IMPORT);
|
||||
s.push('\n');
|
||||
|
||||
// One `#let _pN_field = include "…"` per content file, then the parts array
|
||||
// referencing those bindings.
|
||||
let mut part_dicts: Vec<String> = Vec::with_capacity(lesson.parts.len());
|
||||
|
||||
for (i, part) in lesson.parts.iter().enumerate() {
|
||||
let mut entries: Vec<String> = Vec::new();
|
||||
entries.push(format!("kind: {}", typst_str(&part.kind)));
|
||||
|
||||
for &(field, optional) in content_fields(&part.kind) {
|
||||
if optional && !content_file_exists(part, field) {
|
||||
continue;
|
||||
}
|
||||
let binding = format!("_p{i}_{field}");
|
||||
let inc_path = include_path(part, field);
|
||||
s.push_str(&format!(
|
||||
"#let {binding} = include {}\n",
|
||||
typst_str(&inc_path)
|
||||
));
|
||||
entries.push(format!("{field}: {binding}"));
|
||||
}
|
||||
|
||||
for &field in scalar_fields(&part.kind) {
|
||||
if let Some(value) = scalar_string(part, field) {
|
||||
entries.push(format!("{field}: {}", typst_str(&value)));
|
||||
}
|
||||
}
|
||||
|
||||
part_dicts.push(format!("({})", entries.join(", ")));
|
||||
}
|
||||
|
||||
s.push_str("#display(\n");
|
||||
s.push_str(&format!(" info: {},\n", info_dict(lesson)));
|
||||
s.push_str(&format!(" target: {},\n", typst_str(target)));
|
||||
s.push_str(" parts: (\n");
|
||||
for dict in &part_dicts {
|
||||
s.push_str(&format!(" {dict},\n"));
|
||||
}
|
||||
// Trailing comma already present per element; ensure a 1-element array is
|
||||
// still an array (typst needs the trailing comma, which we always emit).
|
||||
s.push_str(" ),\n");
|
||||
s.push_str(")\n");
|
||||
s
|
||||
}
|
||||
|
||||
/// `(title: "…", author: "…")` — author key omitted when absent.
|
||||
fn info_dict(lesson: &Lesson) -> String {
|
||||
let mut parts = vec![format!("title: {}", typst_str(&lesson.info.title))];
|
||||
if let Some(author) = &lesson.info.author {
|
||||
parts.push(format!("author: {}", typst_str(author)));
|
||||
}
|
||||
format!("({})", parts.join(", "))
|
||||
}
|
||||
|
||||
/// Root-relative include path for `part`'s `field` content file, e.g.
|
||||
/// `/segments/开场对照导言/textbook.typ`. Always forward-slashed and
|
||||
/// leading-`/` so it resolves against the typst project root.
|
||||
fn include_path(part: &Part, field: &str) -> String {
|
||||
let mut p = String::from("/");
|
||||
// `part.path` is the manifest-relative folder; join the `<field>.typ`.
|
||||
p.push_str(&path_to_forward_slash(&part.path));
|
||||
if !p.ends_with('/') {
|
||||
p.push('/');
|
||||
}
|
||||
p.push_str(field);
|
||||
p.push_str(".typ");
|
||||
p
|
||||
}
|
||||
|
||||
/// Does `<part.dir>/<field>.typ` exist on disk? Used to gate optional content.
|
||||
fn content_file_exists(part: &Part, field: &str) -> bool {
|
||||
part.descriptor.dir.join(format!("{field}.typ")).is_file()
|
||||
}
|
||||
|
||||
/// Pull a scalar string field out of the element descriptor, if present and a
|
||||
/// string (and non-empty).
|
||||
fn scalar_string(part: &Part, field: &str) -> Option<String> {
|
||||
match part.descriptor.scalars.get(field) {
|
||||
Some(toml::Value::String(s)) if !s.is_empty() => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Render a `PathBuf` (folder) as a forward-slash string, dropping any leading
|
||||
/// `./` or `/`. Element folder names are UTF-8 (Chinese) and kept verbatim.
|
||||
fn path_to_forward_slash(path: &std::path::Path) -> String {
|
||||
use std::path::Component;
|
||||
let mut out = String::new();
|
||||
for comp in path.components() {
|
||||
// Only `Normal` segments contribute; RootDir / CurDir / Prefix /
|
||||
// ParentDir are ignored. `..` is already rejected by cph-model's loader,
|
||||
// and the part path is relative.
|
||||
if let Component::Normal(s) = comp {
|
||||
if !out.is_empty() {
|
||||
out.push('/');
|
||||
}
|
||||
out.push_str(&s.to_string_lossy());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Quote `value` as a typst string literal, escaping `\` and `"`. Unicode is
|
||||
/// passed through as UTF-8 (typst sources are UTF-8). Control chars that would
|
||||
/// break a single-line literal (newline, CR, tab) are escaped too.
|
||||
fn typst_str(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 2);
|
||||
out.push('"');
|
||||
for c in value.chars() {
|
||||
match c {
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'"' => out.push_str("\\\""),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn escapes_quotes_and_backslashes() {
|
||||
assert_eq!(typst_str(r#"a"b\c"#), r#""a\"b\\c""#);
|
||||
// Unicode passes through.
|
||||
assert_eq!(typst_str("自拟"), "\"自拟\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forward_slash_path_keeps_unicode() {
|
||||
let p = std::path::Path::new("segments/开场对照导言");
|
||||
assert_eq!(path_to_forward_slash(p), "segments/开场对照导言");
|
||||
}
|
||||
}
|
||||
+138
-6
@@ -1,9 +1,141 @@
|
||||
//! `cph-typst` — typst `World`, driver generation, compile, PDF, span mapping.
|
||||
//!
|
||||
//! Owned by **WU-4**. Builds the typst compiler `World` over the engineering
|
||||
//! file's virtual file system (ADR-0006/0007), generates the driver entrypoint
|
||||
//! from the manifest, compiles to PDF, and maps typst spans back to source
|
||||
//! locations for diagnostics.
|
||||
//! 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`).
|
||||
|
||||
// WU-4: implement the World + driver-gen + compile + span mapping. Empty for
|
||||
// now so the workspace compiles.
|
||||
mod diag;
|
||||
mod driver;
|
||||
mod world;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::Diagnostic;
|
||||
use cph_model::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> {
|
||||
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>> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Integration tests for `cph-typst`: driver generation, World, compile-check,
|
||||
//! and PDF export over the `tests/fixtures/mini` lesson.
|
||||
//!
|
||||
//! Render-package dependency: most tests point the [`Engine`] at the bundled
|
||||
//! **render-stub** (`tests/fixtures/render-stub`) so the World/compile/PDF path
|
||||
//! is proven independently of WU-2. One `#[ignore]`d test points at the real
|
||||
//! repo `render/` package for when it is ready / for manual runs.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use cph_diag::Severity;
|
||||
use cph_typst::{generate_driver, Engine};
|
||||
|
||||
fn fixture_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/mini")
|
||||
}
|
||||
|
||||
fn stub_render_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/render-stub")
|
||||
}
|
||||
|
||||
fn real_render_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.join("render")
|
||||
}
|
||||
|
||||
fn load_mini() -> cph_model::Lesson {
|
||||
let (lesson, diags) = cph_model::load(&fixture_root());
|
||||
let lesson = lesson.expect("mini fixture loads into a Lesson");
|
||||
let errors: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "fixture has loader errors: {errors:?}");
|
||||
lesson
|
||||
}
|
||||
|
||||
/// PURE UNIT TEST (no fonts, no render package): the generated driver string
|
||||
/// has the expected shape, order, includes, and scalar.
|
||||
#[test]
|
||||
fn driver_gen_shape_and_order() {
|
||||
let lesson = load_mini();
|
||||
let src = generate_driver(&lesson, "student");
|
||||
|
||||
// Imports the render package and calls display.
|
||||
assert!(src.contains("#import \"@local/cph-render:0.1.0\": display"));
|
||||
assert!(src.contains("#display("));
|
||||
assert!(src.contains("target: \"student\""));
|
||||
assert!(src.contains("title: \"迷你示例课时\""));
|
||||
assert!(src.contains("author: \"测试作者\""));
|
||||
|
||||
// Root-relative includes with UTF-8 folder names, one per content field.
|
||||
assert!(src.contains("include \"/segments/开场对照导言/textbook.typ\""));
|
||||
assert!(src.contains("include \"/lemmas/量纲分析估计/stmt.typ\""));
|
||||
assert!(src.contains("include \"/lemmas/量纲分析估计/proof.typ\""));
|
||||
assert!(src.contains("include \"/examples/自由落体/problem.typ\""));
|
||||
assert!(src.contains("include \"/examples/自由落体/solution.typ\""));
|
||||
|
||||
// Example scalar `source` is emitted from element.toml.
|
||||
assert!(src.contains("source: \"自拟\""));
|
||||
|
||||
// Parts appear in manifest order: segment, then lemma, then example.
|
||||
let seg = src.find("kind: \"segment\"").expect("segment present");
|
||||
let lem = src.find("kind: \"lemma\"").expect("lemma present");
|
||||
let exa = src.find("kind: \"example\"").expect("example present");
|
||||
assert!(seg < lem && lem < exa, "parts must keep manifest order");
|
||||
}
|
||||
|
||||
/// Optional `proof.typ` is only included when it exists on disk. (It exists in
|
||||
/// the mini fixture, so we assert presence; absence is covered by construction
|
||||
/// — a kind without the file would simply omit the binding.)
|
||||
#[test]
|
||||
fn optional_proof_included_when_present() {
|
||||
let lesson = load_mini();
|
||||
let src = generate_driver(&lesson, "teacher");
|
||||
assert!(src.contains("_p1_proof = include"));
|
||||
}
|
||||
|
||||
/// FONT-DEPENDENT: compile the mini lesson through the render-stub. Should
|
||||
/// produce zero Error-severity diagnostics. If this fails purely because the
|
||||
/// test host lacks fonts, the failure will be a typst font error — see the
|
||||
/// note in the crate docs.
|
||||
#[test]
|
||||
fn compile_check_clean_with_stub() {
|
||||
let lesson = load_mini();
|
||||
let engine = Engine::with_render_dir(stub_render_dir());
|
||||
let diags = engine.compile_check(&lesson, "student");
|
||||
let errors: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}");
|
||||
}
|
||||
|
||||
/// FONT-DEPENDENT: a real PDF is produced through the render-stub and is
|
||||
/// non-trivial in size.
|
||||
#[test]
|
||||
fn build_pdf_with_stub() {
|
||||
let lesson = load_mini();
|
||||
let engine = Engine::with_render_dir(stub_render_dir());
|
||||
let pdf = engine
|
||||
.build_pdf(&lesson, "student")
|
||||
.unwrap_or_else(|d| panic!("PDF build failed: {d:#?}"));
|
||||
assert!(pdf.starts_with(b"%PDF"), "output is a PDF");
|
||||
assert!(
|
||||
pdf.len() > 1024,
|
||||
"PDF is non-trivial (got {} bytes)",
|
||||
pdf.len()
|
||||
);
|
||||
|
||||
let out = std::env::temp_dir().join("cph-typst-mini-student.pdf");
|
||||
std::fs::write(&out, &pdf).expect("write pdf");
|
||||
assert!(std::fs::metadata(&out).unwrap().len() > 1024);
|
||||
}
|
||||
|
||||
/// A diagnostic that lands inside the generated driver is re-pointed at the
|
||||
/// driver path with a "generated driver" hint, not dropped. We force this by
|
||||
/// compiling against a render dir whose `display` does not exist, so the
|
||||
/// driver's `#display(...)` call is unknown — the error span is in the driver.
|
||||
#[test]
|
||||
fn driver_internal_span_is_repointed() {
|
||||
let lesson = load_mini();
|
||||
// Point at a render dir with an EMPTY lib.typ (no `display`), so calling
|
||||
// `#display(...)` errors inside the driver.
|
||||
let empty = std::env::temp_dir().join("cph-typst-empty-render");
|
||||
std::fs::create_dir_all(&empty).unwrap();
|
||||
std::fs::write(
|
||||
empty.join("typst.toml"),
|
||||
"[package]\nname=\"cph-render\"\nversion=\"0.1.0\"\nentrypoint=\"lib.typ\"\n\
|
||||
authors=[\"t\"]\nlicense=\"MIT\"\ndescription=\"empty\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(empty.join("lib.typ"), "// no display defined\n").unwrap();
|
||||
|
||||
let engine = Engine::with_render_dir(empty);
|
||||
let diags = engine.compile_check(&lesson, "student");
|
||||
let errors: Vec<_> = diags
|
||||
.iter()
|
||||
.filter(|d| d.severity == Severity::Error)
|
||||
.collect();
|
||||
assert!(!errors.is_empty(), "expected an unknown-`display` error");
|
||||
// At least one error should point at the driver and carry the driver hint.
|
||||
let driver_err = errors.iter().find(|d| {
|
||||
d.span
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.file.to_string_lossy().contains(".cph/driver-"))
|
||||
});
|
||||
assert!(
|
||||
driver_err.is_some(),
|
||||
"an error span should be re-pointed at the generated driver: {errors:#?}"
|
||||
);
|
||||
assert!(
|
||||
driver_err
|
||||
.unwrap()
|
||||
.hint
|
||||
.as_deref()
|
||||
.is_some_and(|h| h.contains("generated driver")),
|
||||
"driver-internal error should carry the generated-driver hint"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full end-to-end against the REAL repo `render/` package: load the mini
|
||||
/// fixture, compile through `cph-render`'s `display`, export a PDF for both
|
||||
/// targets. This proves the whole World → driver → render-package → PDF path,
|
||||
/// not just the stub. Requires system CJK fonts (present on dev + CI via
|
||||
/// fonts-noto-cjk).
|
||||
#[test]
|
||||
fn build_pdf_with_real_render() {
|
||||
let lesson = load_mini();
|
||||
let render_dir = real_render_dir();
|
||||
assert!(
|
||||
render_dir.join("lib.typ").is_file(),
|
||||
"real render package missing at {}",
|
||||
render_dir.display()
|
||||
);
|
||||
let engine = Engine::with_render_dir(render_dir);
|
||||
|
||||
for target in ["student", "teacher"] {
|
||||
let pdf = engine
|
||||
.build_pdf(&lesson, target)
|
||||
.unwrap_or_else(|d| panic!("{target} PDF build failed: {d:#?}"));
|
||||
assert!(pdf.starts_with(b"%PDF"));
|
||||
assert!(pdf.len() > 1024, "{target} PDF too small");
|
||||
let out = std::env::temp_dir().join(format!("cph-typst-mini-real-{target}.pdf"));
|
||||
std::fs::write(&out, &pdf).expect("write pdf");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
kind = "example"
|
||||
source = "自拟"
|
||||
@@ -0,0 +1 @@
|
||||
一物体从静止自由下落,求下落时间 $t$ 后的速度 $v$ 与位移 $h$。
|
||||
@@ -0,0 +1 @@
|
||||
由匀加速运动公式,$v = g t$,$h = 1/2 g t^2$。代入 $g approx 9.8 "m/s"^2$ 即得数值结果。
|
||||
@@ -0,0 +1 @@
|
||||
kind = "lemma"
|
||||
@@ -0,0 +1,3 @@
|
||||
设 $T = l^a g^b$。量纲为 $[T] = "T"$,$[l] = "L"$,$[g] = "L T"^(-2)$。
|
||||
比较两端得 $a + b = 0$ 与 $-2 b = 1$,解得 $a = 1 slash 2$,$b = -1 slash 2$,
|
||||
故 $T prop sqrt(l slash g)$。$qed$
|
||||
@@ -0,0 +1,3 @@
|
||||
设单摆周期 $T$ 仅依赖摆长 $l$ 与重力加速度 $g$,则由量纲分析必有
|
||||
$ T = k sqrt(l / g) $
|
||||
其中 $k$ 为无量纲常数。
|
||||
@@ -0,0 +1,23 @@
|
||||
[project]
|
||||
id = "mini"
|
||||
name = "迷你课时"
|
||||
|
||||
[info]
|
||||
title = "迷你示例课时"
|
||||
author = "测试作者"
|
||||
|
||||
[[parts]]
|
||||
kind = "segment"
|
||||
path = "segments/开场对照导言"
|
||||
|
||||
[[parts]]
|
||||
kind = "lemma"
|
||||
path = "lemmas/量纲分析估计"
|
||||
|
||||
[[parts]]
|
||||
kind = "example"
|
||||
path = "examples/自由落体"
|
||||
|
||||
[targets.student]
|
||||
|
||||
[targets.teacher]
|
||||
@@ -0,0 +1 @@
|
||||
kind = "segment"
|
||||
@@ -0,0 +1,2 @@
|
||||
本节通过对照两种估计方法,引入量纲分析这一工具。我们先回顾自由落体,
|
||||
其中能量与质量的关系可写作 $E = m c^2$(此处仅作记号示例)。
|
||||
@@ -0,0 +1,29 @@
|
||||
// Minimal stand-in for the real `cph-render` package, used so cph-typst's
|
||||
// World / compile / PDF path can be proven independently of WU-2.
|
||||
//
|
||||
// It exercises the same call shape as the real entry: it reads `info`, walks
|
||||
// `parts` IN ORDER, and renders each part's content fields by `kind`. Content
|
||||
// values are already-evaluated content (the driver produced them via include).
|
||||
#let display(info: (:), target: "student", parts: ()) = {
|
||||
set document(title: info.at("title", default: ""))
|
||||
[= #info.at("title", default: "")]
|
||||
[target: #target]
|
||||
for p in parts {
|
||||
let kind = p.at("kind", default: none)
|
||||
[== #kind]
|
||||
if kind == "segment" {
|
||||
p.textbook
|
||||
} else if kind == "lemma" {
|
||||
p.stmt
|
||||
let proof = p.at("proof", default: none)
|
||||
if proof != none { proof }
|
||||
} else if kind == "example" {
|
||||
p.problem
|
||||
p.solution
|
||||
let src = p.at("source", default: none)
|
||||
if src != none [来源:#src]
|
||||
} else if kind == "sop" {
|
||||
p.sop
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
[package]
|
||||
name = "cph-render"
|
||||
version = "0.1.0"
|
||||
entrypoint = "lib.typ"
|
||||
authors = ["cph-typst tests"]
|
||||
license = "MIT"
|
||||
description = "Minimal stand-in for cph-render used by cph-typst tests."
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
name = "cph-render"
|
||||
version = "0.1.0"
|
||||
entrypoint = "lib.typ"
|
||||
compiler = ">=0.15.0"
|
||||
compiler = "0.15.0"
|
||||
authors = ["curriculum-project-hub"]
|
||||
license = "MIT"
|
||||
description = "Curriculum lesson render package: single `display` entry over an ordered list of typed parts (segment/example/lemma/sop), targeting student/teacher handouts."
|
||||
|
||||
Reference in New Issue
Block a user