Przeglądaj źródła

fix(web-admin): drop Word watermark and keep party columns side-by-side

Word export no longer injects watermarks; convert the flex 供方/需方 footer into a two-cell table so html-to-docx preserves left-right layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 miesięcy temu
rodzic
commit
57c2a37171

+ 53 - 11
web-admin/src/utils/crm/contract-html-to-docx.test.ts

@@ -2,11 +2,12 @@ import assert from 'node:assert/strict'
 import { describe, it } from 'node:test'
 import {
   buildContractDocxFileName,
-  injectSxWatermarkHtml,
   inlineRemoteImagesAsDataUrls,
+  normalizePartyFlexLayoutForDocx,
   parseContractSupplierCode,
   prepareContractHtmlForDocx,
   stripContractLogoHtml,
+  stripWatermarkHtml,
   toAbsoluteAssetUrls
 } from './contract-html-to-docx'
 
@@ -34,6 +35,17 @@ describe('stripContractLogoHtml', () => {
   })
 })
 
+describe('stripWatermarkHtml', () => {
+  it('removes watermark images', () => {
+    const html =
+      '<div style="text-align:center"><img src="/watermask.jpg" alt="水印" /></div><p>正文</p>'
+    const out = stripWatermarkHtml(html)
+    assert.equal(out.includes('watermask'), false)
+    assert.equal(out.includes('水印'), false)
+    assert.equal(out.includes('正文'), true)
+  })
+})
+
 describe('toAbsoluteAssetUrls', () => {
   it('prefixes root-relative src with origin', () => {
     const out = toAbsoluteAssetUrls(
@@ -45,30 +57,45 @@ describe('toAbsoluteAssetUrls', () => {
   })
 })
 
-describe('injectSxWatermarkHtml', () => {
-  it('prepends watermark image with absolute url', () => {
-    const out = injectSxWatermarkHtml('<p>合同</p>', 'http://localhost:3006/watermask.jpg')
-    assert.equal(out.includes('http://localhost:3006/watermask.jpg'), true)
-    assert.equal(out.includes('alt="水印"'), true)
-    assert.equal(out.includes('<p>合同</p>'), true)
+describe('normalizePartyFlexLayoutForDocx', () => {
+  it('converts bottom party flex columns into a two-cell table', () => {
+    const html = `
+      <div style="display: flex; justify-content: space-between; margin-top: 10px;">
+        <div style="flex: 1; border: 1px solid #000; padding: 8px;">
+          <div style="font-weight: bold;">供方</div>
+          <div>单位名称(章):甲公司</div>
+        </div>
+        <div style="flex: 1; border: 1px solid #000; padding: 8px;">
+          <div style="font-weight: bold;">需方</div>
+          <div>单位名称(章):乙公司</div>
+        </div>
+      </div>`
+    const out = normalizePartyFlexLayoutForDocx(html)
+    assert.equal(out.includes('display: flex'), false)
+    assert.equal(out.includes('<table'), true)
+    assert.equal(out.includes('width:50%'), true)
+    assert.equal(out.includes('供方'), true)
+    assert.equal(out.includes('需方'), true)
+    assert.equal(out.includes('甲公司'), true)
+    assert.equal(out.includes('乙公司'), true)
   })
 })
 
 describe('prepareContractHtmlForDocx', () => {
-  it('adds SX watermark and keeps logo', () => {
+  it('keeps logo and does not inject watermark for SX', () => {
     const out = prepareContractHtmlForDocx({
       html: '<div><img src="/logo.gif" /></div><p>正文</p>',
       contractNo: 'SX/X-JT20250926002',
       origin: 'http://localhost:3006'
     })
-    assert.equal(out.includes('watermask.jpg'), true)
+    assert.equal(out.includes('watermask'), false)
     assert.equal(out.includes('logo.gif'), true)
     assert.equal(out.includes('http://localhost:3006/logo.gif'), true)
   })
 
-  it('strips logo and skips watermark for non-SX', () => {
+  it('strips logo and watermark for non-SX', () => {
     const out = prepareContractHtmlForDocx({
-      html: '<div><img src="/logo.gif" /></div><p>正文</p>',
+      html: '<div><img src="/logo.gif" /></div><img src="/watermask.jpg" alt="水印" /><p>正文</p>',
       contractNo: 'YD/X-JT20250926001',
       origin: 'http://localhost:3006'
     })
@@ -76,6 +103,21 @@ describe('prepareContractHtmlForDocx', () => {
     assert.equal(out.includes('logo.gif'), false)
     assert.equal(out.includes('正文'), true)
   })
+
+  it('normalizes party flex layout during prepare', () => {
+    const html = `
+      <div style="display: flex; justify-content: space-between; margin-top: 10px;">
+        <div style="flex: 1;"><div>供方</div><div>A</div></div>
+        <div style="flex: 1;"><div>需方</div><div>B</div></div>
+      </div>`
+    const out = prepareContractHtmlForDocx({
+      html,
+      contractNo: 'SX/X-JT1',
+      origin: 'http://localhost:3006'
+    })
+    assert.equal(out.includes('<table'), true)
+    assert.equal(/display:\s*flex/.test(out), false)
+  })
 })
 
 describe('buildContractDocxFileName', () => {

+ 90 - 9
web-admin/src/utils/crm/contract-html-to-docx.ts

@@ -140,6 +140,16 @@ export function stripContractLogoHtml(html: string): string {
   return result
 }
 
+/** Word 导出不含水印图 */
+export function stripWatermarkHtml(html: string): string {
+  let result = html.replace(/<div[^>]*>\s*<img[^>]*(?:watermask|alt\s*=\s*["']水印["'])[^>]*>[\s\S]*?<\/div>/gi, (match) => {
+    if (/watermask|alt\s*=\s*["']水印["']/i.test(match)) return ''
+    return match
+  })
+  result = result.replace(/<img[^>]*(?:watermask|alt\s*=\s*["']水印["'])[^>]*>/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) => {
@@ -147,11 +157,83 @@ export function toAbsoluteAssetUrls(html: string, origin: string): string {
   })
 }
 
-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}`
+/**
+ * 从 openIdx 处的 `<div` 起做括号匹配,返回整段与 innerHTML。
+ */
+function takeBalancedDiv(
+  html: string,
+  openIdx: number
+): { full: string; inner: string; end: number } | null {
+  const slice = html.slice(openIdx)
+  if (!/^<div\b/i.test(slice)) return null
+  const openTagEnd = html.indexOf('>', openIdx)
+  if (openTagEnd < 0) return null
+
+  let i = openTagEnd + 1
+  let depth = 1
+  const lower = html.toLowerCase()
+  while (i < html.length && depth > 0) {
+    const nextOpen = lower.indexOf('<div', i)
+    const nextClose = lower.indexOf('</div>', i)
+    if (nextClose < 0) return null
+    if (nextOpen >= 0 && nextOpen < nextClose) {
+      depth += 1
+      const tagEnd = html.indexOf('>', nextOpen)
+      i = tagEnd < 0 ? nextOpen + 4 : tagEnd + 1
+    } else {
+      depth -= 1
+      if (depth === 0) {
+        const end = nextClose + 6
+        return {
+          full: html.slice(openIdx, end),
+          inner: html.slice(openTagEnd + 1, nextClose),
+          end
+        }
+      }
+      i = nextClose + 6
+    }
+  }
+  return null
+}
+
+/**
+ * 合同底部供方/需方在预览里是 flex 左右栏;Word 不认 flex,导出前改为双列表格。
+ */
+export function normalizePartyFlexLayoutForDocx(html: string): string {
+  const openRe =
+    /<div\b(?=[^>]*style\s*=\s*["'][^"']*display:\s*flex)(?=[^>]*justify-content:\s*space-between)[^>]*>/gi
+  const opens: number[] = []
+  let m: RegExpExecArray | null
+  while ((m = openRe.exec(html)) !== null) {
+    opens.push(m.index)
+  }
+
+  let out = html
+  for (let oi = opens.length - 1; oi >= 0; oi -= 1) {
+    const index = opens[oi]
+    const flex = takeBalancedDiv(out, index)
+    if (!flex) continue
+
+    const children: string[] = []
+    let pos = 0
+    const innerLower = flex.inner.toLowerCase()
+    while (pos < flex.inner.length && children.length < 2) {
+      const divIdx = innerLower.indexOf('<div', pos)
+      if (divIdx < 0) break
+      const child = takeBalancedDiv(flex.inner, divIdx)
+      if (!child) break
+      children.push(child.inner)
+      pos = child.end
+    }
+
+    if (children.length < 2) continue
+    if (!children[0].includes('供方') || !children[1].includes('需方')) continue
+
+    const table = `<table style="width:100%;border-collapse:collapse;margin-top:10px;table-layout:fixed;"><tbody><tr><td style="width:50%;vertical-align:top;border:1px solid #000;padding:8px;line-height:1.3;font-size:12px;">${children[0]}</td><td style="width:50%;vertical-align:top;border:1px solid #000;padding:8px;line-height:1.3;font-size:12px;">${children[1]}</td></tr></tbody></table>`
+    out = out.slice(0, index) + table + out.slice(flex.end)
+  }
+
+  return out
 }
 
 export function prepareContractHtmlForDocx(options: {
@@ -164,15 +246,14 @@ export function prepareContractHtmlForDocx(options: {
   const isSX = supplierCode === 'SX'
 
   let html = options.html || ''
+  // Word 导出明确不要水印图
+  html = stripWatermarkHtml(html)
   if (!isSX) {
     html = stripContractLogoHtml(html)
   }
 
   html = toAbsoluteAssetUrls(html, origin)
-
-  if (isSX) {
-    html = injectSxWatermarkHtml(html, `${origin}/watermask.jpg`)
-  }
+  html = normalizePartyFlexLayoutForDocx(html)
 
   return html
 }