はじめに
こんにちは。Markdown変換ツール LoveMarkdown を開発している者です。
最近、MarkdownをPDFやWordに変換するWebサービスは増えていますが、ほとんどのサービスはファイルをサーバーにアップロードして処理しています。機密情報を含む文書を変換する際、これは少し気になりますよね。
そこで今回は、サーバーに一切ファイルを送らず、ブラウザだけで完結するMarkdown変換ツールの技術的な設計と実装について紹介します。
この記事で分かること
- クライアントサイド変換のアーキテクチャ設計
- KaTeX数式のMarkdownパーサー保護テクニック
- html2canvas + jsPDFによるPDF生成の落とし穴
- Cloudflare Workers + Next.js 16のデプロイ構成
アーキテクチャ概要
┌─────────────────────────────────────────────┐
│ Cloudflare Workers │
│ ┌─────────────────────────────────────┐ │
│ │ Next.js 16 (App Router) │ │
│ │ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ SSR │ │ Client-Side │ │ │
│ │ │ Pages │ │ Conversion │ │ │
│ │ └──────────┘ └──────────────────┘ │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
│ │
▼ ▼
Static Assets Browser APIs
(highlight.js, (html2canvas,
KaTeX CSS) File API, Blob)
ポイントは2つ:
- 変換処理はすべてクライアントサイド — ファイルはブラウザを一切出ない
- サーバーは静的アセットの配信のみ — APIエンドポイントなし、サーバーコストほぼゼロ
1. Markdown → HTML変換:KaTeX数式の保護
Markdown → HTML変換には marked ライブラリを使っていますが、KaTeX数式($E = mc^2$ や $$\int_0^\infty$$)がMarkdownパーサーに壊される問題がありました。
啾題
二次方程式 $ax^2 + bx + c = 0$ の解は
$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$
これを marked.parse() にそのまま渡すと、_(アンダースコア)がイタリック変換されたり、* が太字変換されたりして数式が壊れます。
解決策:プレースホルダー抽出方式
Markdownパーサーに渡す前に数式を抽出し、処理後に復元する方式を採用しました。
function extractMath(md: string): { md: string; placeholders: string[] } {
const placeholders: string[] = [];
// ディスプレイ数式($$...$$)を先に抽出
let processed = md.replace(/\$\$([\s\S]+?)\$\$/g, (_match, math) => {
const idx = placeholders.length;
placeholders.push(`display:${encodeURIComponent(math.trim())}`);
return `%%MATH_${idx}%%`;
});
// インライン数式($...$)を抽出
processed = processed.replace(
/(?<!\$)\$(?!\$)(.+?)(?<!\$)\$(?!\$)/g,
(_match, math) => {
const idx = placeholders.length;
placeholders.push(`inline:${encodeURIComponent(math.trim())}`);
return `%%MATH_${idx}%%`;
}
);
return { md: processed, placeholders };
}
処理フロー:
入力Markdown
↓
extractMath() → 数式を %%MATH_0%% に置換
↓
marked.parse() → HTML生成(プレースホルダーは無視される)
↓
restoreMath() → プレースホルダーをKaTeX HTMLに復元
↓
完成したHTML
%%MATH_0%% 這樣的占位符不会被Markdownパーサーに変換されるため、数式が安全に保護されます。
2. Markdown → PDF生成:3つの方式と最終選択
PDF生成は最も難易度の高い部分でした。3つの方式を試しました。
方式1: サーバーサイド生成(Puppeteer等)
- ❌ ファイルアップロードが必要(プライバシー問題)
- ❌ サーバーコストが発生
方式2: window.print() ブラウザ印刷
- ❌ ブラウザによって出力が大きく異なる
- ❌ レイアウト制御が困難
方式3: html2canvas + html2pdf.js ✅ 採用
- ✅ 100%クライアントサイド
- ✅ レイアウトを完全制御
- ✅ 一貫した出力
実装コード
async function exportToPdf(html: string, template: Template) {
// オフスクリーンコンテナを作成
const container = document.createElement("div");
container.innerHTML = `<style>${baseStyles}</style>${html}`;
// ★重要: html2canvasは<style>ブロックを無視する
// インラインスタイルを直接設定する必要がある
applyInlineStyles(container, template);
// コードブロックのオーバーフローを修正
container.querySelectorAll("pre").forEach((pre) => {
pre.style.setProperty("overflow", "visible", "important");
pre.style.setProperty("white-space", "pre", "important");
});
document.body.appendChild(container);
await html2pdf()
.set({
margin: [0.75, 0.5, 0.75, 0.5],
filename: "document.pdf",
html2canvas: { scale: 2, useCORS: true },
jsPDF: { unit: "in", format: "a4", orientation: "portrait" },
pagebreak: {
mode: ["avoid-all", "css", "legacy"],
avoid: ["pre", "blockquote", "table", "tr", "h1", "h2", "h3"],
},
})
.from(container)
.save();
document.body.removeChild(container);
}
⚠️ 落とし穴メモ
1. <style>ブロックは無視される
html2canvasはCSSOMを読まず、インラインスタイルのみを参照します。element.setAttribute("style", ...) で直接設定するか、element.style.setProperty() を使う必要があります。
2. 改ページ設定は3モード必須
pagebreak: {
mode: ["avoid-all", "css", "legacy"], // ← 3つ全部必要
avoid: ["pre", "blockquote", "table", "tr", "h1", "h2", "h3"],
}
mode に "avoid-all" を含めないと、avoid リストが無視されます。この落とし穴にはまりました。
3. コードブロックの表示切れ
デフォルトでは overflow: hidden がかかっているため、長いコードが途切れます。PDF出力前に明示的に overflow: visible に上書きする必要があります。
3. Word (.docx) 生成
Word生成には docx ライブラリを使い、Markdown要素をWordのParagraph/Heading/Runモデルにマッピングします。
import { Document, Packer, Paragraph, TextRun, HeadingLevel } from "docx";
function markdownToDocx(md: string, template: Template): Blob {
const children = parseMarkdown(md).map((element) => {
switch (element.type) {
case "h1":
return new Paragraph({
heading: HeadingLevel.HEADING_1,
children: [new TextRun({ text: element.text, font: template.fonts.heading })],
});
case "p":
return new Paragraph({
children: [new TextRun({ text: element.text, font: template.fonts.body })],
});
// ... 他の要素タイプ
}
});
const doc = new Document({ sections: [{ children }] });
return Packer.toBlob(doc);
}
Word生成で苦労した点:シンタックスハイライト
PDFやHTMLでは highlight.js でハイライトできますが、Word生成には対応していません。自前でキーワードマッチングによる簡易ハイライトを実装しました:
const KEYWORDS: Record<string, RegExp> = {
python: /\b(def|class|import|from|return|if|else|for|while|try|except)\b/g,
javascript: /\b(const|let|var|function|return|if|else|for|while|class|import|export)\b/g,
typescript: /\b(const|let|var|function|return|if|else|for|while|class|import|export|interface|type)\b/g,
};
完全ではありませんが、主要な言語のキーワードには対応しています。
4. 逆変換:PDF → Markdown
pdfjs-dist を使ってPDFからテキストを抽出し、ヒューリスティックで構造を推定します。
function detectHeading(line: string): string | null {
const trimmed = line.trim();
// 全大文字で80文字未満 → 見出しと推定
if (trimmed === trimmed.toUpperCase() && trimmed.length < 80 && trimmed.length > 3) {
return `## ${trimmed}`;
}
return null;
}
実際のPDFは様々なので完璧ではありませんが、多くの一般的なドキュメントに対してうまく動作します。
5. Cloudflare Workers + Next.js 16 デプロイ
Cloudflare WorkersはNode.js APIをネイティブサポートしていないため、標準の next start では動きません。OpenNext アダプターを使ってビルド成果物を変換します。
ビルドスクリプトの再帰問題
OpenNextのビルドは内部的に npm run build を再実行します。もし build スクリプトがOpenNext自身を指していると、無限ループが発生します。
// scripts/cf-build.js
if (process.env.OPENNEXT_PARENT === "1") {
// 内部呼び出し: 通常のNext.jsビルド
run("npx", ["next", "build"]);
} else {
// 外部呼び出し: OpenNextビルド
run("npx", ["opennextjs-cloudflare", "build"], {
...process.env,
OPENNEXT_PARENT: "1",
});
}
OPENNEXT_PARENT 環境変数で内部呼び出しを識別し、通常の next build のみを実行します。
デプロイ
npm run build # .open-next/ アーティファクトを生成
npx wrangler deploy # Cloudflareにプッシュ
これで全世界のCloudflareエッジから配信されます。
使用ライブラリまとめ
| ライブラリ | 用途 |
|---|---|
marked |
Markdown → HTML パース |
highlight.js |
シンタックスハイライト |
katex |
LaTeX数式レンダリング |
html2canvas |
DOM → canvasキャプチャ |
html2pdf.js |
canvas → PDF生成 |
docx |
Word文書生成 |
pdfjs-dist |
PDFテキスト抽出 |
mammoth |
Word → HTML変換 |
turndown |
HTML → Markdown変換 |
まとめと学び
-
html2canvasはインラインスタイルしか読まない —
<style>ブロックは必ずインラインに変換する - KaTeXはパーサー前に抽出する — プレースホルダー方式で安全に保護
-
改ページは3モード指定が必須 —
avoid-allを含めないとavoidが効かない - クライアントサイド変換 = サーバーコストゼロ — 静的アセット配信のみで完全に動作
- OpenNextのビルド再帰に注意 — 環境変数で内部呼び出しを識別する
実際に使ってみる
LoveMarkdown で動作確認できます。無料・登録不要・ウォーターマークなしです。
ご質問やフィードバックがあれば、コメント欄にお願いします。
この記事は LoveMarkdown の技術紹介記事です。