// cph-render — curriculum lesson render package. // // PUBLIC ENTRY: `render-lesson(info, target, outline, heading-numbering)`. // // MODEL (ADR-0011, outline shape per ADR-0029). A build compiles a *template // file* (e.g. `exports/student.typ`) as the typst main, with the manifest // injected via `--input manifest=`. The template reads the manifest, // loads each **element** entry's content, and calls `render-lesson` here. // Presentation (heading numbering) lives in the template, not the manifest, // not hardcoded-unreachable in this package — we only provide the DEFAULT // scheme (`default-heading-numbering`) for the template to use or override. // // WHY THE TEMPLATE LOADS CONTENT, NOT US (the include-resolution finding): // typst resolves an `include`/`import` path relative to THE PACKAGE/FILE THE // include LEXICALLY APPEARS IN, and a package (`@local/cph-render`) has its OWN // virtual root. A dynamic `include` written inside this file resolves against // the PACKAGE root — even an absolute `/segments/x.typ` lands in the package // dir, NOT the engineering-file root (`--root`). Verified empirically. // Therefore the dynamic-include LOOP must live in the TEMPLATE (which lives at // `/exports/*.typ`, so `//.typ` resolves against // `--root`). `render-lesson` is content-in: it takes an ALREADY-ASSEMBLED // `outline` array of entry dicts and never includes anything itself. // // An `outline` entry the template hands us is one of: // - an ELEMENT: `entry-type: "element"`, `kind` plus that kind's // content/scalar fields. Content field VALUES are already-evaluated typst // content (the template produced them via `include`). // - a SECTION (ADR-0029): `entry-type: "section"`, `title` (heading text), // `depth` (1-based heading level). Contributes no element; it is a // heading at its depth-first-open position in the outline. // // kind -> fields (MVP) — see `part-fields` below, which the template uses to // know what to load: // segment : textbook (content, required) // example : problem (content, required), solution (content, required), // source (string scalar, optional, from /element.toml) // 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 — template passes only the target string): // 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, default-heading-numbering, 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 /// Per-kind field manifest, exported so a TEMPLATE knows exactly what to load /// for each part without hardcoding the matrix. For each kind: /// `content`: field names whose `/.typ` the template must /// `include` (assembling them into the part dict). /// `optional-content`: content fields that may be absent (template should /// probe before including; absence is fine). /// `scalars`: scalar field names the template reads from `/element.toml` /// (currently only example `source`). /// Keeping this here (not in the template) keeps kind->fields a single source of /// truth in the package; the template iterates it generically. #let part-fields = ( segment: (content: ("textbook",), optional-content: (), scalars: ()), example: (content: ("problem", "solution"), optional-content: (), scalars: ("source",)), lemma: (content: ("stmt",), optional-content: ("proof",), scalars: ()), sop: (content: ("sop",), optional-content: (), scalars: ()), ) /// 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)] ], ) } } /// Render one section-heading outline entry (ADR-0029): `entry.title` at /// `entry.depth` (1-based heading level). #let _render-section(entry) = { heading(level: entry.at("depth", default: 1))[#entry.at("title", default: "")] } /// Render one outline entry: an element dispatches on `kind` via /// [`_render-part`]; a section (ADR-0029) renders its heading and contributes /// no element. #let _render-entry(entry, flags) = { if entry.at("entry-type", default: "element") == "section" { _render-section(entry) } else { _render-part(entry, flags) } } /// THE ENTRY POINT — called by a template (`exports/*.typ`). /// /// - `info`: dict, e.g. (title: "…", author: "…"). `author` may be absent. /// Templates typically pass `manifest.at("info", default: (:))`. /// - `target`: string. MVP: "student" | "teacher". Unknown => conservative. /// Each template hardcodes its own target (student.typ => "student"). /// - `outline`: ordered array of outline-entry dicts (ADR-0029), ALREADY /// ASSEMBLED by the template (element content fields included, /// scalars read; section entries carry `title`/`depth`). See file /// header for the entry shapes. /// - `heading-numbering`: array of per-level numbly pattern strings, e.g. /// `("{1:一}、", "{1:1}.{2:1}", "{1:1}.{2:1}.{3:1}")`. Presentation /// lives in the template (ADR-0011); the template passes its chosen /// scheme. Defaults to `default-heading-numbering` (the framework /// default) when the template omits it. #let render-lesson( info: (:), target: "student", outline: (), heading-numbering: default-heading-numbering, ) = { 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.with(heading-numbering: heading-numbering) // 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 entry in outline { _render-entry(entry, flags) } } /// Shift a section entry's `depth` by `delta`; an element entry passes through /// unchanged (elements carry no depth). Used by [`render-bundle`] to nest each /// lesson's own outline one level under that lesson's title heading. #let _shift-depth(entry, delta) = { if entry.at("entry-type", default: "element") == "section" { entry.depth = entry.depth + delta } entry } /// THE BUNDLE ENTRY POINT (ADR-0030) — called by a bundle target's template /// (`exports/.typ` under a `bundle.toml` root) to assemble several /// already-loaded lessons into one combined document. /// /// - `info`: the bundle's own `[info]` (title/author of the 合集). /// - `lessons`: ordered array of `(info: .., target: .., outline: ..)` dicts — /// one per `bundle.toml` `lessons` entry, each ALREADY ASSEMBLED exactly as /// a single-lesson template would assemble it for [`render-lesson`]. /// - `heading-numbering`: shared per-level numbering scheme across the whole /// bundle (ADR-0011 presentation-in-template stance; same knob as /// `render-lesson`). /// - `reset-counters`: whether `example`/`lemma` auto-counters reset at each /// lesson boundary. **Default `true`** (ADR-0030's recommended default: a /// lesson's internal "例题3" means that lesson's 例题3, so cross-lesson /// continuation would silently break author references). Pass `false` for /// a genuine "全书连续编号" 合集. /// /// Each lesson's own outline is rendered under a depth-1 heading naming that /// lesson (`lesson.info.title`); the lesson's own section headings shift one /// level deeper (ADR-0029's per-lesson depths are relative to that lesson, so /// nesting under the lesson-title heading keeps the outline↔structure /// correspondence meaningful in the combined document). `@label`/`@ref` /// cross-references stay global across the whole compiled document (typst's /// ordinary behavior) regardless of `reset-counters`. #let render-bundle( info: (:), lessons: (), heading-numbering: default-heading-numbering, reset-counters: true, ) = { let title = info.at("title", default: []) let author = info.at("author", default: none) set document( title: title, author: if author == none { () } else { author }, ) show: base-style.with(heading-numbering: heading-numbering) title-block() v(1.2em, weak: true) for lesson in lessons { if reset-counters { example-counter.update(0) lemma-counter.update(0) } let flags = _flags-for(lesson.at("target", default: "student")) let lesson-info = lesson.at("info", default: (:)) heading(level: 1)[#lesson-info.at("title", default: [])] for entry in lesson.at("outline", default: ()) { _render-entry(_shift-depth(entry, 1), flags) } } } /// 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) } }