diff --git a/apps/web/app/api/qr/route.tsx b/apps/web/app/api/qr/route.tsx index 60d9d771380..2e9bc088a6c 100644 --- a/apps/web/app/api/qr/route.tsx +++ b/apps/web/app/api/qr/route.tsx @@ -2,6 +2,8 @@ import { handleAndReturnErrorResponse } from "@/lib/api/errors"; import { ratelimitOrThrow } from "@/lib/api/utils"; import { getShortLinkViaEdge, getWorkspaceViaEdge } from "@/lib/planetscale"; import { getDomainViaEdge } from "@/lib/planetscale/get-domain-via-edge"; +import { getQRAsSVG } from "@/lib/qr/api"; +import { DEFAULT_BGCOLOR } from "@/lib/qr/constants"; import { QRCodeSVG } from "@/lib/qr/utils"; import { getQRCodeQuerySchema } from "@/lib/zod/schemas/qr"; import { DUB_QR_LOGO, getSearchParams, isDubDomain } from "@dub/utils"; @@ -21,29 +23,53 @@ export async function GET(req: NextRequest) { await ratelimitOrThrow(req, "qr"); - const { logo, url, size, level, fgColor, bgColor, margin, hideLogo } = - paramsParsed; + const { + logo, + url, + size, + level, + fgColor, + bgColor, + margin, + hideLogo, + format, + } = paramsParsed; const qrCodeLogo = await getQRCodeLogo({ url, logo, hideLogo }); + const qrProps = { + value: url, + size, + level, + fgColor, + margin, + ...(qrCodeLogo + ? { + imageSettings: { + src: qrCodeLogo, + height: size / 4, + width: size / 4, + excavate: true, + }, + } + : {}), + }; + + if (format === "svg") { + const svgString = await getQRAsSVG({ + ...qrProps, + ...(bgColor ? { bgColor } : {}), + }); + const headers = new Headers(CORS_HEADERS); + headers.set("Content-Type", "image/svg+xml"); + headers.set("Content-Disposition", "inline; filename=qr.svg"); + return new Response(svgString, { headers }); + } + return new ImageResponse( QRCodeSVG({ - value: url, - size, - level, - fgColor, - bgColor, - margin, - ...(qrCodeLogo - ? { - imageSettings: { - src: qrCodeLogo, - height: size / 4, - width: size / 4, - excavate: true, - }, - } - : {}), + ...qrProps, + bgColor: bgColor ?? DEFAULT_BGCOLOR, isOGContext: true, }), { diff --git a/apps/web/lib/openapi/qr/index.ts b/apps/web/lib/openapi/qr/index.ts index 30134c09911..65de0a016a5 100644 --- a/apps/web/lib/openapi/qr/index.ts +++ b/apps/web/lib/openapi/qr/index.ts @@ -19,6 +19,9 @@ export const getQRCode: ZodOpenApiOperationObject = { "image/png": { schema: z.string(), }, + "image/svg+xml": { + schema: z.string(), + }, }, }, ...openApiErrorResponses, diff --git a/apps/web/lib/qr/api.tsx b/apps/web/lib/qr/api.tsx index 0d3312f182f..04f60c47419 100644 --- a/apps/web/lib/qr/api.tsx +++ b/apps/web/lib/qr/api.tsx @@ -1,6 +1,5 @@ import qrcodegen from "./codegen"; import { - DEFAULT_BGCOLOR, DEFAULT_FGCOLOR, DEFAULT_LEVEL, DEFAULT_MARGIN, @@ -10,21 +9,25 @@ import { import { QRPropsSVG } from "./types"; import { excavateModules, generatePath, getImageSettings } from "./utils"; -export async function getQRAsSVG(props: QRPropsSVG) { +export async function getQRAsSVG(props: QRPropsSVG): Promise { const { value, size = DEFAULT_SIZE, level = DEFAULT_LEVEL, - bgColor = DEFAULT_BGCOLOR, + bgColor, fgColor = DEFAULT_FGCOLOR, margin = DEFAULT_MARGIN, imageSettings, - ...otherProps } = props; + const shouldUseHigherErrorLevel = + imageSettings?.excavate && (level === "L" || level === "M"); + + const effectiveLevel = shouldUseHigherErrorLevel ? "Q" : level; + let cells = qrcodegen.QrCode.encodeText( value, - ERROR_LEVEL_MAP[level], + ERROR_LEVEL_MAP[effectiveLevel], ).getModules(); const numCells = cells.length + margin * 2; @@ -35,51 +38,49 @@ export async function getQRAsSVG(props: QRPropsSVG) { imageSettings, ); - let image = <>; + let image = ""; if (imageSettings != null && calculatedImageSettings != null) { - if (calculatedImageSettings.excavation != null) { - cells = excavateModules(cells, calculatedImageSettings.excavation); - } + try { + const base64Image = await fetch( + `https://wsrv.nl/?url=${encodeURIComponent(imageSettings.src)}&w=100&h=100&encoding=base64`, + { signal: AbortSignal.timeout(5_000) }, + ).then((res) => { + if (!res.ok) { + throw new Error(`Failed to fetch logo: ${res.status}`); + } + return res.text(); + }); - const base64Image = await fetch( - `https://wsrv.nl/?url=${imageSettings.src}&w=100&h=100&encoding=base64`, - ).then((res) => res.text()); + if (calculatedImageSettings.excavation != null) { + cells = excavateModules(cells, calculatedImageSettings.excavation); + } - image = ( - - ); + image = [ + `', + ].join(" "); + } catch { + // Omit logo if embedding fails; still return a valid QR SVG. + } } // Drawing strategy: instead of a rect per module, we're going to create a - // single path for the dark modules and layer that on top of a light rect, - // for a total of 2 DOM nodes. We pay a bit more in string concat but that's - // way faster than DOM ops. - // For level 1, 441 nodes -> 2 - // For level 40, 31329 -> 2 + // single path for the dark modules. Background is only painted when bgColor + // is set — otherwise the SVG stays transparent. const fgPath = generatePath(cells, margin); + const bgPath = bgColor + ? `` + : ""; - return ( - - - - {image} - - ); + return [ + ``, + bgPath, + ``, + image, + "", + ].join(""); } diff --git a/apps/web/lib/qr/index.tsx b/apps/web/lib/qr/index.tsx index 9b753ed749f..e2c3af9680a 100644 --- a/apps/web/lib/qr/index.tsx +++ b/apps/web/lib/qr/index.tsx @@ -386,9 +386,9 @@ export async function getQRAsSVGDataUri(props: QRProps) { ) .join(""); + // Omit the background fill so downloaded SVGs have a transparent background. const svgData = [ ``, - ``, ``, finderSVG, image, diff --git a/apps/web/lib/zod/schemas/qr.ts b/apps/web/lib/zod/schemas/qr.ts index 1e8180080dc..7f21827cd1d 100644 --- a/apps/web/lib/zod/schemas/qr.ts +++ b/apps/web/lib/zod/schemas/qr.ts @@ -1,9 +1,4 @@ -import { - DEFAULT_BGCOLOR, - DEFAULT_FGCOLOR, - DEFAULT_MARGIN, - QR_LEVELS, -} from "@/lib/qr/constants"; +import { DEFAULT_FGCOLOR, DEFAULT_MARGIN, QR_LEVELS } from "@/lib/qr/constants"; import * as z from "zod/v4"; import { booleanQuerySchema } from "./misc"; import { parseUrlSchema } from "./utils"; @@ -40,9 +35,8 @@ export const getQRCodeQuerySchema = z.object({ bgColor: z .string() .optional() - .default(DEFAULT_BGCOLOR) .describe( - "The background color of the QR code in hex format. Defaults to `#ffffff` if not provided.", + "The background color of the QR code in hex format. Defaults to `#ffffff` for `png`. When omitted for `svg`, the background is transparent.", ), hideLogo: booleanQuerySchema .optional() @@ -64,4 +58,9 @@ export const getQRCodeQuerySchema = z.object({ "DEPRECATED: Margin is included by default. Use the `margin` prop to customize the margin size.", ) .meta({ deprecated: true }), + format: z + .enum(["png", "svg"]) + .optional() + .default("png") + .describe("The format of the QR code. Defaults to `png` if not provided."), });