Files
label-designer/src/labelRenderer.ts
2026-06-12 20:58:51 +08:00

554 lines
16 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 } from './types';
import {
resolveElementValue,
isGraphicContentEmpty,
shouldRenderElement,
ContentResolveMode,
} from './utils';
import {
resolveCornerRadiiPx,
resolveBorderSides,
resolvePaddingMm,
getPaddedRectPx,
traceRoundedRect,
drawElementFill,
drawElementBorders,
drawElementChrome,
} from './elementBox';
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 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 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;
/** 渲染倍率,默认 16PDF 导出可用较低倍率以减轻内存与耗时 */
scale?: number;
/** 输出格式,默认 png */
imageFormat?: 'png' | 'jpeg';
jpegQuality?: number;
}
/** 离屏渲染标签为 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',
scale: scaleOption = 16,
imageFormat = 'png',
jpegQuality = 0.92,
} = options;
const scale = scaleOption;
const canvas = document.createElement('canvas');
canvas.width = Math.max(50, Math.round(widthMm * scale));
canvas.height = Math.max(50, 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([]);
}
ctx.save();
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 (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);
const padPx = (() => {
const pad = resolvePaddingMm(elem);
return {
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, ew - padPx.left - padPx.right);
const writableH = Math.max(1, eh - 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 = ex + padPx.left + writableW / 2;
const transformOriginY = ey + 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 = ex + padPx.left + Math.max(0, (writableW - totalColumnsWidth) / 2) + columnWidth / 2;
} else if (elem.textAlign === 'right') {
startX = ex + padPx.left + Math.max(0, writableW - totalColumnsWidth) + columnWidth / 2;
} else {
startX = ex + 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 = ey + padPx.top + lineHeight / 2;
} else if (vAlign === 'bottom') {
colStartY = ey + eh - padPx.bottom - colTextHeight + lineHeight / 2;
} else {
colStartY =
ey + 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 <= ey + eh - padPx.bottom) {
ctx.fillText(column[i], colX, charY);
}
}
}
} else {
const lines = layoutHorizontalTextLines(ctx, String(value), writableW, wordWrap);
const totalTextHeight = lines.length * lineHeight;
let textX = ex + padPx.left;
let textAlign: CanvasTextAlign = 'left';
if (elem.textAlign === 'center') {
textX = ex + padPx.left + writableW / 2;
textAlign = 'center';
} else if (elem.textAlign === 'right') {
textX = ex + padPx.left + (writableW - 2);
textAlign = 'right';
} else {
textX = ex + padPx.left + 2;
}
ctx.textAlign = textAlign;
let startY: number;
if (vAlign === 'top') {
startY = ey + padPx.top + lineHeight / 2;
} else if (vAlign === 'bottom') {
startY = ey + eh - padPx.bottom - totalTextHeight + lineHeight / 2;
} else {
startY =
ey + 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 <= ey + eh - 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';
}
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') {
drawElementChrome(ctx, ex, ey, ew, eh, elem, scale);
const inner = getPaddedRectPx(ex, ey, ew, eh, elem, scale);
const src = value ? String(value).trim() : '';
if (!src) {
if (mode === 'design') {
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '图片');
}
} else {
try {
const img = await loadImage(src);
drawImageWithFit(ctx, img, inner.x, inner.y, inner.w, inner.h, elem.imageFit ?? 'contain');
} catch {
if (mode === 'design') {
drawImagePlaceholder(ctx, inner.x, inner.y, inner.w, inner.h, '加载失败');
}
}
}
}
ctx.restore();
}
ctx.restore();
return imageFormat === 'jpeg'
? canvas.toDataURL('image/jpeg', jpegQuality)
: canvas.toDataURL('image/png');
}