Files
paperlessmanager/paperless-backend/src/label-print-agent/label-renderer.service.ts
T
2026-05-08 16:37:15 +02:00

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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());
}
}