Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions docs/superpowers/specs/2026-08-01-image-qr-design.md
Original file line number Diff line number Diff line change
@@ -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<ProcessedImage>;
```

**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).
137 changes: 137 additions & 0 deletions src/islands/image/ImageQr.tsx
Original file line number Diff line number Diff line change
@@ -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<File | null>(null);
const [content, setContent] = useState('https://goodwebtools.com');
const [corner, setCorner] = useState<QrCorner>('bottom-right');
const [sizePercent, setSizePercent] = useState(18);
const [card, setCard] = useState(true);
const [result, setResult] = useState<Blob | null>(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 (
<div className="space-y-4">
<Dropzone onDrop={onDrop} accept="image/*" multiple={false}>
<div className="space-y-1">
<p className="text-lg font-bold">Drop an image or click to browse</p>
<p className="text-sm text-muted-foreground">Add a QR code to a corner of an image · or paste (⌘V)</p>
</div>
</Dropzone>

{file && <p className="text-sm font-bold text-foreground">{file.name}</p>}

<label className="block space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">
QR content (text or URL)
</span>
<input
value={content}
onChange={e => setContent(e.target.value)}
className="w-full border-2 border-border bg-muted px-3 py-2 text-sm outline-none focus:shadow-brutal-sm"
/>
</label>

<div className="space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">
Corner
</span>
<div className="flex flex-wrap gap-2">
{CORNERS.map(({ value, label }) => (
<Button
key={value}
variant={corner === value ? 'primary' : 'secondary'}
aria-pressed={corner === value}
onClick={() => setCorner(value)}
>
{label}
</Button>
))}
</div>
</div>

<div className="flex flex-wrap items-end gap-6">
<label className="flex-1 space-y-1.5">
<span className="flex justify-between text-sm font-bold uppercase tracking-wide text-muted-foreground">
<span>Size</span>
<span>{sizePercent}%</span>
</span>
<input
type="range"
min={1}
max={100}
value={sizePercent}
onChange={e => setSizePercent(Number(e.target.value))}
className="w-full accent-accent"
/>
</label>
<div className="space-y-1.5">
<span className="block text-sm font-bold uppercase tracking-wide text-muted-foreground">
Backing
</span>
<Button variant={card ? 'primary' : 'secondary'} aria-pressed={card} onClick={() => setCard(c => !c)}>
White card
</Button>
</div>
</div>

<div className="flex flex-wrap gap-2">
<Button onClick={run} disabled={!file || !content.trim() || busy}>
{busy ? 'Adding…' : 'Add QR'}
</Button>
<Button variant="ghost" onClick={() => { setFile(null); setResult(null); setError(''); }}>
Clear
</Button>
</div>

{error && <Alert variant="error">{error}</Alert>}
{result && <ImageResult blob={result} filename={outName} />}
</div>
);
}
11 changes: 11 additions & 0 deletions src/registry/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
29 changes: 29 additions & 0 deletions src/tools/image/qr-overlay.lib.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
Loading
Loading