表格支持

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

View File

@@ -17,6 +17,17 @@ import {
drawElementBorders,
drawElementChrome,
} from './elementBox';
import {
iterTableCells,
getCellRectMm,
getCellAnchor,
getCellData,
getEffectiveCellStyle,
getTableRows,
getTableCols,
getTableRowHeights,
getTableColWidths,
} from './tableUtils';
const GENERATOR_TRANSPARENT_BG = '#00000000';
@@ -215,6 +226,280 @@ function layoutVerticalTextColumns(
return columns;
}
function drawTextInRect(
ctx: CanvasRenderingContext2D,
elem: TemplateElement,
boxX: number,
boxY: number,
boxW: number,
boxH: number,
value: string,
scale: number
) {
const pad = 2 * scale;
const writableW = Math.max(1, boxW - pad * 2);
const writableH = Math.max(1, boxH - pad * 2);
const fontStyle = elem.fontStyle === 'italic' ? 'italic ' : '';
const fontWeight = elem.fontWeight === 'bold' ? 'bold ' : '';
const fontSizePx = Math.max(6, Math.max(1, Math.round(elem.fontSize * 0.352777 * scale)));
const fontFamily =
elem.fontFamily === 'mono'
? 'JetBrains Mono, SFMono-Regular, Consolas, Courier New, monospace'
: elem.fontFamily === 'serif'
? 'Georgia, Times New Roman, serif'
: 'Inter, system-ui, -apple-system, Arial, sans-serif';
ctx.font = `${fontStyle}${fontWeight}${fontSizePx}px ${fontFamily}`.trim();
ctx.fillStyle = elem.textColor || '#111827';
const letterSpacingPx = Math.max(0, (elem.letterSpacing ?? 0) * scale);
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing =
`${letterSpacingPx}px`;
}
const wordWrap = elem.wordWrap !== false;
const lineHeight = fontSizePx * 1.25;
const columnWidth = lineHeight;
const isVertical = elem.textWritingMode === 'vertical';
ctx.textBaseline = 'middle';
const textScaleX = Math.max(0.1, Math.min(5, elem.textScaleX ?? 1));
const textScaleY = Math.max(0.1, Math.min(5, elem.textScaleY ?? 1));
const transformOriginX = boxX + boxW / 2;
const transformOriginY = boxY + boxH / 2;
ctx.save();
if (textScaleX !== 1 || textScaleY !== 1) {
ctx.translate(transformOriginX, transformOriginY);
ctx.scale(textScaleX, textScaleY);
ctx.translate(-transformOriginX, -transformOriginY);
}
const vAlign = elem.verticalAlign ?? 'middle';
if (isVertical) {
const columns = layoutVerticalTextColumns(String(value), writableH, lineHeight, wordWrap);
const totalColumnsWidth = columns.length * columnWidth;
let startX: number;
if (elem.textAlign === 'center') {
startX = boxX + pad + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
} else if (elem.textAlign === 'right') {
startX = boxX + pad + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
} else {
startX = boxX + pad + columnWidth / 2;
}
ctx.textAlign = 'center';
for (let col = 0; col < columns.length; col++) {
const column = columns[col];
const colTextHeight = column.length * lineHeight;
let colStartY: number;
if (vAlign === 'top') {
colStartY = boxY + pad + lineHeight / 2;
} else if (vAlign === 'bottom') {
colStartY = boxY + boxH - pad - colTextHeight + lineHeight / 2;
} else {
colStartY = boxY + pad + Math.max(0, (writableH - colTextHeight) / 2) + lineHeight / 2;
}
const colX = startX + col * columnWidth;
for (let i = 0; i < column.length; i++) {
const charY = colStartY + i * lineHeight;
if (charY - lineHeight / 2 <= boxY + boxH - pad) {
ctx.fillText(column[i], colX, charY);
}
}
}
} else {
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
const totalTextHeight = lines.length * lineHeight;
let textX = boxX + pad;
let textAlign: CanvasTextAlign = 'left';
if (elem.textAlign === 'center') {
textX = boxX + boxW / 2;
textAlign = 'center';
} else if (elem.textAlign === 'right') {
textX = boxX + boxW - pad;
textAlign = 'right';
} else {
textX = boxX + pad + 2;
}
ctx.textAlign = textAlign;
let startY: number;
if (vAlign === 'top') {
startY = boxY + pad + lineHeight / 2;
} else if (vAlign === 'bottom') {
startY = boxY + boxH - pad - totalTextHeight + lineHeight / 2;
} else {
startY = boxY + pad + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
}
for (let i = 0; i < lines.length; i++) {
const lineY = startY + i * lineHeight;
if (lineY - lineHeight / 2 <= boxY + boxH - pad) {
const line = lines[i];
ctx.fillText(line, textX, lineY);
drawTextLineDecorations(ctx, elem, textX, lineY, line, fontSizePx, textAlign);
}
}
}
ctx.restore();
if (letterSpacingPx > 0 && 'letterSpacing' in ctx) {
(ctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = '0px';
}
}
function alignStrokeCoord(v: number): number {
return Math.round(v) + 0.5;
}
/** 统一绘制表格网格线,避免相邻单元格各画一条边造成双线 */
function drawTableGridLines(
ctx: CanvasRenderingContext2D,
elem: TemplateElement,
ex: number,
ey: number,
scale: number
) {
const borderMm = elem.tableBorderWidth ?? 0.2;
if (borderMm <= 0) return;
const borderW = borderMm * scale;
if (borderW <= 0) return;
const borderColor = elem.tableBorderColor ?? '#374151';
const rows = getTableRows(elem);
const cols = getTableCols(elem);
const rowHeights = getTableRowHeights(elem);
const colWidths = getTableColWidths(elem);
const pad = resolvePaddingMm(elem);
const x0 = pad.left;
const y0 = pad.top;
const totalW = colWidths.reduce((a, b) => a + b, 0);
const totalH = rowHeights.reduce((a, b) => a + b, 0);
const left = ex + x0 * scale;
const top = ey + y0 * scale;
const width = totalW * scale;
const height = totalH * scale;
ctx.strokeStyle = borderColor;
ctx.lineWidth = borderW;
ctx.beginPath();
const inset = borderW / 2;
ctx.rect(
alignStrokeCoord(left + inset),
alignStrokeCoord(top + inset),
Math.max(0, width - borderW),
Math.max(0, height - borderW)
);
let yAcc = y0;
for (let r = 1; r < rows; r++) {
yAcc += rowHeights[r - 1];
const y = alignStrokeCoord(ey + yAcc * scale);
let xAcc = x0;
for (let c = 0; c < cols; c++) {
const w = colWidths[c];
const anchorAbove = getCellAnchor(elem, r - 1, c);
const anchorBelow = getCellAnchor(elem, r, c);
const sameMerge =
anchorAbove.row === anchorBelow.row && anchorAbove.col === anchorBelow.col;
if (!sameMerge) {
const x1 = alignStrokeCoord(ex + xAcc * scale);
const x2 = alignStrokeCoord(ex + (xAcc + w) * scale);
ctx.moveTo(x1, y);
ctx.lineTo(x2, y);
}
xAcc += w;
}
}
let xAcc = x0;
for (let c = 1; c < cols; c++) {
xAcc += colWidths[c - 1];
const x = alignStrokeCoord(ex + xAcc * scale);
let yAcc2 = y0;
for (let r = 0; r < rows; r++) {
const h = rowHeights[r];
const anchorLeft = getCellAnchor(elem, r, c - 1);
const anchorRight = getCellAnchor(elem, r, c);
const sameMerge =
anchorLeft.row === anchorRight.row && anchorLeft.col === anchorRight.col;
if (!sameMerge) {
const y1 = alignStrokeCoord(ey + yAcc2 * scale);
const y2 = alignStrokeCoord(ey + (yAcc2 + h) * scale);
ctx.moveTo(x, y1);
ctx.lineTo(x, y2);
}
yAcc2 += h;
}
}
ctx.stroke();
}
async function drawTableElement(
ctx: CanvasRenderingContext2D,
elem: TemplateElement,
ex: number,
ey: number,
ew: number,
eh: number,
scale: number,
rowData: RecordRow | null,
rowIndex: number,
variableDefaults: Record<string, string> | null | undefined,
mode: ContentResolveMode
) {
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
const sides = resolveBorderSides(elem);
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
ctx.save();
ctx.beginPath();
ctx.rect(inner.x, inner.y, inner.w, inner.h);
ctx.clip();
const anchors = iterTableCells(elem);
for (const { row, col } of anchors) {
const rect = getCellRectMm(elem, row, col);
const cellX = ex + Math.round(rect.x * scale);
const cellY = ey + Math.round(rect.y * scale);
const cellW = Math.round(rect.width * scale);
const cellH = Math.round(rect.height * scale);
const cellData = getCellData(elem, row, col);
if (cellData?.backgroundColor && cellData.backgroundColor !== 'transparent') {
ctx.fillStyle = cellData.backgroundColor;
ctx.fillRect(cellX, cellY, cellW, cellH);
}
const textElem = getEffectiveCellStyle(elem, row, col);
if (!textElem.content.trim()) continue;
const cellValue = resolveElementValue(textElem, rowData, rowIndex, variableDefaults, mode);
const displayText = String(cellValue ?? '').trim();
if (!displayText) continue;
drawTextInRect(ctx, textElem, cellX, cellY, cellW, cellH, displayText, scale);
}
drawTableGridLines(ctx, elem, ex, ey, scale);
ctx.restore();
}
function drawImagePlaceholder(
ctx: CanvasRenderingContext2D,
x: number,
@@ -316,7 +601,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
const value = resolveElementValue(elem, rowData, rowIndex, variableDefaults, mode);
if (isGraphicContentEmpty(elem, value, mode)) {
if (elem.type !== 'table' && isGraphicContentEmpty(elem, value, mode)) {
continue;
}
@@ -545,6 +830,20 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
}
}
}
} else if (elem.type === 'table') {
await drawTableElement(
ctx,
elem,
ex,
ey,
ew,
eh,
scale,
rowData,
rowIndex,
variableDefaults,
mode
);
}
ctx.restore();