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
+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