ブラウザでMarkdownからEPUBを生成する — 正規表現の限界とステートマシン設計

2026.02.14
Share:

はじめに — 「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

インデントの深さによってリストのネストレベルが変わります。正規表現では「現在のインデントレベル」というステート(状態)を持てないため、ネストされたリストの開始と終了を正確に判定できません。

ステートマシンへの移行

ZeronovaZeronova
正規表現の連鎖でテーブルとネストリストに対応しようとしたが、エッジケースが多すぎて破綻した。別のアプローチが必要だ。
Claude Code
行ベースのステートマシンパーサーへの移行を提案します。Markdown を行単位で読み取り、現在の状態(コードブロック内、テーブル内、リスト内、通常段落)に応じて処理を分岐させます。正規表現の連鎖と違い、コードブロック内では他のパターンマッチを一切行わない、という制御が自然に書けます。
ZeronovaZeronova
パーサーの書き直しは大きな工数だが、正規表現でパッチを重ねるよりも結果的に安定するか。
Claude Code
はい。ステートの種類は codeblock / table / heading / hr / blockquote / list / paragraph の7つです。各行を読んだときに「現在のステートで、この行をどう処理するか」を判定するだけなので、ロジックは明快です。

書き直しの結果、パーサーのロジックは以下のようになりました。

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 & の後処理

  1. blockquote 内のテキスト: <blockquote> の中に <& が生のまま含まれていると XHTML としてパースエラーになる。escapeXml() で事前にエスケープする必要がある
  2. リンクURLの &: href="https://example.com?a=1&b=2" は XHTML では不正。&amp; にエスケープする必要がある
  3. テキスト中の bare &: 全テキストに対して、エンティティ参照ではない &&amp; に置換する後処理が必要

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();
  }
}
ZeronovaZeronova
FileReader のバッチ処理で完了判定が狂うバグが出ている。バリデーションでスキップしたファイルのカウントが合わない。
Claude Code
ファイル総数ではなく、実際に FileReader を起動した数をカウントする必要があります。readersStarted を FileReader 生成時にインクリメントし、readersCompletedonload / 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 の変換ツールを作って得た学びをまとめます。

  1. 正規表現の連鎖はコンテキストを持てない: コードブロック内の特殊文字を「変換しない」という判断ができない。ステートマシンはコンテキスト(現在のブロック要素)を保持できるため、この問題を自然に解決する
  2. ブロック要素とインライン要素は分離して処理する: ステートマシンでブロック要素を処理し、インライン変換は別関数に切り出す。正規表現はインライン要素の変換には十分に有効
  3. XHTML は HTML より厳密: & のエスケープ漏れ、自己閉じタグの書き方(<br />)、属性値のクォートなど、HTML では許容されるルーズな記法が XHTML ではエラーになる
  4. FileReader のバッチ処理はカウンタ設計が重要: 「起動した数 = 完了した数」で判定する。スキップされるファイルの存在を考慮しないとレースコンディションが起きる
  5. CSS の page-break は電子書籍の読み心地を大きく左右する: 適切な位置での改ページは、物理的な本のような読書体験を実現する

「Markdown を変換するだけ」は、エッジケースを考慮すると「状態遷移を管理するパーサーを書く」ことと同義でした。正規表現の限界を知り、ステートマシンという適切な抽象化にたどり着いた過程は、他の変換処理にも応用できる知見だと考えています。

関連ツール:

関連記事:

Zeronova avatar

Zeronovaゼロノバ

Product Manager / AI-Native Builder

Web/IT業界19年以上・20以上のWebサービスを担当したPdM。東証プライム上場企業の子会社代表として事業経営を経験。現在はAIを駆使して企画から実装まで完結させる個人開発を実践中。