561 lines
18 KiB
TypeScript
561 lines
18 KiB
TypeScript
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];
|
||
}
|