[Astro] #173 Pandoc Converter — Pandoc を WASM でブラウザに載せ、文書を変換する

[Astro] #173 Pandoc Converter — Pandoc を WASM でブラウザに載せ、文書を変換する

はじめに

前回は、地図に線を引いて、標高の断面図を描くツールでした。

今回は、文書の変換です。Markdown を Word にしたい、Word を Markdown にしたい、HTML を電子書籍にしたい、といったことを、ブラウザの中でやります。

変換の本体は、Pandoc です。Pandoc は、文書の変換では定番のプログラムで、公式に WebAssembly 版(pandoc 3.9)が出ています。これを、ブラウザで動かしました。

今回の要点は、次の 6 つです。

  1. Pandoc の WASM(58 MB)を、gzip で 15.5 MiB にして配る。初回だけ取得して、ブラウザに保存する
  2. 変換は Web Worker の中で行う。画面は止まらない
  3. 設定は、Pandoc の「デフォルトファイル」の形(JSON)で渡す
  4. Pandoc の docx は、日本語の文書に向かない。出力された zip を書き換えて、整える
  5. PDF は作れない。印刷用の HTML を作って、ブラウザの印刷に渡す
  6. docx、pptx、epub の中身(zip の中の XML)を、画面で見られる

完成画面

Markdown を書いて、Word にするところ

左にファイルと形式、中央にエディタ、右にオプションが並んだ Pandoc Converter

左が入力と出力の形式、中央がエディタとプレビュー、右がオプションです。サンプルの文書が最初から入っていて、開くとすぐに変換されます。

HTML のプレビューと、分割表示

左に Markdown、右に日本語向けテーマの HTML プレビューを並べた分割表示

docx の中身を見る(構造タブ)

書き出した docx の zip の中身と、word/document.xml を整形して表示した画面

Word で開いたところ

書き出した docx を Word で開いた画面。A4、表、コード、画像、脚注が出ている

Pandoc を選んだ理由

文書の変換は、すでに便利なツールがたくさんあります。ブラウザで動くものも、あります。ただ、多くは、次のどれかでした。

  • 形式が少ない(Markdown と HTML だけ、など)
  • サーバーに文書を送る
  • 変換は正確でも、細かい設定ができない

Pandoc は、入力 51 種、出力 75 種(3.9 の --list-input-formats と --list-output-formats の数)に対応していて、設定も細かく、変換の質も高いです。これを、そのままブラウザに持ってこられれば、形式と質では、勝てるはずです。

そのかわり、重いです。wasm が 58 MB あります。軽さでは、勝てません。ここは、配り方で、なんとかします。

wasm を配る

15.5 MiB にする

pandoc.wasm は、58.4 MB でした。これを gzip -9 すると、約 15.5 MiB です。lain-lab は、いろいろなホストに静的ファイルを置いています。Cloudflare Pages は、1 ファイルが 25 MiB までなので、この大きさなら、収まります。

配るのは、pandoc.wasm.gz を、そのままです。ブラウザで取得したあと、DecompressionStream('gzip') で展開します。

async function gunzipIfNeeded(bytes) {
  // gzip(1f 8b)なら展開する。ホスト側が自動で展開して配信した場合は、そのまま wasm(00 61 73 6d)
  if (bytes[0] === 0x1f && bytes[1] === 0x8b) {
    const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip'));
    return new Uint8Array(await new Response(stream).arrayBuffer());
  }
  return bytes;
}

先頭の 2 バイトで、gzip かどうかを見分けています。ホストによっては、.gz を自動で展開して返すことがあります。そのときも、同じコードで動きます。

Cache API に入れる

取得したファイルは、Cache API に入れます。2 回目からは、ダウンロードなしで、すぐに始まります。キャッシュが壊れていて、展開やコンパイルに失敗したときは、取り直します。

プライベートウィンドウなどで、Cache API が使えないときは、毎回ダウンロードになるだけで、動きは変わりません。

コンパイルは 1 回だけ

wasm のコンパイルは、重い処理です。そこで、コンパイル済みの WebAssembly.Module を、Worker の中に持っておきます。

Pandoc は、変換の途中で例外が出たり、メモリが大きくなったりすることがあります。wasm のメモリは、いちど大きくなると、縮みません。そのため、次の場合は、Module から、インスタンスを作り直します。

  • 変換の途中で、例外が出たとき
  • 変換のあと、メモリが 900 MB を超えていたとき

作り直しは、コンパイルをやり直さないので、速く済みます。

起動時に、試しに変換する

Pandoc は、初回の docx 変換が、数秒かかります。内部の初期化が、遅延しているためです。そこで、起動のときに、小さな文書を、docx、HTML、JSON に変換しておきます。こうしておくと、最初の変換から、速くなります。

ローカルでの起動は、約 3 秒でした(キャッシュなし、ダウンロードは localhost なので、ほぼゼロ)。実際のネットワークでは、ダウンロードの時間が、そのまま足されます。

Worker で動かす

変換は、すべて Web Worker の中です。メインスレッドは、画面の操作だけをします。

ここで、1 つ、制約がありました。Worker には、importmap が効きません。サイトの他のツールは、importmap で three.js などを読んでいますが、それが使えません。そのため、Worker に必要なコードは、esbuild で、1 ファイルにまとめました(IIFE)。

まとめたのは、次の 2 つです。

  • Pandoc を動かす層(engine.js)
  • WASI の実装(@bjorn3/browser_wasi_shim)

pandoc-worker.js は、28.7 KB です。ビルドは、docs/pandoc-converter/build.mjs で、node を 1 回実行するだけです。

変換の依頼は、1 つずつ順番に処理します。wasm は、同時に 2 つの変換を動かせないからです。入力を編集している間は、新しい依頼が来たら、古い依頼を捨てて、最後のものだけを実行します。

Pandoc に設定を渡す

Pandoc の WASM 版は、コマンドライン引数ではなく、「デフォルトファイル」(--defaults で読ませる YAML)と同じ形の JSON を受け取ります。

{
  from: 'markdown+east_asian_line_breaks',
  to: 'docx',
  'output-file': 'result.docx',
  'input-files': ['document.md'],
  'resource-path': ['.'],
  metadata: { lang: 'ja' },
  citeproc: true,
}

画面の設定は、この形に変換しています。これをやる関数が buildJob で、DOM に触れないので、Node でも動かせます。

さらに、同じ設定から、コマンドラインの表記も作っています。

pandoc -f markdown+east_asian_line_breaks -t docx --citeproc --lua-filter=_filter.lua -M lang=ja --bibliography=refs.bib -o result.docx sample.md

画面の設定が、実際の Pandoc では、どういう指定になるのかが、そのまま見えます。手元の Pandoc で、同じ変換を再現するときにも使えます。デフォルトファイル(YAML)も、ログのタブに出しています。

使えるキーと、使えないキー

デフォルトファイルのキーは、Pandoc のマニュアルに、いくつも載っています。ただ、WASM 版で、そのまま通るとは限りません。知らないキーは、エラーになります。

試して、わかったことです。

  • no-highlight、lua-filters、link-citations は、キーとして使えない
  • コードの色を消すには、highlight-style: 'none' を使う
  • Lua フィルターは、filters に、ファイルのパスで入れる
  • link-citations は、metadata に入れる
  • citeproc: true と、bibliography は、そのまま通る

拡張機能

Markdown には、east_asian_line_breaks、smart、hard_line_breaks など、60 ほどの拡張機能があります。形式の名前に + や - をつなげて、オンとオフを切り替えます。

Pandoc は、extensions-for-format という問い合わせで、形式ごとの拡張機能の一覧と、既定のオンとオフを返します。画面の一覧は、これから作っています。形式を変えると、一覧も変わります。

日本語では、特に east_asian_line_breaks が大事です。これをオンにすると、ソースの途中で改行した日本語が、変換後に、空白なしでつながります。オフのままだと、改行が空白になって、「サンプルです。 日本語の文章は」のように、余計な空白が入ります。そのため、この拡張を、最初からオンにしています(対応しない形式では、自動で無視されます)。

公式のコードに、手を入れたところ

Pandoc の WASM 版には、pandoc-wasm という npm パッケージがあります。ブラウザでの動かし方も、これを見ると、わかります。ただ、そのまま使うには、困ることがありました。

サブフォルダのファイルが読めない

pandoc-wasm の core.js は、ファイルを「名前 → 中身」の平らな表で渡します。img/a.png のような、サブフォルダのファイルが、読めません。

Could not fetch resource img/a.png

フォルダごとの Markdown を、変換するときに、画像が出せません。そこで、engine.js を、自分で書きました。パスの区切りごとに、フォルダを作って、中にファイルを置きます。

function putFile(path, data, readonly) {
  const parts = splitPath(path);
  if (!parts) throw new Error(`使えないパス: ${path}`);
  let dir = root;
  for (let i = 0; i < parts.length - 1; i++) {
    let next = dir.get(parts[i]);
    if (!(next instanceof Directory)) {
      next = new Directory(new Map());
      dir.set(parts[i], next);
    }
    dir = next.contents;
  }
  dir.set(parts[parts.length - 1], new File(data, { readonly }));
}

.. を含むパスと、空のパスは、断っています。

結果は、作られたファイルだけ

元のコードは、入力ファイルも、結果に含めて返します。変えて、「入力にはなかった、新しくできたファイル」だけを返すようにしました。画像が多い文書で、入力を、また返すのは、無駄だからです。

日本語の docx に向かない

ここが、いちばん、重要でした。

Pandoc が出す docx は、そのままでは、日本語の文書に向きません。Word で開くと、次のようになります。

  • 用紙が、Letter のまま
  • 文書の言語が、英語。さらに、東アジアの言語が zh-CN(中国語)になっている
  • フォントは、テーマのフォント(欧文向け)で、日本語のフォントの指定がない
  • ページ番号がない

特に、eastAsia="zh-CN" は、やっかいです。Word は、日本語の文字を、中国語の字形や設定で扱うことがあります。

これを、Pandoc の設定だけで直すのは、難しいです。直すには、--reference-doc で、自分の雛形を渡す方法があります。ただ、雛形は、毎回、Word で作って、保存して、渡す必要があります。ブラウザだけで済ませたいので、別の方法にしました。

出力された docx を、書き換える

docx は、zip です。出力された docx を、zip として開いて、XML を書き換えて、また zip に戻します。これを、patchDocx にしました。

書き換える項目は、次のとおりです。

項目書き換える場所
用紙、向き、余白word/document.xml の最後の sectPr
ページ番号word/footer_pc.xml を新しく作り、sectPr から参照
本文と見出しのフォントword/styles.xml の rFonts
文字サイズdocDefaults の sz
行間pPrDefault の spacing
文書の言語styles.xml の lang(ja-JP)
1 字下げ、段落の前後BodyText と Compact のスタイル
見出しの色Heading1 から Heading9、Title
表の罫線Table スタイルの tblBorders

zip の読み書きは、自分で書きました。外部のライブラリは、使っていません。

  • 読み込み:末尾の目次(EOCD)を探して、中央ディレクトリを読む
  • 圧縮と展開:DecompressionStream('deflate-raw') と CompressionStream('deflate-raw')
  • 書き込み:CRC-32 を計算して、ローカルヘッダと中央ディレクトリを作る
  • 書き換えない部品は、再圧縮せずに、そのまま詰め直す

ページ番号は、PAGE と NUMPAGES のフィールドで、「1」「- 1 -」「1 / 5」の 3 つの書式を選べます。

気をつけたこと:sectPr の順番

sectPr の中は、要素の順番が決まっています。footerReference、footnotePr、pgSz、pgMar の順です。Pandoc が出した sectPr には、footnotePr だけがあります。そこで、footerReference をその前に、pgSz と pgMar をその後ろに、入れています。順番が違うと、Word が「壊れている」と言うことがあるので、最初から、順番を守って書きました。

確認

LibreOffice で PDF にして、確認しました。

  • ページサイズが A4(595 × 842 pt)になっていること
  • 横向きにしたとき、841.89 × 595.30 になること
  • ページ番号が出ること

そのあと、Word で開いて、確認しました(次の節)。

Word で開いてみた

書き出した docx を、Word で開きました。

  • A4 で、2 ページ
  • 本文、箇条書き、タスクリスト(☒ と ☐)、表、コード(色つき)、数式、画像、キャプション、脚注、参考文献が、出ている

小さい黒い四角が、見出しや著者の行の左に出ましたが、これは、Word の編集記号(「次の段落と分離しない」の目印)でした。不具合では、ありません。

「互換モード」とも表示されます。これは、Pandoc の docx が、いつもそうなる仕様です。

自分の mdx を、変換してみた

このサイトの記事(.mdx)も、変換してみました。形になりました。ただ、MDX 特有の部分は、そのままです。

  • ファイルの先頭の import ... from ...; の行が、本文に出る
  • <LinkCard ... /> のようなコンポーネントの行は、中身のない段落になる
  • 日付が、2026-10-11T15:00:00+09:00 のまま出る

これを直す機能を、入れるかどうかは、考えました。ただ、Pandoc は、MDX を知りません。ブログ用の独自の書き方に、ツールを合わせるのは、やめました。そういう文書は、Markdown に直してから、渡します。

HTML のテーマ

Pandoc の HTML は、標準では、素朴な見た目です。テーマを選べるようにしました。

  • Pandoc 標準
  • スタイルなし
  • GitHub 風
  • 日本語・読みやすい
  • 論文・印刷向け(明朝)
  • ダーク

CSS を差し替える

Pandoc には、HTML の CSS を、外す設定(document-css)があります。ただ、3.9 では、variables に document-css: false を渡しても、効きませんでした。

そこで、テンプレートを差し替えました。Pandoc の既定の HTML テンプレートは、query の default-template で取り出せます。その中に、<style> の中身を入れる行があります。

<style>
  $styles.html()$
</style>

$styles.html()$ の部分を、次のように置き換えたテンプレートを、作ります。

$if(highlighting-css)$
$highlighting-css$
$endif$
(テーマの CSS)

$highlighting-css$ を、足し直しているのは、コードの色のためです。$styles.html()$ の中に、コードの色の CSS も含まれていたので、置き換えると、コードの色が消えます。

CSS の中に $ があると、Pandoc のテンプレートが、変数と間違えます。$ は、$$ にしています。

数式と画像

数式の出力は、MathML、MathJax(CDN)、KaTeX(CDN)、そのまま、から選べます。MathML が、既定です。外部のサーバーを使わないからです。

画像と CSS は、HTML に埋め込めます(embed-resources)。付属ファイルは、仮想フォルダの同じパスに置いてあるので、Pandoc が、そのまま読んで、埋め込みます。

reveal.js のスライドは、埋め込みができません(Pandoc が、ネットワークに出られないため)。表示のときに、CDN から読みます。

PDF は作れない

Pandoc で PDF を作るときは、LaTeX や、Typst などの、外部のプログラムを呼びます。WASM 版は、外部のプログラムを呼べません。PDF は、直接は、作れません。

そこで、次の形にしました。

  1. 印刷用の CSS(用紙、余白、ページをまたがない設定)を足した HTML を作る
  2. その HTML を、iframe で表示する
  3. 「印刷 / PDF に保存」のボタンで、ブラウザの印刷を呼ぶ

iframe の sandbox は、allow-same-origin allow-modals にしています。スクリプトは、許していません。変換した文書の中の <script> は、動きません。ただ、allow-same-origin を付けているので、スクリプトを許すと、sandbox の意味がなくなります。ここは、許さないままです。

LaTeX と Typst は、ソースを出すところまでです。PDF にするのは、手元の環境で、やります。

入力まわり

ファイルの扱い

  • ファイルとフォルダの、ドロップ、選択、貼り付け(画像)が、できます
  • フォルダは、中のファイルを、パスのまま取り込みます。一番上のフォルダ名は、外します
  • .zip を入れると、展開して、中のファイルを取り込みます
  • .git、node_modules、.DS_Store などは、取り込みません

取り込んだファイルは、拡張子から、役割を決めます。

  • 変換する文書
  • 付属ファイル(画像など)
  • 文献(.bib、.ris)
  • 引用のスタイル(.csl)
  • Lua フィルター、CSS、テンプレート、参照用の docx

役割は、一覧から変えられます。

文字コード

日本語のファイルは、Shift_JIS や EUC-JP のことがあります。Pandoc は、UTF-8 しか読めません。

そこで、読み込みの時点で、次の順で、文字コードを決めています。

  1. BOM があれば、それに従う(UTF-8、UTF-16)
  2. UTF-8 として、厳密に読めたら、UTF-8
  3. だめなら、Shift_JIS と EUC-JP の両方で読んで、不正な文字が少なく、かなが多いほうを選ぶ

Pandoc には、いつも UTF-8 で渡します。Shift_JIS の .txt を、入れてみたところ、正しく読めました。

複数の文書

文書が複数あるときは、次の 2 つが選べます。

  • 1 つずつ変換して、ZIP にまとめる
  • 1 つにつなげて、変換する(名前順。数字は、数の大きさで並べる)

プレビュー

出力の形式によって、プレビューの作り方を変えています。

出力プレビュー
HTML出力そのもの
docx、odt、epub、pptx書き出したファイルを、Pandoc で読み直して、HTML にしたもの
PDF印刷用の HTML
それ以外(LaTeX、RST など)入力を HTML にしたもの

docx などは、書き出したファイルを、読み直しています。つまり、「Pandoc が、そのファイルを、どう読めるか」が見えます。ただ、ページの設定や、フォントは、読み直しには出ません。そのことは、プレビューの上に、書いてあります。

HTML のプレビューは、画像の src を、data: に置き換えて、iframe に渡しています。

構造タブ

docx、pptx、odt、epub は、zip です。中を見られるように、しました。

  • zip の中の、ファイルの一覧(大きさつき)
  • XML は、インデントして、表示
  • 画像は、そのまま表示
  • 1 つずつ、取り出して、保存できる

書き出した docx の中も、読み込んだ Word ファイルの中も、見られます。仕上げが、効いているかを確かめるときに、役に立ちました。

文書の情報と、AST

変換のあと、同じ入力を、JSON にして、読み直しています(Pandoc の AST です)。ここから、次のことを、出しています。

  • 文字数(日本語の文字と、英単語を、分けて数える)、読む時間の目安
  • 見出しの一覧
  • 画像、リンク、表、コードブロック、脚注、数式、引用の数
  • 見つからない画像(本文に書いてあるが、付属ファイルにない)

AST は、native(Haskell の形)と JSON で、そのまま見られます。Pandoc が、文書を、どう読んだかを、確認できます。

入力が 800 KB を超えるときは、この解析を省略します。

そのほかの機能

  • プリセット:11 個が入っています(日本語の Word、README、HTML、EPUB、スライド、LaTeX、Typst など)。自分の設定の保存と、書き出しも、できます
  • Lua フィルター:例が 7 個(最初の見出しをタイトルにする、引用符を「」にする、など)と、自分で書くところがあります。ネットワークや OS には、触れません
  • テンプレートの編集:形式の既定テンプレートを読み込んで、編集できます
  • 参考文献:.bib と .csl を入れると、[@key] の引用と、文献の一覧が、できます

テスト

Node

DOM が要らない部分は、Node で確かめました。実物の pandoc.wasm を動かして、次のものを変換しました。

  • Markdown から、docx、HTML5、EPUB 3、PPTX、ODT、LaTeX、Typst、reveal.js、RST、Markdown、GFM、JSON、native、分割 HTML、Beamer、プレーンテキスト、AsciiDoc、Org、DocBook、JATS
  • HTML のテーマ 6 種(コードの色が残っていること、画像が埋め込まれること)
  • docx から、GFM と HTML

LibreOffice

仕上げた docx を、LibreOffice で PDF にして、ページサイズとページ番号を確かめました。

Chromium

Astro で、実際のページをビルドして、Playwright の Chromium で、操作しました。

  • 起動、進捗の表示、キャッシュからの 2 回目の起動
  • プリセット、各タブ、分割表示
  • フォルダの取り込み(画像と文献つき)、Shift_JIS のファイル
  • docx の読み込みと、Markdown への変換、ZIP の書き出し
  • 主要な出力形式のプレビュー
  • Lua の構文エラーが、ログに出ること

実機

サイトに入れて、Chrome で、動くことを確認しました。書き出した docx は、Word で開いて、確認しました。

重さ

28 万字(約 800 KB)の Markdown で、測りました。

変換時間
docx約 12 秒(統計のための解析を入れて、約 18 秒)
HTML(画像を埋め込み)約 53 秒、出力は 120 MB

HTML が重いのは、文書に、同じ画像(約 230 KB)が、400 回出てくるからです。Pandoc は、画像を、出てくるたびに、埋め込みます。実際の文書では、こんなことはまず無いと思います。ただ、画像が多い文書を、「埋め込み」で HTML にするときは、気をつけてください。

変換のあと、wasm のメモリは、約 770 MB になっていました。900 MB を超えると、次の変換の前に、インスタンスを作り直します。

画像の合計が 40 MB を超えるときは、Worker に渡す画像を、本文で使っているものだけにしています。毎回、全部を渡すと、コピーが重いからです。

ハマりどころ

1. 未知のキーは、エラー

デフォルトファイルに、知らないキーを入れると、変換が失敗します。マニュアルにあるキーでも、WASM 版では、通らないものがありました(上に書いたとおりです)。1 つずつ、試して確かめました。

2. extract-media

Word から Markdown にするとき、画像を取り出す設定が、extract-media です。ここに、ディレクトリ名(media)を渡すと、media/media/image1.png のように、二重になりました。.zip で終わる名前(media.zip)を渡すと、画像が、1 つの zip に、まとまって出ます。これを、あとで展開しています。

3. バイナリの入力

docx などのバイナリを、標準入力で渡すと、couldn't unpack docx container で、失敗しました。ファイルとして置いて、input-files で渡す必要があります。

4. 文書の CSS を消す設定が効かない

上に書いたとおり、テンプレートを差し替えました。

5. 置き場所

ビルド用のスクリプトを、tools/ という新しいフォルダに入れて、渡してしまいました。サイトのリポジトリには、そのようなフォルダがありません。作るべきではありませんでした。docs/pandoc-converter/ に、移しました。既存の構成に、合わせます。

ライセンス

Pandoc は、GPL-2.0-or-later です。pandoc.wasm を同梱して配るので、ソースの場所を示す必要があります。ページの下に、Pandoc の著作権表示と、リンク(Pandoc 本体と、WASM ビルド)を置いています。

pandoc-wasm の JS は、MIT です。@bjorn3/browser_wasi_shim は、MIT か Apache-2.0 です。pandoc-worker.js の先頭に、書いてあります。

未確認のこと・未対応のこと

  • Safari と Firefox:確かめていません(Chromium と、Chrome だけです)。DecompressionStream と、Worker の中の WebAssembly が、必要です
  • 「PDF に保存」の、印刷ダイアログ:ヘッドレスでは、確かめられませんでした
  • 「日本語の Word 文書」プリセット(游明朝、1 字下げ、ページ番号)を、Word で開いた見た目:確認していません。LibreOffice では、確認しました
  • スマホの幅:縦に並べるだけの簡単な対応で、確かめていません
  • 100 MB を超えるような、大きな文書:測っていません
  • docx の「互換モード」の表示:消す設定は、入れていません
  • 引用のスタイル:日本語の文書で、既定のスタイルは、「Knuth 1984 年」のように、年が付きます。気になるときは、.csl を入れます
  • pdf への直接の変換:できません

おわりに

Pandoc の WASM は、58 MB あって、重いです。ただ、gzip で 15.5 MiB にして、Worker で動かして、キャッシュすれば、使える形になりました。

時間がかかったのは、変換そのものではなく、その周りでした。日本語の docx を整えること、zip を自前で扱うこと、サブフォルダのファイルを渡すこと、文字コードです。

実装メモは、docs/pandoc-converter.md に、残してあります。次にやるなら、Safari と Firefox での確認と、日本語の文書に合う、引用のスタイルの用意です。