|
|
@@ -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 {
|