表格支持

This commit is contained in:
iqudoo
2026-06-13 13:50:42 +08:00
parent 318569c757
commit 38b17c7589
10 changed files with 1933 additions and 56 deletions

560
src/tableUtils.ts Normal file
View File

@@ -0,0 +1,560 @@
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<string, TableCellData> = {};
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<string>();
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 = <K extends keyof TableCellData & keyof TemplateElement>(
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<TableCellData> = {};
for (const key of CELL_TEXT_PATCH_KEYS) {
const val = textElem[key as keyof TemplateElement];
if (val !== undefined) {
(patch as Record<string, unknown>)[key] = val;
}
}
return updateTableCells(elem, [{ row, col }], patch);
}
export function updateTableCells(
elem: TemplateElement,
coords: TableCellCoord[],
patch: Partial<TableCellData>
): 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];
}