| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187 |
- /** 合同打印弹窗:HTML → DOCX 预处理与生成 */
- import { Buffer } from 'buffer'
- const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
- type HtmlToDocxFn = (
- htmlString: string,
- headerHTMLString?: string | null,
- documentOptions?: Record<string, unknown>,
- footerHTMLString?: string | null
- ) => Promise<Blob | ArrayBuffer>
- /**
- * 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<string, string | undefined> }
- }
- 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<HtmlToDocxFn> {
- 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<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(浏览器下载用)。
- * 调用方须先执行 prepareContractHtmlForDocx。
- */
- export async function generateContractDocxBlob(
- preparedHtml: string,
- title?: string
- ): Promise<Blob> {
- ensureDocxNodeGlobals()
- const HTMLtoDOCX = await loadHtmlToDocx()
- const htmlWithImages = await inlineRemoteImagesAsDataUrls(preparedHtml)
- const fullHtml = `<!DOCTYPE html><html><head><meta charset="UTF-8" /></head><body>${htmlWithImages}</body></html>`
- 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(/<div[^>]*>\s*<img[^>]*(?: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(/<img[^>]*(?: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 = `<div style="text-align:center;margin:8px 0 16px;">
- <img src="${watermarkUrl}" alt="水印" style="width:50%;max-width:400px;opacity:0.15;" />
- </div>`
- 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`
- }
|