/** 合同打印弹窗:HTML → DOCX 预处理与生成 */ import { Buffer } from 'buffer' const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' type HtmlToDocxFn = ( htmlString: string, headerHTMLString?: string | null, documentOptions?: Record, footerHTMLString?: string | null ) => Promise /** * html-to-docx(含 browser 包)在 Vite 二次打包后仍会直接读全局 Buffer/global。 * 必须在调用转换前注入,否则 buildImage 报 Buffer is not defined,随后 global is not defined。 */ function ensureDocxNodeGlobals(): void { const g = globalThis as typeof globalThis & { Buffer?: typeof Buffer global?: typeof globalThis process?: { env?: Record } } if (!g.Buffer) g.Buffer = Buffer if (!g.global) g.global = g if (!g.process) g.process = { env: {} } else if (!g.process.env) g.process.env = {} } /** * 懒加载 browser 构建。 * Vite 默认会解析到 Node ESM(依赖 Buffer/global);显式 browser 入口 + 上方 globals 注入。 */ async function loadHtmlToDocx(): Promise { ensureDocxNodeGlobals() // 经 vite alias 指向 browser.esm.js const mod = await import('@turbodocx/html-to-docx') 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 { 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 { const srcRe = /\bsrc=(["'])([^"']+)\1/gi const urls = new Set() 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() 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(浏览器下载用)。 * 调用方须先执行 prepareContractHtmlForDocx。 */ export async function generateContractDocxBlob( preparedHtml: string, title?: string ): Promise { ensureDocxNodeGlobals() const HTMLtoDOCX = await loadHtmlToDocx() const htmlWithImages = await inlineRemoteImagesAsDataUrls(preparedHtml) const fullHtml = `${htmlWithImages}` ensureDocxNodeGlobals() const result = await HTMLtoDOCX(fullHtml, null, { orientation: 'portrait', title: title || '合同', margins: { top: 720, right: 720, bottom: 720, left: 720 } }) if (result instanceof Blob) return result return new Blob([result as ArrayBuffer], { type: DOCX_MIME }) } export function parseContractSupplierCode(contractNo?: string | null): string { if (!contractNo) return 'SX' const match = String(contractNo).match(/^([A-Z]+)\/X-JT/) return match?.[1] || 'SX' } export function stripContractLogoHtml(html: string): string { let result = html.replace(/]*>\s*]*(?:logo\.gif|logo)[^>]*>[\s\S]*?<\/div>/gi, (match) => { if (match.includes('logo.gif') || match.includes('/logo') || (match.includes('logo') && !match.includes('watermask'))) { return '' } return match }) result = result.replace(/]*(?:logo\.gif|logo)[^>]*(?![^<]*watermask)[^>]*>/gi, '') return result } export function toAbsoluteAssetUrls(html: string, origin: string): string { const base = origin.replace(/\/$/, '') return html.replace(/\bsrc=(["'])(\/[^"']+)\1/gi, (_m, quote: string, path: string) => { return `src=${quote}${base}${path}${quote}` }) } export function injectSxWatermarkHtml(html: string, watermarkUrl: string): string { const watermarkBlock = `
水印
` return `${watermarkBlock}${html}` } export function prepareContractHtmlForDocx(options: { html: string contractNo?: string | null origin: string }): string { const origin = options.origin.replace(/\/$/, '') const supplierCode = parseContractSupplierCode(options.contractNo) const isSX = supplierCode === 'SX' let html = options.html || '' if (!isSX) { html = stripContractLogoHtml(html) } html = toAbsoluteAssetUrls(html, origin) if (isSX) { html = injectSxWatermarkHtml(html, `${origin}/watermask.jpg`) } return html } export function buildContractDocxFileName( contractName?: string | null, contractNo?: string | null ): string { const raw = (contractName && String(contractName).trim()) || (contractNo && String(contractNo).trim()) || '合同' const safe = raw.replace(/[/\\:*?"<>|]/g, '-') return `${safe}合同.docx` }