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:
2026-06-22 01:33:40 +08:00
parent c151963967
commit c17af60bb0
12 changed files with 453 additions and 0 deletions
+53
View File
@@ -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,
)
+38
View File
@@ -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
}
})
}
+34
View File
@@ -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
}
})
}
+16
View File
@@ -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
}
}
+19
View File
@@ -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
})
}
}
+16
View File
@@ -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")
+85
View File
@@ -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)
}