forked from EduCraft/curriculum-project-hub
feat(cph): add outline export command
This commit is contained in:
Generated
+2
@@ -410,7 +410,9 @@ dependencies = [
|
||||
"clap_complete",
|
||||
"cph-check",
|
||||
"cph-diag",
|
||||
"cph-model",
|
||||
"cph-typst",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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 补全(可选):
|
||||
|
||||
@@ -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<cph_model::OutlineDocument>, 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
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
},
|
||||
/// 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 `<PATH>/outline.<format>`.
|
||||
#[arg(short = 'o', long, value_name = "OUT")]
|
||||
out: Option<PathBuf>,
|
||||
/// 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<PathBuf>,
|
||||
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
|
||||
|
||||
+169
-8
@@ -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<String>,
|
||||
/// 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<String>,
|
||||
/// Ordered root children, preserving nested sections.
|
||||
pub children: Vec<OutlineNode>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Nested children; empty for an element leaf.
|
||||
pub children: Vec<OutlineNode>,
|
||||
}
|
||||
|
||||
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<OutlineNode>, 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<String>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// Load the engineering file at `root` into a [`Lesson`].
|
||||
@@ -559,6 +668,7 @@ fn load_children(
|
||||
outline: &mut Vec<OutlineEntry>,
|
||||
) {
|
||||
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<String>,
|
||||
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
|
||||
|
||||
@@ -9,6 +9,7 @@ author = "范式教育教研组"
|
||||
[[children]]
|
||||
kind = "segment"
|
||||
path = "segments/intro"
|
||||
notes = "这一节补充一个直观例题"
|
||||
|
||||
[[children]]
|
||||
kind = "lemma"
|
||||
|
||||
@@ -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/子段二
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
/// (`<cache>/cph/render-<version>/`). Extracted once per crate version;
|
||||
/// subsequent runs reuse it.
|
||||
/// (`<cache>/cph/render-<version>/`).
|
||||
///
|
||||
/// 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<PathBuf> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<u8>, Vec<Diagnostic>> {
|
||||
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::<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))
|
||||
}
|
||||
|
||||
/// 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<LessonWorld, Vec<Diagnostic>> {
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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<FileId, Source>,
|
||||
/// Standard library inputs exposed to the Typst source.
|
||||
library: LazyHash<Library>,
|
||||
/// Shared font store (book + lazily-loaded fonts).
|
||||
fonts: Arc<FontStore>,
|
||||
@@ -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<FontStore>,
|
||||
) -> 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<FileId, Source>,
|
||||
input_files: &[(&str, &str)],
|
||||
fonts: Arc<FontStore>,
|
||||
) -> 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<Source> {
|
||||
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<Bytes> {
|
||||
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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user