From ab5a53e8b83065208b3fd1c0f366b6f99df06e2c Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 1 Aug 2026 00:16:23 +0700 Subject: [PATCH 1/2] docs(qr): add-QR-to-image tool design spec Co-Authored-By: Claude Opus 4.8 (1M context) --- .../specs/2026-08-01-image-qr-design.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-01-image-qr-design.md diff --git a/docs/superpowers/specs/2026-08-01-image-qr-design.md b/docs/superpowers/specs/2026-08-01-image-qr-design.md new file mode 100644 index 0000000..59e2f3b --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-image-qr-design.md @@ -0,0 +1,98 @@ +# Add QR to Image Tool — Design + +**Date:** 2026-08-01 +**Tool:** Image → Add QR to Image (`/tools/image-qr`) — NEW +**Type:** New tool +**Icon:** `QrCode` (lucide-react) + +## Problem + +Users want to overlay a QR code (encoding text/URL they type) onto an existing image, in +a chosen corner — e.g. dropping a link QR onto a poster, flyer, or product photo. + +This is distinct from the existing **QR Generator** (`qr-gen`), which only produces a +standalone QR. Here the QR is composited **onto an uploaded image**. + +## Goal + +A client-side tool that renders a QR from typed content and composites it onto an +uploaded/pasted image at a chosen corner, then returns it via `ImageResult`. + +Deps: `qrcode` is already installed — no new dependencies. + +## Design + +### Files + +- `src/tools/image/qr-overlay.lib.ts` — pure geometry/sizing + the `overlayQr` compositor. +- `src/tools/image/qr-overlay.lib.test.ts` — unit tests for the pure helpers. +- `src/islands/image/ImageQr.tsx` — thin island (default export). +- `src/registry/tools.ts` — register `image-qr` (Image, `QrCode` icon, `status: 'beta'`). + +### Controls (island) + +- **Dropzone** + paste (`usePasteImage`). +- **QR content** — text/URL input (default `https://goodwebtools.com`). +- **Corner** — Top-left / Top-right / Bottom-left / Bottom-right (default bottom-right). +- **Size** slider (1–100%, default 18) — QR size as a fraction of the image's shorter side. +- **White card** toggle (default ON) — padded white rounded card behind the QR for a + reliable quiet zone; off = draw the QR (with its own white background) directly. +- **Add QR** / **Clear** → `ImageResult` (Download / Copy / Edit in Annotator). + +### Library API (`qr-overlay.lib.ts`) + +```ts +export type QrCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +export interface QrOverlayOptions { + content: string; + corner: QrCorner; + sizePercent: number; // 1–100, fraction of the shorter side + card: boolean; // white rounded backing card +} + +// Pure, unit-tested: +export function qrPixelSize(sizePercent: number, shorterSide: number): number; // clamps, min 64 +export interface QrPlacement { x: number; y: number; } +export function qrCardPlacement(args: { + canvasW: number; canvasH: number; boxSize: number; margin: number; corner: QrCorner; +}): QrPlacement; // top-left of the box for the chosen corner + +// Canvas draw (build + manual smoke): +export function overlayQr(file: File, options: QrOverlayOptions): Promise; +``` + +**Sizing** (`qrPixelSize`): `size = clamp(round(shorterSide * clampedPct/100), 64, shorterSide)`. +Minimum 64px so the QR stays scannable on small images. + +**Placement** (`qrCardPlacement`, pure): given the outer box size (card or bare QR) and a +`margin`, return the box's top-left `(x, y)`: +- top-left: `(margin, margin)` +- top-right: `(W - margin - boxSize, margin)` +- bottom-left: `(margin, H - margin - boxSize)` +- bottom-right: `(W - margin - boxSize, H - margin - boxSize)` + +**`overlayQr` draw**: +1. `createImageBitmap(file)` → draw onto a same-size canvas. +2. `qrSize = qrPixelSize(sizePercent, min(W,H))`. +3. Render the QR to an offscreen canvas: `await QRCode.toCanvas(qrCanvas, content, { width: qrSize, margin: 1, errorCorrectionLevel: 'M' })`. (Throws on empty/too-long content → surfaced as an error.) +4. `margin = round(min(W,H) * 0.03)`. +5. If `card`: `pad = round(qrSize * 0.12)`, `boxSize = qrSize + pad*2`, `pos = qrCardPlacement({..., boxSize, margin, corner})`. Draw a white rounded rect (`radius = pad`) at `pos`, then draw the QR canvas at `(pos.x + pad, pos.y + pad)`. +6. Else: `boxSize = qrSize`, `pos = qrCardPlacement({..., boxSize, margin, corner})`, draw the QR canvas at `pos`. +7. `encodeCanvas` preserving the input format (`keepFormat`). + +Reuse `keepFormat`, `encodeCanvas`, `ProcessedImage` from `canvas.lib.ts`. + +## Testing (`qr-overlay.lib.test.ts`, jsdom — no real canvas) + +- `qrPixelSize`: 18% of 1000 → 180; clamps min to 64 (e.g. 1% of 1000 → 64); clamps percent to [1,100]; never exceeds the shorter side. +- `qrCardPlacement`: each corner returns the correct `(x,y)` for `W=1000,H=800,boxSize=200,margin=30`. + +`overlayQr` + island: build + manual smoke (upload → content → corner → Add QR → scan the result). + +## Out of scope + +- QR color customization (kept black-on-white for scannability). +- Logo-in-QR / styled QR. +- Multiple QRs at once. +- Reading/decoding QR (that's the existing `qr-read` tool). From 993a30eb821fd5221befdff44538338190876c13 Mon Sep 17 00:00:00 2001 From: Kresna Date: Sat, 1 Aug 2026 00:18:52 +0700 Subject: [PATCH 2/2] feat(image-qr): new Add QR to Image tool Overlay a QR code (encoding typed text/URL) onto a corner of an uploaded image. Corner picker (4 corners), size slider (% of shorter side), and an optional white rounded backing card (default on) for a reliable quiet zone. Pure tested logic in qr-overlay.lib.ts (qrPixelSize, qrCardPlacement); overlayQr renders the QR via the existing qrcode dep and composites it, reusing keepFormat/encodeCanvas. Thin island + registry entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/islands/image/ImageQr.tsx | 137 +++++++++++++++++++++++++ src/registry/tools.ts | 11 ++ src/tools/image/qr-overlay.lib.test.ts | 29 ++++++ src/tools/image/qr-overlay.lib.ts | 115 +++++++++++++++++++++ 4 files changed, 292 insertions(+) create mode 100644 src/islands/image/ImageQr.tsx create mode 100644 src/tools/image/qr-overlay.lib.test.ts create mode 100644 src/tools/image/qr-overlay.lib.ts diff --git a/src/islands/image/ImageQr.tsx b/src/islands/image/ImageQr.tsx new file mode 100644 index 0000000..56c64b1 --- /dev/null +++ b/src/islands/image/ImageQr.tsx @@ -0,0 +1,137 @@ +import { useState } from 'react'; +import { Dropzone } from '@/components/ui/Dropzone'; +import { Button } from '@/components/ui/Button'; +import { Alert } from '@/components/ui/Alert'; +import { ImageResult } from '@/components/ui/ImageResult'; +import { keepFormat } from '@/tools/image/canvas.lib'; +import { overlayQr, type QrCorner } from '@/tools/image/qr-overlay.lib'; +import { usePasteImage } from '@/hooks/usePasteImage'; + +const CORNERS: { value: QrCorner; label: string }[] = [ + { value: 'top-left', label: 'Top left' }, + { value: 'top-right', label: 'Top right' }, + { value: 'bottom-left', label: 'Bottom left' }, + { value: 'bottom-right', label: 'Bottom right' }, +]; + +export default function ImageQr() { + const [file, setFile] = useState(null); + const [content, setContent] = useState('https://goodwebtools.com'); + const [corner, setCorner] = useState('bottom-right'); + const [sizePercent, setSizePercent] = useState(18); + const [card, setCard] = useState(true); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + + const onDrop = (files: File[]) => { + setFile(files.find(f => f.type.startsWith('image/')) ?? null); + setResult(null); + setError(''); + }; + + usePasteImage(f => onDrop([f])); + + const outName = file + ? file.name.replace(/\.[^.]+$/, '') + '-qr.' + keepFormat(file.type).ext + : 'image-qr.png'; + + const run = async () => { + if (!file || !content.trim()) return; + setBusy(true); + setError(''); + setResult(null); + try { + const { blob } = await overlayQr(file, { + content: content.trim(), + corner, + sizePercent, + card, + }); + setResult(blob); + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not add the QR code'); + } finally { + setBusy(false); + } + }; + + return ( +
+ +
+

Drop an image or click to browse

+

Add a QR code to a corner of an image · or paste (⌘V)

+
+
+ + {file &&

{file.name}

} + + + +
+ + Corner + +
+ {CORNERS.map(({ value, label }) => ( + + ))} +
+
+ +
+ +
+ + Backing + + +
+
+ +
+ + +
+ + {error && {error}} + {result && } +
+ ); +} diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 912babb..8d13e93 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -432,6 +432,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/image/ImageStamp'), status: 'beta' }, + { + id: 'image-qr', + name: 'Add QR to Image', + category: 'Image', + route: '/tools/image-qr', + keywords: ['image', 'qr', 'qrcode', 'qr code', 'overlay', 'corner', 'url', 'link', 'add'], + icon: QrCode, + summary: 'Overlay a QR code onto a corner of an image', + load: () => import('@/islands/image/ImageQr'), + status: 'beta' + }, { id: 'image-merge', name: 'Merge Images', diff --git a/src/tools/image/qr-overlay.lib.test.ts b/src/tools/image/qr-overlay.lib.test.ts new file mode 100644 index 0000000..80ad672 --- /dev/null +++ b/src/tools/image/qr-overlay.lib.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from 'vitest'; +import { qrPixelSize, qrCardPlacement } from './qr-overlay.lib'; + +describe('qrPixelSize', () => { + it('sizes the QR as a percent of the shorter side', () => { + expect(qrPixelSize(18, 1000)).toBe(180); + expect(qrPixelSize(50, 800)).toBe(400); + }); + it('never drops below the 64px scannable floor', () => { + expect(qrPixelSize(1, 1000)).toBe(64); + expect(qrPixelSize(5, 500)).toBe(64); // 25 -> floored to 64 + }); + it('never exceeds the shorter side and clamps the percent to [1,100]', () => { + expect(qrPixelSize(100, 300)).toBe(300); + expect(qrPixelSize(150, 300)).toBe(300); + expect(qrPixelSize(0, 1000)).toBe(qrPixelSize(1, 1000)); + }); +}); + +describe('qrCardPlacement', () => { + const base = { canvasW: 1000, canvasH: 800, boxSize: 200, margin: 30 }; + + it('places the box in each corner inset by the margin', () => { + expect(qrCardPlacement({ ...base, corner: 'top-left' })).toEqual({ x: 30, y: 30 }); + expect(qrCardPlacement({ ...base, corner: 'top-right' })).toEqual({ x: 770, y: 30 }); + expect(qrCardPlacement({ ...base, corner: 'bottom-left' })).toEqual({ x: 30, y: 570 }); + expect(qrCardPlacement({ ...base, corner: 'bottom-right' })).toEqual({ x: 770, y: 570 }); + }); +}); diff --git a/src/tools/image/qr-overlay.lib.ts b/src/tools/image/qr-overlay.lib.ts new file mode 100644 index 0000000..44d5615 --- /dev/null +++ b/src/tools/image/qr-overlay.lib.ts @@ -0,0 +1,115 @@ +import QRCode from 'qrcode'; +import { keepFormat, encodeCanvas, type ProcessedImage } from './canvas.lib'; + +export type QrCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +export interface QrOverlayOptions { + content: string; + corner: QrCorner; + sizePercent: number; // 1–100, fraction of the shorter side + card: boolean; // white rounded backing card +} + +const MIN_QR_PX = 64; // scannable floor + +/** QR pixel size as a percent of the image's shorter side, clamped to a scannable range. */ +export function qrPixelSize(sizePercent: number, shorterSide: number): number { + const pct = Math.min(100, Math.max(1, sizePercent)); + const raw = Math.round((shorterSide * pct) / 100); + return Math.min(shorterSide, Math.max(MIN_QR_PX, raw)); +} + +export interface QrPlacement { + x: number; + y: number; +} + +/** Top-left corner of the QR box (card or bare QR), inset from the chosen corner by margin. */ +export function qrCardPlacement(args: { + canvasW: number; + canvasH: number; + boxSize: number; + margin: number; + corner: QrCorner; +}): QrPlacement { + const { canvasW, canvasH, boxSize, margin, corner } = args; + const x = corner === 'top-left' || corner === 'bottom-left' ? margin : canvasW - margin - boxSize; + const y = corner === 'top-left' || corner === 'top-right' ? margin : canvasH - margin - boxSize; + return { x, y }; +} + +/** Fill a rounded rectangle. */ +function fillRoundedRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + radius: number +): void { + const r = Math.min(radius, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.arcTo(x + w, y, x + w, y + r, r); + ctx.lineTo(x + w, y + h - r); + ctx.arcTo(x + w, y + h, x + w - r, y + h, r); + ctx.lineTo(x + r, y + h); + ctx.arcTo(x, y + h, x, y + h - r, r); + ctx.lineTo(x, y + r); + ctx.arcTo(x, y, x + r, y, r); + ctx.closePath(); + ctx.fill(); +} + +/** Composite a QR code (encoding `content`) onto an image, preserving its format. */ +export async function overlayQr(file: File, options: QrOverlayOptions): Promise { + const content = options.content.trim(); + if (!content) throw new Error('Enter the QR content'); + + const bitmap = await createImageBitmap(file); + const width = bitmap.width; + const height = bitmap.height; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + bitmap.close?.(); + throw new Error('Canvas is not supported in this browser'); + } + ctx.drawImage(bitmap, 0, 0); + bitmap.close?.(); + + const qrSize = qrPixelSize(options.sizePercent, Math.min(width, height)); + + // Render the QR to an offscreen canvas. + const qrCanvas = document.createElement('canvas'); + try { + await QRCode.toCanvas(qrCanvas, content, { + width: qrSize, + margin: 1, + errorCorrectionLevel: 'M', + }); + } catch { + throw new Error('Text is too long for a QR code'); + } + + const margin = Math.round(Math.min(width, height) * 0.03); + + if (options.card) { + const pad = Math.round(qrSize * 0.12); + const boxSize = qrSize + pad * 2; + const pos = qrCardPlacement({ canvasW: width, canvasH: height, boxSize, margin, corner: options.corner }); + ctx.fillStyle = '#ffffff'; + fillRoundedRect(ctx, pos.x, pos.y, boxSize, boxSize, pad); + ctx.drawImage(qrCanvas, pos.x + pad, pos.y + pad, qrSize, qrSize); + } else { + const pos = qrCardPlacement({ canvasW: width, canvasH: height, boxSize: qrSize, margin, corner: options.corner }); + ctx.drawImage(qrCanvas, pos.x, pos.y, qrSize, qrSize); + } + + const { mime, quality } = keepFormat(file.type); + const blob = await encodeCanvas(canvas, mime, quality); + return { blob, width, height }; +}