init commit
This commit is contained in:
446
src/labelRenderer.ts
Normal file
446
src/labelRenderer.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import JsBarcode from 'jsbarcode';
|
||||
import QRCode from 'qrcode';
|
||||
import { TemplateElement, RecordRow } from './types';
|
||||
import {
|
||||
resolveElementValue,
|
||||
isGraphicContentEmpty,
|
||||
shouldRenderElement,
|
||||
ContentResolveMode,
|
||||
} from './utils';
|
||||
import {
|
||||
resolveCornerRadiiPx,
|
||||
resolveBorderSides,
|
||||
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 getPaddedRect(
|
||||
x: number,
|
||||
y: number,
|
||||
w: number,
|
||||
h: number,
|
||||
paddingMm: number,
|
||||
scale: number
|
||||
) {
|
||||
const paddingPx = Math.round(paddingMm * scale);
|
||||
return {
|
||||
x: x + paddingPx,
|
||||
y: y + paddingPx,
|
||||
w: Math.max(1, w - 2 * paddingPx),
|
||||
h: Math.max(1, h - 2 * paddingPx),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 paddingMm = elem.padding || 0;
|
||||
const paddingPx = Math.round(paddingMm * scale);
|
||||
const writableW = Math.max(1, ew - 2 * paddingPx);
|
||||
const writableH = Math.max(1, eh - 2 * paddingPx);
|
||||
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
|
||||
const lineHeight = fontSizePx * 1.25;
|
||||
const totalTextHeight = lines.length * lineHeight;
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
let textX = ex + paddingPx;
|
||||
let textAlign: CanvasTextAlign = 'left';
|
||||
if (elem.textAlign === 'center') {
|
||||
textX = ex + paddingPx + writableW / 2;
|
||||
textAlign = 'center';
|
||||
} else if (elem.textAlign === 'right') {
|
||||
textX = ex + paddingPx + (writableW - 2);
|
||||
textAlign = 'right';
|
||||
} else {
|
||||
textX = ex + paddingPx + 2;
|
||||
}
|
||||
ctx.textAlign = textAlign;
|
||||
|
||||
const vAlign = elem.verticalAlign ?? 'middle';
|
||||
let startY: number;
|
||||
if (vAlign === 'top') {
|
||||
startY = ey + paddingPx + lineHeight / 2;
|
||||
} else if (vAlign === 'bottom') {
|
||||
startY = ey + eh - paddingPx - totalTextHeight + lineHeight / 2;
|
||||
} else {
|
||||
startY = ey + paddingPx + Math.max(0, (writableH - totalTextHeight) / 2) + lineHeight / 2;
|
||||
}
|
||||
|
||||
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 + paddingPx + writableW / 2;
|
||||
const transformOriginY = ey + paddingPx + writableH / 2;
|
||||
|
||||
ctx.save();
|
||||
if (textScaleX !== 1 || textScaleY !== 1) {
|
||||
ctx.translate(transformOriginX, transformOriginY);
|
||||
ctx.scale(textScaleX, textScaleY);
|
||||
ctx.translate(-transformOriginX, -transformOriginY);
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const lineY = startY + i * lineHeight;
|
||||
if (lineY - lineHeight / 2 <= ey + eh - paddingPx) {
|
||||
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 = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
|
||||
const tempCanvas = document.createElement('canvas');
|
||||
const barcodeValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE' : '';
|
||||
|
||||
if (!barcodeValue) continue;
|
||||
|
||||
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 = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, 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 = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, scale);
|
||||
const qrValue = value ? String(value).trim() : mode === 'design' ? 'SAMPLE QR' : '';
|
||||
|
||||
if (!qrValue) continue;
|
||||
|
||||
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 = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, 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 = getPaddedRect(ex, ey, ew, eh, elem.padding || 0, 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();
|
||||
|
||||
return imageFormat === 'jpeg'
|
||||
? canvas.toDataURL('image/jpeg', jpegQuality)
|
||||
: canvas.toDataURL('image/png');
|
||||
}
|
||||
Reference in New Issue
Block a user