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