contract-html-to-docx.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. /** 合同打印弹窗:HTML → DOCX 预处理与生成 */
  2. import { Buffer } from 'buffer'
  3. const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
  4. type HtmlToDocxFn = (
  5. htmlString: string,
  6. headerHTMLString?: string | null,
  7. documentOptions?: Record<string, unknown>,
  8. footerHTMLString?: string | null
  9. ) => Promise<Blob | ArrayBuffer>
  10. /**
  11. * html-to-docx(含 browser 包)在 Vite 二次打包后仍会直接读全局 Buffer/global。
  12. * 必须在调用转换前注入,否则 buildImage 报 Buffer is not defined,随后 global is not defined。
  13. */
  14. function ensureDocxNodeGlobals(): void {
  15. const g = globalThis as typeof globalThis & {
  16. Buffer?: typeof Buffer
  17. global?: typeof globalThis
  18. process?: { env?: Record<string, string | undefined> }
  19. }
  20. if (!g.Buffer) g.Buffer = Buffer
  21. if (!g.global) g.global = g
  22. if (!g.process) g.process = { env: {} }
  23. else if (!g.process.env) g.process.env = {}
  24. }
  25. /**
  26. * 懒加载 browser 构建。
  27. * Vite 默认会解析到 Node ESM(依赖 Buffer/global);显式 browser 入口 + 上方 globals 注入。
  28. */
  29. async function loadHtmlToDocx(): Promise<HtmlToDocxFn> {
  30. ensureDocxNodeGlobals()
  31. // 经 vite alias 指向 browser.esm.js
  32. const mod = await import('@turbodocx/html-to-docx')
  33. const candidate = typeof mod === 'function' ? mod : (mod as { default?: unknown }).default
  34. if (typeof candidate !== 'function') {
  35. throw new Error('html-to-docx 加载失败:未获得可调用的转换函数')
  36. }
  37. return candidate as HtmlToDocxFn
  38. }
  39. function arrayBufferToBase64(buffer: ArrayBuffer): string {
  40. const bytes = new Uint8Array(buffer)
  41. let binary = ''
  42. const chunk = 0x8000
  43. for (let i = 0; i < bytes.length; i += chunk) {
  44. binary += String.fromCharCode(...bytes.subarray(i, i + chunk))
  45. }
  46. return btoa(binary)
  47. }
  48. async function urlToDataUrl(url: string): Promise<string | null> {
  49. try {
  50. const res = await fetch(url)
  51. if (!res.ok) return null
  52. const blob = await res.blob()
  53. const buf = await blob.arrayBuffer()
  54. const mime = blob.type || 'application/octet-stream'
  55. return `data:${mime};base64,${arrayBufferToBase64(buf)}`
  56. } catch {
  57. return null
  58. }
  59. }
  60. /**
  61. * 将 HTML 中非 data: 图片先转成 data URL,避免库内 Node Buffer 路径失败导致印章/水印丢失。
  62. */
  63. export async function inlineRemoteImagesAsDataUrls(html: string): Promise<string> {
  64. const srcRe = /\bsrc=(["'])([^"']+)\1/gi
  65. const urls = new Set<string>()
  66. let match: RegExpExecArray | null
  67. while ((match = srcRe.exec(html)) !== null) {
  68. const url = match[2]
  69. if (url && !url.startsWith('data:')) urls.add(url)
  70. }
  71. if (!urls.size) return html
  72. const replacements = new Map<string, string>()
  73. await Promise.all(
  74. [...urls].map(async (url) => {
  75. const dataUrl = await urlToDataUrl(url)
  76. if (dataUrl) replacements.set(url, dataUrl)
  77. })
  78. )
  79. if (!replacements.size) return html
  80. return html.replace(/\bsrc=(["'])([^"']+)\1/gi, (full, quote: string, url: string) => {
  81. const dataUrl = replacements.get(url)
  82. return dataUrl ? `src=${quote}${dataUrl}${quote}` : full
  83. })
  84. }
  85. /**
  86. * 将已预处理的合同 HTML 转为 DOCX Blob(浏览器下载用)。
  87. * 调用方须先执行 prepareContractHtmlForDocx。
  88. */
  89. export async function generateContractDocxBlob(
  90. preparedHtml: string,
  91. title?: string
  92. ): Promise<Blob> {
  93. ensureDocxNodeGlobals()
  94. const HTMLtoDOCX = await loadHtmlToDocx()
  95. const htmlWithImages = await inlineRemoteImagesAsDataUrls(preparedHtml)
  96. const fullHtml = `<!DOCTYPE html><html><head><meta charset="UTF-8" /></head><body>${htmlWithImages}</body></html>`
  97. ensureDocxNodeGlobals()
  98. const result = await HTMLtoDOCX(fullHtml, null, {
  99. orientation: 'portrait',
  100. title: title || '合同',
  101. margins: {
  102. top: 720,
  103. right: 720,
  104. bottom: 720,
  105. left: 720
  106. }
  107. })
  108. if (result instanceof Blob) return result
  109. return new Blob([result as ArrayBuffer], { type: DOCX_MIME })
  110. }
  111. export function parseContractSupplierCode(contractNo?: string | null): string {
  112. if (!contractNo) return 'SX'
  113. const match = String(contractNo).match(/^([A-Z]+)\/X-JT/)
  114. return match?.[1] || 'SX'
  115. }
  116. export function stripContractLogoHtml(html: string): string {
  117. let result = html.replace(/<div[^>]*>\s*<img[^>]*(?:logo\.gif|logo)[^>]*>[\s\S]*?<\/div>/gi, (match) => {
  118. if (match.includes('logo.gif') || match.includes('/logo') || (match.includes('logo') && !match.includes('watermask'))) {
  119. return ''
  120. }
  121. return match
  122. })
  123. result = result.replace(/<img[^>]*(?:logo\.gif|logo)[^>]*(?![^<]*watermask)[^>]*>/gi, '')
  124. return result
  125. }
  126. export function toAbsoluteAssetUrls(html: string, origin: string): string {
  127. const base = origin.replace(/\/$/, '')
  128. return html.replace(/\bsrc=(["'])(\/[^"']+)\1/gi, (_m, quote: string, path: string) => {
  129. return `src=${quote}${base}${path}${quote}`
  130. })
  131. }
  132. export function injectSxWatermarkHtml(html: string, watermarkUrl: string): string {
  133. const watermarkBlock = `<div style="text-align:center;margin:8px 0 16px;">
  134. <img src="${watermarkUrl}" alt="水印" style="width:50%;max-width:400px;opacity:0.15;" />
  135. </div>`
  136. return `${watermarkBlock}${html}`
  137. }
  138. export function prepareContractHtmlForDocx(options: {
  139. html: string
  140. contractNo?: string | null
  141. origin: string
  142. }): string {
  143. const origin = options.origin.replace(/\/$/, '')
  144. const supplierCode = parseContractSupplierCode(options.contractNo)
  145. const isSX = supplierCode === 'SX'
  146. let html = options.html || ''
  147. if (!isSX) {
  148. html = stripContractLogoHtml(html)
  149. }
  150. html = toAbsoluteAssetUrls(html, origin)
  151. if (isSX) {
  152. html = injectSxWatermarkHtml(html, `${origin}/watermask.jpg`)
  153. }
  154. return html
  155. }
  156. export function buildContractDocxFileName(
  157. contractName?: string | null,
  158. contractNo?: string | null
  159. ): string {
  160. const raw = (contractName && String(contractName).trim()) || (contractNo && String(contractNo).trim()) || '合同'
  161. const safe = raw.replace(/[/\\:*?"<>|]/g, '-')
  162. return `${safe}合同.docx`
  163. }