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>
This commit is contained in:
zlwuandCursor committed 2026-09-05 14:59:58 +08:00
1 parent deefdf7bb4
commit 8bbc716d17
3 files changed
+95 -13

No files matched your search

+10
View File
@@ -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'
@@ -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)
})
})
@@ -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 || '合同',