1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザだけで動くMarkdown変換ツールを作った話 〜サーバーに一切ファイルを送らない設計と実装〜

1
Posted at

はじめに

こんにちは。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つ:

  1. 変換処理はすべてクライアントサイド — ファイルはブラウザを一切出ない
  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変換

まとめと学び

  1. html2canvasはインラインスタイルしか読まない<style>ブロックは必ずインラインに変換する
  2. KaTeXはパーサー前に抽出する — プレースホルダー方式で安全に保護
  3. 改ページは3モード指定が必須avoid-all を含めないと avoid が効かない
  4. クライアントサイド変換 = サーバーコストゼロ — 静的アセット配信のみで完全に動作
  5. OpenNextのビルド再帰に注意 — 環境変数で内部呼び出しを識別する

実際に使ってみる

LoveMarkdown で動作確認できます。無料・登録不要・ウォーターマークなしです。

ご質問やフィードバックがあれば、コメント欄にお願いします。


この記事は LoveMarkdown の技術紹介記事です。

1
2
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?