返回博客

Markdown 导出 Word 时图片丢失?我们是如何解决跨域图片和 WebP 兼容性问题的

MD-TO 团队

在开发 md-to.com 的 Markdown 转 Word 功能时,我们遇到了一个让人头疼的问题:用户粘贴的 Markdown 里的图片,导出成 Word 后全部丢失了

这个问题最终涉及两个层面:浏览器安全策略(CORS)和图片格式兼容性(WebP)。本文详细记录问题的排查过程和最终解决方案。


问题现象

用户在编辑器中粘贴了这样的 Markdown:

![编辑器界面](https://example.com/screenshot.webp)
![网站图标](https://example.com/icon.png)

预览区域图片正常显示,但导出的 Word 文件中两张图片都不见了,只剩下 [编辑器界面][网站图标] 这样的占位文字。


问题一:WebP 格式不被 Word 支持

原因分析

我们使用 docx 库生成 Word 文档。这个库的 ImageRun 组件只支持四种图片格式:

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

而现代网站大量使用 WebP 格式(体积比 PNG 小 25-35%)。当我们的代码检测到不支持的格式时,直接抛出了 Unsupported image type 错误,图片就丢失了。

解决方案:Canvas 自动转换

浏览器原生支持渲染 WebP,所以我们可以利用 Canvas API 把 WebP 转成 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);
  });
}

这个函数接受两种输入:

  • Blob:用于已经 fetch 到的图片做纯格式转换
  • URL 字符串:用于 CORS 兜底(后面详述)

无论输入是什么格式(WebP、AVIF 等),输出始终是 PNG——Word 完美支持。


问题二: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;
}

这样所有跨域图片请求变成了同域请求,浏览器不再拦截。


最终架构

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)
  • 单张图片大小限制 10 MB
  • 响应带 24 小时缓存

总结

问题原因方案
WebP 图片在 Word 中不显示docx 库不支持 WebP 格式Canvas API 转为 PNG
跨域图片完全无法获取浏览器 CORS 安全策略服务端 Edge Function 代理
开发环境图片也失败localhost → 外部域名是跨域Vite dev server 代理中间件

核心教训:浏览器的安全模型区分”渲染”和”数据访问”<img> 能显示跨域图片不代表 JS 能读取它的数据。遇到需要在客户端处理跨域资源的场景,服务端代理几乎是唯一可靠的方案。


想试试效果?访问 md-to.com Markdown 转 Word,粘贴带有外部图片的 Markdown,导出看看。