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",
|
"clap_complete",
|
||||||
"cph-check",
|
"cph-check",
|
||||||
"cph-diag",
|
"cph-diag",
|
||||||
|
"cph-model",
|
||||||
"cph-typst",
|
"cph-typst",
|
||||||
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ cph check <工程目录> # 校验合法性(7 类诊断)
|
|||||||
cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF
|
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)。
|
**版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。
|
||||||
|
|
||||||
Shell 补全(可选):
|
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`.
|
/// Build a PDF for `target`.
|
||||||
///
|
///
|
||||||
/// Runs the check phases **(a)–(c)** (load → structural → schema). If those
|
/// 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());
|
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]
|
#[test]
|
||||||
fn unknown_kind_is_an_error() {
|
fn unknown_kind_is_an_error() {
|
||||||
// Build a throwaway lesson whose part declares kind "frob".
|
// Build a throwaway lesson whose part declares kind "frob".
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
cph-check = { path = "../cph-check" }
|
cph-check = { path = "../cph-check" }
|
||||||
cph-diag = { workspace = true }
|
cph-diag = { workspace = true }
|
||||||
|
cph-model = { workspace = true }
|
||||||
cph-typst = { path = "../cph-typst" }
|
cph-typst = { path = "../cph-typst" }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
clap_complete = "4"
|
clap_complete = "4"
|
||||||
|
serde_json = "1"
|
||||||
|
|||||||
@@ -7,11 +7,14 @@
|
|||||||
//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit
|
//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit
|
||||||
//! 0); `build` exits 1 when the PDF could not be produced.
|
//! 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::path::PathBuf;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
use cph_check::CheckReport;
|
use cph_check::CheckReport;
|
||||||
|
use cph_model::OutlineDocument;
|
||||||
use cph_typst::Engine;
|
use cph_typst::Engine;
|
||||||
|
|
||||||
/// The `cph` checker for curriculum engineering files.
|
/// The `cph` checker for curriculum engineering files.
|
||||||
@@ -70,6 +73,21 @@ enum Command {
|
|||||||
#[arg(short = 'o', long, value_name = "OUT")]
|
#[arg(short = 'o', long, value_name = "OUT")]
|
||||||
out: Option<PathBuf>,
|
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
|
/// 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
|
/// sibling: a local convenience, no lesson involved). Pipe to your shell's
|
||||||
/// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`.
|
/// 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)]
|
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||||
enum CompletionTarget {
|
enum CompletionTarget {
|
||||||
Bash,
|
Bash,
|
||||||
@@ -100,6 +135,12 @@ fn main() -> ExitCode {
|
|||||||
Command::Check { path } => run_check(&path, &engine),
|
Command::Check { path } => run_check(&path, &engine),
|
||||||
Command::Build { path, targets, out } => run_build_command(&path, &engine, targets, out),
|
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::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),
|
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),
|
/// Dispatch `cph build` (ADR-0030): with an explicit `--target` (repeatable),
|
||||||
/// build exactly that ordered set; with none, batch every target the lesson
|
/// build exactly that ordered set; with none, batch every target the lesson
|
||||||
/// declares. Each target builds **independently** — one failing does not stop
|
/// declares. Each target builds **independently** — one failing does not stop
|
||||||
|
|||||||
+169
-8
@@ -60,6 +60,17 @@ pub struct Lesson {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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.
|
/// The declared export-target names, in declared order.
|
||||||
pub fn target_names(&self) -> Vec<&str> {
|
pub fn target_names(&self) -> Vec<&str> {
|
||||||
self.targets.iter().map(|t| t.name.as_str()).collect()
|
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).
|
/// 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 {
|
pub enum OutlineEntry {
|
||||||
/// An element at this position: `part_index` into [`Lesson::parts`].
|
/// An element at this position: `part_index` into [`Lesson::parts`].
|
||||||
Element {
|
Element {
|
||||||
/// Index into `Lesson::parts`.
|
/// Index into `Lesson::parts`.
|
||||||
part_index: usize,
|
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
|
/// A section container opens here. Contributes no element; it is a
|
||||||
/// heading, not a part (ADR-0029). `depth` is the section's nesting depth
|
/// 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
|
/// Heading text: the container's `[group].title` if present and
|
||||||
/// non-empty, else the folder's basename.
|
/// non-empty, else the folder's basename.
|
||||||
title: String,
|
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.
|
/// Nesting depth (1-based) — the heading level a renderer should use.
|
||||||
depth: u32,
|
depth: u32,
|
||||||
/// The container folder's path, relative to the engineering-file root.
|
/// 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).
|
/// One declared export target's build config (ADR-0009/0011).
|
||||||
///
|
///
|
||||||
/// An export target is a **build** producing a typed [`Artifact`] via an
|
/// 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
|
/// Element folder path, root-relative, kept verbatim (forward/back slashes
|
||||||
/// as the OS provides) for diagnostics/display.
|
/// as the OS provides) for diagnostics/display.
|
||||||
pub path: PathBuf,
|
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`.
|
/// The element's self-description loaded from its `element.toml`.
|
||||||
pub descriptor: ElementDescriptor,
|
pub descriptor: ElementDescriptor,
|
||||||
}
|
}
|
||||||
@@ -380,6 +487,8 @@ struct RawGroup {
|
|||||||
struct RawChild {
|
struct RawChild {
|
||||||
kind: String,
|
kind: String,
|
||||||
path: String,
|
path: String,
|
||||||
|
#[serde(default)]
|
||||||
|
notes: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load the engineering file at `root` into a [`Lesson`].
|
/// Load the engineering file at `root` into a [`Lesson`].
|
||||||
@@ -559,6 +668,7 @@ fn load_children(
|
|||||||
outline: &mut Vec<OutlineEntry>,
|
outline: &mut Vec<OutlineEntry>,
|
||||||
) {
|
) {
|
||||||
for child in children {
|
for child in children {
|
||||||
|
let element_depth = next_section_depth.saturating_sub(1);
|
||||||
let local_path = PathBuf::from(&child.path);
|
let local_path = PathBuf::from(&child.path);
|
||||||
let full_rel_path = join_rel(rel_prefix, &local_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"),
|
.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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -588,7 +706,15 @@ fn load_children(
|
|||||||
full_rel_path.display()
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,14 +729,26 @@ fn load_children(
|
|||||||
parts.push(Part {
|
parts.push(Part {
|
||||||
kind: child.kind,
|
kind: child.kind,
|
||||||
path: full_rel_path,
|
path: full_rel_path,
|
||||||
|
notes: child.notes,
|
||||||
descriptor,
|
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.
|
// A container: recurse into its own manifest.toml.
|
||||||
(true, false) => {
|
(true, false) => {
|
||||||
let Some(raw_container) = read_container_manifest(&abs_dir, diags) else {
|
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;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -643,6 +781,7 @@ fn load_children(
|
|||||||
outline.push(OutlineEntry::Section {
|
outline.push(OutlineEntry::Section {
|
||||||
kind: child.kind,
|
kind: child.kind,
|
||||||
title,
|
title,
|
||||||
|
notes: child.notes,
|
||||||
depth: next_section_depth,
|
depth: next_section_depth,
|
||||||
path: full_rel_path.clone(),
|
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"),
|
.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.
|
// Incomplete: neither a container nor a leaf.
|
||||||
(false, false) => {
|
(false, false) => {
|
||||||
@@ -688,7 +835,15 @@ fn load_children(
|
|||||||
(container) to this folder",
|
(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,
|
kind: String,
|
||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
root: &Path,
|
root: &Path,
|
||||||
|
notes: Option<String>,
|
||||||
|
depth: u32,
|
||||||
) {
|
) {
|
||||||
let idx = parts.len();
|
let idx = parts.len();
|
||||||
let dir = root.join(&path);
|
let dir = root.join(&path);
|
||||||
parts.push(Part {
|
parts.push(Part {
|
||||||
kind: kind.clone(),
|
kind: kind.clone(),
|
||||||
path,
|
path,
|
||||||
|
notes,
|
||||||
descriptor: ElementDescriptor {
|
descriptor: ElementDescriptor {
|
||||||
kind,
|
kind,
|
||||||
dir,
|
dir,
|
||||||
scalars: toml::Table::new(),
|
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
|
/// Join a container-relative child path onto that container's own
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ author = "范式教育教研组"
|
|||||||
[[children]]
|
[[children]]
|
||||||
kind = "segment"
|
kind = "segment"
|
||||||
path = "segments/intro"
|
path = "segments/intro"
|
||||||
|
notes = "这一节补充一个直观例题"
|
||||||
|
|
||||||
[[children]]
|
[[children]]
|
||||||
kind = "lemma"
|
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.len(), 2);
|
||||||
assert_eq!(lesson.parts[0].kind, "segment");
|
assert_eq!(lesson.parts[0].kind, "segment");
|
||||||
assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro"));
|
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[0].descriptor.kind, "segment");
|
||||||
assert_eq!(lesson.parts[1].kind, "lemma");
|
assert_eq!(lesson.parts[1].kind, "lemma");
|
||||||
assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young"));
|
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!(
|
assert_eq!(
|
||||||
lesson.outline,
|
lesson.outline,
|
||||||
vec![
|
vec![
|
||||||
OutlineEntry::Element { part_index: 0 },
|
OutlineEntry::Element {
|
||||||
OutlineEntry::Element { part_index: 1 },
|
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!(
|
assert_eq!(
|
||||||
lesson.outline,
|
lesson.outline,
|
||||||
vec![
|
vec![
|
||||||
OutlineEntry::Element { part_index: 0 }, // segments/开场白
|
OutlineEntry::Element {
|
||||||
|
part_index: 0,
|
||||||
|
depth: 0,
|
||||||
|
}, // segments/开场白
|
||||||
OutlineEntry::Section {
|
OutlineEntry::Section {
|
||||||
kind: "section".to_string(),
|
kind: "section".to_string(),
|
||||||
title: "导言簇".to_string(),
|
title: "导言簇".to_string(),
|
||||||
depth: 1,
|
depth: 1,
|
||||||
|
notes: None,
|
||||||
path: PathBuf::from("导言簇"),
|
path: PathBuf::from("导言簇"),
|
||||||
},
|
},
|
||||||
OutlineEntry::Element { part_index: 1 }, // 导言簇/segments/子段一
|
OutlineEntry::Element {
|
||||||
|
part_index: 1,
|
||||||
|
depth: 1,
|
||||||
|
}, // 导言簇/segments/子段一
|
||||||
OutlineEntry::Section {
|
OutlineEntry::Section {
|
||||||
kind: "section".to_string(),
|
kind: "section".to_string(),
|
||||||
title: "嵌套子节".to_string(),
|
title: "嵌套子节".to_string(),
|
||||||
depth: 2,
|
depth: 2,
|
||||||
|
notes: None,
|
||||||
path: PathBuf::from("导言簇/嵌套子节"),
|
path: PathBuf::from("导言簇/嵌套子节"),
|
||||||
},
|
},
|
||||||
OutlineEntry::Element { part_index: 2 }, // 导言簇/嵌套子节/lemmas/子引理
|
OutlineEntry::Element {
|
||||||
OutlineEntry::Element { part_index: 3 }, // 导言簇/segments/子段二
|
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.
|
/// extracting the embedded copy to a per-user cache dir if needed.
|
||||||
///
|
///
|
||||||
/// Resolution order:
|
/// Resolution order:
|
||||||
/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point
|
/// 1. `CPH_RENDER_DIR` — an explicit override (dev convenience: point at the
|
||||||
/// at the live repo `render/`).
|
/// live repo `render/`).
|
||||||
/// 2. The extracted embedded copy under the user cache dir
|
/// 2. The extracted embedded copy under the user cache dir
|
||||||
/// (`<cache>/cph/render-<version>/`). Extracted once per crate version;
|
/// (`<cache>/cph/render-<version>/`).
|
||||||
/// subsequent runs reuse it.
|
|
||||||
///
|
///
|
||||||
/// On any failure to locate a cache dir or extract, falls back to a temp-dir
|
/// 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).
|
/// 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(|_| {
|
ensure_extracted().unwrap_or_else(|_| {
|
||||||
// Last-resort: extract under the OS temp dir. Still correct, just not
|
// Last-resort: extract under the OS temp dir. Still correct, just not
|
||||||
// cached across processes.
|
// cached across processes.
|
||||||
let fallback =
|
let fallback = std::env::temp_dir().join(format!(
|
||||||
std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION")));
|
"cph-render-{}-{}",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
RENDER_CACHE_REVISION
|
||||||
|
));
|
||||||
let _ = extract_to(&fallback);
|
let _ = extract_to(&fallback);
|
||||||
fallback
|
fallback
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The version-keyed cache location and a guarantee the embedded tree is present
|
/// Bump when the embedded render package changes without a cph crate-version
|
||||||
/// there. Returns the directory the World should use.
|
/// 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> {
|
fn ensure_extracted() -> std::io::Result<PathBuf> {
|
||||||
let base = dirs::cache_dir()
|
let base = dirs::cache_dir()
|
||||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?;
|
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?;
|
||||||
let dest = base
|
let dest = base
|
||||||
.join("cph")
|
.join("cph")
|
||||||
.join(format!("render-{}", env!("CARGO_PKG_VERSION")));
|
.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");
|
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);
|
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)?;
|
extract_to(&dest)?;
|
||||||
std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?;
|
std::fs::write(&sentinel, expected)?;
|
||||||
Ok(dest)
|
Ok(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ mod embedded;
|
|||||||
mod manifest;
|
mod manifest;
|
||||||
mod world;
|
mod world;
|
||||||
|
|
||||||
|
use cph_diag::{DiagCode, Diagnostic};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use cph_diag::{DiagCode, Diagnostic};
|
use cph_model::{Artifact, Bundle, Lesson, OutlineDocument, Step, TargetConfig};
|
||||||
use cph_model::{Artifact, Bundle, Lesson, Step, TargetConfig};
|
|
||||||
use typst_kit::fonts::{self, FontStore};
|
use typst_kit::fonts::{self, FontStore};
|
||||||
use typst_layout::PagedDocument;
|
use typst_layout::PagedDocument;
|
||||||
use typst_pdf::PdfOptions;
|
use typst_pdf::PdfOptions;
|
||||||
@@ -139,6 +139,34 @@ impl Engine {
|
|||||||
typst_pdf::pdf(&doc, &PdfOptions::default()).map_err(|errors| map_all(&world, &errors))
|
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
|
/// Build the [`LessonWorld`] for `(lesson, target)`, or `Err(blocking)` when
|
||||||
/// the request cannot be honored (see [`target_precheck`]).
|
/// the request cannot be honored (see [`target_precheck`]).
|
||||||
fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> {
|
fn world_for(&self, lesson: &Lesson, target: &str) -> Result<LessonWorld, Vec<Diagnostic>> {
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ fn outline_entry_table(
|
|||||||
) -> toml::Table {
|
) -> toml::Table {
|
||||||
let mut e = toml::Table::new();
|
let mut e = toml::Table::new();
|
||||||
match entry {
|
match entry {
|
||||||
OutlineEntry::Element { part_index } => {
|
OutlineEntry::Element { part_index, .. } => {
|
||||||
let part = &lesson.parts[*part_index];
|
let part = &lesson.parts[*part_index];
|
||||||
e.insert("type".to_string(), toml::Value::String("element".into()));
|
e.insert("type".to_string(), toml::Value::String("element".into()));
|
||||||
e.insert("kind".to_string(), toml::Value::String(part.kind.clone()));
|
e.insert("kind".to_string(), toml::Value::String(part.kind.clone()));
|
||||||
@@ -176,6 +176,7 @@ fn outline_entry_table(
|
|||||||
title,
|
title,
|
||||||
depth,
|
depth,
|
||||||
path,
|
path,
|
||||||
|
notes: _,
|
||||||
} => {
|
} => {
|
||||||
e.insert("type".to_string(), toml::Value::String("section".into()));
|
e.insert("type".to_string(), toml::Value::String("section".into()));
|
||||||
e.insert("kind".to_string(), toml::Value::String(kind.clone()));
|
e.insert("kind".to_string(), toml::Value::String(kind.clone()));
|
||||||
|
|||||||
@@ -48,13 +48,14 @@ use typst::{Library, LibraryExt, World};
|
|||||||
use typst_kit::fonts::FontStore;
|
use typst_kit::fonts::FontStore;
|
||||||
|
|
||||||
/// Root-relative vpath the augmented manifest is served at (in-memory only).
|
/// 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";
|
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`.
|
/// The package spec the template imports and the World mounts from `render_dir`.
|
||||||
pub fn render_package_spec() -> PackageSpec {
|
pub fn render_package_spec() -> PackageSpec {
|
||||||
PackageSpec {
|
PackageSpec {
|
||||||
@@ -76,13 +77,11 @@ pub struct LessonWorld {
|
|||||||
render_dir: PathBuf,
|
render_dir: PathBuf,
|
||||||
/// The render package spec (`@local/cph-render:0.1.0`).
|
/// The render package spec (`@local/cph-render:0.1.0`).
|
||||||
render_spec: PackageSpec,
|
render_spec: PackageSpec,
|
||||||
/// FileId of the template entrypoint (a real file under `root`).
|
/// FileId of the entrypoint.
|
||||||
main: FileId,
|
main: FileId,
|
||||||
/// FileId of the in-memory augmented manifest.
|
/// In-memory project files (manifest, or the outline entrypoint/data).
|
||||||
manifest_id: FileId,
|
virtual_sources: HashMap<FileId, Source>,
|
||||||
/// The augmented-manifest source (in-memory; never on disk).
|
/// Standard library inputs exposed to the Typst source.
|
||||||
manifest_source: Source,
|
|
||||||
/// Standard library, with `sys.inputs.manifest` set.
|
|
||||||
library: LazyHash<Library>,
|
library: LazyHash<Library>,
|
||||||
/// Shared font store (book + lazily-loaded fonts).
|
/// Shared font store (book + lazily-loaded fonts).
|
||||||
fonts: Arc<FontStore>,
|
fonts: Arc<FontStore>,
|
||||||
@@ -95,8 +94,8 @@ impl LessonWorld {
|
|||||||
/// whose injected manifest is `manifest_src` (served virtually at
|
/// whose injected manifest is `manifest_src` (served virtually at
|
||||||
/// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there).
|
/// [`MANIFEST_VPATH`], with `sys.inputs.manifest` pointing there).
|
||||||
///
|
///
|
||||||
/// `template` is the lesson-root-relative template path (e.g.
|
/// `template` is the lesson-root-relative path taken from the target's
|
||||||
/// `exports/student.typ`), taken from the target's `Step::TypstCompile`.
|
/// `Step::TypstCompile`.
|
||||||
pub fn new(
|
pub fn new(
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
render_dir: PathBuf,
|
render_dir: PathBuf,
|
||||||
@@ -107,16 +106,55 @@ impl LessonWorld {
|
|||||||
let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template)))
|
let main_vpath = VirtualPath::new(format!("/{}", path_to_forward_slash(template)))
|
||||||
.expect("template vpath is a valid virtual path");
|
.expect("template vpath is a valid virtual path");
|
||||||
let main = FileId::new(RootedPath::new(VirtualRoot::Project, main_vpath));
|
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 =
|
/// Build a world for a fully virtual outline document and its TOML data.
|
||||||
VirtualPath::new(MANIFEST_VPATH).expect("manifest vpath is a valid virtual path");
|
/// The caller never has to create temporary files in the engineering file.
|
||||||
let manifest_id = FileId::new(RootedPath::new(VirtualRoot::Project, manifest_vpath));
|
pub fn new_outline(
|
||||||
let manifest_source = Source::new(manifest_id, manifest_src);
|
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
|
fn with_virtual_files(
|
||||||
// `toml(sys.inputs.manifest)` reads the augmented manifest.
|
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();
|
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();
|
let library = Library::builder().with_inputs(inputs).build();
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -124,8 +162,7 @@ impl LessonWorld {
|
|||||||
render_dir,
|
render_dir,
|
||||||
render_spec: render_package_spec(),
|
render_spec: render_package_spec(),
|
||||||
main,
|
main,
|
||||||
manifest_id,
|
virtual_sources,
|
||||||
manifest_source,
|
|
||||||
library: LazyHash::new(library),
|
library: LazyHash::new(library),
|
||||||
fonts,
|
fonts,
|
||||||
sources: Mutex::new(HashMap::new()),
|
sources: Mutex::new(HashMap::new()),
|
||||||
@@ -185,8 +222,8 @@ impl World for LessonWorld {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn source(&self, id: FileId) -> FileResult<Source> {
|
fn source(&self, id: FileId) -> FileResult<Source> {
|
||||||
if id == self.manifest_id {
|
if let Some(source) = self.virtual_sources.get(&id) {
|
||||||
return Ok(self.manifest_source.clone());
|
return Ok(source.clone());
|
||||||
}
|
}
|
||||||
// Cache hit?
|
// Cache hit?
|
||||||
if let Some(src) = self.sources.lock().expect("sources mutex").get(&id) {
|
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> {
|
fn file(&self, id: FileId) -> FileResult<Bytes> {
|
||||||
if id == self.manifest_id {
|
if let Some(source) = self.virtual_sources.get(&id) {
|
||||||
return Ok(Bytes::from_string(self.manifest_source.text().to_string()));
|
return Ok(Bytes::from_string(source.text().to_string()));
|
||||||
}
|
}
|
||||||
let bytes = self.read_bytes(id)?;
|
let bytes = self.read_bytes(id)?;
|
||||||
Ok(Bytes::new(bytes))
|
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
|
/// Render a relative `Path` as a forward-slash string, dropping any leading
|
||||||
/// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim.
|
/// `./` or `/` and ignoring `..`. UTF-8 segments kept verbatim.
|
||||||
fn path_to_forward_slash(path: &Path) -> String {
|
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"]);
|
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
|
/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the
|
||||||
/// student template as main with the injected augmented manifest is clean (zero
|
/// student template as main with the injected augmented manifest is clean (zero
|
||||||
/// Error-severity diagnostics). This proves the template → manifest → include
|
/// 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