fix: 保留合同表格调整后的列宽行高并修复导出水印,确保保存与导出布局一致。
This commit is contained in:
1 parent
027d1751a3
commit
94727cdca9
12 files changed
+591
-119
No files matched your search
@@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import {
|
||||
applyProductColumnWidthsInDocxXml,
|
||||
distributeColumnTwips
|
||||
} from './contract-html-to-docx.ts'
|
||||
|
||||
const docxXml = `
|
||||
<w:document>
|
||||
<w:tbl>
|
||||
<w:tblPr><w:tblW w:w="3000" w:type="dxa"/></w:tblPr>
|
||||
<w:tblGrid><w:gridCol w:w="1000"/><w:gridCol w:w="1000"/><w:gridCol w:w="1000"/></w:tblGrid>
|
||||
<w:tr>
|
||||
<w:tc><w:p><w:r><w:t>产品名称</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>规格</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>包装规格</w:t></w:r></w:p></w:tc>
|
||||
</w:tr>
|
||||
<w:tr>
|
||||
<w:tc><w:tcPr><w:gridSpan w:val="2"/></w:tcPr><w:p><w:r><w:t>合计</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>备注</w:t></w:r></w:p></w:tc>
|
||||
</w:tr>
|
||||
</w:tbl>
|
||||
<w:tbl>
|
||||
<w:tblGrid><w:gridCol w:w="500"/><w:gridCol w:w="500"/></w:tblGrid>
|
||||
<w:tr>
|
||||
<w:tc><w:p><w:r><w:t>供方</w:t></w:r></w:p></w:tc>
|
||||
<w:tc><w:p><w:r><w:t>需方</w:t></w:r></w:p></w:tc>
|
||||
</w:tr>
|
||||
</w:tbl>
|
||||
</w:document>`
|
||||
|
||||
describe('distributeColumnTwips', () => {
|
||||
it('keeps the table width and gives the remainder to the last column', () => {
|
||||
assert.deepEqual(distributeColumnTwips([200, 50, 50], 3000), [2000, 500, 500])
|
||||
assert.deepEqual(distributeColumnTwips([1, 1, 1], 10), [3, 3, 4])
|
||||
})
|
||||
})
|
||||
|
||||
describe('applyProductColumnWidthsInDocxXml', () => {
|
||||
it('writes the dragged ratio onto the product table and leaves other tables equal', () => {
|
||||
const xml = applyProductColumnWidthsInDocxXml(docxXml, [200, 50, 50])
|
||||
const product = xml.slice(xml.indexOf('<w:tbl>'), xml.indexOf('</w:tbl>') + 8)
|
||||
assert.match(product, /<w:tblLayout w:type="fixed"\/>/)
|
||||
assert.match(
|
||||
product,
|
||||
/<w:gridCol w:w="2000"\/><w:gridCol w:w="500"\/><w:gridCol w:w="500"\/>/
|
||||
)
|
||||
assert.match(product, /<w:tcW w:w="2000" w:type="dxa"\/>/)
|
||||
assert.match(product, /<w:tcW w:w="2500" w:type="dxa"\/>/)
|
||||
assert.match(xml, /<w:gridCol w:w="500"\/><w:gridCol w:w="500"\/>/)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
/** 合同打印弹窗:HTML → DOCX 预处理与生成 */
|
||||
|
||||
import { Buffer } from 'buffer'
|
||||
import { readProductColumnWidthsPx } from './contract-table-layout'
|
||||
|
||||
const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||
|
||||
@@ -125,7 +126,143 @@ export async function generateContractDocxBlob(
|
||||
const withoutMetaBorders = await clearContractMetaRowBordersInDocx(blob)
|
||||
const withPartyGrid = await stylePartyGridTablesInDocx(withoutMetaBorders)
|
||||
const withTitleLines = await stylePartyTitleUnderlineTablesInDocx(withPartyGrid)
|
||||
return floatSealImagesInDocx(withTitleLines)
|
||||
const withSeals = await floatSealImagesInDocx(withTitleLines)
|
||||
return applyProductColumnWidthsInDocx(withSeals, preparedHtml)
|
||||
}
|
||||
|
||||
/** 按像素比例分配 Word 列宽(twip),最后一列吃掉四舍五入余数。 */
|
||||
export function distributeColumnTwips(widthsPx: number[], totalTwips: number): number[] {
|
||||
const sum = widthsPx.reduce((total, width) => total + width, 0)
|
||||
if (sum <= 0 || totalTwips <= 0 || widthsPx.length === 0) return []
|
||||
const out: number[] = []
|
||||
let used = 0
|
||||
widthsPx.forEach((width, index) => {
|
||||
if (index === widthsPx.length - 1) {
|
||||
out.push(Math.max(1, totalTwips - used))
|
||||
return
|
||||
}
|
||||
const next = Math.max(1, Math.round((width / sum) * totalTwips))
|
||||
out.push(next)
|
||||
used += next
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
function isProductDocxTable(tableXml: string): boolean {
|
||||
const texts = [...tableXml.matchAll(/<w:t[^>]*>([^<]*)<\/w:t>/g)].map((item) => item[1]).join('')
|
||||
return texts.includes('产品名称') && texts.includes('包装规格')
|
||||
}
|
||||
|
||||
function ensureFixedTableLayout(tableXml: string): string {
|
||||
const pr = tableXml.match(/<w:tblPr\b[^>]*>[\s\S]*?<\/w:tblPr>/)
|
||||
if (!pr || pr.index == null) {
|
||||
return tableXml.replace(
|
||||
/<w:tbl\b[^>]*>/,
|
||||
(open) => `${open}<w:tblPr><w:tblLayout w:type="fixed"/></w:tblPr>`
|
||||
)
|
||||
}
|
||||
const fixed = /<w:tblLayout\b/.test(pr[0])
|
||||
? pr[0].replace(/<w:tblLayout\b[^/]*\/>/, '<w:tblLayout w:type="fixed"/>')
|
||||
: pr[0].replace(/<w:tblPr\b[^>]*>/, (open) => `${open}<w:tblLayout w:type="fixed"/>`)
|
||||
return tableXml.slice(0, pr.index) + fixed + tableXml.slice(pr.index + pr[0].length)
|
||||
}
|
||||
|
||||
function rewriteGridColumns(tableXml: string, twips: number[]): string | null {
|
||||
const grid = tableXml.match(/<w:tblGrid>[\s\S]*?<\/w:tblGrid>/)
|
||||
if (!grid || grid.index == null) return null
|
||||
const cols = [...grid[0].matchAll(/<w:gridCol\b[^>]*\/>/g)]
|
||||
if (cols.length !== twips.length) return null
|
||||
let index = 0
|
||||
const nextGrid = grid[0].replace(/<w:gridCol\b[^>]*\/>/g, (tag) => {
|
||||
const width = twips[index]
|
||||
index += 1
|
||||
if (/w:w="/.test(tag)) return tag.replace(/w:w="\d+"/, `w:w="${width}"`)
|
||||
return tag.replace(/<w:gridCol\b/, `<w:gridCol w:w="${width}"`)
|
||||
})
|
||||
return tableXml.slice(0, grid.index) + nextGrid + tableXml.slice(grid.index + grid[0].length)
|
||||
}
|
||||
|
||||
function upsertCellWidth(cell: string, width: number): string {
|
||||
const tag = `<w:tcW w:w="${width}" w:type="dxa"/>`
|
||||
if (/<w:tcW\b[^>]*\/>/.test(cell)) return cell.replace(/<w:tcW\b[^>]*\/>/, tag)
|
||||
if (/<w:tcW\b[\s\S]*?<\/w:tcW>/.test(cell)) return cell.replace(/<w:tcW\b[\s\S]*?<\/w:tcW>/, tag)
|
||||
if (/<w:tcPr\b[^>]*>/.test(cell)) return cell.replace(/<w:tcPr\b[^>]*>/, (open) => `${open}${tag}`)
|
||||
return cell.replace(/<w:tc\b([^>]*)>/, `<w:tc$1><w:tcPr>${tag}</w:tcPr>`)
|
||||
}
|
||||
|
||||
function rewriteRowCellWidths(rowXml: string, twips: number[]): string {
|
||||
const cells = [...rowXml.matchAll(/<w:tc\b[\s\S]*?<\/w:tc>/g)]
|
||||
if (!cells.length) return rowXml
|
||||
let col = 0
|
||||
const widths: number[] = []
|
||||
for (const cell of cells) {
|
||||
const spanMatch = /<w:gridSpan\b[^>]*w:val="(\d+)"/.exec(cell[0])
|
||||
const span = Math.max(1, spanMatch ? Number(spanMatch[1]) : 1)
|
||||
if (col + span > twips.length) return rowXml
|
||||
widths.push(twips.slice(col, col + span).reduce((total, width) => total + width, 0))
|
||||
col += span
|
||||
}
|
||||
if (col !== twips.length) return rowXml
|
||||
let index = 0
|
||||
return rowXml.replace(/<w:tc\b[\s\S]*?<\/w:tc>/g, (cell) => upsertCellWidth(cell, widths[index++]))
|
||||
}
|
||||
|
||||
function maskNestedTables(xml: string): { xml: string; nested: string[] } {
|
||||
const tables = extractBalancedDocxTables(xml)
|
||||
let result = xml
|
||||
for (let i = tables.length - 1; i >= 0; i -= 1) {
|
||||
result = result.slice(0, tables[i].start) + `<!--NESTED${i}-->` + result.slice(tables[i].end)
|
||||
}
|
||||
return { xml: result, nested: tables.map((table) => table.xml) }
|
||||
}
|
||||
|
||||
function rewriteProductTableColumns(tableXml: string, widthsPx: number[]): string {
|
||||
const existing = [
|
||||
...(tableXml.match(/<w:tblGrid>[\s\S]*?<\/w:tblGrid>/)?.[0].matchAll(/w:w="(\d+)"/g) || [])
|
||||
].map((item) => Number(item[1]))
|
||||
if (existing.length !== widthsPx.length) return tableXml
|
||||
const total = existing.reduce((sum, width) => sum + width, 0)
|
||||
if (total <= 0) return tableXml
|
||||
const twips = distributeColumnTwips(widthsPx, total)
|
||||
const withGrid = rewriteGridColumns(ensureFixedTableLayout(tableXml), twips)
|
||||
if (!withGrid) return tableXml
|
||||
const gridEnd = withGrid.indexOf('</w:tblGrid>')
|
||||
if (gridEnd < 0) return withGrid
|
||||
const splitAt = gridEnd + '</w:tblGrid>'.length
|
||||
const masked = maskNestedTables(withGrid.slice(splitAt))
|
||||
const rows = masked.xml.replace(/<w:tr\b[\s\S]*?<\/w:tr>/g, (row) => rewriteRowCellWidths(row, twips))
|
||||
const restored = masked.nested.reduce(
|
||||
(xml, table, index) => xml.replace(`<!--NESTED${index}-->`, table),
|
||||
rows
|
||||
)
|
||||
return withGrid.slice(0, splitAt) + restored
|
||||
}
|
||||
|
||||
/**
|
||||
* html-to-docx 会把表格列等分,并默认自适应,拖过的列宽进不了 Word。
|
||||
* 按产品表像素列宽重写 tblGrid / 单元格宽度,并改为固定布局。
|
||||
*/
|
||||
export function applyProductColumnWidthsInDocxXml(xml: string, widthsPx: number[]): string {
|
||||
if (widthsPx.length < 2) return xml
|
||||
return mapAllDocxTables(xml, (tableXml) => {
|
||||
if (!isProductDocxTable(tableXml)) return tableXml
|
||||
return rewriteProductTableColumns(tableXml, widthsPx)
|
||||
})
|
||||
}
|
||||
|
||||
async function applyProductColumnWidthsInDocx(docxBlob: Blob, html: string): Promise<Blob> {
|
||||
const widths = readProductColumnWidthsPx(html)
|
||||
if (!widths) return docxBlob
|
||||
const JSZip = (await import('jszip')).default
|
||||
const zip = await JSZip.loadAsync(await docxBlob.arrayBuffer())
|
||||
const docFile = zip.file('word/document.xml')
|
||||
if (!docFile) return docxBlob
|
||||
const xml = applyProductColumnWidthsInDocxXml(await docFile.async('string'), widths)
|
||||
zip.file('word/document.xml', xml)
|
||||
return zip.generateAsync({
|
||||
type: 'blob',
|
||||
mimeType: DOCX_MIME
|
||||
})
|
||||
}
|
||||
|
||||
export function parseContractSupplierCode(contractNo?: string | null): string {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { applySavedProductTableLayout, stampSavedProductTableLayout } from './contract-table-layout.ts'
|
||||
|
||||
const edited = `
|
||||
<table style="width: 680px;">
|
||||
<colgroup><col style="width: 120px;"><col style="width: 80px;"></colgroup>
|
||||
<thead><tr style="height: 48px;"><th style="width: 120px;">产品名称</th><th style="width: 80px;">包装规格</th></tr></thead>
|
||||
<tbody>
|
||||
<tr style="height: 64px;"><td>32%液碱</td><td>槽罐车</td></tr>
|
||||
<tr style="height: 36px;"><td>运费</td><td>-</td></tr>
|
||||
</tbody>
|
||||
</table>`
|
||||
|
||||
const template = `
|
||||
<table style="width: 100%;">
|
||||
<thead><tr><th style="width: 13%;">产品名称</th><th style="width: 11%;">包装规格</th></tr></thead>
|
||||
<tbody>{{productTable}}{{productTotalRow}}</tbody>
|
||||
</table>`
|
||||
|
||||
describe('stampSavedProductTableLayout', () => {
|
||||
it('keeps edited column widths and records row heights on the template', () => {
|
||||
const saved = stampSavedProductTableLayout(edited, template)
|
||||
assert.match(saved, /width: 120px/)
|
||||
assert.match(saved, /width: 80px/)
|
||||
assert.match(saved, /data-sx-row-heights="48,64,36"/)
|
||||
assert.match(saved, /\{\{productTable\}\}/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('applySavedProductTableLayout', () => {
|
||||
it('writes saved row heights back onto regenerated rows', () => {
|
||||
const saved = stampSavedProductTableLayout(edited, template).replace(
|
||||
'{{productTable}}{{productTotalRow}}',
|
||||
'<tr><td>32%液碱</td><td>槽罐车</td></tr><tr><td>运费</td><td>-</td></tr>'
|
||||
)
|
||||
const opened = applySavedProductTableLayout(saved)
|
||||
assert.match(opened, /<tr style="height: 48px;"/)
|
||||
assert.match(opened, /<tr style="height: 64px;"/)
|
||||
assert.match(opened, /<td style="height: 64px;">32%液碱<\/td><td style="height: 64px;">槽罐车<\/td>/)
|
||||
assert.match(opened, /<tr style="height: 36px;"/)
|
||||
assert.match(opened, /width: 120px/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 保存模板时产品明细会换回占位符,下次打开再按数据生成行。
|
||||
* 列宽在表头和 col 上,行高记在表格属性里,打开时写回每一行。
|
||||
*/
|
||||
|
||||
const PRODUCT_TABLE_RE = /<table\b[\s\S]*?<\/table>/gi
|
||||
|
||||
const findProductTable = (html: string): { table: string; index: number } | null => {
|
||||
PRODUCT_TABLE_RE.lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
while ((match = PRODUCT_TABLE_RE.exec(html))) {
|
||||
if (/>\s*产品名称\s*</.test(match[0]) && />\s*包装规格\s*</.test(match[0])) {
|
||||
return { table: match[0], index: match.index }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const readPx = (tag: string): number => {
|
||||
const matched = /width\s*:\s*([\d.]+)px/i.exec(tag)
|
||||
const value = matched ? Math.round(Number(matched[1])) : 0
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
const readHeightPx = (tag: string): number => {
|
||||
const matched = /height\s*:\s*([\d.]+)px/i.exec(tag)
|
||||
const value = matched ? Math.round(Number(matched[1])) : 0
|
||||
return Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
/** 产品表已保存的像素列宽。Word / PDF 导出按这个比例,不再等分。 */
|
||||
export const readProductColumnWidthsPx = (html: string): number[] | null => {
|
||||
const found = findProductTable(html)
|
||||
if (!found) return null
|
||||
return readColumnWidths(found.table)
|
||||
}
|
||||
|
||||
const readColumnWidths = (table: string): number[] | null => {
|
||||
const cols = [...table.matchAll(/<col\b[^>]*>/gi)].map((item) => readPx(item[0]))
|
||||
if (cols.length > 1 && cols.every((width) => width > 0)) return cols
|
||||
const header = table.match(/<tr\b[^>]*>[\s\S]*?>\s*产品名称\s*<[\s\S]*?<\/tr>/i)
|
||||
if (!header) return null
|
||||
const cells = [...header[0].matchAll(/<th\b[^>]*>/gi)].map((item) => readPx(item[0]))
|
||||
if (cells.length > 1 && cells.every((width) => width > 0)) return cells
|
||||
return null
|
||||
}
|
||||
|
||||
const readRowHeights = (table: string): number[] =>
|
||||
[...table.matchAll(/<tr\b([^>]*)>([\s\S]*?)<\/tr>/gi)].map((row) => {
|
||||
const fromTr = readHeightPx(row[1])
|
||||
if (fromTr > 0) return fromTr
|
||||
const firstCell = /<(?:td|th)\b[^>]*>/i.exec(row[2])
|
||||
return firstCell ? readHeightPx(firstCell[0]) : 0
|
||||
})
|
||||
|
||||
const writeWidth = (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 writeHeightAttr = (attrs: string, px: number): string => {
|
||||
if (/height\s*:/i.test(attrs)) return attrs.replace(/height\s*:\s*[\d.]+px/i, `height: ${px}px`)
|
||||
if (/style\s*=\s*"/i.test(attrs)) return attrs.replace(/style\s*=\s*"/i, `style="height: ${px}px; `)
|
||||
return `${attrs} style="height: ${px}px;"`
|
||||
}
|
||||
|
||||
const writeColumnWidths = (table: string, widths: number[]): string => {
|
||||
let colIndex = 0
|
||||
let next = table.replace(/<col\b[^>]*>/gi, (col) => {
|
||||
const px = widths[colIndex]
|
||||
colIndex += 1
|
||||
return px ? writeWidth(col, px) : col
|
||||
})
|
||||
if (colIndex !== widths.length) {
|
||||
const cols = widths.map((px) => `<col style="width: ${px}px;">`).join('')
|
||||
if (/<colgroup\b[^>]*>[\s\S]*?<\/colgroup>/i.test(next)) {
|
||||
next = next.replace(/<colgroup\b[^>]*>[\s\S]*?<\/colgroup>/i, `<colgroup>${cols}</colgroup>`)
|
||||
} else {
|
||||
next = next.replace(/<table\b[^>]*>/i, (open) => `${open}<colgroup>${cols}</colgroup>`)
|
||||
}
|
||||
}
|
||||
const header = next.match(/<tr\b[^>]*>[\s\S]*?>\s*产品名称\s*<[\s\S]*?<\/tr>/i)
|
||||
if (header && header.index != null) {
|
||||
let thIndex = 0
|
||||
const rewritten = header[0].replace(/<th\b[^>]*>/gi, (th) => {
|
||||
const px = widths[thIndex]
|
||||
thIndex += 1
|
||||
return px ? writeWidth(th, px) : th
|
||||
})
|
||||
next = next.slice(0, header.index) + rewritten + next.slice(header.index + header[0].length)
|
||||
}
|
||||
const sum = widths.reduce((total, width) => total + width, 0)
|
||||
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
|
||||
}
|
||||
|
||||
const stampAttr = (table: string, heights: number[]): string => {
|
||||
const value = heights.join(',')
|
||||
return table.replace(/<table\b([^>]*)>/i, (open, attrs: string) => {
|
||||
if (/data-sx-row-heights\s*=/i.test(attrs)) {
|
||||
return open.replace(/data-sx-row-heights\s*=\s*"[^"]*"/i, `data-sx-row-heights="${value}"`)
|
||||
}
|
||||
return open.replace(/<table\b/i, `<table data-sx-row-heights="${value}"`)
|
||||
})
|
||||
}
|
||||
|
||||
/** 把编辑器里的列宽、行高记到即将保存的模板上。 */
|
||||
export const stampSavedProductTableLayout = (sourceHtml: string, targetHtml: string): string => {
|
||||
const source = findProductTable(sourceHtml)
|
||||
const target = findProductTable(targetHtml)
|
||||
if (!source || !target) return targetHtml
|
||||
let table = target.table
|
||||
const widths = readColumnWidths(source.table)
|
||||
if (widths) table = writeColumnWidths(table, widths)
|
||||
const heights = readRowHeights(source.table)
|
||||
if (heights.some((height) => height > 0)) table = stampAttr(table, heights)
|
||||
return targetHtml.slice(0, target.index) + table + targetHtml.slice(target.index + target.table.length)
|
||||
}
|
||||
|
||||
/** 打开模板、明细行重新生成后,把保存的行高写回每一行。 */
|
||||
export const applySavedProductTableLayout = (html: string): string => {
|
||||
const found = findProductTable(html)
|
||||
if (!found) return html
|
||||
const raw = /data-sx-row-heights\s*=\s*"([^"]*)"/i.exec(found.table)
|
||||
if (!raw) return html
|
||||
const heights = raw[1].split(',').map((item) => Math.round(Number(item)) || 0)
|
||||
let index = 0
|
||||
const table = found.table.replace(/<tr\b([^>]*)>([\s\S]*?)<\/tr>/gi, (row, attrs: string, inner: string) => {
|
||||
const height = heights[index] || 0
|
||||
index += 1
|
||||
if (height < 20) return row
|
||||
const nextAttrs = writeHeightAttr(attrs, height)
|
||||
const nextInner = inner.replace(/<(td|th)\b([^>]*)>/gi, (_cell, tag: string, cellAttrs: string) => {
|
||||
return `<${tag}${writeHeightAttr(cellAttrs, height)}>`
|
||||
})
|
||||
return `<tr${nextAttrs}>${nextInner}</tr>`
|
||||
})
|
||||
return html.slice(0, found.index) + table + html.slice(found.index + found.table.length)
|
||||
}
|
||||
@@ -2,8 +2,14 @@
|
||||
* 水印图为竖版(约 1280×1811),白底。
|
||||
* 编辑区、打印、PDF 都把水印放在每页右下角。
|
||||
* 按整页居中时,产品表会占满这一页,章会压在单价列上。
|
||||
* PDF 用图片定位。html2canvas 会把 `right 16px bottom 16px` 算成 calc(),
|
||||
* 解析背景时读到空值就抛 Cannot read properties of undefined (reading 'type')。
|
||||
*/
|
||||
|
||||
const WATERMARK_WIDTH_PX = 140
|
||||
const WATERMARK_HEIGHT_PX = Math.round((WATERMARK_WIDTH_PX * 1811) / 1280)
|
||||
const WATERMARK_INSET_PX = 16
|
||||
|
||||
const watermarkImageCss = (imageUrl: string): string => `
|
||||
background-image: url('${imageUrl}');
|
||||
background-repeat: no-repeat;
|
||||
@@ -44,20 +50,19 @@ export const appendContractPageWatermarks = (host: HTMLElement, pageWidthPx: num
|
||||
for (let i = 0; i < pages; i += 1) {
|
||||
const top = i * pageHeightPx
|
||||
const height = Math.min(pageHeightPx, total - top)
|
||||
const mark = document.createElement('div')
|
||||
const mark = document.createElement('img')
|
||||
mark.setAttribute('data-contract-watermark', '1')
|
||||
mark.alt = ''
|
||||
mark.src = '/watermask.jpg'
|
||||
const left = Math.max(0, width - WATERMARK_INSET_PX - WATERMARK_WIDTH_PX)
|
||||
const imageTop = Math.max(0, height - WATERMARK_INSET_PX - WATERMARK_HEIGHT_PX)
|
||||
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;
|
||||
left: ${left}px;
|
||||
top: ${top + imageTop}px;
|
||||
width: ${WATERMARK_WIDTH_PX}px;
|
||||
height: ${WATERMARK_HEIGHT_PX}px;
|
||||
opacity: 0.18;
|
||||
mix-blend-mode: multiply;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
`
|
||||
|
||||
@@ -1,20 +1,37 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { describe, it } from 'node:test'
|
||||
import { applyColumnWidthDelta, applyRowHeightDelta } from './tinymce-table-edge-resize.ts'
|
||||
import {
|
||||
applyColumnWidthDelta,
|
||||
applyRowHeightDelta,
|
||||
clampColumnWidthsToMax
|
||||
} from './tinymce-table-edge-resize.ts'
|
||||
|
||||
describe('applyColumnWidthDelta', () => {
|
||||
it('widens only the dragged column and grows the table by the same amount', () => {
|
||||
const start = [100, 36, 46]
|
||||
it('takes width from the next column and keeps the table width', () => {
|
||||
const start = [100, 80, 60]
|
||||
const { widths, tableWidth } = applyColumnWidthDelta(start, 1, 20)
|
||||
assert.deepEqual(widths, [100, 56, 46])
|
||||
assert.equal(tableWidth, 202)
|
||||
assert.deepEqual(start, [100, 36, 46])
|
||||
assert.deepEqual(widths, [100, 100, 40])
|
||||
assert.equal(tableWidth, 240)
|
||||
assert.deepEqual(start, [100, 80, 60])
|
||||
})
|
||||
|
||||
it('does not shrink a column below 28px', () => {
|
||||
const { widths, tableWidth } = applyColumnWidthDelta([100, 36, 46], 1, -100)
|
||||
assert.deepEqual(widths, [100, 28, 46])
|
||||
assert.equal(tableWidth, 174)
|
||||
it('does not shrink a column below 28px or grow the table from the last column', () => {
|
||||
const shrunk = applyColumnWidthDelta([100, 36, 46], 1, -100)
|
||||
assert.deepEqual(shrunk.widths, [100, 28, 54])
|
||||
assert.equal(shrunk.tableWidth, 182)
|
||||
|
||||
const last = applyColumnWidthDelta([100, 80, 60], 2, 40)
|
||||
assert.deepEqual(last.widths, [100, 80, 60])
|
||||
assert.equal(last.tableWidth, 240)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clampColumnWidthsToMax', () => {
|
||||
it('scales columns down to the page content width', () => {
|
||||
const { widths, tableWidth } = clampColumnWidthsToMax([200, 200, 200], 300)
|
||||
assert.equal(tableWidth, 300)
|
||||
assert.equal(widths.reduce((sum, width) => sum + width, 0), 300)
|
||||
assert.ok(widths.every((width) => width >= 28))
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* 富文本表格只在两条边上拖拽:
|
||||
* 第一行右边界改列宽(整表变宽,邻列不动);
|
||||
* 第一行右边界改列宽,表格总宽保持不变,也不超过版心;
|
||||
* 每一行从第 1 列开始的格子下边界改行高。
|
||||
* 打开编辑器时不写表格尺寸,只有按下拖动才写入。
|
||||
*/
|
||||
|
||||
export const MIN_COL_PX = 28
|
||||
export const MIN_ROW_PX = 20
|
||||
/** A4 内容区约 180mm,按 96dpi 约为 680px。 */
|
||||
export const A4_CONTENT_WIDTH_PX = 680
|
||||
|
||||
const EDGE_PX = 8
|
||||
const HANDLE_PX = 8
|
||||
@@ -18,17 +20,71 @@ export interface ColumnResizeResult {
|
||||
tableWidth: number
|
||||
}
|
||||
|
||||
/** 拖列只改这一列和右边相邻列,表格总宽不变。最后一列不能再把表拉宽。 */
|
||||
export const applyColumnWidthDelta = (
|
||||
widths: number[],
|
||||
index: number,
|
||||
delta: number,
|
||||
minPx = MIN_COL_PX
|
||||
): ColumnResizeResult => {
|
||||
const next = widths.slice()
|
||||
const next = widths.map((width) => Math.max(minPx, Math.round(width)))
|
||||
const current = next[index] ?? minPx
|
||||
next[index] = Math.max(minPx, Math.round(current + delta))
|
||||
const tableWidth = next.reduce((sum, width) => sum + width, 0)
|
||||
return { widths: next, tableWidth }
|
||||
const neighbor = index + 1
|
||||
if (neighbor >= next.length) {
|
||||
next[index] = Math.max(minPx, current + Math.min(0, Math.round(delta)))
|
||||
return { widths: next, tableWidth: next.reduce((sum, width) => sum + width, 0) }
|
||||
}
|
||||
const right = next[neighbor] ?? minPx
|
||||
const applied = Math.min(Math.max(Math.round(delta), -(current - minPx)), right - minPx)
|
||||
next[index] = current + applied
|
||||
next[neighbor] = right - applied
|
||||
return { widths: next, tableWidth: next.reduce((sum, width) => sum + width, 0) }
|
||||
}
|
||||
|
||||
/** 列宽之和超过版心时按比例收回,单列不低于下限。 */
|
||||
export const clampColumnWidthsToMax = (
|
||||
widths: number[],
|
||||
maxWidth: number,
|
||||
minPx = MIN_COL_PX
|
||||
): ColumnResizeResult => {
|
||||
const safe = widths.map((width) => Math.max(minPx, Math.round(width)))
|
||||
const minSum = safe.length * minPx
|
||||
const cap = Math.max(minSum, Math.round(maxWidth))
|
||||
const sum = safe.reduce((total, width) => total + width, 0)
|
||||
if (sum <= cap) return { widths: safe, tableWidth: sum }
|
||||
const extra = sum - cap
|
||||
const flexible = safe.map((width) => width - minPx)
|
||||
const flexSum = flexible.reduce((total, width) => total + width, 0) || 1
|
||||
const rounded = safe.map((width, i) => Math.max(minPx, Math.round(width - (extra * flexible[i]) / flexSum)))
|
||||
let drift = rounded.reduce((total, width) => total + width, 0) - cap
|
||||
for (let i = rounded.length - 1; i >= 0 && drift !== 0; i -= 1) {
|
||||
if (drift > 0) {
|
||||
const step = Math.min(drift, rounded[i] - minPx)
|
||||
rounded[i] -= step
|
||||
drift -= step
|
||||
} else {
|
||||
rounded[i] -= drift
|
||||
drift = 0
|
||||
}
|
||||
}
|
||||
return { widths: rounded, tableWidth: rounded.reduce((total, width) => total + width, 0) }
|
||||
}
|
||||
|
||||
/** 版心取页面内容区与 A4 内容宽中的较小值,避免表被拖出纸面。 */
|
||||
export const measureTableMaxWidth = (table: HTMLTableElement): number => {
|
||||
const view = table.ownerDocument.defaultView
|
||||
const body = table.ownerDocument.body
|
||||
let cap = A4_CONTENT_WIDTH_PX
|
||||
let node = table.parentElement
|
||||
while (node && node !== body) {
|
||||
const style = view?.getComputedStyle(node)
|
||||
const padL = style ? Number.parseFloat(style.paddingLeft) || 0 : 0
|
||||
const padR = style ? Number.parseFloat(style.paddingRight) || 0 : 0
|
||||
const inner = node.clientWidth - padL - padR
|
||||
if (inner >= 320) cap = Math.min(cap, Math.floor(inner))
|
||||
node = node.parentElement
|
||||
}
|
||||
return cap
|
||||
}
|
||||
|
||||
export const applyRowHeightDelta = (height: number, delta: number, minPx = MIN_ROW_PX): number =>
|
||||
@@ -89,6 +145,18 @@ const readColumnWidths = (table: HTMLTableElement): number[] => {
|
||||
return measured
|
||||
}
|
||||
|
||||
/** TinyMCE 序列化优先用 data-mce-style。只改 style 时,导出仍是拖拽前的列宽。 */
|
||||
const mirrorStyle = (el: HTMLElement) => {
|
||||
const style = el.getAttribute('style')
|
||||
if (style) el.setAttribute('data-mce-style', style)
|
||||
}
|
||||
|
||||
const syncLiveTableStyles = (root: ParentNode) => {
|
||||
root.querySelectorAll('table, col, tr, td, th').forEach((node) => {
|
||||
mirrorStyle(node as HTMLElement)
|
||||
})
|
||||
}
|
||||
|
||||
const writeColumnWidths = (table: HTMLTableElement, widths: number[]): void => {
|
||||
const doc = table.ownerDocument
|
||||
const sum = widths.reduce((total, width) => total + width, 0)
|
||||
@@ -127,12 +195,17 @@ const writeColumnWidths = (table: HTMLTableElement, widths: number[]): void => {
|
||||
}
|
||||
index += span
|
||||
})
|
||||
mirrorStyle(table)
|
||||
table.querySelectorAll('col').forEach((col) => mirrorStyle(col as HTMLElement))
|
||||
Array.from(header.cells).forEach((cell) => mirrorStyle(cell))
|
||||
}
|
||||
|
||||
const writeRowHeight = (row: HTMLTableRowElement, cell: HTMLTableCellElement, height: number): void => {
|
||||
const px = `${Math.round(height)}px`
|
||||
row.style.height = px
|
||||
cell.style.height = px
|
||||
mirrorStyle(row)
|
||||
mirrorStyle(cell)
|
||||
}
|
||||
|
||||
type DragState =
|
||||
@@ -279,12 +352,14 @@ export const setupTableEdgeResize = (editor: any): void => {
|
||||
editor.undoManager?.add?.()
|
||||
if (meta.kind === 'col') {
|
||||
if (meta.index < 0 || (cell.colSpan || 1) !== 1) return
|
||||
const fitted = clampColumnWidthsToMax(readColumnWidths(table), measureTableMaxWidth(table))
|
||||
editor.undoManager?.ignore?.(() => writeColumnWidths(table, fitted.widths))
|
||||
state = {
|
||||
kind: 'col',
|
||||
table,
|
||||
index: meta.index,
|
||||
startX: event.clientX,
|
||||
startWidths: readColumnWidths(table)
|
||||
startWidths: fitted.widths
|
||||
}
|
||||
} else {
|
||||
state = {
|
||||
@@ -328,6 +403,8 @@ export const setupTableEdgeResize = (editor: any): void => {
|
||||
}
|
||||
|
||||
editor.on('BeforeGetContent', () => {
|
||||
const body = bodyOf()
|
||||
if (body) syncLiveTableStyles(body)
|
||||
layer?.remove()
|
||||
})
|
||||
editor.on('GetContent', () => {
|
||||
|
||||
@@ -789,12 +789,28 @@ const getDeclaredColWidthPx = (table: HTMLTableElement, colIndex: number): numbe
|
||||
return null
|
||||
}
|
||||
|
||||
/** html2canvas 经常忽略 tr 高度;行高写到该行每一个单元格上,截图才能跟编辑器一致。 */
|
||||
const pinRowHeightsForCapture = (table: HTMLTableElement): void => {
|
||||
Array.from(table.rows).forEach((row) => {
|
||||
const fromRow = parseCssLengthPx(row.style.height)
|
||||
const first = row.cells[0]
|
||||
const fromCell = first ? parseCssLengthPx(first.style.height) : null
|
||||
const height = fromRow ?? fromCell
|
||||
if (!height || height < 20) return
|
||||
const px = `${Math.round(height)}px`
|
||||
row.style.height = px
|
||||
Array.from(row.cells).forEach((cell) => {
|
||||
cell.style.height = px
|
||||
cell.style.minHeight = px
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 将富文本内表格强制适配到 maxWidthPx(用于 PDF/打印容器)。
|
||||
* 将富文本内表格适配到 maxWidthPx(用于 PDF/打印容器)。
|
||||
*
|
||||
* 根因:编辑器 content_style 有 max-width:100%,拖宽后视觉已钳制,但 HTML 仍保留更大的
|
||||
* 单元格/col 像素宽。PDF 临时 DOM 无该样式时,声明宽会撑破 A4,html2canvas 裁切右侧。
|
||||
* 因此必须以「声明列宽之和」为准重写,不能只看 getBoundingClientRect。
|
||||
* 列宽保持编辑器里的像素比例。只有总宽超过页面时才等比缩小。
|
||||
* 不改成整数百分比,避免 12 列四舍五入后和拖拽结果对不上。
|
||||
* 仅改临时 DOM,不回写业务 HTML。
|
||||
*/
|
||||
export function fitRichTextTablesToMaxWidth(root: HTMLElement, maxWidthPx: number): void {
|
||||
@@ -802,11 +818,9 @@ export function fitRichTextTablesToMaxWidth(root: HTMLElement, maxWidthPx: numbe
|
||||
|
||||
const targetW = Math.floor(maxWidthPx)
|
||||
|
||||
// 外层模版常带 max-width:794 + padding,叠在 PDF 容器 padding 上会再挤窄;统一吃满内容区
|
||||
root.querySelectorAll<HTMLElement>('[style*="max-width"]').forEach((el) => {
|
||||
if (el.tagName === 'TABLE' || el.tagName === 'IMG') return
|
||||
el.style.maxWidth = '100%'
|
||||
el.style.width = '100%'
|
||||
el.style.boxSizing = 'border-box'
|
||||
})
|
||||
|
||||
@@ -817,8 +831,6 @@ export function fitRichTextTablesToMaxWidth(root: HTMLElement, maxWidthPx: numbe
|
||||
el.style.boxSizing = 'border-box'
|
||||
el.style.minWidth = '0'
|
||||
el.removeAttribute('width')
|
||||
el.style.wordBreak = 'break-word'
|
||||
el.style.overflowWrap = 'anywhere'
|
||||
})
|
||||
table.removeAttribute('width')
|
||||
|
||||
@@ -842,86 +854,33 @@ export function fitRichTextTablesToMaxWidth(root: HTMLElement, maxWidthPx: numbe
|
||||
}
|
||||
|
||||
const rawSum = raw.reduce((a, b) => a + b, 0) || cols
|
||||
const tableDeclared =
|
||||
parseCssLengthPx(table.style.width) ?? parseCssLengthPx(table.getAttribute('width'))
|
||||
// 任一声明总宽超过目标,或列宽之和超过目标,都必须重写(不能因视觉已钳制而跳过)
|
||||
const needsFit = (tableDeclared != null && tableDeclared > targetW + 1) || rawSum > targetW + 1
|
||||
|
||||
table.style.maxWidth = '100%'
|
||||
table.style.width = '100%'
|
||||
table.style.tableLayout = 'fixed'
|
||||
table.style.boxSizing = 'border-box'
|
||||
|
||||
if (!needsFit && rawSum <= targetW + 1) {
|
||||
// 声明已在范围内:仍统一成百分比,避免 PDF 环境差异
|
||||
let usedPct = 0
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const pct =
|
||||
i === cols - 1
|
||||
? Math.max(1, 100 - usedPct)
|
||||
: Math.max(1, Math.round((raw[i] / rawSum) * 100))
|
||||
if (i < cols - 1) usedPct += pct
|
||||
const pctStr = `${pct}%`
|
||||
getColumnCells(table, i).forEach((cell) => {
|
||||
cell.style.width = pctStr
|
||||
})
|
||||
const head = table.rows[0]
|
||||
if (head) {
|
||||
let idx = 0
|
||||
for (const cell of Array.from(head.cells)) {
|
||||
const span = cell.colSpan || 1
|
||||
if (i >= idx && i < idx + span && span === 1) {
|
||||
cell.style.width = pctStr
|
||||
break
|
||||
}
|
||||
idx += span
|
||||
}
|
||||
}
|
||||
const colEl = table.querySelectorAll('colgroup col')[i] as HTMLElement | undefined
|
||||
if (colEl) {
|
||||
colEl.style.width = pctStr
|
||||
colEl.removeAttribute('width')
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const scale = rawSum > targetW + 1 ? targetW / rawSum : 1
|
||||
let used = 0
|
||||
const widths = raw.map((w, i) => {
|
||||
if (scale === 1) return w
|
||||
if (i === cols - 1) return Math.max(MIN_COL_PX, targetW - used)
|
||||
const next = Math.max(MIN_COL_PX, Math.round((w / rawSum) * targetW))
|
||||
const next = Math.max(MIN_COL_PX, Math.round(w * scale))
|
||||
used += next
|
||||
return next
|
||||
})
|
||||
const sum = widths.reduce((a, b) => a + b, 0)
|
||||
|
||||
table.style.maxWidth = '100%'
|
||||
table.style.width = `${sum}px`
|
||||
table.style.tableLayout = 'fixed'
|
||||
table.style.boxSizing = 'border-box'
|
||||
|
||||
let usedPct = 0
|
||||
for (let i = 0; i < cols; i++) {
|
||||
const pct =
|
||||
i === cols - 1
|
||||
? Math.max(1, 100 - usedPct)
|
||||
: Math.max(1, Math.round((widths[i] / targetW) * 100))
|
||||
if (i < cols - 1) usedPct += pct
|
||||
const pctStr = `${pct}%`
|
||||
const px = `${widths[i]}px`
|
||||
getColumnCells(table, i).forEach((cell) => {
|
||||
cell.style.width = pctStr
|
||||
cell.style.width = px
|
||||
})
|
||||
const head = table.rows[0]
|
||||
if (head) {
|
||||
let idx = 0
|
||||
for (const cell of Array.from(head.cells)) {
|
||||
const span = cell.colSpan || 1
|
||||
if (i >= idx && i < idx + span && span === 1) {
|
||||
cell.style.width = pctStr
|
||||
break
|
||||
}
|
||||
idx += span
|
||||
}
|
||||
}
|
||||
const colEl = table.querySelectorAll('colgroup col')[i] as HTMLElement | undefined
|
||||
if (colEl) {
|
||||
colEl.style.width = pctStr
|
||||
colEl.style.width = px
|
||||
colEl.removeAttribute('width')
|
||||
}
|
||||
}
|
||||
pinRowHeightsForCapture(table)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user