typst 見出し番号:レベル別の書式と none の罠
Typst で見出しレベルごとに番号の付け方を変える方法を検索すると、たいていこの答えに行き当たります。numbering に関数を渡し、番号を付けたくないレベルでは none を返す、というものです。コンパイルは通り、ぱっと見は正しく動いているように見えます。
以下の各パターンを、公式の Typst CLI 0.15.1(現行リリース、2026-07-17)と 0.14.2 の両方でコンパイルし、PDF 内でテキストが実際にどこに配置されるかを測定しました。どちらのバージョンでも、すべてのケースで挙動は同じでした。none を返すやり方には、どんなエラーメッセージにも出てこない副作用が二つあります。
結論(Typst 0.15.1 と 0.14.2、2026-09-25 に検証):
| やりたいこと | 使うもの | 注意点 |
|---|---|---|
| レベル2より下の番号を止める | #show heading.where(level: 3): set heading(numbering: none) | 代わりに番号関数から none を返すと、見出しテキストの前に 0.3em の余白ができ、@references は “Section .” としてコンパイルされてしまう |
"1.1" のようなパターンで深さを制限する | そういう仕組みではない | "1.1" はレベル3の見出しに 1.1.1 を付ける。最後のカウント記号が繰り返される |
| レベルごとに違う書式(「TOP 1:」の次に「1.1」) | nums.pos().len() を調べる番号関数 | 関数が返したものはそのまま参照文や目次にも現れ、末尾のコロンまで含まれる |
| レベルごとの記号(I. / A. / 1.)で最後の数字だけ | numbering(("I.", "A.", "1.").at(n.len() - 1), n.last()) | パターン "I.A.1." では代わりにフルチェーンの I.A.1. になる |
| A から始まり直す付録番号 | #counter(heading).update(0) の後に #set heading(numbering: "A.1", supplement: [Appendix]) | 特になし、期待どおりに動いた |
テスト1:none を返すと余白ができる
よく紹介されているやり方はこうです。
#set heading(numbering: (..n) => if n.pos().len() <= 2 { numbering("1.1", ..n) })
= Intro
== Scope
=== Detail
レベル3の “Detail” には意図どおり番号が付きません。ですが、右にずれています。違いを見えるようにするため、左の余白に赤い線を引きました。

PDF から実測した値です(本文11pt、ページ余白1cmでのテキストのx座標、単位はポイント)。絶対値は余白の設定に左右されるので、値そのものではなく差を見てください。
| パターン | “Detail” の開始位置 | 本文の開始位置 |
|---|---|---|
| 番号付けなし | 28.35 | 28.35 |
レベル3で関数が none を返す | 31.65 | 28.35 |
レベル3で関数が "" を返す | 31.65 | 28.35 |
show heading.where(level: 3): set heading(numbering: none) | 28.35 | 28.35 |
差は 3.30pt で、11pt のときのちょうど 0.3em にあたります。理由はソース(crates/typst-layout/src/rules.rs、タグ v0.15.1)にあります。見出しの numbering に何か値が設定されていれば、Typst は番号を描画し、SPACING_TO_NUMBERING(Em::new(0.3))を足してから本文を続けます。関数が実際に何かを返したかどうかはチェックしていません。空文字列でも同じ挙動になります。
show-set ルールはそのレベルの numbering 自体を none に設定するので、Typst は番号付けと余白の両方をスキップします。
テスト2:それらの見出しへの参照が無言になる
こちらは、完成した PDF にそのまま紛れ込みうる問題です。同じ関数を使ったまま、ラベルを付けて参照してみました。
=== Detail <det>
See @det.

エラーも警告も出ずにコンパイルが通り、“See Section .” と出力されました。numbering が設定されているという理由だけで Typst はその見出しを「番号付き」とみなすので参照は許可され、関数がそのために返した値——つまり何もない——がそのまま表示されます。
show-set ルールを使った場合、同じ参照はビルドに失敗します。
error: cannot reference heading without numbering
= hint: you can enable heading numbering with `#set heading(numbering: "1.")`
ここではビルドエラーのほうが望ましい結果です。壊れた参照を、PDF を送った後ではなく今のうちに見つけられます。
テスト3:“1.1” は深さを制限しない
numbering: "1.1" は「二レベル分番号を付ける」という意味だと思われがちですが、そうではありません。私のレベル3見出しは 1.1.1 Detail になりました。numbering のドキュメント(2026-09-25 に確認)にはこう説明があります。「numbering がパターンで、カウント記号より多くの数字が与えられた場合、最後のカウント記号がその接頭辞ごと繰り返される」(訳は筆者)。特定の深さで止めたいなら、テスト1の show-set ルールを使ってください。
テスト4:レベルごとに違う書式
あるフォーラムの質問は、レベル1を “TOP 1: First Name”、レベル2を “1.1 first subsection” にしたいというものでした。素直な答えはこうです。
#set heading(numbering: (..n) => {
let n = n.pos()
if n.len() == 1 { "TOP " + str(n.first()) + ":" } else { numbering("1.1", ..n) }
})
見出し自体は正しく見えます。ですが目次と参照文はそうなりません。

“See Section TOP 1: and Section 1.2.”——コロンと “TOP” という単語が番号そのものの一部になっているので、どの参照文にもそれらが付き、さらにデフォルトの “Section” という補足語まで付いてしまいます。
最終的にたどり着いたのはこうです。番号そのものはきれいなまま保ち、レベル1では補足語をオフにして、コロンは見出しを描画する箇所だけに足す。
#set heading(numbering: (..n) => {
let n = n.pos()
if n.len() == 1 { "TOP " + str(n.first()) } else { numbering("1.1", ..n) }
})
#show heading.where(level: 1): set heading(supplement: none)
#show heading.where(level: 1): it => if it.numbering == none { it } else {
block(counter(heading).display(it.numbering) + [: ] + it.body)
}

これで参照文は “See TOP 1 and Section 1.2.” となり、目次にも余計なコロンは残りません。
if it.numbering == none { it } というガードは必須です。 最初のバージョンにはこれがなく、両方のバージョンで error: expected string, function, or auto, found none というエラーになりました。目次自体の “Contents” というタイトルも、番号のないレベル1見出しであり、この show ルールはそれにも適用されるからです。
テスト5:最後の数字だけ(I. / A. / 1.)
各レベルが自分の番号だけを表示する “I. / A. / 1.” というアウトライン形式では、パターン "I.A.1." だけでは足りません。これだと I.、I.A.、I.A.1. になってしまいます。深さに応じて記号を選び、最後の数字だけを渡すとうまくいきます。
#set heading(numbering: (..n) => {
let n = n.pos()
numbering(("I.", "A.", "1.").at(n.len() - 1), n.last())
})

カウンター自体は階層全体を数え続けるので、二つ目のレベル2見出しは正しく “B.” になります。三段階より深くネストすると .at() はインデックスエラーで失敗するので、四つ目の記号かフォールバックを追加してください。
テスト6:付録
#set heading(numbering: "1.1")
= Intro
= Method
== Setup <setup>
#counter(heading).update(0)
#set heading(numbering: "A.1", supplement: [Appendix])
= Raw data <raw>
== Tables
See @setup and @raw.
出力は 1 Intro、2 Method、2.1 Setup、A Raw data、A.1 Tables、そして “See Section 2.1 and Appendix A.” です。切り替え前の参照は “Section” のまま、切り替え後は “Appendix” になります。驚きはありません。
私ならこうする
- 番号を隠すために、番号関数から
noneや""を返さない。 レベルごとに show-set ルールを使う。そうすれば余白は避けられ、番号のない見出しへの参照は “Section .” と表示される代わりにビルドが失敗する。 - 装飾は番号の中に入れない。 関数が返すものは何であれ、参照文や目次にそのまま出てくる。コロンや “Chapter” のような単語は show ルールか
supplementのほうに置く。 - カスタムの見出し show ルールには
it.numbering == noneのガードを付ける。 さもないと、目次のタイトルがビルドを壊すことがある。
この検証で扱っていないこと
- 検証したのは Typst 0.15.1 と 0.14.2 の公式 macOS(Apple Silicon)CLI ビルドのみで、2026-09-25 に行った。Web アプリ版は試していない。
- ページとフォントはデフォルト設定、見出しは左揃え。中央揃えの見出しや、独自の
hanging-indentは試していない。 - PDF 出力のみ。HTML 書き出しは見出しの扱いが異なる(見出しのドキュメントを参照)。