Quellcode durchsuchen

fix(web-admin): use html-to-docx browser build and inline images for Word export

Vite was resolving the Node ESM entry which needs Buffer/global; switch to the browser bundle and pre-convert images to data URLs so seals and watermarks embed.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu vor 1 Monat
Ursprung
Commit
8bbc716d17

+ 10 - 0
web-admin/src/env.d.ts

@@ -17,6 +17,16 @@ declare module 'vue-img-cutter'
 
 declare module 'file-saver'
 
+declare module '@turbodocx/html-to-docx/dist/html-to-docx.browser.esm.js' {
+  const HTMLtoDOCX: (
+    htmlString: string,
+    headerHTMLString?: string | null,
+    documentOptions?: Record<string, unknown>,
+    footerHTMLString?: string | null
+  ) => Promise<Blob | ArrayBuffer>
+  export default HTMLtoDOCX
+}
+
 declare module 'qrcode.vue' {
   export type Level = 'L' | 'M' | 'Q' | 'H'
   export type RenderAs = 'canvas' | 'svg'

+ 9 - 0
web-admin/src/utils/crm/contract-html-to-docx.test.ts

@@ -3,6 +3,7 @@ import { describe, it } from 'node:test'
 import {
   buildContractDocxFileName,
   injectSxWatermarkHtml,
+  inlineRemoteImagesAsDataUrls,
   parseContractSupplierCode,
   prepareContractHtmlForDocx,
   stripContractLogoHtml,
@@ -86,3 +87,11 @@ describe('buildContractDocxFileName', () => {
     assert.equal(buildContractDocxFileName('', 'SX/X-JT1'), 'SX-X-JT1合同.docx')
   })
 })
+
+describe('inlineRemoteImagesAsDataUrls', () => {
+  it('keeps data URLs and leaves html without remote images unchanged', async () => {
+    const html = '<p>无图</p><img src="data:image/png;base64,aaa" />'
+    const out = await inlineRemoteImagesAsDataUrls(html)
+    assert.equal(out, html)
+  })
+})

+ 76 - 13
web-admin/src/utils/crm/contract-html-to-docx.ts

@@ -1,15 +1,80 @@
 /** 合同打印弹窗:HTML → DOCX 预处理与生成 */
 
-import htmlToDocxModule from '@turbodocx/html-to-docx'
-
 const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
 
-/** CJS/ESM 互操作:Vite/浏览器多为函数,tsx/Node 偶发 `{ default: fn }` */
-const HTMLtoDOCX = (
-  typeof htmlToDocxModule === 'function'
-    ? htmlToDocxModule
-    : (htmlToDocxModule as { default?: unknown })?.default
-) as (htmlString: string, headerHTMLString?: string | null, documentOptions?: Record<string, unknown>, footerHTMLString?: string | null) => Promise<Blob | ArrayBuffer>
+type HtmlToDocxFn = (
+  htmlString: string,
+  headerHTMLString?: string | null,
+  documentOptions?: Record<string, unknown>,
+  footerHTMLString?: string | null
+) => Promise<Blob | ArrayBuffer>
+
+/**
+ * 懒加载 browser 构建。
+ * Vite 默认会解析到 Node ESM(依赖 Buffer/global),浏览器会报错;
+ * 顶层静态 import browser 包又会让 Node 单测因 XMLHttpRequest 初始化失败。
+ */
+async function loadHtmlToDocx(): Promise<HtmlToDocxFn> {
+  const mod = await import('@turbodocx/html-to-docx/dist/html-to-docx.browser.esm.js')
+  const candidate = typeof mod === 'function' ? mod : (mod as { default?: unknown }).default
+  if (typeof candidate !== 'function') {
+    throw new Error('html-to-docx 加载失败:未获得可调用的转换函数')
+  }
+  return candidate as HtmlToDocxFn
+}
+
+function arrayBufferToBase64(buffer: ArrayBuffer): string {
+  const bytes = new Uint8Array(buffer)
+  let binary = ''
+  const chunk = 0x8000
+  for (let i = 0; i < bytes.length; i += chunk) {
+    binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
+  }
+  return btoa(binary)
+}
+
+async function urlToDataUrl(url: string): Promise<string | null> {
+  try {
+    const res = await fetch(url)
+    if (!res.ok) return null
+    const blob = await res.blob()
+    const buf = await blob.arrayBuffer()
+    const mime = blob.type || 'application/octet-stream'
+    return `data:${mime};base64,${arrayBufferToBase64(buf)}`
+  } catch {
+    return null
+  }
+}
+
+/**
+ * 将 HTML 中非 data: 图片先转成 data URL,避免库内 Node Buffer 路径失败导致印章/水印丢失。
+ */
+export async function inlineRemoteImagesAsDataUrls(html: string): Promise<string> {
+  const srcRe = /\bsrc=(["'])([^"']+)\1/gi
+  const urls = new Set<string>()
+  let match: RegExpExecArray | null
+  while ((match = srcRe.exec(html)) !== null) {
+    const url = match[2]
+    if (url && !url.startsWith('data:')) urls.add(url)
+  }
+
+  if (!urls.size) return html
+
+  const replacements = new Map<string, string>()
+  await Promise.all(
+    [...urls].map(async (url) => {
+      const dataUrl = await urlToDataUrl(url)
+      if (dataUrl) replacements.set(url, dataUrl)
+    })
+  )
+
+  if (!replacements.size) return html
+
+  return html.replace(/\bsrc=(["'])([^"']+)\1/gi, (full, quote: string, url: string) => {
+    const dataUrl = replacements.get(url)
+    return dataUrl ? `src=${quote}${dataUrl}${quote}` : full
+  })
+}
 
 /**
  * 将已预处理的合同 HTML 转为 DOCX Blob(浏览器下载用)。
@@ -19,11 +84,9 @@ export async function generateContractDocxBlob(
   preparedHtml: string,
   title?: string
 ): Promise<Blob> {
-  if (typeof HTMLtoDOCX !== 'function') {
-    throw new Error('html-to-docx 加载失败:未获得可调用的转换函数')
-  }
-
-  const fullHtml = `<!DOCTYPE html><html><head><meta charset="UTF-8" /></head><body>${preparedHtml}</body></html>`
+  const HTMLtoDOCX = await loadHtmlToDocx()
+  const htmlWithImages = await inlineRemoteImagesAsDataUrls(preparedHtml)
+  const fullHtml = `<!DOCTYPE html><html><head><meta charset="UTF-8" /></head><body>${htmlWithImages}</body></html>`
   const result = await HTMLtoDOCX(fullHtml, null, {
     orientation: 'portrait',
     title: title || '合同',