import { TemplateElement, TableCellData } from './types'; import { resolvePaddingMm } from './elementBox'; export interface TableCellCoord { row: number; col: number; } export function tableCellKey(row: number, col: number): string { return `${row},${col}`; } export function parseTableCellKey(key: string): TableCellCoord | null { const [r, c] = key.split(',').map(Number); if (!Number.isFinite(r) || !Number.isFinite(c)) return null; return { row: r, col: c }; } const round1 = (n: number) => Math.round(n * 10) / 10; export function getTableRows(elem: TemplateElement): number { return Math.max(1, elem.tableRows ?? 3); } export function getTableCols(elem: TemplateElement): number { return Math.max(1, elem.tableCols ?? 3); } export function getTableInnerSizeMm(elem: TemplateElement): { width: number; height: number } { const pad = resolvePaddingMm(elem); return { width: Math.max(1, elem.width - pad.left - pad.right), height: Math.max(1, elem.height - pad.top - pad.bottom), }; } export function getTableRowHeights(elem: TemplateElement): number[] { const rows = getTableRows(elem); const stored = elem.tableRowHeights; if (stored && stored.length === rows) { return stored.map((h) => Math.max(0.5, h)); } const { height } = getTableInnerSizeMm(elem); const even = round1(height / rows); return Array.from({ length: rows }, () => even); } export function getTableColWidths(elem: TemplateElement): number[] { const cols = getTableCols(elem); const stored = elem.tableColWidths; if (stored && stored.length === cols) { return stored.map((w) => Math.max(0.5, w)); } const { width } = getTableInnerSizeMm(elem); const even = round1(width / cols); return Array.from({ length: cols }, () => even); } /** 内边距变化后保持行高/列宽不变,自动增大元素尺寸 */ export function expandTableForPadding(elem: TemplateElement): TemplateElement { if (elem.type !== 'table') return elem; const pad = resolvePaddingMm(elem); const rowHeights = getTableRowHeights(elem); const colWidths = getTableColWidths(elem); return { ...elem, tableRowHeights: rowHeights, tableColWidths: colWidths, height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom), width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right), }; } export function syncTableDimensionsFromTracks(elem: TemplateElement): TemplateElement { const pad = resolvePaddingMm(elem); const rowHeights = getTableRowHeights(elem); const colWidths = getTableColWidths(elem); return { ...elem, tableRowHeights: rowHeights, tableColWidths: colWidths, height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom), width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right), }; } export function scaleTableTracks( elem: TemplateElement, newWidth: number, newHeight: number, startWidth: number, startHeight: number, startRowHeights: number[], startColWidths: number[] ): TemplateElement { const pad = resolvePaddingMm(elem); const startInnerW = Math.max(0.1, startWidth - pad.left - pad.right); const startInnerH = Math.max(0.1, startHeight - pad.top - pad.bottom); const newInnerW = Math.max(1, newWidth - pad.left - pad.right); const newInnerH = Math.max(1, newHeight - pad.top - pad.bottom); const scaleW = newInnerW / startInnerW; const scaleH = newInnerH / startInnerH; const tableRowHeights = startRowHeights.map((h) => round1(Math.max(0.5, h * scaleH))); const tableColWidths = startColWidths.map((w) => round1(Math.max(0.5, w * scaleW))); return { ...elem, x: elem.x, y: elem.y, width: round1(newWidth), height: round1(newHeight), tableRowHeights, tableColWidths, }; } export function getCellData(elem: TemplateElement, row: number, col: number): TableCellData | undefined { return elem.tableCells?.[tableCellKey(row, col)]; } export function getCellAnchor(elem: TemplateElement, row: number, col: number): TableCellCoord { const cell = getCellData(elem, row, col); if (cell?.mergeAnchor) { return { row: cell.mergeAnchor.row, col: cell.mergeAnchor.col }; } return { row, col }; } export function getCellSpan( elem: TemplateElement, row: number, col: number ): { rowSpan: number; colSpan: number } { const anchor = getCellAnchor(elem, row, col); const anchorCell = getCellData(elem, anchor.row, anchor.col); return { rowSpan: Math.max(1, anchorCell?.rowSpan ?? 1), colSpan: Math.max(1, anchorCell?.colSpan ?? 1), }; } export function isCellAnchor(elem: TemplateElement, row: number, col: number): boolean { const anchor = getCellAnchor(elem, row, col); return anchor.row === row && anchor.col === col; } export function iterTableCells(elem: TemplateElement): TableCellCoord[] { const rows = getTableRows(elem); const cols = getTableCols(elem); const result: TableCellCoord[] = []; for (let r = 0; r < rows; r++) { for (let c = 0; c < cols; c++) { if (isCellAnchor(elem, r, c)) { result.push({ row: r, col: c }); } } } return result; } export function getCellRectMm( elem: TemplateElement, row: number, col: number ): { x: number; y: number; width: number; height: number } { const pad = resolvePaddingMm(elem); const rowHeights = getTableRowHeights(elem); const colWidths = getTableColWidths(elem); const anchor = getCellAnchor(elem, row, col); const { rowSpan, colSpan } = getCellSpan(elem, anchor.row, anchor.col); let y = pad.top; for (let r = 0; r < anchor.row; r++) y += rowHeights[r] ?? 0; let x = pad.left; for (let c = 0; c < anchor.col; c++) x += colWidths[c] ?? 0; let width = 0; for (let c = anchor.col; c < anchor.col + colSpan; c++) width += colWidths[c] ?? 0; let height = 0; for (let r = anchor.row; r < anchor.row + rowSpan; r++) height += rowHeights[r] ?? 0; return { x: round1(x), y: round1(y), width: round1(width), height: round1(height) }; } export function createDefaultTableElement(id: string, index: number): TemplateElement { const rows = 3; const cols = 3; const rowH = 5; const colW = 10; return { id, type: 'table', name: `表格 ${index}`, x: 5, y: 5, width: cols * colW, height: rows * rowH, content: '', fontSize: 10, textAlign: 'center', verticalAlign: 'middle', wordWrap: true, fontWeight: 'normal', barcodeFormat: 'CODE128', showText: false, fontFamily: 'sans', backgroundColor: '#ffffff', backgroundOpacity: 0, tableRows: rows, tableCols: cols, tableRowHeights: Array.from({ length: rows }, () => rowH), tableColWidths: Array.from({ length: cols }, () => colW), tableBorderWidth: 0.2, tableBorderColor: '#374151', tableCells: {}, }; } export function resizeTableGrid( elem: TemplateElement, newRows: number, newCols: number ): TemplateElement { const rows = Math.max(1, Math.min(50, newRows)); const cols = Math.max(1, Math.min(50, newCols)); const oldRows = getTableRows(elem); const oldCols = getTableCols(elem); const { width, height } = getTableInnerSizeMm(elem); let rowHeights = getTableRowHeights(elem); let colWidths = getTableColWidths(elem); if (rows > oldRows) { const added = rows - oldRows; const avg = rowHeights.reduce((a, b) => a + b, 0) / oldRows; rowHeights = [...rowHeights, ...Array.from({ length: added }, () => round1(avg))]; } else if (rows < oldRows) { rowHeights = rowHeights.slice(0, rows); } if (cols > oldCols) { const added = cols - oldCols; const avg = colWidths.reduce((a, b) => a + b, 0) / oldCols; colWidths = [...colWidths, ...Array.from({ length: added }, () => round1(avg))]; } else if (cols < oldCols) { colWidths = colWidths.slice(0, cols); } const newCells: Record = {}; if (elem.tableCells) { for (const [key, data] of Object.entries(elem.tableCells)) { const coord = parseTableCellKey(key); if (!coord || coord.row >= rows || coord.col >= cols) continue; const anchor = data.mergeAnchor ?? { row: coord.row, col: coord.col }; if (anchor.row >= rows || anchor.col >= cols) continue; newCells[key] = data; } } const pad = resolvePaddingMm(elem); const next: TemplateElement = { ...elem, tableRows: rows, tableCols: cols, tableRowHeights: rowHeights, tableColWidths: colWidths, tableCells: newCells, width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right), height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom), }; return sanitizeTableMerges(next); } function selectionBounds(selection: TableCellCoord[]) { const rows = selection.map((s) => s.row); const cols = selection.map((s) => s.col); return { minR: Math.min(...rows), maxR: Math.max(...rows), minC: Math.min(...cols), maxC: Math.max(...cols), }; } /** 合并选区的左上角锚点坐标 */ export function getMergeAnchorFromSelection(selection: TableCellCoord[]): TableCellCoord { const { minR, minC } = selectionBounds(selection); return { row: minR, col: minC }; } /** 将选区归一化为各合并区域的主单元格,去重 */ export function normalizeTableCellSelection( elem: TemplateElement, selection: TableCellCoord[] ): TableCellCoord[] { const seen = new Set(); const result: TableCellCoord[] = []; for (const coord of selection) { const anchor = getCellAnchor(elem, coord.row, coord.col); const key = tableCellKey(anchor.row, anchor.col); if (!seen.has(key)) { seen.add(key); result.push({ row: anchor.row, col: anchor.col }); } } return result; } export function canMergeTableCells(elem: TemplateElement, selection: TableCellCoord[]): boolean { if (selection.length < 2) return false; const { minR, maxR, minC, maxC } = selectionBounds(selection); const expected = (maxR - minR + 1) * (maxC - minC + 1); if (selection.length !== expected) return false; const selSet = new Set(selection.map((s) => tableCellKey(s.row, s.col))); for (let r = minR; r <= maxR; r++) { for (let c = minC; c <= maxC; c++) { if (!selSet.has(tableCellKey(r, c))) return false; const anchor = getCellAnchor(elem, r, c); const span = getCellSpan(elem, anchor.row, anchor.col); for (let ar = anchor.row; ar < anchor.row + span.rowSpan; ar++) { for (let ac = anchor.col; ac < anchor.col + span.colSpan; ac++) { if (!selSet.has(tableCellKey(ar, ac))) return false; } } } } return true; } export function mergeTableCells(elem: TemplateElement, selection: TableCellCoord[]): TemplateElement { if (!canMergeTableCells(elem, selection)) return elem; const { minR, maxR, minC, maxC } = selectionBounds(selection); const rowSpan = maxR - minR + 1; const colSpan = maxC - minC + 1; const cells = { ...(elem.tableCells ?? {}) }; for (let r = minR; r <= maxR; r++) { for (let c = minC; c <= maxC; c++) { const key = tableCellKey(r, c); const prev = cells[key] ?? {}; if (r === minR && c === minC) { cells[key] = { ...prev, rowSpan, colSpan, mergeAnchor: undefined }; } else { cells[key] = { ...prev, mergeAnchor: { row: minR, col: minC }, rowSpan: undefined, colSpan: undefined }; } } } return { ...elem, tableCells: cells }; } export function canUnmergeTableCell(elem: TemplateElement, row: number, col: number): boolean { const anchor = getCellAnchor(elem, row, col); const span = getCellSpan(elem, anchor.row, anchor.col); return span.rowSpan > 1 || span.colSpan > 1; } export function unmergeTableCell(elem: TemplateElement, row: number, col: number): TemplateElement { const anchor = getCellAnchor(elem, row, col); const span = getCellSpan(elem, anchor.row, anchor.col); if (span.rowSpan === 1 && span.colSpan === 1) return elem; const cells = { ...(elem.tableCells ?? {}) }; for (let r = anchor.row; r < anchor.row + span.rowSpan; r++) { for (let c = anchor.col; c < anchor.col + span.colSpan; c++) { const key = tableCellKey(r, c); const prev = { ...(cells[key] ?? {}) }; delete prev.mergeAnchor; delete prev.rowSpan; delete prev.colSpan; if (Object.keys(prev).length === 0) { delete cells[key]; } else { cells[key] = prev; } } } return { ...elem, tableCells: cells }; } function sanitizeTableMerges(elem: TemplateElement): TemplateElement { const rows = getTableRows(elem); const cols = getTableCols(elem); const cells = { ...(elem.tableCells ?? {}) }; for (const key of Object.keys(cells)) { const coord = parseTableCellKey(key); if (!coord || coord.row >= rows || coord.col >= cols) { delete cells[key]; } } return { ...elem, tableCells: cells }; } export function updateTableRowHeight( elem: TemplateElement, rowIndex: number, heightMm: number ): TemplateElement { const rowHeights = [...getTableRowHeights(elem)]; if (rowIndex < 0 || rowIndex >= rowHeights.length) return elem; rowHeights[rowIndex] = Math.max(0.5, round1(heightMm)); const pad = resolvePaddingMm(elem); return { ...elem, tableRowHeights: rowHeights, height: round1(rowHeights.reduce((a, b) => a + b, 0) + pad.top + pad.bottom), }; } export function updateTableColWidth( elem: TemplateElement, colIndex: number, widthMm: number ): TemplateElement { const colWidths = [...getTableColWidths(elem)]; if (colIndex < 0 || colIndex >= colWidths.length) return elem; colWidths[colIndex] = Math.max(0.5, round1(widthMm)); const pad = resolvePaddingMm(elem); return { ...elem, tableColWidths: colWidths, width: round1(colWidths.reduce((a, b) => a + b, 0) + pad.left + pad.right), }; } /** 单元格原始内容,未设置时为空字符串 */ export function getCellRawContent(elem: TemplateElement, row: number, col: number): string { const anchor = getCellAnchor(elem, row, col); const cell = getCellData(elem, anchor.row, anchor.col); return cell?.content ?? ''; } export function resolveTableCellContent( elem: TemplateElement, row: number, col: number ): string { return getCellRawContent(elem, row, col); } export function getEffectiveCellStyle( elem: TemplateElement, row: number, col: number ): TemplateElement { const anchor = getCellAnchor(elem, row, col); const cell = getCellData(elem, anchor.row, anchor.col); const pick = ( key: K, fallback: TemplateElement[K] ): TemplateElement[K] => { const cellVal = cell?.[key as keyof TableCellData]; if (cellVal !== undefined) return cellVal as TemplateElement[K]; const elemVal = elem[key]; return (elemVal !== undefined ? elemVal : fallback) as TemplateElement[K]; }; return { ...elem, type: 'text', content: getCellRawContent(elem, anchor.row, anchor.col), fontSize: pick('fontSize', elem.fontSize), textAlign: pick('textAlign', elem.textAlign), verticalAlign: pick('verticalAlign', elem.verticalAlign ?? 'middle'), fontWeight: pick('fontWeight', elem.fontWeight), fontStyle: pick('fontStyle', elem.fontStyle ?? 'normal'), textUnderline: pick('textUnderline', elem.textUnderline ?? false), textLineThrough: pick('textLineThrough', elem.textLineThrough ?? false), textScaleX: pick('textScaleX', elem.textScaleX ?? 1), textScaleY: pick('textScaleY', elem.textScaleY ?? 1), letterSpacing: pick('letterSpacing', elem.letterSpacing ?? 0), textWritingMode: pick('textWritingMode', elem.textWritingMode ?? 'horizontal'), textColor: pick('textColor', elem.textColor ?? '#111827'), wordWrap: cell?.wordWrap ?? elem.wordWrap !== false, fontFamily: pick('fontFamily', elem.fontFamily), textFormat: pick('textFormat', elem.textFormat ?? 'text'), decimalPlaces: pick('decimalPlaces', elem.decimalPlaces ?? 2), numberPartDisplay: pick('numberPartDisplay', elem.numberPartDisplay ?? 'full'), textExtractMode: pick('textExtractMode', elem.textExtractMode ?? 'none'), textSplitDelimiter: cell?.textSplitDelimiter ?? elem.textSplitDelimiter, textSplitIndex: cell?.textSplitIndex ?? elem.textSplitIndex, textExtractLength: cell?.textExtractLength ?? elem.textExtractLength, textDisplayPrefix: cell?.textDisplayPrefix ?? elem.textDisplayPrefix, textDisplaySuffix: cell?.textDisplaySuffix ?? elem.textDisplaySuffix, backgroundColor: cell?.backgroundColor, }; } const CELL_TEXT_PATCH_KEYS: (keyof TableCellData)[] = [ 'content', 'fontSize', 'textAlign', 'verticalAlign', 'fontWeight', 'fontStyle', 'textUnderline', 'textLineThrough', 'textScaleX', 'textScaleY', 'letterSpacing', 'textWritingMode', 'textColor', 'backgroundColor', 'wordWrap', 'fontFamily', 'textFormat', 'decimalPlaces', 'numberPartDisplay', 'textExtractMode', 'textSplitDelimiter', 'textSplitIndex', 'textExtractLength', 'textDisplayPrefix', 'textDisplaySuffix', ]; export function applyCellTextElementChange( elem: TemplateElement, row: number, col: number, textElem: TemplateElement ): TemplateElement { const patch: Partial = {}; for (const key of CELL_TEXT_PATCH_KEYS) { const val = textElem[key as keyof TemplateElement]; if (val !== undefined) { (patch as Record)[key] = val; } } return updateTableCells(elem, [{ row, col }], patch); } export function updateTableCells( elem: TemplateElement, coords: TableCellCoord[], patch: Partial ): TemplateElement { const cells = { ...(elem.tableCells ?? {}) }; for (const { row, col } of coords) { const anchor = getCellAnchor(elem, row, col); const key = tableCellKey(anchor.row, anchor.col); cells[key] = { ...(cells[key] ?? {}), ...patch }; } return { ...elem, tableCells: cells }; } export function cellCoordKey(coord: TableCellCoord): string { return tableCellKey(coord.row, coord.col); } /** 点击单元格:已选则取消,未选则加入选区(无需 Shift/Ctrl) */ export function toggleTableCellSelection( current: TableCellCoord[], coord: TableCellCoord ): TableCellCoord[] { const key = cellCoordKey(coord); const exists = current.some((c) => cellCoordKey(c) === key); if (exists) return current.filter((c) => cellCoordKey(c) !== key); return [...current, coord]; }