はじめに — 「Markdownを変換するだけ」の甘い見積もり
「Markdown を XHTML に変換して ZIP で固めれば EPUB になる」。技術的にはその通りです。EPUB 3 のコンテンツは XHTML であり、Markdown から XHTML への変換は多くのライブラリが対応しています。
しかし、ブラウザ上で完結する電子書籍作成ツール(/tools/ebook-creator)を作ってみると、「Markdown を変換するだけ」では済まない問題が次々と浮上しました。GFM テーブル、ネストされたリスト、コードブロック内の特殊文字。正規表現の連鎖で対応しようとして破綻し、最終的にステートマシン方式のパーサーに書き直した経験を共有します。
正規表現チェーンの限界
最初の実装はシンプルでした。Markdown のパターンを上から順番に content.replace(/pattern/g, replacement) で変換していく方式です。
正規表現ベースの Markdown パーサーが複雑なネスト構造で破綻。content.replace() の連鎖では、コードブロック内のマークアップが誤変換される問題が解決できなかった
たとえば、見出しの変換はこうなります。
content.replace(/^## (.+)$/gm, '<h2>$1</h2>')
太字は **text** → <strong>text</strong>、リンクは [text](url) → <a href="url">text</a>。個別のパターンは問題なく動きます。
しかし、以下のようなケースで破綻しました。
```javascript
// この中の **太字** や ## 見出し は変換してはいけない
const regex = /^## (.+)$/gm;
```
コードブロック内の ** や ## が太字や見出しとして誤変換されてしまいます。「コードブロック内は変換をスキップする」という処理を正規表現の連鎖に組み込もうとすると、コードの複雑さが急激に増大します。
GFMテーブルとネストリスト
さらに困難だったのが、GFM(GitHub Flavored Markdown)のテーブルとネストされたリストです。
テーブルの問題
GFM テーブルは複数行にまたがる構造です。
| 項目 | 値 |
|------|-----|
| A | 100 |
| B | 200 |
正規表現で「|------| で区切られた行のブロック」を検出し、<table><thead><tbody> に変換する必要があります。テーブルの開始判定には、ヘッダー行に続くアライメント行(/^\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)+\|?\s*$/)の2行先読みが必要です。しかし、テーブルの前後に別のブロック要素(見出しやリスト)が続く場合、正規表現のマッチ範囲が不安定になりました。
この問題が深刻だったのは、KDP 原稿の実データにテーブルが多用されていたためです。PM と AI の役割分担表、ツール一覧表など、テーブルが表示されないとコンテンツの大部分が欠落します。
ネストリストの問題
- 項目1
- サブ項目1-1
- サブ項目1-2
- 項目2
インデントの深さによってリストのネストレベルが変わります。正規表現では「現在のインデントレベル」というステート(状態)を持てないため、ネストされたリストの開始と終了を正確に判定できません。
ステートマシンへの移行
書き直しの結果、パーサーのロジックは以下のようになりました。
type ParserState = "normal" | "codeblock" | "table" | "list";
function parseMarkdown(content: string): string {
const lines = content.split("\n");
let state: ParserState = "normal";
// 行を順番に処理し、stateに応じて分岐
for (const line of lines) {
if (state === "codeblock") {
// ``` が来るまでエスケープのみ
} else if (line.startsWith("```")) {
state = "codeblock";
} else if (state === "table") {
// | で始まらない行でテーブル終了
}
// ...
}
}
コードブロック内(state === "codeblock")では、閉じの ``` が来るまで一切のマークアップ変換をスキップし、XML エスケープのみ行います。テーブル内(state === "table")では | で始まる行を列として処理し、| で始まらない行が来たらテーブルを閉じます。
インライン変換の分離
ブロック要素の処理とは別に、インライン要素(太字、イタリック、コード、リンク)の変換を applyInlineFormatting() 関数として分離しました。
function applyInlineFormatting(text: string): string {
return text
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/\*(.+?)\*/g, "<em>$1</em>")
.replace(/`(.+?)`/g, "<code>$1</code>")
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
}
インライン変換は正規表現で十分に対応できます。重要なのは、この関数がコードブロック内では呼ばれないことです。ステートマシンがブロック要素のコンテキストを管理しているため、「コードブロック内の太字が誤変換される」問題は原理的に発生しません。
XHTML バリデーションの罠
EPUB の中身は XHTML であり、HTML よりも厳密なバリデーションが必要です。ステートマシンを実装した後も、XHTML 固有の問題がいくつか見つかりました。
XHTML バリデーション修正: blockquote 内テキストに escapeXml() が必要、リンクURLの & を & にエスケープ、bare & の後処理
- blockquote 内のテキスト:
<blockquote>の中に<や&が生のまま含まれていると XHTML としてパースエラーになる。escapeXml()で事前にエスケープする必要がある - リンクURLの
&:href="https://example.com?a=1&b=2"は XHTML では不正。&にエスケープする必要がある - テキスト中の bare
&: 全テキストに対して、エンティティ参照ではない&を&に置換する後処理が必要
FileReader のレースコンディション
EPUB には画像を含めることができます。ユーザーがアップロードした画像を FileReader で Base64 に変換し、EPUB の ZIP に含める処理で、レースコンディションが発生しました。
FileReader バッチ処理のレースコンディション: バリデーションでスキップされたファイルのカウントが合わず、完了判定が狂う。readersStarted / readersCompleted パターンで解決
問題はこうです。10個のファイルがアップロードされたとき、バリデーション(5MB以下、対応フォーマット)でスキップされるファイルがある場合、FileReader を起動した数と完了を待つ数が一致しません。「10個のうち3個がスキップされ、7個の FileReader が起動されたが、完了カウンタは10個を待っている」という状態になり、処理が永遠に完了しないバグでした。
修正は readersStarted / readersCompleted カウンタを導入し、「起動した数 = 完了した数」で完了判定する方式にしました。
let readersStarted = 0;
let readersCompleted = 0;
function checkComplete() {
if (readersCompleted === readersStarted) {
// 全FileReaderの処理が完了
generateEpub();
}
}
readersStarted を FileReader 生成時にインクリメントし、readersCompleted を onload / onerror でインクリメントして、両者が一致したら完了とします。CSSによるページ制御
EPUB リーダーでのページ区切りは CSS で制御します。
CSS ページ制御: h2 { page-break-before: always; } で章ごとに改ページ。hr { page-break-after: always; } で区切り線の後に改ページ
page-break-before: always を H2 に設定すると、各章の冒頭で必ず新しいページが始まります。page-break-after: always を水平線(<hr>)に設定すると、区切り線の後で改ページが入ります。
Kindle のプレビューで確認すると、意図通りにページが区切られていることを確認できました。
まとめ — 「変換するだけ」が複雑になる理由
Markdown → EPUB の変換ツールを作って得た学びをまとめます。
- 正規表現の連鎖はコンテキストを持てない: コードブロック内の特殊文字を「変換しない」という判断ができない。ステートマシンはコンテキスト(現在のブロック要素)を保持できるため、この問題を自然に解決する
- ブロック要素とインライン要素は分離して処理する: ステートマシンでブロック要素を処理し、インライン変換は別関数に切り出す。正規表現はインライン要素の変換には十分に有効
- XHTML は HTML より厳密:
&のエスケープ漏れ、自己閉じタグの書き方(<br />)、属性値のクォートなど、HTML では許容されるルーズな記法が XHTML ではエラーになる - FileReader のバッチ処理はカウンタ設計が重要: 「起動した数 = 完了した数」で判定する。スキップされるファイルの存在を考慮しないとレースコンディションが起きる
- CSS の page-break は電子書籍の読み心地を大きく左右する: 適切な位置での改ページは、物理的な本のような読書体験を実現する
「Markdown を変換するだけ」は、エッジケースを考慮すると「状態遷移を管理するパーサーを書く」ことと同義でした。正規表現の限界を知り、ステートマシンという適切な抽象化にたどり着いた過程は、他の変換処理にも応用できる知見だと考えています。
関連ツール:
- 電子書籍作成ツール — この記事で解説したステートマシンパーサーを搭載
- EPUBビューアー — 生成したEPUBをブラウザで閲覧・検索
- EPUBバリデーター — EPUBの構造・メタデータ・KDP要件を検証
関連記事:
- Markdownで電子書籍(EPUB)を作成・出版する方法 — EPUB作成の実践ガイド
- Next.js で Markdown ブログを構築する方法 — Markdown 処理の基本
- 個人開発サイトに74個の無料ツールを作ってSEO流入を増やした話 — ツール開発の全体戦略
Zeronova(ゼロノバ)
Product Manager / AI-Native Builder
Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。