825 lines
24 KiB
TypeScript
825 lines
24 KiB
TypeScript
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';
|
||
/** 渲染倍率,默认 16;PDF 导出可用较低倍率以减轻内存与耗时 */
|
||
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');
|
||
}
|