103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import * as QRCode from 'qrcode';
|
|
import { Resvg } from '@resvg/resvg-js';
|
|
import type { LabelElement } from '../database/entities/barcode-template.entity';
|
|
|
|
const MM_TO_PX = 300 / 25.4; // 300 DPI
|
|
|
|
function mm(v: number): number {
|
|
return Math.round(v * MM_TO_PX);
|
|
}
|
|
|
|
function escape(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function applyVars(template: string, vars: Record<string, string>): string {
|
|
return template.replace(/\{([^}]+)\}/g, (_, key: string) => {
|
|
const colonIdx = key.indexOf(':');
|
|
if (colonIdx !== -1) {
|
|
const varName = key.slice(0, colonIdx);
|
|
const width = parseInt(key.slice(colonIdx + 1), 10);
|
|
if (!isNaN(width)) {
|
|
return (vars[varName] ?? '').padStart(width, '0');
|
|
}
|
|
}
|
|
return vars[key] ?? `{${key}}`;
|
|
});
|
|
}
|
|
|
|
@Injectable()
|
|
export class LabelRendererService {
|
|
private readonly logger = new Logger(LabelRendererService.name);
|
|
|
|
async render(
|
|
layout: LabelElement[],
|
|
widthMm: number,
|
|
heightMm: number,
|
|
variables: Record<string, string>,
|
|
): Promise<Buffer> {
|
|
const W = mm(widthMm);
|
|
const H = mm(heightMm);
|
|
|
|
const parts: string[] = [];
|
|
|
|
for (const el of layout) {
|
|
if (el.type === 'text') {
|
|
const x = mm(el.x);
|
|
const y = mm(el.y);
|
|
const fontSize = mm(el.fontSize);
|
|
const content = escape(applyVars(el.content, variables));
|
|
const fontWeight = el.bold ? 'bold' : 'normal';
|
|
const textAnchor = el.align === 'center' ? 'middle' : el.align === 'right' ? 'end' : 'start';
|
|
const maxWidthAttr = el.maxWidth ? ` textLength="${mm(el.maxWidth)}" lengthAdjust="spacingAndGlyphs"` : '';
|
|
// librsvg (used by sharp) ignores dominant-baseline; add fontSize to y so that
|
|
// the stored coordinate is the top of the text, not the baseline.
|
|
const yBaseline = y + fontSize;
|
|
parts.push(
|
|
`<text x="${x}" y="${yBaseline}" font-family="Arial,Helvetica,sans-serif" font-size="${fontSize}" font-weight="${fontWeight}" text-anchor="${textAnchor}"${maxWidthAttr}>${content}</text>`,
|
|
);
|
|
} else if (el.type === 'qr') {
|
|
const x = mm(el.x);
|
|
const y = mm(el.y);
|
|
const size = mm(el.sizeMm);
|
|
const content = applyVars(el.content, variables);
|
|
try {
|
|
const qrBuffer = await QRCode.toBuffer(content, {
|
|
type: 'png',
|
|
margin: 0,
|
|
width: size,
|
|
errorCorrectionLevel: 'M',
|
|
});
|
|
const b64 = qrBuffer.toString('base64');
|
|
parts.push(`<image href="data:image/png;base64,${b64}" x="${x}" y="${y}" width="${size}" height="${size}"/>`);
|
|
} catch (err: any) {
|
|
this.logger.warn(`QR-Code-Rendering fehlgeschlagen für "${content}": ${err.message}`);
|
|
}
|
|
} else if (el.type === 'line') {
|
|
const x1 = mm(el.x1);
|
|
const y1 = mm(el.y1);
|
|
const x2 = mm(el.x2);
|
|
const y2 = mm(el.y2);
|
|
const strokeWidth = el.lineWidth ? mm(el.lineWidth) : 1;
|
|
parts.push(`<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="black" stroke-width="${strokeWidth}"/>`);
|
|
}
|
|
}
|
|
|
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">
|
|
<rect width="${W}" height="${H}" fill="white"/>
|
|
${parts.join('\n ')}
|
|
</svg>`;
|
|
|
|
const resvg = new Resvg(svg, {
|
|
font: {
|
|
loadSystemFonts: true,
|
|
fontDirs: ['/usr/share/fonts', '/usr/local/share/fonts'],
|
|
defaultFontFamily: 'Liberation Sans',
|
|
sansSerifFamily: 'Liberation Sans',
|
|
},
|
|
});
|
|
return Buffer.from(resvg.render().asPng());
|
|
}
|
|
}
|