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:
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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user