fix: 保留合同表格调整后的列宽行高并修复导出水印,确保保存与导出布局一致。

This commit is contained in:
wuzilong committed 2026-10-29 15:43:42 +08:00
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)
}
+15 -10
View File
@@ -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)
})
}