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:
1 parent
deefdf7bb4
commit
8bbc716d17
3 files changed
+95
-13
No files matched your search
Vendored
+10
@@ -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 || '合同',
|
||||
|
||||
Reference in new issue
Block a user