Files
label-designer/src/tableUtils.ts
2026-06-14 02:52:51 +08:00

620 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
}
/** 将轨道尺寸总和调整为 targetTotal保持相对比例末项吸收取整误差 */
function scaleTracksToTotal(tracks: number[], targetTotal: number, min = 0.5): number[] {
if (tracks.length === 0) return [];
const safeTotal = Math.max(tracks.length * min, targetTotal);
const current = tracks.reduce((a, b) => a + b, 0);
if (current <= 0) {
return distributeTracks(safeTotal, tracks.length, min);
}
const scaled = tracks.map((t) => round1(Math.max(min, (t / current) * safeTotal)));
const sum = scaled.reduce((a, b) => a + b, 0);
const diff = round1(safeTotal - sum);
if (scaled.length > 0 && diff !== 0) {
scaled[scaled.length - 1] = round1(Math.max(min, scaled[scaled.length - 1] + diff));
}
return scaled;
}
/** 将 inner 区域均分给 count 条轨道(末项吸收取整误差) */
function distributeTracks(total: number, count: number, min = 0.5): number[] {
if (count <= 0) return [];
const safeTotal = Math.max(count * min, total);
const even = round1(safeTotal / count);
const tracks = Array.from({ length: count }, () => even);
const sum = tracks.reduce((a, b) => a + b, 0);
const diff = round1(safeTotal - sum);
if (tracks.length > 0 && diff !== 0) {
tracks[tracks.length - 1] = round1(Math.max(min, tracks[tracks.length - 1] + diff));
}
return tracks;
}
/** 内边距变化后保持元素外框尺寸不变,按比例重算行高/列宽以适配新的内容区 */
export function expandTableForPadding(elem: TemplateElement): TemplateElement {
if (elem.type !== 'table') return elem;
const pad = resolvePaddingMm(elem);
const innerW = Math.max(1, elem.width - pad.left - pad.right);
const innerH = Math.max(1, elem.height - pad.top - pad.bottom);
return {
...elem,
tableRowHeights: scaleTracksToTotal(getTableRowHeights(elem), innerH),
tableColWidths: scaleTracksToTotal(getTableColWidths(elem), innerW),
width: elem.width,
height: elem.height,
};
}
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 buildTableGridBoundariesPx(
elem: TemplateElement,
scale: number,
originX = 0,
originY = 0
): { rowBounds: number[]; colBounds: number[] } {
const pad = resolvePaddingMm(elem);
const rowHeights = getTableRowHeights(elem);
const colWidths = getTableColWidths(elem);
const rowBounds: number[] = [originY + Math.round(pad.top * scale)];
for (const h of rowHeights) {
rowBounds.push(rowBounds[rowBounds.length - 1] + Math.round(h * scale));
}
const colBounds: number[] = [originX + Math.round(pad.left * scale)];
for (const w of colWidths) {
colBounds.push(colBounds[colBounds.length - 1] + Math.round(w * scale));
}
return { rowBounds, colBounds };
}
/** 单元格像素矩形,与 canvas 绘制及设计器选区 overlay 对齐 */
export function getCellRectPx(
elem: TemplateElement,
row: number,
col: number,
scale: number,
originX = 0,
originY = 0
): { x: number; y: number; width: number; height: number } {
const { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, originX, originY);
const anchor = getCellAnchor(elem, row, col);
const { rowSpan, colSpan } = getCellSpan(elem, anchor.row, anchor.col);
const x = colBounds[anchor.col];
const y = rowBounds[anchor.row];
return {
x,
y,
width: colBounds[anchor.col + colSpan] - x,
height: rowBounds[anchor.row + rowSpan] - y,
};
}
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 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 innerW = Math.max(1, elem.width - pad.left - pad.right);
const innerH = Math.max(1, elem.height - pad.top - pad.bottom);
const next: TemplateElement = {
...elem,
tableRows: rows,
tableCols: cols,
tableRowHeights: distributeTracks(innerH, rows),
tableColWidths: distributeTracks(innerW, cols),
tableCells: newCells,
width: elem.width,
height: elem.height,
};
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];
}