import "server-only"; import QRCode, { type QRCodeRenderersOptions } from "qrcode"; import sharp from "sharp"; import { effectiveEcLevel, type QrDesign } from "@/lib/qr/design"; import type { QrLogo } from "@/lib/qr/logo"; import { UserFacingError } from "@/lib/qr/type-module"; /** Fehler beim Erzeugen des Codes, mit Meldung für die Oberfläche. */ export class QrRenderError extends UserFacingError {} function toOptions(design: QrDesign): QRCodeRenderersOptions { return { // Mit Logo gilt immer H — siehe `effectiveEcLevel`. errorCorrectionLevel: effectiveEcLevel(design), margin: design.margin, width: design.size, color: { dark: design.fgColor, light: design.bgColor, }, }; } function wrap(error: unknown): never { const detail = error instanceof Error ? error.message : String(error); if (/too (long|big)|code length overflow|data too long/i.test(detail)) { throw new QrRenderError( "Der Inhalt ist zu lang für einen QR-Code. Kürze die Adresse oder wähle ein niedrigeres Fehlerkorrektur-Level.", ); } throw new QrRenderError(`Der QR-Code konnte nicht erzeugt werden: ${detail}`); } /* ------------------------------------------------------------------- Logo */ /** * Wie das Logo in den Code kommt. * * Hinter dem Bild liegt eine Fläche in der Hintergrundfarbe — ohne sie stünde * das Logo auf den Modulen und ginge optisch unter. Der Rand dieser Fläche * ist ein Zehntel der Logobreite; das reicht, um es abzusetzen, ohne mehr * Code zu verdecken als nötig. * * Das Bild selbst wird eingepasst, nicht beschnitten (`contain`): Ein * Firmenlogo im Querformat soll als Ganzes zu sehen sein. */ const PAD_ANTEIL = 0.1; /** Kantenlänge von Bild und Fläche, in Bildpunkten. */ function logoMasse(gesamt: number, prozent: number) { const bild = Math.round((gesamt * prozent) / 100); const flaeche = Math.round(bild * (1 + 2 * PAD_ANTEIL)); return { bild, flaeche, versatz: Math.round((gesamt - flaeche) / 2) }; } async function legeLogoAuf( qrPng: Buffer, design: QrDesign, logo: QrLogo, ): Promise { const { bild, flaeche, versatz } = logoMasse(design.size, design.logoScale); // Erst das Bild auf die Fläche einpassen, dann die Fläche auf den Code. // Zwei Schritte, weil `extend` den Rand in der Hintergrundfarbe zieht — das // ergibt die Fläche, ohne eine zweite Zeichnung zu brauchen. const rand = Math.round((flaeche - bild) / 2); const kachel = await sharp(logo.bytes) .resize(bild, bild, { fit: "contain", background: design.bgColor }) .extend({ top: rand, bottom: flaeche - bild - rand, left: rand, right: flaeche - bild - rand, background: design.bgColor, }) .png() .toBuffer(); return sharp(qrPng) .composite([{ input: kachel, top: versatz, left: versatz }]) .png() .toBuffer(); } /** * Setzt das Logo in ein fertiges QR-SVG. * * Das SVG von `qrcode` hat ein `viewBox` in Modulen — gerechnet wird deshalb * in Modulen und nicht in Bildpunkten. Eingefügt wird direkt vor ``, * damit Fläche und Bild über den Modulen liegen. */ function legeLogoInSvg(svg: string, design: QrDesign, logo: QrLogo): string { const treffer = /viewBox="0 0 (\d+(?:\.\d+)?) /.exec(svg); if (!treffer) { // Ohne viewBox ließe sich nichts sauber platzieren. Lieber ohne Logo // ausliefern als an einer falschen Stelle. console.error("QR-SVG ohne viewBox — Logo wird ausgelassen."); return svg; } const kante = Number(treffer[1]); const bild = (kante * design.logoScale) / 100; const flaeche = bild * (1 + 2 * PAD_ANTEIL); const versatz = (kante - flaeche) / 2; const bildVersatz = (kante - bild) / 2; const daten = `data:${logo.mimeType};base64,${logo.bytes.toString("base64")}`; const rund = (wert: number) => Number(wert.toFixed(3)); const einsatz = `` + ``; return svg.replace("", `${einsatz}`); } /* ---------------------------------------------------------------- Ausgabe */ export async function renderSvg( content: string, design: QrDesign, logo: QrLogo | null = null, ): Promise { try { const svg = await QRCode.toString(content, { ...toOptions(design), type: "svg", }); return logo === null ? svg : legeLogoInSvg(svg, design, logo); } catch (error) { wrap(error); } } export async function renderPng( content: string, design: QrDesign, logo: QrLogo | null = null, ): Promise { try { const png = await QRCode.toBuffer(content, { ...toOptions(design), type: "png", }); return logo === null ? png : await legeLogoAuf(png, design, logo); } catch (error) { wrap(error); } } export type QrFormat = "png" | "svg"; export const CONTENT_TYPES: Record = { png: "image/png", svg: "image/svg+xml", };