Back to Blog

Images Missing When Exporting Markdown to Word? How We Solved Cross-Origin and WebP Compatibility Issues

MD-TO Team

While building the Markdown-to-Word export feature at md-to.com, we hit a frustrating bug: images in users’ Markdown content vanished after exporting to Word.

The root cause turned out to involve two layers: browser security policy (CORS) and image format compatibility (WebP). This post documents the debugging process and our final solution.


The Symptom

A user pastes Markdown like this into the editor:

![Editor interface](https://example.com/screenshot.webp)
![Site icon](https://example.com/icon.png)

The preview renders both images just fine. But in the exported Word file, both images are gone — replaced by placeholder text like [Editor interface] and [Site icon].


Problem 1: Word Doesn’t Support WebP

Root Cause

We use the docx library to generate Word documents. Its ImageRun component only supports four image formats:

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

Modern websites heavily use WebP (25-35% smaller than PNG). When our code encountered an unsupported format, it threw an Unsupported image type error and the image was silently dropped.

Solution: Auto-Convert via Canvas

Browsers natively render WebP, so we can use the Canvas API to convert it to 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);
  });
}

This function accepts two types of input:

  • Blob: for pure format conversion of already-fetched images
  • URL string: for CORS fallback (more on this below)

Regardless of input format (WebP, AVIF, etc.), the output is always PNG — which Word handles perfectly.


Problem 2: CORS Blocks Cross-Origin Image Fetching

After fixing the WebP issue, we discovered a more fundamental problem: cross-origin images couldn’t be fetched at all.

Root Cause

Our code uses fetch() to get image data:

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

When the image URL is on a different domain than the website (e.g., site is md-to.com, image is on example.com), the browser’s CORS (Cross-Origin Resource Sharing) policy blocks the request:

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

Why Does Preview Work but Export Doesn’t?

Because the browser applies different security levels to different operations:

OperationCross-origin allowed?Why
<img src="external"> rendering✅ YesBrowser renders images without CORS
fetch() data access❌ BlockedJS reading binary data requires CORS
<img crossOrigin> + Canvas export❌ BlockedTainted canvas cannot export data
fetch({mode: 'no-cors'})❌ UnreadableReturns empty body

The preview uses <img> tags (cross-origin rendering allowed), while export needs JavaScript to read the image’s binary data (blocked by CORS). That’s why you can “see it but can’t export it.”

Why Base64 Doesn’t Help Either

You might think: “Just convert images to Base64!”

But Base64 is just an encoding format, not a data acquisition method. The problem isn’t “what format to store in” — it’s “the browser won’t let you access the raw bytes.” Whether you want to encode as Base64, ArrayBuffer, or anything else, the first step is getting the data — and CORS blocks that completely.

Solution: Server-Side Image Proxy

Since there’s no browser-side workaround, we let the server fetch the images. Servers have no CORS restrictions and can freely request any URL.

We created a lightweight Edge Function as an image proxy:

Browser → /api/image-proxy?url=https://example.com/image.png → Our server → example.com
                                                                ↑ No CORS restriction

Proxy function (deployed at the edge):

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

  // Security: only http(s), only image types, size limit
  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': '*',
    },
  });
}

Client-side auto-routing:

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

  // Route cross-origin images through proxy
  if (new URL(resolved).origin !== window.location.origin) {
    return `/api/image-proxy?url=${encodeURIComponent(resolved)}`;
  }

  return resolved;
}

All cross-origin image requests now become same-origin requests — no more CORS issues.


Final Architecture

Image URL in Markdown


  resolveImageSrc()

        ├── Same-origin → Direct fetch

        └── Cross-origin → /api/image-proxy?url=xxx


                        Edge Function forwards


                          Got Blob


                    getDocxImageType() check format

                    ┌─────────┴──────────┐
                    ▼                    ▼
              jpg/png/gif/bmp      webp/avif/other
              Embed directly      Canvas → PNG → Embed

Development Environment

A matching proxy middleware was added to the Vite dev server, ensuring consistent behavior between development and production.

Security Measures

The image proxy is not an unrestricted open proxy:

  • Only allows http:// and https:// protocols
  • Only passes through image Content-Types (png/jpeg/gif/bmp/webp/avif/svg)
  • 10 MB size limit per image
  • 24-hour cache on responses

Summary

ProblemCauseSolution
WebP images don’t appear in Worddocx library doesn’t support WebPCanvas API converts to PNG
Cross-origin images can’t be fetchedBrowser CORS security policyServer-side Edge Function proxy
Images fail in dev environment toolocalhost → external domain is cross-originVite dev server proxy middleware

Key takeaway: The browser’s security model distinguishes between “rendering” and “data access.” Just because <img> can display a cross-origin image doesn’t mean JavaScript can read its data. When you need client-side access to cross-origin resources, a server-side proxy is virtually the only reliable approach.


Want to try it out? Visit md-to.com Markdown to Word, paste some Markdown with external images, and export.