feat: 合同富文本改为表头调列宽、首列调行高,避免拖拽把邻列挤窄。

产品表按像素列宽重排,水印改到页面右下角,打开编辑时不再铺满整表绿线。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-10-29 11:57:39 +08:00
1 parent bde19c0644
commit 3aa0f0ff2e
12 files changed
+734 -161

No files matched your search

@@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
CONTRACT_PRODUCT_COL_WIDTHS_PX,
normalizeContractProductTableWidths
} from './contract-product-table-widths'
const sample = `
<table style="width: 680px; table-layout: fixed;">
<colgroup><col style="width: 73px;"><col style="width: 49px;"><col style="width: 34px;"><col style="width: 39px;"><col style="width: 37px;"><col style="width: 34px;"><col style="width: 82px;"><col style="width: 82px;"><col style="width: 68px;"><col style="width: 67px;"><col style="width: 62px;"><col style="width: 53px;"></colgroup>
<tr><th style="width: 73px;">产品名称</th><th style="width: 49px;">规格</th><th style="width: 34px;">品牌</th><th style="width: 39px;">单位</th><th style="width: 37px;">用量</th><th style="width: 34px;">税率</th><th style="width: 82px;">含税单价(元)</th><th style="width: 82px;">未含税金额(元)</th><th style="width: 68px;">税费(元)</th><th style="width: 67px;">含税金额(元)</th><th style="width: 62px;">包装规格</th><th style="width: 53px;">备注</th></tr>
</table>`
describe('normalizeContractProductTableWidths', () => {
it('rewrites saved pixel columns so the product name is 100px', () => {
const out = normalizeContractProductTableWidths(sample)
assert.match(out, /<col style="width: 100px;">/)
assert.equal(out.includes('width: 73px'), false)
assert.match(out, /<th style="width: 100px;">产品名称<\/th>/)
assert.match(out, /<table style="width: 680px;/)
const amount = CONTRACT_PRODUCT_COL_WIDTHS_PX.slice(2, 6).reduce((sum, n) => sum + n, 0)
assert.ok(amount >= 180)
})
it('leaves unrelated tables unchanged', () => {
const other = '<table><tr><th style="width: 10px;">其他</th></tr></table>'
assert.equal(normalizeContractProductTableWidths(other), other)
})
})
@@ -0,0 +1,58 @@
/**
* 已保存模板常被 TinyMCE 写成像素列宽(产品名称约 73px),
* 百分比补丁匹配不到,长品名和合计大写会被拆行。
* 12 列合计 680px:名称能放下「30%工业硫酸」,
* 品牌+单位+用量+税率合计 184px,能放下十四字大写金额。
*/
export const CONTRACT_PRODUCT_COL_WIDTHS_PX = [100, 36, 46, 44, 50, 44, 62, 70, 54, 58, 68, 48]
const setWidthPx = (tag: string, px: number): string => {
if (/width\s*:/i.test(tag)) {
return tag.replace(/width\s*:\s*[\d.]+(?:px|%)/i, `width: ${px}px`)
}
if (/style\s*=\s*"/i.test(tag)) {
return tag.replace(/style\s*=\s*"/i, `style="width: ${px}px; `)
}
return tag.replace(/\/?>$/, (end) => ` style="width: ${px}px;"${end}`)
}
const rewriteTags = (chunk: string, tagName: 'col' | 'th', widths: number[]): string | null => {
const tags = chunk.match(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')) || []
if (tags.length !== widths.length) return null
let index = 0
return chunk.replace(new RegExp(`<${tagName}\\b[^>]*>`, 'gi'), (tag) => setWidthPx(tag, widths[index++]))
}
/** 只调整含「产品名称」「包装规格」的产品表,其它表不动。 */
export const normalizeContractProductTableWidths = (html: string): string => {
if (!html) return html
const widths = CONTRACT_PRODUCT_COL_WIDTHS_PX
const sum = widths.reduce((total, width) => total + width, 0)
return html.replace(/<table\b[\s\S]*?<\/table>/gi, (table) => {
if (!/>\s*产品名称\s*</.test(table) || !/>\s*包装规格\s*</.test(table)) return table
let next = table
const colsRewritten = rewriteTags(next, 'col', widths)
if (colsRewritten) next = colsRewritten
const headerMatch = next.match(/<tr\b[^>]*>[\s\S]*?>\s*产品名称\s*<[\s\S]*?<\/tr>/i)
if (!headerMatch || headerMatch.index == null) return table
const headerRewritten = rewriteTags(headerMatch[0], 'th', widths)
if (!headerRewritten) return table
next =
next.slice(0, headerMatch.index) +
headerRewritten +
next.slice(headerMatch.index + headerMatch[0].length)
next = next.replace(/<table\b[^>]*>/i, (open) => {
if (/width\s*:/i.test(open)) {
return open.replace(/width\s*:\s*[\d.]+(?:px|%)/i, `width: ${sum}px`)
}
if (/style\s*=\s*"/i.test(open)) {
return open.replace(/style\s*=\s*"/i, `style="width: ${sum}px; `)
}
return open.replace(/>$/, ` style="width: ${sum}px;">`)
})
return next
})
}
@@ -0,0 +1,66 @@
/**
* 水印图为竖版(约 1280×1811),白底。
* 编辑区、打印、PDF 都把水印放在每页右下角。
* 按整页居中时,产品表会占满这一页,章会压在单价列上。
*/
const watermarkImageCss = (imageUrl: string): string => `
background-image: url('${imageUrl}');
background-repeat: no-repeat;
background-position: right 16px bottom 16px;
background-size: 140px auto;
opacity: 0.18;
mix-blend-mode: multiply;
pointer-events: none;
`
export const contractWatermarkPrintCss = (imageUrl: string): string => `
html { background: #fff; }
body { background: transparent; }
body::before {
content: '';
position: fixed;
inset: 0;
${watermarkImageCss(imageUrl)}
z-index: 0;
}
@media print {
body::before {
position: fixed;
print-color-adjust: exact;
-webkit-print-color-adjust: exact;
}
}
`
/** 在已排好版的容器上,按 A4 比例每页右下角铺一枚水印。 */
export const appendContractPageWatermarks = (host: HTMLElement, pageWidthPx: number): void => {
host.querySelectorAll('[data-contract-watermark]').forEach((node) => node.remove())
const width = Math.max(1, pageWidthPx)
const pageHeightPx = (width * 297) / 210
const total = host.scrollHeight
if (total <= 0) return
const pages = Math.max(1, Math.ceil(total / pageHeightPx))
for (let i = 0; i < pages; i += 1) {
const top = i * pageHeightPx
const height = Math.min(pageHeightPx, total - top)
const mark = document.createElement('div')
mark.setAttribute('data-contract-watermark', '1')
mark.style.cssText = `
position: absolute;
left: 0;
top: ${top}px;
width: 100%;
height: ${height}px;
background-image: url('/watermask.jpg');
background-repeat: no-repeat;
background-position: right 16px bottom 16px;
background-size: 140px auto;
opacity: 0.18;
mix-blend-mode: multiply;
pointer-events: none;
z-index: 2;
`
host.appendChild(mark)
}
}