|
|
@@ -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 || '合同',
|