From e0bd6120ec255aa0fbbe8e27ad9fa9b7d0d926a5 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Tue, 4 Aug 2026 16:52:18 +0800 Subject: [PATCH 1/5] docs(adr): nested outline manifest and batch/combined export design ADR-0029 supersedes ADR-0008's flat [[parts]] with a per-folder manifest.toml outline tree (root = implicit top container, single section container kind, DFS pre-order = lesson order). ADR-0030 extends ADR-0009/0011: cph build batches all targets (any failure => non-zero), cph bundle assembles ordered lessons into one SingleFile/FileTree artifact (self-contained lessons, export-time combination only). --- .../0029-engineering-file-nested-manifest.md | 193 ++++++++++++++++++ docs/adr/0030-batch-and-combined-export.md | 167 +++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 docs/adr/0029-engineering-file-nested-manifest.md create mode 100644 docs/adr/0030-batch-and-combined-export.md diff --git a/docs/adr/0029-engineering-file-nested-manifest.md b/docs/adr/0029-engineering-file-nested-manifest.md new file mode 100644 index 0000000..64c25ae --- /dev/null +++ b/docs/adr/0029-engineering-file-nested-manifest.md @@ -0,0 +1,193 @@ +# ADR 0029: Engineering-File Structure Is A Nested Outline Manifest + +## Status + +Accepted. **Supersedes ADR-0008** on the concrete layout of a lesson's +structure (the ordered `[[parts]]` arrangement), and discharges the +"grouping/sectioning" and "manifest richness" gaps ADR-0008 left Open. ADR-0007 +(the engineering file is a real directory tree) stands unchanged. ADR-0005's +"a lesson is an ordered sequence of element instances" stands unchanged — order +and membership are preserved; the *shape* that encodes them is now a tree. + +## Context + +ADR-0008 encodes a lesson's order and membership as a **single flat `[[parts]]` +array** in a root `manifest.toml`, where every `[[parts]]` entry is a `kind` + +`path` to an element folder. Two forces now push against that flat shape: + +1. **Real lessons are internally structured.** TH-144 has a `题目/` tree of + problem/answer pairs and A/B/C sections that exist only in folder names + today. Teachers think of a lesson as an **outline** — sections, sub-sections, + groups of worked examples — not an unbroken flat list of ~40 parts. The + admin/teacher surface (the 老师端 being built against the Hub) is supposed to + show "the project structure, expanding each structural element to the files + inside" — and today that structure is a giant flat scroll. +2. **Outline and file structure should correspond 1:1, not via a separate + index.** The 7.31 design discussion landed on a shape where each level of the + lesson is a folder whose manifest states that level's children — so the + on-disk tree *is* the outline, self-descriptive, with no secondary artifact + to drift out of sync. A flat root `[[parts]]` list, by contrast, names the + whole lesson in one file and forces the folder tree to be a projection of it + (or vice versa) with two sources of truth. + +ADR-0008 itself anticipated this: its Open Questions list "Per-part metadata, +grouping/sectioning (TH-144's A/B/C structure is only in folder names today)" as +explicitly not modeled. This ADR closes that gap. + +## Decision + +### A lesson is a tree of folders; each folder is a self-describing node whose manifest names its children + +The engineering file remains a real directory tree (ADR-0007). The ordering and +membership encoding of ADR-0008 changes from **one flat root `[[parts]]`** to a +**nested, per-folder outline**: + +- The root's `manifest.toml` keeps `[project]`, `[info]`, and the `[targets.*]` + build configuration exactly as ADR-0008/0011 define them. +- The lesson's **structure is expressed as a folder tree**, where every folder + that groups children carries its own small **outline manifest** (per-folder + `manifest.toml`, see *Name and discriminator* below) stating that level's + ordered children. +- A **leaf** is an element folder exactly as ADR-0008 defines it: an + `element.toml` declaring `kind` + scalar fields, plus convention-named + content `.typ` siblings. A leaf has no outline manifest — its own + `element.toml` is its descriptor. +- An **internal folder** (a grouping node) has an outline manifest but no + `element.toml`: it is not an element, it is a container of elements/containers. + It carries only structure and, optionally, group-level scalar metadata. + +### The engineering-file root is itself the implicit top container + +Children live directly in the root `manifest.toml`'s `children` array — there is +no mandated single top-level section folder. Rationale: migration is a pure +flatten of the existing root `[[parts]]` into root `children` (same order, zero +forcing); a mandated wrapper folder would be pure indirection for most lessons. +A lesson that wants a top-level section simply creates one as a child +(consistent with the ADR-0021 folder-tree precedent, which holds direct children +at the root). + +### Name and discriminator: every grouping folder uses `manifest.toml` + +Every folder that groups children uses the same filename, `manifest.toml`, at +every level including the root: + +- Root `manifest.toml`: `[project]`, `[info]`, `[targets.*]`, plus a `children` + array. +- Internal folder `manifest.toml`: `children` (+ optional `[group]` scalars); + never `[project]`/`[info]`/`[targets.*]`. + +The leaf/container discriminator is disjoint and structural: a folder with +`element.toml` is a **leaf** (ADR-0008 descriptor); a folder with `manifest.toml` +and no `element.toml` is a **container**; a folder with neither is a structural +error. `OUTLINE.toml` was rejected (a new reserved name, no benefit over the +uniform name); `info.toml` was rejected because it collides with the model's +`Info` (title/author, folded into root `manifest.toml`'s `[info]` by ADR-0008) +and would blur "metadata vs structure". The 7.31 sketch's intent — each level +self-describes its children — is preserved; the name aligns with current +ADR-0008. + +### Order is encoded per-folder, and the lesson order is the depth-first traversal + +ADR-0005 requires the lesson to be an ordered sequence. In the tree, **order is +declared locally at each folder** by the order of children in that folder's +`manifest.toml`. The canonical lesson order is the **depth-first pre-order +traversal** of the tree: an internal folder contributes no element of its own +(its label is a heading, not a part), and leaves contribute in the order they +appear. The checker materializes this traversal; no part of the lesson order +lives in a typst script (ADR-0008's core rejection of typst-as-order-manifest +stands). + +Concretely, a `segment` that in TH-141 was one flat `[[parts]]` entry can now be +a folder whose outline lists its sub-segments and examples in order — and any +grouping (TH-144's A/B/C, a "导言 cluster", a "例题组") is a folder, transparent +in the element sequence but a real node in the outline. + +### The outline manifest shape + +A folder's `manifest.toml` `children` array holds its ordered children. Each +child entry is either: + +```toml +# a leaf element (ADR-0008 descriptor), by relative path +{ kind = "example", path = "examples/41届复赛三-1-混注石油" } + +# or an internal grouping folder, by relative path (recursed) +{ kind = "section", path = "导言簇" } +``` + +The `kind` of a **leaf** is still read from that folder's `element.toml` +(ADR-0008: the folder is self-describing; the outline entry may restate it for +readability but the `element.toml` is authoritative). The `kind` of an +**internal** child is a container kind — recognized from a small, open set of +container kinds — and selects how that subtree is rendered/grouped. Leaves and +containers are disjoint by construction: a folder is a container iff it has a +`manifest.toml`; a leaf iff it has an `element.toml`. A folder must have exactly +one of the two. + +### Container kinds: MVP ships exactly one — `section`, rendered as a heading + +The demonstrated needs (TH-144's A/B/C, a "导言簇") are all `section`, so the +MVP ships exactly one container kind: + +- A `section` opens a **heading** at its depth in the DFS, then renders its + children in order; it never appears in the element part sequence (the + already-decided DFS semantics). +- The heading uses `[group].title` when present, else the folder name. +- `group` (a heading-less visual grouping) and any other container kind are + **deferred**: added only when a real need appears, honoring ADR-0005's open + universe / "add when needed". + +### Group-level scalars + +An internal folder MAY carry a `[group]` table (e.g. a title distinct from the +folder name, a description) in its `manifest.toml`. Kept minimal — no other +container metadata until a real need appears. + +### The flat `[[parts]]` array at the root is retired for structure + +The root `manifest.toml` no longer needs a root-level `[[parts]]` that names the +whole lesson. Lesson structure lives in the folder tree, rooted at the +engineering-file root's `manifest.toml` `children`. `[targets.*]` and +`[project]`/`[info]` stay at the root `manifest.toml`. + +## Consequences + +- The teacher/admin surface shows the outline: the folder tree *is* the lesson + structure, self-descriptive and 1:1 with files. Opening an element reveals its + files (as the 老师端 requirement asked). This discharges the 7.31 driver + ("项目内部有一套 cph schema 定义的结构,由 manifest 组织,给老师看的应该是这个, + 展开每个结构元素内部才是文件"). +- The checker can still recover the full ordered lesson **without evaluating + typst**: it reads the root `manifest.toml` (project/info/targets) and walks the + folder tree, honoring each folder's `manifest.toml` children order and each + leaf's `element.toml`. Order and membership remain declarative data, greppable + and diffable. +- Grouping (sections) is now a real, checkable structure rather than a folder + naming convention — TH-144's A/B/C can be first-class. +- Every folder is self-describing, so a subtree can be understood/moved on its + own; nothing about a subtree's structure lives only in the root file. +- Migration is mechanical: flatten the existing root `[[parts]]` into root + `children` with the same leaf order (root is the implicit top container, so no + wrapper folder is needed). The element sequence is unchanged, so + `cph check`/`cph build` semantics for leaves carry over. + +## Open Questions / Deferred + +- **Target-scoped container options** (e.g. hide a section in the student + build): deferred, stay out of structure. ADR-0009/0011 field-visibility / + per-target map already handles this in rendered output, not structure; keep it + there unless a concrete need forces it back into the manifest. +- **Additional container kinds** beyond `section` (e.g. a heading-less `group`): + deferred until a real need appears (open universe, ADR-0005). +- **Container metadata beyond `[group]`** (title/description): deferred — only + the minimal `[group]` table ships; richer container scalars await a concrete + authoring need. + +## Supersedes + +ADR-0008's "the ordering manifest is declarative" decision stands; this ADR +replaces its **flat `[[parts]]` encoding of order/membership** with the nested +per-folder outline. ADR-0008's other decisions (declarative `manifest.toml`/ +`element.toml`, folder self-description, content-file naming convention, schema +as source of truth for which `.typ` files exist) are unchanged and carry into +this tree form. diff --git a/docs/adr/0030-batch-and-combined-export.md b/docs/adr/0030-batch-and-combined-export.md new file mode 100644 index 0000000..1a7c8da --- /dev/null +++ b/docs/adr/0030-batch-and-combined-export.md @@ -0,0 +1,167 @@ +# ADR 0030: Batch & Combined Export + +## Status + +Accepted. **Extends/refines ADR-0009 and ADR-0011** (export target = a build +producing a typed artifact) by adding **two** export dimensions that today have +no home: (1) building **multiple targets of one lesson** in one batch, and (2) +**combining multiple lessons into one** artifact — a 讲义合集 / course bundle. +It does **not** redefine the SingleFile vs FileTree artifact distinction +(ADR-0011) or the single-`typstCompile`-step MVP; it adds the *collection* +semantics on top. + +## Context + +Today `cph build --target T` builds exactly one target `T` of one engineering +file into one artifact. Two real needs fall outside that: + +1. **A lesson's multiple versions.** A lesson already declares several targets + (student handout, teacher plan, slides, script). Producing all of them is + today N separate `cph build` invocations with no shared invocation, ordering, + or failure summary. Teachers preparing a lesson want "build the whole lesson + in all needed forms" as one action. + +2. **Combining lessons into one deliverable.** ADR-0005 deferred "course = + arrangement of lessons" — a course/unit is *not* an engineering file; it is + an arrangement of lessons "modeled elsewhere". The elsewhere is empty. A real + deliverable is a **讲义合集 / course bundle** — several lessons ordered into + one document (e.g. "期中复习合集", a term bundle, a topic compilation). This + spans multiple engineering files and currently has no model and no CLI path. + +Both are product-plain features (the 老师端 exports; a bundle is what a teacher +hands a class), not architectural speculation. + +## Decision + +### The existing single-lesson single-target build is the atomic unit + +ADR-0009/0011's model — a target is a build over one lesson producing one +`Artifact` via ordered `Step`s — is unchanged and remains the *unit*. Nothing +below replaces it; the new semantics are **aggregations over that unit**. + +### Dimension 1 — Batch: build a set of targets of one lesson + +`cph build` on a lesson gains the ability to produce **several targets in one +invocation**, as one batched operation: + +- The lesson root `manifest.toml` `[targets.*]` already enumerates the declared + targets and their order (ADR-0008/0011). Building "all declared targets" is the + default batch: each declared target builds to its own artifact + (`build/.{pdf,md}`), in declaration order. +- A batched build is **non-transactional and independent per target**: each + target is a separate build with its own artifact, own diagnostics, own + exit/result. One target failing (e.g. teacher plan PDF) does not block the rest + (student PDF), matching the per-target independent-failure stance of + ADR-0009's "missing render for a used kind ⇒ warning, non-blocking". +- The batch emits a **summary**: a per-target ledger (ok/failed + its artifact + or error), and a **non-zero aggregate exit if any target failed** to produce + its artifact. A target that fails to produce its artifact is a real defect + (this repo's fail-fast stance — don't paper over bugs), distinct from + ADR-0009's "missing render rule ⇒ warning" (a policy-level skip, non-error). + "Don't block the rest" still holds: every target is attempted, but a single + failure makes the aggregate non-zero so CI/observability catch it. This is the + CLI's job; it is the natural "build the whole lesson" affordance. + +### Dimension 2 — Combined: arrange multiple lessons into one artifact + +A **course bundle** is a new, lightweight, second kind of engineering-file-adjacent +unit: an **ordered arrangement of lessons** (ADR-0005's deferred "course = +arrangement of lessons" finally given a concrete export home). + +#### Bundle carrier: a directory containing `bundle.toml` + +A bundle is a **directory containing `bundle.toml`**. A directory gives the +bundle a stable root for relative lesson paths and a home for build output +(echoing ADR-0007's "engineering file = directory"). The `bundle.toml` carries: + +- `[info]` — the bundle's own title/author (of the 合集); +- `[targets.*]` — the bundle's build configuration, reusing ADR-0011's build + mechanism; +- an ordered `lessons` array — each entry: a lesson path (relative to the + bundle root, pointing at each engineering-file root, each a self-contained + directory tree per ADR-0007) plus optional per-lesson per-target overrides. + +A bundle target produces an **ordered concatenation/assembly of the lessons' +artifacts or content** into one `Artifact`, reusing the ADR-0011 artifact ADT: + +- `SingleFile` — a combined document (讲义合集): the lessons' content assembled + in order into one compiled document, with the existing cross-reference / + `@label` machinery working because it is one compiled document (the same + reason ADR-0011 gives for why SingleFile concatenation works at all). +- `FileTree` — each lesson to its own file plus a generated index (ADR-0011's + third-party-archive case, now with a first-class multi-lesson trigger). + +A bundle target's steps are the **ordered typed steps** of ADR-0011, but the +"map" now operates at the level of whole lessons rather than a single lesson's +parts: a step like `assembleLessons` (ordered inclusion of each lesson's +content/artifact) plus the existing `typstCompile`/`shell` steps for assembly +and any post-processing. Concretely the framework provides a +`typstCompile`-style step that pulls each listed lesson's content in order into +one document (mirroring how a single lesson's template pulls its parts). + +#### Renumbering in a `SingleFile` bundle: template-resident, default reset per lesson + +Numbering is presentation, which ADR-0011 already owns to the template file (not +the manifest), so the **bundle target's template decides** whether auto-counters +reset at lesson boundaries; the framework ships a helper to reset counters at a +lesson boundary. The **recommended default resets auto-counters at each lesson +boundary**: lessons are authored self-contained, so an internal "例题3" means +that lesson's 例题3; cross-lesson continuation would silently break author +references. A genuine "全书 continuous numbering" is an explicit template +override. `@label` cross-references stay global (resolved by label name, +independent of counters); only auto-increment counters reset. + +#### Bundles do not nest (MVP) + +A bundle references lessons only, not other bundles, until a real need appears — +mirrors the tree-nesting simplicity and keeps the first bundle target minimal. + +### Invariant: lessons stay self-contained; combination is export-time only + +Opening the door to combining lessons must **not** open the door to cross-lesson +imports inside a lesson's own content (ADR-0006's import boundary: within one +engineering file plus `@package`, never into a sibling lesson). A bundle is +allowed to *assemble already-authored lessons at export time* — reading their +content for the combined artifact — but no lesson's rich content may `import` +another lesson's internals as part of *its own* authoring. Combination is a +**projection over self-contained lessons**, exactly as a render target is a +projection over a lesson. This keeps each engineering file independently +checkable, buildable, and movable, and avoids reintroducing cross-file coupling +ADR-0006 explicitly rejected. + +### CLI surface + +- `cph build ` → all declared targets (the batch default). +- `cph build --target student --target teacher` → the named subset + (the multi-version batch), in the given order. +- `cph bundle --target ` → build one combined bundle target + (`SingleFile` merged doc or `FileTree`), giving the multi-lesson merge. + (`cph build` on a bundle root is the batch-of-bundle-targets equivalent.) + +## Consequences + +- **One lesson, many versions** is one command with a per-target ledger — the + natural 老师端 "导出全部版本" action, and any single failure surfaces as a + non-zero aggregate. +- **Course = arrangement of lessons** gets a concrete, export-focused home (the + bundle), discharging the ADR-0005 deferred item without inventing a full + course-authoring model. +- The **artifact/distinction and build-step machinery (ADR-0011) is reused** — a + bundle target is just a build whose inputs are whole lessons, not a new + parallel export engine. +- **Self-containment stays** (ADR-0006/0007): each lesson remains independently + checkable and buildable; the bundle only reads them for assembly. A lesson and + a bundle can version/evolve independently. +- The teacher surface can offer "export all versions" (batch) and "compile into + a 合集" (combined) as two concrete, productisible actions. + +## Open Questions / Deferred + +- **Bundle-of-bundles / nesting** — no nesting in MVP; re-open only when a real + need appears. +- **Dedup/caching across targets and lessons** — none in MVP, consistent with + ADR-0011's "no caching in MVP". +- **Exact counter-reset semantics in a `SingleFile` bundle** — the default + (reset per lesson) is decided; the precise mechanism (which counters, how the + template override is expressed) is settled with the first bundle template + implementation. From 9927d38c18549e74328f81897518e78161628252 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Tue, 4 Aug 2026 20:31:05 +0800 Subject: [PATCH 2/5] feat(cph): implement nested outline manifest and batch/combined export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0029 — nested outline manifest, supersedes ADR-0008's flat [[parts]]: - cph-model: recursive loader over manifest.toml containers / element.toml leaves; Lesson.parts (pure elements, DFS order) + Lesson.outline (elements interleaved with section headings at their DFS-open position); rejects ambiguous/incomplete folders and root-vs-container table misplacement - cph-diag: new DiagCode::ManifestMalformed for carrier-document structure errors (discharges an existing TODO) - cph-typst: augmented manifest now serializes the outline (element/section entries) instead of a flat parts array - render/lib.typ: render-lesson renders section headings at their depth - examples/TH-141 migrated to 5 nested section containers + 3 root segments, byte-identical element order; smoke-verified via cph check/build + pdftotext ADR-0030 — batch & combined export, extends ADR-0009/0011: - cph build with no --target batches every declared target (repeatable --target for an explicit subset); any target failure => non-zero exit, per-target ledger, independent per-target execution - cph-model: bundle.toml loader (directory + [info]/[targets.*]/ordered lessons with per-lesson target overrides) - cph-typst: augmented bundle manifest (path-prefixed member outlines), Engine::{compile_check_bundle,build_bundle_pdf} - render/lib.typ: render-bundle assembles member lessons under per-lesson headings, depth-shifts their own section headings, resets example/lemma counters at each lesson boundary by default - cph-cli: `cph bundle --target ` subcommand, same batching contract as `cph build` - new bundle fixtures/tests (cph-model unit + cph-typst through-template PDF compile), smoke-verified via a real 2-lesson merged PDF Verification: cargo fmt/clippy/test clean across the workspace (68 tests); real cph check/build/bundle runs against TH-141 and a bundle fixture, PDF content inspected via pdftotext. --- crates/README.md | 12 +- crates/cph-check/src/lib.rs | 95 ++- crates/cph-check/tests/pipeline.rs | 22 +- crates/cph-cli/src/main.rs | 187 ++++- crates/cph-diag/src/lib.rs | 13 +- crates/cph-model/src/lib.rs | 760 +++++++++++++++--- crates/cph-model/tests/bundle.rs | 83 ++ .../both-manifest-and-element/manifest.toml | 12 + .../segments/broken}/element.toml | 0 .../segments/broken/manifest.toml | 1 + .../fixtures/bundle-malformed/bundle.toml | 2 + .../bundle-missing-lesson/bundle.toml | 5 + .../tests/fixtures/bundle-valid/bundle.toml | 16 + .../bundle-valid/lesson-a/manifest.toml | 12 + .../lesson-a/segments/a}/element.toml | 0 .../lesson-a/segments/a/textbook.typ | 1 + .../bundle-valid/lesson-b/manifest.toml | 12 + .../bundle-valid/lesson-b/segments/b}/element.toml | 0 .../lesson-b/segments/b/textbook.typ | 1 + .../container-root-tables/manifest.toml | 12 + .../section/manifest.toml | 5 + .../fixtures/kind-mismatch/manifest.toml | 2 +- .../tests/fixtures/missing-part/manifest.toml | 4 +- .../manifest.toml | 12 + .../segments/broken/.gitkeep | 0 .../tests/fixtures/nested/manifest.toml | 16 + .../fixtures/nested/segments/开场白}/element.toml | 0 .../nested/segments/开场白/textbook.typ | 1 + .../fixtures/nested/导言簇/manifest.toml | 14 + .../nested/导言簇/segments/子段一}/element.toml | 0 .../导言簇/segments/子段一/textbook.typ | 1 + .../nested/导言簇/segments/子段二}/element.toml | 0 .../导言簇/segments/子段二/textbook.typ | 1 + .../导言簇/嵌套子节/lemmas/子引理}/element.toml | 0 .../导言簇/嵌套子节/lemmas/子引理/stmt.typ | 1 + .../nested/导言簇/嵌套子节/manifest.toml | 3 + .../root-group-declared/manifest.toml | 15 + .../root-group-declared/segments/a}/element.toml | 0 .../segments/a/textbook.typ | 1 + .../tests/fixtures/valid/manifest.toml | 4 +- crates/cph-model/tests/load.rs | 166 +++- crates/cph-typst/src/lib.rs | 83 +- crates/cph-typst/src/manifest.rs | 189 ++++- crates/cph-typst/tests/bundle.rs | 122 +++ crates/cph-typst/tests/compile.rs | 98 ++- .../tests/fixtures/bundle/bundle.toml | 16 + .../tests/fixtures/bundle/exports/merged.typ | 76 ++ .../fixtures/bundle/lesson-a/manifest.toml | 17 + .../fixtures/bundle/lesson-a/segments/a}/element.toml | 0 .../bundle/lesson-a/segments/a/textbook.typ | 1 + .../lesson-a/小节/lemmas/引理甲}/element.toml | 0 .../lesson-a/小节/lemmas/引理甲/stmt.typ | 1 + .../bundle/lesson-a/小节/manifest.toml | 6 + .../fixtures/bundle/lesson-b/manifest.toml | 12 + .../bundle/lesson-b/segments/b}/element.toml | 0 .../bundle/lesson-b/segments/b/textbook.typ | 1 + .../tests/fixtures/mini/exports/student.typ | 78 +- .../tests/fixtures/mini/exports/teacher.typ | 67 +- .../tests/fixtures/mini/manifest.toml | 14 +- .../mini/引理组/lemmas/无证明引理}/element.toml | 0 .../{ => 引理组}/lemmas/无证明引理/stmt.typ | 0 .../mini/引理组/lemmas/量纲分析估计}/element.toml | 0 .../lemmas/量纲分析估计/proof.typ | 0 .../{ => 引理组}/lemmas/量纲分析估计/stmt.typ | 0 .../tests/fixtures/mini/引理组/manifest.toml | 10 + examples/TH-141/exports/student.typ | 78 +- examples/TH-141/exports/teacher.typ | 67 +- examples/TH-141/manifest.toml | 160 +--- .../examples/41届复赛三-1-混注石油/element.toml | 0 .../examples/41届复赛三-1-混注石油/problem.typ | 0 .../examples/41届复赛三-1-混注石油/solution.typ | 0 .../lemmas/Eötvös规则}/element.toml | 0 .../lemmas/Eötvös规则/stmt.typ | 0 .../lemmas/Guggenheim-Katayama改良}/element.toml | 0 .../lemmas/Guggenheim-Katayama改良/stmt.typ | 0 .../lemmas/表面熵热力学关系}/element.toml | 0 .../lemmas/表面熵热力学关系/proof.typ | 0 .../lemmas/表面熵热力学关系/stmt.typ | 0 examples/TH-141/σTp态函数建模/manifest.toml | 34 + .../segments/σTp态函数导言}/element.toml | 0 .../segments/σTp态函数导言/textbook.typ | 0 .../segments/σT建模导言}/element.toml | 0 .../segments/σT建模导言/textbook.typ | 0 .../segments/σ作为态函数的图像}/element.toml | 0 .../segments/σ作为态函数的图像/textbook.typ | 0 .../segments/微观派Lm下降/element.toml | 0 .../segments/微观派Lm下降/textbook.typ | 0 .../lemmas/固体表面能的晶面各向异性}/element.toml | 0 .../lemmas/固体表面能的晶面各向异性/stmt.typ | 0 .../lemmas/拉伸固体的总应力}/element.toml | 0 .../lemmas/拉伸固体的总应力/proof.typ | 0 .../lemmas/拉伸固体的总应力/stmt.typ | 0 .../lemmas/缺键模型迁移到固气}/element.toml | 0 .../lemmas/缺键模型迁移到固气/stmt.typ | 0 examples/TH-141/固气界面/manifest.toml | 26 + .../segments/不同物质γ量级对比}/element.toml | 0 .../segments/不同物质γ量级对比/textbook.typ | 0 .../segments/固气界面导言}/element.toml | 0 .../segments/固气界面导言/textbook.typ | 0 .../segments/表面能γ与表面应力f}/element.toml | 0 .../segments/表面能γ与表面应力f/textbook.typ | 0 .../lemmas/Dupré关系}/element.toml | 0 .../{ => 固液界面}/lemmas/Dupré关系/proof.typ | 0 .../{ => 固液界面}/lemmas/Dupré关系/stmt.typ | 0 .../lemmas/GGZ浸润判据}/element.toml | 0 .../lemmas/GGZ浸润判据/proof.typ | 0 .../lemmas/GGZ浸润判据/stmt.typ | 0 .../lemmas/Girifalco-Good公式}/element.toml | 0 .../lemmas/Girifalco-Good公式/proof.typ | 0 .../lemmas/Girifalco-Good公式/stmt.typ | 0 .../lemmas/Young方程}/element.toml | 0 .../{ => 固液界面}/lemmas/Young方程/proof.typ | 0 .../{ => 固液界面}/lemmas/Young方程/stmt.typ | 0 examples/TH-141/固液界面/manifest.toml | 38 + .../segments/Fowkes极性修正}/element.toml | 0 .../segments/Fowkes极性修正/textbook.typ | 0 .../segments/三相接触导言}/element.toml | 0 .../segments/三相接触导言/textbook.typ | 0 .../segments/固液界面导言}/element.toml | 0 .../segments/固液界面导言/textbook.typ | 0 .../segments/固液界面能的物理图像/element.toml | 1 + .../segments/固液界面能的物理图像/textbook.typ | 0 .../segments/浸润全谱与高低能表面/element.toml | 1 + .../segments/浸润全谱与高低能表面/textbook.typ | 0 examples/TH-141/收束/manifest.toml | 14 + .../segments/各模型对水的预测对照/element.toml | 1 + .../segments/各模型对水的预测对照/textbook.typ | 0 .../收束/segments/收束导言/element.toml | 1 + .../{ => 收束}/segments/收束导言/textbook.typ | 0 .../segments/算不准背后的真实物理/element.toml | 1 + .../segments/算不准背后的真实物理/textbook.typ | 0 .../examples/41届复赛三-2-缺键模型/element.toml | 0 .../examples/41届复赛三-2-缺键模型/problem.typ | 0 .../examples/41届复赛三-2-缺键模型/solution.typ | 0 .../lemmas/LJ对势积分标度}/element.toml | 0 .../lemmas/LJ对势积分标度/proof.typ | 0 .../lemmas/LJ对势积分标度/stmt.typ | 0 .../lemmas/Stefan极简估算}/element.toml | 0 .../lemmas/Stefan极简估算/proof.typ | 0 .../lemmas/Stefan极简估算/stmt.typ | 0 .../lemmas/立方格子下zeta具体值}/element.toml | 0 .../lemmas/立方格子下zeta具体值/proof.typ | 0 .../lemmas/立方格子下zeta具体值/stmt.typ | 0 .../lemmas/缺键模型一般公式/element.toml | 1 + .../lemmas/缺键模型一般公式/proof.typ | 0 .../lemmas/缺键模型一般公式/stmt.typ | 0 .../液气界面/lemmas/量纲分析估计/element.toml | 1 + .../lemmas/量纲分析估计/stmt.typ | 0 examples/TH-141/液气界面/manifest.toml | 42 + .../液气界面/segments/LJ积分导言/element.toml | 1 + .../segments/LJ积分导言/textbook.typ | 0 .../segments/微观建模的共同骨架/element.toml | 1 + .../segments/微观建模的共同骨架/textbook.typ | 0 .../segments/液气界面导言/element.toml | 1 + .../segments/液气界面导言/textbook.typ | 0 .../segments/缺键模型导言/element.toml | 1 + .../segments/缺键模型导言/textbook.typ | 0 render/examples/smoke-eng/exports/student.typ | 78 +- render/examples/smoke-eng/exports/teacher.typ | 67 +- render/examples/smoke-eng/manifest.toml | 48 +- render/lib.typ | 128 ++- render/templates/README.md | 34 +- render/templates/merged.typ | 76 ++ render/templates/student.typ | 78 +- render/templates/teacher.typ | 67 +- 165 files changed, 2638 insertions(+), 691 deletions(-) create mode 100644 crates/cph-model/tests/bundle.rs create mode 100644 crates/cph-model/tests/fixtures/both-manifest-and-element/manifest.toml rename {examples/TH-141/segments/Fowkes极性修正 => crates/cph-model/tests/fixtures/both-manifest-and-element/segments/broken}/element.toml (100%) create mode 100644 crates/cph-model/tests/fixtures/both-manifest-and-element/segments/broken/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/bundle-malformed/bundle.toml create mode 100644 crates/cph-model/tests/fixtures/bundle-missing-lesson/bundle.toml create mode 100644 crates/cph-model/tests/fixtures/bundle-valid/bundle.toml create mode 100644 crates/cph-model/tests/fixtures/bundle-valid/lesson-a/manifest.toml rename {examples/TH-141/segments/LJ积分导言 => crates/cph-model/tests/fixtures/bundle-valid/lesson-a/segments/a}/element.toml (100%) create mode 100644 crates/cph-model/tests/fixtures/bundle-valid/lesson-a/segments/a/textbook.typ create mode 100644 crates/cph-model/tests/fixtures/bundle-valid/lesson-b/manifest.toml rename {examples/TH-141/segments/σTp态函数导言 => crates/cph-model/tests/fixtures/bundle-valid/lesson-b/segments/b}/element.toml (100%) create mode 100644 crates/cph-model/tests/fixtures/bundle-valid/lesson-b/segments/b/textbook.typ create mode 100644 crates/cph-model/tests/fixtures/container-root-tables/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/container-root-tables/section/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/neither-manifest-nor-element/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/neither-manifest-nor-element/segments/broken/.gitkeep create mode 100644 crates/cph-model/tests/fixtures/nested/manifest.toml rename {examples/TH-141/segments/σT建模导言 => crates/cph-model/tests/fixtures/nested/segments/开场白}/element.toml (100%) create mode 100644 crates/cph-model/tests/fixtures/nested/segments/开场白/textbook.typ create mode 100644 crates/cph-model/tests/fixtures/nested/导言簇/manifest.toml rename {examples/TH-141/segments/σ作为态函数的图像 => crates/cph-model/tests/fixtures/nested/导言簇/segments/子段一}/element.toml (100%) create mode 100644 crates/cph-model/tests/fixtures/nested/导言簇/segments/子段一/textbook.typ rename {examples/TH-141/segments/三相接触导言 => crates/cph-model/tests/fixtures/nested/导言簇/segments/子段二}/element.toml (100%) create mode 100644 crates/cph-model/tests/fixtures/nested/导言簇/segments/子段二/textbook.typ rename crates/{cph-typst/tests/fixtures/mini/lemmas/无证明引理 => cph-model/tests/fixtures/nested/导言簇/嵌套子节/lemmas/子引理}/element.toml (100%) create mode 100644 crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/lemmas/子引理/stmt.typ create mode 100644 crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/root-group-declared/manifest.toml rename {examples/TH-141/segments/不同物质γ量级对比 => crates/cph-model/tests/fixtures/root-group-declared/segments/a}/element.toml (100%) create mode 100644 crates/cph-model/tests/fixtures/root-group-declared/segments/a/textbook.typ create mode 100644 crates/cph-typst/tests/bundle.rs create mode 100644 crates/cph-typst/tests/fixtures/bundle/bundle.toml create mode 100644 crates/cph-typst/tests/fixtures/bundle/exports/merged.typ create mode 100644 crates/cph-typst/tests/fixtures/bundle/lesson-a/manifest.toml rename {examples/TH-141/segments/各模型对水的预测对照 => crates/cph-typst/tests/fixtures/bundle/lesson-a/segments/a}/element.toml (100%) create mode 100644 crates/cph-typst/tests/fixtures/bundle/lesson-a/segments/a/textbook.typ rename crates/cph-typst/tests/fixtures/{mini/lemmas/量纲分析估计 => bundle/lesson-a/小节/lemmas/引理甲}/element.toml (100%) create mode 100644 crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/lemmas/引理甲/stmt.typ create mode 100644 crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/manifest.toml create mode 100644 crates/cph-typst/tests/fixtures/bundle/lesson-b/manifest.toml rename {examples/TH-141/segments/固气界面导言 => crates/cph-typst/tests/fixtures/bundle/lesson-b/segments/b}/element.toml (100%) create mode 100644 crates/cph-typst/tests/fixtures/bundle/lesson-b/segments/b/textbook.typ rename {examples/TH-141/lemmas/Dupré关系 => crates/cph-typst/tests/fixtures/mini/引理组/lemmas/无证明引理}/element.toml (100%) rename crates/cph-typst/tests/fixtures/mini/{ => 引理组}/lemmas/无证明引理/stmt.typ (100%) rename {examples/TH-141/lemmas/Eötvös规则 => crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计}/element.toml (100%) rename crates/cph-typst/tests/fixtures/mini/{ => 引理组}/lemmas/量纲分析估计/proof.typ (100%) rename crates/cph-typst/tests/fixtures/mini/{ => 引理组}/lemmas/量纲分析估计/stmt.typ (100%) create mode 100644 crates/cph-typst/tests/fixtures/mini/引理组/manifest.toml rename examples/TH-141/{ => σTp态函数建模}/examples/41届复赛三-1-混注石油/element.toml (100%) rename examples/TH-141/{ => σTp态函数建模}/examples/41届复赛三-1-混注石油/problem.typ (100%) rename examples/TH-141/{ => σTp态函数建模}/examples/41届复赛三-1-混注石油/solution.typ (100%) rename examples/TH-141/{lemmas/GGZ浸润判据 => σTp态函数建模/lemmas/Eötvös规则}/element.toml (100%) rename examples/TH-141/{ => σTp态函数建模}/lemmas/Eötvös规则/stmt.typ (100%) rename examples/TH-141/{lemmas/Girifalco-Good公式 => σTp态函数建模/lemmas/Guggenheim-Katayama改良}/element.toml (100%) rename examples/TH-141/{ => σTp态函数建模}/lemmas/Guggenheim-Katayama改良/stmt.typ (100%) rename examples/TH-141/{lemmas/Guggenheim-Katayama改良 => σTp态函数建模/lemmas/表面熵热力学关系}/element.toml (100%) rename examples/TH-141/{ => σTp态函数建模}/lemmas/表面熵热力学关系/proof.typ (100%) rename examples/TH-141/{ => σTp态函数建模}/lemmas/表面熵热力学关系/stmt.typ (100%) create mode 100644 examples/TH-141/σTp态函数建模/manifest.toml rename examples/TH-141/{segments/固液界面导言 => σTp态函数建模/segments/σTp态函数导言}/element.toml (100%) rename examples/TH-141/{ => σTp态函数建模}/segments/σTp态函数导言/textbook.typ (100%) rename examples/TH-141/{segments/固液界面能的物理图像 => σTp态函数建模/segments/σT建模导言}/element.toml (100%) rename examples/TH-141/{ => σTp态函数建模}/segments/σT建模导言/textbook.typ (100%) rename examples/TH-141/{segments/微观建模的共同骨架 => σTp态函数建模/segments/σ作为态函数的图像}/element.toml (100%) rename examples/TH-141/{ => σTp态函数建模}/segments/σ作为态函数的图像/textbook.typ (100%) rename examples/TH-141/{ => σTp态函数建模}/segments/微观派Lm下降/element.toml (100%) rename examples/TH-141/{ => σTp态函数建模}/segments/微观派Lm下降/textbook.typ (100%) rename examples/TH-141/{lemmas/LJ对势积分标度 => 固气界面/lemmas/固体表面能的晶面各向异性}/element.toml (100%) rename examples/TH-141/{ => 固气界面}/lemmas/固体表面能的晶面各向异性/stmt.typ (100%) rename examples/TH-141/{lemmas/Stefan极简估算 => 固气界面/lemmas/拉伸固体的总应力}/element.toml (100%) rename examples/TH-141/{ => 固气界面}/lemmas/拉伸固体的总应力/proof.typ (100%) rename examples/TH-141/{ => 固气界面}/lemmas/拉伸固体的总应力/stmt.typ (100%) rename examples/TH-141/{lemmas/Young方程 => 固气界面/lemmas/缺键模型迁移到固气}/element.toml (100%) rename examples/TH-141/{ => 固气界面}/lemmas/缺键模型迁移到固气/stmt.typ (100%) create mode 100644 examples/TH-141/固气界面/manifest.toml rename examples/TH-141/{segments/收束导言 => 固气界面/segments/不同物质γ量级对比}/element.toml (100%) rename examples/TH-141/{ => 固气界面}/segments/不同物质γ量级对比/textbook.typ (100%) rename examples/TH-141/{segments/浸润全谱与高低能表面 => 固气界面/segments/固气界面导言}/element.toml (100%) rename examples/TH-141/{ => 固气界面}/segments/固气界面导言/textbook.typ (100%) rename examples/TH-141/{segments/液气界面导言 => 固气界面/segments/表面能γ与表面应力f}/element.toml (100%) rename examples/TH-141/{ => 固气界面}/segments/表面能γ与表面应力f/textbook.typ (100%) rename examples/TH-141/{lemmas/固体表面能的晶面各向异性 => 固液界面/lemmas/Dupré关系}/element.toml (100%) rename examples/TH-141/{ => 固液界面}/lemmas/Dupré关系/proof.typ (100%) rename examples/TH-141/{ => 固液界面}/lemmas/Dupré关系/stmt.typ (100%) rename examples/TH-141/{lemmas/拉伸固体的总应力 => 固液界面/lemmas/GGZ浸润判据}/element.toml (100%) rename examples/TH-141/{ => 固液界面}/lemmas/GGZ浸润判据/proof.typ (100%) rename examples/TH-141/{ => 固液界面}/lemmas/GGZ浸润判据/stmt.typ (100%) rename examples/TH-141/{lemmas/立方格子下zeta具体值 => 固液界面/lemmas/Girifalco-Good公式}/element.toml (100%) rename examples/TH-141/{ => 固液界面}/lemmas/Girifalco-Good公式/proof.typ (100%) rename examples/TH-141/{ => 固液界面}/lemmas/Girifalco-Good公式/stmt.typ (100%) rename examples/TH-141/{lemmas/缺键模型一般公式 => 固液界面/lemmas/Young方程}/element.toml (100%) rename examples/TH-141/{ => 固液界面}/lemmas/Young方程/proof.typ (100%) rename examples/TH-141/{ => 固液界面}/lemmas/Young方程/stmt.typ (100%) create mode 100644 examples/TH-141/固液界面/manifest.toml rename examples/TH-141/{segments/算不准背后的真实物理 => 固液界面/segments/Fowkes极性修正}/element.toml (100%) rename examples/TH-141/{ => 固液界面}/segments/Fowkes极性修正/textbook.typ (100%) rename examples/TH-141/{segments/缺键模型导言 => 固液界面/segments/三相接触导言}/element.toml (100%) rename examples/TH-141/{ => 固液界面}/segments/三相接触导言/textbook.typ (100%) rename examples/TH-141/{segments/表面能γ与表面应力f => 固液界面/segments/固液界面导言}/element.toml (100%) rename examples/TH-141/{ => 固液界面}/segments/固液界面导言/textbook.typ (100%) create mode 100644 examples/TH-141/固液界面/segments/固液界面能的物理图像/element.toml rename examples/TH-141/{ => 固液界面}/segments/固液界面能的物理图像/textbook.typ (100%) create mode 100644 examples/TH-141/固液界面/segments/浸润全谱与高低能表面/element.toml rename examples/TH-141/{ => 固液界面}/segments/浸润全谱与高低能表面/textbook.typ (100%) create mode 100644 examples/TH-141/收束/manifest.toml create mode 100644 examples/TH-141/收束/segments/各模型对水的预测对照/element.toml rename examples/TH-141/{ => 收束}/segments/各模型对水的预测对照/textbook.typ (100%) create mode 100644 examples/TH-141/收束/segments/收束导言/element.toml rename examples/TH-141/{ => 收束}/segments/收束导言/textbook.typ (100%) create mode 100644 examples/TH-141/收束/segments/算不准背后的真实物理/element.toml rename examples/TH-141/{ => 收束}/segments/算不准背后的真实物理/textbook.typ (100%) rename examples/TH-141/{ => 液气界面}/examples/41届复赛三-2-缺键模型/element.toml (100%) rename examples/TH-141/{ => 液气界面}/examples/41届复赛三-2-缺键模型/problem.typ (100%) rename examples/TH-141/{ => 液气界面}/examples/41届复赛三-2-缺键模型/solution.typ (100%) rename examples/TH-141/{lemmas/缺键模型迁移到固气 => 液气界面/lemmas/LJ对势积分标度}/element.toml (100%) rename examples/TH-141/{ => 液气界面}/lemmas/LJ对势积分标度/proof.typ (100%) rename examples/TH-141/{ => 液气界面}/lemmas/LJ对势积分标度/stmt.typ (100%) rename examples/TH-141/{lemmas/表面熵热力学关系 => 液气界面/lemmas/Stefan极简估算}/element.toml (100%) rename examples/TH-141/{ => 液气界面}/lemmas/Stefan极简估算/proof.typ (100%) rename examples/TH-141/{ => 液气界面}/lemmas/Stefan极简估算/stmt.typ (100%) rename examples/TH-141/{lemmas/量纲分析估计 => 液气界面/lemmas/立方格子下zeta具体值}/element.toml (100%) rename examples/TH-141/{ => 液气界面}/lemmas/立方格子下zeta具体值/proof.typ (100%) rename examples/TH-141/{ => 液气界面}/lemmas/立方格子下zeta具体值/stmt.typ (100%) create mode 100644 examples/TH-141/液气界面/lemmas/缺键模型一般公式/element.toml rename examples/TH-141/{ => 液气界面}/lemmas/缺键模型一般公式/proof.typ (100%) rename examples/TH-141/{ => 液气界面}/lemmas/缺键模型一般公式/stmt.typ (100%) create mode 100644 examples/TH-141/液气界面/lemmas/量纲分析估计/element.toml rename examples/TH-141/{ => 液气界面}/lemmas/量纲分析估计/stmt.typ (100%) create mode 100644 examples/TH-141/液气界面/manifest.toml create mode 100644 examples/TH-141/液气界面/segments/LJ积分导言/element.toml rename examples/TH-141/{ => 液气界面}/segments/LJ积分导言/textbook.typ (100%) create mode 100644 examples/TH-141/液气界面/segments/微观建模的共同骨架/element.toml rename examples/TH-141/{ => 液气界面}/segments/微观建模的共同骨架/textbook.typ (100%) create mode 100644 examples/TH-141/液气界面/segments/液气界面导言/element.toml rename examples/TH-141/{ => 液气界面}/segments/液气界面导言/textbook.typ (100%) create mode 100644 examples/TH-141/液气界面/segments/缺键模型导言/element.toml rename examples/TH-141/{ => 液气界面}/segments/缺键模型导言/textbook.typ (100%) create mode 100644 render/templates/merged.typ diff --git a/crates/README.md b/crates/README.md index 237c8a5..e47838a 100644 --- a/crates/README.md +++ b/crates/README.md @@ -3,10 +3,12 @@ These crates implement the rule-based lesson checker whose semantics are pinned by the ADRs in `docs/adr/`: it reads an engineering-file (one lesson, ADR-0005) -laid out per ADR-0008 (declarative `manifest.toml` + per-element -`element.toml`), validates structure and content, and emits diagnostics. -`cph-diag` (the shared diagnostic vocabulary), `cph-model` (the ADR-0008 loader), -and `cph-typst` (the typst `World` / compile / span-mapping layer) are +laid out per ADR-0029 (a nested outline manifest — every container folder +carries `manifest.toml`, every leaf carries `element.toml`; supersedes +ADR-0008's flat `[[parts]]`), validates structure and content, and emits +diagnostics. `cph-diag` (the shared diagnostic vocabulary), `cph-model` (the +ADR-0029 loader, also loading `bundle.toml` arrangements per ADR-0030), and +`cph-typst` (the typst `World` / compile / span-mapping layer) are deliberately reusable by future components such as an `exporter`, which is why they live in this repo-wide `crates/` directory rather than under any single component; `cph-schema` (kind JSON Schemas + validation), `cph-check` @@ -18,7 +20,7 @@ entrypoint) are the checker proper. | crate | owner | role | |---------------|-------|------| | `cph-diag` | WU-1 | shared diagnostic vocabulary (`Severity`, `DiagCode`, `Diagnostic`, `SourceSpan`) — reusable | -| `cph-model` | WU-1 | parses the ADR-0008 layout into an in-memory ordered `Lesson` — reusable | +| `cph-model` | WU-1 | parses the ADR-0029 nested outline layout (+ ADR-0030 bundles) into an in-memory ordered `Lesson`/`Bundle` — reusable | | `cph-schema` | WU-3 | the 4 stdlib kind JSON Schemas + structural validation | | `cph-typst` | WU-4 | typst `World`, driver generation, compile, PDF, span mapping — reusable | | `cph-check` | WU-5 | orchestration: render-coverage and the full check pipeline | diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs index c925c63..8653abd 100644 --- a/crates/cph-check/src/lib.rs +++ b/crates/cph-check/src/lib.rs @@ -196,6 +196,96 @@ pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option>, Ch } } +/// Build a PDF for a **bundle** target (ADR-0030): the multi-lesson combined +/// artifact. Mirrors [`build`]'s contract and gating, but runs phases (a)–(c) +/// over **every member lesson independently** (ADR-0030's invariant: each +/// lesson stays independently checkable; the bundle only reads them for +/// assembly). Any member's structural/schema error refuses the whole bundle +/// build — a broken member lesson makes the combined artifact invalid too. +pub fn build_bundle(root: &Path, engine: &Engine, target: &str) -> (Option>, CheckReport) { + let mut diags = Vec::new(); + + let (bundle, load_diags) = cph_model::load_bundle(root); + diags.extend(load_diags); + + let Some(bundle) = bundle else { + return ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: false, + }, + ); + }; + + let known = cph_schema::known_kinds(); + for member in &bundle.lessons { + run_structural_and_schema(&member.lesson, known, &mut diags); + } + + if diags.iter().any(|d| d.severity == Severity::Error) { + return ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + ); + } + + match engine.build_bundle_pdf(&bundle, target) { + Ok(bytes) => ( + Some(bytes), + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + ), + Err(compile_diags) => { + diags.extend(compile_diags); + ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }, + ) + } + } +} + +/// The bundle's declared export-target names, in declared order — or +/// `[DEFAULT_TARGET]` if it declares none (ADR-0030 batch default, mirroring +/// [`declared_target_names`]). +pub fn declared_bundle_target_names(root: &Path) -> Vec { + let (bundle, _) = cph_model::load_bundle(root); + match bundle { + Some(b) if !b.targets.is_empty() => { + b.target_names().into_iter().map(String::from).collect() + } + _ => vec![DEFAULT_TARGET.to_string()], + } +} + +/// The lesson's declared export-target names, in declared order — or +/// `[DEFAULT_TARGET]` if it declares none (ADR-0030 batch default: `cph build` +/// with no `--target` builds every declared target). +/// +/// Loads the lesson read-only, ignoring diagnostics: an unloadable lesson (or +/// one with a malformed root manifest) still yields `[DEFAULT_TARGET]` here so +/// the caller's subsequent per-target build attempt is what surfaces the real +/// load error — this helper only resolves *which names to attempt*, never +/// gates on lesson validity. +pub fn declared_target_names(root: &Path) -> Vec { + let (lesson, _) = cph_model::load(root); + match lesson { + Some(l) if !l.targets.is_empty() => { + l.target_names().into_iter().map(String::from).collect() + } + _ => vec![DEFAULT_TARGET.to_string()], + } +} + /// One shell step's execution outcome (for [`run_shell_target`]). #[derive(Debug, Clone, PartialEq)] pub struct ShellStepOutcome { @@ -441,8 +531,9 @@ pub struct MarkdownAssembleReport { } /// Execute the `AssembleMarkdown` steps of a target (ADR-0015): concatenate each -/// element's `.md` markdown content file in `[[parts]]` order into the -/// target's single-file artifact. This is the **third typed step**: unlike +/// element's `.md` markdown content file in `parts` order (ADR-0029's +/// depth-first element sequence) into the target's single-file artifact. +/// This is the **third typed step**: unlike /// [`build`] (typst template → PDF) the framework owns the read/concatenate/write /// itself (not a typst compile, not an external tool like [`run_shell_target`]). /// diff --git a/crates/cph-check/tests/pipeline.rs b/crates/cph-check/tests/pipeline.rs index a7e6398..8e27402 100644 --- a/crates/cph-check/tests/pipeline.rs +++ b/crates/cph-check/tests/pipeline.rs @@ -59,7 +59,7 @@ name = "broken" [info] title = "broken" -[[parts]] +[[children]] kind = "frob" path = "elements/widget" "#, @@ -123,7 +123,7 @@ name = "broken" [info] title = "broken" -[[parts]] +[[children]] kind = "segment" path = "segments/does-not-exist" "#, @@ -150,7 +150,7 @@ name = "cov" [info] title = "cov" -[[parts]] +[[children]] kind = "segment" path = "segments/intro" @@ -276,7 +276,7 @@ name = "sh" [info] title = "sh" -[[parts]] +[[children]] kind = "segment" path = "segments/intro" @@ -350,7 +350,7 @@ name = "sh" [info] title = "sh" -[[parts]] +[[children]] kind = "segment" path = "segments/missing" @@ -390,7 +390,7 @@ fn write_markdown_assemble_target_lesson(tmp: &Path, slides: &[(&str, &str)]) { parts.push('\n'); } parts.push_str(&format!( - "[[parts]]\nkind = \"segment\"\npath = \"segments/{name}\"\n" + "[[children]]\nkind = \"segment\"\npath = \"segments/{name}\"\n" )); } std::fs::write( @@ -468,8 +468,8 @@ fn run_markdown_assemble_target_skips_parts_without_the_field() { // Only the first segment has a slides.md; the second is skipped (optional). let tmp = tempdir(); let mut parts = String::new(); - parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n\n"); - parts.push_str("[[parts]]\nkind = \"segment\"\npath = \"segments/b\"\n"); + parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n\n"); + parts.push_str("[[children]]\nkind = \"segment\"\npath = \"segments/b\"\n"); std::fs::write( tmp.join("manifest.toml"), format!( @@ -527,7 +527,7 @@ name = "md" [info] title = "md" -[[parts]] +[[children]] kind = "segment" path = "segments/a" @@ -584,7 +584,7 @@ name = "md" [info] title = "md" -[[parts]] +[[children]] kind = "segment" path = "segments/a" @@ -630,7 +630,7 @@ name = "md" [info] title = "md" -[[parts]] +[[children]] kind = "segment" path = "segments/missing" diff --git a/crates/cph-cli/src/main.rs b/crates/cph-cli/src/main.rs index c285028..9ad5c10 100644 --- a/crates/cph-cli/src/main.rs +++ b/crates/cph-cli/src/main.rs @@ -35,14 +35,38 @@ enum Command { /// Path to the engineering-file root (the folder with `manifest.toml`). path: PathBuf, }, - /// Build a PDF for a render target. Exits 1 if the build fails. + /// Build one or more render targets. Exits 1 if any target fails. + /// + /// With no `--target`, batches every target the lesson declares + /// (ADR-0030): each target builds independently — one failing does not + /// stop the rest — and the exit code is non-zero if any target failed. + /// Repeat `--target` to build an explicit ordered subset instead. Build { /// Path to the engineering-file root (the folder with `manifest.toml`). path: PathBuf, - /// Render target to export. - #[arg(long, default_value = "student")] - target: String, - /// Output PDF path. Defaults to `/build/.pdf`. + /// Render target(s) to export. Repeatable. Defaults to every target + /// the lesson declares (or `student` if it declares none). + #[arg(long = "target")] + targets: Vec, + /// Output path for a *single*-target build. Defaults to + /// `/build/.pdf`. Rejected when building more than one + /// target (ambiguous: which target would it name?). + #[arg(short = 'o', long, value_name = "OUT")] + out: Option, + }, + /// Build one or more bundle targets (ADR-0030): combine an ordered + /// arrangement of self-contained lessons (`bundle.toml`) into one + /// artifact. Same batching/exit-code contract as `build`. + Bundle { + /// Path to the bundle root (the folder with `bundle.toml`). + path: PathBuf, + /// Bundle target(s) to export. Repeatable. Defaults to every target + /// the bundle declares (or `student` if it declares none). + #[arg(long = "target")] + targets: Vec, + /// Output path for a *single*-target build. Defaults to + /// `/build/.pdf`. Rejected when building more than one + /// target. #[arg(short = 'o', long, value_name = "OUT")] out: Option, }, @@ -74,7 +98,8 @@ fn main() -> ExitCode { match cli.command { Command::Check { path } => run_check(&path, &engine), - Command::Build { path, target, out } => run_build(&path, &engine, &target, out), + Command::Build { path, targets, out } => run_build_command(&path, &engine, targets, out), + Command::Bundle { path, targets, out } => run_bundle_command(&path, &engine, targets, out), Command::Completions { shell } => run_completions(shell), } } @@ -132,25 +157,76 @@ fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode { } } -fn run_build( +/// Dispatch `cph build` (ADR-0030): with an explicit `--target` (repeatable), +/// build exactly that ordered set; with none, batch every target the lesson +/// declares. Each target builds **independently** — one failing does not stop +/// the rest — and prints a per-target ledger when building more than one. +/// Exits non-zero iff **any** target failed to produce its artifact (a build +/// failure is a real defect, distinct from the non-blocking `renderIgnored` +/// warning class — ADR-0030). +fn run_build_command( + path: &std::path::Path, + engine: &Engine, + targets: Vec, + out: Option, +) -> ExitCode { + let target_list = if targets.is_empty() { + cph_check::declared_target_names(path) + } else { + targets + }; + + if target_list.len() > 1 && out.is_some() { + eprintln!( + "error: -o/--out only applies to a single-target build; pass exactly one --target with -o" + ); + return ExitCode::FAILURE; + } + + let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len()); + for target in &target_list { + if target_list.len() > 1 { + eprintln!("=== target '{target}' ==="); + } + let ok = run_build_one(path, engine, target, out.clone()); + results.push((target.clone(), ok)); + } + + if target_list.len() > 1 { + eprintln!("--- build summary ---"); + for (target, ok) in &results { + eprintln!("{target}: {}", if *ok { "ok" } else { "failed" }); + } + } + + if results.iter().any(|(_, ok)| !ok) { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } +} + +/// Build one target, returning whether it succeeded. Routes to the shell, +/// markdown-assemble, or typst-compile path per the target's step shape. +fn run_build_one( path: &std::path::Path, engine: &Engine, target: &str, out: Option, -) -> ExitCode { +) -> bool { // A target whose steps are shell commands (a tool-generated asset bundle, // ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by // executing those commands, not by compiling a typst template. Detect that // shape up front and route accordingly. if cph_check::target_is_shell(path, target) { - return run_shell_build(path, engine, target); + return run_shell_build(path, engine, target) == ExitCode::SUCCESS; } // A target whose steps assemble markdown (ADR-0015: slides outline / 逐字稿 // transcript surfaces) is built by concatenating per-element `.md` // files in parts order, not by compiling a typst template. if cph_check::target_is_markdown_assemble(path, target) { - return run_markdown_assemble_build(path, engine, target); + return run_markdown_assemble_build(path, engine, target) == ExitCode::SUCCESS; } let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf"))); @@ -166,19 +242,102 @@ fn run_build( "error: cannot create output directory '{}': {e}", parent.display() ); - return ExitCode::FAILURE; + return false; } } if let Err(e) = std::fs::write(&out_path, &bytes) { eprintln!("error: cannot write '{}': {e}", out_path.display()); - return ExitCode::FAILURE; + return false; } println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); - ExitCode::SUCCESS + true } None => { eprintln!("build failed: {} errors", report.error_count()); - ExitCode::FAILURE + false + } + } +} + +/// Dispatch `cph bundle` (ADR-0030) — same batching/exit-code contract as +/// [`run_build_command`], over a bundle's own declared targets. MVP bundle +/// targets are `typst-compile` only (no shell/markdown-assemble routing — +/// ADR-0030 did not extend those step kinds to bundles). +fn run_bundle_command( + path: &std::path::Path, + engine: &Engine, + targets: Vec, + out: Option, +) -> ExitCode { + let target_list = if targets.is_empty() { + cph_check::declared_bundle_target_names(path) + } else { + targets + }; + + if target_list.len() > 1 && out.is_some() { + eprintln!( + "error: -o/--out only applies to a single-target build; pass exactly one --target with -o" + ); + return ExitCode::FAILURE; + } + + let mut results: Vec<(String, bool)> = Vec::with_capacity(target_list.len()); + for target in &target_list { + if target_list.len() > 1 { + eprintln!("=== target '{target}' ==="); + } + let ok = run_bundle_one(path, engine, target, out.clone()); + results.push((target.clone(), ok)); + } + + if target_list.len() > 1 { + eprintln!("--- build summary ---"); + for (target, ok) in &results { + eprintln!("{target}: {}", if *ok { "ok" } else { "failed" }); + } + } + + if results.iter().any(|(_, ok)| !ok) { + ExitCode::FAILURE + } else { + ExitCode::SUCCESS + } +} + +/// Build one bundle target, returning whether it succeeded. +fn run_bundle_one( + path: &std::path::Path, + engine: &Engine, + target: &str, + out: Option, +) -> bool { + let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf"))); + + let (pdf, report) = cph_check::build_bundle(path, engine, target); + print_diagnostics(&report); + + match pdf { + Some(bytes) => { + if let Some(parent) = out_path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!( + "error: cannot create output directory '{}': {e}", + parent.display() + ); + return false; + } + } + if let Err(e) = std::fs::write(&out_path, &bytes) { + eprintln!("error: cannot write '{}': {e}", out_path.display()); + return false; + } + println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); + true + } + None => { + eprintln!("build failed: {} errors", report.error_count()); + false } } } diff --git a/crates/cph-diag/src/lib.rs b/crates/cph-diag/src/lib.rs index 4e2bdbf..d92f825 100644 --- a/crates/cph-diag/src/lib.rs +++ b/crates/cph-diag/src/lib.rs @@ -65,7 +65,8 @@ pub struct SourceSpan { /// Do not invent codes outside this enum without a deliberate decision. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] pub enum DiagCode { - /// A `[[parts]]` entry references a folder/path that does not exist. + /// A `[[parts]]`/outline entry references a folder/path that does not + /// exist. PartPathMissing, /// An element declares a `kind` that is not a known kind. UnknownKind, @@ -86,6 +87,15 @@ pub enum DiagCode { /// The engineering file's `.cph-version` is not compatible with the running /// CLI's version (ADR-0016). Decided at load time; `error` severity. CphVersionMismatch, + /// A `manifest.toml`/`bundle.toml` is structurally broken: invalid TOML, a + /// required table missing (root `[project]`/`[info]`), a folder that is + /// neither a container (`manifest.toml`) nor a leaf (`element.toml`) — or + /// is ambiguously both (ADR-0029) — or a bundle `lessons` entry malformed + /// (ADR-0030). Distinct from `SchemaViolation` (instance data vs. its + /// kind's schema): this code is for the *carrier document's own* + /// structure. Added to discharge the manifest-level errors that used to + /// overload `SchemaViolation` before this code existed. + ManifestMalformed, } impl DiagCode { @@ -102,6 +112,7 @@ impl DiagCode { DiagCode::TypstCompile => "E-TYPST-COMPILE", DiagCode::RenderIgnored => "W-RENDER-IGNORED", DiagCode::CphVersionMismatch => "E-CPH-VERSION", + DiagCode::ManifestMalformed => "E-MANIFEST", } } } diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs index 09bc2be..f48df48 100644 --- a/crates/cph-model/src/lib.rs +++ b/crates/cph-model/src/lib.rs @@ -1,58 +1,96 @@ -//! `cph-model` — load the ADR-0008 declarative layout into an in-memory lesson. +//! `cph-model` — load the ADR-0029 nested outline manifest into an in-memory +//! lesson. //! -//! This crate is the **loader**, not the full checker. It reads -//! `/manifest.toml` (project / info / ordered `[[parts]]` / declared -//! `[targets.*]`) and each part's `//element.toml`, and produces an -//! ordered [`Lesson`], where the order of `parts` carries teaching semantics. +//! This crate is the **loader**, not the full checker. A lesson's structure is +//! a folder tree (ADR-0007): every folder that groups children carries a +//! `manifest.toml` (root or internal — see [`load`]); every leaf carries an +//! `element.toml` (ADR-0008's element descriptor, unchanged). The loader walks +//! that tree depth-first and produces: +//! +//! - [`Lesson::parts`] — the **ordered element sequence** (ADR-0005): pure +//! elements, no containers. This is what `cph-check`/`cph-schema`/the render +//! pipeline validate and include content from — unaffected by nesting. +//! - [`Lesson::outline`] — the **full rendering-order sequence**: elements +//! interleaved with section headings, at the depth-first traversal position +//! they open at (ADR-0029). This is what the augmented manifest (built by +//! `cph-typst`) walks to hand the template a rendering order that includes +//! headings. //! //! Scope boundaries (deliberately staying in lane): -//! - It validates **structure** only: manifest shape, element.toml shape, and -//! the cross-check `part.kind == element.toml kind`. +//! - It validates **structure** only: manifest/outline shape, element.toml +//! shape, the leaf/container discriminator, and the cross-check +//! `part.kind == element.toml kind`. //! - It does **not** validate instance data against a kind's JSON Schema (that -//! is WU-3 / `cph-schema`), does **not** check that `content` `.typ` files -//! exist (also schema-driven, WU-3), and does **not** compile typst (WU-4). +//! is `cph-schema`), does **not** check that `content` `.typ` files exist +//! (also schema-driven), does **not** compile typst, and does **not** +//! validate a container's declared kind against the known container-kind set +//! (that is `cph-check`'s job, mirroring how it also owns the known +//! *element*-kind check). //! //! Entry point: [`load`]. use std::path::{Component, Path, PathBuf}; -use cph_diag::{DiagCode, Diagnostic}; +use cph_diag::{DiagCode, Diagnostic, Severity}; use serde::{Deserialize, Serialize}; -/// An ordered, in-memory lesson loaded from an engineering file. -/// -/// `parts` is an ordered -/// `Vec`, and that order is the lesson's order (ADR-0008 §"the lesson manifest -/// is declarative" — the `[[parts]]` array order is the single source of truth). +/// An in-memory lesson loaded from an engineering file (ADR-0029). #[derive(Debug, Clone, PartialEq, Serialize)] pub struct Lesson { - /// `[project]` from the manifest (id, name). + /// `[project]` from the root manifest (id, name). pub project: Project, - /// `[info]` from the manifest (title, optional author). + /// `[info]` from the root manifest (title, optional author). pub info: Info, - /// The ordered parts — the lesson's element sequence. + /// The ordered **element** sequence — the lesson's element order (ADR-0005). + /// Contains only leaves; containers never appear here (a container + /// "contributes no element of its own", ADR-0029). Index-stable: an + /// [`OutlineEntry::Element`] names a position in this `Vec` by index. pub parts: Vec, - /// Declared export targets, collected from the `[targets.]` tables. - /// - /// Per ADR-0009/0011 an export target is a *build* producing a typed - /// [`Artifact`] via an ordered list of typed [`Step`]s. This carries those - /// in declared (TOML document) order. See [`TargetConfig`]. + /// The full depth-first rendering order: elements (by index into `parts`) + /// interleaved with section headings, at the position they open in the + /// tree (ADR-0029). Consumed by the augmented-manifest builder so a + /// template can render headings in their real position; `cph-check`'s + /// structural/schema/coverage phases do not need it (they use `parts`). + pub outline: Vec, + /// Declared export targets, collected from the root manifest's + /// `[targets.]` tables (ADR-0009/0011). pub targets: Vec, - /// Engineering-file root (absolute), for resolving part paths. + /// Engineering-file root (absolute), for resolving part/container paths. pub root: PathBuf, } impl Lesson { /// The declared export-target names, in declared order. - /// - /// Convenience for callers that only need the names (the shape this crate - /// exposed before ADR-0009 turned `targets` into structured - /// [`TargetConfig`]s). pub fn target_names(&self) -> Vec<&str> { self.targets.iter().map(|t| t.name.as_str()).collect() } } +/// One entry in the lesson's full rendering-order sequence (ADR-0029). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub enum OutlineEntry { + /// An element at this position: `part_index` into [`Lesson::parts`]. + Element { + /// Index into `Lesson::parts`. + part_index: usize, + }, + /// A section container opens here. Contributes no element; it is a + /// heading, not a part (ADR-0029). `depth` is the section's nesting depth + /// (1 = a section directly under the engineering-file root). + Section { + /// The container's declared kind (MVP: always `"section"`; validated + /// against the known container-kind set by `cph-check`, not here). + kind: String, + /// Heading text: the container's `[group].title` if present and + /// non-empty, else the folder's basename. + title: String, + /// Nesting depth (1-based) — the heading level a renderer should use. + depth: u32, + /// The container folder's path, relative to the engineering-file root. + path: PathBuf, + }, +} + /// One declared export target's build config (ADR-0009/0011). /// /// An export target is a **build** producing a typed [`Artifact`] via an @@ -185,7 +223,7 @@ pub enum Step { run: String, }, /// Assemble a single-file markdown deliverable by concatenating each - /// element's `field` markdown content file in `[[parts]]` order. ADR-0011 + /// element's `field` markdown content file in `parts` order. ADR-0011 /// `assembleMarkdown` (ADR-0015). Not a typst build — the /// framework owns the read/concatenate/write itself. AssembleMarkdown { @@ -205,7 +243,7 @@ impl Step { } } -/// `[project]` table. +/// `[project]` table (root manifest only). #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct Project { /// Stable project id. @@ -214,7 +252,8 @@ pub struct Project { pub name: String, } -/// `[info]` table (passed through to render targets verbatim). +/// `[info]` table (root manifest only; passed through to render targets +/// verbatim). /// /// The *canonical* model whose /// `authors` is always a list. The authoring-surface form (string-or-array @@ -232,13 +271,23 @@ pub struct Info { pub authors: Vec, } -/// One `[[parts]]` entry plus its loaded element descriptor. +/// One element leaf, plus its loaded `element.toml` descriptor. +/// +/// `path` is the element folder's path relative to the engineering-file root, +/// regardless of how deeply nested it is in the outline tree (ADR-0029) — the +/// loader accumulates full root-relative paths during the depth-first walk, so +/// every downstream consumer (schema validation, augmented-manifest include +/// paths) keeps working against a root-relative path exactly as before ADR-0029. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct Part { - /// Declared kind from the manifest (one of the known kinds; ADR-0006). + /// Declared kind from the containing outline entry (ADR-0006). This is the + /// **declared** kind, which may disagree with the `element.toml` kind (a + /// disagreement is reported as [`DiagCode::UnknownKind`], but the declared + /// kind — not the descriptor's — is what `cph-check`'s known-kind check + /// tests, matching pre-ADR-0029 behavior). pub kind: String, - /// Element folder path **as written in the manifest** (relative to root), - /// kept verbatim for diagnostics / display. + /// Element folder path, root-relative, kept verbatim (forward/back slashes + /// as the OS provides) for diagnostics/display. pub path: PathBuf, /// The element's self-description loaded from its `element.toml`. pub descriptor: ElementDescriptor, @@ -250,26 +299,32 @@ pub struct Part { /// directory), so a folder is self-describing. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct ElementDescriptor { - /// `kind` from `element.toml`. Must equal the part's `kind`; a mismatch is - /// reported as a diagnostic (see [`load`]). + /// `kind` from `element.toml`. Must equal the part's declared `kind`; a + /// mismatch is reported as a diagnostic (see [`load`]). pub kind: String, /// Element folder, absolute path. pub dir: PathBuf, /// The remaining `element.toml` keys (the scalar fields). Schema validation - /// of these is WU-3's job, not this loader's. + /// of these is `cph-schema`'s job, not this loader's. pub scalars: toml::Table, } // --- raw deserialization shapes (mirror the on-disk TOML) --------------------- +/// One `manifest.toml`, root or container (ADR-0029). Root-only tables +/// (`project`/`info`/`targets`) and the container-only `group` table coexist in +/// one shape; [`load`]/[`load_children`] enforce which is expected where and +/// flag misplacement rather than rejecting parse outright (non-fatal, like +/// every other structural defect this loader collects). #[derive(Debug, Deserialize)] struct RawManifest { project: Option, info: Option, #[serde(default)] - parts: Vec, - #[serde(default)] targets: toml::Table, + group: Option, + #[serde(default)] + children: Vec, } #[derive(Debug, Deserialize)] @@ -309,8 +364,20 @@ impl RawAuthor { } } +/// A container's optional `[group]` table (ADR-0029): presentation metadata for +/// a section, kept minimal per the ADR's recommended default. #[derive(Debug, Deserialize)] -struct RawPart { +struct RawGroup { + title: Option, +} + +/// One `children` entry (ADR-0029): a `kind` + a **parent-relative** `path`. +/// The loader resolves on disk whether the named folder is a leaf +/// (`element.toml`) or a container (`manifest.toml`) — `kind` is the declared +/// label, cross-checked against the leaf's `element.toml` kind (unchanged from +/// ADR-0008) but never used to pick the leaf/container branch itself. +#[derive(Debug, Deserialize)] +struct RawChild { kind: String, path: String, } @@ -318,30 +385,33 @@ struct RawPart { /// Load the engineering file at `root` into a [`Lesson`]. /// /// Returns `(Option, Vec)`: -/// - `Some(lesson)` whenever the manifest parses into a `Lesson` at all. The -/// structure is produced even when individual parts have problems, so the -/// WU-5 orchestrator gets **both** the partial lesson and the collected -/// diagnostics. -/// - `None` only on a hard failure where no `Lesson` can be built: the +/// - `Some(lesson)` whenever the root manifest parses into a `Lesson` at all. +/// The structure is produced even when individual parts/containers have +/// problems, so the orchestrator gets **both** the partial lesson and the +/// collected diagnostics. +/// - `None` only on a hard failure where no `Lesson` can be built: the root /// `manifest.toml` is missing/unreadable, is not valid TOML, or lacks the /// required `[project]` / `[info]` tables. In that case the diagnostics /// describe the hard failure. /// -/// Collected (non-fatal) diagnostics include: a part path that does not exist -/// or escapes the root via `..`, a missing/malformed `element.toml`, and a +/// Collected (non-fatal) diagnostics include: a child path that does not exist +/// or escapes its container via `..`, a folder that is neither a container nor +/// a leaf (or ambiguously both), a container manifest misplacing root-only +/// tables, a missing/malformed `element.toml`, and a /// `part.kind != element.toml kind` mismatch. /// -/// ## Diagnostic-code mapping (judgment call, WU-1) +/// ## Diagnostic-code mapping /// -/// `cph-diag`'s code set is closed and has **no** dedicated "manifest -/// malformed" code. We deliberately do not invent one here. Until such a code -/// is added, manifest-level structural errors (bad TOML, missing `[project]` / -/// `[info]`) are mapped to the closest existing code, [`DiagCode::SchemaViolation`], -/// with a message making the real cause clear. A genuinely missing **part -/// path** uses [`DiagCode::PartPathMissing`] (its actual meaning); a missing / -/// unreadable / malformed `element.toml` also maps to `SchemaViolation`. -// TODO(cph-diag): consider adding a dedicated `ManifestMalformed` code so -// manifest-structure errors don't overload `SchemaViolation`. +/// [`DiagCode::ManifestMalformed`] is for the **carrier document's own** +/// structure being broken: unreadable/invalid-TOML `manifest.toml` (root or +/// container), a required root table missing, a container manifest misplacing +/// `[project]`/`[info]`/`[targets]`, a folder that is neither/both a +/// container and a leaf, and an unreadable/invalid/kindless `element.toml`. +/// [`DiagCode::SchemaViolation`] stays reserved for *instance data* not +/// conforming to a schema (a kind's JSON Schema, or a target's +/// artifact/step config shape) — this loader never emits it. A genuinely +/// missing **child path** uses [`DiagCode::PartPathMissing`]; a declared kind +/// disagreeing with `element.toml`'s kind uses [`DiagCode::UnknownKind`]. pub fn load(root: &Path) -> (Option, Vec) { let mut diags = Vec::new(); @@ -352,7 +422,7 @@ pub fn load(root: &Path) -> (Option, Vec) { Err(e) => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, format!("cannot read manifest.toml: {e}"), ) .with_hint(format!( @@ -369,7 +439,7 @@ pub fn load(root: &Path) -> (Option, Vec) { Err(e) => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, format!("manifest.toml is not valid TOML: {e}"), ) .with_hint("fix the TOML syntax in manifest.toml"), @@ -395,7 +465,7 @@ pub fn load(root: &Path) -> (Option, Vec) { None => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, "manifest.toml is missing the required [project] table", ) .with_hint("add a [project] table with `id` and `name`"), @@ -412,7 +482,7 @@ pub fn load(root: &Path) -> (Option, Vec) { None => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, "manifest.toml is missing the required [info] table", ) .with_hint("add an [info] table with at least `title`"), @@ -421,6 +491,21 @@ pub fn load(root: &Path) -> (Option, Vec) { } }; + // The root is the implicit top container (ADR-0029): it must not declare + // `[group]` (that is container-only presentation metadata). + if raw.group.is_some() { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + "the root manifest.toml must not declare [group]; [group] is \ + container-only presentation metadata", + ) + .with_hint( + "remove [group] from the root manifest.toml, or move this content into a container", + ), + ); + } + // Parse each [targets.] table into a structured build config // (ADR-0009/0011). Order is the TOML document order, as before. Malformed // target config is non-fatal: it is reported and the target is kept with @@ -431,76 +516,263 @@ pub fn load(root: &Path) -> (Option, Vec) { .map(|(name, value)| parse_target(name, value, &mut diags)) .collect(); - // Load each part. Problems are collected, not fatal: we still build the - // Part (with a best-effort descriptor) so order/membership is observable. - let mut parts = Vec::with_capacity(raw.parts.len()); - for raw_part in raw.parts { - let rel_path = PathBuf::from(&raw_part.path); - - // Reject `..` traversal: a part path must stay within the root. - if has_parent_traversal(&rel_path) { - diags.push( - Diagnostic::error( - DiagCode::PartPathMissing, - format!( - "part path '{}' escapes the engineering-file root via '..'", - raw_part.path - ), - ) - .with_hint("part paths must be relative folders inside the engineering file"), - ); - // Still record the part with an empty descriptor so order is kept. - parts.push(Part { - kind: raw_part.kind.clone(), - path: rel_path.clone(), - descriptor: ElementDescriptor { - kind: raw_part.kind, - dir: root.join(&rel_path), - scalars: toml::Table::new(), - }, - }); - continue; - } - - let dir = root.join(&rel_path); - - let descriptor = if !dir.is_dir() { - diags.push( - Diagnostic::error( - DiagCode::PartPathMissing, - format!("part folder '{}' does not exist", raw_part.path), - ) - .with_hint(format!( - "create the folder '{}' or fix the `path` in manifest.toml", - raw_part.path - )), - ); - ElementDescriptor { - kind: raw_part.kind.clone(), - dir, - scalars: toml::Table::new(), - } - } else { - load_descriptor(&dir, &rel_path, &raw_part.kind, &mut diags) - }; - - parts.push(Part { - kind: raw_part.kind, - path: rel_path, - descriptor, - }); - } + // Walk the outline tree depth-first (ADR-0029), starting at the root's own + // children. A direct child section of the root opens at depth 1. + let mut parts = Vec::new(); + let mut outline = Vec::new(); + load_children( + root, + root, + Path::new(""), + raw.children, + 1, + &mut diags, + &mut parts, + &mut outline, + ); let lesson = Lesson { project, info, parts, + outline, targets, root: root.to_path_buf(), }; (Some(lesson), diags) } +/// Recursively load one container's ordered `children` into `parts`/`outline` +/// (ADR-0029). `container_dir` is the container's own absolute directory; +/// `rel_prefix` is that container's own root-relative path (empty for the +/// engineering-file root). `next_section_depth` is the depth a **direct** +/// section child of this container would open at (1 for the root's children). +#[allow(clippy::too_many_arguments)] +fn load_children( + container_dir: &Path, + root: &Path, + rel_prefix: &Path, + children: Vec, + next_section_depth: u32, + diags: &mut Vec, + parts: &mut Vec, + outline: &mut Vec, +) { + for child in children { + let local_path = PathBuf::from(&child.path); + let full_rel_path = join_rel(rel_prefix, &local_path); + + // Reject `..` traversal: a child path must stay within its container. + if has_parent_traversal(&local_path) { + diags.push( + Diagnostic::error( + DiagCode::PartPathMissing, + format!("child path '{}' escapes its container via '..'", child.path), + ) + .with_hint("child paths must be relative folders inside the containing folder"), + ); + push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + continue; + } + + let abs_dir = container_dir.join(&local_path); + + if !abs_dir.is_dir() { + diags.push( + Diagnostic::error( + DiagCode::PartPathMissing, + format!("child folder '{}' does not exist", full_rel_path.display()), + ) + .with_hint(format!( + "create the folder '{}' or fix the `path` in its container's manifest.toml", + full_rel_path.display() + )), + ); + push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + continue; + } + + let has_manifest = abs_dir.join("manifest.toml").is_file(); + let has_element = abs_dir.join("element.toml").is_file(); + + match (has_manifest, has_element) { + // A leaf: exactly ADR-0008's element folder. + (false, true) => { + let descriptor = load_descriptor(&abs_dir, &full_rel_path, &child.kind, diags); + let idx = parts.len(); + parts.push(Part { + kind: child.kind, + path: full_rel_path, + descriptor, + }); + outline.push(OutlineEntry::Element { part_index: idx }); + } + // A container: recurse into its own manifest.toml. + (true, false) => { + let Some(raw_container) = read_container_manifest(&abs_dir, diags) else { + push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + continue; + }; + + if raw_container.project.is_some() + || raw_container.info.is_some() + || !raw_container.targets.is_empty() + { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!( + "container manifest.toml at '{}' must not declare \ + [project]/[info]/[targets]; those are root-only", + full_rel_path.display() + ), + ) + .with_hint( + "remove [project]/[info]/[targets] from this container's \ + manifest.toml — they belong only at the engineering-file root", + ), + ); + } + + let title = raw_container + .group + .and_then(|g| g.title) + .filter(|t| !t.trim().is_empty()) + .unwrap_or_else(|| folder_name(&abs_dir)); + + outline.push(OutlineEntry::Section { + kind: child.kind, + title, + depth: next_section_depth, + path: full_rel_path.clone(), + }); + + load_children( + &abs_dir, + root, + &full_rel_path, + raw_container.children, + next_section_depth + 1, + diags, + parts, + outline, + ); + } + // Ambiguous: both a container and a leaf. + (true, true) => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!( + "folder '{}' has both manifest.toml and element.toml; a folder \ + must be exactly one of a container or a leaf", + full_rel_path.display() + ), + ) + .with_hint("remove one of manifest.toml or element.toml from this folder"), + ); + push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + } + // Incomplete: neither a container nor a leaf. + (false, false) => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!( + "folder '{}' has neither manifest.toml nor element.toml", + full_rel_path.display() + ), + ) + .with_hint( + "add an element.toml (leaf) or a manifest.toml with `children` \ + (container) to this folder", + ), + ); + push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + } + } + } +} + +/// Read and parse a container's own `manifest.toml`. On any read/parse failure, +/// reports a [`DiagCode::ManifestMalformed`] diagnostic and returns `None` (the +/// caller falls back to a broken-leaf placeholder so order/count stays stable). +fn read_container_manifest(dir: &Path, diags: &mut Vec) -> Option { + let path = dir.join("manifest.toml"); + let src = match std::fs::read_to_string(&path) { + Ok(s) => s, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!("cannot read {}: {e}", path.display()), + ) + .with_hint("expected a container manifest.toml with a `children` array"), + ); + return None; + } + }; + match toml::from_str(&src) { + Ok(r) => Some(r), + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!("{} is not valid TOML: {e}", path.display()), + ) + .with_hint("fix the TOML syntax in this container's manifest.toml"), + ); + None + } + } +} + +/// Record a broken child as a placeholder leaf (empty descriptor) so that +/// order/count stays observable even when the folder could not be resolved to +/// either a leaf or a container. Mirrors the pre-ADR-0029 behavior for a +/// missing part folder. +fn push_broken_leaf( + parts: &mut Vec, + outline: &mut Vec, + kind: String, + path: PathBuf, + root: &Path, +) { + let idx = parts.len(); + let dir = root.join(&path); + parts.push(Part { + kind: kind.clone(), + path, + descriptor: ElementDescriptor { + kind, + dir, + scalars: toml::Table::new(), + }, + }); + outline.push(OutlineEntry::Element { part_index: idx }); +} + +/// Join a container-relative child path onto that container's own +/// root-relative prefix, producing a full root-relative path. An empty prefix +/// (the engineering-file root itself) returns `local` unchanged. +fn join_rel(prefix: &Path, local: &Path) -> PathBuf { + if prefix.as_os_str().is_empty() { + local.to_path_buf() + } else { + prefix.join(local) + } +} + +/// A folder's basename as a `String` (used as a section's default title when +/// `[group].title` is absent). Falls back to the folder's full path string in +/// the (pathological) case it has no file-name component. +fn folder_name(dir: &Path) -> String { + dir.file_name() + .and_then(|n| n.to_str()) + .map(str::to_string) + .unwrap_or_else(|| dir.display().to_string()) +} + /// The cph version the running CLI was built with (ADR-0016). Pulled from the /// crate's `CARGO_PKG_VERSION` at compile time. pub const CPH_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -570,7 +842,7 @@ fn load_descriptor( Err(e) => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, format!( "cannot read element.toml for part '{}': {e}", rel_path.display() @@ -594,7 +866,7 @@ fn load_descriptor( Err(e) => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, format!( "element.toml for part '{}' is not valid TOML: {e}", rel_path.display() @@ -617,7 +889,7 @@ fn load_descriptor( Some(_) => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, format!( "element.toml for part '{}' has a non-string `kind`", rel_path.display() @@ -630,7 +902,7 @@ fn load_descriptor( None => { diags.push( Diagnostic::error( - DiagCode::SchemaViolation, + DiagCode::ManifestMalformed, format!( "element.toml for part '{}' is missing the required `kind` key", rel_path.display() @@ -1065,6 +1337,228 @@ fn has_parent_traversal(path: &Path) -> bool { path.components().any(|c| matches!(c, Component::ParentDir)) } +// --- bundle: an ordered arrangement of lessons (ADR-0030) -------------------- + +/// A **bundle** (ADR-0030): a directory carrying `bundle.toml`, which arranges +/// an ordered list of already-authored, self-contained lessons into one export +/// unit. Discharges ADR-0005's deferred "course = arrangement of lessons" for +/// the export purpose, without inventing a full course-authoring model. +/// +/// A bundle target (declared exactly like a lesson's `[targets.*]`, +/// ADR-0009/0011) assembles its member lessons at build time; this crate only +/// loads the *arrangement* — which lessons, in what order, with which +/// per-lesson overrides. See [`load_bundle`]. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct Bundle { + /// `[info]` from `bundle.toml` — the 合集's own title/author. + pub info: Info, + /// The ordered member lessons. + pub lessons: Vec, + /// Declared export targets, collected from `bundle.toml`'s + /// `[targets.]` tables (ADR-0009/0011) — reusing the exact same + /// build/artifact/step shape a lesson's targets use. + pub targets: Vec, + /// Bundle root (absolute) — the directory containing `bundle.toml`. + pub root: PathBuf, +} + +impl Bundle { + /// The declared export-target names, in declared order (mirrors + /// [`Lesson::target_names`]). + pub fn target_names(&self) -> Vec<&str> { + self.targets.iter().map(|t| t.name.as_str()).collect() + } +} + +/// One `[[lessons]]` entry in `bundle.toml`, plus that lesson's loaded +/// [`Lesson`] (loaded exactly as [`load`] would from its own directory — a +/// bundle never re-derives lesson-loading logic). +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct BundleLesson { + /// The lesson directory's path, relative to the bundle root, as written in + /// `bundle.toml` (kept verbatim for diagnostics/display). + pub path: PathBuf, + /// Which of that lesson's own declared targets to render into the bundle + /// (ADR-0030: the bundle assembles a lesson's already-authored content, it + /// does not re-target it). Defaults to the lesson's first declared target, + /// or `"student"` if the lesson declares none. + pub target: String, + /// The member lesson, loaded from `/`. + pub lesson: Lesson, +} + +#[derive(Debug, Deserialize)] +struct RawBundleManifest { + info: Option, + #[serde(default)] + targets: toml::Table, + #[serde(default)] + lessons: Vec, +} + +#[derive(Debug, Deserialize)] +struct RawBundleLesson { + path: String, + target: Option, +} + +/// Load the bundle at `root` (a directory containing `bundle.toml`) into a +/// [`Bundle`]. +/// +/// Returns `(Option, Vec)`, mirroring [`load`]'s contract: +/// - `Some(bundle)` whenever `bundle.toml` parses at all — even when a member +/// lesson fails to load, so the orchestrator sees both the partial bundle +/// and every collected diagnostic (that member's `Lesson` is still present, +/// just with its own load errors alongside). +/// - `None` only on a hard failure: `bundle.toml` missing/unreadable, not +/// valid TOML, or missing the required `[info]` table. +/// +/// Each `[[lessons]]` entry's path must stay within the bundle root (no `..` +/// traversal) and must resolve to a real directory; violations are +/// [`DiagCode::PartPathMissing`], matching a lesson's own child-path +/// diagnostics. A member lesson's own load diagnostics are folded in verbatim +/// (they already carry their own context). +pub fn load_bundle(root: &Path) -> (Option, Vec) { + let mut diags = Vec::new(); + + let manifest_path = root.join("bundle.toml"); + let manifest_src = match std::fs::read_to_string(&manifest_path) { + Ok(s) => s, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!("cannot read bundle.toml: {e}"), + ) + .with_hint(format!( + "expected a bundle manifest at {}", + manifest_path.display() + )), + ); + return (None, diags); + } + }; + + let raw: RawBundleManifest = match toml::from_str(&manifest_src) { + Ok(r) => r, + Err(e) => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + format!("bundle.toml is not valid TOML: {e}"), + ) + .with_hint("fix the TOML syntax in bundle.toml"), + ); + return (None, diags); + } + }; + + let info = match raw.info { + Some(i) => Info { + title: i.title, + authors: i.author.map(RawAuthor::into_vec).unwrap_or_default(), + }, + None => { + diags.push( + Diagnostic::error( + DiagCode::ManifestMalformed, + "bundle.toml is missing the required [info] table", + ) + .with_hint("add an [info] table with at least `title`"), + ); + return (None, diags); + } + }; + + let targets: Vec = raw + .targets + .into_iter() + .map(|(name, value)| parse_target(name, value, &mut diags)) + .collect(); + + let mut lessons = Vec::with_capacity(raw.lessons.len()); + for raw_lesson in raw.lessons { + let rel_path = PathBuf::from(&raw_lesson.path); + + if has_parent_traversal(&rel_path) { + diags.push( + Diagnostic::error( + DiagCode::PartPathMissing, + format!( + "bundle lesson path '{}' escapes the bundle root via '..'", + raw_lesson.path + ), + ) + .with_hint("bundle lesson paths must be relative folders inside the bundle root"), + ); + continue; + } + + let abs_dir = root.join(&rel_path); + if !abs_dir.is_dir() { + diags.push( + Diagnostic::error( + DiagCode::PartPathMissing, + format!( + "bundle lesson folder '{}' does not exist", + rel_path.display() + ), + ) + .with_hint(format!( + "create the folder '{}' or fix the `path` in bundle.toml", + rel_path.display() + )), + ); + continue; + } + + let (lesson, lesson_diags) = load(&abs_dir); + diags.extend(lesson_diags); + let Some(lesson) = lesson else { + // The member lesson's own hard-failure diagnostic already explains + // why; skip it from the bundle rather than fabricate a placeholder + // Lesson (unlike a broken element child, there is no lighter-weight + // stand-in for "an entire unloadable lesson"). + continue; + }; + + let target = raw_lesson.target.unwrap_or_else(|| { + lesson + .targets + .first() + .map(|t| t.name.clone()) + .unwrap_or_else(|| DEFAULT_LESSON_TARGET.to_string()) + }); + + lessons.push(BundleLesson { + path: rel_path, + target, + lesson, + }); + } + + let bundle = Bundle { + info, + lessons, + targets, + root: root.to_path_buf(), + }; + (Some(bundle), diags) +} + +/// The target name a bundle member falls back to when its `bundle.toml` entry +/// gives no explicit `target` and the member lesson itself declares no targets +/// (mirrors [`Step::default_for`]'s "student" convention). +const DEFAULT_LESSON_TARGET: &str = "student"; + +/// Whether any diagnostic in `diags` is `Error`-severity — the shared +/// legality predicate `load`/`load_bundle` callers use (mirrors +/// `cph-check::CheckReport::has_errors`, kept local here so this crate never +/// depends on `cph-check`). +pub fn has_error_diagnostic(diags: &[Diagnostic]) -> bool { + diags.iter().any(|d| d.severity == Severity::Error) +} + #[cfg(test)] mod tests { use super::*; @@ -1076,4 +1570,20 @@ mod tests { assert!(!has_parent_traversal(Path::new("a/b/c"))); assert!(!has_parent_traversal(Path::new("segments/intro"))); } + + #[test] + fn join_rel_empty_prefix_returns_local() { + assert_eq!( + join_rel(Path::new(""), Path::new("a/b")), + PathBuf::from("a/b") + ); + } + + #[test] + fn join_rel_nonempty_prefix_joins() { + assert_eq!( + join_rel(Path::new("导言簇"), Path::new("开场白")), + PathBuf::from("导言簇/开场白") + ); + } } diff --git a/crates/cph-model/tests/bundle.rs b/crates/cph-model/tests/bundle.rs new file mode 100644 index 0000000..6074b35 --- /dev/null +++ b/crates/cph-model/tests/bundle.rs @@ -0,0 +1,83 @@ +//! Integration tests for `cph_model::load_bundle` (ADR-0030): an ordered +//! arrangement of self-contained lessons, loaded from `bundle.toml`. + +use std::path::PathBuf; + +use cph_diag::DiagCode; +use cph_model::load_bundle; + +fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +#[test] +fn valid_bundle_loads_lessons_in_order_with_overrides() { + let (bundle, diags) = load_bundle(&fixture("bundle-valid")); + let bundle = bundle.expect("valid bundle fixture must produce a Bundle"); + assert!( + diags.is_empty(), + "valid bundle fixture must have no diagnostics, got: {diags:?}" + ); + + assert_eq!(bundle.info.title, "测试合集"); + assert_eq!( + bundle.info.authors, + vec!["张老师".to_string(), "李老师".to_string()] + ); + + assert_eq!(bundle.lessons.len(), 2); + + // lesson-a: no explicit `target` in bundle.toml -> falls back to the + // lesson's own first declared target ("student"). + assert_eq!(bundle.lessons[0].path, PathBuf::from("lesson-a")); + assert_eq!(bundle.lessons[0].target, "student"); + assert_eq!(bundle.lessons[0].lesson.info.title, "课时A"); + + // lesson-b: explicit `target = "teacher"` in bundle.toml, overriding the + // lesson's own single declared target (also "teacher" here, but the point + // is the bundle entry's `target` wins regardless). + assert_eq!(bundle.lessons[1].path, PathBuf::from("lesson-b")); + assert_eq!(bundle.lessons[1].target, "teacher"); + assert_eq!(bundle.lessons[1].lesson.info.title, "课时B"); + + // The bundle's own targets are collected exactly like a lesson's. + assert_eq!(bundle.target_names(), vec!["merged"]); +} + +#[test] +fn missing_lesson_folder_yields_part_path_missing_and_is_skipped() { + let (bundle, diags) = load_bundle(&fixture("bundle-missing-lesson")); + let bundle = bundle.expect("must still produce a best-effort Bundle"); + assert!( + bundle.lessons.is_empty(), + "the missing lesson is skipped, not placeholder'd" + ); + + let missing: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::PartPathMissing) + .collect(); + assert_eq!( + missing.len(), + 1, + "exactly one PartPathMissing expected, got: {diags:?}" + ); +} + +#[test] +fn malformed_bundle_toml_is_a_hard_failure() { + let (bundle, diags) = load_bundle(&fixture("bundle-malformed")); + assert!(bundle.is_none(), "malformed bundle.toml is a hard failure"); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code, DiagCode::ManifestMalformed); +} + +#[test] +fn missing_bundle_toml_is_a_hard_failure() { + let (bundle, diags) = load_bundle(&fixture("does-not-exist-at-all")); + assert!(bundle.is_none()); + assert_eq!(diags.len(), 1); + assert_eq!(diags[0].code, DiagCode::ManifestMalformed); +} diff --git a/crates/cph-model/tests/fixtures/both-manifest-and-element/manifest.toml b/crates/cph-model/tests/fixtures/both-manifest-and-element/manifest.toml new file mode 100644 index 0000000..c8968ec --- /dev/null +++ b/crates/cph-model/tests/fixtures/both-manifest-and-element/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "fixture-both" +name = "both" + +[info] +title = "文件夹既是容器又是叶子" + +[[children]] +kind = "segment" +path = "segments/broken" + +[targets.student] diff --git a/examples/TH-141/segments/Fowkes极性修正/element.toml b/crates/cph-model/tests/fixtures/both-manifest-and-element/segments/broken/element.toml similarity index 100% rename from examples/TH-141/segments/Fowkes极性修正/element.toml rename to crates/cph-model/tests/fixtures/both-manifest-and-element/segments/broken/element.toml diff --git a/crates/cph-model/tests/fixtures/both-manifest-and-element/segments/broken/manifest.toml b/crates/cph-model/tests/fixtures/both-manifest-and-element/segments/broken/manifest.toml new file mode 100644 index 0000000..26149a8 --- /dev/null +++ b/crates/cph-model/tests/fixtures/both-manifest-and-element/segments/broken/manifest.toml @@ -0,0 +1 @@ +[[children]] diff --git a/crates/cph-model/tests/fixtures/bundle-malformed/bundle.toml b/crates/cph-model/tests/fixtures/bundle-malformed/bundle.toml new file mode 100644 index 0000000..e1d887c --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-malformed/bundle.toml @@ -0,0 +1,2 @@ +[info +this is broken diff --git a/crates/cph-model/tests/fixtures/bundle-missing-lesson/bundle.toml b/crates/cph-model/tests/fixtures/bundle-missing-lesson/bundle.toml new file mode 100644 index 0000000..2d15c94 --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-missing-lesson/bundle.toml @@ -0,0 +1,5 @@ +[info] +title = "缺失课时的合集" + +[[lessons]] +path = "does-not-exist" diff --git a/crates/cph-model/tests/fixtures/bundle-valid/bundle.toml b/crates/cph-model/tests/fixtures/bundle-valid/bundle.toml new file mode 100644 index 0000000..f8b676e --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-valid/bundle.toml @@ -0,0 +1,16 @@ +[info] +title = "测试合集" +author = ["张老师", "李老师"] + +[[lessons]] +path = "lesson-a" + +[[lessons]] +path = "lesson-b" +target = "teacher" + +[targets.merged] +artifact = { type = "single-file", filepath = "build/merged.pdf" } +[[targets.merged.steps]] +type = "typst-compile" +template = "exports/merged.typ" diff --git a/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/manifest.toml b/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/manifest.toml new file mode 100644 index 0000000..bd9952d --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "lesson-a" +name = "lesson-a" + +[info] +title = "课时A" + +[[children]] +kind = "segment" +path = "segments/a" + +[targets.student] diff --git a/examples/TH-141/segments/LJ积分导言/element.toml b/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/segments/a/element.toml similarity index 100% rename from examples/TH-141/segments/LJ积分导言/element.toml rename to crates/cph-model/tests/fixtures/bundle-valid/lesson-a/segments/a/element.toml diff --git a/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/segments/a/textbook.typ b/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/segments/a/textbook.typ new file mode 100644 index 0000000..344f76f --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-valid/lesson-a/segments/a/textbook.typ @@ -0,0 +1 @@ +A. diff --git a/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/manifest.toml b/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/manifest.toml new file mode 100644 index 0000000..1e7279d --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "lesson-b" +name = "lesson-b" + +[info] +title = "课时B" + +[[children]] +kind = "segment" +path = "segments/b" + +[targets.teacher] diff --git a/examples/TH-141/segments/σTp态函数导言/element.toml b/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/segments/b/element.toml similarity index 100% rename from examples/TH-141/segments/σTp态函数导言/element.toml rename to crates/cph-model/tests/fixtures/bundle-valid/lesson-b/segments/b/element.toml diff --git a/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/segments/b/textbook.typ b/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/segments/b/textbook.typ new file mode 100644 index 0000000..3f52430 --- /dev/null +++ b/crates/cph-model/tests/fixtures/bundle-valid/lesson-b/segments/b/textbook.typ @@ -0,0 +1 @@ +B. diff --git a/crates/cph-model/tests/fixtures/container-root-tables/manifest.toml b/crates/cph-model/tests/fixtures/container-root-tables/manifest.toml new file mode 100644 index 0000000..72b7d9e --- /dev/null +++ b/crates/cph-model/tests/fixtures/container-root-tables/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "fixture-container-root-tables" +name = "container-root-tables" + +[info] +title = "容器错误声明了根级表" + +[[children]] +kind = "section" +path = "section" + +[targets.student] diff --git a/crates/cph-model/tests/fixtures/container-root-tables/section/manifest.toml b/crates/cph-model/tests/fixtures/container-root-tables/section/manifest.toml new file mode 100644 index 0000000..44d923f --- /dev/null +++ b/crates/cph-model/tests/fixtures/container-root-tables/section/manifest.toml @@ -0,0 +1,5 @@ +[project] +id = "should-not-be-here" +name = "should-not-be-here" + +children = [] diff --git a/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml b/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml index fedd3aa..7a2b685 100644 --- a/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml +++ b/crates/cph-model/tests/fixtures/kind-mismatch/manifest.toml @@ -5,7 +5,7 @@ name = "kind-mismatch" [info] title = "kind 不一致测试" -[[parts]] +[[children]] kind = "segment" path = "segments/intro" diff --git a/crates/cph-model/tests/fixtures/missing-part/manifest.toml b/crates/cph-model/tests/fixtures/missing-part/manifest.toml index d54f585..9099578 100644 --- a/crates/cph-model/tests/fixtures/missing-part/manifest.toml +++ b/crates/cph-model/tests/fixtures/missing-part/manifest.toml @@ -5,11 +5,11 @@ name = "missing-part" [info] title = "缺部件测试" -[[parts]] +[[children]] kind = "segment" path = "segments/intro" -[[parts]] +[[children]] kind = "lemma" path = "lemmas/does-not-exist" diff --git a/crates/cph-model/tests/fixtures/neither-manifest-nor-element/manifest.toml b/crates/cph-model/tests/fixtures/neither-manifest-nor-element/manifest.toml new file mode 100644 index 0000000..5bf320d --- /dev/null +++ b/crates/cph-model/tests/fixtures/neither-manifest-nor-element/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "fixture-neither" +name = "neither" + +[info] +title = "文件夹既不是容器也不是叶子" + +[[children]] +kind = "segment" +path = "segments/broken" + +[targets.student] diff --git a/crates/cph-model/tests/fixtures/neither-manifest-nor-element/segments/broken/.gitkeep b/crates/cph-model/tests/fixtures/neither-manifest-nor-element/segments/broken/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/crates/cph-model/tests/fixtures/nested/manifest.toml b/crates/cph-model/tests/fixtures/nested/manifest.toml new file mode 100644 index 0000000..1b92fda --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/manifest.toml @@ -0,0 +1,16 @@ +[project] +id = "fixture-nested" +name = "nested" + +[info] +title = "嵌套结构测试" + +[[children]] +kind = "segment" +path = "segments/开场白" + +[[children]] +kind = "section" +path = "导言簇" + +[targets.student] diff --git a/examples/TH-141/segments/σT建模导言/element.toml b/crates/cph-model/tests/fixtures/nested/segments/开场白/element.toml similarity index 100% rename from examples/TH-141/segments/σT建模导言/element.toml rename to crates/cph-model/tests/fixtures/nested/segments/开场白/element.toml diff --git a/crates/cph-model/tests/fixtures/nested/segments/开场白/textbook.typ b/crates/cph-model/tests/fixtures/nested/segments/开场白/textbook.typ new file mode 100644 index 0000000..dde1619 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/segments/开场白/textbook.typ @@ -0,0 +1 @@ +开场白。 diff --git a/crates/cph-model/tests/fixtures/nested/导言簇/manifest.toml b/crates/cph-model/tests/fixtures/nested/导言簇/manifest.toml new file mode 100644 index 0000000..d1a29e8 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/导言簇/manifest.toml @@ -0,0 +1,14 @@ +[group] +title = "导言簇" + +[[children]] +kind = "segment" +path = "segments/子段一" + +[[children]] +kind = "section" +path = "嵌套子节" + +[[children]] +kind = "segment" +path = "segments/子段二" diff --git a/examples/TH-141/segments/σ作为态函数的图像/element.toml b/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段一/element.toml similarity index 100% rename from examples/TH-141/segments/σ作为态函数的图像/element.toml rename to crates/cph-model/tests/fixtures/nested/导言簇/segments/子段一/element.toml diff --git a/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段一/textbook.typ b/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段一/textbook.typ new file mode 100644 index 0000000..bd0bb08 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段一/textbook.typ @@ -0,0 +1 @@ +子段一。 diff --git a/examples/TH-141/segments/三相接触导言/element.toml b/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段二/element.toml similarity index 100% rename from examples/TH-141/segments/三相接触导言/element.toml rename to crates/cph-model/tests/fixtures/nested/导言簇/segments/子段二/element.toml diff --git a/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段二/textbook.typ b/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段二/textbook.typ new file mode 100644 index 0000000..ff49341 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/导言簇/segments/子段二/textbook.typ @@ -0,0 +1 @@ +子段二。 diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/无证明引理/element.toml b/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/lemmas/子引理/element.toml similarity index 100% rename from crates/cph-typst/tests/fixtures/mini/lemmas/无证明引理/element.toml rename to crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/lemmas/子引理/element.toml diff --git a/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/lemmas/子引理/stmt.typ b/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/lemmas/子引理/stmt.typ new file mode 100644 index 0000000..e1c5373 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/lemmas/子引理/stmt.typ @@ -0,0 +1 @@ +子引理陈述。 diff --git a/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/manifest.toml b/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/manifest.toml new file mode 100644 index 0000000..e6676ea --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/导言簇/嵌套子节/manifest.toml @@ -0,0 +1,3 @@ +[[children]] +kind = "lemma" +path = "lemmas/子引理" diff --git a/crates/cph-model/tests/fixtures/root-group-declared/manifest.toml b/crates/cph-model/tests/fixtures/root-group-declared/manifest.toml new file mode 100644 index 0000000..f5bf565 --- /dev/null +++ b/crates/cph-model/tests/fixtures/root-group-declared/manifest.toml @@ -0,0 +1,15 @@ +[project] +id = "fixture-root-group" +name = "root-group" + +[info] +title = "根级 manifest 错误声明了 group" + +[group] +title = "不该在根级" + +[[children]] +kind = "segment" +path = "segments/a" + +[targets.student] diff --git a/examples/TH-141/segments/不同物质γ量级对比/element.toml b/crates/cph-model/tests/fixtures/root-group-declared/segments/a/element.toml similarity index 100% rename from examples/TH-141/segments/不同物质γ量级对比/element.toml rename to crates/cph-model/tests/fixtures/root-group-declared/segments/a/element.toml diff --git a/crates/cph-model/tests/fixtures/root-group-declared/segments/a/textbook.typ b/crates/cph-model/tests/fixtures/root-group-declared/segments/a/textbook.typ new file mode 100644 index 0000000..2c88a88 --- /dev/null +++ b/crates/cph-model/tests/fixtures/root-group-declared/segments/a/textbook.typ @@ -0,0 +1 @@ +a. diff --git a/crates/cph-model/tests/fixtures/valid/manifest.toml b/crates/cph-model/tests/fixtures/valid/manifest.toml index 227556b..98c6709 100644 --- a/crates/cph-model/tests/fixtures/valid/manifest.toml +++ b/crates/cph-model/tests/fixtures/valid/manifest.toml @@ -6,11 +6,11 @@ name = "valid-2-part" title = "测试课:两个部件" author = "范式教育教研组" -[[parts]] +[[children]] kind = "segment" path = "segments/intro" -[[parts]] +[[children]] kind = "lemma" path = "lemmas/young" diff --git a/crates/cph-model/tests/load.rs b/crates/cph-model/tests/load.rs index 64a4d3c..1bfcb96 100644 --- a/crates/cph-model/tests/load.rs +++ b/crates/cph-model/tests/load.rs @@ -1,11 +1,12 @@ //! Integration tests for `cph_model::load`, driven by static fixtures under -//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0008 -//! on-disk format. +//! `tests/fixtures/`. The fixtures double as documentation of the ADR-0029 +//! on-disk format (a nested outline manifest; supersedes ADR-0008's flat +//! `[[parts]]`). use std::path::PathBuf; use cph_diag::DiagCode; -use cph_model::load; +use cph_model::{load, OutlineEntry}; /// Absolute path to a fixture engineering-file root. fn fixture(name: &str) -> PathBuf { @@ -39,6 +40,16 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() { assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young")); assert_eq!(lesson.parts[1].descriptor.kind, "lemma"); + // The outline is a flat sequence of elements-by-index when there are no + // containers. + assert_eq!( + lesson.outline, + vec![ + OutlineEntry::Element { part_index: 0 }, + OutlineEntry::Element { part_index: 1 }, + ] + ); + // `source` scalar survives on the lemma descriptor; `kind` is removed. let scalars = &lesson.parts[1].descriptor.scalars; assert_eq!( @@ -74,6 +85,58 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() { ); } +#[test] +fn nested_sections_flatten_depth_first_with_correct_depths() { + let (lesson, diags) = load(&fixture("nested")); + let lesson = lesson.expect("nested fixture must produce a Lesson"); + assert!( + diags.is_empty(), + "nested fixture must have no diagnostics, got: {diags:?}" + ); + + // DFS pre-order element sequence (ADR-0029): containers contribute no + // element of their own. + let paths: Vec<_> = lesson.parts.iter().map(|p| p.path.clone()).collect(); + assert_eq!( + paths, + vec![ + PathBuf::from("segments/开场白"), + PathBuf::from("导言簇/segments/子段一"), + PathBuf::from("导言簇/嵌套子节/lemmas/子引理"), + PathBuf::from("导言簇/segments/子段二"), + ], + "root-relative paths must accumulate through every nesting level" + ); + + // The outline interleaves section headings at their DFS-open position, + // with depth 1 for a section directly under the root and depth 2 for one + // nested inside another section. + assert_eq!( + lesson.outline, + vec![ + OutlineEntry::Element { part_index: 0 }, // segments/开场白 + OutlineEntry::Section { + kind: "section".to_string(), + title: "导言簇".to_string(), + depth: 1, + path: PathBuf::from("导言簇"), + }, + OutlineEntry::Element { part_index: 1 }, // 导言簇/segments/子段一 + OutlineEntry::Section { + kind: "section".to_string(), + title: "嵌套子节".to_string(), + depth: 2, + path: PathBuf::from("导言簇/嵌套子节"), + }, + OutlineEntry::Element { part_index: 2 }, // 导言簇/嵌套子节/lemmas/子引理 + OutlineEntry::Element { part_index: 3 }, // 导言簇/segments/子段二 + ] + ); + + // The outer section declares [group].title = "导言簇"; the inner section + // has no [group] at all, so its title falls back to the folder basename. +} + #[test] fn missing_part_folder_yields_part_path_missing() { let (lesson, diags) = load(&fixture("missing-part")); @@ -126,7 +189,7 @@ fn malformed_manifest_is_a_hard_failure() { "malformed manifest must be a hard failure (None)" ); assert_eq!(diags.len(), 1, "one hard-failure diagnostic expected"); - assert_eq!(diags[0].code, DiagCode::SchemaViolation); + assert_eq!(diags[0].code, DiagCode::ManifestMalformed); assert_eq!(diags[0].severity, cph_diag::Severity::Error); } @@ -136,7 +199,98 @@ fn missing_manifest_is_a_hard_failure() { let (lesson, diags) = load(&fixture("does-not-exist-at-all")); assert!(lesson.is_none()); assert_eq!(diags.len(), 1); - assert_eq!(diags[0].code, DiagCode::SchemaViolation); + assert_eq!(diags[0].code, DiagCode::ManifestMalformed); +} + +#[test] +fn folder_with_both_manifest_and_element_is_manifest_malformed() { + let (lesson, diags) = load(&fixture("both-manifest-and-element")); + let lesson = lesson.expect("must still produce a best-effort Lesson"); + assert_eq!( + lesson.parts.len(), + 1, + "the ambiguous child is a placeholder" + ); + + let malformed: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::ManifestMalformed) + .collect(); + assert_eq!( + malformed.len(), + 1, + "exactly one ManifestMalformed expected, got: {diags:?}" + ); + assert!( + malformed[0] + .message + .contains("both manifest.toml and element.toml"), + "message should explain the ambiguity, got: {}", + malformed[0].message + ); +} + +#[test] +fn folder_with_neither_manifest_nor_element_is_manifest_malformed() { + let (lesson, diags) = load(&fixture("neither-manifest-nor-element")); + let lesson = lesson.expect("must still produce a best-effort Lesson"); + assert_eq!( + lesson.parts.len(), + 1, + "the incomplete child is a placeholder" + ); + + let malformed: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::ManifestMalformed) + .collect(); + assert_eq!( + malformed.len(), + 1, + "exactly one ManifestMalformed expected, got: {diags:?}" + ); + assert!( + malformed[0] + .message + .contains("neither manifest.toml nor element.toml"), + "message should explain the gap, got: {}", + malformed[0].message + ); +} + +#[test] +fn container_declaring_root_only_tables_is_manifest_malformed() { + let (lesson, diags) = load(&fixture("container-root-tables")); + assert!( + lesson.is_some(), + "a container misplacing root tables is non-fatal" + ); + + let malformed: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("root-only")) + .collect(); + assert_eq!( + malformed.len(), + 1, + "exactly one root-only-table diagnostic expected, got: {diags:?}" + ); +} + +#[test] +fn root_manifest_declaring_group_is_manifest_malformed() { + let (lesson, diags) = load(&fixture("root-group-declared")); + assert!(lesson.is_some(), "the root declaring [group] is non-fatal"); + + let malformed: Vec<_> = diags + .iter() + .filter(|d| d.code == DiagCode::ManifestMalformed && d.message.contains("[group]")) + .collect(); + assert_eq!( + malformed.len(), + 1, + "exactly one root-[group] diagnostic expected, got: {diags:?}" + ); } #[test] @@ -290,7 +444,7 @@ fn tmp_lesson_with_version(version: Option<&str>) -> tempfile::TempDir { let p = tmp.path(); std::fs::write( p.join("manifest.toml"), - "[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[parts]]\nkind = \"segment\"\npath = \"segments/a\"\n", + "[project]\nid = \"v\"\nname = \"v\"\n[info]\ntitle = \"v\"\n[[children]]\nkind = \"segment\"\npath = \"segments/a\"\n", ) .unwrap(); let seg = p.join("segments").join("a"); diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs index e1bf25f..9d369b8 100644 --- a/crates/cph-typst/src/lib.rs +++ b/crates/cph-typst/src/lib.rs @@ -42,12 +42,12 @@ mod world; use std::path::PathBuf; use cph_diag::{DiagCode, Diagnostic}; -use cph_model::{Artifact, Lesson, Step, TargetConfig}; +use cph_model::{Artifact, Bundle, Lesson, Step, TargetConfig}; use typst_kit::fonts::{self, FontStore}; use typst_layout::PagedDocument; use typst_pdf::PdfOptions; -pub use manifest::build_augmented_manifest; +pub use manifest::{build_augmented_bundle_manifest, build_augmented_manifest}; pub use world::{render_package_spec, LessonWorld, MANIFEST_VPATH}; /// The compile/PDF engine: holds the shared font store and the on-disk location @@ -142,7 +142,7 @@ impl Engine { /// 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> { - let template = target_precheck(lesson, target)?; + let template = target_precheck(target, &lesson.targets)?; let manifest_src = build_augmented_manifest(lesson); Ok(LessonWorld::new( lesson.root.clone(), @@ -152,6 +152,60 @@ impl Engine { self.fonts.clone(), )) } + + /// Compile-check `bundle` for `target` (ADR-0030) — same contract as + /// [`Engine::compile_check`], but over a [`Bundle`]'s own declared targets + /// and the augmented **bundle** manifest (each member lesson's outline, + /// path-prefixed to resolve against the bundle root). + pub fn compile_check_bundle(&self, bundle: &Bundle, target: &str) -> Vec { + let world = match self.world_for_bundle(bundle, target) { + Ok(world) => world, + Err(blocking) => return blocking, + }; + let warned = typst::compile::(&world); + + let mut out = Vec::new(); + if let Err(errors) = &warned.output { + out.extend(map_all(&world, errors)); + } + out.extend(map_all(&world, &warned.warnings)); + out + } + + /// Build a PDF for `bundle` / `target` (ADR-0030) — same contract as + /// [`Engine::build_pdf`], over a [`Bundle`]'s own declared targets. + pub fn build_bundle_pdf( + &self, + bundle: &Bundle, + target: &str, + ) -> Result, Vec> { + let world = self.world_for_bundle(bundle, target)?; + let warned = typst::compile::(&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 `(bundle, target)`: same shape as + /// [`Engine::world_for`], main file resolved under the **bundle** root and + /// the injected manifest built by [`build_augmented_bundle_manifest`]. + fn world_for_bundle( + &self, + bundle: &Bundle, + target: &str, + ) -> Result> { + let template = target_precheck(target, &bundle.targets)?; + let manifest_src = build_augmented_bundle_manifest(bundle); + Ok(LessonWorld::new( + bundle.root.clone(), + self.render_dir.clone(), + &template, + manifest_src, + self.fonts.clone(), + )) + } } impl Default for Engine { @@ -160,13 +214,16 @@ impl Default for Engine { } } -/// Validate a `(lesson, target)` request and resolve the template path to -/// compile. Returns `Ok(template_path)` (relative to the lesson root) when the -/// request is buildable, or `Err(blocking_diagnostics)` when it is not: +/// Validate a `(targets, target)` request and resolve the template path to +/// compile. Shared by [`Engine::world_for`] (a lesson's `targets`) and +/// [`Engine::world_for_bundle`] (a bundle's own `targets` — ADR-0030 gives a +/// bundle target the exact same build/artifact/step shape). Returns +/// `Ok(template_path)` (relative to the lesson/bundle root) when the request is +/// buildable, or `Err(blocking_diagnostics)` when it is not: /// -/// - **Unknown target** (the `--target` name isn't in `lesson.targets`, and the -/// lesson declares at least one target): a `SchemaViolation` error — a target -/// must be declared in the manifest to be built (ADR-0009). +/// - **Unknown target** (the `--target` name isn't in `targets`, and `targets` +/// is non-empty): a `SchemaViolation` error — a target must be declared in +/// the manifest to be built (ADR-0009). /// - **No declared targets at all**: not an error — callers (e.g. `cph-check`) /// may compile-check a defaulted `"student"` target the lesson never declared. /// The stock template path `exports/.typ` is used (the framework @@ -177,10 +234,10 @@ impl Default for Engine { /// [`Step::Shell`], returns a clear "not yet implemented" `SchemaViolation` /// rather than wrong output. The template is taken from the **first** /// `TypstCompile` step (MVP: one step per target). -fn target_precheck(lesson: &Lesson, target: &str) -> Result> { - let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else { - if lesson.targets.is_empty() { - // Lesson declares no targets; the orchestrator compiles a defaulted +fn target_precheck(target: &str, targets: &[TargetConfig]) -> Result> { + let Some(tc) = targets.iter().find(|t| t.name == target) else { + if targets.is_empty() { + // Declares no targets; the orchestrator compiles a defaulted // target. Use the stock template path (matches cph-model's default). return Ok(PathBuf::from(format!("exports/{target}.typ"))); } diff --git a/crates/cph-typst/src/manifest.rs b/crates/cph-typst/src/manifest.rs index cdd4e58..54b0ef0 100644 --- a/crates/cph-typst/src/manifest.rs +++ b/crates/cph-typst/src/manifest.rs @@ -1,18 +1,28 @@ -//! Augmented-manifest construction (ADR-0011). +//! Augmented-manifest construction (ADR-0011, outline shape per ADR-0029). //! //! The template (`exports/.typ`) reads the manifest via -//! `toml(sys.inputs.manifest)`, then for each part `include`s its content fields -//! by a **computed** path and reads scalar fields from `/element.toml`. -//! For *optional* content fields the template must know whether the file exists -//! on disk — typst has no file-exists primitive and a missing `include` is a -//! hard error (see the OPEN contract point in `render/templates/student.typ`). +//! `toml(sys.inputs.manifest)`, then for each **element** outline entry +//! `include`s its content fields by a **computed** path and reads scalar +//! fields from `/element.toml`. For *optional* content fields the +//! template must know whether the file exists on disk — typst has no +//! file-exists primitive and a missing `include` is a hard error (see the OPEN +//! contract point in `render/templates/student.typ`). //! //! The ENGINE has filesystem access, so it closes that gap: it builds an -//! **augmented manifest** = the lesson's `[info]` + ordered `[[parts]]`, with a -//! per-part **`fields` array** listing the content fields whose `.typ` -//! actually exists under the lesson root. The augmented manifest is served as an -//! in-memory virtual file in the [`crate::world::LessonWorld`] (it is **never** -//! written to the user's tree), and injected via `sys.inputs.manifest`. +//! **augmented manifest** = the lesson's `[info]` + the ordered `[[outline]]` +//! (ADR-0029's depth-first rendering order — elements interleaved with section +//! headings at their DFS-open position). Each `[[outline]]` entry carries a +//! `type` discriminator (`"element"` | `"section"`): +//! +//! - `type = "element"`: `kind`, `path`, and a per-part **`fields` array** +//! listing the content fields whose `.typ` actually exists under the +//! lesson root (same contract as before ADR-0029). +//! - `type = "section"`: `kind`, `title`, `depth`, `path` — a section heading; +//! the template renders it without touching any content file. +//! +//! The augmented manifest is served as an in-memory virtual file in the +//! [`crate::world::LessonWorld`] (it is **never** written to the user's tree), +//! and injected via `sys.inputs.manifest`. //! //! ## `fields` is computed from `cph-schema` //! @@ -20,23 +30,104 @@ //! ([`cph_schema::KindSchema::content_field_names`]) — the same knowledge the //! render package exposes as `part-fields`. We reuse it here rather than //! re-deriving a kind→fields map, so the engine and the template agree on what a -//! kind's content fields are. For each part, a content field is listed in +//! kind's content fields are. For each element, a content field is listed in //! `fields` iff `//.typ` is a real file. -use cph_model::Lesson; +use std::path::Path; + +use cph_model::{Bundle, BundleLesson, Lesson, OutlineEntry}; /// Build the augmented-manifest TOML source for `lesson`. /// /// The result is a self-contained TOML document the template's /// `toml(sys.inputs.manifest)` reads. It carries `[info]` (title + optional -/// author) and the ordered `[[parts]]`, each with `kind`, `path`, and a -/// `fields = [...]` array of the content fields present on disk (per -/// [`present_fields`]). It does **not** reproduce `[project]` or `[targets.*]` -/// — the template only consumes `info` and `parts`. +/// author) and the ordered `[[outline]]` (ADR-0029's depth-first rendering +/// order), each entry typed `"element"` or `"section"` per the module docs. It +/// does **not** reproduce `[project]` or `[targets.*]` — the template only +/// consumes `info` and `outline`. pub fn build_augmented_manifest(lesson: &Lesson) -> String { let mut doc = toml::Table::new(); - // [info] + doc.insert("info".to_string(), toml::Value::Table(info_table(lesson))); + + // [[outline]] — ADR-0029's depth-first rendering order: elements + // interleaved with section headings at their DFS-open position. + let outline: Vec = lesson + .outline + .iter() + .map(|entry| toml::Value::Table(outline_entry_table(lesson, entry, None))) + .collect(); + doc.insert("outline".to_string(), toml::Value::Array(outline)); + + toml::to_string(&doc).expect("augmented manifest serializes") +} + +/// Build the augmented **bundle** manifest TOML source for `bundle` (ADR-0030). +/// +/// The bundle template (`exports/.typ` under the `bundle.toml` root) +/// reads it via `toml(sys.inputs.manifest)`. It carries `[info]` (the bundle's +/// own title/author) and the ordered `[[lessons]]`, each a +/// `(info, target, outline)` table — the same shape a single-lesson template +/// would assemble, except every outline entry's `path` is **prefixed with that +/// lesson's own bundle-root-relative directory** (`BundleLesson::path`), since +/// the bundle template's computed include paths resolve against the *bundle* +/// root, not each lesson's own root (ADR-0030: combination reads +/// already-authored lessons at export time; each lesson's `path` bookkeeping +/// stays correct because the prefix is applied only here, in the manifest the +/// template consumes — never inside a lesson's own authored content). +pub fn build_augmented_bundle_manifest(bundle: &Bundle) -> String { + let mut doc = toml::Table::new(); + + let mut info = toml::Table::new(); + info.insert( + "title".to_string(), + toml::Value::String(bundle.info.title.clone()), + ); + if !bundle.info.authors.is_empty() { + let authors = bundle + .info + .authors + .iter() + .cloned() + .map(toml::Value::String) + .collect(); + info.insert("author".to_string(), toml::Value::Array(authors)); + } + doc.insert("info".to_string(), toml::Value::Table(info)); + + let lessons: Vec = bundle + .lessons + .iter() + .map(|bl| toml::Value::Table(bundle_lesson_table(bl))) + .collect(); + doc.insert("lessons".to_string(), toml::Value::Array(lessons)); + + toml::to_string(&doc).expect("augmented bundle manifest serializes") +} + +/// Build one `[[lessons]]` entry's table: that member lesson's own `info`, +/// its selected `target`, and its outline with every entry's `path` prefixed +/// by the lesson's bundle-relative directory. +fn bundle_lesson_table(bl: &BundleLesson) -> toml::Table { + let mut t = toml::Table::new(); + t.insert( + "info".to_string(), + toml::Value::Table(info_table(&bl.lesson)), + ); + t.insert("target".to_string(), toml::Value::String(bl.target.clone())); + let outline: Vec = bl + .lesson + .outline + .iter() + .map(|entry| toml::Value::Table(outline_entry_table(&bl.lesson, entry, Some(&bl.path)))) + .collect(); + t.insert("outline".to_string(), toml::Value::Array(outline)); + t +} + +/// Build the `[info]` table shared by a single-lesson manifest and a bundle +/// member's `info` entry. +fn info_table(lesson: &Lesson) -> toml::Table { let mut info = toml::Table::new(); info.insert( "title".to_string(), @@ -52,30 +143,60 @@ pub fn build_augmented_manifest(lesson: &Lesson) -> String { .collect(); info.insert("author".to_string(), toml::Value::Array(authors)); } - doc.insert("info".to_string(), toml::Value::Table(info)); + info +} - // [[parts]] — preserve declared order; attach the on-disk `fields` array. - let parts: Vec = lesson - .parts - .iter() - .map(|part| { - let mut entry = toml::Table::new(); - entry.insert("kind".to_string(), toml::Value::String(part.kind.clone())); - entry.insert( +/// Build one `[[outline]]` entry's table for either variant of +/// [`OutlineEntry`]. `bundle_prefix`, when set (ADR-0030's bundle case), is +/// joined onto the emitted `path` so the bundle template's computed include +/// resolves against the bundle root rather than the lesson's own root. +fn outline_entry_table( + lesson: &Lesson, + entry: &OutlineEntry, + bundle_prefix: Option<&Path>, +) -> toml::Table { + let mut e = toml::Table::new(); + match entry { + 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())); + e.insert( "path".to_string(), - toml::Value::String(path_to_forward_slash(&part.path)), + toml::Value::String(prefixed_forward_slash(bundle_prefix, &part.path)), ); let fields = present_fields(lesson, part) .into_iter() .map(toml::Value::String) .collect(); - entry.insert("fields".to_string(), toml::Value::Array(fields)); - toml::Value::Table(entry) - }) - .collect(); - doc.insert("parts".to_string(), toml::Value::Array(parts)); + e.insert("fields".to_string(), toml::Value::Array(fields)); + } + OutlineEntry::Section { + kind, + title, + depth, + path, + } => { + e.insert("type".to_string(), toml::Value::String("section".into())); + e.insert("kind".to_string(), toml::Value::String(kind.clone())); + e.insert("title".to_string(), toml::Value::String(title.clone())); + e.insert("depth".to_string(), toml::Value::Integer(i64::from(*depth))); + e.insert( + "path".to_string(), + toml::Value::String(prefixed_forward_slash(bundle_prefix, path)), + ); + } + } + e +} - toml::to_string(&doc).expect("augmented manifest serializes") +/// [`path_to_forward_slash`], with `prefix` (a bundle member's own +/// bundle-relative directory) joined in front when present. +fn prefixed_forward_slash(prefix: Option<&Path>, path: &Path) -> String { + match prefix { + Some(p) => path_to_forward_slash(&p.join(path)), + None => path_to_forward_slash(path), + } } /// The content fields of `part`'s kind whose `//.typ` diff --git a/crates/cph-typst/tests/bundle.rs b/crates/cph-typst/tests/bundle.rs new file mode 100644 index 0000000..d0fd920 --- /dev/null +++ b/crates/cph-typst/tests/bundle.rs @@ -0,0 +1,122 @@ +//! Integration tests for the bundle build path (ADR-0030): compiling a bundle +//! target's template (`exports/.typ` under a `bundle.toml` root) as +//! main, injecting the augmented **bundle** manifest, against the real +//! `render/` package. + +use std::path::PathBuf; + +use cph_diag::Severity; +use cph_typst::{build_augmented_bundle_manifest, Engine}; + +fn fixture_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/bundle") +} + +fn real_render_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("render") +} + +fn load_bundle() -> cph_model::Bundle { + let (bundle, diags) = cph_model::load_bundle(&fixture_root()); + let bundle = bundle.expect("bundle fixture loads into a Bundle"); + let errors: Vec<_> = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "fixture has loader errors: {errors:?}"); + bundle +} + +/// PURE UNIT TEST (no fonts, no render package): the augmented bundle manifest +/// carries the bundle's own `[info]` and an ordered `[[lessons]]`, each with +/// that member's own `info`/`target` and a `path`-prefixed outline (ADR-0030). +#[test] +fn augmented_bundle_manifest_prefixes_member_paths() { + let bundle = load_bundle(); + let src = build_augmented_bundle_manifest(&bundle); + + assert!( + src.contains("测试合集"), + "bundle info.title present:\n{src}" + ); + + let doc: toml::Value = toml::from_str(&src).expect("augmented bundle manifest is valid TOML"); + let lessons = doc + .get("lessons") + .and_then(|l| l.as_array()) + .expect("lessons array present"); + assert_eq!(lessons.len(), 2, "two member lessons:\n{src}"); + + // lesson-a: target defaults to its own first declared target ("student"), + // outline paths are prefixed with "lesson-a/". + let a_target = lessons[0].get("target").unwrap().as_str().unwrap(); + assert_eq!(a_target, "student"); + let a_outline = lessons[0].get("outline").unwrap().as_array().unwrap(); + // segment + section heading + lemma = 3 outline entries. + assert_eq!(a_outline.len(), 3); + let a_seg_path = a_outline[0].get("path").unwrap().as_str().unwrap(); + assert_eq!(a_seg_path, "lesson-a/segments/a"); + let a_section_path = a_outline[1].get("path").unwrap().as_str().unwrap(); + assert_eq!(a_section_path, "lesson-a/小节"); + let a_lemma_path = a_outline[2].get("path").unwrap().as_str().unwrap(); + assert_eq!(a_lemma_path, "lesson-a/小节/lemmas/引理甲"); + + // lesson-b: bundle.toml overrides `target = "teacher"`. + let b_target = lessons[1].get("target").unwrap().as_str().unwrap(); + assert_eq!(b_target, "teacher"); + let b_outline = lessons[1].get("outline").unwrap().as_array().unwrap(); + assert_eq!(b_outline.len(), 1); + let b_seg_path = b_outline[0].get("path").unwrap().as_str().unwrap(); + assert_eq!(b_seg_path, "lesson-b/segments/b"); +} + +/// THROUGH-TEMPLATE compile-check against the REAL render package: compiling +/// the bundle's `merged` target as main with the injected augmented bundle +/// manifest is clean. +#[test] +fn compile_check_clean_through_bundle_template() { + let bundle = load_bundle(); + let engine = Engine::with_render_dir(real_render_dir()); + let diags = engine.compile_check_bundle(&bundle, "merged"); + let errors: Vec<_> = diags + .iter() + .filter(|d| d.severity == Severity::Error) + .collect(); + assert!(errors.is_empty(), "unexpected compile errors: {errors:#?}"); +} + +/// THROUGH-TEMPLATE PDF export, fully offline: a non-trivial combined PDF is +/// produced from the two member lessons through the real bundle template. +#[test] +fn build_bundle_pdf_through_template_offline() { + let bundle = load_bundle(); + let engine = Engine::with_render_dir(real_render_dir()); + + let pdf = engine + .build_bundle_pdf(&bundle, "merged") + .unwrap_or_else(|d| panic!("bundle PDF build failed: {d:#?}")); + assert!(pdf.starts_with(b"%PDF"), "output is a PDF"); + assert!( + pdf.len() > 1024, + "bundle PDF is non-trivial (got {} bytes)", + pdf.len() + ); +} + +/// An undeclared bundle target name is a blocking `SchemaViolation`, exactly +/// like a lesson's own unknown-target path. +#[test] +fn unknown_bundle_target_is_blocking() { + let bundle = load_bundle(); + let engine = Engine::with_render_dir(real_render_dir()); + let diags = engine.compile_check_bundle(&bundle, "nonexistent"); + assert_eq!(diags.len(), 1, "one blocking diagnostic: {diags:#?}"); + assert_eq!(diags[0].severity, Severity::Error); + assert!( + diags[0].message.contains("not declared"), + "expected an undeclared-target error: {diags:#?}" + ); +} diff --git a/crates/cph-typst/tests/compile.rs b/crates/cph-typst/tests/compile.rs index 889efdd..2bea1f5 100644 --- a/crates/cph-typst/tests/compile.rs +++ b/crates/cph-typst/tests/compile.rs @@ -39,10 +39,12 @@ fn load_mini() -> cph_model::Lesson { } /// PURE UNIT TEST (no fonts, no render package): the augmented manifest carries -/// `[info]`, the ordered `[[parts]]`, and a per-part `fields` array listing the -/// content fields present on disk. +/// `[info]` and the ordered `[[outline]]` (ADR-0029) — elements (with a +/// per-element `fields` array of the content fields present on disk) +/// interleaved with the section heading the mini fixture nests its two lemmas +/// under. #[test] -fn augmented_manifest_has_per_part_fields() { +fn augmented_manifest_has_outline_with_section_and_fields() { let lesson = load_mini(); let src = build_augmented_manifest(&lesson); @@ -50,66 +52,86 @@ fn augmented_manifest_has_per_part_fields() { assert!(src.contains("迷你示例课时"), "info.title present:\n{src}"); assert!(src.contains("测试作者"), "info.author present:\n{src}"); - // Parse it back to inspect the per-part fields precisely. + // Parse it back to inspect the outline entries precisely. let doc: toml::Value = toml::from_str(&src).expect("augmented manifest is valid TOML"); - let parts = doc - .get("parts") + let outline = doc + .get("outline") .and_then(|p| p.as_array()) - .expect("parts array present"); - assert_eq!(parts.len(), 4, "four parts in declared order:\n{src}"); + .expect("outline array present"); + // segment, section, lemma, lemma, example — 5 entries (ADR-0029: the + // section contributes a heading entry, not an element). + assert_eq!(outline.len(), 5, "five outline entries:\n{src}"); - // `fields` is a presence SET (the template tests membership), so order is - // not load-bearing; sort for a stable assertion. - let fields_of = |idx: usize| -> Vec { - let mut v: Vec = parts[idx] - .get("fields") - .and_then(|f| f.as_array()) - .expect("part has a fields array") - .iter() - .map(|v| v.as_str().unwrap().to_string()) - .collect(); - v.sort(); - v - }; - let path_of = |idx: usize| { - parts[idx] - .get("path") + let entry_type = |idx: usize| { + outline[idx] + .get("type") .unwrap() .as_str() .unwrap() .to_string() }; let kind_of = |idx: usize| { - parts[idx] + outline[idx] .get("kind") .unwrap() .as_str() .unwrap() .to_string() }; + let path_of = |idx: usize| { + outline[idx] + .get("path") + .unwrap() + .as_str() + .unwrap() + .to_string() + }; + // `fields` is a presence SET (the template tests membership), so order is + // not load-bearing; sort for a stable assertion. + let fields_of = |idx: usize| -> Vec { + let mut v: Vec = outline[idx] + .get("fields") + .and_then(|f| f.as_array()) + .expect("element entry has a fields array") + .iter() + .map(|v| v.as_str().unwrap().to_string()) + .collect(); + v.sort(); + v + }; - // Order preserved: segment, lemma (w/ proof), lemma (no proof), example. + // Order preserved: segment, section (引理组), lemma (w/ proof), lemma (no + // proof), example. + assert_eq!(entry_type(0), "element"); assert_eq!(kind_of(0), "segment"); - assert_eq!(kind_of(1), "lemma"); - assert_eq!(kind_of(2), "lemma"); - assert_eq!(kind_of(3), "example"); - - // Paths kept as forward-slash UTF-8. assert_eq!(path_of(0), "segments/开场对照导言"); - assert_eq!(path_of(2), "lemmas/无证明引理"); - - // segment: only `textbook` exists. assert_eq!(fields_of(0), vec!["textbook"]); + + assert_eq!(entry_type(1), "section"); + assert_eq!(outline[1].get("title").unwrap().as_str().unwrap(), "引理组"); + assert_eq!(outline[1].get("depth").unwrap().as_integer().unwrap(), 1); + assert_eq!(path_of(1), "引理组"); + + assert_eq!(entry_type(2), "element"); + assert_eq!(kind_of(2), "lemma"); + assert_eq!(path_of(2), "引理组/lemmas/量纲分析估计"); // lemma WITH proof.typ: both stmt + proof present (sorted). - assert_eq!(fields_of(1), vec!["proof", "stmt"]); + assert_eq!(fields_of(2), vec!["proof", "stmt"]); + + assert_eq!(entry_type(3), "element"); + assert_eq!(kind_of(3), "lemma"); + assert_eq!(path_of(3), "引理组/lemmas/无证明引理"); // lemma WITHOUT proof.typ: only stmt present (the OPTIONAL-content path). assert_eq!( - fields_of(2), + fields_of(3), vec!["stmt"], "proof must be omitted when absent" ); + + assert_eq!(entry_type(4), "element"); + assert_eq!(kind_of(4), "example"); // example: problem + solution present (source is a scalar, not a content field). - assert_eq!(fields_of(3), vec!["problem", "solution"]); + assert_eq!(fields_of(4), vec!["problem", "solution"]); } /// THROUGH-TEMPLATE compile-check against the REAL render package: compiling the @@ -202,6 +224,7 @@ fn file_tree_artifact_is_deferred() { authors: vec![], }, parts: vec![], + outline: vec![], targets: vec![TargetConfig { name: "web".into(), artifact: Artifact::FileTree { @@ -241,6 +264,7 @@ fn shell_only_target_is_deferred() { authors: vec![], }, parts: vec![], + outline: vec![], targets: vec![TargetConfig { name: "packaged".into(), artifact: Artifact::SingleFile { diff --git a/crates/cph-typst/tests/fixtures/bundle/bundle.toml b/crates/cph-typst/tests/fixtures/bundle/bundle.toml new file mode 100644 index 0000000..f87508c --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/bundle.toml @@ -0,0 +1,16 @@ +[info] +title = "测试合集" +author = "测试作者" + +[[lessons]] +path = "lesson-a" + +[[lessons]] +path = "lesson-b" +target = "teacher" + +[targets.merged] +artifact = { type = "single-file", filepath = "build/merged.pdf" } +[[targets.merged.steps]] +type = "typst-compile" +template = "exports/merged.typ" diff --git a/crates/cph-typst/tests/fixtures/bundle/exports/merged.typ b/crates/cph-typst/tests/fixtures/bundle/exports/merged.typ new file mode 100644 index 0000000..5e6c00a --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/exports/merged.typ @@ -0,0 +1,76 @@ +// DEFAULT BUNDLE TEMPLATE (ADR-0030, outline shape ADR-0029). +// +// Lives in a bundle at `/exports/.typ`, e.g. +// `exports/merged.typ`. Compiled AS MAIN with the augmented BUNDLE manifest +// injected: +// typst compile --root --input manifest= exports/merged.typ +// +// Structurally identical to the single-lesson `student.typ`/`teacher.typ` +// templates (see their notes on why the include loop lives in the template, +// not in cph-render), except it reads `manifest.lessons` (an ordered array of +// per-lesson `(info, target, outline)` tables — see +// `cph_typst::build_augmented_bundle_manifest`) instead of a single +// `manifest.outline`, and calls `render-bundle` instead of `render-lesson`. +// +// Every outline entry's `path` in a bundle manifest is ALREADY prefixed with +// that lesson's own bundle-root-relative directory (done by the Rust engine), +// so the same `include "/" + path + "/" + field + ".typ"` computation used by +// a single-lesson template resolves correctly here too — no special-casing +// needed in this loop. + +#import "@local/cph-render:0.1.0": render-bundle, part-fields, default-heading-numbering + +#let manifest = toml(sys.inputs.manifest) +#let info = manifest.at("info", default: (:)) +#let raw-lessons = manifest.at("lessons", default: ()) + +// Assemble one outline entry exactly as a single-lesson template would. +#let assemble-entry(raw) = { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) + + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } + } + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry + } +} + +#let lessons = raw-lessons.map(raw => ( + info: raw.at("info", default: (:)), + target: raw.at("target", default: "student"), + outline: raw.at("outline", default: ()).map(assemble-entry), +)) + +// Presentation: shared per-level heading numbering across the whole bundle, +// and the ADR-0030 recommended default of resetting auto-counters at each +// lesson boundary (override `reset-counters: false` for continuous numbering). +#render-bundle( + info: info, + lessons: lessons, + heading-numbering: default-heading-numbering, + reset-counters: true, +) diff --git a/crates/cph-typst/tests/fixtures/bundle/lesson-a/manifest.toml b/crates/cph-typst/tests/fixtures/bundle/lesson-a/manifest.toml new file mode 100644 index 0000000..35af5ed --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/lesson-a/manifest.toml @@ -0,0 +1,17 @@ +[project] +id = "lesson-a" +name = "lesson-a" + +[info] +title = "课时A" +author = "作者A" + +[[children]] +kind = "segment" +path = "segments/a" + +[[children]] +kind = "section" +path = "小节" + +[targets.student] diff --git a/examples/TH-141/segments/各模型对水的预测对照/element.toml b/crates/cph-typst/tests/fixtures/bundle/lesson-a/segments/a/element.toml similarity index 100% rename from examples/TH-141/segments/各模型对水的预测对照/element.toml rename to crates/cph-typst/tests/fixtures/bundle/lesson-a/segments/a/element.toml diff --git a/crates/cph-typst/tests/fixtures/bundle/lesson-a/segments/a/textbook.typ b/crates/cph-typst/tests/fixtures/bundle/lesson-a/segments/a/textbook.typ new file mode 100644 index 0000000..fe92a37 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/lesson-a/segments/a/textbook.typ @@ -0,0 +1 @@ += 课时A导言 diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/element.toml b/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/lemmas/引理甲/element.toml similarity index 100% rename from crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/element.toml rename to crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/lemmas/引理甲/element.toml diff --git a/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/lemmas/引理甲/stmt.typ b/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/lemmas/引理甲/stmt.typ new file mode 100644 index 0000000..c3983e7 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/lemmas/引理甲/stmt.typ @@ -0,0 +1 @@ +引理甲陈述。 diff --git a/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/manifest.toml b/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/manifest.toml new file mode 100644 index 0000000..d655375 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/lesson-a/小节/manifest.toml @@ -0,0 +1,6 @@ +[group] +title = "小节" + +[[children]] +kind = "lemma" +path = "lemmas/引理甲" diff --git a/crates/cph-typst/tests/fixtures/bundle/lesson-b/manifest.toml b/crates/cph-typst/tests/fixtures/bundle/lesson-b/manifest.toml new file mode 100644 index 0000000..1e7279d --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/lesson-b/manifest.toml @@ -0,0 +1,12 @@ +[project] +id = "lesson-b" +name = "lesson-b" + +[info] +title = "课时B" + +[[children]] +kind = "segment" +path = "segments/b" + +[targets.teacher] diff --git a/examples/TH-141/segments/固气界面导言/element.toml b/crates/cph-typst/tests/fixtures/bundle/lesson-b/segments/b/element.toml similarity index 100% rename from examples/TH-141/segments/固气界面导言/element.toml rename to crates/cph-typst/tests/fixtures/bundle/lesson-b/segments/b/element.toml diff --git a/crates/cph-typst/tests/fixtures/bundle/lesson-b/segments/b/textbook.typ b/crates/cph-typst/tests/fixtures/bundle/lesson-b/segments/b/textbook.typ new file mode 100644 index 0000000..6762120 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/bundle/lesson-b/segments/b/textbook.typ @@ -0,0 +1 @@ += 课时B导言 diff --git a/crates/cph-typst/tests/fixtures/mini/exports/student.typ b/crates/cph-typst/tests/fixtures/mini/exports/student.typ index a33d25d..14d091d 100644 --- a/crates/cph-typst/tests/fixtures/mini/exports/student.typ +++ b/crates/cph-typst/tests/fixtures/mini/exports/student.typ @@ -1,4 +1,4 @@ -// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). +// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // This is a *real, editable* file that lives in an engineering file at // `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the @@ -15,15 +15,16 @@ // its own virtual root — an include inside cph-render would resolve against the // PACKAGE, not the engineering root. A `//.typ` written HERE // (this template lives under `--root`) resolves against `--root`. So the -// template loads content and hands cph-render an already-assembled `parts` array. +// template loads content and hands cph-render an already-assembled `outline` +// array (elements interleaved with section headings, ADR-0029). // // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file // exist" primitive (a missing `include` is a hard compile error). So the // template CANNOT probe disk the way the old Rust driver did for lemma `proof`. // It relies on the manifest declaring which optional content fields are present, -// via a per-part `fields` array listing the content fields that exist on disk +// via a per-element `fields` array listing the content fields that exist on disk // (the engine knows this — it walks the part dir). Required fields are loaded -// unconditionally; optional fields load only if listed in `fields`. If a part +// unconditionally; optional fields load only if listed in `fields`. If an element // omits `fields`, optional content is skipped (conservative). The exact shape of // this declaration is for the manifest/Rust contract to pin. @@ -35,38 +36,51 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: +// - an "element" entry: include its content fields (computed absolute paths, +// resolved against --root) and read scalar fields from /element.toml. +// `part-fields` (from cph-render) is the single source of truth for +// kind->fields. +// - a "section" entry (ADR-0029): pass its title/depth straight through — no +// content to load, it is a heading. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -74,6 +88,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/crates/cph-typst/tests/fixtures/mini/exports/teacher.typ b/crates/cph-typst/tests/fixtures/mini/exports/teacher.typ index b976b1f..abc5b8a 100644 --- a/crates/cph-typst/tests/fixtures/mini/exports/teacher.typ +++ b/crates/cph-typst/tests/fixtures/mini/exports/teacher.typ @@ -1,4 +1,4 @@ -// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). +// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with // the manifest injected: @@ -18,38 +18,47 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: an "element" entry includes its content fields +// and reads scalars from element.toml; a "section" entry (ADR-0029) passes +// title/depth straight through as a heading, no content to load. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -57,6 +66,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/crates/cph-typst/tests/fixtures/mini/manifest.toml b/crates/cph-typst/tests/fixtures/mini/manifest.toml index 61c8f62..4b49c2f 100644 --- a/crates/cph-typst/tests/fixtures/mini/manifest.toml +++ b/crates/cph-typst/tests/fixtures/mini/manifest.toml @@ -6,19 +6,15 @@ name = "迷你课时" title = "迷你示例课时" author = "测试作者" -[[parts]] +[[children]] kind = "segment" path = "segments/开场对照导言" -[[parts]] -kind = "lemma" -path = "lemmas/量纲分析估计" +[[children]] +kind = "section" +path = "引理组" -[[parts]] -kind = "lemma" -path = "lemmas/无证明引理" - -[[parts]] +[[children]] kind = "example" path = "examples/自由落体" diff --git a/examples/TH-141/lemmas/Dupré关系/element.toml b/crates/cph-typst/tests/fixtures/mini/引理组/lemmas/无证明引理/element.toml similarity index 100% rename from examples/TH-141/lemmas/Dupré关系/element.toml rename to crates/cph-typst/tests/fixtures/mini/引理组/lemmas/无证明引理/element.toml diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/无证明引理/stmt.typ b/crates/cph-typst/tests/fixtures/mini/引理组/lemmas/无证明引理/stmt.typ similarity index 100% rename from crates/cph-typst/tests/fixtures/mini/lemmas/无证明引理/stmt.typ rename to crates/cph-typst/tests/fixtures/mini/引理组/lemmas/无证明引理/stmt.typ diff --git a/examples/TH-141/lemmas/Eötvös规则/element.toml b/crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计/element.toml similarity index 100% rename from examples/TH-141/lemmas/Eötvös规则/element.toml rename to crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计/element.toml diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/proof.typ b/crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计/proof.typ similarity index 100% rename from crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/proof.typ rename to crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计/proof.typ diff --git a/crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/stmt.typ b/crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计/stmt.typ similarity index 100% rename from crates/cph-typst/tests/fixtures/mini/lemmas/量纲分析估计/stmt.typ rename to crates/cph-typst/tests/fixtures/mini/引理组/lemmas/量纲分析估计/stmt.typ diff --git a/crates/cph-typst/tests/fixtures/mini/引理组/manifest.toml b/crates/cph-typst/tests/fixtures/mini/引理组/manifest.toml new file mode 100644 index 0000000..daaf457 --- /dev/null +++ b/crates/cph-typst/tests/fixtures/mini/引理组/manifest.toml @@ -0,0 +1,10 @@ +[group] +title = "引理组" + +[[children]] +kind = "lemma" +path = "lemmas/量纲分析估计" + +[[children]] +kind = "lemma" +path = "lemmas/无证明引理" diff --git a/examples/TH-141/exports/student.typ b/examples/TH-141/exports/student.typ index a33d25d..14d091d 100644 --- a/examples/TH-141/exports/student.typ +++ b/examples/TH-141/exports/student.typ @@ -1,4 +1,4 @@ -// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). +// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // This is a *real, editable* file that lives in an engineering file at // `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the @@ -15,15 +15,16 @@ // its own virtual root — an include inside cph-render would resolve against the // PACKAGE, not the engineering root. A `//.typ` written HERE // (this template lives under `--root`) resolves against `--root`. So the -// template loads content and hands cph-render an already-assembled `parts` array. +// template loads content and hands cph-render an already-assembled `outline` +// array (elements interleaved with section headings, ADR-0029). // // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file // exist" primitive (a missing `include` is a hard compile error). So the // template CANNOT probe disk the way the old Rust driver did for lemma `proof`. // It relies on the manifest declaring which optional content fields are present, -// via a per-part `fields` array listing the content fields that exist on disk +// via a per-element `fields` array listing the content fields that exist on disk // (the engine knows this — it walks the part dir). Required fields are loaded -// unconditionally; optional fields load only if listed in `fields`. If a part +// unconditionally; optional fields load only if listed in `fields`. If an element // omits `fields`, optional content is skipped (conservative). The exact shape of // this declaration is for the manifest/Rust contract to pin. @@ -35,38 +36,51 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: +// - an "element" entry: include its content fields (computed absolute paths, +// resolved against --root) and read scalar fields from /element.toml. +// `part-fields` (from cph-render) is the single source of truth for +// kind->fields. +// - a "section" entry (ADR-0029): pass its title/depth straight through — no +// content to load, it is a heading. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -74,6 +88,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/examples/TH-141/exports/teacher.typ b/examples/TH-141/exports/teacher.typ index b976b1f..abc5b8a 100644 --- a/examples/TH-141/exports/teacher.typ +++ b/examples/TH-141/exports/teacher.typ @@ -1,4 +1,4 @@ -// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). +// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with // the manifest injected: @@ -18,38 +18,47 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: an "element" entry includes its content fields +// and reads scalars from element.toml; a "section" entry (ADR-0029) passes +// title/depth straight through as a heading, no content to load. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -57,6 +66,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/examples/TH-141/manifest.toml b/examples/TH-141/manifest.toml index 3376bf0..c6b85d3 100644 --- a/examples/TH-141/manifest.toml +++ b/examples/TH-141/manifest.toml @@ -6,161 +6,37 @@ name = "TH-141_表面张力的严肃理论" title = "TH-141:表面张力的严肃理论" author = "范式教育教研组" -[[parts]] +[[children]] kind = "segment" path = "segments/开场对照导言" -[[parts]] +[[children]] kind = "segment" path = "segments/胡克唯象模型回顾" -[[parts]] +[[children]] kind = "segment" path = "segments/液面拉伸的本质" -[[parts]] -kind = "segment" -path = "segments/液气界面导言" +[[children]] +kind = "section" +path = "液气界面" -[[parts]] -kind = "segment" -path = "segments/微观建模的共同骨架" +[[children]] +kind = "section" +path = "固气界面" -[[parts]] -kind = "lemma" -path = "lemmas/量纲分析估计" +[[children]] +kind = "section" +path = "固液界面" -[[parts]] -kind = "segment" -path = "segments/缺键模型导言" +[[children]] +kind = "section" +path = "σTp态函数建模" -[[parts]] -kind = "lemma" -path = "lemmas/缺键模型一般公式" - -[[parts]] -kind = "example" -path = "examples/41届复赛三-2-缺键模型" - -[[parts]] -kind = "lemma" -path = "lemmas/Stefan极简估算" - -[[parts]] -kind = "lemma" -path = "lemmas/立方格子下zeta具体值" - -[[parts]] -kind = "segment" -path = "segments/LJ积分导言" - -[[parts]] -kind = "lemma" -path = "lemmas/LJ对势积分标度" - -[[parts]] -kind = "segment" -path = "segments/固气界面导言" - -[[parts]] -kind = "segment" -path = "segments/表面能γ与表面应力f" - -[[parts]] -kind = "lemma" -path = "lemmas/拉伸固体的总应力" - -[[parts]] -kind = "lemma" -path = "lemmas/缺键模型迁移到固气" - -[[parts]] -kind = "lemma" -path = "lemmas/固体表面能的晶面各向异性" - -[[parts]] -kind = "segment" -path = "segments/不同物质γ量级对比" - -[[parts]] -kind = "segment" -path = "segments/固液界面导言" - -[[parts]] -kind = "segment" -path = "segments/固液界面能的物理图像" - -[[parts]] -kind = "lemma" -path = "lemmas/Dupré关系" - -[[parts]] -kind = "lemma" -path = "lemmas/Girifalco-Good公式" - -[[parts]] -kind = "segment" -path = "segments/Fowkes极性修正" - -[[parts]] -kind = "segment" -path = "segments/三相接触导言" - -[[parts]] -kind = "lemma" -path = "lemmas/Young方程" - -[[parts]] -kind = "lemma" -path = "lemmas/GGZ浸润判据" - -[[parts]] -kind = "segment" -path = "segments/浸润全谱与高低能表面" - -[[parts]] -kind = "segment" -path = "segments/σT建模导言" - -[[parts]] -kind = "segment" -path = "segments/微观派Lm下降" - -[[parts]] -kind = "lemma" -path = "lemmas/Eötvös规则" - -[[parts]] -kind = "lemma" -path = "lemmas/Guggenheim-Katayama改良" - -[[parts]] -kind = "lemma" -path = "lemmas/表面熵热力学关系" - -[[parts]] -kind = "segment" -path = "segments/σTp态函数导言" - -[[parts]] -kind = "segment" -path = "segments/σ作为态函数的图像" - -[[parts]] -kind = "example" -path = "examples/41届复赛三-1-混注石油" - -[[parts]] -kind = "segment" -path = "segments/收束导言" - -[[parts]] -kind = "segment" -path = "segments/各模型对水的预测对照" - -[[parts]] -kind = "segment" -path = "segments/算不准背后的真实物理" +[[children]] +kind = "section" +path = "收束" # Export targets (ADR-0009/0011): each target is a build producing a typed # artifact, run as an ordered list of typed steps. A `typst-compile` step names a diff --git a/examples/TH-141/examples/41届复赛三-1-混注石油/element.toml b/examples/TH-141/σTp态函数建模/examples/41届复赛三-1-混注石油/element.toml similarity index 100% rename from examples/TH-141/examples/41届复赛三-1-混注石油/element.toml rename to examples/TH-141/σTp态函数建模/examples/41届复赛三-1-混注石油/element.toml diff --git a/examples/TH-141/examples/41届复赛三-1-混注石油/problem.typ b/examples/TH-141/σTp态函数建模/examples/41届复赛三-1-混注石油/problem.typ similarity index 100% rename from examples/TH-141/examples/41届复赛三-1-混注石油/problem.typ rename to examples/TH-141/σTp态函数建模/examples/41届复赛三-1-混注石油/problem.typ diff --git a/examples/TH-141/examples/41届复赛三-1-混注石油/solution.typ b/examples/TH-141/σTp态函数建模/examples/41届复赛三-1-混注石油/solution.typ similarity index 100% rename from examples/TH-141/examples/41届复赛三-1-混注石油/solution.typ rename to examples/TH-141/σTp态函数建模/examples/41届复赛三-1-混注石油/solution.typ diff --git a/examples/TH-141/lemmas/GGZ浸润判据/element.toml b/examples/TH-141/σTp态函数建模/lemmas/Eötvös规则/element.toml similarity index 100% rename from examples/TH-141/lemmas/GGZ浸润判据/element.toml rename to examples/TH-141/σTp态函数建模/lemmas/Eötvös规则/element.toml diff --git a/examples/TH-141/lemmas/Eötvös规则/stmt.typ b/examples/TH-141/σTp态函数建模/lemmas/Eötvös规则/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/Eötvös规则/stmt.typ rename to examples/TH-141/σTp态函数建模/lemmas/Eötvös规则/stmt.typ diff --git a/examples/TH-141/lemmas/Girifalco-Good公式/element.toml b/examples/TH-141/σTp态函数建模/lemmas/Guggenheim-Katayama改良/element.toml similarity index 100% rename from examples/TH-141/lemmas/Girifalco-Good公式/element.toml rename to examples/TH-141/σTp态函数建模/lemmas/Guggenheim-Katayama改良/element.toml diff --git a/examples/TH-141/lemmas/Guggenheim-Katayama改良/stmt.typ b/examples/TH-141/σTp态函数建模/lemmas/Guggenheim-Katayama改良/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/Guggenheim-Katayama改良/stmt.typ rename to examples/TH-141/σTp态函数建模/lemmas/Guggenheim-Katayama改良/stmt.typ diff --git a/examples/TH-141/lemmas/Guggenheim-Katayama改良/element.toml b/examples/TH-141/σTp态函数建模/lemmas/表面熵热力学关系/element.toml similarity index 100% rename from examples/TH-141/lemmas/Guggenheim-Katayama改良/element.toml rename to examples/TH-141/σTp态函数建模/lemmas/表面熵热力学关系/element.toml diff --git a/examples/TH-141/lemmas/表面熵热力学关系/proof.typ b/examples/TH-141/σTp态函数建模/lemmas/表面熵热力学关系/proof.typ similarity index 100% rename from examples/TH-141/lemmas/表面熵热力学关系/proof.typ rename to examples/TH-141/σTp态函数建模/lemmas/表面熵热力学关系/proof.typ diff --git a/examples/TH-141/lemmas/表面熵热力学关系/stmt.typ b/examples/TH-141/σTp态函数建模/lemmas/表面熵热力学关系/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/表面熵热力学关系/stmt.typ rename to examples/TH-141/σTp态函数建模/lemmas/表面熵热力学关系/stmt.typ diff --git a/examples/TH-141/σTp态函数建模/manifest.toml b/examples/TH-141/σTp态函数建模/manifest.toml new file mode 100644 index 0000000..00d2ff5 --- /dev/null +++ b/examples/TH-141/σTp态函数建模/manifest.toml @@ -0,0 +1,34 @@ +[group] +title = "σTp 态函数建模" + +[[children]] +kind = "segment" +path = "segments/σT建模导言" + +[[children]] +kind = "segment" +path = "segments/微观派Lm下降" + +[[children]] +kind = "lemma" +path = "lemmas/Eötvös规则" + +[[children]] +kind = "lemma" +path = "lemmas/Guggenheim-Katayama改良" + +[[children]] +kind = "lemma" +path = "lemmas/表面熵热力学关系" + +[[children]] +kind = "segment" +path = "segments/σTp态函数导言" + +[[children]] +kind = "segment" +path = "segments/σ作为态函数的图像" + +[[children]] +kind = "example" +path = "examples/41届复赛三-1-混注石油" diff --git a/examples/TH-141/segments/固液界面导言/element.toml b/examples/TH-141/σTp态函数建模/segments/σTp态函数导言/element.toml similarity index 100% rename from examples/TH-141/segments/固液界面导言/element.toml rename to examples/TH-141/σTp态函数建模/segments/σTp态函数导言/element.toml diff --git a/examples/TH-141/segments/σTp态函数导言/textbook.typ b/examples/TH-141/σTp态函数建模/segments/σTp态函数导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/σTp态函数导言/textbook.typ rename to examples/TH-141/σTp态函数建模/segments/σTp态函数导言/textbook.typ diff --git a/examples/TH-141/segments/固液界面能的物理图像/element.toml b/examples/TH-141/σTp态函数建模/segments/σT建模导言/element.toml similarity index 100% rename from examples/TH-141/segments/固液界面能的物理图像/element.toml rename to examples/TH-141/σTp态函数建模/segments/σT建模导言/element.toml diff --git a/examples/TH-141/segments/σT建模导言/textbook.typ b/examples/TH-141/σTp态函数建模/segments/σT建模导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/σT建模导言/textbook.typ rename to examples/TH-141/σTp态函数建模/segments/σT建模导言/textbook.typ diff --git a/examples/TH-141/segments/微观建模的共同骨架/element.toml b/examples/TH-141/σTp态函数建模/segments/σ作为态函数的图像/element.toml similarity index 100% rename from examples/TH-141/segments/微观建模的共同骨架/element.toml rename to examples/TH-141/σTp态函数建模/segments/σ作为态函数的图像/element.toml diff --git a/examples/TH-141/segments/σ作为态函数的图像/textbook.typ b/examples/TH-141/σTp态函数建模/segments/σ作为态函数的图像/textbook.typ similarity index 100% rename from examples/TH-141/segments/σ作为态函数的图像/textbook.typ rename to examples/TH-141/σTp态函数建模/segments/σ作为态函数的图像/textbook.typ diff --git a/examples/TH-141/segments/微观派Lm下降/element.toml b/examples/TH-141/σTp态函数建模/segments/微观派Lm下降/element.toml similarity index 100% rename from examples/TH-141/segments/微观派Lm下降/element.toml rename to examples/TH-141/σTp态函数建模/segments/微观派Lm下降/element.toml diff --git a/examples/TH-141/segments/微观派Lm下降/textbook.typ b/examples/TH-141/σTp态函数建模/segments/微观派Lm下降/textbook.typ similarity index 100% rename from examples/TH-141/segments/微观派Lm下降/textbook.typ rename to examples/TH-141/σTp态函数建模/segments/微观派Lm下降/textbook.typ diff --git a/examples/TH-141/lemmas/LJ对势积分标度/element.toml b/examples/TH-141/固气界面/lemmas/固体表面能的晶面各向异性/element.toml similarity index 100% rename from examples/TH-141/lemmas/LJ对势积分标度/element.toml rename to examples/TH-141/固气界面/lemmas/固体表面能的晶面各向异性/element.toml diff --git a/examples/TH-141/lemmas/固体表面能的晶面各向异性/stmt.typ b/examples/TH-141/固气界面/lemmas/固体表面能的晶面各向异性/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/固体表面能的晶面各向异性/stmt.typ rename to examples/TH-141/固气界面/lemmas/固体表面能的晶面各向异性/stmt.typ diff --git a/examples/TH-141/lemmas/Stefan极简估算/element.toml b/examples/TH-141/固气界面/lemmas/拉伸固体的总应力/element.toml similarity index 100% rename from examples/TH-141/lemmas/Stefan极简估算/element.toml rename to examples/TH-141/固气界面/lemmas/拉伸固体的总应力/element.toml diff --git a/examples/TH-141/lemmas/拉伸固体的总应力/proof.typ b/examples/TH-141/固气界面/lemmas/拉伸固体的总应力/proof.typ similarity index 100% rename from examples/TH-141/lemmas/拉伸固体的总应力/proof.typ rename to examples/TH-141/固气界面/lemmas/拉伸固体的总应力/proof.typ diff --git a/examples/TH-141/lemmas/拉伸固体的总应力/stmt.typ b/examples/TH-141/固气界面/lemmas/拉伸固体的总应力/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/拉伸固体的总应力/stmt.typ rename to examples/TH-141/固气界面/lemmas/拉伸固体的总应力/stmt.typ diff --git a/examples/TH-141/lemmas/Young方程/element.toml b/examples/TH-141/固气界面/lemmas/缺键模型迁移到固气/element.toml similarity index 100% rename from examples/TH-141/lemmas/Young方程/element.toml rename to examples/TH-141/固气界面/lemmas/缺键模型迁移到固气/element.toml diff --git a/examples/TH-141/lemmas/缺键模型迁移到固气/stmt.typ b/examples/TH-141/固气界面/lemmas/缺键模型迁移到固气/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/缺键模型迁移到固气/stmt.typ rename to examples/TH-141/固气界面/lemmas/缺键模型迁移到固气/stmt.typ diff --git a/examples/TH-141/固气界面/manifest.toml b/examples/TH-141/固气界面/manifest.toml new file mode 100644 index 0000000..7158a77 --- /dev/null +++ b/examples/TH-141/固气界面/manifest.toml @@ -0,0 +1,26 @@ +[group] +title = "固气界面" + +[[children]] +kind = "segment" +path = "segments/固气界面导言" + +[[children]] +kind = "segment" +path = "segments/表面能γ与表面应力f" + +[[children]] +kind = "lemma" +path = "lemmas/拉伸固体的总应力" + +[[children]] +kind = "lemma" +path = "lemmas/缺键模型迁移到固气" + +[[children]] +kind = "lemma" +path = "lemmas/固体表面能的晶面各向异性" + +[[children]] +kind = "segment" +path = "segments/不同物质γ量级对比" diff --git a/examples/TH-141/segments/收束导言/element.toml b/examples/TH-141/固气界面/segments/不同物质γ量级对比/element.toml similarity index 100% rename from examples/TH-141/segments/收束导言/element.toml rename to examples/TH-141/固气界面/segments/不同物质γ量级对比/element.toml diff --git a/examples/TH-141/segments/不同物质γ量级对比/textbook.typ b/examples/TH-141/固气界面/segments/不同物质γ量级对比/textbook.typ similarity index 100% rename from examples/TH-141/segments/不同物质γ量级对比/textbook.typ rename to examples/TH-141/固气界面/segments/不同物质γ量级对比/textbook.typ diff --git a/examples/TH-141/segments/浸润全谱与高低能表面/element.toml b/examples/TH-141/固气界面/segments/固气界面导言/element.toml similarity index 100% rename from examples/TH-141/segments/浸润全谱与高低能表面/element.toml rename to examples/TH-141/固气界面/segments/固气界面导言/element.toml diff --git a/examples/TH-141/segments/固气界面导言/textbook.typ b/examples/TH-141/固气界面/segments/固气界面导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/固气界面导言/textbook.typ rename to examples/TH-141/固气界面/segments/固气界面导言/textbook.typ diff --git a/examples/TH-141/segments/液气界面导言/element.toml b/examples/TH-141/固气界面/segments/表面能γ与表面应力f/element.toml similarity index 100% rename from examples/TH-141/segments/液气界面导言/element.toml rename to examples/TH-141/固气界面/segments/表面能γ与表面应力f/element.toml diff --git a/examples/TH-141/segments/表面能γ与表面应力f/textbook.typ b/examples/TH-141/固气界面/segments/表面能γ与表面应力f/textbook.typ similarity index 100% rename from examples/TH-141/segments/表面能γ与表面应力f/textbook.typ rename to examples/TH-141/固气界面/segments/表面能γ与表面应力f/textbook.typ diff --git a/examples/TH-141/lemmas/固体表面能的晶面各向异性/element.toml b/examples/TH-141/固液界面/lemmas/Dupré关系/element.toml similarity index 100% rename from examples/TH-141/lemmas/固体表面能的晶面各向异性/element.toml rename to examples/TH-141/固液界面/lemmas/Dupré关系/element.toml diff --git a/examples/TH-141/lemmas/Dupré关系/proof.typ b/examples/TH-141/固液界面/lemmas/Dupré关系/proof.typ similarity index 100% rename from examples/TH-141/lemmas/Dupré关系/proof.typ rename to examples/TH-141/固液界面/lemmas/Dupré关系/proof.typ diff --git a/examples/TH-141/lemmas/Dupré关系/stmt.typ b/examples/TH-141/固液界面/lemmas/Dupré关系/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/Dupré关系/stmt.typ rename to examples/TH-141/固液界面/lemmas/Dupré关系/stmt.typ diff --git a/examples/TH-141/lemmas/拉伸固体的总应力/element.toml b/examples/TH-141/固液界面/lemmas/GGZ浸润判据/element.toml similarity index 100% rename from examples/TH-141/lemmas/拉伸固体的总应力/element.toml rename to examples/TH-141/固液界面/lemmas/GGZ浸润判据/element.toml diff --git a/examples/TH-141/lemmas/GGZ浸润判据/proof.typ b/examples/TH-141/固液界面/lemmas/GGZ浸润判据/proof.typ similarity index 100% rename from examples/TH-141/lemmas/GGZ浸润判据/proof.typ rename to examples/TH-141/固液界面/lemmas/GGZ浸润判据/proof.typ diff --git a/examples/TH-141/lemmas/GGZ浸润判据/stmt.typ b/examples/TH-141/固液界面/lemmas/GGZ浸润判据/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/GGZ浸润判据/stmt.typ rename to examples/TH-141/固液界面/lemmas/GGZ浸润判据/stmt.typ diff --git a/examples/TH-141/lemmas/立方格子下zeta具体值/element.toml b/examples/TH-141/固液界面/lemmas/Girifalco-Good公式/element.toml similarity index 100% rename from examples/TH-141/lemmas/立方格子下zeta具体值/element.toml rename to examples/TH-141/固液界面/lemmas/Girifalco-Good公式/element.toml diff --git a/examples/TH-141/lemmas/Girifalco-Good公式/proof.typ b/examples/TH-141/固液界面/lemmas/Girifalco-Good公式/proof.typ similarity index 100% rename from examples/TH-141/lemmas/Girifalco-Good公式/proof.typ rename to examples/TH-141/固液界面/lemmas/Girifalco-Good公式/proof.typ diff --git a/examples/TH-141/lemmas/Girifalco-Good公式/stmt.typ b/examples/TH-141/固液界面/lemmas/Girifalco-Good公式/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/Girifalco-Good公式/stmt.typ rename to examples/TH-141/固液界面/lemmas/Girifalco-Good公式/stmt.typ diff --git a/examples/TH-141/lemmas/缺键模型一般公式/element.toml b/examples/TH-141/固液界面/lemmas/Young方程/element.toml similarity index 100% rename from examples/TH-141/lemmas/缺键模型一般公式/element.toml rename to examples/TH-141/固液界面/lemmas/Young方程/element.toml diff --git a/examples/TH-141/lemmas/Young方程/proof.typ b/examples/TH-141/固液界面/lemmas/Young方程/proof.typ similarity index 100% rename from examples/TH-141/lemmas/Young方程/proof.typ rename to examples/TH-141/固液界面/lemmas/Young方程/proof.typ diff --git a/examples/TH-141/lemmas/Young方程/stmt.typ b/examples/TH-141/固液界面/lemmas/Young方程/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/Young方程/stmt.typ rename to examples/TH-141/固液界面/lemmas/Young方程/stmt.typ diff --git a/examples/TH-141/固液界面/manifest.toml b/examples/TH-141/固液界面/manifest.toml new file mode 100644 index 0000000..9406a95 --- /dev/null +++ b/examples/TH-141/固液界面/manifest.toml @@ -0,0 +1,38 @@ +[group] +title = "固液界面" + +[[children]] +kind = "segment" +path = "segments/固液界面导言" + +[[children]] +kind = "segment" +path = "segments/固液界面能的物理图像" + +[[children]] +kind = "lemma" +path = "lemmas/Dupré关系" + +[[children]] +kind = "lemma" +path = "lemmas/Girifalco-Good公式" + +[[children]] +kind = "segment" +path = "segments/Fowkes极性修正" + +[[children]] +kind = "segment" +path = "segments/三相接触导言" + +[[children]] +kind = "lemma" +path = "lemmas/Young方程" + +[[children]] +kind = "lemma" +path = "lemmas/GGZ浸润判据" + +[[children]] +kind = "segment" +path = "segments/浸润全谱与高低能表面" diff --git a/examples/TH-141/segments/算不准背后的真实物理/element.toml b/examples/TH-141/固液界面/segments/Fowkes极性修正/element.toml similarity index 100% rename from examples/TH-141/segments/算不准背后的真实物理/element.toml rename to examples/TH-141/固液界面/segments/Fowkes极性修正/element.toml diff --git a/examples/TH-141/segments/Fowkes极性修正/textbook.typ b/examples/TH-141/固液界面/segments/Fowkes极性修正/textbook.typ similarity index 100% rename from examples/TH-141/segments/Fowkes极性修正/textbook.typ rename to examples/TH-141/固液界面/segments/Fowkes极性修正/textbook.typ diff --git a/examples/TH-141/segments/缺键模型导言/element.toml b/examples/TH-141/固液界面/segments/三相接触导言/element.toml similarity index 100% rename from examples/TH-141/segments/缺键模型导言/element.toml rename to examples/TH-141/固液界面/segments/三相接触导言/element.toml diff --git a/examples/TH-141/segments/三相接触导言/textbook.typ b/examples/TH-141/固液界面/segments/三相接触导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/三相接触导言/textbook.typ rename to examples/TH-141/固液界面/segments/三相接触导言/textbook.typ diff --git a/examples/TH-141/segments/表面能γ与表面应力f/element.toml b/examples/TH-141/固液界面/segments/固液界面导言/element.toml similarity index 100% rename from examples/TH-141/segments/表面能γ与表面应力f/element.toml rename to examples/TH-141/固液界面/segments/固液界面导言/element.toml diff --git a/examples/TH-141/segments/固液界面导言/textbook.typ b/examples/TH-141/固液界面/segments/固液界面导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/固液界面导言/textbook.typ rename to examples/TH-141/固液界面/segments/固液界面导言/textbook.typ diff --git a/examples/TH-141/固液界面/segments/固液界面能的物理图像/element.toml b/examples/TH-141/固液界面/segments/固液界面能的物理图像/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/固液界面/segments/固液界面能的物理图像/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/固液界面能的物理图像/textbook.typ b/examples/TH-141/固液界面/segments/固液界面能的物理图像/textbook.typ similarity index 100% rename from examples/TH-141/segments/固液界面能的物理图像/textbook.typ rename to examples/TH-141/固液界面/segments/固液界面能的物理图像/textbook.typ diff --git a/examples/TH-141/固液界面/segments/浸润全谱与高低能表面/element.toml b/examples/TH-141/固液界面/segments/浸润全谱与高低能表面/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/固液界面/segments/浸润全谱与高低能表面/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/浸润全谱与高低能表面/textbook.typ b/examples/TH-141/固液界面/segments/浸润全谱与高低能表面/textbook.typ similarity index 100% rename from examples/TH-141/segments/浸润全谱与高低能表面/textbook.typ rename to examples/TH-141/固液界面/segments/浸润全谱与高低能表面/textbook.typ diff --git a/examples/TH-141/收束/manifest.toml b/examples/TH-141/收束/manifest.toml new file mode 100644 index 0000000..e08e1d6 --- /dev/null +++ b/examples/TH-141/收束/manifest.toml @@ -0,0 +1,14 @@ +[group] +title = "收束" + +[[children]] +kind = "segment" +path = "segments/收束导言" + +[[children]] +kind = "segment" +path = "segments/各模型对水的预测对照" + +[[children]] +kind = "segment" +path = "segments/算不准背后的真实物理" diff --git a/examples/TH-141/收束/segments/各模型对水的预测对照/element.toml b/examples/TH-141/收束/segments/各模型对水的预测对照/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/收束/segments/各模型对水的预测对照/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/各模型对水的预测对照/textbook.typ b/examples/TH-141/收束/segments/各模型对水的预测对照/textbook.typ similarity index 100% rename from examples/TH-141/segments/各模型对水的预测对照/textbook.typ rename to examples/TH-141/收束/segments/各模型对水的预测对照/textbook.typ diff --git a/examples/TH-141/收束/segments/收束导言/element.toml b/examples/TH-141/收束/segments/收束导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/收束/segments/收束导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/收束导言/textbook.typ b/examples/TH-141/收束/segments/收束导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/收束导言/textbook.typ rename to examples/TH-141/收束/segments/收束导言/textbook.typ diff --git a/examples/TH-141/收束/segments/算不准背后的真实物理/element.toml b/examples/TH-141/收束/segments/算不准背后的真实物理/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/收束/segments/算不准背后的真实物理/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/算不准背后的真实物理/textbook.typ b/examples/TH-141/收束/segments/算不准背后的真实物理/textbook.typ similarity index 100% rename from examples/TH-141/segments/算不准背后的真实物理/textbook.typ rename to examples/TH-141/收束/segments/算不准背后的真实物理/textbook.typ diff --git a/examples/TH-141/examples/41届复赛三-2-缺键模型/element.toml b/examples/TH-141/液气界面/examples/41届复赛三-2-缺键模型/element.toml similarity index 100% rename from examples/TH-141/examples/41届复赛三-2-缺键模型/element.toml rename to examples/TH-141/液气界面/examples/41届复赛三-2-缺键模型/element.toml diff --git a/examples/TH-141/examples/41届复赛三-2-缺键模型/problem.typ b/examples/TH-141/液气界面/examples/41届复赛三-2-缺键模型/problem.typ similarity index 100% rename from examples/TH-141/examples/41届复赛三-2-缺键模型/problem.typ rename to examples/TH-141/液气界面/examples/41届复赛三-2-缺键模型/problem.typ diff --git a/examples/TH-141/examples/41届复赛三-2-缺键模型/solution.typ b/examples/TH-141/液气界面/examples/41届复赛三-2-缺键模型/solution.typ similarity index 100% rename from examples/TH-141/examples/41届复赛三-2-缺键模型/solution.typ rename to examples/TH-141/液气界面/examples/41届复赛三-2-缺键模型/solution.typ diff --git a/examples/TH-141/lemmas/缺键模型迁移到固气/element.toml b/examples/TH-141/液气界面/lemmas/LJ对势积分标度/element.toml similarity index 100% rename from examples/TH-141/lemmas/缺键模型迁移到固气/element.toml rename to examples/TH-141/液气界面/lemmas/LJ对势积分标度/element.toml diff --git a/examples/TH-141/lemmas/LJ对势积分标度/proof.typ b/examples/TH-141/液气界面/lemmas/LJ对势积分标度/proof.typ similarity index 100% rename from examples/TH-141/lemmas/LJ对势积分标度/proof.typ rename to examples/TH-141/液气界面/lemmas/LJ对势积分标度/proof.typ diff --git a/examples/TH-141/lemmas/LJ对势积分标度/stmt.typ b/examples/TH-141/液气界面/lemmas/LJ对势积分标度/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/LJ对势积分标度/stmt.typ rename to examples/TH-141/液气界面/lemmas/LJ对势积分标度/stmt.typ diff --git a/examples/TH-141/lemmas/表面熵热力学关系/element.toml b/examples/TH-141/液气界面/lemmas/Stefan极简估算/element.toml similarity index 100% rename from examples/TH-141/lemmas/表面熵热力学关系/element.toml rename to examples/TH-141/液气界面/lemmas/Stefan极简估算/element.toml diff --git a/examples/TH-141/lemmas/Stefan极简估算/proof.typ b/examples/TH-141/液气界面/lemmas/Stefan极简估算/proof.typ similarity index 100% rename from examples/TH-141/lemmas/Stefan极简估算/proof.typ rename to examples/TH-141/液气界面/lemmas/Stefan极简估算/proof.typ diff --git a/examples/TH-141/lemmas/Stefan极简估算/stmt.typ b/examples/TH-141/液气界面/lemmas/Stefan极简估算/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/Stefan极简估算/stmt.typ rename to examples/TH-141/液气界面/lemmas/Stefan极简估算/stmt.typ diff --git a/examples/TH-141/lemmas/量纲分析估计/element.toml b/examples/TH-141/液气界面/lemmas/立方格子下zeta具体值/element.toml similarity index 100% rename from examples/TH-141/lemmas/量纲分析估计/element.toml rename to examples/TH-141/液气界面/lemmas/立方格子下zeta具体值/element.toml diff --git a/examples/TH-141/lemmas/立方格子下zeta具体值/proof.typ b/examples/TH-141/液气界面/lemmas/立方格子下zeta具体值/proof.typ similarity index 100% rename from examples/TH-141/lemmas/立方格子下zeta具体值/proof.typ rename to examples/TH-141/液气界面/lemmas/立方格子下zeta具体值/proof.typ diff --git a/examples/TH-141/lemmas/立方格子下zeta具体值/stmt.typ b/examples/TH-141/液气界面/lemmas/立方格子下zeta具体值/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/立方格子下zeta具体值/stmt.typ rename to examples/TH-141/液气界面/lemmas/立方格子下zeta具体值/stmt.typ diff --git a/examples/TH-141/液气界面/lemmas/缺键模型一般公式/element.toml b/examples/TH-141/液气界面/lemmas/缺键模型一般公式/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/液气界面/lemmas/缺键模型一般公式/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/缺键模型一般公式/proof.typ b/examples/TH-141/液气界面/lemmas/缺键模型一般公式/proof.typ similarity index 100% rename from examples/TH-141/lemmas/缺键模型一般公式/proof.typ rename to examples/TH-141/液气界面/lemmas/缺键模型一般公式/proof.typ diff --git a/examples/TH-141/lemmas/缺键模型一般公式/stmt.typ b/examples/TH-141/液气界面/lemmas/缺键模型一般公式/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/缺键模型一般公式/stmt.typ rename to examples/TH-141/液气界面/lemmas/缺键模型一般公式/stmt.typ diff --git a/examples/TH-141/液气界面/lemmas/量纲分析估计/element.toml b/examples/TH-141/液气界面/lemmas/量纲分析估计/element.toml new file mode 100644 index 0000000..e0a523e --- /dev/null +++ b/examples/TH-141/液气界面/lemmas/量纲分析估计/element.toml @@ -0,0 +1 @@ +kind = "lemma" diff --git a/examples/TH-141/lemmas/量纲分析估计/stmt.typ b/examples/TH-141/液气界面/lemmas/量纲分析估计/stmt.typ similarity index 100% rename from examples/TH-141/lemmas/量纲分析估计/stmt.typ rename to examples/TH-141/液气界面/lemmas/量纲分析估计/stmt.typ diff --git a/examples/TH-141/液气界面/manifest.toml b/examples/TH-141/液气界面/manifest.toml new file mode 100644 index 0000000..2593bed --- /dev/null +++ b/examples/TH-141/液气界面/manifest.toml @@ -0,0 +1,42 @@ +[group] +title = "液气界面" + +[[children]] +kind = "segment" +path = "segments/液气界面导言" + +[[children]] +kind = "segment" +path = "segments/微观建模的共同骨架" + +[[children]] +kind = "lemma" +path = "lemmas/量纲分析估计" + +[[children]] +kind = "segment" +path = "segments/缺键模型导言" + +[[children]] +kind = "lemma" +path = "lemmas/缺键模型一般公式" + +[[children]] +kind = "example" +path = "examples/41届复赛三-2-缺键模型" + +[[children]] +kind = "lemma" +path = "lemmas/Stefan极简估算" + +[[children]] +kind = "lemma" +path = "lemmas/立方格子下zeta具体值" + +[[children]] +kind = "segment" +path = "segments/LJ积分导言" + +[[children]] +kind = "lemma" +path = "lemmas/LJ对势积分标度" diff --git a/examples/TH-141/液气界面/segments/LJ积分导言/element.toml b/examples/TH-141/液气界面/segments/LJ积分导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/液气界面/segments/LJ积分导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/LJ积分导言/textbook.typ b/examples/TH-141/液气界面/segments/LJ积分导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/LJ积分导言/textbook.typ rename to examples/TH-141/液气界面/segments/LJ积分导言/textbook.typ diff --git a/examples/TH-141/液气界面/segments/微观建模的共同骨架/element.toml b/examples/TH-141/液气界面/segments/微观建模的共同骨架/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/液气界面/segments/微观建模的共同骨架/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/微观建模的共同骨架/textbook.typ b/examples/TH-141/液气界面/segments/微观建模的共同骨架/textbook.typ similarity index 100% rename from examples/TH-141/segments/微观建模的共同骨架/textbook.typ rename to examples/TH-141/液气界面/segments/微观建模的共同骨架/textbook.typ diff --git a/examples/TH-141/液气界面/segments/液气界面导言/element.toml b/examples/TH-141/液气界面/segments/液气界面导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/液气界面/segments/液气界面导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/液气界面导言/textbook.typ b/examples/TH-141/液气界面/segments/液气界面导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/液气界面导言/textbook.typ rename to examples/TH-141/液气界面/segments/液气界面导言/textbook.typ diff --git a/examples/TH-141/液气界面/segments/缺键模型导言/element.toml b/examples/TH-141/液气界面/segments/缺键模型导言/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/examples/TH-141/液气界面/segments/缺键模型导言/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/examples/TH-141/segments/缺键模型导言/textbook.typ b/examples/TH-141/液气界面/segments/缺键模型导言/textbook.typ similarity index 100% rename from examples/TH-141/segments/缺键模型导言/textbook.typ rename to examples/TH-141/液气界面/segments/缺键模型导言/textbook.typ diff --git a/render/examples/smoke-eng/exports/student.typ b/render/examples/smoke-eng/exports/student.typ index a33d25d..14d091d 100644 --- a/render/examples/smoke-eng/exports/student.typ +++ b/render/examples/smoke-eng/exports/student.typ @@ -1,4 +1,4 @@ -// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). +// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // This is a *real, editable* file that lives in an engineering file at // `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the @@ -15,15 +15,16 @@ // its own virtual root — an include inside cph-render would resolve against the // PACKAGE, not the engineering root. A `//.typ` written HERE // (this template lives under `--root`) resolves against `--root`. So the -// template loads content and hands cph-render an already-assembled `parts` array. +// template loads content and hands cph-render an already-assembled `outline` +// array (elements interleaved with section headings, ADR-0029). // // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file // exist" primitive (a missing `include` is a hard compile error). So the // template CANNOT probe disk the way the old Rust driver did for lemma `proof`. // It relies on the manifest declaring which optional content fields are present, -// via a per-part `fields` array listing the content fields that exist on disk +// via a per-element `fields` array listing the content fields that exist on disk // (the engine knows this — it walks the part dir). Required fields are loaded -// unconditionally; optional fields load only if listed in `fields`. If a part +// unconditionally; optional fields load only if listed in `fields`. If an element // omits `fields`, optional content is skipped (conservative). The exact shape of // this declaration is for the manifest/Rust contract to pin. @@ -35,38 +36,51 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: +// - an "element" entry: include its content fields (computed absolute paths, +// resolved against --root) and read scalar fields from /element.toml. +// `part-fields` (from cph-render) is the single source of truth for +// kind->fields. +// - a "section" entry (ADR-0029): pass its title/depth straight through — no +// content to load, it is a heading. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -74,6 +88,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/render/examples/smoke-eng/exports/teacher.typ b/render/examples/smoke-eng/exports/teacher.typ index b976b1f..abc5b8a 100644 --- a/render/examples/smoke-eng/exports/teacher.typ +++ b/render/examples/smoke-eng/exports/teacher.typ @@ -1,4 +1,4 @@ -// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). +// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with // the manifest injected: @@ -18,38 +18,47 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: an "element" entry includes its content fields +// and reads scalars from element.toml; a "section" entry (ADR-0029) passes +// title/depth straight through as a heading, no content to load. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -57,6 +66,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/render/examples/smoke-eng/manifest.toml b/render/examples/smoke-eng/manifest.toml index bb64c9d..876632f 100644 --- a/render/examples/smoke-eng/manifest.toml +++ b/render/examples/smoke-eng/manifest.toml @@ -1,11 +1,15 @@ -# Throwaway smoke engineering file for the cph-render template round (ADR-0011). -# Exercises all 4 kinds + nested headings (per-level numbering) + an example with -# a `source` scalar and one without + a lemma with proof and one without. +# Throwaway smoke engineering file for the cph-render template round +# (ADR-0011, outline shape ADR-0029). Exercises all 4 kinds + a section heading +# + nested headings (per-level numbering) + an example with a `source` scalar +# and one without + a lemma with proof and one without. # -# The per-part `fields` array lists the content fields present ON DISK. The -# template uses it to decide whether to load OPTIONAL content (lemma `proof`), -# because typst has no file-exists primitive. (OPEN: the exact manifest shape for -# declaring optional-field presence is for the Rust/manifest contract to pin.) +# This file is compiled DIRECTLY via `typst compile --input manifest=/manifest.toml` +# (see README below), bypassing the Rust engine's augmented-manifest builder +# (`cph_typst::build_augmented_manifest`). So the `[[outline]]` shape here is +# hand-authored to already be what that builder would produce: an ordered array +# of `type = "element"` (kind, path, fields — the content fields present on +# disk, since typst has no file-exists primitive) and `type = "section"` +# (kind, title, depth) entries. [project] id = "smoke-eng" @@ -15,38 +19,54 @@ name = "cph-render template smoke" title = "向量与几何 · 示例讲义" author = ["张老师", "李老师"] -# segment — nested headings exercise per-level numbering (一、 / 1.1 / 1.1.1). -[[parts]] +# segment — exercises per-level numbering (一、 / 1.1 / 1.1.1) alongside the +# section heading below. +[[outline]] +type = "element" kind = "segment" path = "segments/向量数量积" fields = ["textbook"] +# A section heading (ADR-0029): opens depth-1, groups the two examples that +# follow. Contributes no element to the sequence. +[[outline]] +type = "section" +kind = "section" +title = "数量积的坐标计算" +depth = 1 +path = "examples" + # example WITH source scalar -[[parts]] +[[outline]] +type = "element" kind = "example" path = "examples/坐标数量积" fields = ["problem", "solution"] # example WITHOUT source scalar -[[parts]] +[[outline]] +type = "element" kind = "example" path = "examples/求模长" fields = ["problem", "solution"] # lemma WITH proof (proof.typ present on disk; declared in fields) -[[parts]] +[[outline]] +type = "element" kind = "lemma" path = "lemmas/柯西不等式" fields = ["stmt", "proof"] # lemma WITHOUT proof (proof.typ absent; fields omits it) -[[parts]] +[[outline]] +type = "element" kind = "lemma" path = "lemmas/垂直判据" fields = ["stmt"] # sop -[[parts]] +[[outline]] +type = "element" kind = "sop" path = "sops/求夹角步骤" fields = ["sop"] diff --git a/render/lib.typ b/render/lib.typ index d809220..1baf5a9 100644 --- a/render/lib.typ +++ b/render/lib.typ @@ -1,14 +1,14 @@ // cph-render — curriculum lesson render package. // -// PUBLIC ENTRY: `render-lesson(info, target, parts, heading-numbering)`. +// PUBLIC ENTRY: `render-lesson(info, target, outline, heading-numbering)`. // -// MODEL (ADR-0011). 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 part'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. +// 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 @@ -19,11 +19,15 @@ // 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 -// `parts` array of dicts and never includes anything itself. +// `outline` array of entry dicts and never includes anything itself. // -// A part dict the template hands us: -// `kind` plus that kind's content/scalar fields. Content field VALUES are -// already-evaluated typst content (the template produced them via `include`). +// 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: @@ -41,7 +45,6 @@ // 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 @@ -106,14 +109,33 @@ } } +/// 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"). -/// - `parts`: ordered array of part dicts, ALREADY ASSEMBLED by the template -/// (content fields included, scalars read). See file header. +/// - `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 @@ -122,7 +144,7 @@ #let render-lesson( info: (:), target: "student", - parts: (), + outline: (), heading-numbering: default-heading-numbering, ) = { let title = info.at("title", default: []) @@ -146,7 +168,77 @@ subtitle-block(flags.subtitle) // Render strictly in array order. - for part in parts { - _render-part(part, flags) + 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) + } } } diff --git a/render/templates/README.md b/render/templates/README.md index c4cbd59..510c524 100644 --- a/render/templates/README.md +++ b/render/templates/README.md @@ -1,4 +1,4 @@ -# Default export templates (ADR-0011) +# Default export templates (ADR-0011, outline shape ADR-0029) `student.typ` / `teacher.typ` are the **framework default templates**. In a real engineering file they live at `exports/student.typ` / `exports/teacher.typ`; the @@ -9,11 +9,16 @@ offline smoke test below. Each template: 1. reads the injected manifest: `toml(sys.inputs.manifest)`; -2. loops `manifest.parts`, `include`-ing each content field via a computed - **root-relative absolute** path `//.typ`, and reading scalar - fields (example `source`) from `//element.toml`; -3. assembles a `parts` array and calls `cph-render`'s `render-lesson(...)`, - passing the per-level heading numbering (presentation lives in the template). +2. loops `manifest.outline` (ADR-0029's depth-first rendering order — elements + interleaved with section headings at their DFS-open position). For an + `type = "element"` entry: `include`-ing each content field via a computed + **root-relative absolute** path `//.typ`, and reading + scalar fields (example `source`) from `//element.toml`. For a + `type = "section"` entry: passing its `title`/`depth` straight through — no + content to load, it is a heading; +3. assembles an `outline` array of entry dicts and calls `cph-render`'s + `render-lesson(...)`, passing the per-level heading numbering (presentation + lives in the template). ## Why the include loop is in the template, not in cph-render @@ -23,7 +28,7 @@ root**. A dynamic `include` written inside the package resolves against the *package* dir — even an absolute `/segments/x.typ` — never the engineering `--root`. Verified empirically. The template lives under `--root`, so its `//.typ` resolves against `--root`. Hence the template loads -content and hands `render-lesson` an already-assembled `parts` array; +content and hands `render-lesson` an already-assembled `outline` array; `render-lesson` never includes anything. ## Key path facts for the engine @@ -36,9 +41,13 @@ content and hands `render-lesson` an already-assembled `parts` array; relative to the *template's* location (`exports/`), so a bare `manifest=manifest.toml` would look in `exports/`. Pass the leading `/`. - **Optional content presence** (lemma `proof`): typst has no file-exists - primitive, so the template cannot probe disk. It reads a per-part `fields` - array from the manifest listing the content fields present on disk. *(OPEN: the - exact manifest shape for this is for the Rust/manifest contract to pin.)* + primitive, so the template cannot probe disk. It reads a per-element + `fields` array from the manifest listing the content fields present on + disk. *(OPEN: the exact manifest shape for this is for the Rust/manifest + contract to pin.)* +- **Sections carry no content fields.** A `type = "section"` outline entry + (ADR-0029) has only `kind`/`title`/`depth`/`path`; the template passes it + through untouched — no `include`, no `element.toml` read. ## Offline smoke test @@ -60,4 +69,7 @@ typst compile --root examples/smoke-eng \ ``` (`examples/smoke-eng/exports/{student,teacher}.typ` are copies of the defaults -here, mirroring how a real engineering file carries its own templates.) +here, mirroring how a real engineering file carries its own templates. The +`examples/smoke-eng/manifest.toml` is hand-authored directly in the augmented +`[[outline]]` shape that `cph_typst::build_augmented_manifest` would otherwise +produce, since this smoke test bypasses the Rust engine entirely.) diff --git a/render/templates/merged.typ b/render/templates/merged.typ new file mode 100644 index 0000000..5e6c00a --- /dev/null +++ b/render/templates/merged.typ @@ -0,0 +1,76 @@ +// DEFAULT BUNDLE TEMPLATE (ADR-0030, outline shape ADR-0029). +// +// Lives in a bundle at `/exports/.typ`, e.g. +// `exports/merged.typ`. Compiled AS MAIN with the augmented BUNDLE manifest +// injected: +// typst compile --root --input manifest= exports/merged.typ +// +// Structurally identical to the single-lesson `student.typ`/`teacher.typ` +// templates (see their notes on why the include loop lives in the template, +// not in cph-render), except it reads `manifest.lessons` (an ordered array of +// per-lesson `(info, target, outline)` tables — see +// `cph_typst::build_augmented_bundle_manifest`) instead of a single +// `manifest.outline`, and calls `render-bundle` instead of `render-lesson`. +// +// Every outline entry's `path` in a bundle manifest is ALREADY prefixed with +// that lesson's own bundle-root-relative directory (done by the Rust engine), +// so the same `include "/" + path + "/" + field + ".typ"` computation used by +// a single-lesson template resolves correctly here too — no special-casing +// needed in this loop. + +#import "@local/cph-render:0.1.0": render-bundle, part-fields, default-heading-numbering + +#let manifest = toml(sys.inputs.manifest) +#let info = manifest.at("info", default: (:)) +#let raw-lessons = manifest.at("lessons", default: ()) + +// Assemble one outline entry exactly as a single-lesson template would. +#let assemble-entry(raw) = { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) + + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } + } + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry + } +} + +#let lessons = raw-lessons.map(raw => ( + info: raw.at("info", default: (:)), + target: raw.at("target", default: "student"), + outline: raw.at("outline", default: ()).map(assemble-entry), +)) + +// Presentation: shared per-level heading numbering across the whole bundle, +// and the ADR-0030 recommended default of resetting auto-counters at each +// lesson boundary (override `reset-counters: false` for continuous numbering). +#render-bundle( + info: info, + lessons: lessons, + heading-numbering: default-heading-numbering, + reset-counters: true, +) diff --git a/render/templates/student.typ b/render/templates/student.typ index a33d25d..14d091d 100644 --- a/render/templates/student.typ +++ b/render/templates/student.typ @@ -1,4 +1,4 @@ -// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011). +// DEFAULT STUDENT TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // This is a *real, editable* file that lives in an engineering file at // `exports/student.typ`. The framework compiles it AS THE MAIN FILE with the @@ -15,15 +15,16 @@ // its own virtual root — an include inside cph-render would resolve against the // PACKAGE, not the engineering root. A `//.typ` written HERE // (this template lives under `--root`) resolves against `--root`. So the -// template loads content and hands cph-render an already-assembled `parts` array. +// template loads content and hands cph-render an already-assembled `outline` +// array (elements interleaved with section headings, ADR-0029). // // OPEN CONTRACT POINT — optional-content presence. typst has no "does this file // exist" primitive (a missing `include` is a hard compile error). So the // template CANNOT probe disk the way the old Rust driver did for lemma `proof`. // It relies on the manifest declaring which optional content fields are present, -// via a per-part `fields` array listing the content fields that exist on disk +// via a per-element `fields` array listing the content fields that exist on disk // (the engine knows this — it walks the part dir). Required fields are loaded -// unconditionally; optional fields load only if listed in `fields`. If a part +// unconditionally; optional fields load only if listed in `fields`. If an element // omits `fields`, optional content is skipped (conservative). The exact shape of // this declaration is for the manifest/Rust contract to pin. @@ -35,38 +36,51 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: +// - an "element" entry: include its content fields (computed absolute paths, +// resolved against --root) and read scalar fields from /element.toml. +// `part-fields` (from cph-render) is the single source of truth for +// kind->fields. +// - a "section" entry (ADR-0029): pass its title/depth straight through — no +// content to load, it is a heading. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -74,6 +88,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) diff --git a/render/templates/teacher.typ b/render/templates/teacher.typ index b976b1f..abc5b8a 100644 --- a/render/templates/teacher.typ +++ b/render/templates/teacher.typ @@ -1,4 +1,4 @@ -// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011). +// DEFAULT TEACHER TEMPLATE (framework default; ADR-0011, outline shape ADR-0029). // // Lives in an engineering file at `exports/teacher.typ`. Compiled AS MAIN with // the manifest injected: @@ -18,38 +18,47 @@ // Read the injected manifest (a path string relative to typst --root). #let manifest = toml(sys.inputs.manifest) #let info = manifest.at("info", default: (:)) -#let raw-parts = manifest.at("parts", default: ()) +#let raw-outline = manifest.at("outline", default: ()) -// Assemble each part: include its content fields (computed absolute paths, -// resolved against --root) and read scalar fields from /element.toml. -// `part-fields` (from cph-render) is the single source of truth for kind->fields. -#let parts = raw-parts.map(raw => { - let kind = raw.at("kind", default: none) - let path = raw.at("path", default: none) - let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) - // Which optional content fields are present on disk (manifest-declared). - let present = raw.at("fields", default: ()) - let part = (kind: kind) +// Assemble each outline entry: an "element" entry includes its content fields +// and reads scalars from element.toml; a "section" entry (ADR-0029) passes +// title/depth straight through as a heading, no content to load. +#let outline = raw-outline.map(raw => { + if raw.at("type", default: "element") == "section" { + ( + entry-type: "section", + kind: raw.at("kind", default: none), + title: raw.at("title", default: ""), + depth: raw.at("depth", default: 1), + ) + } else { + let kind = raw.at("kind", default: none) + let path = raw.at("path", default: none) + let spec = part-fields.at(kind, default: (content: (), optional-content: (), scalars: ())) + // Which optional content fields are present on disk (manifest-declared). + let present = raw.at("fields", default: ()) + let entry = (entry-type: "element", kind: kind) - // Required content fields: /.typ (absolute, root-relative). - for field in spec.content { - part.insert(field, include "/" + path + "/" + field + ".typ") - } - // Optional content fields: only when the manifest says the file exists. - for field in spec.optional-content { - if field in present { - part.insert(field, include "/" + path + "/" + field + ".typ") + // Required content fields: /.typ (absolute, root-relative). + for field in spec.content { + entry.insert(field, include "/" + path + "/" + field + ".typ") } - } - // Scalar fields come from /element.toml. - if spec.scalars.len() > 0 { - let element = toml("/" + path + "/element.toml") - for field in spec.scalars { - let v = element.at(field, default: none) - if v != none and v != "" { part.insert(field, v) } + // Optional content fields: only when the manifest says the file exists. + for field in spec.optional-content { + if field in present { + entry.insert(field, include "/" + path + "/" + field + ".typ") + } } + // Scalar fields come from /element.toml. + if spec.scalars.len() > 0 { + let element = toml("/" + path + "/element.toml") + for field in spec.scalars { + let v = element.at(field, default: none) + if v != none and v != "" { entry.insert(field, v) } + } + } + entry } - part }) // Presentation: per-level heading numbering. Default (一、 / 1.1 / 1.1.1) comes @@ -57,6 +66,6 @@ #render-lesson( info: info, target: target, - parts: parts, + outline: outline, heading-numbering: default-heading-numbering, ) From fe8a17c6ad067a6687166133a12b144636464645 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 5 Aug 2026 16:34:02 +0800 Subject: [PATCH 3/5] feat(cph): add outline export command --- Cargo.lock | 2 + README.md | 10 + crates/cph-check/src/lib.rs | 33 ++++ crates/cph-check/tests/pipeline.rs | 17 ++ crates/cph-cli/Cargo.toml | 2 + crates/cph-cli/src/main.rs | 170 +++++++++++++++++ crates/cph-model/src/lib.rs | 177 +++++++++++++++++- .../tests/fixtures/valid/manifest.toml | 1 + crates/cph-model/tests/load.rs | 43 ++++- crates/cph-typst/src/embedded.rs | 41 ++-- crates/cph-typst/src/lib.rs | 32 +++- crates/cph-typst/src/manifest.rs | 3 +- crates/cph-typst/src/world.rs | 94 +++++++--- crates/cph-typst/tests/compile.rs | 12 ++ render/lib.typ | 54 ++++++ 15 files changed, 635 insertions(+), 56 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7a97ef7..11e5355 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,7 +410,9 @@ dependencies = [ "clap_complete", "cph-check", "cph-diag", + "cph-model", "cph-typst", + "serde_json", ] [[package]] diff --git a/README.md b/README.md index 02669f1..9192400 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,16 @@ cph check <工程目录> # 校验合法性(7 类诊断) cph build <工程目录> --target student -o build/student.pdf # 渲讲义 PDF ``` +```sh +cph outline <工程目录> # 默认写入 <工程目录>/outline.pdf +cph outline <工程目录> --format md # 或 json / pdf +cph outline <工程目录> --format pdf --force # 明确允许覆盖已有 outline.pdf +``` + +大纲节点来自根及各级容器 `manifest.toml` 的 `[[children]]`;可在 child 上填写多行 +`notes = """…"""` 作为教师备课提示。它会进入 outline 的 JSON/Markdown, +并在 PDF 中以独立的“教学提示”区域呈现,不会混入学生/教师讲义正文。 + **版本契约(ADR-0016):** 教研工程文件根放一个 `.cph-version` 文件,内容为它面向的 cph 版本(如 `0.0.2`)。`cph` 加载时比对自身版本,不相容则报 `E-CPH-VERSION` error 并拒绝(当前判定为版本完全相等;后续可放宽为 semver 区间,只改一处谓词)。`examples/` 与本仓 fixture 已带该文件作为迁移起点;缺文件的工程暂时跳过此检查(OPEN)。 Shell 补全(可选): diff --git a/crates/cph-check/src/lib.rs b/crates/cph-check/src/lib.rs index 8653abd..e9163ce 100644 --- a/crates/cph-check/src/lib.rs +++ b/crates/cph-check/src/lib.rs @@ -130,6 +130,39 @@ pub fn check(root: &Path, engine: &Engine) -> CheckReport { } } +/// Load and validate the lesson, then project it into an outline. +/// +/// Outline output is derived from the manifest and part metadata, not from the +/// rendered lesson body. It therefore runs the same load → structural → schema +/// gates as other non-typst builds, but intentionally does not compile any +/// target. An invalid lesson is never written in any outline format. +pub fn outline(root: &Path) -> (Option, CheckReport) { + let mut diags = Vec::new(); + let (lesson, load_diags) = cph_model::load(root); + diags.extend(load_diags); + + let Some(lesson) = lesson else { + return ( + None, + CheckReport { + diagnostics: dedup(diags), + lesson_loaded: false, + }, + ); + }; + + run_structural_and_schema(&lesson, cph_schema::known_kinds(), &mut diags); + let report = CheckReport { + diagnostics: dedup(diags), + lesson_loaded: true, + }; + if report.has_errors() { + (None, report) + } else { + (Some(lesson.outline_document()), report) + } +} + /// Build a PDF for `target`. /// /// Runs the check phases **(a)–(c)** (load → structural → schema). If those diff --git a/crates/cph-check/tests/pipeline.rs b/crates/cph-check/tests/pipeline.rs index 8e27402..260382c 100644 --- a/crates/cph-check/tests/pipeline.rs +++ b/crates/cph-check/tests/pipeline.rs @@ -45,6 +45,23 @@ fn good_fixture_has_no_errors() { assert!(!report.has_errors()); } +#[test] +fn outline_projects_parts_in_manifest_order() { + let (outline, report) = cph_check::outline(&mini_fixture()); + assert_eq!( + report.error_count(), + 0, + "outline should validate the fixture" + ); + let outline = outline.expect("valid lesson should produce an outline"); + assert_eq!(outline.title, "迷你示例课时"); + assert_eq!(outline.children.len(), 3); + assert_eq!(outline.children[0].title, "开场对照导言"); + assert_eq!(outline.children[1].kind, "section"); + assert_eq!(outline.children[1].children[0].title, "量纲分析估计"); + assert_eq!(outline.children[2].title, "自由落体"); +} + #[test] fn unknown_kind_is_an_error() { // Build a throwaway lesson whose part declares kind "frob". diff --git a/crates/cph-cli/Cargo.toml b/crates/cph-cli/Cargo.toml index b55feb9..2466ff1 100644 --- a/crates/cph-cli/Cargo.toml +++ b/crates/cph-cli/Cargo.toml @@ -11,6 +11,8 @@ path = "src/main.rs" [dependencies] cph-check = { path = "../cph-check" } cph-diag = { workspace = true } +cph-model = { workspace = true } cph-typst = { path = "../cph-typst" } clap = { version = "4", features = ["derive"] } clap_complete = "4" +serde_json = "1" diff --git a/crates/cph-cli/src/main.rs b/crates/cph-cli/src/main.rs index 9ad5c10..f4f93a0 100644 --- a/crates/cph-cli/src/main.rs +++ b/crates/cph-cli/src/main.rs @@ -7,11 +7,14 @@ //! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit //! 0); `build` exits 1 when the PDF could not be produced. +use std::fs::OpenOptions; +use std::io::Write; use std::path::PathBuf; use std::process::ExitCode; use clap::{Parser, Subcommand}; use cph_check::CheckReport; +use cph_model::OutlineDocument; use cph_typst::Engine; /// The `cph` checker for curriculum engineering files. @@ -70,6 +73,21 @@ enum Command { #[arg(short = 'o', long, value_name = "OUT")] out: Option, }, + /// Export the teacher-facing outline as Markdown, PDF, or JSON. + Outline { + /// Path to the engineering-file root. Defaults to the current directory. + #[arg(default_value = ".")] + path: PathBuf, + /// Output format. Defaults to PDF. + #[arg(long, value_enum, default_value_t = OutlineFormat::Pdf)] + format: OutlineFormat, + /// Output path. Defaults to `/outline.`. + #[arg(short = 'o', long, value_name = "OUT")] + out: Option, + /// Allow replacing an existing output file. + #[arg(long)] + force: bool, + }, /// Print a shell-completion script to stdout (clap_complete; ADR-0013 opt-in /// sibling: a local convenience, no lesson involved). Pipe to your shell's /// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`. @@ -79,6 +97,23 @@ enum Command { }, } +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +enum OutlineFormat { + Md, + Pdf, + Json, +} + +impl OutlineFormat { + fn extension(self) -> &'static str { + match self { + Self::Md => "md", + Self::Pdf => "pdf", + Self::Json => "json", + } + } +} + #[derive(Debug, Clone, Copy, clap::ValueEnum)] enum CompletionTarget { Bash, @@ -100,6 +135,12 @@ fn main() -> ExitCode { Command::Check { path } => run_check(&path, &engine), Command::Build { path, targets, out } => run_build_command(&path, &engine, targets, out), Command::Bundle { path, targets, out } => run_bundle_command(&path, &engine, targets, out), + Command::Outline { + path, + format, + out, + force, + } => run_outline(&path, &engine, format, out, force), Command::Completions { shell } => run_completions(shell), } } @@ -157,6 +198,135 @@ fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode { } } +fn run_outline( + path: &std::path::Path, + engine: &Engine, + format: OutlineFormat, + out: Option, + force: bool, +) -> ExitCode { + let out_path = out.unwrap_or_else(|| path.join(format!("outline.{}", format.extension()))); + + if out_path.exists() && !force { + eprintln!( + "warning: output '{}' already exists; pass --force to overwrite", + out_path.display() + ); + return ExitCode::FAILURE; + } + + let (outline, report) = cph_check::outline(path); + print_diagnostics(&report); + let Some(outline) = outline else { + eprintln!("outline failed: fix the lesson before exporting"); + return ExitCode::FAILURE; + }; + + let bytes = match format { + OutlineFormat::Md => render_outline_markdown(&outline).into_bytes(), + OutlineFormat::Json => match serde_json::to_vec_pretty(&outline) { + Ok(mut bytes) => { + bytes.push(b'\n'); + bytes + } + Err(e) => { + eprintln!("outline failed: cannot serialize JSON: {e}"); + return ExitCode::FAILURE; + } + }, + OutlineFormat::Pdf => match engine.build_outline_pdf(&outline) { + Ok(bytes) => bytes, + Err(diags) => { + for diagnostic in &diags { + eprintln!("{diagnostic}"); + } + eprintln!("outline failed: PDF compilation failed"); + return ExitCode::FAILURE; + } + }, + }; + + if force && out_path.exists() { + eprintln!( + "warning: overwriting existing output '{}'", + out_path.display() + ); + } + if let Err(e) = write_outline_output(&out_path, &bytes, force) { + eprintln!("outline failed: {e}"); + return ExitCode::FAILURE; + } + println!("wrote {} ({} bytes)", out_path.display(), bytes.len()); + ExitCode::SUCCESS +} + +fn render_outline_markdown(outline: &OutlineDocument) -> String { + let mut body = format!("# {}\n\n", outline.title.trim()); + if !outline.authors.is_empty() { + body.push_str("作者:"); + body.push_str(&outline.authors.join("、")); + body.push_str("\n\n"); + } + for child in &outline.children { + append_outline_markdown(&mut body, child, 2); + } + body +} + +fn append_outline_markdown(body: &mut String, node: &cph_model::OutlineNode, level: usize) { + let level = level.min(6); + body.push_str(&"#".repeat(level)); + body.push(' '); + body.push_str(&node.title); + if !node.kind.is_empty() { + body.push_str(" `["); + body.push_str(&node.kind); + body.push_str("]`"); + } + body.push_str("\n\n"); + + if let Some(notes) = node.notes.as_deref() { + body.push_str("> 教学提示:\n"); + for line in notes.lines() { + body.push_str("> "); + body.push_str(line); + body.push('\n'); + } + body.push('\n'); + } + + for child in &node.children { + append_outline_markdown(body, child, level + 1); + } +} + +fn write_outline_output(path: &std::path::Path, bytes: &[u8], force: bool) -> Result<(), String> { + if let Some(parent) = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + { + std::fs::create_dir_all(parent) + .map_err(|e| format!("cannot create output directory '{}': {e}", parent.display()))?; + } + + if force { + std::fs::write(path, bytes).map_err(|e| format!("cannot write '{}': {e}", path.display())) + } else { + let mut file = match OpenOptions::new().write(true).create_new(true).open(path) { + Ok(file) => file, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(format!( + "output '{}' already exists; pass --force to overwrite", + path.display() + )); + } + Err(e) => return Err(format!("cannot create '{}': {e}", path.display())), + }; + file.write_all(bytes) + .map_err(|e| format!("cannot write '{}': {e}", path.display())) + } +} + /// Dispatch `cph build` (ADR-0030): with an explicit `--target` (repeatable), /// build exactly that ordered set; with none, batch every target the lesson /// declares. Each target builds **independently** — one failing does not stop diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs index f48df48..4eff4d8 100644 --- a/crates/cph-model/src/lib.rs +++ b/crates/cph-model/src/lib.rs @@ -60,6 +60,17 @@ pub struct Lesson { } impl Lesson { + /// Project the nested depth-first outline into the target-independent + /// document shape used by `cph outline`. + pub fn outline_document(&self) -> OutlineDocument { + let (children, _) = consume_outline_children(self, 0, 0); + OutlineDocument { + title: self.info.title.clone(), + authors: self.info.authors.clone(), + children, + } + } + /// The declared export-target names, in declared order. pub fn target_names(&self) -> Vec<&str> { self.targets.iter().map(|t| t.name.as_str()).collect() @@ -67,12 +78,14 @@ impl Lesson { } /// One entry in the lesson's full rendering-order sequence (ADR-0029). -#[derive(Debug, Clone, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub enum OutlineEntry { /// An element at this position: `part_index` into [`Lesson::parts`]. Element { /// Index into `Lesson::parts`. part_index: usize, + /// Container depth: 0 for a root child, 1 inside a direct section, etc. + depth: u32, }, /// A section container opens here. Contributes no element; it is a /// heading, not a part (ADR-0029). `depth` is the section's nesting depth @@ -84,6 +97,8 @@ pub enum OutlineEntry { /// Heading text: the container's `[group].title` if present and /// non-empty, else the folder's basename. title: String, + /// Optional teacher-facing planning note from the `children` entry. + notes: Option, /// Nesting depth (1-based) — the heading level a renderer should use. depth: u32, /// The container folder's path, relative to the engineering-file root. @@ -91,6 +106,95 @@ pub enum OutlineEntry { }, } +/// A target-independent outline projection of a nested lesson. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OutlineDocument { + /// Course title from the root `[info].title`. + pub title: String, + /// Authors from the root `[info].author`. + pub authors: Vec, + /// Ordered root children, preserving nested sections. + pub children: Vec, +} + +/// One section or element in the exported outline tree. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct OutlineNode { + /// Heading text shown to a teacher. + pub title: String, + /// Element or container kind. + pub kind: String, + /// Root-relative source path. + pub path: PathBuf, + /// Optional teacher-facing planning note. + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + /// Nested children; empty for an element leaf. + pub children: Vec, +} + +impl OutlineNode { + fn from_part(part: &Part) -> Self { + let title = part + .path + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| part.path.to_string_lossy().into_owned()); + Self { + title, + kind: part.kind.clone(), + path: part.path.clone(), + notes: part.notes.clone().filter(|notes| !notes.trim().is_empty()), + children: Vec::new(), + } + } +} + +/// Convert the flat depth-first sequence into nested JSON/format nodes. +fn consume_outline_children( + lesson: &Lesson, + start: usize, + minimum_section_depth: u32, +) -> (Vec, usize) { + let mut children = Vec::new(); + let mut index = start; + while index < lesson.outline.len() { + match &lesson.outline[index] { + OutlineEntry::Element { part_index, depth } => { + if *depth < minimum_section_depth { + break; + } + children.push(OutlineNode::from_part(&lesson.parts[*part_index])); + index += 1; + } + OutlineEntry::Section { + kind, + title, + notes, + depth, + path, + } => { + if *depth < minimum_section_depth { + break; + } + let section_depth = *depth; + let (nested, next) = consume_outline_children(lesson, index + 1, section_depth); + children.push(OutlineNode { + title: title.clone(), + kind: kind.clone(), + path: path.clone(), + notes: notes.clone().filter(|note| !note.trim().is_empty()), + children: nested, + }); + index = next; + } + } + } + (children, index) +} + /// One declared export target's build config (ADR-0009/0011). /// /// An export target is a **build** producing a typed [`Artifact`] via an @@ -289,6 +393,9 @@ pub struct Part { /// Element folder path, root-relative, kept verbatim (forward/back slashes /// as the OS provides) for diagnostics/display. pub path: PathBuf, + /// Optional teacher-facing planning note from the `children` entry. + /// This is outline metadata, not a content field in `element.toml`. + pub notes: Option, /// The element's self-description loaded from its `element.toml`. pub descriptor: ElementDescriptor, } @@ -380,6 +487,8 @@ struct RawGroup { struct RawChild { kind: String, path: String, + #[serde(default)] + notes: Option, } /// Load the engineering file at `root` into a [`Lesson`]. @@ -559,6 +668,7 @@ fn load_children( outline: &mut Vec, ) { for child in children { + let element_depth = next_section_depth.saturating_sub(1); let local_path = PathBuf::from(&child.path); let full_rel_path = join_rel(rel_prefix, &local_path); @@ -571,7 +681,15 @@ fn load_children( ) .with_hint("child paths must be relative folders inside the containing folder"), ); - push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); continue; } @@ -588,7 +706,15 @@ fn load_children( full_rel_path.display() )), ); - push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); continue; } @@ -603,14 +729,26 @@ fn load_children( parts.push(Part { kind: child.kind, path: full_rel_path, + notes: child.notes, descriptor, }); - outline.push(OutlineEntry::Element { part_index: idx }); + outline.push(OutlineEntry::Element { + part_index: idx, + depth: element_depth, + }); } // A container: recurse into its own manifest.toml. (true, false) => { let Some(raw_container) = read_container_manifest(&abs_dir, diags) else { - push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); continue; }; @@ -643,6 +781,7 @@ fn load_children( outline.push(OutlineEntry::Section { kind: child.kind, title, + notes: child.notes, depth: next_section_depth, path: full_rel_path.clone(), }); @@ -671,7 +810,15 @@ fn load_children( ) .with_hint("remove one of manifest.toml or element.toml from this folder"), ); - push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); } // Incomplete: neither a container nor a leaf. (false, false) => { @@ -688,7 +835,15 @@ fn load_children( (container) to this folder", ), ); - push_broken_leaf(parts, outline, child.kind, full_rel_path, root); + push_broken_leaf( + parts, + outline, + child.kind, + full_rel_path, + root, + child.notes, + element_depth, + ); } } } @@ -737,19 +892,25 @@ fn push_broken_leaf( kind: String, path: PathBuf, root: &Path, + notes: Option, + depth: u32, ) { let idx = parts.len(); let dir = root.join(&path); parts.push(Part { kind: kind.clone(), path, + notes, descriptor: ElementDescriptor { kind, dir, scalars: toml::Table::new(), }, }); - outline.push(OutlineEntry::Element { part_index: idx }); + outline.push(OutlineEntry::Element { + part_index: idx, + depth, + }); } /// Join a container-relative child path onto that container's own diff --git a/crates/cph-model/tests/fixtures/valid/manifest.toml b/crates/cph-model/tests/fixtures/valid/manifest.toml index 98c6709..5e858a0 100644 --- a/crates/cph-model/tests/fixtures/valid/manifest.toml +++ b/crates/cph-model/tests/fixtures/valid/manifest.toml @@ -9,6 +9,7 @@ author = "范式教育教研组" [[children]] kind = "segment" path = "segments/intro" +notes = "这一节补充一个直观例题" [[children]] kind = "lemma" diff --git a/crates/cph-model/tests/load.rs b/crates/cph-model/tests/load.rs index 1bfcb96..5024c5c 100644 --- a/crates/cph-model/tests/load.rs +++ b/crates/cph-model/tests/load.rs @@ -35,6 +35,17 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() { assert_eq!(lesson.parts.len(), 2); assert_eq!(lesson.parts[0].kind, "segment"); assert_eq!(lesson.parts[0].path, PathBuf::from("segments/intro")); + assert_eq!( + lesson.parts[0].notes.as_deref(), + Some("这一节补充一个直观例题") + ); + let outline = lesson.outline_document(); + assert_eq!(outline.children[0].title, "intro"); + assert_eq!( + outline.children[0].notes.as_deref(), + Some("这一节补充一个直观例题") + ); + assert!(outline.children[0].children.is_empty()); assert_eq!(lesson.parts[0].descriptor.kind, "segment"); assert_eq!(lesson.parts[1].kind, "lemma"); assert_eq!(lesson.parts[1].path, PathBuf::from("lemmas/young")); @@ -45,8 +56,14 @@ fn valid_two_part_lesson_loads_in_order_with_no_errors() { assert_eq!( lesson.outline, vec![ - OutlineEntry::Element { part_index: 0 }, - OutlineEntry::Element { part_index: 1 }, + OutlineEntry::Element { + part_index: 0, + depth: 0, + }, + OutlineEntry::Element { + part_index: 1, + depth: 0, + }, ] ); @@ -114,22 +131,36 @@ fn nested_sections_flatten_depth_first_with_correct_depths() { assert_eq!( lesson.outline, vec![ - OutlineEntry::Element { part_index: 0 }, // segments/开场白 + OutlineEntry::Element { + part_index: 0, + depth: 0, + }, // segments/开场白 OutlineEntry::Section { kind: "section".to_string(), title: "导言簇".to_string(), depth: 1, + notes: None, path: PathBuf::from("导言簇"), }, - OutlineEntry::Element { part_index: 1 }, // 导言簇/segments/子段一 + OutlineEntry::Element { + part_index: 1, + depth: 1, + }, // 导言簇/segments/子段一 OutlineEntry::Section { kind: "section".to_string(), title: "嵌套子节".to_string(), depth: 2, + notes: None, path: PathBuf::from("导言簇/嵌套子节"), }, - OutlineEntry::Element { part_index: 2 }, // 导言簇/嵌套子节/lemmas/子引理 - OutlineEntry::Element { part_index: 3 }, // 导言簇/segments/子段二 + OutlineEntry::Element { + part_index: 2, + depth: 2, + }, // 导言簇/嵌套子节/lemmas/子引理 + OutlineEntry::Element { + part_index: 3, + depth: 1, + }, // 导言簇/segments/子段二 ] ); diff --git a/crates/cph-typst/src/embedded.rs b/crates/cph-typst/src/embedded.rs index 0d32ce2..6236d58 100644 --- a/crates/cph-typst/src/embedded.rs +++ b/crates/cph-typst/src/embedded.rs @@ -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 -/// (`/cph/render-/`). Extracted once per crate version; -/// subsequent runs reuse it. +/// (`/cph/render-/`). /// /// 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 { 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) } diff --git a/crates/cph-typst/src/lib.rs b/crates/cph-typst/src/lib.rs index 9d369b8..b0b418f 100644 --- a/crates/cph-typst/src/lib.rs +++ b/crates/cph-typst/src/lib.rs @@ -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> { + 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::(&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> { diff --git a/crates/cph-typst/src/manifest.rs b/crates/cph-typst/src/manifest.rs index 54b0ef0..db2f185 100644 --- a/crates/cph-typst/src/manifest.rs +++ b/crates/cph-typst/src/manifest.rs @@ -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())); diff --git a/crates/cph-typst/src/world.rs b/crates/cph-typst/src/world.rs index 08b08fc..ac0c269 100644 --- a/crates/cph-typst/src/world.rs +++ b/crates/cph-typst/src/world.rs @@ -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, + /// Standard library inputs exposed to the Typst source. library: LazyHash, /// Shared font store (book + lazily-loaded fonts). fonts: Arc, @@ -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, + ) -> 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, + input_files: &[(&str, &str)], + fonts: Arc, + ) -> 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 { - 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 { - 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 { diff --git a/crates/cph-typst/tests/compile.rs b/crates/cph-typst/tests/compile.rs index 2bea1f5..bd5314d 100644 --- a/crates/cph-typst/tests/compile.rs +++ b/crates/cph-typst/tests/compile.rs @@ -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 diff --git a/render/lib.typ b/render/lib.typ index 1baf5a9..bd57176 100644 --- a/render/lib.typ +++ b/render/lib.typ @@ -242,3 +242,57 @@ } } } + +/// 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) + } +} From dd8fcfd3666ffc5dce22155614f64c789ffceb25 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 5 Aug 2026 17:12:42 +0800 Subject: [PATCH 4/5] chore(cph): track TH-141 outline PDF --- examples/TH-141/outline.pdf | 1066 +++++++++++++++++++++++++++++++++++ 1 file changed, 1066 insertions(+) create mode 100644 examples/TH-141/outline.pdf diff --git a/examples/TH-141/outline.pdf b/examples/TH-141/outline.pdf new file mode 100644 index 0000000..3558e6c --- /dev/null +++ b/examples/TH-141/outline.pdf @@ -0,0 +1,1066 @@ +%PDF-1.7 +% + +1 0 obj +<> +endobj +2 0 obj +</Dest 144 0 R>> +endobj +3 0 obj +</Dest 145 0 R>> +endobj +4 0 obj +</Dest 146 0 R>> +endobj +5 0 obj +</Dest 147 0 R>> +endobj +6 0 obj +</Dest 148 0 R>> +endobj +7 0 obj +</Dest 149 0 R>> +endobj +8 0 obj +</Dest 150 0 R>> +endobj +9 0 obj +</Dest 151 0 R>> +endobj +10 0 obj +</Dest 152 0 R>> +endobj +11 0 obj +</Dest 153 0 R>> +endobj +12 0 obj +</Dest 154 0 R>> +endobj +13 0 obj +</Dest 155 0 R>> +endobj +14 0 obj +</Dest 156 0 R>> +endobj +15 0 obj +</Dest 157 0 R>> +endobj +16 0 obj +</Dest 158 0 R>> +endobj +17 0 obj +</Dest 159 0 R>> +endobj +18 0 obj +</Dest 160 0 R>> +endobj +19 0 obj +</Dest 161 0 R>> +endobj +20 0 obj +</Dest 162 0 R>> +endobj +21 0 obj +</Dest 163 0 R>> +endobj +22 0 obj +</Dest 164 0 R>> +endobj +23 0 obj +</Dest 165 0 R>> +endobj +24 0 obj +</Dest 166 0 R>> +endobj +25 0 obj +</Dest 167 0 R>> +endobj +26 0 obj +</Dest 168 0 R>> +endobj +27 0 obj +</Dest 169 0 R>> +endobj +28 0 obj +</Dest 170 0 R>> +endobj +29 0 obj +</Dest 171 0 R>> +endobj +30 0 obj +</Dest 172 0 R>> +endobj +31 0 obj +</Dest 173 0 R>> +endobj +32 0 obj +</Dest 174 0 R>> +endobj +33 0 obj +</Dest 175 0 R>> +endobj +34 0 obj +</Dest 176 0 R>> +endobj +35 0 obj +</Dest 177 0 R>> +endobj +36 0 obj +</Dest 178 0 R>> +endobj +37 0 obj +</Dest 179 0 R>> +endobj +38 0 obj +</Dest 180 0 R>> +endobj +39 0 obj +</Dest 181 0 R>> +endobj +40 0 obj +</Dest 182 0 R>> +endobj +41 0 obj +</Dest 183 0 R>> +endobj +42 0 obj +</Dest 184 0 R>> +endobj +43 0 obj +</Dest 185 0 R>> +endobj +44 0 obj +</Dest 186 0 R>> +endobj +45 0 obj +</Dest 187 0 R>> +endobj +46 0 obj +<> +endobj +47 0 obj +<>/K[142 0 R]/ParentTree<>/ParentTreeNextKey 3>> +endobj +48 0 obj +[51 0 R 51 0 R 52 0 R 53 0 R 54 0 R 54 0 R 55 0 R 56 0 R 56 0 R 57 0 R 58 0 R 58 0 R 59 0 R 60 0 R 60 0 R 61 0 R 62 0 R 62 0 R 63 0 R 64 0 R 64 0 R 65 0 R 66 0 R 66 0 R 67 0 R 68 0 R 68 0 R 69 0 R 70 0 R 70 0 R 71 0 R 72 0 R 72 0 R 73 0 R 74 0 R 74 0 R 74 0 R 75 0 R 76 0 R 76 0 R 76 0 R 76 0 R 77 0 R 78 0 R 78 0 R 78 0 R 79 0 R] +endobj +49 0 obj +[80 0 R 80 0 R 80 0 R 81 0 R 82 0 R 82 0 R 83 0 R 84 0 R 84 0 R 85 0 R 86 0 R 86 0 R 86 0 R 86 0 R 86 0 R 87 0 R 88 0 R 88 0 R 89 0 R 90 0 R 90 0 R 91 0 R 92 0 R 92 0 R 93 0 R 94 0 R 94 0 R 94 0 R 94 0 R 95 0 R 96 0 R 96 0 R 97 0 R 98 0 R 98 0 R 99 0 R 100 0 R 100 0 R 101 0 R 102 0 R 102 0 R 102 0 R 103 0 R 104 0 R 104 0 R 104 0 R 105 0 R 106 0 R 106 0 R 106 0 R 107 0 R 108 0 R 108 0 R 109 0 R 110 0 R 110 0 R 110 0 R 111 0 R] +endobj +50 0 obj +[112 0 R 112 0 R 112 0 R 113 0 R 114 0 R 114 0 R 115 0 R 116 0 R 116 0 R 116 0 R 116 0 R 117 0 R 118 0 R 118 0 R 118 0 R 118 0 R 119 0 R 120 0 R 120 0 R 120 0 R 120 0 R 121 0 R 122 0 R 122 0 R 122 0 R 123 0 R 124 0 R 124 0 R 124 0 R 125 0 R 126 0 R 126 0 R 127 0 R 128 0 R 128 0 R 128 0 R 128 0 R 129 0 R 130 0 R 130 0 R 130 0 R 131 0 R 132 0 R 132 0 R 133 0 R 134 0 R 134 0 R 135 0 R 136 0 R 136 0 R 137 0 R 138 0 R 138 0 R 139 0 R 140 0 R 140 0 R 141 0 R] +endobj +51 0 obj +<> +endobj +52 0 obj +<> +endobj +53 0 obj +<> +endobj +54 0 obj +</K[4 5]/Pg 197 0 R>> +endobj +55 0 obj +<>]/K[6]/Pg 197 0 R>> +endobj +56 0 obj +</K[7 8]/Pg 197 0 R>> +endobj +57 0 obj +<>]/K[9]/Pg 197 0 R>> +endobj +58 0 obj +</K[10 11]/Pg 197 0 R>> +endobj +59 0 obj +<>]/K[12]/Pg 197 0 R>> +endobj +60 0 obj +</K[13 14]/Pg 197 0 R>> +endobj +61 0 obj +<>]/K[15]/Pg 197 0 R>> +endobj +62 0 obj +</K[16 17]/Pg 197 0 R>> +endobj +63 0 obj +<>]/K[18]/Pg 197 0 R>> +endobj +64 0 obj +</K[19 20]/Pg 197 0 R>> +endobj +65 0 obj +<>]/K[21]/Pg 197 0 R>> +endobj +66 0 obj +</K[22 23]/Pg 197 0 R>> +endobj +67 0 obj +<>]/K[24]/Pg 197 0 R>> +endobj +68 0 obj +</K[25 26]/Pg 197 0 R>> +endobj +69 0 obj +<>]/K[27]/Pg 197 0 R>> +endobj +70 0 obj +</K[28 29]/Pg 197 0 R>> +endobj +71 0 obj +<>]/K[30]/Pg 197 0 R>> +endobj +72 0 obj +</K[31 32]/Pg 197 0 R>> +endobj +73 0 obj +<>]/K[33]/Pg 197 0 R>> +endobj +74 0 obj +</K[34 35 36]/Pg 197 0 R>> +endobj +75 0 obj +<>]/K[37]/Pg 197 0 R>> +endobj +76 0 obj +</K[38 39 40 41]/Pg 197 0 R>> +endobj +77 0 obj +<>]/K[42]/Pg 197 0 R>> +endobj +78 0 obj +</K[43 44 45]/Pg 197 0 R>> +endobj +79 0 obj +<>]/K[46]/Pg 197 0 R>> +endobj +80 0 obj +</K[0 1 2]/Pg 198 0 R>> +endobj +81 0 obj +<>]/K[3]/Pg 198 0 R>> +endobj +82 0 obj +</K[4 5]/Pg 198 0 R>> +endobj +83 0 obj +<>]/K[6]/Pg 198 0 R>> +endobj +84 0 obj +</K[7 8]/Pg 198 0 R>> +endobj +85 0 obj +<>]/K[9]/Pg 198 0 R>> +endobj +86 0 obj +</K[10 11 12 13 14]/Pg 198 0 R>> +endobj +87 0 obj +<>]/K[15]/Pg 198 0 R>> +endobj +88 0 obj +</K[16 17]/Pg 198 0 R>> +endobj +89 0 obj +<>]/K[18]/Pg 198 0 R>> +endobj +90 0 obj +</K[19 20]/Pg 198 0 R>> +endobj +91 0 obj +<>]/K[21]/Pg 198 0 R>> +endobj +92 0 obj +</K[22 23]/Pg 198 0 R>> +endobj +93 0 obj +<>]/K[24]/Pg 198 0 R>> +endobj +94 0 obj +</K[25 26 27 28]/Pg 198 0 R>> +endobj +95 0 obj +<>]/K[29]/Pg 198 0 R>> +endobj +96 0 obj +</K[30 31]/Pg 198 0 R>> +endobj +97 0 obj +<>]/K[32]/Pg 198 0 R>> +endobj +98 0 obj +</K[33 34]/Pg 198 0 R>> +endobj +99 0 obj +<>]/K[35]/Pg 198 0 R>> +endobj +100 0 obj +</K[36 37]/Pg 198 0 R>> +endobj +101 0 obj +<>]/K[38]/Pg 198 0 R>> +endobj +102 0 obj +</K[39 40 41]/Pg 198 0 R>> +endobj +103 0 obj +<>]/K[42]/Pg 198 0 R>> +endobj +104 0 obj +</K[43 44 45]/Pg 198 0 R>> +endobj +105 0 obj +<>]/K[46]/Pg 198 0 R>> +endobj +106 0 obj +</K[47 48 49]/Pg 198 0 R>> +endobj +107 0 obj +<>]/K[50]/Pg 198 0 R>> +endobj +108 0 obj +</K[51 52]/Pg 198 0 R>> +endobj +109 0 obj +<>]/K[53]/Pg 198 0 R>> +endobj +110 0 obj +</K[54 55 56]/Pg 198 0 R>> +endobj +111 0 obj +<>]/K[57]/Pg 198 0 R>> +endobj +112 0 obj +</K[0 1 2]/Pg 199 0 R>> +endobj +113 0 obj +<>]/K[3]/Pg 199 0 R>> +endobj +114 0 obj +</K[4 5]/Pg 199 0 R>> +endobj +115 0 obj +<>]/K[6]/Pg 199 0 R>> +endobj +116 0 obj +</K[7 8 9 10]/Pg 199 0 R>> +endobj +117 0 obj +<>]/K[11]/Pg 199 0 R>> +endobj +118 0 obj +</K[12 13 14 15]/Pg 199 0 R>> +endobj +119 0 obj +<>]/K[16]/Pg 199 0 R>> +endobj +120 0 obj +</K[17 18 19 20]/Pg 199 0 R>> +endobj +121 0 obj +<>]/K[21]/Pg 199 0 R>> +endobj +122 0 obj +</K[22 23 24]/Pg 199 0 R>> +endobj +123 0 obj +<>]/K[25]/Pg 199 0 R>> +endobj +124 0 obj +</K[26 27 28]/Pg 199 0 R>> +endobj +125 0 obj +<>]/K[29]/Pg 199 0 R>> +endobj +126 0 obj +</K[30 31]/Pg 199 0 R>> +endobj +127 0 obj +<>]/K[32]/Pg 199 0 R>> +endobj +128 0 obj +</K[33 34 35 36]/Pg 199 0 R>> +endobj +129 0 obj +<>]/K[37]/Pg 199 0 R>> +endobj +130 0 obj +</K[38 39 40]/Pg 199 0 R>> +endobj +131 0 obj +<>]/K[41]/Pg 199 0 R>> +endobj +132 0 obj +</K[42 43]/Pg 199 0 R>> +endobj +133 0 obj +<>]/K[44]/Pg 199 0 R>> +endobj +134 0 obj +</K[45 46]/Pg 199 0 R>> +endobj +135 0 obj +<>]/K[47]/Pg 199 0 R>> +endobj +136 0 obj +</K[48 49]/Pg 199 0 R>> +endobj +137 0 obj +<>]/K[50]/Pg 199 0 R>> +endobj +138 0 obj +</K[51 52]/Pg 199 0 R>> +endobj +139 0 obj +<>]/K[53]/Pg 199 0 R>> +endobj +140 0 obj +</K[54 55]/Pg 199 0 R>> +endobj +141 0 obj +<>]/K[56]/Pg 199 0 R>> +endobj +142 0 obj +<> +endobj +143 0 obj +[/ICCBased 209 0 R] +endobj +144 0 obj +[197 0 R/XYZ 62.362206 702.52026 0] +endobj +145 0 obj +[197 0 R/XYZ 62.362206 650.6449 0] +endobj +146 0 obj +[197 0 R/XYZ 62.362206 598.7695 0] +endobj +147 0 obj +[197 0 R/XYZ 62.362206 499.33868 0] +endobj +148 0 obj +[197 0 R/XYZ 62.362206 453.54248 0] +endobj +149 0 obj +[197 0 R/XYZ 62.362206 407.74628 0] +endobj +150 0 obj +[197 0 R/XYZ 62.362206 361.95007 0] +endobj +151 0 obj +[197 0 R/XYZ 62.362206 316.15387 0] +endobj +152 0 obj +[197 0 R/XYZ 62.362206 270.35767 0] +endobj +153 0 obj +[197 0 R/XYZ 62.362206 224.56146 0] +endobj +154 0 obj +[197 0 R/XYZ 62.362206 178.76526 0] +endobj +155 0 obj +[197 0 R/XYZ 62.362206 132.96906 0] +endobj +156 0 obj +[198 0 R/XYZ 62.362206 783.8583 0] +endobj +157 0 obj +[198 0 R/XYZ 62.362206 692.26587 0] +endobj +158 0 obj +[198 0 R/XYZ 62.362206 648.2289 0] +endobj +159 0 obj +[198 0 R/XYZ 62.362206 604.1919 0] +endobj +160 0 obj +[198 0 R/XYZ 62.362206 560.1549 0] +endobj +161 0 obj +[198 0 R/XYZ 62.362206 516.1179 0] +endobj +162 0 obj +[198 0 R/XYZ 62.362206 472.08087 0] +endobj +163 0 obj +[198 0 R/XYZ 62.362206 384.00687 0] +endobj +164 0 obj +[198 0 R/XYZ 62.362206 339.96988 0] +endobj +165 0 obj +[198 0 R/XYZ 62.362206 295.93286 0] +endobj +166 0 obj +[198 0 R/XYZ 62.362206 251.89587 0] +endobj +167 0 obj +[198 0 R/XYZ 62.362206 207.85889 0] +endobj +168 0 obj +[198 0 R/XYZ 62.362206 163.8219 0] +endobj +169 0 obj +[198 0 R/XYZ 62.362206 119.78485 0] +endobj +170 0 obj +[199 0 R/XYZ 62.362206 783.8583 0] +endobj +171 0 obj +[199 0 R/XYZ 62.362206 739.8213 0] +endobj +172 0 obj +[199 0 R/XYZ 62.362206 651.74725 0] +endobj +173 0 obj +[199 0 R/XYZ 62.362206 607.71027 0] +endobj +174 0 obj +[199 0 R/XYZ 62.362206 563.6733 0] +endobj +175 0 obj +[199 0 R/XYZ 62.362206 519.6362 0] +endobj +176 0 obj +[199 0 R/XYZ 62.362206 475.59927 0] +endobj +177 0 obj +[199 0 R/XYZ 62.362206 431.5623 0] +endobj +178 0 obj +[199 0 R/XYZ 62.362206 387.52527 0] +endobj +179 0 obj +[199 0 R/XYZ 62.362206 343.48828 0] +endobj +180 0 obj +[199 0 R/XYZ 62.362206 255.41425 0] +endobj +181 0 obj +[199 0 R/XYZ 62.362206 211.37726 0] +endobj +182 0 obj +[199 0 R/XYZ 62.362206 167.34027 0] +endobj +183 0 obj +[199 0 R/XYZ 62.362206 299.4513 0] +endobj +184 0 obj +[199 0 R/XYZ 62.362206 695.7843 0] +endobj +185 0 obj +[198 0 R/XYZ 62.362206 428.04388 0] +endobj +186 0 obj +[198 0 R/XYZ 62.362206 738.0621 0] +endobj +187 0 obj +[197 0 R/XYZ 62.362206 546.89404 0] +endobj +188 0 obj +<>/Font<>>> +endobj +189 0 obj +<>/Font<>>> +endobj +190 0 obj +<>/Font<>>> +endobj +191 0 obj +<> +endobj +192 0 obj +<>/FontDescriptor 193 0 R/DW 0/W[0 0 1000 1 1 625 2 2 757 3 3 370 4 5 590 6 6 325 7 41 1000 42 42 325 43 43 590 44 50 1000 51 51 590 52 59 1000 60 60 590 61 63 1000 64 64 590 65 67 1000 68 68 590 69 69 624 70 70 421 71 71 581 72 72 372 73 73 591 74 74 641 75 77 1000 78 78 590 79 83 1000 84 84 510.99997 85 87 1000 88 88 590 89 89 578 90 90 568 91 91 1000 92 92 590 93 97 1000 98 98 589 99 115 1000 116 116 714 117 117 637 118 118 644 119 119 436 120 120 581 121 122 1000 123 123 717 124 124 304 125 125 315 126 126 527 127 127 626 128 128 644 129 129 585 130 130 863 131 131 604 132 132 495 133 137 1000 138 138 580 139 139 597 140 140 1000 141 141 613 142 149 1000 150 150 642 151 151 227 152 155 1000 156 156 964 157 157 1000 158 158 615 159 159 626 160 160 576 161 162 1000 163 163 640 164 164 686 165 165 574 166 186 1000]>> +endobj +193 0 obj +<> +endobj +194 0 obj +<> +endobj +195 0 obj +<>/FontDescriptor 196 0 R/DW 0/W[0 10 1000 11 11 473 12 12 547 13 13 566 14 14 974 15 15 661 16 16 367 17 17 538 18 18 332 19 19 596 20 20 334 21 21 557 22 22 564 23 23 638 24 24 471 25 25 256 26 26 352 27 27 557 28 28 558]>> +endobj +196 0 obj +<> +endobj +197 0 obj +<> +endobj +198 0 obj +<> +endobj +199 0 obj +<> +endobj +200 0 obj +<> +stream +x+x* +endstream +endobj +201 0 obj +<> +stream +xmˎ9 E +@ȴJo"uUc ]6XK'AER<$Lo;V_zz=vחy|inx:Ee:ͷ=RsP|9zǤy/J]̧Wj?Pp՝PwO|QOe:':"{y<r<ŮzJ)ue~ޟ/W{ +N|/-_T#/ +~8w~8L7C4<ϫZoզֶNk|iw[ڻ-[-`Y˱e;X|`Xq6)9%m&``f4BVݪM IjFa tNH; +e:Ⳅv[#>%|m.DXka1_B|nLF|I:7 >N ňE|>&|F ">7jDm/ 6L/k皅*B4_ }U E;H,ħ[5/|cC u`/, +-Ӣ&,Gv-7J&,g^,񅘡eEb!ؤB,e/[g$ 5qHl<8ϬqࡃCE:CEhOii-U_#_ yqkǩcb'+_HW=F-߁/"|kB>Ig>=$K$;7d0p}@|I7(x~ڊ8!˘Of)>7&[ܘ$cnF@ ɃiD:9<[F)šFc" P#V-!(܈R`(܈";_]Dj#7f/> +stream +xtXɻ>0Lw0ʴ `9GT0!I@rNP"i9'05iu95=~a=ϭꪯTWW}~oI $ (0,pk@yC݆L so-nI*DeNJ7fWYXdFzzOMqHq}Xť5=p\w0ﰨAQ!ޞ^aìmF f= ‚ i.r :ʲޡa!!,gzyX pǐk]B!nK\w@ mH`P`Hw`DI:4%1ύw9Shl$~@f%1F?^}ZXhlKJƦ0.I9i,:Ayq#i&ZFГHqqz??Y߿oG[?iKO-z3HCg9&'%%ϫzzzy3W!OqvBT/- +-.V=kCvzazzz$}$^rp{L*&\GCaѹA~͸Q޽NKL妮e|K]P%krQYfI&wn:vѽczV:ǼOxWyAj7x!UVN`}avxG:qQ]G=wLإz1^&NaҙL2M2-zz3;kK5s57fyjA҂ p¤-ھbs[RaeW,BUWXPFFp:u6r]\\K_ wי{y7o> +:2..6̌Oh?<_uBm m [,MqLۉ:rhlZ=!6k=D1sN؆NlF)6wA :?ɉ-tFs9ϻ?nn6Q3U3r:د(G¯T'$X +ƤJH¬_~J7:O'D]g]vO`0\K3*MebAN}vl/ys9M;a&A- ΜuoX}}j]'SuGΨ :*y.L8L(jNCݣ ?[%&-Gkk;:Q?ԔW+,)|dљ W7XyoCpS u>+i|Hn4zϗS`4,әA 3b/"j朦|nQ|'w;~3$ VЃRqnBV &,YQdD}v6.wRrx)~&{CƴmlsAbpA/0{`Rc\{M{Rp6v(MYϮޅo[4[H 60'n!&ۘhMv$sI{YλN<\rOҊ/#ەTdNTttK,϶B*~M(yfW%9)r]gҲ4hqe t@?v`t>1}FSb\d>Cz 'b:JD:]BT 7HqsȕvEIxc?*1&3Ρ@"Imadw:;>ˉ5-I)m:A9b.9#!\7n|<_3F}-H0z)d|X!O?T0VI4,CC#S'Kryq8b6Br9S]eg.qe6ѷ8ni8i|uO]>?켔XaqJQ)*.Ӓ̩I?֘r#tEM#:cUAX}F't@'>ھB}*=ʼn]b|'z,mmʵ|QTN:frfb.L̛> +!\olR(F.N^}L~NDAoAՀ|l.#Xdw$;;9= 3^604xqAшk9Rr[k!Cp;V,0iN9\T' Oq~2}l +hv f3%ޣ?HG~T/X:w`75b/|՛Cݨ S4]nFg!Dk ?iy=yq$3")I=f`xAkA3AJI +}ٗ?FVzbG)0,u=& ?cFJף#/j +]_aͪ1n&Mb~VVb|>;8ud12% y2J8z^N4MZfG/X2񄹪I*`ۿ*;([Dbay_ &^{mU jjeׅWeŔ +c&o\1nRTrCWIaZA߇+++zܑ,ۋU?|?Y/G^b&M FtBRL;NUO~z2'/lv 4 q B`i-vb&g|V> +zb煁y` B/NJC[d|G,2k3J+ G8P8I.\c6QGj_nPFOwYNȚ -@.!k_mڴ="+y:/!ٻ%1xoŞ蒍wܮ$s<FPoT$CdSpGȺ_P@'^ FBZ MZ~>xUNwpa友/'LQKk4~|xkCcʀ?pJ yH J㣦th^}Ա_oPO,V[w/Sexj7V]Zu\\ %BY9Y_({kϊ_`e0aJG CId-f94g]3/o9 [? +½g eعO^o| +jeÅrc[[2=d +,XzQ׮!?׊d-p 67p]JPIG58Ƣ[˱ٟ9%ۂ7olٲ3";y:#[sbSr]yr +]>Ea|̇k +QSa +WܛY7e`1zh4a[BI25|7[3J~48BJtB*+^(%b VZp(z5}P{uHo2iE&Dm]3PD׹:WFnW?Q0Ǡ Ş-ma# 651۬ 1,]38u>>y'߆=3MPi@ɶ"PB˟|5\&00| +#A qǒƒ&!c*x/7t@a(-gb6bW'NO_ǏsrWV:Psf8] #jU_c^-t`x "ILG1494{ +Qd'Boz +LoAFtʄ'=ԉ>8tSM HCz"34tl yLеA+ dz`s0),-`HV CUޯI0,o򎜟k۞J5mmz *S5ZYQ/M7Ə'19%B_ +ŹgW62͜E}rC`V!ap?eU[@3"3y:u_._ngYibGx|JW6]fyͱAu~]KS^SAC+;L[h[ljL[JMɸʨbJzPWQc9rf5펛C=1A!!)ܹ-`TZedrr__vn` CwHIOŃbT`]?]4fJ,2å`΃[ +k.Ne7u $=G}an@0uH;:h30W/7{I yD78g}8{䷌߹dn/d^0Ӿ%7a2̀[tD_3h`HrANjV"϶uV0=#~GٶHAԉt:VvƳ+Tp v)P$'i U^dWZgUXϮ +ͬrvKk86Aח;hRʏo֬管G +am8x!;&nn9,VHvΝ[U[#",RBS%sGʓ +quYq뷪2Brчt{?J7ꋸeWdQ/F2e9QrY6A=ȶͣ;mB V9+f-o-7'ܯ-)ҷf +1Ʒ` {RO2ߋR遧)Fxyy;z>5;%'%0fF8ߘBn뀟?..r{o] 9o&u8)/St`?!2rp( 30̗MG/?fFi lLk0H>—p2m2R:nr乵X|yq۷oTmLJޕ'jbr W9Tr;űaK_r,gHO0 +FTY| ؐK08 e\ #R0h #!Kz ֤z`XZ%[#6 +HemP17?/?(ӥakT@U[? W  cN1%K? 9w4bx@u&$Gd889ǟ'՗T,x$wwzVWyT6>辠Q3r|/-0IAAP@ +%^|Dxˮ?|YI*97a2;70SUZMP}0-gc. 5C7euf߃)d yC=46Q-KC`*?ퟷ؃ 6C?0 '?Nr&P?o.j xYͫlAXkJ0lbQ&;Js")"C;MMz b'(䴙x6': +#&"1vúX;*0FZ>*C` R;VI?ɑԣɼҘ3y_Q9w0@>N:[Laiǡ:@{yg:8ӿ$*b~bV&"ʷ??}СsaR 4ߐ)0}:MgpgfNo#ju +SdOy])n)Y̋`83ca} +QJޏj9/v^(\UZV17正FST  wX_;]'$NCE܊N4Z=C an3;},od 5GXܬwF,hZggt +(!9wo-_:< wrP\^8sYN!.cf[and*+%- &O ƺEGnؤJr]߷d&PšD +wGL_-ۜeOYM_q`c~2uz윅nSOs̎tJ ȉ mVXQZ}4?Ƕu[L *?b&¡.DPe7,>3dTئdeefs7ٜ*Mef%7`x#h Mj{L{3 +q90 i V_k])q +vH,e4Ȇ%@ZML]eS/OZ^r$gGUW]a>d!5_##(.;Ye(T#5zs[~ll +眙tl1M_PQx֦ͯy ?yZ3:&r5IeTN Dp0;E{JR7h C})E8~+X8viG8#OSp)Y=ta\%lߐڰ,i:-8m?wlrz6pisт%S *uJFׯ7?N?MBa&L7z˜pГFRO!2AgJK0]'@+;vuE< ª4;OsSφx2j ɴ:_}կ 'GgFg(Yo0QCg s(pI эcs>2 mټeG>>I{/9Ӓɝ^& c+8BK +ۻ?Se5]x`~Ok /ֲqv$ uN\7eFg,+,ܭRurK*/)!`1 +F Z+A@ao%k-#ڲL#m E4V E^FtCtAc]\KW+ЌCm|d\\')ZJ^:7b^L٦dH]FŘ❐~`Cm ޖe700մF)>\ H1͏-]l۹v.ON*#YW4ʸK54lNMHuIJL_7km٥PWf*=mMԕS ;bWwg=IOy\REDGDlK ĕ/Tϭ\5wغ1ns9Dp#VW_>-ݳ23w_uQ%hPAK"N KFP{RRشi~Hy} \66YN㝱r;/$USRPUGNr٬<"tb/4V`1 $i#:R-d?U'rk.` n#z00E}^)Ǻ:Q3/Y r֟łYE)g)}S|4ʨI_5s5PAF*52Lϡ L G濎:9y99En/vM.ƶvtr-i_ZNa:R\ɔ\ّf`:(r׀ί=JƉ9>@ЗβN>aTv +j~ ֟ZgR | ]d툁) ++ES#orR/C_4}m~i'F ]\oԑ؟M&k_aޒ]h7os+dde?_Y'~-}%Je+zyVƣ*3[JI#zbgN 2$ ,4VLa#ki;S3W>^U^R7¬Ϯ00 RTfn +ә[7Y0yZmӉoG?ppl=Лe>$=~Sm| +9oMlȴC#â㸧7 +2K9Iyr.V uY8'beJJkmwy`4]6"Z*sI -tω9x%zcj̹9vNs׭^|nBÙEͩNw%ZArOdd?U씭gjվ.~'V?]C!¾}W=dlYﰹ`sֲ)sX'}=g\svBDAz%ujw˩0t&>1) <5=^3R_> , N_}FwtFF?)JcXk޷;x[l: 62䢓Β$uYCO:az Cdv9B>|?Ы8*ojJTXQeLE_C ,&R:-u +,5aTnOؐ]Efw Ju~jjޒ7*h9~zXfiesQ<͒Uz^ӻ(f*#wS[ Gg5[iN=H=Hoty9/FsH?*=cg/~iK8}cPկhBYVX3 +QKX܆%WN $NN!36P8800`+1T>%/em!Cq'CȿC'0uC3i>N%A #,-D,#(`構KZ&̭?ZcFØg%$mBKf`Lٖod-C0F$(q^-\B`$9sD\Xo0ÆݦNkZƂWYi + (TD<ڂPw2JXII\MSnŕ#nm,[,b%_~}s;HB@TA I/OAOCV&ZHw,wl7?roX cq(|Lh3fkʴ^22jj8o3\|ۆ/]]Vɯ $ѹ._ ,>Lq aYZj@\/q3W҅PMX}xQn&pd%ا,yC ʔŔŖo(8R;3cfJccnF/QzX0~ K7}dlɏ-%c?}mt?lmF0[֥ep4dŎdƺvM<R+'ͮ&')NkUI^Ѿflffl&p}&2;*7ӱL&;+3ke]e]T`L:.:K=h C)C%?caWcL &l/Ԩ:m~ͨ8B5l=[)9^|@_)jbuCɶme*|Rc4hVI.:3|x,ޣ#hwc֛gիBֺ9Y^|=ia=dEfԙd4BNبH¯OVND`nїo*m:[mmY٩y,Z]v)gE<к=8{ _ӷ)ӯaG{".te0X޾uBG,WהUO䞑@&as]_^^qf~jTn͍-)x%QwJcEBvˋ)!%5y 1hmOǔs6FrPtfff#=M:V$ =źAl1 vIλ3I<&L)>o.b\YszϷ_)0&`r% Jn+"A\Nm; ĥ^S[ݧ6e>m'/p0+⛻aC3s}u]t\L[a 4h +` ky +p S ,]17JƧf+*aؗNOl кX  +\OHۤטyM5 c.99i&;4'(-]S,h?]` cKtX㲋j1{ǪZ.2ԕ!xbòkU ,t璅͉2Tfq0lIe?V,vv\⟺#a]s+oֶM2@%_ +・7 +dZCcS-!T(6ng}y︞XyOҊ#9C!+#:\7Saw='n(ZS1Ln ?"ҝ &@ +p3 `Ϋ:. '7Rr6L 5)5'+onlWκ!S١9)art*xïfkzIeK~ Z8kwN?<}EQ +-W=,<}&w ƢQ@wxPw|Yϯ-. l y˧ lQFN~T?f "Kӣ + K8pta Fq1Lb@j]O2{Җvrex*Jn&:,0y7A[em [N9L$pSLLw[41s i?re~L3!J Ӱ'iKz*=Dv_78G/CóO?<}DiE'QAwl,Hzau9 #/*qY50սN h4Þo4D6 DvCSA.I ΈR{V'PA餌YE`0L|6BG9z]].g._˽…@ Vh c]QKk9>{(4 w~y5)b(0?*42i 2 9| i rI|og) +R_pR7SagN%zf,M՝W;$ 02IL#ERN⡀d@I;NR(I;Np^cHl)Y9KT;Yq?g)cLLH%@,8+8DЧLdt:qDn`c^iz0/,Ox:Y_yS'WyZnnkmFy~o,:kH {c j1>^\N8~囅?)0n +8(0 ^aLӬ+rt.A0xђ޹[CRnv;oʇvC t/V^ӏAn{wGh,76`y)^Mܫӱ`Dk̩|Ewo `@*=)͇|VLrjR48A4:k0 +<إ+WZZX3Z>~}?Ox^[܎9m͍"Ga]|eH[)^⇄_5lu6%FZO޻HKU{w(.(z]I7s̕i.,/ ɋJ^Rw>_(;Ce08 +޿.3\-ݑg Xy@Mg22;ldd2fGJ 2ˎN3<&GgYB\79'%\sZveT22DXv);!,;:? -F;nHKS@[ ą0`B/4'CcWGN7h˕$+gƵD:d:R皸Mᕾ|wum.gy+m8#*ZMjc`u]2=ǯdyO{Es:+t""4+UTe\ Y^)b5+Ǭϯ~ +Rt5dXCEk(% ql5nCYp)F)he<"r^ +V1UZ̦F8z&8`THr^W$7+(oIYw/]:rJ2"AFs81e34d#+U!Qs{1Z 9즲تbNէJӐ W;G#΅ +7dyDZ:h#G/0ޅm n6cV>[}zwÿ(AC#2D|'h.,Y}[b 9;Tq5kq*c2װC[ XBJp7;Co2,?aT`Bg&4}߈iI1V/O +^Uܫfoi;{F  @,zW"My=z}8hB ۢ"$E[xl[~>/~_}Bؖ?j_ 4*/zFefp?ۤ;^Cy\ fz 1 ŚHЇρ79 +\k| +q2,zSE$caoCe0OK 栒O z`@n`%ǵ_Ǒ&euV!J_2c& 7lH [r;nlszocE0.D +cW)B7xx%L<Y )b@z-[3eK!^ +$^Vʕ1֡92>!?3ś Pm6zJ6̍T$ad_EX_Ǒ_dEr ésrMFh ' WC;@θn7x geĵlop1-dIZR%[@V]t` 7i8G/&Y,޴\,U zJep $n C]YO\vq͹Uk[tֶ z0ЛLOE,ekFE1ǝ0뽺¥rL>nA#`gί>Jc3n,ms32vi'N:'7r4:2!L,dImZXJ(]a=SPxQQdk(tUIhnsØs$9Tnbs$A9bo9*/?w.]޵"r&@ ,=yX; 1@Y'7JSCR“(4o?:P3K*zLMɰMCuQ|ԣ`_KuWӢr$s\/}))OX%զTQ0Uy) -/5ib`_8y la=&3/}{͠gBZrlݼ(-*i-k`)#p><;6~֝kF+{C2|nuwQ0ppqgyQ ]_d1џFԸg̐ M0>.39N=NHe)%W2_CٌS쩀uez7!P+nzįusτ=~#g_Y$i!kR!3mҭOztكAQE:²e-p1:t@Z">Z`#\S18Hę+­f27о돔$,781dvTZBuN;(5ѭ\5SeϺȂkZ+/ߦډ8i9N4+7`sLdYhG?,D" Bg7*p`DG5Ko 'Oe'WKa3h J֘ڔtR82P#G-=- i-uKl +fU$ Ӛ j A<|pP ,ɓ'jk)̇aLjY ZAU/ӭ(켃,f|"Rks6!u= -Ti&yΖ]u&>" &rzF)mQ#>h'=cq6]Mqg"p̀,&{lpit7NIO){08M]XC@wLd\Z Ik:Z+/OĆ:994d|۬{&yYkzefg.ڸm7t*ɐPeljgb8(lEۥkVf[P̀.|(0"H@eԷ.q&'15YA#k(YCNxFq8 Z0eHH؈p("@gv w>HL#䡟(#ގ-DO^"A; I)"M<-C}8 ":{n!hEh @?dL?ªE ğwv!Q=<ǔsr duuڠ| EƆ"Ŏ y?n&gxv OK5eɒgҗNp70iƖEQt>Op p%Z E3hr[`ULU,K +&\EVPv&Ƹ^&^z4_C]6ZH+ +quxWM)F5edYKNo[Ⰻĥf[mz@[_oo- x=Xl[amqH}gz)8<2v_+Qd3:0AͼG G{Z`T* ~͟XS̏{P{z{O8FzGIw" L 5y^Omn1TGeM TF/{$4^#ᘉDꞵ3uQ(K)чH2]s}]]vm=QKOU; +znF˚v ql|0=^\%yVj498`D}(ӹ +p.L}4p5VQ\jjfnV~N"5%=5xF0y'OeJPc\.Q!Wn"8-F&Bw`[%7 zQ"/~#5o0_PUpMTv8_zc;cQ?E+-67 }ذaX+%"ОșN3L"^ hJ+fZH:nM`6=0 TVDK0qs 7-F0?lLȀFp F(-+:%!HPBWQ8Gsg%]N;_#,&@̝w;(z!zqDdWtgdz(bq"FՋ%v<ص$BO& +0x~!#~&8'"\j:10 &>zՅ9Krjjʖug<-l"Tiвw"w0O&eL-- {)r +ټ GH<W'P+#+Kjp}SC32#|O}QCחEE9+R7[ٿˋHOOuyr>O*([%*om$dvruu-v)-`X8y [$\\VH)0$ʹAL`Ha 2 k2 ܃#pߓ`RP ð| a3"P]P]Pa O' (Т?s1QQDp$FERaK2-C:p +!Qklfɮ ]OJ_RJ pEۭl|7J"!@  +_j: 5H '_:`zX$\u3$8KsK3=y9D*%wC, T a I v&ݹ` @$+s˲Mv +p$I\gqCR?WQ]k΍^6[ ۤW( S"%/%?)wI ,,@o@AXQxuM +tɰΰIŠV[O._~yù7e`@>!;1'Qs3)yTq77:B5HfQ56EfQ8DA,N=N- .}􂺄V}`Io"-pHqoqoZ~<3WyF8̮>V B=w2rR03ȣGK +h #q:,ljʩ$#ɼv}X6ɠLinL63=(0Jj,΀"֡6s 9ف U*?Ε_{Mwv,JU(Zqo)gaiXH`;.hK7"c`'hh :[3RR%q +Q%2 }v;PAK_ujY7WxIHV"۳842ЧGL&w1qi&{GS <"M9Yb"?%q,ʎ?zhˁg_@0}[v`A(sœHc$8ސJ3fq%2s/"S0+\p +BR}Nzל;U>G +'tIiQe4٥Ni/J<<[ExPA0Ƒ IuihOG3 #/Wpď|_4?o~ܧWp֠BPa.V6U]o;0Fezp@1rp LSh#= (M +\A ͈ڣu5` 煷(JP/:0`*P_z@2' +6 |)X/P=X U?I탵Шlz'<)+ Dp <0eNLtмWJg3N&)4jDm$ƽhB5&ʊ%%3$T aMCͦޡZ :t?&㯸ܴث6ӽ44TF!) !Cc+A~Ń@AI߻JfTT85t?}B;{=Ґk ?Owo v 8=HdL/|ukxݴB +T +}<#2|39046*brR3SXf /uiq/^4㌋xbaG^~=<^Gie.ezl}ҽP22b&W\T@O(+80V#`,=xZAc퓮':ð(r /hӑ'Wpz{zjnF*#N5o"g i0(AԒ94t>ԝ*Iuzj9}x7H/Mq1o?/b8)Zj: :v3eZV? +3cE'#oej{yX04,h/(xOs+N4'6S"SPʥmЂDЄcI-4t;>zUuRT Rق5IHA{r/{wOa/jBɼ6WOp8uhF*4ڎ[*-1BerS u>7s>:neL.lYӕg?woŵlqWfΆv&,+0&UʛE!;ؠ4t[ؐ8!5ce CzZ/CE*L})j BXm On+;{P!=j\s>zUʥB3d2uEL62˿4m˽ ݆3++w^xDїZ> RW@DJgre,f};aSJ45v}PЫb]v+[Ҽ3YT %KS̫E;AxvPo}HnƑyLZ.-rQݚ~!Ϟ*Ƅt}$de59[,{]/jj-_{FvF?|5%!m[v'F)#I۠hq)o +7+n1xهg2DxfPH +`>, an6._>a{pe%&/!j*J :L?#?/F}vER5&vf?֨?L0gNrhfwk5 ay*\zx:{;= Kg:vyTeYY>9~y/Ozq3uw7MSVl-qJ68VˉԬ, +spdUTI#w 1Z$ +~-_py$wʋ/AE32x=u[zFbE9`p,HyfQy)TY^zU?>\|Y`X?8Fm #0V4#3T~_S_U0p?W4ջYYؖ6HШ1pFAi\lF +vhXb1 7 ߭K Z,/<4 8U),Vy}6g0\ \19^_q yf+[S 07ZLgOM sM cRD G -Am@[xGyoԑ?E‹QJoM0#IB}.'q1޾D'vD:#BVLz@xǠ4y%@@q +dPekAyARo-2HQ:7D*CQzG>URm joU?lҷs֦iΝ[6UgC)g̫&`%N&E2'>H؋/@g$ąG~A*҃tҸF +tџ*s>߅V`jI uFt́T l,iAdXh B!]Wf t@~ "Ф{ya8px*=E7)/@paRt {R<v\o~%-l.[^ܾ{itayw0[ +s.Y\Vc+s+١LQFK_b1g. ߡ'@0cuֲ55{Yk*<)4q,&Ggxrh a`, +.LD ޴zy+X^/߾\x鋃PaGȋͽ>J 1gc{EPn@e}vh{S+(}V|_0iK+`=mdH#P)q*vЋ9[T cPg]H`鷉_9Ёݠv4`U5OK 2O/6#/齉br.=iZ6G?C-shg}̸߷H y= +r^o8F&,[vU[-+L> +ja4hC5Cg!lp&y .."&ᘝ8=;uxf\zSD䷂ߎmgQOTn}fO_nguEEH:}9&أt Tdi;iZw$#=BIF%qEeWo:7@`8mJTtP ƯNz\kzڅz~Xf%3>0>)e䋺]W^d}cJpp + t9&^nfOrZVm\7q{Egtek|$GTA.,(M\ȍsڨ'|(A4І&!ROK 'Ul- 8{fjh*_:AP Re֧QkGd,9m:fm^͙>6DP~R23@ DAҍiն4{tFwSha'#71^Bmh& qVBh sվ;Y˒Y;(Te.2{Ɲe_of7qg^s! Å ,}Ҏka\Ax5ru9  C~}4@Ar ,$B'qZa{w2[ws(LM(]f/͖ l~y6y5iOwv^oz%-j*YUƅ0RS @"3+QT3C^q`[U2C dxllxE13qWUuxF4#S_@b*F9")CqL +{Xr +UN+=g +W֙SxG§T]a&H;,  %Mzu8=ts=mγ}4fټՁ_:y>K|TaG\qi\9>w ;\yj o51ihQh衠k?һPz0q&n$`{8tLcԞOj6g4Z!k3묤yy%2 ׹S/=^dGaHEF=<24p#!׵g]Q<?u` @kD0ޡ0r9E0|2n%/ߒp\iuzzhF>#GN*їfU$w$)3+1tn%G̎t8c + 녛@'Ga<9 y ^K2ͤ\bK%-+g/XӀ~남ȊEFv6._t$$N;l}|<>(CF ICiG`7`r_F m@H*Џ+IѻOgoިY*PˉA[Y*0 d␵+wPjZ> +stream +xuWyXijwgM㨨xxxɍ( !W r9C8TTFg#|bj#~}Jxe<{,]Nbu^+;%QU/3͑WboAc,]Y}0:UϪ*˦ю&AYYXX.PфF[8TV)WhdR*}H!K$Y@%An\CLGA +Y RҨITr<+`EߞUiWD<;a atQu|>A(*}ri KXVeꥂ=p+kT{ 6`+0@(`hVhu1)lXO= h{E]d,΃63 רLڧvSTbQ=N(f{:3GPfݵ)Z>z>AN}w&bz4ef)R9y\:nىGH3r5\Ƿ>?˂BȏI{_w0-Wb3ȳߖį5mپZ\<}c󒓋u~qOv]zs岢.m{tEyɳlY͢ѯwR5gOB|4JDDNQHi26h>Z'a-ò([梠7Q()r;_S %:~ >Nʃ#CE K.a >70÷׻aDӮ,ū'H$~R)}(LצjΒP:&_X]P\AO/:N`)eML6|vP;Z*jT||cE7Y_LF,*!1eݬ$| ٙ`r0P"^^Tү!\MTei@ͪL@KTCg8(TPej+>oCB*C;C'Cp5Jl*Uxիj4IP +c8C!ODcrY6M"oDي7Jmk0ΪV`e k^o`DH[YO@6^J- drFc}( #ɷ#JNqG"wJ⣎7 +Іkt>#،!g<=jF{i"73=iy"K00dž|>:y:?dRn' =s1G]U轉l<(+ d^2|44t_ It#/E1(c(CgmSu#'ۺDŽ +:Kη ,cٝR4|4MN#{Dļx6y\G:խXB<*>vI#@ +QFeO2ff*Wu}}ixMM I6X~=kWlwveVvU_s7,l . +c,23oHdM(1H +wu` &8DoB.+x7g̪] ffP +ܿsrTVUeJ n;rLNBb +wa`#Vm˚\L@y> ~NU*Q=CBp7K6'Q&0|0²Gچ +.Dw1]VZS;>fO.Gs"  >y鏡줔4bEB6p(N\ 'V=>C]"y}[MqVg u<cX +3I+'CCK)Kkcdwܪ0Dᗊ{bUq5/SЌ,>08c( $+0a ❐Y}IZ/.!kM^2pq xہ/Ff#ƲQjP˦_w8^X +Oc{G+ ?>38y_Qfd< j8X0hAKѸ8>qVa-|UxuNN3[`U zF*눅%ffȱ 9&G1epdTzz~TؐV +̖n(j4vaLcm3vihX6T{Q5b`hvRrP#66ytg4. ؈:,G20x) xW_$ksW )v=~wMV0ZI}n@fPr&İN*qbR&+G=w#s("C"P "$6("@p*`56ݳd͔xo*`b_z,NЮǰAuE*}a]=V>a7Bdv| OQIhir4O^4 +'r[ -A> Z )sO'6#I}ؐ4%7B Hn k/C}yߑ|#Jܩ{>Wg;eusJQ0&4ՅD02#{]a5F>tA1ϥ.[X4yjvCEͱ"7>``i X¦޼}trF[P^IYB7mR +˄0VgJDvvcAyC5́ҪAjccJX6I> 8Bc>HNIxǗ)`+8AI@ffr{ +7rɻbd$N0E2f/p X0/;%Gtv\,iUΤoT=0Xz~NtZ&ᕚӌvҝE `S.b d{C [^DM#;%^ ^#gfw{ia{ DrYmu\= Siq>(^~0M%s`d55#{ߣR/P`v3;q md:ZԻk=Mt۱5b +.JI(eb"$~|p9B$Bc-=Z0K*f2R ~^*O*ˋCJ_QE|?O`1rX!s|tDzcKc2l&/HsJ29}03 h[D!= ٣3 to? d ZU_a׍7 =%kaxX 儱!d`-V9X1{\l06,y9Pɽďmfw,UgН8NQ1yXt̑!4H ||HQDv^\4_s2λKeG'pړ+#>\mdÂn*"#6+~d~Q^Bgߪ뗪KYYP$'~3/xVf'XF}v`!l|g|#_QYCTf/@74(+;(㖊4pÞVsۄdO+a쩖3yWѢUҜv9h~~Ht1KG Y0GF.X SKg" 9IgA'ЬC& +x9\VP8l/Y*'Lx99b +|tc4^\qy}?ka|aiFI?Ų/]l5[0 a .Wb paXD[L?҄1>s<;0y\s }}Z(2[')4u*L]VU*|4}ՓޱeulQ +P +endstream +endobj +206 0 obj +<> +stream +xZYsG~ׯQJ'9rFA*l#jJUvг}==3ztoot 72h\'Cց0/,fr21hLNcf|>a6ԌO Rn9[ͭ]*LjdEV}W*{ZB5ie=[ߎh]'>/VdHj;ཐ`Lі̃68sNm A3:PȘ[jG`:@Df}@-Μ>5V^N'{JT04 jN jW4AʨEZ>]g|ޑuFwV[ECw)Œ5o˶/>Z{GݫaM&g;rTy'rKhIMF@vodrםˁjwMnNn(~92kliCM| %z)>,Ba4Xs%.6\NWǀWh +C }/V{vwu2ד{Q2UrwK ]ɕ?s!F 'ו'3z.K@ĩ7յTCWmV|ʶZR@~ȝ: ]a|̷ezkDrҺo:+8`N^hr`cO+ +.dH~nyV;{yy^=/|@؞?Pp;Pװ&seZ\٬PlS To*ՔLi.l7sl9!-v]*u|g B)ܐ5)Zz˨(PjR.nkW8d.$sYxN +){%hvnj/(x`r3U,#i{\@^uK2y̯<*"pI:$xH1b-C9m(`n-ߓ56c")jS1(IGPhb+ t\K!X:`Njm>l>|V,=u lv> +stream +xko6W0-Bo5j ؚnsݤMmnn}ؒe1i Gl=~;:{d0weAf"05io }YgoA6<C$[pvYx}:sos~*bQN$)F݈XwHC`c%_vϧ'ǣ /]L>LceQIAFJ 2T`ES%6*뽸Pbp`6n,V9gqANyuqM +̟ǝ% 7eVB#&nG㢄~$[;J @fr~ &?B6E=+ULNyt2; kK.sמ=t5iwmeEdF6ayHm-KcY5 碏vi9)pD*e] 6c!o5 Zc 48 +hbP\(1( +3 ́*|cV 6ʂsfQvr:H  y%L5kND Hf?Xk "VאKxj 8Gwf~!hTm I6&ҙi^M 6e#Mσb6XmѣV>m͢m[XkA t +ThTM֜gmmFS"&*7ƝQ*t.PT[8o -J:N3g 椐9H#kL +Lb!߃_3"gIT)z"9$el[BC92خaEޠXF +wLB-Q7;{%?|̿r?Z~5_;B3v! %33d|,IC1۾(fWO^3]NC˸ϙ_>9.\"}N"6 40AX1Ծ`pf#:hٶPVC+hI1!)XoV@:!wSSΕ\@8m*l}HNI >b@y6;`,SG9XYhv:LMyhdOKzos)v 4D=6Dl[lm+Nu"{qw:_=ۛLӧMCޟLGE7?zqr6LΚ5W}W%\ +r}g&V +endstream +endobj +208 0 obj +<> +stream +x]o6+t9o/0&a6^d^vh.uOY&vD*RCCF +N Y*liL;Bs+dF|lXe,ˬS[zE^K2l2aoAջ='>9JqmFYHtk.{]#}e܍%vL +l}}^$iks34`5]) )lGhE.,(Q"3dbqaFIAYRPeWwkWeLDޠi%'Kd\)*x>?`QNz@_ "5)' IӠknH"B 9i9:eٖ3WxهMef~]πU3`YƑ'jZ(fw\ruիcq15vz3!n6HP@u Aʱej]z,h,v%0+u)vaeR^4 xiv X*4#@NU|^|@Zc y=pҽlQbU.ŭ˭V& Z®KqXY0";w9Z F`2$! 1p=4qA- @x'頪4' 简j+ҢUleUlS(/8Ej9/Ow ([_ cظR\?$r](Nqr$ ),*~c@ntg V֡Km4quYT\ΡgS=b[N̚an|8y0ppyW$Ք]1HT+g홦!! $*OGID^IԺI8m7 D& PI5:X'ZS[o?& H Җ@>ڦ|3tG2 5uF4!F!I4I*!%IćyxeD䞃C{$i]:v(P +9`L"CM1|kq~؟Gn|>{~8?޿<6Svr6j^f< *j/P) +endstream +endobj +209 0 obj +<> +stream +xuJPFOUvDD@`]\ +FMkIRB|&إnऋ(HrTų9` ըa&ʺ:l +3Ŭ*ުnh)&C|>b纝黓AvCƫ+ y') +̵8+//Author/Creator(Typst 0.15.0)>> +endobj +211 0 obj +<> +stream +TH-141:表面张力的严肃理论范式教育教研组Typst 0.15.0zh-CN3application/pdf4GcoofWhl9BWlcMUTXiEnA==CPy0Lk0NYjK7dVf4CjYIQw==proof1.7 +endstream +endobj +212 0 obj +<>/ViewerPreferences<>/Outlines 46 0 R>> +endobj +xref +0 213 +0000000000 65535 f +0000000016 00000 n +0000000085 00000 n +0000000191 00000 n +0000000316 00000 n +0000000438 00000 n +0000000548 00000 n +0000000681 00000 n +0000000802 00000 n +0000000923 00000 n +0000001053 00000 n +0000001204 00000 n +0000001344 00000 n +0000001492 00000 n +0000001616 00000 n +0000001752 00000 n +0000001876 00000 n +0000002028 00000 n +0000002172 00000 n +0000002320 00000 n +0000002480 00000 n +0000002628 00000 n +0000002764 00000 n +0000002928 00000 n +0000003080 00000 n +0000003268 00000 n +0000003432 00000 n +0000003580 00000 n +0000003732 00000 n +0000003884 00000 n +0000004048 00000 n +0000004196 00000 n +0000004360 00000 n +0000004528 00000 n +0000004748 00000 n +0000004916 00000 n +0000005084 00000 n +0000005256 00000 n +0000005444 00000 n +0000005592 00000 n +0000005776 00000 n +0000005948 00000 n +0000006114 00000 n +0000006300 00000 n +0000006451 00000 n +0000006593 00000 n +0000006726 00000 n +0000006793 00000 n +0000006978 00000 n +0000007325 00000 n +0000007771 00000 n +0000008245 00000 n +0000008314 00000 n +0000008381 00000 n +0000008448 00000 n +0000008550 00000 n +0000008653 00000 n +0000008763 00000 n +0000008866 00000 n +0000008974 00000 n +0000009078 00000 n +0000009174 00000 n +0000009278 00000 n +0000009382 00000 n +0000009486 00000 n +0000009602 00000 n +0000009706 00000 n +0000009810 00000 n +0000009914 00000 n +0000010018 00000 n +0000010122 00000 n +0000010234 00000 n +0000010338 00000 n +0000010470 00000 n +0000010574 00000 n +0000010697 00000 n +0000010801 00000 n +0000010935 00000 n +0000011039 00000 n +0000011146 00000 n +0000011250 00000 n +0000011362 00000 n +0000011465 00000 n +0000011559 00000 n +0000011662 00000 n +0000011764 00000 n +0000011867 00000 n +0000011996 00000 n +0000012100 00000 n +0000012212 00000 n +0000012316 00000 n +0000012432 00000 n +0000012536 00000 n +0000012664 00000 n +0000012768 00000 n +0000012890 00000 n +0000012994 00000 n +0000013090 00000 n +0000013194 00000 n +0000013298 00000 n +0000013402 00000 n +0000013523 00000 n +0000013628 00000 n +0000013740 00000 n +0000013845 00000 n +0000013993 00000 n +0000014098 00000 n +0000014222 00000 n +0000014327 00000 n +0000014432 00000 n +0000014537 00000 n +0000014649 00000 n +0000014754 00000 n +0000014863 00000 n +0000014967 00000 n +0000015086 00000 n +0000015190 00000 n +0000015310 00000 n +0000015415 00000 n +0000015526 00000 n +0000015631 00000 n +0000015746 00000 n +0000015851 00000 n +0000015967 00000 n +0000016072 00000 n +0000016240 00000 n +0000016345 00000 n +0000016458 00000 n +0000016563 00000 n +0000016682 00000 n +0000016787 00000 n +0000016907 00000 n +0000017012 00000 n +0000017145 00000 n +0000017250 00000 n +0000017339 00000 n +0000017444 00000 n +0000017541 00000 n +0000017646 00000 n +0000017767 00000 n +0000017872 00000 n +0000017993 00000 n +0000018098 00000 n +0000018838 00000 n +0000018875 00000 n +0000018928 00000 n +0000018980 00000 n +0000019032 00000 n +0000019085 00000 n +0000019138 00000 n +0000019191 00000 n +0000019244 00000 n +0000019297 00000 n +0000019350 00000 n +0000019403 00000 n +0000019456 00000 n +0000019509 00000 n +0000019561 00000 n +0000019614 00000 n +0000019666 00000 n +0000019718 00000 n +0000019770 00000 n +0000019822 00000 n +0000019875 00000 n +0000019928 00000 n +0000019981 00000 n +0000020034 00000 n +0000020087 00000 n +0000020140 00000 n +0000020192 00000 n +0000020245 00000 n +0000020297 00000 n +0000020349 00000 n +0000020402 00000 n +0000020455 00000 n +0000020507 00000 n +0000020559 00000 n +0000020612 00000 n +0000020664 00000 n +0000020717 00000 n +0000020770 00000 n +0000020823 00000 n +0000020876 00000 n +0000020929 00000 n +0000020981 00000 n +0000021033 00000 n +0000021086 00000 n +0000021138 00000 n +0000021191 00000 n +0000021303 00000 n +0000021415 00000 n +0000021527 00000 n +0000021682 00000 n +0000022661 00000 n +0000022874 00000 n +0000023033 00000 n +0000023412 00000 n +0000023627 00000 n +0000023755 00000 n +0000023883 00000 n +0000024011 00000 n +0000024093 00000 n +0000025538 00000 n +0000053322 00000 n +0000053403 00000 n +0000053997 00000 n +0000059093 00000 n +0000060552 00000 n +0000062164 00000 n +0000063824 00000 n +0000064167 00000 n +0000064328 00000 n +0000065483 00000 n +trailer +<> +startxref +65673 +%%EOF \ No newline at end of file From 3d46525f40f44c6005ee6cfaf89b4ac3d4f08248 Mon Sep 17 00:00:00 2001 From: Hong Jiarong Date: Wed, 5 Aug 2026 17:36:43 +0800 Subject: [PATCH 5/5] fix(cph): correct nested outline hierarchy --- crates/cph-model/src/lib.rs | 2 +- .../tests/fixtures/nested/manifest.toml | 5 + .../fixtures/nested/收束簇/manifest.toml | 6 + .../nested/收束簇/segments/总结/element.toml | 1 + .../nested/收束簇/segments/总结/textbook.typ | 1 + crates/cph-model/tests/load.rs | 20 +- crates/cph-typst/src/embedded.rs | 2 +- examples/TH-141/manifest.toml | 5 + examples/TH-141/outline.pdf | 1066 ----------------- render/lib.typ | 9 - 10 files changed, 39 insertions(+), 1078 deletions(-) create mode 100644 crates/cph-model/tests/fixtures/nested/收束簇/manifest.toml create mode 100644 crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/element.toml create mode 100644 crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/textbook.typ delete mode 100644 examples/TH-141/outline.pdf diff --git a/crates/cph-model/src/lib.rs b/crates/cph-model/src/lib.rs index 4eff4d8..4d08155 100644 --- a/crates/cph-model/src/lib.rs +++ b/crates/cph-model/src/lib.rs @@ -176,7 +176,7 @@ fn consume_outline_children( depth, path, } => { - if *depth < minimum_section_depth { + if *depth <= minimum_section_depth { break; } let section_depth = *depth; diff --git a/crates/cph-model/tests/fixtures/nested/manifest.toml b/crates/cph-model/tests/fixtures/nested/manifest.toml index 1b92fda..25e858f 100644 --- a/crates/cph-model/tests/fixtures/nested/manifest.toml +++ b/crates/cph-model/tests/fixtures/nested/manifest.toml @@ -12,5 +12,10 @@ path = "segments/开场白" [[children]] kind = "section" path = "导言簇" +notes = "这里先建立直观图像,再进入分组推导。" + +[[children]] +kind = "section" +path = "收束簇" [targets.student] diff --git a/crates/cph-model/tests/fixtures/nested/收束簇/manifest.toml b/crates/cph-model/tests/fixtures/nested/收束簇/manifest.toml new file mode 100644 index 0000000..97c6dfd --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/收束簇/manifest.toml @@ -0,0 +1,6 @@ +[group] +title = "收束簇" + +[[children]] +kind = "segment" +path = "segments/总结" diff --git a/crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/element.toml b/crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/element.toml new file mode 100644 index 0000000..d67ae12 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/element.toml @@ -0,0 +1 @@ +kind = "segment" diff --git a/crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/textbook.typ b/crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/textbook.typ new file mode 100644 index 0000000..8e38b02 --- /dev/null +++ b/crates/cph-model/tests/fixtures/nested/收束簇/segments/总结/textbook.typ @@ -0,0 +1 @@ +总结 diff --git a/crates/cph-model/tests/load.rs b/crates/cph-model/tests/load.rs index 5024c5c..b635d57 100644 --- a/crates/cph-model/tests/load.rs +++ b/crates/cph-model/tests/load.rs @@ -121,6 +121,7 @@ fn nested_sections_flatten_depth_first_with_correct_depths() { PathBuf::from("导言簇/segments/子段一"), PathBuf::from("导言簇/嵌套子节/lemmas/子引理"), PathBuf::from("导言簇/segments/子段二"), + PathBuf::from("收束簇/segments/总结"), ], "root-relative paths must accumulate through every nesting level" ); @@ -139,7 +140,7 @@ fn nested_sections_flatten_depth_first_with_correct_depths() { kind: "section".to_string(), title: "导言簇".to_string(), depth: 1, - notes: None, + notes: Some("这里先建立直观图像,再进入分组推导。".to_string()), path: PathBuf::from("导言簇"), }, OutlineEntry::Element { @@ -161,8 +162,25 @@ fn nested_sections_flatten_depth_first_with_correct_depths() { part_index: 3, depth: 1, }, // 导言簇/segments/子段二 + OutlineEntry::Section { + kind: "section".to_string(), + title: "收束簇".to_string(), + depth: 1, + notes: None, + path: PathBuf::from("收束簇"), + }, + OutlineEntry::Element { + part_index: 4, + depth: 1, + }, // 收束簇/segments/总结 ] ); + let document = lesson.outline_document(); + assert_eq!(document.children.len(), 3); + assert_eq!(document.children[1].title, "导言簇"); + assert_eq!(document.children[1].children.len(), 3); + assert_eq!(document.children[2].title, "收束簇"); + assert_eq!(document.children[2].children[0].title, "总结"); // The outer section declares [group].title = "导言簇"; the inner section // has no [group] at all, so its title falls back to the folder basename. diff --git a/crates/cph-typst/src/embedded.rs b/crates/cph-typst/src/embedded.rs index 6236d58..1db9506 100644 --- a/crates/cph-typst/src/embedded.rs +++ b/crates/cph-typst/src/embedded.rs @@ -60,7 +60,7 @@ pub fn resolve_render_dir() -> PathBuf { /// 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"; +const RENDER_CACHE_REVISION: &str = "outline-v2"; /// The version/revision-keyed cache location and a guarantee the embedded tree /// is present there. Returns the directory the World should use. diff --git a/examples/TH-141/manifest.toml b/examples/TH-141/manifest.toml index c6b85d3..7e9b154 100644 --- a/examples/TH-141/manifest.toml +++ b/examples/TH-141/manifest.toml @@ -21,22 +21,27 @@ path = "segments/液面拉伸的本质" [[children]] kind = "section" path = "液气界面" +notes = "先从液面拉伸的宏观图像切入,再用缺键模型和 LJ 对势逐步建立微观解释;可补充一个数量级估算例题。" [[children]] kind = "section" path = "固气界面" +notes = "把液体表面能与固体表面应力放在同一张对照表中,强调固体表面能的晶面各向异性。" [[children]] kind = "section" path = "固液界面" +notes = "围绕界面能的物理图像,串起 Dupré、Girifalco-Good、Fowkes 与 Young 方程;可安排一个浸润判据例题。" [[children]] kind = "section" path = "σTp态函数建模" +notes = "这一节是温度依赖建模主线,先回顾 σT,再解释微观模型和经验规则之间的联系;进阶学生可比较不同模型的适用范围。" [[children]] kind = "section" path = "收束" +notes = "最后对照各模型对水的预测,回收本节主线,并明确为什么实际数值可能算不准。" # Export targets (ADR-0009/0011): each target is a build producing a typed # artifact, run as an ordered list of typed steps. A `typst-compile` step names a diff --git a/examples/TH-141/outline.pdf b/examples/TH-141/outline.pdf deleted file mode 100644 index 3558e6c..0000000 --- a/examples/TH-141/outline.pdf +++ /dev/null @@ -1,1066 +0,0 @@ -%PDF-1.7 -% - -1 0 obj -<> -endobj -2 0 obj -</Dest 144 0 R>> -endobj -3 0 obj -</Dest 145 0 R>> -endobj -4 0 obj -</Dest 146 0 R>> -endobj -5 0 obj -</Dest 147 0 R>> -endobj -6 0 obj -</Dest 148 0 R>> -endobj -7 0 obj -</Dest 149 0 R>> -endobj -8 0 obj -</Dest 150 0 R>> -endobj -9 0 obj -</Dest 151 0 R>> -endobj -10 0 obj -</Dest 152 0 R>> -endobj -11 0 obj -</Dest 153 0 R>> -endobj -12 0 obj -</Dest 154 0 R>> -endobj -13 0 obj -</Dest 155 0 R>> -endobj -14 0 obj -</Dest 156 0 R>> -endobj -15 0 obj -</Dest 157 0 R>> -endobj -16 0 obj -</Dest 158 0 R>> -endobj -17 0 obj -</Dest 159 0 R>> -endobj -18 0 obj -</Dest 160 0 R>> -endobj -19 0 obj -</Dest 161 0 R>> -endobj -20 0 obj -</Dest 162 0 R>> -endobj -21 0 obj -</Dest 163 0 R>> -endobj -22 0 obj -</Dest 164 0 R>> -endobj -23 0 obj -</Dest 165 0 R>> -endobj -24 0 obj -</Dest 166 0 R>> -endobj -25 0 obj -</Dest 167 0 R>> -endobj -26 0 obj -</Dest 168 0 R>> -endobj -27 0 obj -</Dest 169 0 R>> -endobj -28 0 obj -</Dest 170 0 R>> -endobj -29 0 obj -</Dest 171 0 R>> -endobj -30 0 obj -</Dest 172 0 R>> -endobj -31 0 obj -</Dest 173 0 R>> -endobj -32 0 obj -</Dest 174 0 R>> -endobj -33 0 obj -</Dest 175 0 R>> -endobj -34 0 obj -</Dest 176 0 R>> -endobj -35 0 obj -</Dest 177 0 R>> -endobj -36 0 obj -</Dest 178 0 R>> -endobj -37 0 obj -</Dest 179 0 R>> -endobj -38 0 obj -</Dest 180 0 R>> -endobj -39 0 obj -</Dest 181 0 R>> -endobj -40 0 obj -</Dest 182 0 R>> -endobj -41 0 obj -</Dest 183 0 R>> -endobj -42 0 obj -</Dest 184 0 R>> -endobj -43 0 obj -</Dest 185 0 R>> -endobj -44 0 obj -</Dest 186 0 R>> -endobj -45 0 obj -</Dest 187 0 R>> -endobj -46 0 obj -<> -endobj -47 0 obj -<>/K[142 0 R]/ParentTree<>/ParentTreeNextKey 3>> -endobj -48 0 obj -[51 0 R 51 0 R 52 0 R 53 0 R 54 0 R 54 0 R 55 0 R 56 0 R 56 0 R 57 0 R 58 0 R 58 0 R 59 0 R 60 0 R 60 0 R 61 0 R 62 0 R 62 0 R 63 0 R 64 0 R 64 0 R 65 0 R 66 0 R 66 0 R 67 0 R 68 0 R 68 0 R 69 0 R 70 0 R 70 0 R 71 0 R 72 0 R 72 0 R 73 0 R 74 0 R 74 0 R 74 0 R 75 0 R 76 0 R 76 0 R 76 0 R 76 0 R 77 0 R 78 0 R 78 0 R 78 0 R 79 0 R] -endobj -49 0 obj -[80 0 R 80 0 R 80 0 R 81 0 R 82 0 R 82 0 R 83 0 R 84 0 R 84 0 R 85 0 R 86 0 R 86 0 R 86 0 R 86 0 R 86 0 R 87 0 R 88 0 R 88 0 R 89 0 R 90 0 R 90 0 R 91 0 R 92 0 R 92 0 R 93 0 R 94 0 R 94 0 R 94 0 R 94 0 R 95 0 R 96 0 R 96 0 R 97 0 R 98 0 R 98 0 R 99 0 R 100 0 R 100 0 R 101 0 R 102 0 R 102 0 R 102 0 R 103 0 R 104 0 R 104 0 R 104 0 R 105 0 R 106 0 R 106 0 R 106 0 R 107 0 R 108 0 R 108 0 R 109 0 R 110 0 R 110 0 R 110 0 R 111 0 R] -endobj -50 0 obj -[112 0 R 112 0 R 112 0 R 113 0 R 114 0 R 114 0 R 115 0 R 116 0 R 116 0 R 116 0 R 116 0 R 117 0 R 118 0 R 118 0 R 118 0 R 118 0 R 119 0 R 120 0 R 120 0 R 120 0 R 120 0 R 121 0 R 122 0 R 122 0 R 122 0 R 123 0 R 124 0 R 124 0 R 124 0 R 125 0 R 126 0 R 126 0 R 127 0 R 128 0 R 128 0 R 128 0 R 128 0 R 129 0 R 130 0 R 130 0 R 130 0 R 131 0 R 132 0 R 132 0 R 133 0 R 134 0 R 134 0 R 135 0 R 136 0 R 136 0 R 137 0 R 138 0 R 138 0 R 139 0 R 140 0 R 140 0 R 141 0 R] -endobj -51 0 obj -<> -endobj -52 0 obj -<> -endobj -53 0 obj -<> -endobj -54 0 obj -</K[4 5]/Pg 197 0 R>> -endobj -55 0 obj -<>]/K[6]/Pg 197 0 R>> -endobj -56 0 obj -</K[7 8]/Pg 197 0 R>> -endobj -57 0 obj -<>]/K[9]/Pg 197 0 R>> -endobj -58 0 obj -</K[10 11]/Pg 197 0 R>> -endobj -59 0 obj -<>]/K[12]/Pg 197 0 R>> -endobj -60 0 obj -</K[13 14]/Pg 197 0 R>> -endobj -61 0 obj -<>]/K[15]/Pg 197 0 R>> -endobj -62 0 obj -</K[16 17]/Pg 197 0 R>> -endobj -63 0 obj -<>]/K[18]/Pg 197 0 R>> -endobj -64 0 obj -</K[19 20]/Pg 197 0 R>> -endobj -65 0 obj -<>]/K[21]/Pg 197 0 R>> -endobj -66 0 obj -</K[22 23]/Pg 197 0 R>> -endobj -67 0 obj -<>]/K[24]/Pg 197 0 R>> -endobj -68 0 obj -</K[25 26]/Pg 197 0 R>> -endobj -69 0 obj -<>]/K[27]/Pg 197 0 R>> -endobj -70 0 obj -</K[28 29]/Pg 197 0 R>> -endobj -71 0 obj -<>]/K[30]/Pg 197 0 R>> -endobj -72 0 obj -</K[31 32]/Pg 197 0 R>> -endobj -73 0 obj -<>]/K[33]/Pg 197 0 R>> -endobj -74 0 obj -</K[34 35 36]/Pg 197 0 R>> -endobj -75 0 obj -<>]/K[37]/Pg 197 0 R>> -endobj -76 0 obj -</K[38 39 40 41]/Pg 197 0 R>> -endobj -77 0 obj -<>]/K[42]/Pg 197 0 R>> -endobj -78 0 obj -</K[43 44 45]/Pg 197 0 R>> -endobj -79 0 obj -<>]/K[46]/Pg 197 0 R>> -endobj -80 0 obj -</K[0 1 2]/Pg 198 0 R>> -endobj -81 0 obj -<>]/K[3]/Pg 198 0 R>> -endobj -82 0 obj -</K[4 5]/Pg 198 0 R>> -endobj -83 0 obj -<>]/K[6]/Pg 198 0 R>> -endobj -84 0 obj -</K[7 8]/Pg 198 0 R>> -endobj -85 0 obj -<>]/K[9]/Pg 198 0 R>> -endobj -86 0 obj -</K[10 11 12 13 14]/Pg 198 0 R>> -endobj -87 0 obj -<>]/K[15]/Pg 198 0 R>> -endobj -88 0 obj -</K[16 17]/Pg 198 0 R>> -endobj -89 0 obj -<>]/K[18]/Pg 198 0 R>> -endobj -90 0 obj -</K[19 20]/Pg 198 0 R>> -endobj -91 0 obj -<>]/K[21]/Pg 198 0 R>> -endobj -92 0 obj -</K[22 23]/Pg 198 0 R>> -endobj -93 0 obj -<>]/K[24]/Pg 198 0 R>> -endobj -94 0 obj -</K[25 26 27 28]/Pg 198 0 R>> -endobj -95 0 obj -<>]/K[29]/Pg 198 0 R>> -endobj -96 0 obj -</K[30 31]/Pg 198 0 R>> -endobj -97 0 obj -<>]/K[32]/Pg 198 0 R>> -endobj -98 0 obj -</K[33 34]/Pg 198 0 R>> -endobj -99 0 obj -<>]/K[35]/Pg 198 0 R>> -endobj -100 0 obj -</K[36 37]/Pg 198 0 R>> -endobj -101 0 obj -<>]/K[38]/Pg 198 0 R>> -endobj -102 0 obj -</K[39 40 41]/Pg 198 0 R>> -endobj -103 0 obj -<>]/K[42]/Pg 198 0 R>> -endobj -104 0 obj -</K[43 44 45]/Pg 198 0 R>> -endobj -105 0 obj -<>]/K[46]/Pg 198 0 R>> -endobj -106 0 obj -</K[47 48 49]/Pg 198 0 R>> -endobj -107 0 obj -<>]/K[50]/Pg 198 0 R>> -endobj -108 0 obj -</K[51 52]/Pg 198 0 R>> -endobj -109 0 obj -<>]/K[53]/Pg 198 0 R>> -endobj -110 0 obj -</K[54 55 56]/Pg 198 0 R>> -endobj -111 0 obj -<>]/K[57]/Pg 198 0 R>> -endobj -112 0 obj -</K[0 1 2]/Pg 199 0 R>> -endobj -113 0 obj -<>]/K[3]/Pg 199 0 R>> -endobj -114 0 obj -</K[4 5]/Pg 199 0 R>> -endobj -115 0 obj -<>]/K[6]/Pg 199 0 R>> -endobj -116 0 obj -</K[7 8 9 10]/Pg 199 0 R>> -endobj -117 0 obj -<>]/K[11]/Pg 199 0 R>> -endobj -118 0 obj -</K[12 13 14 15]/Pg 199 0 R>> -endobj -119 0 obj -<>]/K[16]/Pg 199 0 R>> -endobj -120 0 obj -</K[17 18 19 20]/Pg 199 0 R>> -endobj -121 0 obj -<>]/K[21]/Pg 199 0 R>> -endobj -122 0 obj -</K[22 23 24]/Pg 199 0 R>> -endobj -123 0 obj -<>]/K[25]/Pg 199 0 R>> -endobj -124 0 obj -</K[26 27 28]/Pg 199 0 R>> -endobj -125 0 obj -<>]/K[29]/Pg 199 0 R>> -endobj -126 0 obj -</K[30 31]/Pg 199 0 R>> -endobj -127 0 obj -<>]/K[32]/Pg 199 0 R>> -endobj -128 0 obj -</K[33 34 35 36]/Pg 199 0 R>> -endobj -129 0 obj -<>]/K[37]/Pg 199 0 R>> -endobj -130 0 obj -</K[38 39 40]/Pg 199 0 R>> -endobj -131 0 obj -<>]/K[41]/Pg 199 0 R>> -endobj -132 0 obj -</K[42 43]/Pg 199 0 R>> -endobj -133 0 obj -<>]/K[44]/Pg 199 0 R>> -endobj -134 0 obj -</K[45 46]/Pg 199 0 R>> -endobj -135 0 obj -<>]/K[47]/Pg 199 0 R>> -endobj -136 0 obj -</K[48 49]/Pg 199 0 R>> -endobj -137 0 obj -<>]/K[50]/Pg 199 0 R>> -endobj -138 0 obj -</K[51 52]/Pg 199 0 R>> -endobj -139 0 obj -<>]/K[53]/Pg 199 0 R>> -endobj -140 0 obj -</K[54 55]/Pg 199 0 R>> -endobj -141 0 obj -<>]/K[56]/Pg 199 0 R>> -endobj -142 0 obj -<> -endobj -143 0 obj -[/ICCBased 209 0 R] -endobj -144 0 obj -[197 0 R/XYZ 62.362206 702.52026 0] -endobj -145 0 obj -[197 0 R/XYZ 62.362206 650.6449 0] -endobj -146 0 obj -[197 0 R/XYZ 62.362206 598.7695 0] -endobj -147 0 obj -[197 0 R/XYZ 62.362206 499.33868 0] -endobj -148 0 obj -[197 0 R/XYZ 62.362206 453.54248 0] -endobj -149 0 obj -[197 0 R/XYZ 62.362206 407.74628 0] -endobj -150 0 obj -[197 0 R/XYZ 62.362206 361.95007 0] -endobj -151 0 obj -[197 0 R/XYZ 62.362206 316.15387 0] -endobj -152 0 obj -[197 0 R/XYZ 62.362206 270.35767 0] -endobj -153 0 obj -[197 0 R/XYZ 62.362206 224.56146 0] -endobj -154 0 obj -[197 0 R/XYZ 62.362206 178.76526 0] -endobj -155 0 obj -[197 0 R/XYZ 62.362206 132.96906 0] -endobj -156 0 obj -[198 0 R/XYZ 62.362206 783.8583 0] -endobj -157 0 obj -[198 0 R/XYZ 62.362206 692.26587 0] -endobj -158 0 obj -[198 0 R/XYZ 62.362206 648.2289 0] -endobj -159 0 obj -[198 0 R/XYZ 62.362206 604.1919 0] -endobj -160 0 obj -[198 0 R/XYZ 62.362206 560.1549 0] -endobj -161 0 obj -[198 0 R/XYZ 62.362206 516.1179 0] -endobj -162 0 obj -[198 0 R/XYZ 62.362206 472.08087 0] -endobj -163 0 obj -[198 0 R/XYZ 62.362206 384.00687 0] -endobj -164 0 obj -[198 0 R/XYZ 62.362206 339.96988 0] -endobj -165 0 obj -[198 0 R/XYZ 62.362206 295.93286 0] -endobj -166 0 obj -[198 0 R/XYZ 62.362206 251.89587 0] -endobj -167 0 obj -[198 0 R/XYZ 62.362206 207.85889 0] -endobj -168 0 obj -[198 0 R/XYZ 62.362206 163.8219 0] -endobj -169 0 obj -[198 0 R/XYZ 62.362206 119.78485 0] -endobj -170 0 obj -[199 0 R/XYZ 62.362206 783.8583 0] -endobj -171 0 obj -[199 0 R/XYZ 62.362206 739.8213 0] -endobj -172 0 obj -[199 0 R/XYZ 62.362206 651.74725 0] -endobj -173 0 obj -[199 0 R/XYZ 62.362206 607.71027 0] -endobj -174 0 obj -[199 0 R/XYZ 62.362206 563.6733 0] -endobj -175 0 obj -[199 0 R/XYZ 62.362206 519.6362 0] -endobj -176 0 obj -[199 0 R/XYZ 62.362206 475.59927 0] -endobj -177 0 obj -[199 0 R/XYZ 62.362206 431.5623 0] -endobj -178 0 obj -[199 0 R/XYZ 62.362206 387.52527 0] -endobj -179 0 obj -[199 0 R/XYZ 62.362206 343.48828 0] -endobj -180 0 obj -[199 0 R/XYZ 62.362206 255.41425 0] -endobj -181 0 obj -[199 0 R/XYZ 62.362206 211.37726 0] -endobj -182 0 obj -[199 0 R/XYZ 62.362206 167.34027 0] -endobj -183 0 obj -[199 0 R/XYZ 62.362206 299.4513 0] -endobj -184 0 obj -[199 0 R/XYZ 62.362206 695.7843 0] -endobj -185 0 obj -[198 0 R/XYZ 62.362206 428.04388 0] -endobj -186 0 obj -[198 0 R/XYZ 62.362206 738.0621 0] -endobj -187 0 obj -[197 0 R/XYZ 62.362206 546.89404 0] -endobj -188 0 obj -<>/Font<>>> -endobj -189 0 obj -<>/Font<>>> -endobj -190 0 obj -<>/Font<>>> -endobj -191 0 obj -<> -endobj -192 0 obj -<>/FontDescriptor 193 0 R/DW 0/W[0 0 1000 1 1 625 2 2 757 3 3 370 4 5 590 6 6 325 7 41 1000 42 42 325 43 43 590 44 50 1000 51 51 590 52 59 1000 60 60 590 61 63 1000 64 64 590 65 67 1000 68 68 590 69 69 624 70 70 421 71 71 581 72 72 372 73 73 591 74 74 641 75 77 1000 78 78 590 79 83 1000 84 84 510.99997 85 87 1000 88 88 590 89 89 578 90 90 568 91 91 1000 92 92 590 93 97 1000 98 98 589 99 115 1000 116 116 714 117 117 637 118 118 644 119 119 436 120 120 581 121 122 1000 123 123 717 124 124 304 125 125 315 126 126 527 127 127 626 128 128 644 129 129 585 130 130 863 131 131 604 132 132 495 133 137 1000 138 138 580 139 139 597 140 140 1000 141 141 613 142 149 1000 150 150 642 151 151 227 152 155 1000 156 156 964 157 157 1000 158 158 615 159 159 626 160 160 576 161 162 1000 163 163 640 164 164 686 165 165 574 166 186 1000]>> -endobj -193 0 obj -<> -endobj -194 0 obj -<> -endobj -195 0 obj -<>/FontDescriptor 196 0 R/DW 0/W[0 10 1000 11 11 473 12 12 547 13 13 566 14 14 974 15 15 661 16 16 367 17 17 538 18 18 332 19 19 596 20 20 334 21 21 557 22 22 564 23 23 638 24 24 471 25 25 256 26 26 352 27 27 557 28 28 558]>> -endobj -196 0 obj -<> -endobj -197 0 obj -<> -endobj -198 0 obj -<> -endobj -199 0 obj -<> -endobj -200 0 obj -<> -stream -x+x* -endstream -endobj -201 0 obj -<> -stream -xmˎ9 E -@ȴJo"uUc ]6XK'AER<$Lo;V_zz=vחy|inx:Ee:ͷ=RsP|9zǤy/J]̧Wj?Pp՝PwO|QOe:':"{y<r<ŮzJ)ue~ޟ/W{ +N|/-_T#/ -~8w~8L7C4<ϫZoզֶNk|iw[ڻ-[-`Y˱e;X|`Xq6)9%m&``f4BVݪM IjFa tNH; -e:Ⳅv[#>%|m.DXka1_B|nLF|I:7 >N ňE|>&|F ">7jDm/ 6L/k皅*B4_ }U E;H,ħ[5/|cC u`/, --Ӣ&,Gv-7J&,g^,񅘡eEb!ؤB,e/[g$ 5qHl<8ϬqࡃCE:CEhOii-U_#_ yqkǩcb'+_HW=F-߁/"|kB>Ig>=$K$;7d0p}@|I7(x~ڊ8!˘Of)>7&[ܘ$cnF@ ɃiD:9<[F)šFc" P#V-!(܈R`(܈";_]Dj#7f/> -stream -xtXɻ>0Lw0ʴ `9GT0!I@rNP"i9'05iu95=~a=ϭꪯTWW}~oI $ (0,pk@yC݆L so-nI*DeNJ7fWYXdFzzOMqHq}Xť5=p\w0ﰨAQ!ޞ^aìmF f= ‚ i.r :ʲޡa!!,gzyX pǐk]B!nK\w@ mH`P`Hw`DI:4%1ύw9Shl$~@f%1F?^}ZXhlKJƦ0.I9i,:Ayq#i&ZFГHqqz??Y߿oG[?iKO-z3HCg9&'%%ϫzzzy3W!OqvBT/- --.V=kCvzazzz$}$^rp{L*&\GCaѹA~͸Q޽NKL妮e|K]P%krQYfI&wn:vѽczV:ǼOxWyAj7x!UVN`}avxG:qQ]G=wLإz1^&NaҙL2M2-zz3;kK5s57fyjA҂ p¤-ھbs[RaeW,BUWXPFFp:u6r]\\K_ wי{y7o> -:2..6̌Oh?<_uBm m [,MqLۉ:rhlZ=!6k=D1sN؆NlF)6wA :?ɉ-tFs9ϻ?nn6Q3U3r:د(G¯T'$X +ƤJH¬_~J7:O'D]g]vO`0\K3*MebAN}vl/ys9M;a&A- ΜuoX}}j]'SuGΨ :*y.L8L(jNCݣ ?[%&-Gkk;:Q?ԔW+,)|dљ W7XyoCpS u>+i|Hn4zϗS`4,әA 3b/"j朦|nQ|'w;~3$ VЃRqnBV &,YQdD}v6.wRrx)~&{CƴmlsAbpA/0{`Rc\{M{Rp6v(MYϮޅo[4[H 60'n!&ۘhMv$sI{YλN<\rOҊ/#ەTdNTttK,϶B*~M(yfW%9)r]gҲ4hqe t@?v`t>1}FSb\d>Cz 'b:JD:]BT 7HqsȕvEIxc?*1&3Ρ@"Imadw:;>ˉ5-I)m:A9b.9#!\7n|<_3F}-H0z)d|X!O?T0VI4,CC#S'Kryq8b6Br9S]eg.qe6ѷ8ni8i|uO]>?켔XaqJQ)*.Ӓ̩I?֘r#tEM#:cUAX}F't@'>ھB}*=ʼn]b|'z,mmʵ|QTN:frfb.L̛> -!\olR(F.N^}L~NDAoAՀ|l.#Xdw$;;9= 3^604xqAшk9Rr[k!Cp;V,0iN9\T' Oq~2}l -hv f3%ޣ?HG~T/X:w`75b/|՛Cݨ S4]nFg!Dk ?iy=yq$3")I=f`xAkA3AJI -}ٗ?FVzbG)0,u=& ?cFJף#/j -]_aͪ1n&Mb~VVb|>;8ud12% y2J8z^N4MZfG/X2񄹪I*`ۿ*;([Dbay_ &^{mU jjeׅWeŔ -c&o\1nRTrCWIaZA߇+++zܑ,ۋU?|?Y/G^b&M FtBRL;NUO~z2'/lv 4 q B`i-vb&g|V> -zb煁y` B/NJC[d|G,2k3J+ G8P8I.\c6QGj_nPFOwYNȚ -@.!k_mڴ="+y:/!ٻ%1xoŞ蒍wܮ$s<FPoT$CdSpGȺ_P@'^ FBZ MZ~>xUNwpa友/'LQKk4~|xkCcʀ?pJ yH J㣦th^}Ա_oPO,V[w/Sexj7V]Zu\\ %BY9Y_({kϊ_`e0aJG CId-f94g]3/o9 [? -½g eعO^o| -jeÅrc[[2=d -,XzQ׮!?׊d-p 67p]JPIG58Ƣ[˱ٟ9%ۂ7olٲ3";y:#[sbSr]yr -]>Ea|̇k -QSa -WܛY7e`1zh4a[BI25|7[3J~48BJtB*+^(%b VZp(z5}P{uHo2iE&Dm]3PD׹:WFnW?Q0Ǡ Ş-ma# 651۬ 1,]38u>>y'߆=3MPi@ɶ"PB˟|5\&00| -#A qǒƒ&!c*x/7t@a(-gb6bW'NO_ǏsrWV:Psf8] #jU_c^-t`x "ILG1494{ -Qd'Boz -LoAFtʄ'=ԉ>8tSM HCz"34tl yLеA+ dz`s0),-`HV CUޯI0,o򎜟k۞J5mmz *S5ZYQ/M7Ə'19%B_ -ŹgW62͜E}rC`V!ap?eU[@3"3y:u_._ngYibGx|JW6]fyͱAu~]KS^SAC+;L[h[ljL[JMɸʨbJzPWQc9rf5펛C=1A!!)ܹ-`TZedrr__vn` CwHIOŃbT`]?]4fJ,2å`΃[ -k.Ne7u $=G}an@0uH;:h30W/7{I yD78g}8{䷌߹dn/d^0Ӿ%7a2̀[tD_3h`HrANjV"϶uV0=#~GٶHAԉt:VvƳ+Tp v)P$'i U^dWZgUXϮ -ͬrvKk86Aח;hRʏo֬管G -am8x!;&nn9,VHvΝ[U[#",RBS%sGʓ -quYq뷪2Brчt{?J7ꋸeWdQ/F2e9QrY6A=ȶͣ;mB V9+f-o-7'ܯ-)ҷf -1Ʒ` {RO2ߋR遧)Fxyy;z>5;%'%0fF8ߘBn뀟?..r{o] 9o&u8)/St`?!2rp( 30̗MG/?fFi lLk0H>—p2m2R:nr乵X|yq۷oTmLJޕ'jbr W9Tr;űaK_r,gHO0 -FTY| ؐK08 e\ #R0h #!Kz ֤z`XZ%[#6 +HemP17?/?(ӥakT@U[? W  cN1%K? 9w4bx@u&$Gd889ǟ'՗T,x$wwzVWyT6>辠Q3r|/-0IAAP@ -%^|Dxˮ?|YI*97a2;70SUZMP}0-gc. 5C7euf߃)d yC=46Q-KC`*?ퟷ؃ 6C?0 '?Nr&P?o.j xYͫlAXkJ0lbQ&;Js")"C;MMz b'(䴙x6': -#&"1vúX;*0FZ>*C` R;VI?ɑԣɼҘ3y_Q9w0@>N:[Laiǡ:@{yg:8ӿ$*b~bV&"ʷ??}СsaR 4ߐ)0}:MgpgfNo#ju -SdOy])n)Y̋`83ca} -QJޏj9/v^(\UZV17正FST  wX_;]'$NCE܊N4Z=C an3;},od 5GXܬwF,hZggt -(!9wo-_:< wrP\^8sYN!.cf[and*+%- &O ƺEGnؤJr]߷d&PšD -wGL_-ۜeOYM_q`c~2uz윅nSOs̎tJ ȉ mVXQZ}4?Ƕu[L *?b&¡.DPe7,>3dTئdeefs7ٜ*Mef%7`x#h Mj{L{3 -q90 i V_k])q -vH,e4Ȇ%@ZML]eS/OZ^r$gGUW]a>d!5_##(.;Ye(T#5zs[~ll -眙tl1M_PQx֦ͯy ?yZ3:&r5IeTN Dp0;E{JR7h C})E8~+X8viG8#OSp)Y=ta\%lߐڰ,i:-8m?wlrz6pisт%S *uJFׯ7?N?MBa&L7z˜pГFRO!2AgJK0]'@+;vuE< ª4;OsSφx2j ɴ:_}կ 'GgFg(Yo0QCg s(pI эcs>2 mټeG>>I{/9Ӓɝ^& c+8BK -ۻ?Se5]x`~Ok /ֲqv$ uN\7eFg,+,ܭRurK*/)!`1 -F Z+A@ao%k-#ڲL#m E4V E^FtCtAc]\KW+ЌCm|d\\')ZJ^:7b^L٦dH]FŘ❐~`Cm ޖe700մF)>\ H1͏-]l۹v.ON*#YW4ʸK54lNMHuIJL_7km٥PWf*=mMԕS ;bWwg=IOy\REDGDlK ĕ/Tϭ\5wغ1ns9Dp#VW_>-ݳ23w_uQ%hPAK"N KFP{RRشi~Hy} \66YN㝱r;/$USRPUGNr٬<"tb/4V`1 $i#:R-d?U'rk.` n#z00E}^)Ǻ:Q3/Y r֟łYE)g)}S|4ʨI_5s5PAF*52Lϡ L G濎:9y99En/vM.ƶvtr-i_ZNa:R\ɔ\ّf`:(r׀ί=JƉ9>@ЗβN>aTv -j~ ֟ZgR | ]d툁) -+ES#orR/C_4}m~i'F ]\oԑ؟M&k_aޒ]h7os+dde?_Y'~-}%Je+zyVƣ*3[JI#zbgN 2$ ,4VLa#ki;S3W>^U^R7¬Ϯ00 RTfn -ә[7Y0yZmӉoG?ppl=Лe>$=~Sm| -9oMlȴC#â㸧7 -2K9Iyr.V uY8'beJJkmwy`4]6"Z*sI -tω9x%zcj̹9vNs׭^|nBÙEͩNw%ZArOdd?U씭gjվ.~'V?]C!¾}W=dlYﰹ`sֲ)sX'}=g\svBDAz%ujw˩0t&>1) <5=^3R_> , N_}FwtFF?)JcXk޷;x[l: 62䢓Β$uYCO:az Cdv9B>|?Ы8*ojJTXQeLE_C ,&R:-u -,5aTnOؐ]Efw Ju~jjޒ7*h9~zXfiesQ<͒Uz^ӻ(f*#wS[ Gg5[iN=H=Hoty9/FsH?*=cg/~iK8}cPկhBYVX3 -QKX܆%WN $NN!36P8800`+1T>%/em!Cq'CȿC'0uC3i>N%A #,-D,#(`構KZ&̭?ZcFØg%$mBKf`Lٖod-C0F$(q^-\B`$9sD\Xo0ÆݦNkZƂWYi - (TD<ڂPw2JXII\MSnŕ#nm,[,b%_~}s;HB@TA I/OAOCV&ZHw,wl7?roX cq(|Lh3fkʴ^22jj8o3\|ۆ/]]Vɯ $ѹ._ ,>Lq aYZj@\/q3W҅PMX}xQn&pd%ا,yC ʔŔŖo(8R;3cfJccnF/QzX0~ K7}dlɏ-%c?}mt?lmF0[֥ep4dŎdƺvM<R+'ͮ&')NkUI^Ѿflffl&p}&2;*7ӱL&;+3ke]e]T`L:.:K=h C)C%?caWcL &l/Ԩ:m~ͨ8B5l=[)9^|@_)jbuCɶme*|Rc4hVI.:3|x,ޣ#hwc֛gիBֺ9Y^|=ia=dEfԙd4BNبH¯OVND`nїo*m:[mmY٩y,Z]v)gE<к=8{ _ӷ)ӯaG{".te0X޾uBG,WהUO䞑@&as]_^^qf~jTn͍-)x%QwJcEBvˋ)!%5y 1hmOǔs6FrPtfff#=M:V$ =źAl1 vIλ3I<&L)>o.b\YszϷ_)0&`r% Jn+"A\Nm; ĥ^S[ݧ6e>m'/p0+⛻aC3s}u]t\L[a 4h -` ky -p S ,]17JƧf+*aؗNOl кX  -\OHۤטyM5 c.99i&;4'(-]S,h?]` cKtX㲋j1{ǪZ.2ԕ!xbòkU ,t璅͉2Tfq0lIe?V,vv\⟺#a]s+oֶM2@%_ -・7 -dZCcS-!T(6ng}y︞XyOҊ#9C!+#:\7Saw='n(ZS1Ln ?"ҝ &@ -p3 `Ϋ:. '7Rr6L 5)5'+onlWκ!S١9)art*xïfkzIeK~ Z8kwN?<}EQ --W=,<}&w ƢQ@wxPw|Yϯ-. l y˧ lQFN~T?f "Kӣ - K8pta Fq1Lb@j]O2{Җvrex*Jn&:,0y7A[em [N9L$pSLLw[41s i?re~L3!J Ӱ'iKz*=Dv_78G/CóO?<}DiE'QAwl,Hzau9 #/*qY50սN h4Þo4D6 DvCSA.I ΈR{V'PA餌YE`0L|6BG9z]].g._˽…@ Vh c]QKk9>{(4 w~y5)b(0?*42i 2 9| i rI|og) -R_pR7SagN%zf,M՝W;$ 02IL#ERN⡀d@I;NR(I;Np^cHl)Y9KT;Yq?g)cLLH%@,8+8DЧLdt:qDn`c^iz0/,Ox:Y_yS'WyZnnkmFy~o,:kH {c j1>^\N8~囅?)0n -8(0 ^aLӬ+rt.A0xђ޹[CRnv;oʇvC t/V^ӏAn{wGh,76`y)^Mܫӱ`Dk̩|Ewo `@*=)͇|VLrjR48A4:k0 -<إ+WZZX3Z>~}?Ox^[܎9m͍"Ga]|eH[)^⇄_5lu6%FZO޻HKU{w(.(z]I7s̕i.,/ ɋJ^Rw>_(;Ce08 -޿.3\-ݑg Xy@Mg22;ldd2fGJ 2ˎN3<&GgYB\79'%\sZveT22DXv);!,;:? -F;nHKS@[ ą0`B/4'CcWGN7h˕$+gƵD:d:R皸Mᕾ|wum.gy+m8#*ZMjc`u]2=ǯdyO{Es:+t""4+UTe\ Y^)b5+Ǭϯ~ -Rt5dXCEk(% ql5nCYp)F)he<"r^ -V1UZ̦F8z&8`THr^W$7+(oIYw/]:rJ2"AFs81e34d#+U!Qs{1Z 9즲تbNէJӐ W;G#΅ -7dyDZ:h#G/0ޅm n6cV>[}zwÿ(AC#2D|'h.,Y}[b 9;Tq5kq*c2װC[ XBJp7;Co2,?aT`Bg&4}߈iI1V/O -^Uܫfoi;{F  @,zW"My=z}8hB ۢ"$E[xl[~>/~_}Bؖ?j_ 4*/zFefp?ۤ;^Cy\ fz 1 ŚHЇρ79 -\k| -q2,zSE$caoCe0OK 栒O z`@n`%ǵ_Ǒ&euV!J_2c& 7lH [r;nlszocE0.D +cW)B7xx%L<Y )b@z-[3eK!^ -$^Vʕ1֡92>!?3ś Pm6zJ6̍T$ad_EX_Ǒ_dEr ésrMFh ' WC;@θn7x geĵlop1-dIZR%[@V]t` 7i8G/&Y,޴\,U zJep $n C]YO\vq͹Uk[tֶ z0ЛLOE,ekFE1ǝ0뽺¥rL>nA#`gί>Jc3n,ms32vi'N:'7r4:2!L,dImZXJ(]a=SPxQQdk(tUIhnsØs$9Tnbs$A9bo9*/?w.]޵"r&@ ,=yX; 1@Y'7JSCR“(4o?:P3K*zLMɰMCuQ|ԣ`_KuWӢr$s\/}))OX%զTQ0Uy) -/5ib`_8y la=&3/}{͠gBZrlݼ(-*i-k`)#p><;6~֝kF+{C2|nuwQ0ppqgyQ ]_d1џFԸg̐ M0>.39N=NHe)%W2_CٌS쩀uez7!P+nzįusτ=~#g_Y$i!kR!3mҭOztكAQE:²e-p1:t@Z">Z`#\S18Hę+­f27о돔$,781dvTZBuN;(5ѭ\5SeϺȂkZ+/ߦډ8i9N4+7`sLdYhG?,D" Bg7*p`DG5Ko 'Oe'WKa3h J֘ڔtR82P#G-=- i-uKl -fU$ Ӛ j A<|pP ,ɓ'jk)̇aLjY ZAU/ӭ(켃,f|"Rks6!u= -Ti&yΖ]u&>" &rzF)mQ#>h'=cq6]Mqg"p̀,&{lpit7NIO){08M]XC@wLd\Z Ik:Z+/OĆ:994d|۬{&yYkzefg.ڸm7t*ɐPeljgb8(lEۥkVf[P̀.|(0"H@eԷ.q&'15YA#k(YCNxFq8 Z0eHH؈p("@gv w>HL#䡟(#ގ-DO^"A; I)"M<-C}8 ":{n!hEh @?dL?ªE ğwv!Q=<ǔsr duuڠ| EƆ"Ŏ y?n&gxv OK5eɒgҗNp70iƖEQt>Op p%Z E3hr[`ULU,K -&\EVPv&Ƹ^&^z4_C]6ZH+ -quxWM)F5edYKNo[Ⰻĥf[mz@[_oo- x=Xl[amqH}gz)8<2v_+Qd3:0AͼG G{Z`T* ~͟XS̏{P{z{O8FzGIw" L 5y^Omn1TGeM TF/{$4^#ᘉDꞵ3uQ(K)чH2]s}]]vm=QKOU; -znF˚v ql|0=^\%yVj498`D}(ӹ +p.L}4p5VQ\jjfnV~N"5%=5xF0y'OeJPc\.Q!Wn"8-F&Bw`[%7 zQ"/~#5o0_PUpMTv8_zc;cQ?E+-67 }ذaX+%"ОșN3L"^ hJ+fZH:nM`6=0 TVDK0qs 7-F0?lLȀFp F(-+:%!HPBWQ8Gsg%]N;_#,&@̝w;(z!zqDdWtgdz(bq"FՋ%v<ص$BO& -0x~!#~&8'"\j:10 &>zՅ9Krjjʖug<-l"Tiвw"w0O&eL-- {)r -ټ GH<W'P+#+Kjp}SC32#|O}QCחEE9+R7[ٿˋHOOuyr>O*([%*om$dvruu-v)-`X8y [$\\VH)0$ʹAL`Ha 2 k2 ܃#pߓ`RP ð| a3"P]P]Pa O' (Т?s1QQDp$FERaK2-C:p -!Qklfɮ ]OJ_RJ pEۭl|7J"!@  -_j: 5H '_:`zX$\u3$8KsK3=y9D*%wC, T a I v&ݹ` @$+s˲Mv -p$I\gqCR?WQ]k΍^6[ ۤW( S"%/%?)wI ,,@o@AXQxuM -tɰΰIŠV[O._~yù7e`@>!;1'Qs3)yTq77:B5HfQ56EfQ8DA,N=N- .}􂺄V}`Io"-pHqoqoZ~<3WyF8̮>V B=w2rR03ȣGK -h #q:,ljʩ$#ɼv}X6ɠLinL63=(0Jj,΀"֡6s 9ف U*?Ε_{Mwv,JU(Zqo)gaiXH`;.hK7"c`'hh :[3RR%q -Q%2 }v;PAK_ujY7WxIHV"۳842ЧGL&w1qi&{GS <"M9Yb"?%q,ʎ?zhˁg_@0}[v`A(sœHc$8ސJ3fq%2s/"S0+\p -BR}Nzל;U>G -'tIiQe4٥Ni/J<<[ExPA0Ƒ IuihOG3 #/Wpď|_4?o~ܧWp֠BPa.V6U]o;0Fezp@1rp LSh#= (M -\A ͈ڣu5` 煷(JP/:0`*P_z@2' -6 |)X/P=X U?I탵Шlz'<)+ Dp <0eNLtмWJg3N&)4jDm$ƽhB5&ʊ%%3$T aMCͦޡZ :t?&㯸ܴث6ӽ44TF!) !Cc+A~Ń@AI߻JfTT85t?}B;{=Ґk ?Owo v 8=HdL/|ukxݴB -T -}<#2|39046*brR3SXf /uiq/^4㌋xbaG^~=<^Gie.ezl}ҽP22b&W\T@O(+80V#`,=xZAc퓮':ð(r /hӑ'Wpz{zjnF*#N5o"g i0(AԒ94t>ԝ*Iuzj9}x7H/Mq1o?/b8)Zj: :v3eZV? -3cE'#oej{yX04,h/(xOs+N4'6S"SPʥmЂDЄcI-4t;>zUuRT Rق5IHA{r/{wOa/jBɼ6WOp8uhF*4ڎ[*-1BerS u>7s>:neL.lYӕg?woŵlqWfΆv&,+0&UʛE!;ؠ4t[ؐ8!5ce CzZ/CE*L})j BXm On+;{P!=j\s>zUʥB3d2uEL62˿4m˽ ݆3++w^xDїZ> RW@DJgre,f};aSJ45v}PЫb]v+[Ҽ3YT %KS̫E;AxvPo}HnƑyLZ.-rQݚ~!Ϟ*Ƅt}$de59[,{]/jj-_{FvF?|5%!m[v'F)#I۠hq)o -7+n1xهg2DxfPH -`>, an6._>a{pe%&/!j*J :L?#?/F}vER5&vf?֨?L0gNrhfwk5 ay*\zx:{;= Kg:vyTeYY>9~y/Ozq3uw7MSVl-qJ68VˉԬ, -spdUTI#w 1Z$ +~-_py$wʋ/AE32x=u[zFbE9`p,HyfQy)TY^zU?>\|Y`X?8Fm #0V4#3T~_S_U0p?W4ջYYؖ6HШ1pFAi\lF -vhXb1 7 ߭K Z,/<4 8U),Vy}6g0\ \19^_q yf+[S 07ZLgOM sM cRD G -Am@[xGyoԑ?E‹QJoM0#IB}.'q1޾D'vD:#BVLz@xǠ4y%@@q -dPekAyARo-2HQ:7D*CQzG>URm joU?lҷs֦iΝ[6UgC)g̫&`%N&E2'>H؋/@g$ąG~A*҃tҸF -tџ*s>߅V`jI uFt́T l,iAdXh B!]Wf t@~ "Ф{ya8px*=E7)/@paRt {R<v\o~%-l.[^ܾ{itayw0[ -s.Y\Vc+s+١LQFK_b1g. ߡ'@0cuֲ55{Yk*<)4q,&Ggxrh a`, -.LD ޴zy+X^/߾\x鋃PaGȋͽ>J 1gc{EPn@e}vh{S+(}V|_0iK+`=mdH#P)q*vЋ9[T cPg]H`鷉_9Ёݠv4`U5OK 2O/6#/齉br.=iZ6G?C-shg}̸߷H y= -r^o8F&,[vU[-+L> -ja4hC5Cg!lp&y .."&ᘝ8=;uxf\zSD䷂ߎmgQOTn}fO_nguEEH:}9&أt Tdi;iZw$#=BIF%qEeWo:7@`8mJTtP ƯNz\kzڅz~Xf%3>0>)e䋺]W^d}cJpp - t9&^nfOrZVm\7q{Egtek|$GTA.,(M\ȍsڨ'|(A4І&!ROK 'Ul- 8{fjh*_:AP Re֧QkGd,9m:fm^͙>6DP~R23@ DAҍiն4{tFwSha'#71^Bmh& qVBh sվ;Y˒Y;(Te.2{Ɲe_of7qg^s! Å ,}Ҏka\Ax5ru9  C~}4@Ar ,$B'qZa{w2[ws(LM(]f/͖ l~y6y5iOwv^oz%-j*YUƅ0RS @"3+QT3C^q`[U2C dxllxE13qWUuxF4#S_@b*F9")CqL -{Xr -UN+=g -W֙SxG§T]a&H;,  %Mzu8=ts=mγ}4fټՁ_:y>K|TaG\qi\9>w ;\yj o51ihQh衠k?һPz0q&n$`{8tLcԞOj6g4Z!k3묤yy%2 ׹S/=^dGaHEF=<24p#!׵g]Q<?u` @kD0ޡ0r9E0|2n%/ߒp\iuzzhF>#GN*їfU$w$)3+1tn%G̎t8c - 녛@'Ga<9 y ^K2ͤ\bK%-+g/XӀ~남ȊEFv6._t$$N;l}|<>(CF ICiG`7`r_F m@H*Џ+IѻOgoިY*PˉA[Y*0 d␵+wPjZ> -stream -xuWyXijwgM㨨xxxɍ( !W r9C8TTFg#|bj#~}Jxe<{,]Nbu^+;%QU/3͑WboAc,]Y}0:UϪ*˦ю&AYYXX.PфF[8TV)WhdR*}H!K$Y@%An\CLGA -Y RҨITr<+`EߞUiWD<;a atQu|>A(*}ri KXVeꥂ=p+kT{ 6`+0@(`hVhu1)lXO= h{E]d,΃63 רLڧvSTbQ=N(f{:3GPfݵ)Z>z>AN}w&bz4ef)R9y\:nىGH3r5\Ƿ>?˂BȏI{_w0-Wb3ȳߖį5mپZ\<}c󒓋u~qOv]zs岢.m{tEyɳlY͢ѯwR5gOB|4JDDNQHi26h>Z'a-ò([梠7Q()r;_S %:~ >Nʃ#CE K.a >70÷׻aDӮ,ū'H$~R)}(LצjΒP:&_X]P\AO/:N`)eML6|vP;Z*jT||cE7Y_LF,*!1eݬ$| ٙ`r0P"^^Tү!\MTei@ͪL@KTCg8(TPej+>oCB*C;C'Cp5Jl*Uxիj4IP -c8C!ODcrY6M"oDي7Jmk0ΪV`e k^o`DH[YO@6^J- drFc}( #ɷ#JNqG"wJ⣎7 -Іkt>#،!g<=jF{i"73=iy"K00dž|>:y:?dRn' =s1G]U轉l<(+ d^2|44t_ It#/E1(c(CgmSu#'ۺDŽ -:Kη ,cٝR4|4MN#{Dļx6y\G:խXB<*>vI#@ -QFeO2ff*Wu}}ixMM I6X~=kWlwveVvU_s7,l . -c,23oHdM(1H -wu` &8DoB.+x7g̪] ffP -ܿsrTVUeJ n;rLNBb -wa`#Vm˚\L@y> ~NU*Q=CBp7K6'Q&0|0²Gچ -.Dw1]VZS;>fO.Gs"  >y鏡줔4bEB6p(N\ 'V=>C]"y}[MqVg u<cX -3I+'CCK)Kkcdwܪ0Dᗊ{bUq5/SЌ,>08c( $+0a ❐Y}IZ/.!kM^2pq xہ/Ff#ƲQjP˦_w8^X +Oc{G+ ?>38y_Qfd< j8X0hAKѸ8>qVa-|UxuNN3[`U zF*눅%ffȱ 9&G1epdTzz~TؐV +̖n(j4vaLcm3vihX6T{Q5b`hvRrP#66ytg4. ؈:,G20x) xW_$ksW )v=~wMV0ZI}n@fPr&İN*qbR&+G=w#s("C"P "$6("@p*`56ݳd͔xo*`b_z,NЮǰAuE*}a]=V>a7Bdv| OQIhir4O^4 -'r[ -A> Z )sO'6#I}ؐ4%7B Hn k/C}yߑ|#Jܩ{>Wg;eusJQ0&4ՅD02#{]a5F>tA1ϥ.[X4yjvCEͱ"7>``i X¦޼}trF[P^IYB7mR -˄0VgJDvvcAyC5́ҪAjccJX6I> 8Bc>HNIxǗ)`+8AI@ffr{ -7rɻbd$N0E2f/p X0/;%Gtv\,iUΤoT=0Xz~NtZ&ᕚӌvҝE `S.b d{C [^DM#;%^ ^#gfw{ia{ DrYmu\= Siq>(^~0M%s`d55#{ߣR/P`v3;q md:ZԻk=Mt۱5b +.JI(eb"$~|p9B$Bc-=Z0K*f2R ~^*O*ˋCJ_QE|?O`1rX!s|tDzcKc2l&/HsJ29}03 h[D!= ٣3 to? d ZU_a׍7 =%kaxX 儱!d`-V9X1{\l06,y9Pɽďmfw,UgН8NQ1yXt̑!4H ||HQDv^\4_s2λKeG'pړ+#>\mdÂn*"#6+~d~Q^Bgߪ뗪KYYP$'~3/xVf'XF}v`!l|g|#_QYCTf/@74(+;(㖊4pÞVsۄdO+a쩖3yWѢUҜv9h~~Ht1KG Y0GF.X SKg" 9IgA'ЬC& -x9\VP8l/Y*'Lx99b -|tc4^\qy}?ka|aiFI?Ų/]l5[0 a .Wb paXD[L?҄1>s<;0y\s }}Z(2[')4u*L]VU*|4}ՓޱeulQ -P -endstream -endobj -206 0 obj -<> -stream -xZYsG~ׯQJ'9rFA*l#jJUvг}==3ztoot 72h\'Cց0/,fr21hLNcf|>a6ԌO Rn9[ͭ]*LjdEV}W*{ZB5ie=[ߎh]'>/VdHj;ཐ`Lі̃68sNm A3:PȘ[jG`:@Df}@-Μ>5V^N'{JT04 jN jW4AʨEZ>]g|ޑuFwV[ECw)Œ5o˶/>Z{GݫaM&g;rTy'rKhIMF@vodrםˁjwMnNn(~92kliCM| %z)>,Ba4Xs%.6\NWǀWh -C }/V{vwu2ד{Q2UrwK ]ɕ?s!F 'ו'3z.K@ĩ7յTCWmV|ʶZR@~ȝ: ]a|̷ezkDrҺo:+8`N^hr`cO+ -.dH~nyV;{yy^=/|@؞?Pp;Pװ&seZ\٬PlS To*ՔLi.l7sl9!-v]*u|g B)ܐ5)Zz˨(PjR.nkW8d.$sYxN -){%hvnj/(x`r3U,#i{\@^uK2y̯<*"pI:$xH1b-C9m(`n-ߓ56c")jS1(IGPhb+ t\K!X:`Njm>l>|V,=u lv> -stream -xko6W0-Bo5j ؚnsݤMmnn}ؒe1i Gl=~;:{d0weAf"05io }YgoA6<C$[pvYx}:sos~*bQN$)F݈XwHC`c%_vϧ'ǣ /]L>LceQIAFJ 2T`ES%6*뽸Pbp`6n,V9gqANyuqM -̟ǝ% 7eVB#&nG㢄~$[;J @fr~ &?B6E=+ULNyt2; kK.sמ=t5iwmeEdF6ayHm-KcY5 碏vi9)pD*e] 6c!o5 Zc 48 -hbP\(1( -3 ́*|cV 6ʂsfQvr:H  y%L5kND Hf?Xk "VאKxj 8Gwf~!hTm I6&ҙi^M 6e#Mσb6XmѣV>m͢m[XkA t +ThTM֜gmmFS"&*7ƝQ*t.PT[8o -J:N3g 椐9H#kL -Lb!߃_3"gIT)z"9$el[BC92خaEޠXF -wLB-Q7;{%?|̿r?Z~5_;B3v! %33d|,IC1۾(fWO^3]NC˸ϙ_>9.\"}N"6 40AX1Ծ`pf#:hٶPVC+hI1!)XoV@:!wSSΕ\@8m*l}HNI >b@y6;`,SG9XYhv:LMyhdOKzos)v 4D=6Dl[lm+Nu"{qw:_=ۛLӧMCޟLGE7?zqr6LΚ5W}W%\ -r}g&V -endstream -endobj -208 0 obj -<> -stream -x]o6+t9o/0&a6^d^vh.uOY&vD*RCCF -N Y*liL;Bs+dF|lXe,ˬS[zE^K2l2aoAջ='>9JqmFYHtk.{]#}e܍%vL -l}}^$iks34`5]) )lGhE.,(Q"3dbqaFIAYRPeWwkWeLDޠi%'Kd\)*x>?`QNz@_ "5)' IӠknH"B 9i9:eٖ3WxهMef~]πU3`YƑ'jZ(fw\ruիcq15vz3!n6HP@u Aʱej]z,h,v%0+u)vaeR^4 xiv X*4#@NU|^|@Zc y=pҽlQbU.ŭ˭V& Z®KqXY0";w9Z F`2$! 1p=4qA- @x'頪4' 简j+ҢUleUlS(/8Ej9/Ow ([_ cظR\?$r](Nqr$ ),*~c@ntg V֡Km4quYT\ΡgS=b[N̚an|8y0ppyW$Ք]1HT+g홦!! $*OGID^IԺI8m7 D& PI5:X'ZS[o?& H Җ@>ڦ|3tG2 5uF4!F!I4I*!%IćyxeD䞃C{$i]:v(P -9`L"CM1|kq~؟Gn|>{~8?޿<6Svr6j^f< *j/P) -endstream -endobj -209 0 obj -<> -stream -xuJPFOUvDD@`]\ -FMkIRB|&إnऋ(HrTų9` ըa&ʺ:l -3Ŭ*ުnh)&C|>b纝黓AvCƫ+ y') -̵8+//Author/Creator(Typst 0.15.0)>> -endobj -211 0 obj -<> -stream -TH-141:表面张力的严肃理论范式教育教研组Typst 0.15.0zh-CN3application/pdf4GcoofWhl9BWlcMUTXiEnA==CPy0Lk0NYjK7dVf4CjYIQw==proof1.7 -endstream -endobj -212 0 obj -<>/ViewerPreferences<>/Outlines 46 0 R>> -endobj -xref -0 213 -0000000000 65535 f -0000000016 00000 n -0000000085 00000 n -0000000191 00000 n -0000000316 00000 n -0000000438 00000 n -0000000548 00000 n -0000000681 00000 n -0000000802 00000 n -0000000923 00000 n -0000001053 00000 n -0000001204 00000 n -0000001344 00000 n -0000001492 00000 n -0000001616 00000 n -0000001752 00000 n -0000001876 00000 n -0000002028 00000 n -0000002172 00000 n -0000002320 00000 n -0000002480 00000 n -0000002628 00000 n -0000002764 00000 n -0000002928 00000 n -0000003080 00000 n -0000003268 00000 n -0000003432 00000 n -0000003580 00000 n -0000003732 00000 n -0000003884 00000 n -0000004048 00000 n -0000004196 00000 n -0000004360 00000 n -0000004528 00000 n -0000004748 00000 n -0000004916 00000 n -0000005084 00000 n -0000005256 00000 n -0000005444 00000 n -0000005592 00000 n -0000005776 00000 n -0000005948 00000 n -0000006114 00000 n -0000006300 00000 n -0000006451 00000 n -0000006593 00000 n -0000006726 00000 n -0000006793 00000 n -0000006978 00000 n -0000007325 00000 n -0000007771 00000 n -0000008245 00000 n -0000008314 00000 n -0000008381 00000 n -0000008448 00000 n -0000008550 00000 n -0000008653 00000 n -0000008763 00000 n -0000008866 00000 n -0000008974 00000 n -0000009078 00000 n -0000009174 00000 n -0000009278 00000 n -0000009382 00000 n -0000009486 00000 n -0000009602 00000 n -0000009706 00000 n -0000009810 00000 n -0000009914 00000 n -0000010018 00000 n -0000010122 00000 n -0000010234 00000 n -0000010338 00000 n -0000010470 00000 n -0000010574 00000 n -0000010697 00000 n -0000010801 00000 n -0000010935 00000 n -0000011039 00000 n -0000011146 00000 n -0000011250 00000 n -0000011362 00000 n -0000011465 00000 n -0000011559 00000 n -0000011662 00000 n -0000011764 00000 n -0000011867 00000 n -0000011996 00000 n -0000012100 00000 n -0000012212 00000 n -0000012316 00000 n -0000012432 00000 n -0000012536 00000 n -0000012664 00000 n -0000012768 00000 n -0000012890 00000 n -0000012994 00000 n -0000013090 00000 n -0000013194 00000 n -0000013298 00000 n -0000013402 00000 n -0000013523 00000 n -0000013628 00000 n -0000013740 00000 n -0000013845 00000 n -0000013993 00000 n -0000014098 00000 n -0000014222 00000 n -0000014327 00000 n -0000014432 00000 n -0000014537 00000 n -0000014649 00000 n -0000014754 00000 n -0000014863 00000 n -0000014967 00000 n -0000015086 00000 n -0000015190 00000 n -0000015310 00000 n -0000015415 00000 n -0000015526 00000 n -0000015631 00000 n -0000015746 00000 n -0000015851 00000 n -0000015967 00000 n -0000016072 00000 n -0000016240 00000 n -0000016345 00000 n -0000016458 00000 n -0000016563 00000 n -0000016682 00000 n -0000016787 00000 n -0000016907 00000 n -0000017012 00000 n -0000017145 00000 n -0000017250 00000 n -0000017339 00000 n -0000017444 00000 n -0000017541 00000 n -0000017646 00000 n -0000017767 00000 n -0000017872 00000 n -0000017993 00000 n -0000018098 00000 n -0000018838 00000 n -0000018875 00000 n -0000018928 00000 n -0000018980 00000 n -0000019032 00000 n -0000019085 00000 n -0000019138 00000 n -0000019191 00000 n -0000019244 00000 n -0000019297 00000 n -0000019350 00000 n -0000019403 00000 n -0000019456 00000 n -0000019509 00000 n -0000019561 00000 n -0000019614 00000 n -0000019666 00000 n -0000019718 00000 n -0000019770 00000 n -0000019822 00000 n -0000019875 00000 n -0000019928 00000 n -0000019981 00000 n -0000020034 00000 n -0000020087 00000 n -0000020140 00000 n -0000020192 00000 n -0000020245 00000 n -0000020297 00000 n -0000020349 00000 n -0000020402 00000 n -0000020455 00000 n -0000020507 00000 n -0000020559 00000 n -0000020612 00000 n -0000020664 00000 n -0000020717 00000 n -0000020770 00000 n -0000020823 00000 n -0000020876 00000 n -0000020929 00000 n -0000020981 00000 n -0000021033 00000 n -0000021086 00000 n -0000021138 00000 n -0000021191 00000 n -0000021303 00000 n -0000021415 00000 n -0000021527 00000 n -0000021682 00000 n -0000022661 00000 n -0000022874 00000 n -0000023033 00000 n -0000023412 00000 n -0000023627 00000 n -0000023755 00000 n -0000023883 00000 n -0000024011 00000 n -0000024093 00000 n -0000025538 00000 n -0000053322 00000 n -0000053403 00000 n -0000053997 00000 n -0000059093 00000 n -0000060552 00000 n -0000062164 00000 n -0000063824 00000 n -0000064167 00000 n -0000064328 00000 n -0000065483 00000 n -trailer -<> -startxref -65673 -%%EOF \ No newline at end of file diff --git a/render/lib.typ b/render/lib.typ index bd57176..80b4ab4 100644 --- a/render/lib.typ +++ b/render/lib.typ @@ -248,17 +248,8 @@ /// 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 != "" {