Vivliostyle には目次を自動生成する機能があります。ただそのままではいささかシンプルすぎるため、カスタマイズをしたいのですが、あまり情報がありません。そこで、自分で試したことを備忘録として投稿しておきます。
- 目標
- 最終形
- 前提
- フォルダ構成
- vivliostyle.config.js の中身
- 原稿の中身
- カウンタの話
- カウンタを利用して部・章・節の表示をカスタマイズする
- 目次に当てる CSS を編集
- まとめ
- おまけ
目標
vivliostyle.config.js を適切に設定すると、以下のような目次が表示されます。

これをいろいろ頑張って以下の目次にするのが目標です。

対応内容を言葉にするとこんな感じです。
- 一番上にある邪魔なタイトルを消す
- 部・章・節でそれぞれ違う番号の表示方法を定義する
- 部・章・節で文字サイズを調整し、間隔をいい感じにする
最終形
以下のリポジトリに push しました。答えだけ知りたい方はリポジトリを clone して確認してみてください。
gitlab.com
前提
環境は以下の通りです。
ベースのテーマとして @vivliostyle/theme-base@^2.0.0 を使っていることを前提とします。create book で作成されるものはすべてこれを拡張しています。
フォルダ構成
以下のようなフォルダ構成にします。
. ├── contents # 原稿ファイル群 │ ├── 00_01_preface.md │ ├── 01__part-top.md │ ├── 01_01_chapter1.md │ ├── 01_02_chapter2.md │ ├── 02__part-top.md │ ├── 02_01_chapter1.md │ └── 99_01_afterwords.md ├── themes # CSS ファイル群 │ ├── common.css │ └── toc.css └── vivliostyle.config.js
原稿ファイルの命名規則は以下の通りです。
- 〇〇_△△_filename.md とする。filename は任意。
- 〇〇 = 部 (ただし 00 は Frontmatter, 99 は Backmatter)
- △△ = 章
- 部の先頭のページは 〇〇__part-top.md とする。
こうしておくことで、名前順にファイルを表示したときに自然に本文の順序で表示されるようになります。
themes にはベースのテーマに追加で上書きするために使う css 群を格納しています。
vivliostyle.config.js の中身
vivliostyle.config.js では以下のように指定します。
const contentsDir = 'contents'; const themesDir = 'themes'; const globalTheme = [ '@vivliostyle/theme-base@^2.0.0', `${themesDir}/common.css`, ]; module.exports = { title: 'simpleAutoToc', author: 'Smooth Pudding <dummy@address>', language: 'ja', size: 'A4', theme: globalTheme, entry: [ // Frontmatter `${contentsDir}/00_01_preface.md`, { rel: contentsDir, theme: [...globalTheme, `${themesDir}/toc.css`], }, // Part 1 `${contentsDir}/01__part-top.md`, `${contentsDir}/01_01_chapter1.md`, `${contentsDir}/01_02_chapter2.md`, // Part 2 `${contentsDir}/02__part-top.md`, `${contentsDir}/02_01_chapter1.md`, // Backmatter `${contentsDir}/99_01_afterwords.md`, ], toc: { htmlPath: `${contentsDir}/00_02_toc.html`, sectionDepth: 2, title: '目次', }, }
順番に見ていきましょう。まず先頭ではいくつかの変数を定義します。vivliostyle.config.js は、拡張子から分かる通り、実は JavaScript のファイルなので、変数や関数の定義が可能です。
const contentsDir = 'contents'; const themesDir = 'themes'; const globalTheme = [ '@vivliostyle/theme-base@^2.0.0', `${themesDir}/common.css`, ];
全体の theme は globalTheme で当てます。
module.exports = { // 中略 theme: globalTheme, // 中略 }
entry には表示する順番で原稿ファイルを指定していきます。目次のファイルには忘れずに globalTheme と toc.css を当てておきます。ここでも JavaScript の記法を使っています。
module.exports = { // 中略 entry: [ // Frontmatter `${contentsDir}/00_01_preface.md`, { rel: contentsDir, theme: [...globalTheme, `${themesDir}/toc.css`], }, // Part 1 `${contentsDir}/01__part-top.md`, `${contentsDir}/01_01_chapter1.md`, `${contentsDir}/01_02_chapter2.md`, // Part 2 `${contentsDir}/02__part-top.md`, `${contentsDir}/02_01_chapter1.md`, // Backmatter `${contentsDir}/99_01_afterwords.md`, ], // 中略 }
さらに目次を自動生成する設定を toc で行います。sectionDepth はどの深さの項目まで目次に表示するかを制御します。
module.exports = { // 中略 toc: { htmlPath: `${contentsDir}/00_02_toc.html`, sectionDepth: 2, title: '目次', }, }
原稿の中身
大きく分けて3種類あります。
- 前書き・後書き
- 部の先頭のファイル
- 章のファイル
まず前書きと後書きは以下のようなシンプルな構成にします。メタデータには title だけ記述しておきます。ch-title-plain にはあとで CSS を当てます。
--- title: "はじめに" --- <div class="ch-title-plain">はじめに</div> 導入の文章。
部の先頭ファイルは、メタデータのクラス名で part を指定するようにします。part-title にはあとで CSS を当てます。
--- title: "パート例 1" class: "part" --- <div class="part-title">パート例 1</div>
章のファイルは、メタデータのクラス名で chapter を指定するようにします。ch-title にはあとで CSS を当てます。また、h1 要素は使わないようにします。この理由についても後述します。
--- title: "チャプター例 1" class: "chapter" --- <div class="ch-title">チャプター例 1</div> あああ ## h2 要素 1 いいい ### h3 要素 1 ううう ## h2 要素 2 えええ
ここまで用意した状態で vivliostyle preview すると、冒頭の自動生成目次ができあがるはずです。

カウンタの話
文書を作成するとき、わざわざ章番号や節番号を手でふることはしません。ビルド時によしなにカウントしてくれています。この役割をしているのがカウンタです。
カウンタは CSS にある仕組みで、Vivliostyle 公式の資料の中でも使い方が紹介されています。つまり CSS を上手に書いてあげることで、部・章・節の番号をいい感じに制御できるというわけです。
ところで、theme-base には以下のカウンタが用意されています。
- vs-counter-sections: 節番号を制御。入れ子になっている。
- vs-counter-chapter: 章番号を制御。
- vs-counter-part: 部番号を制御。
- vs-counter-document: ファイル単位でカウント。
このうち最初の3つを利用します。今回の目的では自分でカウンタを新たに用意する必要はありません。
vs-counter-sections
ファイル内で初期化されます。h1, h2, h3, ... 要素を作ると自動でインクリメントされます。
vs-counter-chapter
ドキュメント全体で定義されています。class: "chapter" のファイルごとにインクリメントされます。
vs-counter-part
ドキュメント全体で定義されています。class: "part" のファイルごとにインクリメントされます。
カウンタを利用して部・章・節の表示をカスタマイズする
部・章・節の表示は以下のようにします。
- 部の番号は第〇部のように書く。〇はローマ数字にする。
- 章の番号は第〇章のように書く。〇はアラビア数字にする。
- 節の番号は (章番号).〇.〇のように書く。〇はアラビア数字にする。
これらの定義を common.css に記述していきます。
まず部の定義から。表示用の変数 "--myvar-part--marker-content" と参照用の変数*1 "--myvar-part--call-content" を用意しておきます。
:root { --myvar-part--marker-content: '第', counter(vs-counter-part, upper-roman), '部'; --myvar-part--call-content: '第', target-counter(attr(href), vs-counter-part, upper-roman), '部'; }
部が切り替わったタイミングで vs-counter-chapter をリセットしておきたいので、以下も記述します。
@page part-document { counter-reset: vs-counter-chapter; }
これを使って、部の先頭のページをカスタマイズしてみます。**__part-top.md のファイルには part-title というクラス名の div 要素のみを置いてあるので、これに css を当てます。今回は簡単に上下の中央に左寄せで表示して、その手前に先程定義した部番号を表示するようにしました。
.part-title::before { content: var(--myvar-part--marker-content); margin-right: 0.5em; } .part-title { font-weight: var(--vs--heading-font-weight); font-family: var(--vs--heading-font-family); font-size: 2.6em; position: absolute; top: 50%; transform: translateY(-50%); break-before: recto; }
以下のような見た目になります。

同様に章番号も定義していきます。表示用の変数 "--myvar-chapter--marker-content" と参照用の変数 "--myvar-chapter--call-content" を定義します。
:root { --myvar-chapter--marker-content: '第', counter(vs-counter-chapter), '章'; --myvar-chapter--call-content: '第', target-counter(attr(href), vs-counter-chapter), '章'; }
こちらも実際に使ってみましょう。ch-title と ch-title-plain の見た目は揃えた上で、ch-title の方だけ章番号の表示を与えるようにしてみます。
.ch-title::before { content: var(--myvar-chapter--marker-content); display: block; } .ch-title-plain, .ch-title { font-weight: var(--vs--heading-font-weight); font-family: var(--vs--heading-font-family); font-size: 2.6em; break-before: recto; }
以下のような見た目になります。


最後に節番号も再定義します。"--vs-section--marker-display" を inline にして節番号を表示するようにします。節番号には表示用と参照用の変数が元々用意されているので、その定義を上書きします。
:root { --vs-section--marker-display: inline; --vs-section--marker-content: counter(vs-counter-chapter), '.', counters( vs-counter-sections, var(--vs-section--counter-delimiter), var(--vs-section--counter-style) ); --vs-section--call-content: target-counter(attr(href), vs-counter-chapter), '.', target-counters( attr(href), vs-counter-sections, var(--vs-section--counter-delimiter), var(--vs-section--counter-style) ); }
これで節の始まりの番号が章の番号になりました。

目次に当てる CSS を編集
では本題の目次の修正を行っていきます。まず目次の html ファイルを確認します*2。
<html lang="ja"> <head> <!-- 略 --> </head> <body> <h1>simpleAutoToc</h1> <nav id="toc" role="doc-toc"> <h2>目次</h2> <ol> <li><a href="00_01_preface.html">はじめに</a></li> <li><a href="01__part-top.html">パート例 1</a></li> <li> <a href="01_01_chapter1.html">チャプター例 1</a> <ol> <li data-section-level="2"> <a href="01_01_chapter1.html#h2-%E8%A6%81%E7%B4%A0-1" >h2 要素 1</a > </li> <li data-section-level="2"> <a href="01_01_chapter1.html#h2-%E8%A6%81%E7%B4%A0-2" >h2 要素 2</a > </li> </ol> </li> <!-- 略 --> </ol> </nav> </body> </html>
これを見ながら一つひとつ toc.css に実装していきます。
まず上部のタイトルを消します。h1 要素を非表示にすればよいですね。
body:has(nav[role='doc-toc']) > h1 { display: none; }
次に間隔を調整します。節のアイテムには data-section-level という属性がついていることを利用し、それ以外の間隔を広めにします。
nav[role='doc-toc'] li:not([data-section-level]) { margin-top: 1em; }
次に部番号を追加します。href の末尾が "__part-top.html" となる a タグについて、手前に "--myvar-part--call-content" を表示するようにします。合わせてフォントサイズも h2 の見出しサイズにします。
nav[role='doc-toc'] li > a[href$='__part-top.html']::before { content: var(--myvar-part--call-content); margin-right: 1em; } nav[role='doc-toc'] li > a[href$='__part-top.html'] { font-size: var(--vs--h2-font-size); }
次に章番号を追加します。章番号は以下を同時に満たすものに追加します。
- data-section-level を含む要素の中ではない
- href が __part-top.html でもなければ、00 でも 99 でも始まらない
また章の文字は部よりも1文字分字下げします。
nav[role='doc-toc'] li:not([data-section-level]) > a:not([href$='__part-top.html'], [href^='00'], [href^='99'])::before { content: var(--myvar-chapter--call-content); margin-right: 1em; } nav[role='doc-toc'] li:not([data-section-level]) > a:not([href$='__part-top.html'], [href^='00'], [href^='99']) { margin-left: 1em; }
次に節番号を追加します。これも1文字分字下げしておきます。
nav[role='doc-toc'] li[data-section-level] > a::before { content: var(--vs-section--call-content); margin-right: 1em; }
最後に、章と節のフォントサイズを設定しておきます。
nav[role='doc-toc'] li:not([data-section-level]) > a:not([href$='__part-top.html']) { font-size: var(--vs--h3-font-size); }
これできれいな目次が表示されるようになります。

ちなみに、class=chapter の原稿ファイルで h1 要素を使うと、href に .html ファイル全体ではなく h1 要素へのリンクが指定されます。つまり h1 要素の内容とメタデータの title のいずれか一方のみが使われる仕様になっているようです。そのため、以下のいずれかを選ぶことになります。
- h1 要素を使わないで、節番号に vs-counter-chapter を潜り込ませる
- h1 要素での vs-counter-sections の処理を上書きして制御する
前者のほうが比較的シンプルに実装できるため、こちらを選択しました。このあたりは目次自動生成の仕様に依存するので、今後のアップデートで事情が変わるかもしれませんね。
まとめ
vivliostyle の自動生成目次をカスタマイズするために、CSS のカウンタを使って変数を定義し、その変数を使って目次に当てる CSS を定義しました。
ちなみに、theme-base の機能については、GitHub の theme-base のページにある README を見るとわかりやすいです。より細かく制御したい場合は確認してみてください。
github.com
ではまた。
おまけ
部番号・章番号・節番号を本文中で参照したいとき(「第3章では〜」など)は、以下のようにすればうまく行きます。
まず節番号は、節に id を割り振った上で、a タグのエイリアスを使って以下の様に書けます。data-ref=sec と書くのがポイントです。
## ほげほげ{#hoge} [](#hoge){data-ref=sec}
部番号・章番号については、まず以下を common.css に追加しておきます。
a[data-ref='part']::before { content: var(--myvar-part--call-content); } a[data-ref='ch']::before { content: var(--myvar-chapter--call-content); }
その上で以下のように書けば同様に参照できます。href で .md ではなく .html と書くことに注意してください。
[](01__part-top.html){data-ref=part} [](01_01_chapter1.html){data-ref=ch}