);
}
+
+/* -------------------------------------------------------------------- Logo */
+
+/**
+ * Das Bild in der Mitte des Codes.
+ *
+ * Bewusst ein eigener Abschnitt und nicht bloß ein Feld: Ein Logo zieht drei
+ * Entscheidungen nach sich — woher es kommt, wie groß es wird, und dass die
+ * Fehlerkorrektur dafür hochgesetzt wird. Das gehört sichtbar beieinander.
+ */
+function LogoAuswahl({
+ design,
+ patch,
+ hasBrandingLogo,
+}: {
+ design: QrDesign;
+ patch: (changes: Partial) => void;
+ hasBrandingLogo: boolean;
+}) {
+ const an = design.logoSource !== null;
+
+ const waehle = (quelle: LogoSource | null) => {
+ if (quelle === null) {
+ patch({ logoSource: null, logoUploadId: null });
+ } else if (quelle === "branding") {
+ patch({ logoSource: "branding", logoUploadId: null });
+ } else {
+ patch({ logoSource: "upload" });
+ }
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/qr-generator.tsx b/src/components/qr-generator.tsx
index 5f1e9f3..0b0548e 100644
--- a/src/components/qr-generator.tsx
+++ b/src/components/qr-generator.tsx
@@ -47,9 +47,12 @@ export function QrGenerator({
typeId,
/** Darf der Kurz-Link selbst vergeben werden? Sonst erscheint das Feld nicht. */
maySetSlug = true,
+ /** Ist ein Logo der Installation hinterlegt? Sonst nur eigener Upload. */
+ hasBrandingLogo = false,
}: {
typeId: QrTypeId;
maySetSlug?: boolean;
+ hasBrandingLogo?: boolean;
}) {
const qrType = qrTypes[typeId];
const Form = qrForms[typeId];
@@ -510,7 +513,11 @@ export function QrGenerator({
Gestaltung
-
+
diff --git a/src/lib/codes.ts b/src/lib/codes.ts
index a11efdf..5386b0a 100644
--- a/src/lib/codes.ts
+++ b/src/lib/codes.ts
@@ -3,7 +3,7 @@ import "server-only";
import type { Prisma, QrCode } from "@/generated/prisma/client";
import type { QrCodeDto } from "@/lib/dto";
import { landingUrl, shortUrl } from "@/lib/env";
-import type { EcLevel } from "@/lib/qr/design";
+import type { EcLevel, LogoSource } from "@/lib/qr/design";
import { getQrType } from "@/lib/qr/registry";
import type { QrTypeId } from "@/lib/qr/type-module";
@@ -83,6 +83,9 @@ export function toQrCodeDto(code: QrCodeWithRelations): QrCodeDto {
size: code.size,
ecLevel: code.ecLevel as EcLevel,
margin: code.margin,
+ logoSource: code.logoSource as LogoSource | null,
+ logoUploadId: code.logoUploadId,
+ logoScale: code.logoScale,
},
scans: code.scans,
lastScanAt: code.lastScanAt?.toISOString() ?? null,
diff --git a/src/lib/dto.ts b/src/lib/dto.ts
index c3bf3ed..df53a51 100644
--- a/src/lib/dto.ts
+++ b/src/lib/dto.ts
@@ -1,4 +1,4 @@
-import type { EcLevel } from "@/lib/qr/design";
+import type { EcLevel, LogoSource } from "@/lib/qr/design";
import type { QrTypeId } from "@/lib/qr/type-module";
/** Statisch oder dynamisch — bestimmt, was ein Code überhaupt kann. */
@@ -35,6 +35,9 @@ export type QrCodeDto = {
size: number;
ecLevel: EcLevel;
margin: number;
+ logoSource: LogoSource | null;
+ logoUploadId: string | null;
+ logoScale: number;
};
scans: number;
lastScanAt: string | null;
diff --git a/src/lib/qr/design.ts b/src/lib/qr/design.ts
index 2b2db02..73b70e0 100644
--- a/src/lib/qr/design.ts
+++ b/src/lib/qr/design.ts
@@ -13,6 +13,29 @@ export const EC_LEVEL_LABELS: Record = {
export const MIN_SIZE = 256;
export const MAX_SIZE = 2048;
+/**
+ * Logo in der Mitte des Codes.
+ *
+ * `branding` nimmt das Logo der Installation aus dem Erscheinungsbild,
+ * `upload` ein eigenes, das zu diesem Code gehört. Ohne Angabe kein Logo.
+ */
+export const LOGO_SOURCES = ["branding", "upload"] as const;
+export type LogoSource = (typeof LOGO_SOURCES)[number];
+
+/**
+ * Kantenlänge des Logos in Prozent der Kantenlänge des Codes.
+ *
+ * Bei 20 % sind knapp 4 % der Fläche verdeckt — das steckt die
+ * Fehlerkorrektur H (30 %) mühelos weg. Die Obergrenze liegt bei 25 %, weil
+ * darüber weniger die Fehlerkorrektur das Problem ist als die Scanner-Apps:
+ * Manche suchen in der Mitte nach Ausrichtungsmustern.
+ */
+export const MIN_LOGO_SCALE = 10;
+export const MAX_LOGO_SCALE = 25;
+/** Ab hier ein Hinweis, dass es knapp wird. */
+export const LOGO_SCALE_WARN = 22;
+export const DEFAULT_LOGO_SCALE = 20;
+
const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
const hexColor = z
@@ -39,7 +62,25 @@ export const designSchema = z.object({
.int({ error: "Der Rand muss eine ganze Zahl sein." })
.min(0, { error: "Der Rand darf nicht negativ sein." })
.max(16, { error: "Der Rand darf höchstens 16 Module breit sein." }),
-});
+ logoSource: z
+ .enum(LOGO_SOURCES, { error: "Unbekannte Herkunft des Logos." })
+ .nullable()
+ .default(null),
+ logoUploadId: z.string().trim().min(1).nullable().default(null),
+ logoScale: z.coerce
+ .number({ error: "Die Logogröße muss eine Zahl sein." })
+ .int({ error: "Die Logogröße muss eine ganze Zahl sein." })
+ .min(MIN_LOGO_SCALE, {
+ error: `Das Logo muss mindestens ${MIN_LOGO_SCALE} % breit sein.`,
+ })
+ .max(MAX_LOGO_SCALE, {
+ error: `Das Logo darf höchstens ${MAX_LOGO_SCALE} % breit sein — darüber wird der Code unzuverlässig.`,
+ })
+ .default(DEFAULT_LOGO_SCALE),
+}).refine(
+ (design) => design.logoSource !== "upload" || design.logoUploadId !== null,
+ { error: "Für ein eigenes Logo fehlt die hochgeladene Datei." },
+);
export type QrDesign = z.infer;
@@ -49,8 +90,21 @@ export const defaultDesign: QrDesign = {
size: 512,
ecLevel: "M",
margin: 4,
+ logoSource: null,
+ logoUploadId: null,
+ logoScale: DEFAULT_LOGO_SCALE,
};
+/**
+ * Ein Logo verdeckt einen Teil des Codes. Was fehlt, muss die Fehlerkorrektur
+ * ausgleichen — deshalb gilt mit Logo immer **H**, unabhängig davon, was in
+ * der Auswahl steht. Die Oberfläche sagt das dazu; heimlich geschieht hier
+ * nichts.
+ */
+export function effectiveEcLevel(design: QrDesign): EcLevel {
+ return design.logoSource === null ? design.ecLevel : "H";
+}
+
/** Farbvorschläge für die schnelle Auswahl in der Oberfläche. */
export const COLOR_PRESETS: ReadonlyArray<{
label: string;
diff --git a/src/lib/qr/logo.ts b/src/lib/qr/logo.ts
new file mode 100644
index 0000000..da569b7
--- /dev/null
+++ b/src/lib/qr/logo.ts
@@ -0,0 +1,52 @@
+import "server-only";
+
+import { getBranding } from "@/lib/branding";
+import { prisma } from "@/lib/db";
+import type { QrDesign } from "@/lib/qr/design";
+import { QrRequestError } from "@/lib/qr/type-module";
+
+/** Das Bild, das in die Mitte des Codes kommt. */
+export type QrLogo = {
+ bytes: Buffer;
+ mimeType: string;
+};
+
+/**
+ * Holt das Logo, das die Gestaltung verlangt — oder `null`.
+ *
+ * Zwei Quellen: das Logo der Installation aus dem Erscheinungsbild, oder eine
+ * Datei, die zu diesem Code hochgeladen wurde. Beides liegt als Upload in der
+ * Datenbank.
+ *
+ * Fehlt die Datei, ist das kein stiller Ausfall: Ein Code, der ohne Logo
+ * herauskommt, obwohl eines eingestellt war, fiele erst nach dem Druck auf.
+ */
+export async function ladeLogo(design: QrDesign): Promise {
+ if (design.logoSource === null) return null;
+
+ const id =
+ design.logoSource === "branding"
+ ? (await getBranding()).logoUploadId
+ : design.logoUploadId;
+
+ if (!id) {
+ throw new QrRequestError(
+ design.logoSource === "branding"
+ ? "Für diese Installation ist kein Logo hinterlegt — unter Verwaltung → Erscheinungsbild lässt sich eines hochladen."
+ : "Das hochgeladene Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
+ );
+ }
+
+ const datei = await prisma.upload.findUnique({
+ where: { id },
+ select: { data: true, mimeType: true },
+ });
+
+ if (!datei) {
+ throw new QrRequestError(
+ "Das Logo wurde nicht gefunden. Bitte lade es erneut hoch.",
+ );
+ }
+
+ return { bytes: Buffer.from(datei.data), mimeType: datei.mimeType };
+}
diff --git a/src/lib/qr/render.ts b/src/lib/qr/render.ts
index 055f254..a86d895 100644
--- a/src/lib/qr/render.ts
+++ b/src/lib/qr/render.ts
@@ -1,8 +1,10 @@
import "server-only";
import QRCode, { type QRCodeRenderersOptions } from "qrcode";
+import sharp from "sharp";
-import type { QrDesign } from "@/lib/qr/design";
+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. */
@@ -10,7 +12,8 @@ export class QrRenderError extends UserFacingError {}
function toOptions(design: QrDesign): QRCodeRenderersOptions {
return {
- errorCorrectionLevel: design.ecLevel,
+ // Mit Logo gilt immer H — siehe `effectiveEcLevel`.
+ errorCorrectionLevel: effectiveEcLevel(design),
margin: design.margin,
width: design.size,
color: {
@@ -30,12 +33,103 @@ function wrap(error: unknown): never {
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 {
- return await QRCode.toString(content, { ...toOptions(design), type: "svg" });
+ const svg = await QRCode.toString(content, {
+ ...toOptions(design),
+ type: "svg",
+ });
+ return logo === null ? svg : legeLogoInSvg(svg, design, logo);
} catch (error) {
wrap(error);
}
@@ -44,9 +138,14 @@ export async function renderSvg(
export async function renderPng(
content: string,
design: QrDesign,
+ logo: QrLogo | null = null,
): Promise {
try {
- return await QRCode.toBuffer(content, { ...toOptions(design), type: "png" });
+ const png = await QRCode.toBuffer(content, {
+ ...toOptions(design),
+ type: "png",
+ });
+ return logo === null ? png : await legeLogoAuf(png, design, logo);
} catch (error) {
wrap(error);
}
diff --git a/src/lib/uploads.shared.ts b/src/lib/uploads.shared.ts
index a13dc80..6d2c564 100644
--- a/src/lib/uploads.shared.ts
+++ b/src/lib/uploads.shared.ts
@@ -35,6 +35,16 @@ export const UPLOAD_KINDS = {
// das dann bei jedem Seitenaufruf mitgeht.
maxBytes: 2 * 1024 * 1024,
},
+ qrlogo: {
+ label: "Logo im Code",
+ // Ohne GIF: Ein bewegtes Bild wird beim Einsetzen ohnehin zum Standbild,
+ // und die Auswahl soll nicht mehr versprechen, als hinten herauskommt.
+ mimeTypes: ["image/png", "image/jpeg", "image/webp", "image/avif"],
+ accept: "image/png,image/jpeg,image/webp,image/avif",
+ // Das Logo landet auf wenigen hundert Bildpunkten in der Mitte des Codes.
+ // Mehr als zwei Megabyte bringen dort nichts.
+ maxBytes: 2 * 1024 * 1024,
+ },
favicon: {
label: "Favicon",
// Zusätzlich ICO — viele haben genau das noch herumliegen.
diff --git a/src/lib/uploads.ts b/src/lib/uploads.ts
index 87d5ff8..ce49cb3 100644
--- a/src/lib/uploads.ts
+++ b/src/lib/uploads.ts
@@ -189,6 +189,36 @@ export async function attachUploads(
});
}
+/**
+ * Hängt **eine** Datei an einen Code, ohne die übrigen anzurühren.
+ *
+ * Gebraucht für das Logo aus der Gestaltung: Ändert sich nur das Aussehen
+ * eines Codes, sollen die Dateien seines Inhalts unberührt bleiben.
+ */
+export async function attachUpload(
+ qrCodeId: string,
+ uploadId: string,
+): Promise {
+ await prisma.upload.updateMany({
+ // Dieselbe Bedingung wie oben: keine fremde Datei an sich ziehen.
+ where: { id: uploadId, OR: [{ qrCodeId: null }, { qrCodeId }] },
+ data: { qrCodeId },
+ });
+}
+
+/**
+ * Löst eine Datei wieder vom Code — sie wird danach als herrenlos weggeräumt.
+ */
+export async function detachUpload(
+ qrCodeId: string,
+ uploadId: string,
+): Promise {
+ await prisma.upload.updateMany({
+ where: { id: uploadId, qrCodeId },
+ data: { qrCodeId: null },
+ });
+}
+
/** Wie lange eine hochgeladene, aber nie gespeicherte Datei liegen bleibt. */
const ORPHAN_HOURS = 24;