问题背景
碰到一个问题:将包含图片和SVG数学公式的HTML内容导出为Word文档时,将图片都转为ase64格式导出,在WPS Word中显示正常,但是在Microsoft Word中出现图片示异常。
具体问题表现
- WPS兼容性:在WPS中显示正常,说明是Microsoft Word特有的兼容性问题
- SVG数学公式:在Word中显示为"当前无法显示此图片"
- 普通图片:显示不正常或无法显示
技术方案设计
三重兜底机制
我们设计了一个三层处理机制,确保在各种情况下都能提供最佳的用户体验:
第一层:前端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被请求了两次,导致资源浪费和性能问题。
解决方案
- 串行处理:将图片处理改为串行处理,避免并发问题
- 随机延迟:为每个请求添加随机延迟,避免缓存和并发冲突
- 重复检测:添加已处理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导出中的图片显示问题。