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>
This commit is contained in:
zlwuandCursor committed 2026-09-05 15:21:10 +08:00
1 parent f872333df6
commit 57c2a37171
2 files changed
+143 -20

No files matched your search

@@ -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', () => {
@@ -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
}