ブログに戻る

Markdown を Word にエクスポートすると画像が消える?クロスオリジンと WebP 互換性の解決方法

MD-TO チーム

md-to.com の Markdown から Word へのエクスポート機能を開発中、厄介なバグに遭遇しました:ユーザーが貼り付けた Markdown 内の画像が、Word にエクスポートするとすべて消えてしまうという問題です。

原因は2つの層にまたがっていました:ブラウザのセキュリティポリシー(CORS)と画像フォーマットの互換性(WebP)。この記事では、デバッグの過程と最終的な解決策を詳しく記録します。


症状

ユーザーがエディタに以下のような Markdown を貼り付けます:

![エディタ画面](https://example.com/screenshot.webp)
![サイトアイコン](https://example.com/icon.png)

プレビューでは画像が正常に表示されます。しかしエクスポートした Word ファイルでは、2枚とも画像が消えて[エディタ画面][サイトアイコン] というプレースホルダーテキストだけが残ります。


問題1:Word が WebP をサポートしていない

原因分析

Word ドキュメントの生成には docx ライブラリを使用しています。このライブラリの ImageRun コンポーネントは4種類の画像フォーマットのみサポートしています:

type DocxImageType = 'jpg' | 'png' | 'gif' | 'bmp';

現代のウェブサイトでは WebP フォーマットが広く使われています(PNG より25-35%小さい)。コードがサポート外のフォーマットを検出すると、Unsupported image type エラーをスローし、画像が失われていました。

解決策:Canvas による自動変換

ブラウザは WebP のレンダリングをネイティブサポートしているため、Canvas API を使って PNG に変換できます:

function loadImageViaCanvas(
  source: Blob | string,
): Promise<{ blob: Blob; width: number; height: number }> {
  return new Promise((resolve, reject) => {
    const img = new Image();
    const isBlobSource = source instanceof Blob;
    if (!isBlobSource) {
      img.crossOrigin = 'anonymous';
    }
    const blobUrl = isBlobSource ? URL.createObjectURL(source) : null;

    img.onload = () => {
      if (blobUrl) URL.revokeObjectURL(blobUrl);
      const { naturalWidth: width, naturalHeight: height } = img;
      const canvas = document.createElement('canvas');
      canvas.width = width;
      canvas.height = height;
      const ctx = canvas.getContext('2d')!;
      ctx.drawImage(img, 0, 0);
      canvas.toBlob(
        (pngBlob) => {
          if (pngBlob) resolve({ blob: pngBlob, width, height });
          else reject(new Error('Canvas toBlob failed'));
        },
        'image/png',
      );
    };
    img.onerror = () => reject(new Error('Failed to load image'));
    img.src = blobUrl ?? (source as string);
  });
}

この関数は2種類の入力を受け付けます:

  • Blob:すでに fetch 済みの画像のフォーマット変換用
  • URL 文字列:CORS フォールバック用(後述)

入力フォーマットが何であれ(WebP、AVIF など)、出力は常に PNG です——Word で完璧に動作します。


問題2:CORS がクロスオリジン画像の取得をブロック

WebP の問題を解決した後、より根本的な問題を発見しました:クロスオリジンの画像がまったく取得できない

原因分析

コードでは fetch() を使って画像データを取得しています:

const response = await fetch(imageUrl);
const blob = await response.blob();

画像の URL がウェブサイトと異なるドメインにある場合(例:サイトは md-to.com、画像は example.com)、ブラウザの CORS(オリジン間リソース共有) セキュリティポリシーがリクエストをブロックします:

Access to fetch at 'https://example.com/image.png'
from origin 'https://md-to.com' has been blocked by CORS policy

なぜプレビューは表示されるのにエクスポートできないのか?

ブラウザが操作ごとに異なるセキュリティレベルを適用しているためです:

操作クロスオリジン許可理由
<img src="外部URL"> レンダリング✅ 許可ブラウザの画像表示に CORS は不要
fetch() データ取得❌ ブロックJS によるバイナリデータ読み取りには CORS が必要
<img crossOrigin> + Canvas 抽出❌ ブロック汚染された Canvas はデータ抽出不可
fetch({mode: 'no-cors'})❌ 読み取り不可空の body が返される

プレビュー<img> タグを使用(クロスオリジンレンダリング許可)、エクスポートは JS で画像のバイナリデータを読み取る必要があります(CORS でブロック)。これが「見えるのにエクスポートできない」理由です。

Base64 でも解決できない理由

「画像を Base64 に変換すればいいのでは?」と思うかもしれません。

しかし Base64 は単なるエンコード形式であり、データ取得手段ではありません。問題は「どのフォーマットで保存するか」ではなく、「ブラウザが生のバイトデータへのアクセスを許可しない」ことです。Base64、ArrayBuffer、その他何に変換するにしても、まずデータを取得する必要があります——そしてそのステップが CORS によって完全にブロックされています。

解決策:サーバーサイド画像プロキシ

ブラウザ側で解決できないなら、サーバーに画像を取得させます。サーバーには CORS の制限がなく、任意の URL を自由にリクエストできます。

軽量な Edge Function を画像プロキシとして作成しました:

ブラウザ → /api/image-proxy?url=https://example.com/image.png → 自社サーバー → example.com
                                                                 ↑ CORS制限なし

プロキシ関数(Edge にデプロイ):

export async function onRequest({ request }) {
  const { searchParams } = new URL(request.url);
  const target = searchParams.get('url');

  // セキュリティ:http(s)のみ、画像タイプのみ、サイズ制限
  const upstream = await fetch(target);
  const contentType = upstream.headers.get('content-type');

  return new Response(await upstream.arrayBuffer(), {
    headers: {
      'Content-Type': contentType,
      'Cache-Control': 'public, max-age=86400',
      'Access-Control-Allow-Origin': '*',
    },
  });
}

クライアントサイドの自動ルーティング

function resolveImageSrc(src: string): string {
  const resolved = new URL(src, window.location.href).toString();

  // クロスオリジン画像をプロキシ経由にルーティング
  if (new URL(resolved).origin !== window.location.origin) {
    return `/api/image-proxy?url=${encodeURIComponent(resolved)}`;
  }

  return resolved;
}

これにより、すべてのクロスオリジン画像リクエストが同一オリジンリクエストに変わり、CORS の問題が解消されます。


最終アーキテクチャ

Markdown 内の画像 URL


  resolveImageSrc()

        ├── 同一オリジン → 直接 fetch

        └── クロスオリジン → /api/image-proxy?url=xxx


                        Edge Function が転送


                         Blob を取得


                    getDocxImageType() フォーマット確認

                    ┌─────────┴──────────┐
                    ▼                    ▼
              jpg/png/gif/bmp      webp/avif/その他
              Word に直接埋め込み   Canvas → PNG → 埋め込み

開発環境

Vite dev server に同じパスのプロキシミドルウェアを追加し、開発環境と本番環境の動作を統一しています。

セキュリティ対策

画像プロキシは無制限のオープンプロキシではありません:

  • http://https:// プロトコルのみ許可
  • 画像の Content-Type のみ通過(png/jpeg/gif/bmp/webp/avif/svg)
  • 1画像あたり 10 MB のサイズ制限
  • レスポンスに24時間キャッシュ

まとめ

問題原因解決策
WebP 画像が Word に表示されないdocx ライブラリが WebP 非対応Canvas API で PNG に変換
クロスオリジン画像が取得できないブラウザの CORS セキュリティポリシーサーバーサイド Edge Function プロキシ
開発環境でも画像が失敗するlocalhost → 外部ドメインはクロスオリジンVite dev server プロキシミドルウェア

重要な教訓:ブラウザのセキュリティモデルは「レンダリング」と「データアクセス」を区別している<img> がクロスオリジン画像を表示できるからといって、JavaScript がそのデータを読み取れるわけではありません。クライアントサイドでクロスオリジンリソースを処理する必要がある場合、サーバーサイドプロキシがほぼ唯一の信頼できるアプローチです。


実際に試してみませんか?md-to.com Markdown to Word にアクセスして、外部画像を含む Markdown を貼り付けてエクスポートしてみてください。