fix(render): per-level heading numbering via numbly; config-driven, offline (WU-C)

Fixes the numbering bug (single pattern "一、" reused across levels → level-2
rendered "二、一、…"). display now takes a `config` dict; heading numbering uses
numbly per level — framework default ("{1:一}、","{1:1}.{2:1}","{1:1}.{2:1}.{3:1}")
→ 一、 / 1.1 / 1.1.1 — overridable via config.numbering.heading from the
engineering file (ADR-0009's file-resident override path, the layer whose absence
caused the bug). Verified: grep "二、一" = 0 across all smoke PDFs.

Adds @preview/numbly:0.1.0 (pure typst, zero transitive deps), VENDORED in-repo
at render/vendor/typst-packages/preview/numbly/0.1.0/ for network-free CI;
resolve via --package-cache-path render/vendor/typst-packages. parts contract +
student/teacher field visibility unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-22 08:42:15 +08:00
parent b5bf9d60d1
commit 6f46aa708a
9 changed files with 187 additions and 10 deletions
+14
View File
@@ -0,0 +1,14 @@
// Smoke test — exercises the per-target presentation `config` override path
// (ADR-0009). Here the engineering-file-supplied `config.numbering.heading`
// overrides the framework-default heading numbering. The patterns happen to
// match the correct per-level scheme (level-1 `一、`, level-2 `1.1`), proving
// the override is wired AND yields correct (NOT `二、一、`) output.
#import "../lib.typ": display
#import "smoke-parts.typ": info, parts
#display(
info: info,
target: "teacher",
parts: parts,
config: (numbering: (heading: ("{1:一}、", "{1:1}.{2:1}"))),
)
+14 -1
View File
@@ -8,13 +8,26 @@
) )
#let parts = ( #let parts = (
// segment // segment — with NESTED headings so per-level numbering is visible:
// level-1 `=` should render `一、`, level-2 `==` should render `1.1`
// (NOT the old buggy `二、一、`).
( (
kind: "segment", kind: "segment",
textbook: [ textbook: [
= 平面向量的数量积
本节研究平面向量的基本运算。设 $arrow(a)$$arrow(b)$ 为平面内两个向量, 本节研究平面向量的基本运算。设 $arrow(a)$$arrow(b)$ 为平面内两个向量,
其数量积定义为 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$ 其数量积定义为 $arrow(a) dot arrow(b) = |arrow(a)| |arrow(b)| cos theta$
其中 $theta$ 为两向量的夹角。 其中 $theta$ 为两向量的夹角。
== 坐标表示
在直角坐标系下,若 $arrow(a) = (x_1, y_1)$$arrow(b) = (x_2, y_2)$,则
$arrow(a) dot arrow(b) = x_1 x_2 + y_1 y_2$
== 几何意义
数量积等于一个向量的模与另一向量在其方向上投影之积。
], ],
), ),
// example WITH source // example WITH source
+20 -3
View File
@@ -23,7 +23,7 @@
// fields). Never crashes. The "no render rule => warning" // fields). Never crashes. The "no render rule => warning"
// diagnostic is the Rust side's job, not ours. // diagnostic is the Rust side's job, not ours.
#import "src/style.typ": base-style, title-block, subtitle-block #import "src/style.typ": base-style, default-heading-numbering, title-block, subtitle-block
#import "src/elements/segment.typ": display-segment #import "src/elements/segment.typ": display-segment
#import "src/elements/example.typ": display-example #import "src/elements/example.typ": display-example
#import "src/elements/lemma.typ": display-lemma #import "src/elements/lemma.typ": display-lemma
@@ -75,17 +75,34 @@
/// - `info`: dict, e.g. (title: "…", author: "…"). `author` may be absent. /// - `info`: dict, e.g. (title: "…", author: "…"). `author` may be absent.
/// - `target`: string. MVP: "student" | "teacher". Unknown => conservative. /// - `target`: string. MVP: "student" | "teacher". Unknown => conservative.
/// - `parts`: ordered array of part dicts (see file header). /// - `parts`: ordered array of part dicts (see file header).
#let display(info: (:), target: "student", parts: ()) = { /// - `config`: dict carrying the target's build/presentation overrides
/// (ADR-0009). Open/forward-compatible — read keys with
/// `.at(.., default: ..)`. Recognised keys (MVP):
/// `config.numbering.heading`: array of per-level numbly pattern
/// strings, e.g. `("{1:一}、", "{1:1}.{2:1}")`. Optional; when
/// absent the framework default (correct per-level scheme) is
/// used. Future presentation knobs slot in without touching
/// this signature.
#let display(info: (:), target: "student", parts: (), config: (:)) = {
let title = info.at("title", default: []) let title = info.at("title", default: [])
let author = info.at("author", default: none) let author = info.at("author", default: none)
// Resolve presentation config -> framework defaults, file may override.
// `config.numbering.heading` (array of numbly patterns) overrides the
// framework default per-level numbering when present.
let numbering-cfg = config.at("numbering", default: (:))
let heading-numbering = numbering-cfg.at(
"heading",
default: default-heading-numbering,
)
// `document` author wants a string/array; normalise the optional field. // `document` author wants a string/array; normalise the optional field.
set document( set document(
title: title, title: title,
author: if author == none { () } else { author }, author: if author == none { () } else { author },
) )
show: base-style show: base-style.with(heading-numbering: heading-numbering)
// Reset shared counters so each rendered lesson numbers from 1. // Reset shared counters so each rendered lesson numbers from 1.
example-counter.update(0) example-counter.update(0)
+24 -6
View File
@@ -1,18 +1,36 @@
// Base document styling: CJK fonts, headings, math, paragraph layout, page. // Base document styling: CJK fonts, headings, math, paragraph layout, page.
// //
// Zero external dependencies. Heading numbering uses builtin `numbering` // Heading numbering is PER-LEVEL and built from a numbly pattern array passed
// strings (no @preview/numbly) so the package compiles with no network. // in by the caller (lib.typ resolves it from config + framework default). This
// fixes the old single-pattern bug where one `numbering: "一、"` was reused for
// every level (a level-2 heading rendered as `二、一、…`). The only @preview
// dependency is `@preview/numbly`, vendored under `render/vendor/...` for
// network-free CI; see render/typst.toml's note.
#import "@preview/numbly:0.1.0": numbly
#import "fonts.typ" #import "fonts.typ"
/// Apply the base style to a document body. Used as `show: base-style`. /// Framework-default per-level heading numbering patterns (numbly syntax).
#let base-style(doc) = { /// Level 1 -> `一、`, level 2 -> `1.1`, level 3 -> `1.1.1`. This is the
/// correct-by-default scheme; `config.numbering.heading` overrides it.
#let default-heading-numbering = (
"{1:一}、",
"{1:1}.{2:1}",
"{1:1}.{2:1}.{3:1}",
)
/// Apply the base style to a document body. Used as `show: base-style(..)`.
///
/// `heading-numbering` is an array of per-level numbly pattern strings; it is
/// spread into `numbly(..)` to build the per-level heading numbering function.
#let base-style(heading-numbering: default-heading-numbering, doc) = {
// Language / region drive CJK line-breaking and punctuation. // Language / region drive CJK line-breaking and punctuation.
set text(lang: "zh", region: "cn", size: 12pt, font: fonts.serif) set text(lang: "zh", region: "cn", size: 12pt, font: fonts.serif)
set text(cjk-latin-spacing: auto) set text(cjk-latin-spacing: auto)
// Heading numbering: 一、 / 1.1 / 1.1.1 — builtin numbering, no deps. // Per-level heading numbering via numbly: each level gets its own pattern,
set heading(numbering: "一、") // so level 2 renders `1.1`, not the old buggy `二、一、`.
set heading(numbering: numbly(..heading-numbering))
show heading: set text(font: fonts.sans) show heading: set text(font: fonts.sans)
// Number display equations. // Number display equations.
+10
View File
@@ -6,3 +6,13 @@ compiler = "0.15.0"
authors = ["curriculum-project-hub"] authors = ["curriculum-project-hub"]
license = "MIT" license = "MIT"
description = "Curriculum lesson render package: single `display` entry over an ordered list of typed parts (segment/example/lemma/sop), targeting student/teacher handouts." description = "Curriculum lesson render package: single `display` entry over an ordered list of typed parts (segment/example/lemma/sop), targeting student/teacher handouts."
# External @preview dependencies (resolved via the typst package registry):
# @preview/numbly:0.1.0 — per-level heading numbering (src/style.typ).
# numbly is dependency-free (pure typst). For network-free CI the package is
# VENDORED in-repo at:
# render/vendor/typst-packages/preview/numbly/0.1.0/
# mirroring the typst preview-cache layout
# ({cache}/typst/packages/preview/numbly/0.1.0/). Point typst's package cache /
# resolver at render/vendor/typst-packages (e.g. TYPST_PACKAGE_CACHE_PATH or the
# embedded World's preview dir) so `@preview/numbly:0.1.0` resolves offline.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 梦飞翔
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,43 @@
# numbly
A package that helps you to specify different numbering formats for different levels of headings.
Suppose you want to specify the following numbering format for your document:
- Appendix A. Guide
- A.1. Installation
- Step 1. Download
- Step 2. Install
- A.2. Usage
You might use `if` to achieve this:
```typst
#set heading(numbering: (..nums) => {
nums = nums.pos()
if nums.len() == 1 {
return "Appendix " + numbering("A.", ..nums)
} else if nums.len() == 2 {
return numbering("A.1.", ..nums)
} else {
return "Step " + numbering("1.", nums.last())
}
})
= Guide
== Installation
=== Download
=== Install
== Usage
```
But with `numbly`, you can do this more easily:
```typst
#import "@preview/numbly:0.1.0": numbly
#set heading(numbering: numbly(
"Appendix {1:A}.", // use {level:format} to specify the format
"{1:A}.{2}.", // if format is not specified, arabic numbers will be used
"Step {3}.", // here, we only want the 3rd level
))
```
@@ -0,0 +1,31 @@
#let numbly(..arr, default: "1.") = (..nums) => {
let arr = arr.pos()
nums = nums.pos()
if nums.len() > arr.len() {
if default == none {
return none
}
if type(default) == function {
return default(..nums)
}
return numbering(default, ..nums)
}
let format = arr.at(nums.len() - 1)
if format == none {
return none
}
if type(format) == function {
return format(..nums)
}
format.replace(
regex("\{(\d)(:(.+?))?\}"),
m => {
let (a, b, c) = m.captures
if b != none {
numbering(c, nums.at(int(a) - 1))
} else {
str(nums.at(int(a) - 1))
}
},
)
}
@@ -0,0 +1,10 @@
[package]
name = "numbly"
version = "0.1.0"
entrypoint = "lib.typ"
authors = ["flaribbit <@flaribbit>"]
license = "MIT"
description = "A package that helps you to specify different numbering formats for different levels of headings."
categories = ["utility"]
keywords = ["numbering", "helper", "tool"]
repository = "https://github.com/flaribbit/numbly"