From 57c2a37171eaa7f355a7a780d22b0b658718d0c7 Mon Sep 17 00:00:00 2001 From: zlwu Date: Sat, 5 Sep 2026 15:21:10 +0800 Subject: [PATCH] fix(web-admin): drop Word watermark and keep party columns side-by-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../utils/crm/contract-html-to-docx.test.ts | 64 +++++++++--- .../src/utils/crm/contract-html-to-docx.ts | 99 +++++++++++++++++-- 2 files changed, 143 insertions(+), 20 deletions(-) diff --git a/web-admin/src/utils/crm/contract-html-to-docx.test.ts b/web-admin/src/utils/crm/contract-html-to-docx.test.ts index e4db5ae1a..bdb153f44 100644 --- a/web-admin/src/utils/crm/contract-html-to-docx.test.ts +++ b/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 = + '
水印

正文

' + 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('

合同

', '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('

合同

'), true) +describe('normalizePartyFlexLayoutForDocx', () => { + it('converts bottom party flex columns into a two-cell table', () => { + const html = ` +
+
+
供方
+
单位名称(章):甲公司
+
+
+
需方
+
单位名称(章):乙公司
+
+
` + const out = normalizePartyFlexLayoutForDocx(html) + assert.equal(out.includes('display: flex'), false) + assert.equal(out.includes(' { - it('adds SX watermark and keeps logo', () => { + it('keeps logo and does not inject watermark for SX', () => { const out = prepareContractHtmlForDocx({ html: '

正文

', 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: '

正文

', + html: '
水印

正文

', 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 = ` +
+
供方
A
+
需方
B
+
` + const out = prepareContractHtmlForDocx({ + html, + contractNo: 'SX/X-JT1', + origin: 'http://localhost:3006' + }) + assert.equal(out.includes(' { diff --git a/web-admin/src/utils/crm/contract-html-to-docx.ts b/web-admin/src/utils/crm/contract-html-to-docx.ts index 4917799bb..d11974c5c 100644 --- a/web-admin/src/utils/crm/contract-html-to-docx.ts +++ b/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(/]*>\s*]*(?:watermask|alt\s*=\s*["']水印["'])[^>]*>[\s\S]*?<\/div>/gi, (match) => { + if (/watermask|alt\s*=\s*["']水印["']/i.test(match)) return '' + return match + }) + result = result.replace(/]*(?: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 = `
- 水印 -
` - return `${watermarkBlock}${html}` +/** + * 从 openIdx 处的 `', 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('', 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 = + /]*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('${children[0]}${children[1]}` + 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 }