diff --git a/Cargo.lock b/Cargo.lock index 7a97ef7..11e5355 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,7 +410,9 @@ dependencies = [ "clap_complete", "cph-check", "cph-diag", + "cph-model", "cph-typst", + "serde_json", ] [[package]] diff --git a/README.md b/README.md index 02669f1..9192400 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,16 @@ cph check <工程目录> # 校验合法性(7 类诊断) cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF ``` +```sh +cph outline <工程目录> # 默认写入 <工程目录>/outline.pdf +cph outline <工程目录> --format md # 或 json / pdf +cph outline <工程目录> --format pdf --force # 明确允许覆盖已有 outline.pdf +``` + +大纲节点来自根及各级容器 `manifest.toml` 的 `[[children]]`;可在 child 上填写多行 +`notes = """…"""` 作为教师备课提示。它会进入 outline 的 JSON/Markdown, +并在 PDF 中以独立的“教学提示”区域呈现,不会混入学生/教师讲义正文。 + **版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。 Shell 补全(可选): diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs index 8653abd..e9163ce 100644 --- a/crates/cph-check/src/lib.rs +++ b/crates/cph-check/src/lib.rs @@ -130,6 +130,39 @@ pub fn check(root: &Path, engine: &Engine) -> CheckReport { } } +/// Load and validate the lesson, then project it into an outline. +/// +/// Outline output is derived from the manifest and part metadata, not from the +/// rendered lesson body. It therefore runs the same load → structural → schema +/// gates as other non-typst builds, but intentionally does not compile any +/// target. An invalid lesson is never written in any outline format. +pub fn outline(root: &Path) -> (Option, CheckReport) { + let mut diags = Vec::new(); + let (lesson, load_diags) = cph_model::load(root); + diags.extend(load_diags); + + let Some(lesson) = lesson else { + return ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: false, + }, + ); + }; + + run_structural_and_schema(&lesson, cph_schema::known_kinds(), &mut diags); + let report = CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }; + if report.has_errors() { + (None, report) + } else { + (Some(lesson.outline_document()), report) + } +} + /// Build a PDF for `target`. /// /// Runs the check phases **(a)–(c)** (load → structural → schema). If those diff --git a/crates/cph-check/tests/pipeline.rs b/crates/cph-check/tests/pipeline.rs index 8e27402..260382c 100644 --- a/crates/cph-check/tests/pipeline.rs +++ b/crates/cph-check/tests/pipeline.rs @@ -45,6 +45,23 @@ fn good_fixture_has_no_errors() { assert!(!report.has_errors()); } +#[test] +fn outline_projects_parts_in_manifest_order() { + let (outline, report) = cph_check::outline(&mini_fixture()); + assert_eq!( + report.error_count(), + 0, + "outline should validate the fixture" + ); + let outline = outline.expect("valid lesson should produce an outline"); + assert_eq!(outline.title, "迷你示例课时"); + assert_eq!(outline.children.len(), 3); + assert_eq!(outline.children[0].title, "开场对照导言"); + assert_eq!(outline.children[1].kind, "section"); + assert_eq!(outline.children[1].children[0].title, "量纲分析估计"); + assert_eq!(outline.children[2].title, "自由落体"); +} + #[test] fn unknown_kind_is_an_error() { // Build a throwaway lesson whose part declares kind "frob". diff --git a/crates/cph-cli/Cargo.toml b/crates/cph-cli/Cargo.toml index b55feb9..2466ff1 100644 --- a/crates/cph-cli/Cargo.toml +++ b/crates/cph-cli/Cargo.toml @@ -11,6 +11,8 @@ path = "src/main.rs" [dependencies] cph-check = { path = "../cph-check" } cph-diag = { workspace = true } +cph-model = { workspace = true } cph-typst = { path = "../cph-typst" } clap = { version = "4", features = ["derive"] } clap_complete = "4" +serde_json = "1" diff --git a/crates/cph-cli/src/main.rs b/crates/cph-cli/src/main.rs index 9ad5c10..f4f93a0 100644 --- a/crates/cph-cli/src/main.rs +++ b/crates/cph-cli/src/main.rs @@ -7,11 +7,14 @@ //! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit //! 0); `build` exits 1 when the PDF could not be produced. +use std::fs::OpenOptions; +use std::io::Write; use std::path::PathBuf; use std::process::ExitCode; use clap::{Parser, Subcommand}; use cph_check::CheckReport; +use cph_model::OutlineDocument; use cph_typst::Engine; /// The `cph` checker for curriculum engineering files. @@ -70,6 +73,21 @@ enum Command { #[arg(short = 'o', long, value_name = "OUT")] out: Option, }, + /// Export the teacher-facing outline as Markdown, PDF, or JSON. + Outline { + /// Path to the engineering-file root. Defaults to the current directory. + #[arg(default_value = ".")] + path: PathBuf, + /// Output format. Defaults to PDF. + #[arg(long, value_enum, default_value_t = OutlineFormat::Pdf)] + format: OutlineFormat, + /// Output path. Defaults to `/outline.`. + #[arg(short = 'o', long, value_name = "OUT")] + out: Option, + /// Allow replacing an existing output file. + #[arg(long)] + force: bool, + }, /// Print a shell-completion script to stdout (clap_complete; ADR-0013 opt-in /// sibling: a local convenience, no lesson involved). Pipe to your shell's /// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`. @@ -79,6 +97,23 @@ enum Command { }, } +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +enum OutlineFormat { + Md, + Pdf, + Json, +} + +impl OutlineFormat { + fn extension(self) -> &'static str { + match self { + Self::Md => "md", + Self::Pdf => "pdf", + Self::Json => "json", + } + } +} + #[derive(Debug, Clone, Copy, clap::ValueEnum)] enum CompletionTarget { Bash, @@ -100,6 +135,12 @@ fn main() -> ExitCode { Command::Check { path } => run_check(&path, &engine), Command::Build { path, targets, out } => run_build_command(&path, &engine, targets, out), Command::Bundle { path, targets, out } => run_bundle_command(&path, &engine, targets, out), + Command::Outline { + path, + format, + out, + force, + } => run_outline(&path, &engine, format, out, force), Command::Completions { shell } => run_completions(shell), } } @@ -157,6 +198,135 @@ fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode { } } +fn run_outline( + path: &std::path::Path, + engine: &Engine, + format: OutlineFormat, + out: Option, + force: bool, +) -> ExitCode { + let out_path = out.unwrap_or_else(|| path.join(format!("outline.{}", format.extension()))); + + if out_path.exists() && !force { + eprintln!( + "warning: output '{}' already exists; pass --force to overwrite", + out_path.display() + ); + return ExitCode::FAILURE; + } + + let (outline, report) = cph_check::outline(path); + print_diagnostics(&report); + let Some(outline) = outline else { + eprintln!("outline failed: fix the lesson before exporting"); + return ExitCode::FAILURE; + }; + + let bytes = match format { + OutlineFormat::Md => render_outline_markdown(&outline).into_bytes(), + OutlineFormat::Json => match serde_json::to_vec_pretty(&outline) { + Ok(mut bytes) => { + bytes.push(b'\n'); + bytes + } + Err(e) => { + eprintln!("outline failed: cannot serialize JSON: {e}"); + return ExitCode::FAILURE; + } + }, + OutlineFormat::Pdf => match engine.build_outline_pdf(&outline) { + Ok(bytes) => bytes, + Err(diags) => { + for diagnostic in &diags { + eprintln!("{diagnostic}"); + } + eprintln!("outline failed: PDF compilation failed"); + return ExitCode::FAILURE; + } + }, + }; + + if force && out_path.exists() { + eprintln!( + "warning: overwriting existing output '{}'", + out_path.display() + ); + } + if let Err(e) = write_outline_output(&out_path, &bytes, force) { + eprintln!("outline failed: {e}"); + return ExitCode::FAILURE; + } + println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); + ExitCode::SUCCESS +} + +fn render_outline_markdown(outline: &OutlineDocument) -> String { + let mut body = format!("# {}\n\n", outline.title.trim()); + if !outline.authors.is_empty() { + body.push_str("作者:"); + body.push_str(&outline.authors.join("、")); + body.push_str("\n\n"); + } + for child in &outline.children { + append_outline_markdown(&mut body, child, 2); + } + body +} + +fn append_outline_markdown(body: &mut String, node: &cph_model::OutlineNode, level: usize) { + let level = level.min(6); + body.push_str(&"#".repeat(level)); + body.push(' '); + body.push_str(&node.title); + if !node.kind.is_empty() { + body.push_str(" `["); + body.push_str(&node.kind); + body.push_str("]`"); + } + body.push_str("\n\n"); + + if let Some(notes) = node.notes.as_deref() { + body.push_str("> 教学提示:\n"); + for line in notes.lines() { + body.push_str("> "); + body.push_str(line); + body.push('\n'); + } + body.push('\n'); + } + + for child in &node.children { + append_outline_markdown(body, child, level + 1); + } +} + +fn write_outline_output(path: &std::path::Path, bytes: &[u8], force: bool) -> Result<(), String> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent) + .map_err(|e| format!("cannot create output directory '{}': {e}", parent.display()))?; + } + + if force { + std::fs::write(path, bytes).map_err(|e| format!("cannot write '{}': {e}", path.display())) + } else { + let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(format!( + "output '{}' already exists; pass --force to overwrite", + path.display() + )); + } + Err(e) => return Err(format!("cannot create '{}': {e}", path.display())), + }; + file.write_all(bytes) + .map_err(|e| format!("cannot write '{}': {e}", path.display())) + } +} + /// Dispatch `cph build` (ADR-0030): with an explicit `--target` (repeatable), /// build exactly that ordered set; with none, batch every target the lesson /// declares. Each target builds **independently** — one failing does not stop diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs index f48df48..4eff4d8 100644 --- a/crates/cph-model/src/lib.rs +++ b/crates/cph-model/src/lib.rs @@ -60,6 +60,17 @@ pub struct Lesson { } impl Lesson { + /// Project the nested depth-first outline into the target-independent + /// document shape used by `cph outline`. + pub fn outline_document(&self) -> OutlineDocument { + let (children, _) = consume_outline_children(self, 0, 0); + OutlineDocument { + title: self.info.title.clone(), + authors: self.info.authors.clone(), + children, + } + } + /// The declared export-target names, in declared order. pub fn target_names(&self) -> Vec<&str> { self.targets.iter().map(|t| t.name.as_str()).collect() @@ -67,12 +78,14 @@ impl Lesson { } /// One entry in the lesson's full rendering-order sequence (ADR-0029). -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum OutlineEntry { /// An element at this position: `part_index` into [`Lesson::parts`]. Element { /// Index into `Lesson::parts`. part_index: usize, + /// Container depth: 0 for a root child, 1 inside a direct section, etc. + depth: u32, }, /// A section container opens here. Contributes no element; it is a /// heading, not a part (ADR-0029). `depth` is the section's nesting depth @@ -84,6 +97,8 @@ pub enum OutlineEntry { /// Heading text: the container's `[group].title` if present and /// non-empty, else the folder's basename. title: String, + /// Optional teacher-facing planning note from the `children` entry. + notes: Option, /// Nesting depth (1-based) — the heading level a renderer should use. depth: u32, /// The container folder's path, relative to the engineering-file root. @@ -91,6 +106,95 @@ pub enum OutlineEntry { }, } +/// A target-independent outline projection of a nested lesson. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OutlineDocument { + /// Course title from the root `[info].title`. + pub title: String, + /// Authors from the root `[info].author`. + pub authors: Vec, + /// Ordered root children, preserving nested sections. + pub children: Vec, +} + +/// One section or element in the exported outline tree. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OutlineNode { + /// Heading text shown to a teacher. + pub title: String, + /// Element or container kind. + pub kind: String, + /// Root-relative source path. + pub path: PathBuf, + /// Optional teacher-facing planning note. + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + /// Nested children; empty for an element leaf. + pub children: Vec, +} + +impl OutlineNode { + fn from_part(part: &Part) -> Self { + let title = part + .path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| part.path.to_string_lossy().into_owned()); + Self { + title, + kind: part.kind.clone(), + path: part.path.clone(), + notes: part.notes.clone().filter(|notes| !notes.trim().is_empty()), + children: Vec::new(), + } + } +} + +/// Convert the flat depth-first sequence into nested JSON/format nodes. +fn consume_outline_children( + lesson: &Lesson, + start: usize, + minimum_section_depth: u32, +) -> (Vec, usize) { + let mut children = Vec::new(); + let mut index = start; + while index < lesson.outline.len() { + match &lesson.outline[index] { + OutlineEntry::Element { part_index, depth } => { + if *depth < minimum_section_depth { + break; + } + children.push(OutlineNode::from_part(&lesson.parts[*part_index])); + index += 1; + } + OutlineEntry::Section { + kind, + title, + notes, + depth, + path, + } => { + if *depth < minimum_section_depth { + break; + } + let section_depth = *depth; + let (nested, next) = consume_outline_children(lesson, index + 1, section_depth); + children.push(OutlineNode { + title: title.clone(), + kind: kind.clone(), + path: path.clone(), + notes: notes.clone().filter(|note| !note.trim().is_empty()), + children: nested, + }); + index = next; + } + } + } + (children, index) +} + /// One declared export target's build config (ADR-0009/0011). /// /// An export target is a **build** producing a typed [`Artifact`] via an @@ -289,6 +393,9 @@ pub struct Part { /// Element folder path, root-relative, kept verbatim (forward/back slashes /// as the OS provides) for diagnostics/display. pub path: PathBuf, + /// Optional teacher-facing planning note from the `children` entry. + /// This is outline metadata, not a content field in `element.toml`. + pub notes: Option, /// The element's self-description loaded from its `element.toml`. pub descriptor: ElementDescriptor, } @@ -380,6 +487,8 @@ struct RawGroup { struct RawChild { kind: String, path: String, + #[serde(default)] + notes: Option, } /// Load the engineering file at `root` into a [`Lesson`]. @@ -559,6 +668,7 @@ fn load_children( outline: &mut Vec, ) { for child in children { + let element_depth = next_section_depth.saturating_sub(1); let local_path = PathBuf::from(&child.path); let full_rel_path = join_rel(rel_prefix, &local_path); @@ -571,7 +681,15 @@ fn load_children( ) .with_hint("child paths must be relative folders inside the containing folder"), ); - push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); continue; } @@ -588,7 +706,15 @@ fn load_children( full_rel_path.display() )), ); - push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); continue; } @@ -603,14 +729,26 @@ fn load_children( parts.push(Part { kind: child.kind, path: full_rel_path, + notes: child.notes, descriptor, }); - outline.push(OutlineEntry::Element { part_index: idx }); + outline.push(OutlineEntry::Element { + part_index: idx, + depth: element_depth, + }); } // A container: recurse into its own manifest.toml. (true, false) => { let Some(raw_container) = read_container_manifest(&abs_dir, diags) else { - push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); continue; }; @@ -643,6 +781,7 @@ fn load_children( outline.push(OutlineEntry::Section { kind: child.kind, title, + notes: child.notes, depth: next_section_depth, path: full_rel_path.clone(), }); @@ -671,7 +810,15 @@ fn load_children( ) .with_hint("remove one of manifest.toml or element.toml from this folder"), ); - push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); } // Incomplete: neither a container nor a leaf. (false, false) => { @@ -688,7 +835,15 @@ fn load_children( (container) to this folder", ), ); - push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); } } } @@ -737,19 +892,25 @@ fn push_broken_leaf( kind: String, path: PathBuf, root: &Path, + notes: Option, + depth: u32, ) { let idx = parts.len(); let dir = root.join(&path); parts.push(Part { kind: kind.clone(), path, + notes, descriptor: ElementDescriptor { kind, dir, scalars: toml::Table::new(), }, }); - outline.push(OutlineEntry::Element { part_index: idx }); + outline.push(OutlineEntry::Element { + part_index: idx, + depth, + }); } /// Join a container-relative child path onto that container's own diff --git a/crates/cph-model/tests/fixtures/valid/manifest.toml b/crates/cph-model/tests/fixtures/valid/manifest.toml index 98c6709..5e858a0 100644 --- a/crates/cph-model/tests/fixtures/valid/manifest.toml +++ b/crates/cph-model/tests/fixtures/valid/manifest.toml @@ -9,6 +9,7 @@ author = "范式教育教研组" [[children]] kind = "segment" path = "segments/intro" +notes = "这一节补充一个直观例题" [[children]] kind = "lemma" diff --git a/crates/cph-model/tests/load.rs b/crates/cph-model/tests/load.rs index 1bfcb96..5024c5c 100644 --- a/crates/cph-model/tests/load.rs +++ b/crates/cph-model/tests/load.rs @@ -35,6 +35,17 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() { assert_eq!(lesson.parts.len(), 2); assert_eq!(lesson.parts[0].kind, "segment"); assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro")); + assert_eq!( + lesson.parts[0].notes.as_deref(), + Some("这一节补充一个直观例题") + ); + let outline = lesson.outline_document(); + assert_eq!(outline.children[0].title, "intro"); + assert_eq!( + outline.children[0].notes.as_deref(), + Some("这一节补充一个直观例题") + ); + assert!(outline.children[0].children.is_empty()); assert_eq!(lesson.parts[0].descriptor.kind, "segment"); assert_eq!(lesson.parts[1].kind, "lemma"); assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young")); @@ -45,8 +56,14 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() { assert_eq!( lesson.outline, vec![ - OutlineEntry::Element { part_index: 0 }, - OutlineEntry::Element { part_index: 1 }, + OutlineEntry::Element { + part_index: 0, + depth: 0, + }, + OutlineEntry::Element { + part_index: 1, + depth: 0, + }, ] ); @@ -114,22 +131,36 @@ fn nested_sections_flatten_depth_first_with_correct_depths() { assert_eq!( lesson.outline, vec![ - OutlineEntry::Element { part_index: 0 }, // segments/开场白 + OutlineEntry::Element { + part_index: 0, + depth: 0, + }, // segments/开场白 OutlineEntry::Section { kind: "section".to_string(), title: "导言簇".to_string(), depth: 1, + notes: None, path: PathBuf::from("导言簇"), }, - OutlineEntry::Element { part_index: 1 }, // 导言簇/segments/子段一 + OutlineEntry::Element { + part_index: 1, + depth: 1, + }, // 导言簇/segments/子段一 OutlineEntry::Section { kind: "section".to_string(), title: "嵌套子节".to_string(), depth: 2, + notes: None, path: PathBuf::from("导言簇/嵌套子节"), }, - OutlineEntry::Element { part_index: 2 }, // 导言簇/嵌套子节/lemmas/子引理 - OutlineEntry::Element { part_index: 3 }, // 导言簇/segments/子段二 + OutlineEntry::Element { + part_index: 2, + depth: 2, + }, // 导言簇/嵌套子节/lemmas/子引理 + OutlineEntry::Element { + part_index: 3, + depth: 1, + }, // 导言簇/segments/子段二 ] ); diff --git a/crates/cph-typst/src/embedded.rs b/crates/cph-typst/src/embedded.rs index 0d32ce2..6236d58 100644 --- a/crates/cph-typst/src/embedded.rs +++ b/crates/cph-typst/src/embedded.rs @@ -33,11 +33,10 @@ static RENDER_DIR: Dir<'_> = include_dir!("$CPH_STAGED_RENDER_DIR"); /// extracting the embedded copy to a per-user cache dir if needed. /// /// Resolution order: -/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point -/// at the live repo `render/`). +/// 1. `CPH_RENDER_DIR` — an explicit override (dev convenience: point at the +/// live repo `render/`). /// 2. The extracted embedded copy under the user cache dir -/// (`/cph/render-/`). Extracted once per crate version; -/// subsequent runs reuse it. +/// (`/cph/render-/`). /// /// On any failure to locate a cache dir or extract, falls back to a temp-dir /// location so the engine still works (just re-extracting per process). @@ -48,31 +47,47 @@ pub fn resolve_render_dir() -> PathBuf { ensure_extracted().unwrap_or_else(|_| { // Last-resort: extract under the OS temp dir. Still correct, just not // cached across processes. - let fallback = - std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION"))); + let fallback = std::env::temp_dir().join(format!( + "cph-render-{}-{}", + env!("CARGO_PKG_VERSION"), + RENDER_CACHE_REVISION + )); let _ = extract_to(&fallback); fallback }) } -/// The version-keyed cache location and a guarantee the embedded tree is present -/// there. Returns the directory the World should use. +/// Bump when the embedded render package changes without a cph crate-version +/// bump. Otherwise a user's old per-version cache can miss newly added package +/// functions (such as `render-outline`). +const RENDER_CACHE_REVISION: &str = "outline-v1"; + +/// The version/revision-keyed cache location and a guarantee the embedded tree +/// is present there. Returns the directory the World should use. fn ensure_extracted() -> std::io::Result { let base = dirs::cache_dir() .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?; let dest = base .join("cph") .join(format!("render-{}", env!("CARGO_PKG_VERSION"))); - - // A sentinel marks a complete extraction; if present, reuse as-is. (Keyed by - // version, so a new `cph` version re-extracts into a fresh dir.) let sentinel = dest.join(".extracted"); - if sentinel.is_file() { + let expected = format!("{}:{}", env!("CARGO_PKG_VERSION"), RENDER_CACHE_REVISION); + + if std::fs::read_to_string(&sentinel) + .map(|contents| contents.trim_end() == expected) + .unwrap_or(false) + { return Ok(dest); } + // The crate version can stay stable while the embedded render package + // evolves. Remove the old tree before extracting so deleted files do not + // survive a revision refresh. + if dest.exists() { + std::fs::remove_dir_all(&dest)?; + } extract_to(&dest)?; - std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?; + std::fs::write(&sentinel, expected)?; Ok(dest) } diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs index 9d369b8..b0b418f 100644 --- a/crates/cph-typst/src/lib.rs +++ b/crates/cph-typst/src/lib.rs @@ -39,10 +39,10 @@ mod embedded; mod manifest; mod world; +use cph_diag::{DiagCode, Diagnostic}; use std::path::PathBuf; -use cph_diag::{DiagCode, Diagnostic}; -use cph_model::{Artifact, Bundle, Lesson, Step, TargetConfig}; +use cph_model::{Artifact, Bundle, Lesson, OutlineDocument, Step, TargetConfig}; use typst_kit::fonts::{self, FontStore}; use typst_layout::PagedDocument; use typst_pdf::PdfOptions; @@ -139,6 +139,34 @@ impl Engine { typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors)) } + /// Build a PDF for an outline document without creating files in the lesson. + /// + /// The outline entrypoint and its TOML data are served by an in-memory + /// [`LessonWorld`]. This keeps outline generation independent of any + /// declared lesson export target while reusing the embedded fonts and PDF + /// backend. + pub fn build_outline_pdf(&self, outline: &OutlineDocument) -> Result, Vec> { + const SOURCE: &str = r#"#import "@local/cph-render:0.1.0": render-outline +#let outline = toml(sys.inputs.outline) +#render-outline(outline) +"#; + + let outline_src = toml::to_string(outline).expect("outline serializes to TOML"); + let world = LessonWorld::new_outline( + PathBuf::from("."), + self.render_dir.clone(), + SOURCE.to_owned(), + outline_src, + self.fonts.clone(), + ); + 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)) + } + /// Build the [`LessonWorld`] for `(lesson, target)`, or `Err(blocking)` when /// the request cannot be honored (see [`target_precheck`]). fn world_for(&self, lesson: &Lesson, target: &str) -> Result> { diff --git a/crates/cph-typst/src/manifest.rs b/crates/cph-typst/src/manifest.rs index 54b0ef0..db2f185 100644 --- a/crates/cph-typst/src/manifest.rs +++ b/crates/cph-typst/src/manifest.rs @@ -157,7 +157,7 @@ fn outline_entry_table( ) -> toml::Table { let mut e = toml::Table::new(); match entry { - OutlineEntry::Element { part_index } => { + OutlineEntry::Element { part_index, .. } => { let part = &lesson.parts[*part_index]; e.insert("type".to_string(), toml::Value::String("element".into())); e.insert("kind".to_string(), toml::Value::String(part.kind.clone())); @@ -176,6 +176,7 @@ fn outline_entry_table( title, depth, path, + notes: _, } => { e.insert("type".to_string(), toml::Value::String("section".into())); e.insert("kind".to_string(), toml::Value::String(kind.clone())); diff --git a/crates/cph-typst/src/world.rs b/crates/cph-typst/src/world.rs index 08b08fc..ac0c269 100644 --- a/crates/cph-typst/src/world.rs +++ b/crates/cph-typst/src/world.rs @@ -48,13 +48,14 @@ use typst::{Library, LibraryExt, World}; use typst_kit::fonts::FontStore; /// Root-relative vpath the augmented manifest is served at (in-memory only). -/// -/// A **leading slash** is essential: the template lives under `exports/`, and -/// `toml(sys.inputs.manifest)` resolves a relative path against the template's -/// own directory — a bare name would miss. A root-relative absolute path anchors -/// at `--root` (the lesson root) regardless of where the template sits. pub const MANIFEST_VPATH: &str = "/.cph/manifest.toml"; +/// Root-relative vpath of the virtual outline entrypoint. +pub const OUTLINE_VPATH: &str = "/exports/outline.typ"; + +/// Root-relative vpath of the virtual outline data file. +pub const OUTLINE_DATA_VPATH: &str = "/.cph/outline.toml"; + /// The package spec the template imports and the World mounts from `render_dir`. pub fn render_package_spec() -> PackageSpec { PackageSpec { @@ -76,13 +77,11 @@ pub struct LessonWorld { render_dir: PathBuf, /// The render package spec (`@local/cph-render:0.1.0`). render_spec: PackageSpec, - /// FileId of the template entrypoint (a real file under `root`). + /// FileId of the entrypoint. main: FileId, - /// FileId of the in-memory augmented manifest. - manifest_id: FileId, - /// The augmented-manifest source (in-memory; never on disk). - manifest_source: Source, - /// Standard library, with `sys.inputs.manifest` set. + /// In-memory project files (manifest, or the outline entrypoint/data). + virtual_sources: HashMap, + /// Standard library inputs exposed to the Typst source. library: LazyHash, /// Shared font store (book + lazily-loaded fonts). fonts: Arc, @@ -95,8 +94,8 @@ impl LessonWorld { /// whose injected manifest is `manifest_src` (served virtually at /// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there). /// - /// `template` is the lesson-root-relative template path (e.g. - /// `exports/student.typ`), taken from the target's `Step::TypstCompile`. + /// `template` is the lesson-root-relative path taken from the target's + /// `Step::TypstCompile`. pub fn new( root: PathBuf, render_dir: PathBuf, @@ -107,16 +106,55 @@ impl LessonWorld { let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template))) .expect("template vpath is a valid virtual path"); let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath)); + let manifest_id = project_file_id(MANIFEST_VPATH); + let mut virtual_sources = HashMap::new(); + virtual_sources.insert(manifest_id, Source::new(manifest_id, manifest_src)); + Self::with_virtual_files( + root, + render_dir, + main, + virtual_sources, + &[("manifest", MANIFEST_VPATH)], + fonts, + ) + } - let manifest_vpath = - VirtualPath::new(MANIFEST_VPATH).expect("manifest vpath is a valid virtual path"); - let manifest_id = FileId::new(RootedPath::new(VirtualRoot::Project, manifest_vpath)); - let manifest_source = Source::new(manifest_id, manifest_src); + /// Build a world for a fully virtual outline document and its TOML data. + /// The caller never has to create temporary files in the engineering file. + pub fn new_outline( + root: PathBuf, + render_dir: PathBuf, + source: String, + outline_src: String, + fonts: Arc, + ) -> Self { + let main = project_file_id(OUTLINE_VPATH); + let outline_id = project_file_id(OUTLINE_DATA_VPATH); + let mut virtual_sources = HashMap::new(); + virtual_sources.insert(main, Source::new(main, source)); + virtual_sources.insert(outline_id, Source::new(outline_id, outline_src)); + Self::with_virtual_files( + root, + render_dir, + main, + virtual_sources, + &[("outline", OUTLINE_DATA_VPATH)], + fonts, + ) + } - // Inject `sys.inputs.manifest = "/.cph/manifest.toml"` so the template's - // `toml(sys.inputs.manifest)` reads the augmented manifest. + fn with_virtual_files( + root: PathBuf, + render_dir: PathBuf, + main: FileId, + virtual_sources: HashMap, + input_files: &[(&str, &str)], + fonts: Arc, + ) -> Self { let mut inputs = Dict::new(); - inputs.insert("manifest".into(), Value::Str(MANIFEST_VPATH.into())); + for (name, path) in input_files { + inputs.insert((*name).into(), Value::Str((*path).into())); + } let library = Library::builder().with_inputs(inputs).build(); Self { @@ -124,8 +162,7 @@ impl LessonWorld { render_dir, render_spec: render_package_spec(), main, - manifest_id, - manifest_source, + virtual_sources, library: LazyHash::new(library), fonts, sources: Mutex::new(HashMap::new()), @@ -185,8 +222,8 @@ impl World for LessonWorld { } fn source(&self, id: FileId) -> FileResult { - if id == self.manifest_id { - return Ok(self.manifest_source.clone()); + if let Some(source) = self.virtual_sources.get(&id) { + return Ok(source.clone()); } // Cache hit? if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) { @@ -203,8 +240,8 @@ impl World for LessonWorld { } fn file(&self, id: FileId) -> FileResult { - if id == self.manifest_id { - return Ok(Bytes::from_string(self.manifest_source.text().to_string())); + if let Some(source) = self.virtual_sources.get(&id) { + return Ok(Bytes::from_string(source.text().to_string())); } let bytes = self.read_bytes(id)?; Ok(Bytes::new(bytes)) @@ -220,6 +257,11 @@ impl World for LessonWorld { } } +fn project_file_id(path: &str) -> FileId { + let vpath = VirtualPath::new(path).expect("virtual project path is valid"); + FileId::new(RootedPath::new(VirtualRoot::Project, vpath)) +} + /// Render a relative `Path` as a forward-slash string, dropping any leading /// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim. fn path_to_forward_slash(path: &Path) -> String { diff --git a/crates/cph-typst/tests/compile.rs b/crates/cph-typst/tests/compile.rs index 2bea1f5..bd5314d 100644 --- a/crates/cph-typst/tests/compile.rs +++ b/crates/cph-typst/tests/compile.rs @@ -134,6 +134,18 @@ fn augmented_manifest_has_outline_with_section_and_fields() { assert_eq!(fields_of(4), vec!["problem", "solution"]); } +#[test] +fn outline_pdf_renders_without_lesson_files() { + let lesson = load_mini(); + let outline = lesson.outline_document(); + let engine = Engine::with_render_dir(real_render_dir()); + let pdf = engine + .build_outline_pdf(&outline) + .expect("outline PDF should compile"); + assert!(pdf.starts_with(b"%PDF"), "output should be a PDF"); + assert!(pdf.len() > 1_000, "outline PDF should be non-trivial"); +} + /// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the /// student template as main with the injected augmented manifest is clean (zero /// Error-severity diagnostics). This proves the template → manifest → include diff --git a/render/lib.typ b/render/lib.typ index 1baf5a9..bd57176 100644 --- a/render/lib.typ +++ b/render/lib.typ @@ -242,3 +242,57 @@ } } } + +/// Render a teacher-facing outline. `children` is intentionally separate from +/// the lesson body: each node heading is accompanied by an optional planning +/// note rendered as a visually distinct teaching-tip box. +#let _render-outline-node(node, level) = { + let title = node.at("title", default: "") + let kind = node.at("kind", default: "") + heading(level: level)[#title] + + if kind != "" { + block( + inset: (x: 0.35em, y: 0.1em), + fill: gray.lighten(80%), + radius: 0.2em, + text(size: 9pt, fill: gray.darken(30%))[#kind], + ) + } + + let notes = node.at("notes", default: none) + if notes != none and notes != "" { + block( + width: 100%, + inset: (x: 1em, y: 0.7em), + fill: rgb("#fff8e7"), + stroke: rgb("#e6c45a") + 0.6pt, + radius: 0.3em, + breakable: true, + { + set text(size: 10.5pt) + text(weight: "bold")[教学提示] + linebreak() + notes + }, + ) + } + + for child in node.at("children", default: ()) { + _render-outline-node(child, level + 1) + } +} + +/// Render the outline document supplied by `cph outline --format pdf`. +#let render-outline(outline) = { + let title = outline.at("title", default: "课程大纲") + let authors = outline.at("authors", default: ()) + set document(title: title, author: authors) + show: base-style.with(heading-numbering: default-heading-numbering) + + title-block() + subtitle-block([课程大纲]) + for child in outline.at("children", default: ()) { + _render-outline-node(child, 1) + } +}