我html生成的doc文档为何在手机文档显示不了图片?

背景:记录分享页 需要把「摘要」导出为文档供用户下载。摘要内容为 Markdown 渲染的 HTML,包含标题、时间、表格、列表和图片(OSS 跨域图片)。 结论先行:只有「真 OOXML docx + 图片二进制内嵌」能在手机端 Word / WPS / 文件预览里完整显示,其余方案在手机端都有硬伤。

没耐心的直接跳转流程图✨

一、为什么手机打不开图?三种方案的对比

方案 文件实质 桌面 Word 手机 Word WPS / 手机预览 结论
HTML 伪装 .doc(application/msword + HTML) HTML 文本 ✅ 图片正常 ❌ 图片不显示 ❌ 图片不显示 PC 可用,手机无图
altChunk docx(html-docx-js) HTML 原样塞进 docx 包 ✅ 打开时合并渲染 ❌ 打开空白 ❌ 打开空白 手机端不支持 altChunk
真 OOXML docx(docx 库) 图片为二进制打包进 word/media/ ✅ ✅ ✅ 最终方案

1. HTML 伪装 .doc:桌面能看,手机无图

.doc 文件本质是 HTML。桌面 Word 内置 HTML 渲染引擎,能解析 <img src="data:image/jpeg;base64,...">;但手机 Word / WPS / 系统文件预览对 HTML 版 .doc 支持很差,普遍不解析 data URI 图片------图片区域直接空白。这是格式层面的限制,代码侧无解。

2. html-docx-js:altChunk 机制的陷阱

html-docx-js 生成的 docx 使用 altChunk 机制:HTML 原样嵌入包内,靠桌面版 Word 打开时现场合并渲染 。手机 Word、WPS、微信/QQ 文件预览都不支持 altChunk,读到包里没有任何可渲染内容,打开即空白。

3. docx 库:把 HTML 结构转成真正的 Word 元素

用 docx 库解析 HTML DOM,把每个元素映射为 OOXML 元素(段落 / 表格 / ImageRun),图片解码后以二进制写入 word/media/。产物是标准 OOXML,全端兼容。

二、最终方案设计

2.1 按端分流

  • 手机浏览器 (UA 检测 viewParam.isMobile):docx 库生成真 .docx,图片二进制内嵌
  • PC 浏览器 :维持原 HTML 伪装 .doc(桌面 Word 渲染正常,改动最小)
  • 纯文本 tab :两端都用 .doc(无图片,手机端显示文字没有问题)
  • 降级链路 :docx 转换失败 → 降级 .doc → 再失败 → 降级 .txt

2.2 前置条件:图片必须是 base64

docx 无法引用远程 URL,导出前先把所有图片转 base64:

  1. 同源图片:直接 fetch → blob → FileReader
  2. 跨域图片:Image + Canvas(img.crossOrigin = 'anonymous')→ canvas.toDataURL

依赖:图片服务器的 CORS 配置 。浏览器以 CORS 模式请求图片,若服务器不返回 Access-Control-Allow-Origin,Image 加载直接失败。OSS 白名单需覆盖实际部署域名,否则转换失败 → 图片保持远程地址 → docx 嵌图时被跳过。

2.3 HTML → OOXML 元素映射

HTML OOXML(docx 库)
h1--h4 加粗标题,分级字号(32/28/24 半磅)
p、行内 strong/em/del/code/a 对应样式的 TextRun
table 带边框 Table,表头灰底(E8E8E8)加粗
ul / ol 项目符号列表 / 手动编号列表
img(base64) 居中 ImageRun,按 width/height 属性(上限 500×800px)
blockquote 左缩进 480 + 灰底
pre Courier New 代码块
hr thematicBreak 分隔线

全局默认样式:微软雅黑(含 eastAsia 声明,避免中文回退宋体)、10.5pt、显式左对齐。

三、手机端特有的坑

3.1 手动换行导致整行拉伸

现象:标题、引用块里每个字被拉开、平均分布到整行;正文正常。

原因 :手机 Word/WPS 会把含 <w:br/> 手动换行的段落整行拉伸铺满 。marked 配置了 breaks: true,标题(「标题\n时间」)和引用块内的换行都生成了 <br>。

修复 :转换时按 <br> 把段落拆成独立段落 ,产物中不存在任何手动换行;同时 docDefaults 显式声明 alignment: LEFT。

3.2 标题字号

标题段落加 docx-title 标记类,转换时首段用 16pt 加粗,<br> 之后的时间行自动恢复默认 10.5pt(拆段逻辑中遇到 BR 丢弃首段附加样式)。

3.3 诊断日志

  • [docx导出] X/Y 张图片未转成 base64,将不会出现在 docx 中------导出前统计
  • [docx导出] 图片非 base64,已跳过(前缀): ...------逐张跳过原因

四、关键代码示例

4.1 按端分流与降级链路

js 复制代码
/** 下载为 .doc(isDocHtml=true 时 content 为完整 HTML)或降级为 .txt
 *  手机浏览器走真 docx:手机 Word/WPS 不解析 HTML .doc 里的 base64 图片,只有 OOXML 内嵌图片才能显示;PC 维持 .doc */
downloadDocOrTxt(content, beforeDot, isDocHtml) {
  const downloadBlob = (blob, filename) => {
    const url = URL.createObjectURL(blob)
    const a = document.createElement('a')
    a.href = url
    a.download = filename
    a.style.display = 'none'
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
    URL.revokeObjectURL(url)
  }
  const downloadDoc = () => {
    const html = isDocHtml
      ? content
      : `<!DOCTYPE html><html><head><meta charset="UTF-8"></head><body><pre>${this.escapeHtml(content)}</pre></body></html>`
    downloadBlob(new Blob(['\uFEFF' + html], { type: 'application/msword' }), beforeDot + '.doc')
  }
  try {
    if (isDocHtml && this.viewParam.isMobile) {
      // 手机端:生成真 docx(图片为二进制内嵌),失败时降级回 .doc
      this.htmlToDocxBlob(content)
        .then(blob => downloadBlob(blob, beforeDot + '.docx'))
        .catch(e => {
          console.warn('docx 转换失败,降级为 .doc:', e)
          try {
            downloadDoc()
          } catch (e2) {
            downloadTxt() // 最终兜底
          }
        })
    } else {
      downloadDoc()
    }
  } catch (e) {
    downloadTxt()
  }
}

4.2 跨域图片转 base64(Image + Canvas)

js 复制代码
/** 使用 Image + Canvas 将跨域图片 URL 转为 base64(需服务器支持 CORS) */
imageUrlToBase64(src) {
  return new Promise((resolve) => {
    const img = new Image()
    img.crossOrigin = 'anonymous' // 服务器无 CORS 头时这里会直接 onerror
    img.onload = () => {
      try {
        const canvas = document.createElement('canvas')
        canvas.width = img.naturalWidth
        canvas.height = img.naturalHeight
        canvas.getContext('2d').drawImage(img, 0, 0)
        // JPEG 兼容性更好;手机 Word 对 PNG base64 支持不佳
        const dataUrl = canvas.toDataURL('image/jpeg', 0.92)
        resolve(dataUrl)
      } catch (_) {
        resolve(src) // 画布被跨域污染时兜底:保留原 URL
      }
    }
    img.onerror = () => resolve(src)
    img.src = src
  })
}

4.3 HTML → docx:入口与全局样式

js 复制代码
import { Document, Packer, Paragraph, TextRun, ImageRun, Table, TableRow, TableCell,
  AlignmentType, BorderStyle, WidthType, ShadingType } from 'docx'

async htmlToDocxBlob(html) {
  const dom = new DOMParser().parseFromString(html, 'text/html')
  // 诊断:统计未转 base64 的图片
  const imgs = dom.body.querySelectorAll('img[src]')
  const remoteImgs = Array.from(imgs).filter(i => !i.getAttribute('src').startsWith('data:')).length
  if (remoteImgs > 0) console.warn(`[docx导出] ${remoteImgs}/${imgs.length} 张图片未转成 base64,将不会出现在 docx 中`)

  const children = []
  Array.from(dom.body.childNodes).forEach(node => this.appendDocxBlock(node, children))

  const doc = new Document({
    styles: {
      default: {
        document: {
          run: { font: { ascii: 'Microsoft YaHei', eastAsia: 'Microsoft YaHei', hAnsi: 'Microsoft YaHei' }, size: 21 },
          // 显式声明左对齐:手机 Word/WPS 对未声明对齐的中文段落会按两端对齐渲染
          paragraph: { alignment: AlignmentType.LEFT, spacing: { after: 100, line: 300 } }
        }
      }
    },
    sections: [{ children }]
  })
  return Packer.toBlob(doc)
}

4.4 段落转换:按 <br> 拆段(修复手机端拉伸)

js 复制代码
case 'P': {
  // 按 <br> 拆分为独立段落:手机 Word/WPS 对含手动换行的段落会把整行拉伸铺满
  // docx-title 标记的段落首行(标题)使用大字号,后续行(时间)保持默认字号
  const isTitle = node.classList.contains('docx-title')
  const segments = this.inlineRunsSplitByBr(node, isTitle ? { size: 32, bold: true } : undefined)
  segments.forEach(runs => {
    if (runs.length) out.push(new Paragraph({ children: runs }))
  })
  return
}

/** 行内内容按 <br> 拆分为多段 TextRun/ImageRun 数组 */
inlineRunsSplitByBr(node, firstSegmentStyle) {
  const segments = [[]]
  const walk = (el, style) => {
    if (el.nodeType === Node.TEXT_NODE) {
      const text = el.textContent.replace(/\s+/g, ' ')
      if (text.trim() || text === ' ') segments[segments.length - 1].push(new TextRun({ text, ...style }))
      return
    }
    if (el.nodeType !== Node.ELEMENT_NODE) return
    const tag = el.tagName
    if (tag === 'BR') {
      segments.push([])
      delete style.size // 换行后的行(如时间行)不再使用首段的大字号
      return
    }
    if (tag === 'IMG') {
      const run = this.imgToDocxRun(el)
      if (run) segments[segments.length - 1].push(run)
      return
    }
    const nextStyle = { ...style }
    if (tag === 'STRONG' || tag === 'B') nextStyle.bold = true
    if (tag === 'EM' || tag === 'I') nextStyle.italics = true
    // ... del/strike、code/等宽字体、a/下划线
    el.childNodes.forEach(child => walk(child, nextStyle))
  }
  node.childNodes.forEach(child => walk(child, firstSegmentStyle ? { ...firstSegmentStyle } : {}))
  return segments
}

4.5 base64 图片 → ImageRun(二进制内嵌)

js 复制代码
imgToDocxRun(img) {
  const src = img.getAttribute('src') || ''
  const match = src.match(/^data:(image\/[\w+.-]+);base64,(.+)$/)
  if (!match) {
    console.warn('[docx导出] 图片非 base64,已跳过(前缀):', src.slice(0, 100))
    return null // 远程图片无法嵌入,跳过
  }
  const bytes = Uint8Array.from(atob(match[2]), c => c.charCodeAt(0))
  const w = Math.min(parseInt(img.getAttribute('width'), 10) || 300, 500)
  const h = Math.min(parseInt(img.getAttribute('height'), 10) || Math.round(w * 0.75), 800)
  return new ImageRun({ data: bytes, transformation: { width: w, height: h } })
}

4.6 表格转换(表头灰底加粗)

js 复制代码
tableToDocx(tableEl) {
  const border = { style: BorderStyle.SINGLE, size: 4, color: '666666' }
  const rows = Array.from(tableEl.rows).map(tr => {
    const cells = Array.from(tr.cells)
    const isHead = tr.parentElement.tagName === 'THEAD' || cells.every(cell => cell.tagName === 'TH')
    return new TableRow({
      tableHeader: isHead,
      children: cells.map(cell => new TableCell({
        shading: isHead ? { type: ShadingType.CLEAR, fill: 'E8E8E8', color: 'auto' } : undefined,
        margins: { top: 80, bottom: 80, left: 120, right: 120 },
        children: [new Paragraph({ children: this.inlineToDocxRuns(cell) })]
      }))
    })
  })
  return new Table({
    width: { size: 100, type: WidthType.PERCENTAGE },
    borders: { top: border, bottom: border, left: border, right: border, insideHorizontal: border, insideVertical: border },
    rows
  })
}

五、流程图

六、注意事项与运维依赖

  1. OSS CORS 白名单必须覆盖部署域名 (含协议,如 https://*.example.com),否则跨域图片转 base64 失败,docx 里没有图片。这是运维配置,代码侧无兜底。
  2. docx@7.8.2 使用固定版本(ES5 产物,webpack 4 / babel 兼容);v9 起 ImageRun 需要 type 参数,升级时需改造。
  3. 导出依赖控制台日志排查问题,关注 [docx导出] 前缀的警告。
  4. 纯文本导出走 .doc 路径,两端一致,不涉及图片问题。
相关推荐
前端双越老师3 小时前
程序员 1v1 访谈系列第十期:纯前端坚持不了多久了
前端·ai编程·全栈
门思科技5 小时前
LoRaWAN 网络排障的五层漏斗:从"上不到云"到"数值不对",每一层该用什么工具证明
前端
计算机魔术师5 小时前
Cursor 官方插件商城现恶意 Claude Code 扩展,约 11 小时影响最多 5000 名开发者
前端
掘金者阿豪5 小时前
MongoDB迁移到金仓数据库:商品属性继续灵活存,订单报表交给 SQL
前端·后端
栉甜5 小时前
Vue2学习内容(上)
前端·javascript·vue.js·学习
梦曦i5 小时前
v0.2.0 架构升级:class + reactive 重塑状态管理
前端·uni-app x
布丁爱干饭5 小时前
寄给午后
前端
java1234_小锋6 小时前
【技术专题】CSS3 网页技术 - CSS3 布局进阶
前端·css·css3
zhippt_10146 小时前
只剩截图没有源文件?图转PPT把图片还原成可编辑幻灯片
前端·人工智能·powerpoint