[Astro] #173 Pandoc Converter — Pandoc を WASM でブラウザに載せ、文書を変換する
はじめに
前回は、地図に線を引いて、標高の断面図を描くツールでした。
[Astro] #172 Elevation Profile — 地図に線を引いて、標高の断面図を描く
地図の上に線を引くと、その線に沿った標高の断面図が出るツールを作りました。
lain-lab.com今回は、文書の変換です。Markdown を Word にしたい、Word を Markdown にしたい、HTML を電子書籍にしたい、といったことを、ブラウザの中でやります。
変換の本体は、Pandoc です。Pandoc は、文書の変換では定番のプログラムで、公式に WebAssembly 版(pandoc 3.9)が出ています。これを、ブラウザで動かしました。
今回の要点は、次の 6 つです。
- Pandoc の WASM(58 MB)を、gzip で 15.5 MiB にして配る。初回だけ取得して、ブラウザに保存する
- 変換は Web Worker の中で行う。画面は止まらない
- 設定は、Pandoc の「デフォルトファイル」の形(JSON)で渡す
- Pandoc の docx は、日本語の文書に向かない。出力された zip を書き換えて、整える
- PDF は作れない。印刷用の HTML を作って、ブラウザの印刷に渡す
- docx、pptx、epub の中身(zip の中の XML)を、画面で見られる
完成画面
Markdown を書いて、Word にするところ
左が入力と出力の形式、中央がエディタとプレビュー、右がオプションです。サンプルの文書が最初から入っていて、開くとすぐに変換されます。
HTML のプレビューと、分割表示
docx の中身を見る(構造タブ)
Word で開いたところ
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 は、直接は、作れません。
そこで、次の形にしました。
- 印刷用の CSS(用紙、余白、ページをまたがない設定)を足した HTML を作る
- その HTML を、
iframeで表示する - 「印刷 / 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 しか読めません。
そこで、読み込みの時点で、次の順で、文字コードを決めています。
- BOM があれば、それに従う(UTF-8、UTF-16)
- UTF-8 として、厳密に読めたら、UTF-8
- だめなら、Shift_JIS と EUC-JP の両方で読んで、不正な文字が少なく、かなが多いほうを選ぶ
Pandoc には、いつも UTF-8 で渡します。Shift_JIS の .txt を、入れてみたところ、正しく読めました。
複数の文書
文書が複数あるときは、次の 2 つが選べます。
- 1 つずつ変換して、ZIP にまとめる
- 1 つにつなげて、変換する(名前順。数字は、数の大きさで並べる)
プレビュー
出力の形式によって、プレビューの作り方を変えています。
| 出力 | プレビュー |
|---|---|
| HTML | 出力そのもの |
| docx、odt、epub、pptx | 書き出したファイルを、Pandoc で読み直して、HTML にしたもの |
| 印刷用の 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 での確認と、日本語の文書に合う、引用のスタイルの用意です。