forked from bai/curriculum-project-hub
feat(render): rebuild typst render package cph-render (WU-2)
Single entry `display(info, target, parts)` over an ordered parts array, the frozen contract the generated typst driver calls. Per-kind dispatch for segment/example/lemma/sop; student/teacher matrix derived internally from `target` (student hides example solution + lemma proof; teacher shows all). Content-field values are consumed as already-evaluated content (driver `include`s them ⇒ module body, ADR-0006), never imported/stringified. Optional fields (lemma proof, example source) handled gracefully; unknown kind/target degrade without crashing. Zero @preview deps (CI-robust); CJK + math styled. Smoke compiles to non-empty student/teacher PDFs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
|||||||
|
// Shared hand-written sample lesson exercising all 4 kinds.
|
||||||
|
// Content field values are plain content blocks — exactly what the Rust driver
|
||||||
|
// would hand us via `include`.
|
||||||
|
|
||||||
|
#let info = (
|
||||||
|
title: "向量与几何 · 示例讲义",
|
||||||
|
author: ("张老师", "李老师"),
|
||||||
|
)
|
||||||
|
|
||||||
|
#let parts = (
|
||||||
|
// segment
|
||||||
|
(
|
||||||
|
kind: "segment",
|
||||||
|
textbook: [
|
||||||
|
本节研究平面向量的基本运算。设 $arrow(a)$、$arrow(b)$ 为平面内两个向量,
|
||||||
|
其数量积定义为 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$,
|
||||||
|
其中 $theta$ 为两向量的夹角。
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// example WITH source
|
||||||
|
(
|
||||||
|
kind: "example",
|
||||||
|
source: "2024 高考甲卷",
|
||||||
|
problem: [
|
||||||
|
已知 $arrow(a) = (1, 2)$,$arrow(b) = (3, -1)$,求 $arrow(a) dot arrow(b)$。
|
||||||
|
],
|
||||||
|
solution: [
|
||||||
|
由坐标公式,$arrow(a) dot arrow(b) = 1 times 3 + 2 times (-1) = 3 - 2 = 1$。
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// example WITHOUT source
|
||||||
|
(
|
||||||
|
kind: "example",
|
||||||
|
problem: [
|
||||||
|
求向量 $arrow(a) = (3, 4)$ 的模长 $|arrow(a)|$。
|
||||||
|
],
|
||||||
|
solution: [
|
||||||
|
$|arrow(a)| = sqrt(3^2 + 4^2) = sqrt(25) = 5$。
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// lemma WITH proof
|
||||||
|
(
|
||||||
|
kind: "lemma",
|
||||||
|
stmt: [
|
||||||
|
对任意向量 $arrow(a)$、$arrow(b)$,有 $|arrow(a) dot arrow(b)| <= |arrow(a)| |arrow(b)|$。
|
||||||
|
],
|
||||||
|
proof: [
|
||||||
|
由数量积定义 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$,
|
||||||
|
又 $|cos theta| <= 1$,故 $|arrow(a) dot arrow(b)| = |arrow(a)| |arrow(b)| |cos theta| <= |arrow(a)| |arrow(b)|$。
|
||||||
|
$qed$
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// lemma WITHOUT proof (proof key omitted entirely)
|
||||||
|
(
|
||||||
|
kind: "lemma",
|
||||||
|
stmt: [
|
||||||
|
两个非零向量垂直当且仅当其数量积为零,即 $arrow(a) perp arrow(b) <==> arrow(a) dot arrow(b) = 0$。
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// sop
|
||||||
|
(
|
||||||
|
kind: "sop",
|
||||||
|
sop: [
|
||||||
|
求两向量夹角的标准步骤:
|
||||||
|
+ 计算数量积 $arrow(a) dot arrow(b)$;
|
||||||
|
+ 分别计算模长 $|arrow(a)|$、$|arrow(b)|$;
|
||||||
|
+ 代入 $cos theta = (arrow(a) dot arrow(b)) / (|arrow(a)| |arrow(b)|)$ 求出 $theta$。
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Smoke test — student target. Imports the package by RELATIVE path so it
|
||||||
|
// compiles in place (no @local install needed).
|
||||||
|
#import "../lib.typ": display
|
||||||
|
#import "smoke-parts.typ": info, parts
|
||||||
|
|
||||||
|
#display(info: info, target: "student", parts: parts)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// Smoke test — teacher target. Imports the package by RELATIVE path.
|
||||||
|
#import "../lib.typ": display
|
||||||
|
#import "smoke-parts.typ": info, parts
|
||||||
|
|
||||||
|
#display(info: info, target: "teacher", parts: parts)
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
// cph-render — curriculum lesson render package.
|
||||||
|
//
|
||||||
|
// Single public entry: `display(info, target, parts)`.
|
||||||
|
//
|
||||||
|
// A lesson is an ORDERED array of typed parts. Each part is a dict with a
|
||||||
|
// `kind` plus that kind's content/scalar fields. Content field VALUES are
|
||||||
|
// already-evaluated typst content (the driver produces them via `include`) —
|
||||||
|
// never modules or strings; we render them directly.
|
||||||
|
//
|
||||||
|
// kind -> fields (MVP):
|
||||||
|
// segment : textbook (content, required)
|
||||||
|
// example : problem (content, required), solution (content, required),
|
||||||
|
// source (string, optional)
|
||||||
|
// lemma : stmt (content, required), proof (content, optional)
|
||||||
|
// sop : sop (content, required)
|
||||||
|
//
|
||||||
|
// target -> show/hide (the (kind x target) render matrix; defaults per
|
||||||
|
// ADR-0005/0008, derived internally — caller passes no flags):
|
||||||
|
// student : example problem only (hide solution); lemma stmt only (hide
|
||||||
|
// proof); segment textbook; sop shown.
|
||||||
|
// teacher : everything shown.
|
||||||
|
// unknown : rendered conservatively == student (show only required-public
|
||||||
|
// fields). Never crashes. The "no render rule => warning"
|
||||||
|
// diagnostic is the Rust side's job, not ours.
|
||||||
|
|
||||||
|
#import "src/style.typ": base-style, title-block, subtitle-block
|
||||||
|
#import "src/elements/segment.typ": display-segment
|
||||||
|
#import "src/elements/example.typ": display-example
|
||||||
|
#import "src/elements/lemma.typ": display-lemma
|
||||||
|
#import "src/elements/sop.typ": display-sop
|
||||||
|
#import "src/elements/common.typ": example-counter, lemma-counter
|
||||||
|
|
||||||
|
/// Resolve a target string to the set of show/hide booleans.
|
||||||
|
/// Unknown targets fall back to the conservative (student-like) profile.
|
||||||
|
#let _flags-for(target) = {
|
||||||
|
if target == "teacher" {
|
||||||
|
(show-solution: true, show-proof: true, subtitle: [教师版讲义])
|
||||||
|
} else if target == "student" {
|
||||||
|
(show-solution: false, show-proof: false, subtitle: [学生版讲义])
|
||||||
|
} else {
|
||||||
|
// Unknown target: conservative — show only required-public fields.
|
||||||
|
(show-solution: false, show-proof: false, subtitle: [讲义])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render one part according to its `kind`. Unknown kinds render an inline
|
||||||
|
/// note instead of crashing.
|
||||||
|
#let _render-part(part, flags) = {
|
||||||
|
let kind = part.at("kind", default: none)
|
||||||
|
if kind == "segment" {
|
||||||
|
display-segment(part)
|
||||||
|
} else if kind == "example" {
|
||||||
|
display-example(part, show-solution: flags.show-solution)
|
||||||
|
} else if kind == "lemma" {
|
||||||
|
display-lemma(part, show-proof: flags.show-proof)
|
||||||
|
} else if kind == "sop" {
|
||||||
|
display-sop(part)
|
||||||
|
} else {
|
||||||
|
// Don't crash on an unrecognised kind; surface it visibly instead.
|
||||||
|
block(
|
||||||
|
width: 100%,
|
||||||
|
inset: 0.6em,
|
||||||
|
fill: red.lighten(85%),
|
||||||
|
stroke: red.darken(20%) + 0.6pt,
|
||||||
|
radius: 0.4em,
|
||||||
|
text(fill: red.darken(30%), weight: "bold")[
|
||||||
|
[未知部件类型 / unknown kind: #repr(kind)]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// THE ENTRY POINT.
|
||||||
|
///
|
||||||
|
/// - `info`: dict, e.g. (title: "…", author: "…"). `author` may be absent.
|
||||||
|
/// - `target`: string. MVP: "student" | "teacher". Unknown => conservative.
|
||||||
|
/// - `parts`: ordered array of part dicts (see file header).
|
||||||
|
#let display(info: (:), target: "student", parts: ()) = {
|
||||||
|
let title = info.at("title", default: [])
|
||||||
|
let author = info.at("author", default: none)
|
||||||
|
|
||||||
|
// `document` author wants a string/array; normalise the optional field.
|
||||||
|
set document(
|
||||||
|
title: title,
|
||||||
|
author: if author == none { () } else { author },
|
||||||
|
)
|
||||||
|
|
||||||
|
show: base-style
|
||||||
|
|
||||||
|
// Reset shared counters so each rendered lesson numbers from 1.
|
||||||
|
example-counter.update(0)
|
||||||
|
lemma-counter.update(0)
|
||||||
|
|
||||||
|
let flags = _flags-for(target)
|
||||||
|
|
||||||
|
title-block()
|
||||||
|
subtitle-block(flags.subtitle)
|
||||||
|
|
||||||
|
// Render strictly in array order.
|
||||||
|
for part in parts {
|
||||||
|
_render-part(part, flags)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// Shared building blocks for element renderers: counters, tag lines, framed
|
||||||
|
// boxes, divider lines. Zero external deps (no fontawesome/showybox) — tags use
|
||||||
|
// plain bold text labels instead of icon glyphs.
|
||||||
|
|
||||||
|
#import "../fonts.typ"
|
||||||
|
#import "../style.typ": no-indent
|
||||||
|
|
||||||
|
/// Counters for numbered elements. Shared across the package so numbering is
|
||||||
|
/// continuous across the lesson regardless of which file renders a part.
|
||||||
|
#let example-counter = counter("cph-example")
|
||||||
|
#let lemma-counter = counter("cph-lemma")
|
||||||
|
|
||||||
|
/// A bold label line introducing a section of an element body, e.g.
|
||||||
|
/// "例题 1" or "解析". Starts flush-left (cancels paragraph indent).
|
||||||
|
#let tag-line(label) = {
|
||||||
|
no-indent
|
||||||
|
set text(font: fonts.sans, weight: "bold")
|
||||||
|
label
|
||||||
|
h(1em / 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A faint horizontal divider between sections inside an element box.
|
||||||
|
#let divider(color) = block(spacing: 1em, {
|
||||||
|
set align(center)
|
||||||
|
line(length: 100%, stroke: color.darken(20%) + 0.5pt)
|
||||||
|
})
|
||||||
|
|
||||||
|
/// A framed, tinted, breakable box wrapping an element's content.
|
||||||
|
/// `hue` selects the accent color family (degrees on the oklch hue wheel).
|
||||||
|
#let element-box(hue, body) = {
|
||||||
|
let theme = oklch(70%, 0.08, hue * 1deg)
|
||||||
|
block(
|
||||||
|
inset: (x: 1.5em, y: 1em),
|
||||||
|
breakable: true,
|
||||||
|
width: 100%,
|
||||||
|
fill: theme.lighten(70%).transparentize(50%),
|
||||||
|
stroke: theme.darken(60%) + 0.8pt,
|
||||||
|
radius: 0.5em,
|
||||||
|
{
|
||||||
|
set par(first-line-indent: 2em, spacing: 1em)
|
||||||
|
body
|
||||||
|
parbreak()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accent hue per element kind (keeps the four kinds visually distinct).
|
||||||
|
#let hue-of = (
|
||||||
|
example: 250,
|
||||||
|
lemma: 150,
|
||||||
|
segment: 90,
|
||||||
|
sop: 30,
|
||||||
|
)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// example: a worked example / problem.
|
||||||
|
//
|
||||||
|
// Contract fields:
|
||||||
|
// content: `problem` (required), `solution` (required)
|
||||||
|
// scalar: `source` (optional string)
|
||||||
|
//
|
||||||
|
// Numbered "例题 N" via the shared example-counter.
|
||||||
|
// student: show `problem`, HIDE `solution`.
|
||||||
|
// teacher: show both.
|
||||||
|
// `source`, when present, is shown as a small annotation on the problem line.
|
||||||
|
|
||||||
|
#import "../fonts.typ"
|
||||||
|
#import "common.typ": element-box, tag-line, divider, example-counter, hue-of
|
||||||
|
|
||||||
|
#let display-example(part, show-solution: true) = {
|
||||||
|
let theme = oklch(70%, 0.08, hue-of.example * 1deg)
|
||||||
|
example-counter.step()
|
||||||
|
|
||||||
|
element-box(hue-of.example, {
|
||||||
|
// Problem
|
||||||
|
tag-line(context [例题 #example-counter.display()])
|
||||||
|
let source = part.at("source", default: none)
|
||||||
|
if source != none and source != "" {
|
||||||
|
text(size: 0.85em, fill: gray.darken(30%))[(来源:#source)]
|
||||||
|
h(0.3em)
|
||||||
|
}
|
||||||
|
set text(font: fonts.accent)
|
||||||
|
part.problem
|
||||||
|
|
||||||
|
// Solution (teacher-only by default)
|
||||||
|
if show-solution {
|
||||||
|
divider(theme)
|
||||||
|
tag-line[解析]
|
||||||
|
set text(font: fonts.accent)
|
||||||
|
part.solution
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// lemma: a stated result, optionally with a proof.
|
||||||
|
//
|
||||||
|
// Contract fields:
|
||||||
|
// content: `stmt` (required), `proof` (optional)
|
||||||
|
// scalar: none
|
||||||
|
//
|
||||||
|
// Numbered "定理 N" via the shared lemma-counter.
|
||||||
|
// student: show `stmt`, HIDE `proof`.
|
||||||
|
// teacher: show both (when `proof` is present).
|
||||||
|
// `proof` may be absent (key omitted) — handled gracefully.
|
||||||
|
|
||||||
|
#import "../fonts.typ"
|
||||||
|
#import "common.typ": element-box, tag-line, divider, lemma-counter, hue-of
|
||||||
|
|
||||||
|
#let display-lemma(part, show-proof: true) = {
|
||||||
|
let theme = oklch(70%, 0.08, hue-of.lemma * 1deg)
|
||||||
|
lemma-counter.step()
|
||||||
|
|
||||||
|
element-box(hue-of.lemma, {
|
||||||
|
// Statement
|
||||||
|
tag-line(context [定理 #lemma-counter.display()])
|
||||||
|
set text(font: fonts.accent)
|
||||||
|
part.stmt
|
||||||
|
|
||||||
|
// Proof: only when requested AND actually provided.
|
||||||
|
let proof = part.at("proof", default: none)
|
||||||
|
if show-proof and proof != none {
|
||||||
|
divider(theme)
|
||||||
|
tag-line[证明]
|
||||||
|
set text(font: fonts.accent)
|
||||||
|
proof
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// segment: a stretch of textbook prose.
|
||||||
|
//
|
||||||
|
// Contract fields:
|
||||||
|
// content: `textbook` (required)
|
||||||
|
// scalar: none
|
||||||
|
//
|
||||||
|
// Rendered identically for all targets (textbook is always public). Not boxed
|
||||||
|
// or numbered — it is the connective body text of the lesson.
|
||||||
|
|
||||||
|
/// Render a segment part. `show-textbook` is always true in MVP but kept for
|
||||||
|
/// symmetry with the other kinds' show/hide flags.
|
||||||
|
#let display-segment(part, show-textbook: true) = {
|
||||||
|
if show-textbook {
|
||||||
|
part.textbook
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// sop: a "standard operating procedure" — a methods/steps block.
|
||||||
|
//
|
||||||
|
// Contract fields:
|
||||||
|
// content: `sop` (required)
|
||||||
|
// scalar: none
|
||||||
|
//
|
||||||
|
// Shown for all targets. Lightly boxed for visual separation; not numbered.
|
||||||
|
|
||||||
|
#import "common.typ": element-box, tag-line, hue-of
|
||||||
|
|
||||||
|
/// Render a sop part.
|
||||||
|
#let display-sop(part, show-sop: true) = {
|
||||||
|
if show-sop {
|
||||||
|
element-box(hue-of.sop, {
|
||||||
|
tag-line[方法]
|
||||||
|
part.sop
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Font families. The lesson text is Chinese + heavy math.
|
||||||
|
//
|
||||||
|
// We pass FONT FALLBACK LISTS rather than single names: typst tries each in
|
||||||
|
// order and falls back to the next if a face is missing. This means we never
|
||||||
|
// hard-fail when one specific CJK font is absent on the compile host — a key
|
||||||
|
// MVP robustness requirement (CI may have a different font set).
|
||||||
|
|
||||||
|
/// Serif body font (宋体). First broadly-shipped match wins; if none are
|
||||||
|
/// present typst falls back to its own default — we never hard-fail.
|
||||||
|
#let serif = ("Noto Serif CJK SC", "Source Han Serif SC", "Songti SC", "SimSun")
|
||||||
|
|
||||||
|
/// Sans / heading font (黑体).
|
||||||
|
#let sans = ("Noto Sans CJK SC", "Source Han Sans SC", "PingFang SC", "Heiti SC", "SimHei")
|
||||||
|
|
||||||
|
/// Accent font used for problem/proof bodies (仿宋 feel).
|
||||||
|
#let accent = ("FZFangSong-Z02S", "FangSong", "STFangsong", "Songti SC")
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// Base document styling: CJK fonts, headings, math, paragraph layout, page.
|
||||||
|
//
|
||||||
|
// Zero external dependencies. Heading numbering uses builtin `numbering`
|
||||||
|
// strings (no @preview/numbly) so the package compiles with no network.
|
||||||
|
|
||||||
|
#import "fonts.typ"
|
||||||
|
|
||||||
|
/// Apply the base style to a document body. Used as `show: base-style`.
|
||||||
|
#let base-style(doc) = {
|
||||||
|
// Language / region drive CJK line-breaking and punctuation.
|
||||||
|
set text(lang: "zh", region: "cn", size: 12pt, font: fonts.serif)
|
||||||
|
set text(cjk-latin-spacing: auto)
|
||||||
|
|
||||||
|
// Heading numbering: 一、 / 1.1 / 1.1.1 — builtin numbering, no deps.
|
||||||
|
set heading(numbering: "一、")
|
||||||
|
show heading: set text(font: fonts.sans)
|
||||||
|
|
||||||
|
// Number display equations.
|
||||||
|
set math.equation(numbering: "(1)")
|
||||||
|
|
||||||
|
set par(
|
||||||
|
first-line-indent: (amount: 2em, all: true),
|
||||||
|
leading: 0.8em,
|
||||||
|
spacing: 1.5em,
|
||||||
|
justify: true,
|
||||||
|
)
|
||||||
|
|
||||||
|
set page(
|
||||||
|
margin: (x: 2.2cm, y: 2.4cm),
|
||||||
|
footer: context align(center)[
|
||||||
|
#counter(page).get().at(0) / #counter(page).final().at(0)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
set table(inset: 5pt, stroke: 0.75pt)
|
||||||
|
|
||||||
|
// Inline quotes -> subtle highlight (matches prototype feel).
|
||||||
|
show quote.where(block: false): it => highlight(
|
||||||
|
it.body,
|
||||||
|
fill: gray.transparentize(60%),
|
||||||
|
top-edge: 1em,
|
||||||
|
bottom-edge: -0.2em,
|
||||||
|
extent: 0.35em,
|
||||||
|
radius: 0.25em,
|
||||||
|
)
|
||||||
|
|
||||||
|
show link: set text(fill: eastern)
|
||||||
|
show link: underline
|
||||||
|
|
||||||
|
doc
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancel the active paragraph first-line indent (for tag lines that should
|
||||||
|
/// start flush-left). Defensive against both `length` and `dict` indent forms.
|
||||||
|
#let no-indent = context {
|
||||||
|
let fli = par.first-line-indent
|
||||||
|
if type(fli) == length {
|
||||||
|
h(-fli)
|
||||||
|
} else {
|
||||||
|
h(-fli.amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Document title block.
|
||||||
|
#let title-block() = {
|
||||||
|
set align(center)
|
||||||
|
set text(font: fonts.sans, size: 22pt, weight: "bold")
|
||||||
|
context document.title
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subtitle + authors block under the title.
|
||||||
|
#let subtitle-block(subtitle) = {
|
||||||
|
set align(center)
|
||||||
|
set text(size: 14pt)
|
||||||
|
v(1.2em, weak: true)
|
||||||
|
subtitle
|
||||||
|
context {
|
||||||
|
let authors = document.author
|
||||||
|
if authors != () {
|
||||||
|
v(0.8em, weak: true)
|
||||||
|
authors.join(h(1.5em))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
v(1.2em, weak: true)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[package]
|
||||||
|
name = "cph-render"
|
||||||
|
version = "0.1.0"
|
||||||
|
entrypoint = "lib.typ"
|
||||||
|
compiler = ">=0.15.0"
|
||||||
|
authors = ["curriculum-project-hub"]
|
||||||
|
license = "MIT"
|
||||||
|
description = "Curriculum lesson render package: single `display` entry over an ordered list of typed parts (segment/example/lemma/sop), targeting student/teacher handouts."
|
||||||
Reference in New Issue
Block a user