Vue导出Html为Word中包含图片在Microsoft Word显示异常问题

发布于:2025-07-19 ⋅ 阅读:(16) ⋅ 点赞:(0)

问题背景

碰到一个问题:将包含图片和SVG数学公式的HTML内容导出为Word文档时,将图片都转为ase64格式导出,在WPS Word中显示正常,但是在Microsoft Word中出现图片示异常。
在这里插入图片描述

具体问题表现

  1. WPS兼容性:在WPS中显示正常,说明是Microsoft Word特有的兼容性问题
  2. SVG数学公式:在Word中显示为"当前无法显示此图片"
  3. 普通图片:显示不正常或无法显示

技术方案设计

三重兜底机制

我们设计了一个三层处理机制,确保在各种情况下都能提供最佳的用户体验:

第一层:前端Canvas转Base64
const imageUrlToWordCompatibleBase64 = async (imageUrl: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    try {
      const img = new Image();
      
      img.onload = () => {
        try {
          const canvas = document.createElement('canvas');
          const ctx = canvas.getContext('2d');
          if (!ctx) {
            reject(new Error('can not create canvas context'));
            return;
          }
          
          // 设置Canvas尺寸
          canvas.width = img.width;
          canvas.height = img.height;
          
          // 设置白色背景
          ctx.fillStyle = 'white';
          ctx.fillRect(0, 0, canvas.width, canvas.height);
          
          // 绘制图片
          ctx.drawImage(img, 0, 0);
          
          // 转换为PNG base64,使用更兼容的格式
          const base64 = canvas.toDataURL('image/png, 0.9');
          resolve(base64);
        } catch (error) {
          reject(error);
        }
      };
      
      img.onerror = () => {
        reject(new Error(`image load failed: ${imageUrl}`));
      };
      
      img.src = imageUrl;
    } catch (error) {
      reject(error);
    }
  });
};
第二层:后端代理转Base64
const getImageBase64ViaProxy = async ({imageUrl, imageId}: {imageUrl: string, imageId: number}): Promise<string> => {
  try {
    // 添加随机延迟避免缓存
    const randomDelay = Math.random() * 2000 + 1000;
    await new Promise(resolve => setTimeout(resolve, randomDelay));
    
    const response = await adminApi.getImageBase64Api({ 
      imageId: imageId,
      imagePath: imageUrl,
    });
    
    const data = response.data;
    if (!data) {
      throw new Error('image base64 is null');
    }
    
    // 处理后端返回的新结构 { imageId: string, imageBase64: string }
    if (data && typeof data === 'object' && 'imageId' in data && 'imageBase64' in data) {
      const imageBase64 = data.imageBase64;
      
      if (typeof imageBase64 === 'string') {
        if (imageBase64.startsWith('data:image/')) {
          return imageBase64;
        } else {
          return `data:image/png;base64,${imageBase64}`;
        }
      }
    }
    
    // 兼容旧格式:如果后端返回的是base64字符串,直接返回
    if (typeof data === 'string') {
      const dataStr = data as string;
      if (dataStr.startsWith('data:image/')) {
        return dataStr;
      } else {
        return `data:image/png;base64,${dataStr}`;
      }
    }
    
    throw new Error('Unsupported data format from backend');
  } catch (error) {
    console.error(`处理图片失败 ImageId: ${imageId}`, error);
    throw error;
  }
};
第三层:降级为Alt提示

当图片处理失败时,提供一个友好的降级方案:

// 降级为alt提示
imgElement.style.maxWidth = '100%';
imgElement.style.height = 'auto';
imgElement.style.display = 'inline-block';
imgElement.style.margin = '8px 0';
imgElement.style.borderRadius = '4px';
imgElement.style.border = '1px solid #ddd';
imgElement.style.padding = '4px';
imgElement.style.backgroundColor = '#f9f9f9';
if (!imgElement.alt) {
  imgElement.alt = `image: ${src}`;
}

SVG数学公式特殊处理

针对SVG数学公式,我们设计了专门的转换方案:

const svgToWordCompatiblePng = async (svgElement: SVGElement, width: number, height: number): Promise<string> => {
  return new Promise((resolve, reject) => {
    try {
      // 克隆SVG元素
      const clonedSvg = svgElement.cloneNode(true) as SVGElement;
      
      // 设置SVG尺寸
      clonedSvg.setAttribute('width', width.toString());
      clonedSvg.setAttribute('height', height.toString());
      
      // 设置viewBox以保持比例
      if (!clonedSvg.getAttribute('viewBox')) {
        clonedSvg.setAttribute('viewBox', `0 0 ${width} ${height}`);
      }
      
      // 序列化SVG
      const serializer = new XMLSerializer();
      const svgString = serializer.serializeToString(clonedSvg);
      
      // 创建SVG的data URL
      const svgDataUrl = 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svgString)));
      
      // 创建Image对象
      const img = new Image();
      img.onload = () => {
        try {
          // 创建Canvas
          const canvas = document.createElement('canvas');
          const ctx = canvas.getContext('2d');
          if (!ctx) {
            reject(new Error('can not create canvas context'));
            return;
          }
          
          // 设置Canvas尺寸
          canvas.width = width;
          canvas.height = height;
          
          // 设置白色背景(确保透明度问题)
          ctx.fillStyle = 'white';
          ctx.fillRect(0, 0, width, height);
          
          // 绘制图片到Canvas
          ctx.drawImage(img, 0, 0, width, height);
          
          // 转换为PNG base64,使用更兼容的格式
          const pngBase64 = canvas.toDataURL('image/png, 0.9');
          resolve(pngBase64);
        } catch (error) {
          reject(error);
        }
      };
      
      img.onerror = () => {
        reject(new Error('image load failed'));
      };
      
      img.src = svgDataUrl;
    } catch (error) {
      reject(error);
    }
  });
};

并发处理优化

问题分析

在初期实现中,我们遇到了并发请求导致的重复处理问题。相同imageId被请求了两次,导致资源浪费和性能问题。

解决方案

  1. 串行处理:将图片处理改为串行处理,避免并发问题
  2. 随机延迟:为每个请求添加随机延迟,避免缓存和并发冲突
  3. 重复检测:添加已处理imageId集合,避免重复请求
// 串行处理图片,避免并发问题
const results: Array<{
  success: boolean;
  method: string;
  index: number;
  imgElement: HTMLImageElement;
  base64?: string;
  error?: string;
}> = [];

for (let i = 0; i < imageProcessingTasks.length; i++) {
  const task = imageProcessingTasks[i];
  const { imgElement, src, index, originalWidth, originalHeight } = task;
  
  try {
    // 1. 尝试前端canvas转base64
    try {
      const base64 = await imageUrlToWordCompatibleBase64(src);
      imgElement.src = base64;
      results.push({ success: true, method: 'canvas', index, imgElement, base64 });
    } catch (error: any) {
      // 2. 尝试后端代理
      try {
        const base64 = await getImageBase64ViaProxy({ imageUrl: src, imageId: index});
        imgElement.src = base64;
        results.push({ success: true, method: 'proxy', index, imgElement, base64 });
      } catch (proxyError: any) {
        // 3. 降级为alt提示
        // ... 降级处理代码
      }
    }
  } catch (error: any) {
    console.error(`Error processing image ${index + 1}:`, error);
    results.push({ success: false, method: 'error', index, imgElement, error: error?.message || error });
  }
}

重复Base64检测和修复

问题识别

我们发现相同图片可能产生相同的base64结果,这可能导致Word中的显示问题。

解决方案

// 检查是否有重复的base64结果
const base64Results = results
  .filter(r => r.success && r.method === 'proxy')
  .map(r => r.base64)
  .filter(Boolean);

const uniqueBase64 = new Set(base64Results);
if (uniqueBase64.size !== base64Results.length) {
  // 详细分析重复的base64
  const base64Count = new Map<string, number>();
  base64Results.forEach((base64) => {
    if (base64) {
      base64Count.set(base64, (base64Count.get(base64) || 0) + 1);
    }
  });
  
  // 尝试修复重复问题:为重复的图片重新请求
  const duplicateTasks: Array<{
    imgElement: HTMLImageElement;
    src: string;
    index: number;
    originalWidth: number;
    originalHeight: number;
    originalResult: any;
    needsReprocessing: boolean;
  }> = [];
  
  // 记录已经处理过的imageId,避免重复处理
  const processedImageIds = new Set<number>();
  
  base64Count.forEach((count, base64) => {
    if (count > 1) {
      const duplicateResults = results
        .filter(r => r.success && r.method === 'proxy' && r.base64 === base64)
        .map((r, idx) => ({ result: r, taskIndex: idx }))
        .filter(({ result, taskIndex }) => {
          const task = imageProcessingTasks[taskIndex];
          return task !== undefined;
        });
      
      // 保留第一个,其余的需要重新处理
      duplicateResults.slice(1).forEach(({ result, taskIndex }) => {
        const task = imageProcessingTasks[taskIndex];
        if (task && !processedImageIds.has(task.index)) {
          processedImageIds.add(task.index);
          duplicateTasks.push({
            ...task,
            originalResult: result,
            needsReprocessing: true
          });
        }
      });
    }
  });
  
  if (duplicateTasks.length > 0) {
    // 重新处理重复的图片,添加随机延迟避免并发问题
    for (const duplicateTask of duplicateTasks) {
      try {
        // 添加随机延迟
        await new Promise(resolve => setTimeout(resolve, Math.random() * 1000 + 500));
        
        const newBase64 = await getImageBase64ViaProxy({ 
          imageUrl: duplicateTask.src, 
          imageId: duplicateTask.index 
        });
        
        // 更新对应的img元素
        const imgElement = duplicateTask.imgElement;
        if (imgElement && newBase64 !== duplicateTask.originalResult.base64) {
          imgElement.src = newBase64;
        }
      } catch (error) {
        console.error(`重新处理失败: ImageId ${duplicateTask.index}`, error);
      }
    }
  }
}

Word模板优化

为了确保在Microsoft Word中的最佳显示效果,我们设计了专门的HTML模板:

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible content=IE=edge">
  <style>
    body {
      font-family: 'Times New Roman', Times, serif;  /* Microsoft Word默认字体 */
      line-height: 1.5;
      font-size: 12pt;  /* Word默认字号 */
      margin: 0;
      padding: 20px;
    }
    img { 
      max-width: 100%; 
      height: auto; 
      display: inline-block;
      vertical-align: middle;
      margin: 4px;
    }
    /* Microsoft Word兼容的图片样式 */
    img[src^="data:image/"] {
      border: none;
      outline: none;
    }
    /* 确保图片在Word中正确显示 */
    .word-image {
      display: inline-block;
      vertical-align: middle;
      margin: 4px;
    }
  </style>
</head>
<body>
  <!-- 内容占位符 -->
</body>
</html>

导出功能实现

主要导出函数

export const exportDocx = async (
  className: string,
  title = 'document',
  type = 'docx'
): Promise<void> => {
  const baseTemplate = `
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="UTF-8">
      <meta http-equiv="X-UA-Compatible content=IE=edge">
      <style>
        body {
          font-family: Times New Roman', Times, serif;
          line-height: 1.5;
          font-size: 12pt;
          margin: 0;
          padding: 20px;
        }
        img { 
          max-width: 100%; 
          height: auto; 
          display: inline-block;
          vertical-align: middle;
          margin: 4px;
        }
        img[src^="data:image/"] {
          border: none;
          outline: none;
        }
        .word-image {
          display: inline-block;
          vertical-align: middle;
          margin: 4px;
        }
      </style>
    </head>
    <body>
      ${getHTMLContentByClassName(className)}
    </body>
    </html>
  `;

  const htmlSvgContent = await handleSvgToBase64(baseTemplate);

  try {
    const options = {
      orientation: 'portrait',
      margins: { top: 720, right: 720, bottom: 720, left: 720 }, // Word默认边距
      header: false,
      footer: false,
      pageSize: 'A4'
    };

    const data = await asBlob(htmlSvgContent, options as any);
    const fileName = `${title.replace(/[<>:"/\\|?*]/g, '')}-${Date.now()}.${type}`; // 移除非法字符
    saveAs(data as Blob, fileName);
  } catch (error) {
    console.error('export docx error:', error);
  }
};

关键注意事项

1. 图片格式兼容性

  • PNG格式:Microsoft Word对PNG格式支持最好
  • Base64编码:确保使用正确的MIME类型前缀
  • 白色背景:为透明图片设置白色背景,避免显示问题

2. 并发处理

  • 串行处理:避免并发请求导致的重复处理
  • 随机延迟:防止缓存和并发冲突
  • 重复检测:识别并修复重复的base64结果

3. 错误处

  • 三层兜底:确保在各种情况下都有降级方案
  • 详细日志:记录处理过程,便于调试
  • 用户友好:提供清晰的错误提示

最终效果

经过优化后,我们的解决方案实现了:
在这里插入图片描述

测试结果

  • ✅ Microsoft Word 2016/2019/365:图片正常显示
  • ✅ WPS Office:完全兼容
  • ✅ 数学公式:SVG转PNG后正常显示
  • ✅ 复杂布局:保持原有格式和样式

总结

这个方案成功解决了Microsoft Word导出中的图片显示问题。


网站公告

今日签到

点亮在社区的每一天
去签到