Zur Wahl stehen zwei Quellen: das Logo der Installation aus dem Erscheinungsbild oder ein eigenes je Code. Die Groesse ist zwischen 10 und 25 Prozent der Kantenlaenge einstellbar, hinter dem Bild liegt eine Flaeche in der Hintergrundfarbe. Mit Logo gilt die Fehlerkorrektur H, unabhaengig von der Auswahl — was das Bild verdeckt, muss sie ausgleichen. Die Auswahl steht dann fest auf H und sagt auch warum; heimlich geschieht das nicht. Ab 22 Prozent kommt eine Warnung dazu. Gerendert wird weiterhin serverseitig, damit Vorschau und Download uebereinstimmen: das SVG bekommt ein eingebettetes Bild, das PNG wird mit sharp zusammengesetzt. sharp lag ohnehin als Abhaengigkeit von Next im Baum und steht jetzt ausdruecklich in der package.json. Ein eigenes Logo gehoert zur Gestaltung, nicht zum Inhalt — es muss trotzdem an den Code gehaengt werden. Sonst raeumt die Aufraeumroutine es nach einem Tag weg und der Code verliert sein Bild. Refs #8
160 lines
5.1 KiB
TypeScript
160 lines
5.1 KiB
TypeScript
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<Buffer> {
|
|
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 `</svg>`,
|
|
* 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 =
|
|
`<rect x="${rund(versatz)}" y="${rund(versatz)}" width="${rund(flaeche)}" height="${rund(flaeche)}" fill="${design.bgColor}"/>` +
|
|
`<image x="${rund(bildVersatz)}" y="${rund(bildVersatz)}" width="${rund(bild)}" height="${rund(bild)}" ` +
|
|
`preserveAspectRatio="xMidYMid meet" href="${daten}"/>`;
|
|
|
|
return svg.replace("</svg>", `${einsatz}</svg>`);
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- Ausgabe */
|
|
|
|
export async function renderSvg(
|
|
content: string,
|
|
design: QrDesign,
|
|
logo: QrLogo | null = null,
|
|
): Promise<string> {
|
|
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<Buffer> {
|
|
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<QrFormat, string> = {
|
|
png: "image/png",
|
|
svg: "image/svg+xml",
|
|
};
|