diff --git a/render/examples/smoke-parts.typ b/render/examples/smoke-parts.typ new file mode 100644 index 0000000..fec937d --- /dev/null +++ b/render/examples/smoke-parts.typ @@ -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$。 + ], + ), +) diff --git a/render/examples/smoke-student.typ b/render/examples/smoke-student.typ new file mode 100644 index 0000000..37c6d68 --- /dev/null +++ b/render/examples/smoke-student.typ @@ -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) diff --git a/render/examples/smoke-teacher.typ b/render/examples/smoke-teacher.typ new file mode 100644 index 0000000..47daac4 --- /dev/null +++ b/render/examples/smoke-teacher.typ @@ -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) diff --git a/render/lib.typ b/render/lib.typ new file mode 100644 index 0000000..04f64e7 --- /dev/null +++ b/render/lib.typ @@ -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) + } +} diff --git a/render/src/elements/common.typ b/render/src/elements/common.typ new file mode 100644 index 0000000..b15d16f --- /dev/null +++ b/render/src/elements/common.typ @@ -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, +) diff --git a/render/src/elements/example.typ b/render/src/elements/example.typ new file mode 100644 index 0000000..12c8ea3 --- /dev/null +++ b/render/src/elements/example.typ @@ -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 + } + }) +} diff --git a/render/src/elements/lemma.typ b/render/src/elements/lemma.typ new file mode 100644 index 0000000..f3765c6 --- /dev/null +++ b/render/src/elements/lemma.typ @@ -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 + } + }) +} diff --git a/render/src/elements/segment.typ b/render/src/elements/segment.typ new file mode 100644 index 0000000..57e8954 --- /dev/null +++ b/render/src/elements/segment.typ @@ -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 + } +} diff --git a/render/src/elements/sop.typ b/render/src/elements/sop.typ new file mode 100644 index 0000000..73714eb --- /dev/null +++ b/render/src/elements/sop.typ @@ -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 + }) + } +} diff --git a/render/src/fonts.typ b/render/src/fonts.typ new file mode 100644 index 0000000..b1607a7 --- /dev/null +++ b/render/src/fonts.typ @@ -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") diff --git a/render/src/style.typ b/render/src/style.typ new file mode 100644 index 0000000..cb97c20 --- /dev/null +++ b/render/src/style.typ @@ -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) +} diff --git a/render/typst.toml b/render/typst.toml new file mode 100644 index 0000000..c389bf2 --- /dev/null +++ b/render/typst.toml @@ -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."