feat(cph): add outline export command

This commit is contained in:
2026-08-05 16:34:02 +08:00
committed by 洪佳荣
parent 9927d38c18
commit fe8a17c6ad
15 changed files with 635 additions and 56 deletions
Generated
+2
View File
@@ -410,7 +410,9 @@ dependencies = [
"clap_complete",
"cph-check",
"cph-diag",
"cph-model",
"cph-typst",
"serde_json",
]
[[package]]
+10
View File
@@ -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 补全(可选):
+33
View File
@@ -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
+17
View File
@@ -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".
+2
View File
@@ -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"
+170
View File
@@ -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
View File
@@ -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
+1
View File
@@ -9,6 +9,7 @@ author = "范式教育教研组"
[[children]]
kind = "segment"
path = "segments/intro"
notes = "这一节补充一个直观例题"
[[children]]
kind = "lemma"
+37 -6
View File
@@ -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/子段二
]
);
+28 -13
View File
@@ -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)
}
+30 -2
View File
@@ -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>> {
+2 -1
View File
@@ -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()));
+68 -26
View File
@@ -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 {
+12
View File
@@ -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
+54
View File
@@ -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)
}
}