Files
label-designer/src/labelRenderer.ts
2026-06-14 03:59:45 +08:00

825 lines
24 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 JsBarcode from 'jsbarcode';
import QRCode from 'qrcode';
import { TemplateElement, RecordRow, LabelTemplate } from './types';
import {
resolveElementValue,
isGraphicContentEmpty,
shouldRenderElement,
ContentResolveMode,
} from './utils';
import {
resolveCornerRadiiPx,
resolveBorderSides,
resolvePaddingMm,
getPaddedRectPx,
traceRoundedRect,
drawElementFill,
drawElementBorders,
drawElementChrome,
} from './elementBox';
import {
iterTableCells,
getCellAnchor,
getCellData,
getEffectiveCellStyle,
getTableRows,
getTableCols,
buildTableGridBoundariesPx,
getCellRectPx,
} from './tableUtils';
const GENERATOR_TRANSPARENT_BG = '#00000000';
function measureLineWidth(ctx: CanvasRenderingContext2D, line: string): number {
return ctx.measureText(line).width;
}
function getTextLineSpan(
textX: number,
lineWidth: number,
textAlign: CanvasTextAlign
): { left: number; right: number } {
if (textAlign === 'center') {
return { left: textX - lineWidth / 2, right: textX + lineWidth / 2 };
}
if (textAlign === 'right') {
return { left: textX - lineWidth, right: textX };
}
return { left: textX, right: textX + lineWidth };
}
function drawTextLineDecorations(
ctx: CanvasRenderingContext2D,
elem: TemplateElement,
textX: number,
lineY: number,
line: string,
fontSizePx: number,
textAlign: CanvasTextAlign
) {
if (!elem.textUnderline && !elem.textLineThrough) return;
const lineWidth = measureLineWidth(ctx, line);
const { left, right } = getTextLineSpan(textX, lineWidth, textAlign);
const color = elem.textColor || '#111827';
ctx.save();
ctx.strokeStyle = color;
ctx.lineWidth = Math.max(1, fontSizePx * 0.06);
ctx.beginPath();
if (elem.textUnderline) {
const y = lineY + fontSizePx * 0.35;
ctx.moveTo(left, y);
ctx.lineTo(right, y);
}
if (elem.textLineThrough) {
const y = lineY;
ctx.moveTo(left, y);
ctx.lineTo(right, y);
}
ctx.stroke();
ctx.restore();
}
function loadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = () => reject(new Error('Image load failed'));
img.src = src;
});
}
function loadImageOrNull(src: string): Promise<HTMLImageElement | null> {
const trimmed = src.trim();
if (!trimmed) return Promise.resolve(null);
return loadImage(trimmed).catch(() => null);
}
async function resolveElementImage(
elem: TemplateElement,
primarySrc: string,
mode: ContentResolveMode
): Promise<{ img: HTMLImageElement | null; showPlaceholder: boolean }> {
const primary = primarySrc.trim();
const fallback = (elem.defaultImage ?? '').trim();
let img = await loadImageOrNull(primary);
if (!img && fallback) {
img = await loadImageOrNull(fallback);
}
if (img) return { img, showPlaceholder: false };
if (mode === 'design' && !primary && !fallback) {
return { img: null, showPlaceholder: true };
}
return { img: null, showPlaceholder: false };
}
function drawImageWithFit(
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
x: number,
y: number,
w: number,
h: number,
fit: 'contain' | 'cover' | 'fill' = 'contain'
) {
if (fit === 'fill') {
ctx.drawImage(img, x, y, w, h);
return;
}
const imgRatio = img.width / img.height;
const boxRatio = w / h;
if (fit === 'contain') {
let dw = w;
let dh = h;
let dx = x;
let dy = y;
if (imgRatio > boxRatio) {
dh = w / imgRatio;
dy = y + (h - dh) / 2;
} else {
dw = h * imgRatio;
dx = x + (w - dw) / 2;
}
ctx.drawImage(img, dx, dy, dw, dh);
return;
}
let sx = 0;
let sy = 0;
let sw = img.width;
let sh = img.height;
if (imgRatio > boxRatio) {
sw = img.height * boxRatio;
sx = (img.width - sw) / 2;
} else {
sh = img.width / boxRatio;
sy = (img.height - sh) / 2;
}
ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);
}
function applyElementRotation(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
rotationDeg: number | undefined
) {
const rotation = rotationDeg ?? 0;
if (rotation === 0) return;
const cx = x + w / 2;
const cy = y + h / 2;
ctx.translate(cx, cy);
ctx.rotate((rotation * Math.PI) / 180);
ctx.translate(-cx, -cy);
}
function layoutHorizontalTextLines(
ctx: CanvasRenderingContext2D,
value: string,
writableW: number,
wordWrap: boolean
): string[] {
const lines: string[] = [];
const paragraphs = String(value).split(/\r?\n/);
for (const paragraph of paragraphs) {
if (!wordWrap) {
lines.push(paragraph);
continue;
}
let currentLine = '';
for (let i = 0; i < paragraph.length; i++) {
const char = paragraph[i];
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > writableW - 4 && i > 0 && currentLine.length > 0) {
lines.push(currentLine);
currentLine = char;
} else {
currentLine = testLine;
}
}
if (currentLine || lines.length === 0) {
lines.push(currentLine);
}
}
return lines;
}
/** 竖排:按列组织字符,列高受限时换列 */
function layoutVerticalTextColumns(
value: string,
writableH: number,
lineHeight: number,
wordWrap: boolean
): string[][] {
const columns: string[][] = [];
const paragraphs = String(value).split(/\r?\n/);
for (let p = 0; p < paragraphs.length; p++) {
const paragraph = paragraphs[p];
if (!wordWrap) {
columns.push([...paragraph]);
} else {
let currentColumn: string[] = [];
for (const char of paragraph) {
const colHeight = (currentColumn.length + 1) * lineHeight;
if (colHeight > writableH - 4 && currentColumn.length > 0) {
columns.push(currentColumn);
currentColumn = [char];
} else {
currentColumn.push(char);
}
}
if (currentColumn.length > 0) {
columns.push(currentColumn);
}
}
if (p < paragraphs.length - 1) {
columns.push([]);
}
}
if (columns.length === 0) {
columns.push([]);
}
return columns;
}
function drawTextContentInBox(
ctx: CanvasRenderingContext2D,
elem: TemplateElement,
boxX: number,
boxY: number,
boxW: number,
boxH: number,
value: string,
scale: number
) {
const pad = resolvePaddingMm(elem);
const padPx = {
top: Math.round(pad.top * scale),
right: Math.round(pad.right * scale),
bottom: Math.round(pad.bottom * scale),
left: Math.round(pad.left * scale),
};
const writableW = Math.max(1, boxW - padPx.left - padPx.right);
const writableH = Math.max(1, boxH - padPx.top - padPx.bottom);
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 + padPx.left + writableW / 2;
const transformOriginY = boxY + padPx.top + writableH / 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 + padPx.left + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
} else if (elem.textAlign === 'right') {
startX = boxX + padPx.left + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
} else {
startX = boxX + padPx.left + 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 + padPx.top + lineHeight / 2;
} else if (vAlign === 'bottom') {
colStartY = boxY + boxH - padPx.bottom - colTextHeight + lineHeight / 2;
} else {
colStartY =
boxY + padPx.top + 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 - padPx.bottom) {
ctx.fillText(column[i], colX, charY);
}
}
}
} else {
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
const totalTextHeight = lines.length * lineHeight;
let textX = boxX + padPx.left;
let textAlign: CanvasTextAlign = 'left';
if (elem.textAlign === 'center') {
textX = boxX + padPx.left + writableW / 2;
textAlign = 'center';
} else if (elem.textAlign === 'right') {
textX = boxX + padPx.left + (writableW - 2);
textAlign = 'right';
} else {
textX = boxX + padPx.left + 2;
}
ctx.textAlign = textAlign;
let startY: number;
if (vAlign === 'top') {
startY = boxY + padPx.top + lineHeight / 2;
} else if (vAlign === 'bottom') {
startY = boxY + boxH - padPx.bottom - totalTextHeight + lineHeight / 2;
} else {
startY = boxY + padPx.top + 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 - padPx.bottom) {
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 { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey);
const left = colBounds[0];
const top = rowBounds[0];
const width = colBounds[colBounds.length - 1] - left;
const height = rowBounds[rowBounds.length - 1] - top;
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)
);
for (let r = 1; r < rows; r++) {
const y = alignStrokeCoord(rowBounds[r]);
for (let c = 0; c < cols; 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) {
ctx.moveTo(alignStrokeCoord(colBounds[c]), y);
ctx.lineTo(alignStrokeCoord(colBounds[c + 1]), y);
}
}
}
for (let c = 1; c < cols; c++) {
const x = alignStrokeCoord(colBounds[c]);
for (let r = 0; r < rows; 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) {
ctx.moveTo(x, alignStrokeCoord(rowBounds[r]));
ctx.lineTo(x, alignStrokeCoord(rowBounds[r + 1]));
}
}
}
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 { rowBounds, colBounds } = buildTableGridBoundariesPx(elem, scale, ex, ey);
const gridX = colBounds[0];
const gridY = rowBounds[0];
const gridW = colBounds[colBounds.length - 1] - gridX;
const gridH = rowBounds[rowBounds.length - 1] - gridY;
ctx.save();
ctx.beginPath();
ctx.rect(gridX, gridY, gridW, gridH);
ctx.clip();
const anchors = iterTableCells(elem);
for (const { row, col } of anchors) {
const { x: cellX, y: cellY, width: cellW, height: cellH } = getCellRectPx(
elem,
row,
col,
scale,
ex,
ey
);
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;
drawTextContentInBox(
ctx,
{
...textElem,
padding: 0,
paddingTop: 0,
paddingRight: 0,
paddingBottom: 0,
paddingLeft: 0,
},
cellX,
cellY,
cellW,
cellH,
displayText,
scale
);
}
drawTableGridLines(ctx, elem, ex, ey, scale);
ctx.restore();
}
function drawImagePlaceholder(
ctx: CanvasRenderingContext2D,
x: number,
y: number,
w: number,
h: number,
message: string
) {
ctx.fillStyle = '#2a2a2a';
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = '#555';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(x + 0.5, y + 0.5, w - 1, h - 1);
ctx.setLineDash([]);
ctx.fillStyle = '#888';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(message, x + w / 2, y + h / 2);
}
export interface RenderLabelOptions {
widthMm: number;
heightMm: number;
elements: TemplateElement[];
rowData: RecordRow | null;
rowIndex: number;
showBorder?: boolean;
transparentBackground?: boolean;
variableDefaults?: Record<string, string> | null;
mode?: ContentResolveMode;
/** 输出参考背景(生成/打印),绘制在元素下层 */
referenceBackground?: string;
referenceBackgroundOpacity?: number;
referenceBackgroundFit?: 'contain' | 'cover' | 'fill';
/** 渲染倍率,默认 16PDF 导出可用较低倍率以减轻内存与耗时 */
scale?: number;
/** 输出格式,默认 png */
imageFormat?: 'png' | 'jpeg';
jpegQuality?: number;
/** 为 false 时不裁剪到画布边界(用于拖动浮层完整显示元素) */
clipToCanvas?: boolean;
}
export type ReferenceBackgroundRenderOptions = Pick<
RenderLabelOptions,
'referenceBackground' | 'referenceBackgroundOpacity' | 'referenceBackgroundFit'
>;
type ReferenceBackgroundTemplate = Pick<
LabelTemplate,
'previewReferenceBackground' | 'previewReferenceBackgroundOpacity' | 'previewReferenceBackgroundFit'
>;
function buildReferenceBackgroundRenderOptions(
template: ReferenceBackgroundTemplate
): ReferenceBackgroundRenderOptions {
if (!template.previewReferenceBackground) {
return {};
}
return {
referenceBackground: template.previewReferenceBackground,
referenceBackgroundOpacity: template.previewReferenceBackgroundOpacity ?? 100,
referenceBackgroundFit: template.previewReferenceBackgroundFit ?? 'cover',
};
}
/** 模板列表 / 设计预览等:有参考图即绘制 */
export function resolveReferenceBackgroundForPreview(
template: ReferenceBackgroundTemplate
): ReferenceBackgroundRenderOptions {
return buildReferenceBackgroundRenderOptions(template);
}
/** 模板开启「输出参考背景」时,供 PDF / 打印 / 排版预览使用的渲染参数 */
export function resolveReferenceBackgroundForOutput(
template: ReferenceBackgroundTemplate &
Pick<LabelTemplate, 'includeReferenceBackgroundInOutput'>
): ReferenceBackgroundRenderOptions {
if (!template.includeReferenceBackgroundInOutput) {
return {};
}
return buildReferenceBackgroundRenderOptions(template);
}
/** 离屏渲染标签为 PNG data URL */
export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise<string> {
const {
widthMm,
heightMm,
elements,
rowData,
rowIndex,
showBorder = false,
transparentBackground = false,
variableDefaults = null,
mode = 'design',
referenceBackground,
referenceBackgroundOpacity = 100,
referenceBackgroundFit = 'cover',
scale: scaleOption = 16,
imageFormat = 'png',
jpegQuality = 0.92,
clipToCanvas = true,
} = options;
const scale = scaleOption;
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(widthMm * scale));
canvas.height = Math.max(1, Math.round(heightMm * scale));
const ctx = canvas.getContext('2d');
if (!ctx) return '';
if (!transparentBackground) {
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
if (showBorder) {
ctx.strokeStyle = '#cccccc';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(0, 0, canvas.width, canvas.height);
ctx.setLineDash([]);
}
if (referenceBackground) {
const refImg = await loadImageOrNull(referenceBackground);
if (refImg) {
ctx.save();
ctx.globalAlpha = Math.min(1, Math.max(0, referenceBackgroundOpacity / 100));
drawImageWithFit(ctx, refImg, 0, 0, canvas.width, canvas.height, referenceBackgroundFit);
ctx.restore();
}
}
ctx.save();
if (clipToCanvas) {
ctx.beginPath();
ctx.rect(0, 0, canvas.width, canvas.height);
ctx.clip();
}
for (const elem of elements) {
const ex = Math.round(elem.x * scale);
const ey = Math.round(elem.y * scale);
const ew = Math.round(elem.width * scale);
const eh = Math.round(elem.height * scale);
if (!shouldRenderElement(elem, rowData, rowIndex, variableDefaults, mode)) {
continue;
}
const value = resolveElementValue(elem, rowData, rowIndex, variableDefaults, mode);
if (elem.type !== 'table' && isGraphicContentEmpty(elem, value, mode)) {
continue;
}
ctx.save();
applyElementRotation(ctx, ex, ey, ew, eh, elem.rotation);
if (elem.type === 'text') {
const radii = resolveCornerRadiiPx(elem, scale, ew, eh);
const sides = resolveBorderSides(elem);
ctx.save();
traceRoundedRect(ctx, ex, ey, ew, eh, radii);
ctx.clip();
drawElementFill(ctx, ex, ey, ew, eh, elem, radii);
drawTextContentInBox(ctx, elem, ex, ey, ew, eh, String(value), scale);
ctx.restore();
drawElementBorders(ctx, ex, ey, ew, eh, radii, sides, elem, scale);
} else if (elem.type === 'barcode') {
try {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
const tempCanvas = document.createElement('canvas');
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
if (barcodeValue) {
JsBarcode(tempCanvas, barcodeValue, {
format: elem.barcodeFormat || 'CODE128',
width: 2,
height: 40,
displayValue: elem.showText ?? false,
fontSize: 14,
margin: 2,
background: GENERATOR_TRANSPARENT_BG,
});
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
}
} catch (error) {
console.warn('Barcode drawing failed', error);
if (mode === 'design') {
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 2;
ctx.strokeRect(inner.x, inner.y, inner.w, inner.h);
ctx.fillStyle = '#b91c1c';
ctx.font = '10px Courier New';
ctx.textAlign = 'center';
ctx.fillText('ERR BARCODE', inner.x + inner.w / 2, inner.y + inner.h / 2);
}
}
} else if (elem.type === 'qrcode') {
try {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : '';
if (qrValue) {
const tempCanvas = document.createElement('canvas');
await QRCode.toCanvas(tempCanvas, qrValue, {
width: Math.max(64, inner.w),
margin: 0,
color: {
dark: '#000000',
light: GENERATOR_TRANSPARENT_BG,
},
});
ctx.drawImage(tempCanvas, inner.x, inner.y, inner.w, inner.h);
}
} catch (error) {
console.warn('QR code drawing failed', error);
if (mode === 'design') {
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
ctx.fillStyle = '#f3f4f6';
ctx.fillRect(inner.x, inner.y, inner.w, inner.h);
ctx.strokeStyle = '#ef4444';
ctx.lineWidth = 1;
ctx.strokeRect(inner.x, inner.y, inner.w, inner.h);
}
}
} else if (elem.type === 'image') {
const primarySrc = value ? String(value).trim() : '';
const { img, showPlaceholder } = await resolveElementImage(elem, primarySrc, mode);
if (!img && !showPlaceholder) {
ctx.restore();
continue;
}
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
if (img) {
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
} else {
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
}
} else if (elem.type === 'table') {
await drawTableElement(
ctx,
elem,
ex,
ey,
ew,
eh,
scale,
rowData,
rowIndex,
variableDefaults,
mode
);
}
ctx.restore();
}
ctx.restore();
return imageFormat === 'jpeg'
? canvas.toDataURL('image/jpeg', jpegQuality)
: canvas.toDataURL('image/png');
}