Logo in der Mitte des QR-Codes
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
This commit is contained in:
@@ -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 <QrGenerator typeId={state.qrType.id} maySetSlug={maySetSlug} />;
|
||||
// Ohne hinterlegtes Logo entfaellt die Wahl der Herkunft — dann bleibt nur
|
||||
// der eigene Upload.
|
||||
const { logoUploadId } = await getBranding();
|
||||
|
||||
return (
|
||||
<QrGenerator
|
||||
typeId={state.qrType.id}
|
||||
maySetSlug={maySetSlug}
|
||||
hasBrandingLogo={logoUploadId !== null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"Content-Type": CONTENT_TYPES[format],
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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<Response> {
|
||||
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<Response> {
|
||||
});
|
||||
// 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) {
|
||||
|
||||
@@ -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<Response> {
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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<Response> {
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -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<QrDesign>) => onChange({ ...design, ...changes });
|
||||
const contrast = evaluateContrast(design.fgColor, design.bgColor);
|
||||
@@ -165,7 +173,11 @@ export function DesignPanel({
|
||||
|
||||
<Field
|
||||
label="Fehlerkorrektur"
|
||||
hint={EC_LEVEL_LABELS[design.ecLevel]}
|
||||
hint={
|
||||
design.logoSource === null
|
||||
? EC_LEVEL_LABELS[design.ecLevel]
|
||||
: "Mit Logo gilt H — was das Bild verdeckt, muss die Fehlerkorrektur ausgleichen."
|
||||
}
|
||||
>
|
||||
<div
|
||||
role="radiogroup"
|
||||
@@ -177,11 +189,14 @@ export function DesignPanel({
|
||||
key={level}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={design.ecLevel === level}
|
||||
aria-checked={
|
||||
design.logoSource === null ? design.ecLevel === level : level === "H"
|
||||
}
|
||||
disabled={design.logoSource !== null}
|
||||
onClick={() => 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"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<LogoAuswahl
|
||||
design={design}
|
||||
patch={patch}
|
||||
hasBrandingLogo={hasBrandingLogo}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- 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<QrDesign>) => 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 (
|
||||
<fieldset className="border-t border-slate-200 pt-4">
|
||||
<legend className="pr-2 text-xs font-semibold tracking-wide text-slate-500 uppercase">
|
||||
Logo in der Mitte
|
||||
</legend>
|
||||
|
||||
<label className="mt-3 flex cursor-pointer items-start gap-2.5">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={an}
|
||||
onChange={(event) =>
|
||||
waehle(
|
||||
event.target.checked ? (hasBrandingLogo ? "branding" : "upload") : null,
|
||||
)
|
||||
}
|
||||
className="mt-0.5 size-4 shrink-0 rounded border-slate-300 accent-indigo-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">
|
||||
Ein Logo in die Mitte des Codes setzen
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{an ? (
|
||||
<div className="mt-4 space-y-4">
|
||||
{hasBrandingLogo ? (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Herkunft des Logos"
|
||||
className="space-y-2"
|
||||
>
|
||||
{(
|
||||
[
|
||||
["branding", "Logo dieser Installation"],
|
||||
["upload", "Eigenes Logo hochladen"],
|
||||
] as [LogoSource, string][]
|
||||
).map(([quelle, text]) => (
|
||||
<label
|
||||
key={quelle}
|
||||
className="flex cursor-pointer items-center gap-2.5"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="logo-quelle"
|
||||
checked={design.logoSource === quelle}
|
||||
onChange={() => waehle(quelle)}
|
||||
className="size-4 shrink-0 accent-indigo-600"
|
||||
/>
|
||||
<span className="text-sm text-slate-700">{text}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{design.logoSource === "upload" ? (
|
||||
<div className="space-y-2">
|
||||
<FileField
|
||||
kind="qrlogo"
|
||||
label={design.logoUploadId ? "Anderes Logo wählen" : "Logo wählen"}
|
||||
onUploaded={(datei) => patch({ logoUploadId: datei.fileId })}
|
||||
/>
|
||||
{design.logoUploadId ? null : (
|
||||
<p className="text-sm text-amber-700">
|
||||
Noch keine Datei gewählt — bis dahin bleibt die Mitte frei.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Field
|
||||
label={`Größe: ${design.logoScale} % der Kantenlänge`}
|
||||
htmlFor="qr-logo-scale"
|
||||
hint="Bezogen auf die Breite des Codes. Bei 20 % sind rund 4 % der Fläche verdeckt."
|
||||
>
|
||||
<input
|
||||
id="qr-logo-scale"
|
||||
type="range"
|
||||
min={MIN_LOGO_SCALE}
|
||||
max={MAX_LOGO_SCALE}
|
||||
step={1}
|
||||
value={design.logoScale}
|
||||
onChange={(event) => patch({ logoScale: Number(event.target.value) })}
|
||||
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-slate-200 accent-indigo-600"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{design.logoScale > LOGO_SCALE_WARN ? (
|
||||
<Notice tone="warning">
|
||||
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.
|
||||
</Notice>
|
||||
) : null}
|
||||
|
||||
<p className="text-sm text-slate-500">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<CardTitle hint="Farben, Größe und Fehlerkorrektur gelten für Vorschau und Download.">
|
||||
Gestaltung
|
||||
</CardTitle>
|
||||
<DesignPanel design={design} onChange={setDesign} />
|
||||
<DesignPanel
|
||||
design={design}
|
||||
onChange={setDesign}
|
||||
hasBrandingLogo={hasBrandingLogo}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
+55
-1
@@ -13,6 +13,29 @@ export const EC_LEVEL_LABELS: Record<EcLevel, string> = {
|
||||
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<typeof designSchema>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<QrLogo | null> {
|
||||
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 };
|
||||
}
|
||||
+103
-4
@@ -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<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 {
|
||||
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<Buffer> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user