Toolarity

typst 見出し番号:レベル別の書式と none の罠

トラブル対処Typst 公開 2026年10月19日

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” には意図どおり番号が付きません。ですが、右にずれています。違いを見えるようにするため、左の余白に赤い線を引きました。

Typst 0.15.1 でレンダリングした二つの図を左右に並べたもの。どちらも左の余白に赤い線がある。左(A):番号関数がレベル3で none を返しており、見出し "Detail" は線よりわずかに右から始まるが、"1 Intro"、"1.1 Scope"、"Body." は線の上から始まる。右(B):show-set ルールでレベル3の番号を none に設定しており、"Detail" は線の上から始まる。

PDF から実測した値です(本文11pt、ページ余白1cmでのテキストのx座標、単位はポイント)。絶対値は余白の設定に左右されるので、値そのものではなく差を見てください。

パターン“Detail” の開始位置本文の開始位置
番号付けなし28.3528.35
レベル3で関数が none を返す31.6528.35
レベル3で関数が "" を返す31.6528.35
show heading.where(level: 3): set heading(numbering: none)28.3528.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.

Typst 0.15.1 の出力。目次には "1 Intro"、"1.1 Scope"、そして番号のない "Detail" が並ぶ。見出しの下の本文には "See Section ." とあり、"Section" とピリオドの間には何も入っていない。

エラーも警告も出ずにコンパイルが通り、“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) }
})

見出し自体は正しく見えます。ですが目次と参照文はそうなりません。

Typst 0.15.1 の出力。見出しは "TOP 1: First Name"、"1.1 first subsection"、"1.2 second subsection"、"TOP 2: Second name" と表示される。目次には "TOP 1:" と "TOP 2:" がコロン付きのまま表示され、本文には "See Section TOP 1: and Section 1.2." とある。

“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)
}

修正後の Typst 0.15.1 の出力。目次は "TOP 1  First Name" とコロンなしで表示され、見出しは "TOP 1: First Name" と "TOP 2: Second name" のまま、本文は "See TOP 1 and Section 1.2." になる。

これで参照文は “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())
})

Typst 0.15.1 の出力:"I. Top level heading"、"A. First subheading"、"1. Second subheading"、"B. Another subheading"。

カウンター自体は階層全体を数え続けるので、二つ目のレベル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 書き出しは見出しの扱いが異なる(見出しのドキュメントを参照)。