diff --git a/README.md b/README.md index 86826ca..43d5ddd 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ entscheidet die Verwaltung — nicht gebrauchte lassen sich ausblenden. sowie Feedback-Formulare — öffentlich, ohne einen Hinweis auf die Anwendung. - **Gestaltung:** freie Farben mit Kontrastwarnung, 256–2048 px, Fehlerkorrektur L/M/Q/H, einstellbarer Rand. Download als PNG und SVG. +- **Logo in der Mitte:** das Logo der Installation oder ein eigenes je Code, + 10–25 % der Kantenlänge, mit erzwungener Fehlerkorrektur H. - **Meine Codes:** Liste oder Karten, Suche, Sortierung, Gruppenfilter, Ziel ändern, löschen. Statische Codes lassen sich auf Wunsch mit Namen ablegen, um sie wiederzufinden. diff --git a/docs/funktionen.md b/docs/funktionen.md index 1662174..c135543 100644 --- a/docs/funktionen.md +++ b/docs/funktionen.md @@ -122,6 +122,29 @@ Liste steht dort „statisch“ und ein Strich. Wer nach dem Ablegen den Inhalt ändert, bekommt das Ablegen erneut angeboten — der vorherige Eintrag bleibt als eigener Schnappschuss bestehen. +### Ein Logo in der Mitte + +Ein Bild in die Mitte des Codes setzen, wie man es von Firmen-Codes kennt. +Zur Wahl stehen zwei Quellen: + +- das **Logo dieser Installation** aus dem Erscheinungsbild — ein Haken + genügt, kein Upload je Code; +- ein **eigenes Logo** je Code, für Kundenprojekte mit fremdem Zeichen. + +Die Größe ist zwischen 10 und 25 % der Kantenlänge einstellbar; voreingestellt +sind 20 %, das entspricht rund 4 % der Fläche. Hinter dem Bild liegt eine +Fläche in der Hintergrundfarbe, damit es sich absetzt. + +**Die Fehlerkorrektur wird dabei auf H erzwungen** — was das Bild verdeckt, +muss sie ausgleichen. Die Auswahl steht dann fest auf H und sagt auch warum; +heimlich geschieht das nicht. Ab 22 % erscheint eine Warnung: Die +Fehlerkorrektur trägt das zwar, aber manche Scanner-Apps suchen in der Mitte +nach Ausrichtungsmustern. + +Jeder erzeugte Code wird in der Prüfung wieder eingelesen — über mehrere +Größen, Prozentsätze und Inhaltslängen. Das ersetzt trotzdem nicht den einen +Scan mit dem eigenen Handy, bevor etwas in den Druck geht. + ### Der Kurz-Link Ohne eigene Angabe entsteht er aus acht Zeichen eines Alphabets ohne diff --git a/package-lock.json b/package-lock.json index 039f195..c4ba013 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "qrcode-generator", - "version": "1.1.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "qrcode-generator", - "version": "1.1.0", + "version": "1.6.0", "dependencies": { "@prisma/adapter-pg": "^7.9.1", "@prisma/client": "^7.9.1", @@ -17,6 +17,7 @@ "react": "19.2.8", "react-dom": "19.2.8", "server-only": "^0.0.1", + "sharp": "^0.35.3", "zod": "^4.4.3" }, "devDependencies": { @@ -566,7 +567,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -2065,6 +2065,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", @@ -4039,7 +4105,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -7795,7 +7860,6 @@ "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", @@ -7845,7 +7909,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, diff --git a/package.json b/package.json index 7423cb9..e840a46 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "react": "19.2.8", "react-dom": "19.2.8", "server-only": "^0.0.1", + "sharp": "^0.35.3", "zod": "^4.4.3" }, "devDependencies": { diff --git a/prisma/migrations/20260821020000_logo_im_code/migration.sql b/prisma/migrations/20260821020000_logo_im_code/migration.sql new file mode 100644 index 0000000..53b87ff --- /dev/null +++ b/prisma/migrations/20260821020000_logo_im_code/migration.sql @@ -0,0 +1,11 @@ +-- Ein Logo in der Mitte des Codes, wie man es von Firmen-Codes kennt. +-- +-- Drei Angaben gehoeren zur Gestaltung: woher das Bild kommt, welche Datei es +-- ist und wie gross es sein soll. Ohne Herkunft gibt es kein Logo — deshalb +-- sind die ersten beiden Spalten leer erlaubt und ohne Vorbelegung. +ALTER TABLE "QrCode" ADD COLUMN "logoSource" TEXT; +ALTER TABLE "QrCode" ADD COLUMN "logoUploadId" TEXT; + +-- Die Groesse hat eine Vorbelegung, damit bestehende Codes einen gueltigen +-- Wert tragen. Ohne Logo bleibt er wirkungslos. +ALTER TABLE "QrCode" ADD COLUMN "logoScale" INTEGER NOT NULL DEFAULT 20; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 898c2c5..182c735 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -182,6 +182,14 @@ model QrCode { size Int @default(512) ecLevel String @default("M") margin Int @default(4) + /// Logo in der Mitte: "branding" nimmt das Logo der Installation, + /// "upload" eine eigene Datei. Leer heisst: kein Logo. + logoSource String? + /// Die eigene Datei, wenn `logoSource` = "upload". Sie haengt ueber + /// `Upload.qrCodeId` am Code und verschwindet mit ihm. + logoUploadId String? + /// Kantenlaenge des Logos in Prozent der Kantenlaenge des Codes. + logoScale Int @default(20) scans Int @default(0) lastScanAt DateTime? createdAt DateTime @default(now()) diff --git a/src/app/(app)/erstellen/[type]/page.tsx b/src/app/(app)/erstellen/[type]/page.tsx index 41df18a..61e1aa9 100644 --- a/src/app/(app)/erstellen/[type]/page.tsx +++ b/src/app/(app)/erstellen/[type]/page.tsx @@ -5,6 +5,7 @@ import { notFound } from "next/navigation"; import { Icon } from "@/components/icons"; import { QrGenerator } from "@/components/qr-generator"; import { resolveTargetGroup } from "@/lib/auth/groups"; +import { getBranding } from "@/lib/branding"; import { can } from "@/lib/auth/policy"; import { getSettings } from "@/lib/settings"; import { requirePageActor } from "@/lib/auth/guard"; @@ -85,5 +86,15 @@ export default async function CreatePage({ params }: Props) { const groupId = await resolveTargetGroup(actor, undefined).catch(() => undefined); const maySetSlug = can(actor, "codes:slug", { groupId, editorsMaySetSlug }); - return ; + // Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur + // der eigene Upload. + const { logoUploadId } = await getBranding(); + + return ( + + ); } diff --git a/src/app/api/codes/[id]/qr/route.ts b/src/app/api/codes/[id]/qr/route.ts index 39d9997..948da8a 100644 --- a/src/app/api/codes/[id]/qr/route.ts +++ b/src/app/api/codes/[id]/qr/route.ts @@ -8,6 +8,7 @@ import { requireImplementedQrType } from "@/lib/qr/availability"; import { errorResponse } from "@/lib/http"; import { designSchema } from "@/lib/qr/design"; import { getQrType } from "@/lib/qr/registry"; +import { ladeLogo } from "@/lib/qr/logo"; import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render"; import { downloadFilename, QrRequestError } from "@/lib/qr/request"; @@ -48,7 +49,11 @@ export async function GET( size: code.size, ecLevel: code.ecLevel, margin: code.margin, + logoSource: code.logoSource, + logoUploadId: code.logoUploadId, + logoScale: code.logoScale, }); + const logo = await ladeLogo(design); // Was im Bild steckt, haengt an der Betriebsart: Ein dynamischer Code // traegt die Kurz-URL, ein statischer seinen Inhalt selbst. @@ -58,8 +63,8 @@ export async function GET( : shortUrl(code.slug, code.domain.hostname); const body = format === "png" - ? new Uint8Array(await renderPng(content, design)) - : await renderSvg(content, design); + ? new Uint8Array(await renderPng(content, design, logo)) + : await renderSvg(content, design, logo); const headers: Record = { "Content-Type": CONTENT_TYPES[format], diff --git a/src/app/api/codes/[id]/route.ts b/src/app/api/codes/[id]/route.ts index 9aaafe7..98f94e6 100644 --- a/src/app/api/codes/[id]/route.ts +++ b/src/app/api/codes/[id]/route.ts @@ -13,7 +13,7 @@ import { assertDynamicPossible } from "@/lib/qr/request"; import { describeSlugProblem, normalizeSlug } from "@/lib/slug"; import { assertSlugAvailable } from "@/lib/slug-resolve"; import { getSettings } from "@/lib/settings"; -import { attachUploads } from "@/lib/uploads"; +import { attachUpload, attachUploads, detachUpload } from "@/lib/uploads"; import { QrRequestError } from "@/lib/qr/type-module"; export const dynamic = "force-dynamic"; @@ -133,6 +133,18 @@ export async function PATCH( Object.assign(update, parsed.data.design); } + // Ein eigenes Logo gehört zur Gestaltung, nicht zum Inhalt. Es muss + // trotzdem an den Code gehängt werden — sonst liegt die Datei herrenlos + // herum und wird nach einem Tag weggeräumt, mitsamt dem Logo im Code. + const logoVorher = + existing.logoSource === "upload" ? existing.logoUploadId : null; + const logoNachher = + parsed.data.design === undefined + ? logoVorher + : parsed.data.design.logoSource === "upload" + ? parsed.data.design.logoUploadId + : null; + /* ------------------------------------------------------- Kurz-Link */ const neuerSlug = parsed.data.slug?.trim(); @@ -225,7 +237,18 @@ export async function PATCH( // Eine ausgetauschte Datei verliert dabei ihren Bezug zum Code und wird // später als verwaist weggeräumt. if (neueDateien !== null) { - await attachUploads(updated.id, neueDateien); + await attachUploads( + updated.id, + logoNachher ? [...neueDateien, logoNachher] : neueDateien, + ); + } else if (logoNachher && logoNachher !== logoVorher) { + await attachUpload(updated.id, logoNachher); + } + + // Ein abgelegtes oder ausgetauschtes Logo verliert seinen Bezug und wird + // später weggeräumt. + if (logoVorher && logoVorher !== logoNachher) { + await detachUpload(updated.id, logoVorher); } return Response.json({ code: toQrCodeDto(updated) }); diff --git a/src/app/api/codes/route.ts b/src/app/api/codes/route.ts index 239279e..4f5ba8d 100644 --- a/src/app/api/codes/route.ts +++ b/src/app/api/codes/route.ts @@ -45,6 +45,24 @@ const createSchema = z.object({ domainId: z.string().trim().min(1).optional(), }); +/** + * Alle Dateien, die zu einem Code gehören. + * + * Das sind die des Typ-Moduls — ein PDF, ein Bild — **und** ein eigenes Logo + * aus der Gestaltung. Wird das vergessen, löst `attachUploads` das Logo beim + * nächsten Speichern wieder vom Code und räumt es später als herrenlos weg. + */ +function dateienDesCodes( + qrType: { uploadIds: (data: never) => string[] }, + data: unknown, + design: { logoSource: string | null; logoUploadId: string | null }, +): string[] { + const ids = qrType.uploadIds(data as never); + return design.logoSource === "upload" && design.logoUploadId + ? [...ids, design.logoUploadId] + : ids; +} + function isUniqueViolation(error: unknown): boolean { return ( typeof error === "object" && @@ -160,7 +178,7 @@ export async function POST(request: NextRequest): Promise { aliases: { select: { slug: true }, orderBy: { createdAt: "desc" } }, }, }); - await attachUploads(created.id, qrType.uploadIds(validated.data)); + await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design)); return Response.json({ code: toQrCodeDto(created) }, { status: 201 }); } @@ -187,7 +205,7 @@ export async function POST(request: NextRequest): Promise { }); // Erst jetzt gehören die hochgeladenen Dateien zu diesem Code — // vorher lagen sie ohne Bezug herum. - await attachUploads(created.id, qrType.uploadIds(validated.data)); + await attachUploads(created.id, dateienDesCodes(qrType, validated.data, design)); return Response.json({ code: toQrCodeDto(created) }, { status: 201 }); } catch (error) { diff --git a/src/app/api/qr/download/route.ts b/src/app/api/qr/download/route.ts index 21f8c49..3128e66 100644 --- a/src/app/api/qr/download/route.ts +++ b/src/app/api/qr/download/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { getActor, requireSignedIn } from "@/lib/auth/actor"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; +import { ladeLogo } from "@/lib/qr/logo"; import { CONTENT_TYPES, renderPng, renderSvg } from "@/lib/qr/render"; import { downloadFilename, @@ -38,10 +39,11 @@ export async function POST(request: NextRequest): Promise { const { qrType, data, content } = await resolveQrRequest(parsed.data); + const logo = await ladeLogo(design); const body = format === "png" - ? new Uint8Array(await renderPng(content, design)) - : await renderSvg(content, design); + ? new Uint8Array(await renderPng(content, design, logo)) + : await renderSvg(content, design, logo); return new Response(body, { headers: { diff --git a/src/app/api/qr/preview/route.ts b/src/app/api/qr/preview/route.ts index 5b8f24c..6778644 100644 --- a/src/app/api/qr/preview/route.ts +++ b/src/app/api/qr/preview/route.ts @@ -2,6 +2,7 @@ import type { NextRequest } from "next/server"; import { getActor, requireSignedIn } from "@/lib/auth/actor"; import { errorResponse, firstZodMessage, jsonError } from "@/lib/http"; +import { ladeLogo } from "@/lib/qr/logo"; import { renderSvg } from "@/lib/qr/render"; import { qrRequestSchema, resolveQrRequest } from "@/lib/qr/request"; @@ -21,7 +22,8 @@ export async function POST(request: NextRequest): Promise { } const { content } = await resolveQrRequest(parsed.data); - const svg = await renderSvg(content, parsed.data.design); + const logo = await ladeLogo(parsed.data.design); + const svg = await renderSvg(content, parsed.data.design, logo); return Response.json({ content, diff --git a/src/components/design-panel.tsx b/src/components/design-panel.tsx index 3b7e150..bd43072 100644 --- a/src/components/design-panel.tsx +++ b/src/components/design-panel.tsx @@ -1,15 +1,20 @@ "use client"; import { Icon } from "@/components/icons"; +import { FileField } from "@/components/qr-forms/file-field"; import { Field, Notice, TextInput, cx } from "@/components/ui"; import { evaluateContrast } from "@/lib/qr/contrast"; import { COLOR_PRESETS, EC_LEVELS, EC_LEVEL_LABELS, + LOGO_SCALE_WARN, + MAX_LOGO_SCALE, MAX_SIZE, + MIN_LOGO_SCALE, MIN_SIZE, type EcLevel, + type LogoSource, type QrDesign, } from "@/lib/qr/design"; @@ -62,9 +67,12 @@ function ColorField({ export function DesignPanel({ design, onChange, + /** Ist unter „Erscheinungsbild“ ein Logo hinterlegt? Sonst entfällt die Wahl. */ + hasBrandingLogo = false, }: { design: QrDesign; onChange: (design: QrDesign) => void; + hasBrandingLogo?: boolean; }) { const patch = (changes: Partial) => onChange({ ...design, ...changes }); const contrast = evaluateContrast(design.fgColor, design.bgColor); @@ -165,7 +173,11 @@ export function DesignPanel({
patch({ ecLevel: level })} className={cx( - "rounded-lg border py-2 text-sm font-medium transition-colors", - design.ecLevel === level + "rounded-lg border py-2 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-50", + (design.logoSource === null ? design.ecLevel : "H") === level ? "border-indigo-600 bg-accent-soft text-accent-soft-fg" : "border-slate-300 bg-surface text-slate-600 hover:bg-slate-50", )} @@ -208,6 +223,147 @@ export function DesignPanel({ className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600" /> + +
); } + +/* -------------------------------------------------------------------- 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 ( +
+ + Logo in der Mitte + + + + + {an ? ( +
+ {hasBrandingLogo ? ( +
+ {( + [ + ["branding", "Logo dieser Installation"], + ["upload", "Eigenes Logo hochladen"], + ] as [LogoSource, string][] + ).map(([quelle, text]) => ( + + ))} +
+ ) : null} + + {design.logoSource === "upload" ? ( +
+ patch({ logoUploadId: datei.fileId })} + /> + {design.logoUploadId ? null : ( +

+ Noch keine Datei gewählt — bis dahin bleibt die Mitte frei. +

+ )} +
+ ) : null} + + + patch({ logoScale: Number(event.target.value) })} + className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600" + /> + + + {design.logoScale > LOGO_SCALE_WARN ? ( + + Ab {LOGO_SCALE_WARN} % wird es knapp. Die Fehlerkorrektur trägt das + zwar, aber manche Scanner-Apps suchen in der Mitte nach + Ausrichtungsmustern. Prüfe den fertigen Code mit mehr als einer App, + bevor er in den Druck geht. + + ) : null} + +

+ Hinter dem Logo liegt eine Fläche in der Hintergrundfarbe, damit es + sich absetzt. Ein Code mit Logo gehört vor dem Druck einmal + eingescannt — das ist die einzige Prüfung, die wirklich zählt. +

+
+ ) : null} +
+ ); +} 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;