feat: 合同富文本改为表头调列宽、首列调行高,避免拖拽把邻列挤窄。
产品表按像素列宽重排,水印改到页面右下角,打开编辑时不再铺满整表绿线。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user