0
0

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からWordファイルを作成する

0
Posted at

概要

サーバーサイド処理を一切挟まず、ブラウザ上のJavaScriptのみ(完全フロントエンド)でMarkdownテキストおよびMermaidダイアグラムをWord(.docx)ファイルに変換・出力するWebアプリケーションです。

以下の解説を含めて、ほぼ生成AIが作りましたが、生成AIが作ったmarkdown形式の出力を、書類に起こす場合などちょっとWordにしたい時(Pandocがインストールできない、めんどくさい時など)には便利かと思います。

デモ

デモ・コード全体


1. 全体構成

  • マークダウン解析 (marked.js v18): テキストを構文木(AST/Tokens)に分解し、見出し、段落、リスト、テーブル構造を取得します。
  • 図表レンダリング (Mermaid.js v11): ````mermaid ` コードブロックを検出して、DOM上の非表示領域でSVGベクトル画像として描画します。
  • 画像変換 (HTML5 Canvas & Data URL): 生成されたSVGを2倍スケーリング(高画質化)した上でキャンバスに再描画し、透過背景を白で塗りつぶしたPNGバイナリ(Uint8Array)へ変換します。
  • Word文書生成 (docx.js v9): 抽出したテキスト・装飾要素・PNG画像データ(ImageRun)を組み合わせてOffice Open XML形式(.docx)を構築し、ブラウザでバイナリ Blob を生成してダウンロードさせます。

2. ブロック別

HTML

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Markdown to Word Converter (Full)</title>
  <style>
  //省略
  </style>
</head>
<body>
  <h1>Markdown → Word 変換 (テーブル・番号付きリスト対応)</h1>
  <textarea id="markdownInput" placeholder="ここにMarkdownを入力してください...">
# 1. サンプルドキュメント
これはテストです。**太字** や *斜体* が使えます。
## 2. リストのテスト
- 通常の箇条書き1
- 通常の箇条書き2
1. 番号付きリストの1番目
2. 番号付きリストの2番目
## 3. 表(テーブル)のテスト
| 商品名 | 価格 | 備考 |
| :--- | :--- | :--- |
| りんご | 150円 | 青森県産 |
| バナナ | 200円 | フィリピン産 |
| メロン | 1,200円 | 高級品 |
# 2. Mermaidダイアグラムのテスト
```mermaid
flowchart TB
    A[起床] --> B[顔を洗う] -->
    C{眠い?} -- 眠くない --> D[朝食を食べる]
    C{眠い?} -- 眠い --> E[二度寝する] -- 起きる --> C{眠い?}
    D[朝食を食べる] --> 
    F[歯を磨く] -->
    G((やっぱり寝る))
    ```
</textarea>
  <br>
  <button id="downloadBtn">Wordファイルを出力する</button> 
  <script type='module'>
  // 後述
  </script>
</body>
</html>  

モジュールインポートとライブラリ初期化

import * as docx from '[https://cdn.jsdelivr.net/npm/docx@9.7.1/+esm](https://cdn.jsdelivr.net/npm/docx@9.7.1/+esm)';
import * as marked from '[https://cdn.jsdelivr.net/npm/marked@18.0.14/+esm](https://cdn.jsdelivr.net/npm/marked@18.0.14/+esm)';
import mermaid from '[https://cdn.jsdelivr.net/npm/mermaid@11.4.1/+esm](https://cdn.jsdelivr.net/npm/mermaid@11.4.1/+esm)';

// Mermaidの初期設定
mermaid.initialize({
  startOnLoad: false,
  theme: 'default',
  securityLevel: 'loose'
});
  • ES Modules (+esm): CDNから直接ESモジュールとしてライブラリを取り込んでいます。
  • Mermaid初期化: 自動レンダリング(startOnLoad: false)をオフにし、変換ボタンが押されたタイミングでJavaScript制御により動的に個別描画できるように設定しています。

イベントハンドラーと主変換フロー (downloadBtn.addEventListener)

const downloadBtn = document.getElementById('downloadBtn');

downloadBtn.addEventListener('click', async () => {
  const mdText = document.getElementById('markdownInput').value;
  // ...
  downloadBtn.disabled = true;
  downloadBtn.textContent = '変換中...';

  try {
    const wordChildren = [];
    let mermaidCounter = 0;

    // ```mermaid ... ``` を抽出する正規表現
    const mermaidRegex = /```mermaid\s*\n?([\s\S]*?)```/gi;
    let lastIndex = 0;
    let match;

    while ((match = mermaidRegex.exec(mdText)) !== null) {
      // 1. 直前のMarkdownテキスト部分を切り出してWord要素へ追加
      const textBefore = mdText.substring(lastIndex, match.index);
      if (textBefore.trim()) {
        const tokens = marked.lexer(textBefore);
        appendTokensToWord(tokens, wordChildren);
      }

      // 2. Mermaidブロック部分を非同期でPNG化して Word (ImageRun) へ挿入
      const mermaidCode = match[1].trim();
      mermaidCounter++;
      
      try {
        const imageData = await renderMermaidToPng(mermaidCode, `mermaid-${mermaidCounter}-${Date.now()}`);
        
        wordChildren.push(new docx.Paragraph({
          children: [
            new docx.ImageRun({
              data: imageData.arrayBuffer,
              transformation: {
                width: imageData.width,
                height: imageData.height
              },
              type: 'png'
            })
          ],
          spacing: { before: 120, after: 120 },
          alignment: docx.AlignmentType.CENTER
        }));
      } catch (err) {
        // ... エラー時は赤いフォールバック文字列を出力
      }

      lastIndex = mermaidRegex.lastIndex;
    }

    // 3. 最後のMermaidより後に残っているテキストを処理
    const remainingText = mdText.substring(lastIndex);
    if (remainingText.trim()) {
      const tokens = marked.lexer(remainingText);
      appendTokensToWord(tokens, wordChildren);
    }
  • 正規表現による最優先セパレート処理 (mermaidRegex):
    Markdownパーサーのバグやネスト仕様変更に依存しないよう、入力テキスト全体から mermaid ... を直接検出します。
  • インターリーブ型テキストスライス:
    「通常のMarkdownブロック」→「Mermaid(画像)」→「通常のMarkdownブロック」の順にテキストの位置インデックス(lastIndex)を保持しながら順番通りにパースして配列 wordChildren(Wordの段落要素集積配列)に詰め込んでいきます。

Word文書オブジェクト生成とファイルダウンロード

    // Wordドキュメント作成
    const doc = new docx.Document({
      numbering: {
        config: [
          {
            reference: "ordered-list-style",
            levels: [{ level: 0, format: docx.NumberFormat.DECIMAL, text: "%1.", alignment: docx.AlignmentType.LEFT }],
          },
        ],
      },
      sections: [{
        properties: {},
        children: wordChildren
      }]
    });

    // ダウンロード処理
    const blob = await docx.Packer.toBlob(doc);
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'markdown_mermaid_export.docx';
    a.click();
    URL.revokeObjectURL(url);

  • ナンバリング設定(numbering): 番号付きリストがWord文書内で正しくインデント・自動採番されるよう、スタイルID (ordered-list-style) をあらかじめ定義しています。
  • メモリ上でのBlob生成と自動DL: docx.Packer.toBlob() でドキュメントをバイナリ化し、仮想URL(URL.createObjectURL)を作ってアンカータグ(<a>)の擬似クリックイベントで自動ダウンロードを実行しています。

マークダウントークンからWordノードへのマッピング (appendTokensToWord)

function appendTokensToWord(tokens, wordChildren) {
  for (const token of tokens) {
    if (token.type === 'heading') {
      wordChildren.push(new docx.Paragraph({
        text: token.text,
        heading: docx.HeadingLevel[`HEADING_${token.depth}`] || docx.HeadingLevel.HEADING_1,
        spacing: { before: 240, after: 120 }
      }));
    } 
    // ... code, paragraph, list, table の処理

  • ASTマッピング: marked.lexer() が抽出した各種トークン型(heading, paragraph, list, table)を判別し、それぞれ docx.Paragraph や docx.Table オブジェクトへ変換します。
  • テーブル構造の構築: 表要素(type === 'table')では、docx.TableRow および docx.TableCell をループ構築し、ヘッダー行には背景色(F2F2F2)や太字の装飾を適用しています。

インライン文字装飾の処理 (parseInlineTokens)

function parseInlineTokens(inlineTokens) {
  if (!inlineTokens) return [];
  return inlineTokens.map(token => {
    if (token.type === 'strong') {
      return new docx.TextRun({ text: token.text, bold: true });
    } else if (token.type === 'em') {
      return new docx.TextRun({ text: token.text, italics: true });
    } else if (token.type === 'text') {
      return new docx.TextRun({ text: token.text });
    }
    return new docx.TextRun({ text: token.text || token.raw || '' });
  });
}

  • TextRun によるインライン装飾: 段落内のテキスト要素(インライン)を走査し、strong(太字)や em(斜体)などの条件に応じて docx.TextRun のプロパティ(bold: true, italics: true)を設定します。

ブロック 6: Mermaidから高画質PNGへの変換処理 (renderMermaidToPng)

このプログラムの中で技術的に最も高度な工夫が施されている箇所です。

async function renderMermaidToPng(code, id) {
  let renderArea = document.getElementById('mermaidRenderArea');
  if (!renderArea) {
    renderArea = document.createElement('div');
    renderArea.id = 'mermaidRenderArea';
    // 描画およびviewBox取得のため非表示(opacity:0)でDOM配置
    renderArea.style.position = 'fixed';
    renderArea.style.left = '-9999px';
    renderArea.style.top = '-9999px';
    renderArea.style.opacity = '0';
    document.body.appendChild(renderArea);
  }
  renderArea.innerHTML = '';

  // 1. Mermaid SVG描画
  const { svg } = await mermaid.render(id, code);
  renderArea.innerHTML = svg;
  const svgElement = renderArea.querySelector('svg');

  // 2. viewBox属性からの正確な縦横比(アスペクト比)取得
  let width = 600;
  let height = 400;
  const viewBox = svgElement.getAttribute('viewBox');
  if (viewBox) {
    const parts = viewBox.split(/[\s,]+/).map(Number);
    if (parts.length === 4 && parts[2] > 0 && parts[3] > 0) {
      width = parts[2];
      height = parts[3];
    }
  }

  // 3. Word領域(最大480pt)に収まるようアスペクト比を維持してスケーリング
  const maxWidth = 480;
  if (width > maxWidth) {
    height = (height * maxWidth) / width;
    width = maxWidth;
  }

  // XML構造の破損(Word破損エラー)防止のため寸法を整数化
  width = Math.round(width);
  height = Math.round(height);

  // 4. 高解像度Canvas(2倍スケール)の作成と背景色(白)塗りつぶし
  const scale = 2;
  const canvas = document.createElement('canvas');
  canvas.width = width * scale;
  canvas.height = height * scale;
  const ctx = canvas.getContext('2d');
  ctx.scale(scale, scale);

  ctx.fillStyle = '#ffffff';
  ctx.fillRect(0, 0, width, height);

  // 5. Data URLによる安全なHTMLImageElement読み込み(Tainted Canvas対策)
  svgElement.setAttribute('width', width);
  svgElement.setAttribute('height', height);
  const svgString = new XMLSerializer().serializeToString(svgElement);
  const dataUrl = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgString);

  return new Promise((resolve, reject) => {
    const img = new Image();
    img.crossOrigin = 'anonymous';
    img.onload = () => {
      ctx.drawImage(img, 0, 0, width, height);

      try {
        const dataURI = canvas.toDataURL('image/png');
        // Base64からUint8Arrayバイナリ配列へ変換
        const byteString = atob(dataURI.split(',')[1]);
        const ab = new ArrayBuffer(byteString.length);
        const ia = new Uint8Array(ab);
        for (let i = 0; i < byteString.length; i++) {
          ia[i] = byteString.charCodeAt(i);
        }

        resolve({ arrayBuffer: ia, width: width, height: height });
      } catch (e) {
        reject(e);
      }
    };
    img.onerror = () => reject(new Error('SVG画像の読み込みに失敗しました。'));
    img.src = dataUrl;
  });
}

その他ポイント:

  1. 画面外DOMレンダリング (position: fixed; left: -9999px):
    Mermaidのレンダリングエンジンは実際のDOMツリー上に要素が存在しないとレイアウト計算が行えません。そのため、画面外かつ透明(opacity: 0)な隠しコンテナを用意してSVGを出力させています。
  2. アスペクト比崩れ対策 (viewBox 解析):
    非表示DOM領域で getBoundingClientRect() を取るとサイズ情報が崩れやすいため、SVG内部の viewBox="0 0 W H" 属性から本来の図形比率を取得し、画像の縦横比が狂う現象(縦長潰れなど)を防いでいます。
  3. Word文書破損防止の寸法整数化 (Math.round):
    Word(.docx)内部のXML表記において、画像変換サイズ(EMU等)に小数が含まれているとWord起動時にファイル破損警告が出ることがあります。これを防ぐために幅と高さを必ず整数(Math.round)に丸めています。
  4. CORS/Tainted Canvas回避 (data:image/svg+xml;...):
    通常 URL.createObjectURL(blob) でCanvasに描画した画像はセキュリティ上の「汚染されたキャンバス」と判定され、toDataURL や toBlob がブロックされます。これを回避するため、SVG文字列を encodeURIComponent して直に Data URL へ埋め込む手法を採用しています。
  5. 透過画像の黒化防止:
    SVGから書き出すデフォルトのCanvas背景は透明(黒透過)になるため、あらかじめ ctx.fillStyle = '#ffffff'; ctx.fillRect(...) で白地の背景を塗ってから図形を描画させています。
0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?