diff --git a/docs/superpowers/specs/2026-07-31-image-stamp-design.md b/docs/superpowers/specs/2026-07-31-image-stamp-design.md new file mode 100644 index 0000000..9bebcb4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-image-stamp-design.md @@ -0,0 +1,114 @@ +# Image Stamp Tool — Design + +**Date:** 2026-07-31 +**Tool:** Image → Stamp (`/tools/image-stamp`) — NEW +**Type:** New tool +**Icon:** `Stamp` (lucide-react) + +## Problem + +Users want to slap a document-status **stamp** onto an image — the classic bordered +"rubber stamp" mark (CONFIDENTIAL, PAID, …), not a subtle repeated watermark. It should +be customizable: text, bold, italic, font family, color, placement, and an optional +border box. + +This is distinct from the existing **Watermark** tool (subtle, tiled/diagonal, protective +overlay). A stamp is a single bold status mark. + +## Goal + +A new client-side tool that composites a rubber-stamp mark onto an uploaded/pasted image +and returns it via the shared `ImageResult` (Download / Copy / Edit in Annotator). + +## Design + +### Files + +- `src/tools/image/stamp.lib.ts` — pure logic (helpers + geometry + `stampImage`). +- `src/tools/image/stamp.lib.test.ts` — unit tests for the pure helpers/geometry. +- `src/islands/image/ImageStamp.tsx` — thin island (default export). +- `src/registry/tools.ts` — register `image-stamp` (Image, `Stamp` icon, `status: 'beta'`). + +### Controls (island) + +- **Dropzone** + paste (`usePasteImage`) — same pattern as Watermark. +- **Preset chips** — clicking one fills the text and sets a sensible default color; text + stays editable. Presets: + `Confidential` (red), `Paid` (green), `Draft` (gray), `Approved` (green), `Void` (red), + `Urgent` (red), `Copy` (blue), `Original` (blue), `Sample` (orange), `For Review` (orange). +- **Text** input (free text). +- **Font family** dropdown: Sans / Serif / Mono / Condensed. +- **Bold** toggle, **Italic** toggle. +- **Color** picker. +- **Border box** toggle (default ON) — the bordered rubber-stamp look; off = plain text. +- **Placement**: Center (diagonal) / Top-left / Top-right / Bottom-left / Bottom-right. +- **Scale** slider (1–100%) and **Opacity** slider (1–100%, default 85%). +- **Apply stamp** / **Clear** buttons → `ImageResult`. + +### Library API (`stamp.lib.ts`) + +```ts +export type StampFont = 'sans' | 'serif' | 'mono' | 'condensed'; +export type StampPlacement = 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +export interface StampOptions { + text: string; + color: string; // hex + bold: boolean; + italic: boolean; + font: StampFont; + bordered: boolean; + placement: StampPlacement; + scale: number; // 1–100 percent + opacity: number; // 1–100 percent +} + +// Pure, unit-tested: +export const STAMP_PRESETS: { label: string; color: string }[]; +export function fontStackFor(font: StampFont): string; +export function stampFontScale(percent: number): number; // 1/16..1/3, clamped +export interface StampGeometry { cx: number; cy: number; boxW: number; boxH: number; rotation: number; } +export function stampGeometry(args: { + canvasW: number; canvasH: number; textW: number; fontSize: number; placement: StampPlacement; +}): StampGeometry; + +// Canvas draw (build + manual smoke, like watermarkImage): +export function stampImage(file: File, options: StampOptions): Promise; +``` + +**Geometry rules** (`stampGeometry`, pure): +- `padding = fontSize * 0.4`; `boxW = textW + padding*2`; `boxH = fontSize + padding*2`. +- `center`: `cx=W/2, cy=H/2, rotation = -20°` (radians). This is the classic diagonal look. +- corners: `margin = fontSize * 0.6`; box centered `margin` in from the chosen corner; + `rotation = 0`. + +**`stampImage` draw** (in canvas): +1. `createImageBitmap(file)` → draw onto a canvas of the same size. +2. Compute `fontSize = max(14, round(min(W,H) * stampFontScale(scale)))`, set + `ctx.font = \`${italic?'italic ':''}${bold?'bold ':''}${fontSize}px ${fontStackFor(font)}\``. +3. `textW = ctx.measureText(text).width`; `g = stampGeometry(...)`. +4. `ctx.globalAlpha = opacity/100`; translate to `(g.cx,g.cy)`, rotate `g.rotation`. +5. If `bordered`: stroke a rounded-rect of `g.boxW × g.boxH` centered at origin, with + `lineWidth = max(2, fontSize*0.1)`, same color. +6. Draw text centered (`textAlign='center'`, `textBaseline='middle'`) in `color`. +7. Restore alpha; `encodeCanvas` preserving the input format (`keepFormat`). + +Reuse `keepFormat`, `encodeCanvas`, `ProcessedImage` from `canvas.lib.ts`. + +## Testing (`stamp.lib.test.ts`, jsdom — no real canvas) + +- `fontStackFor` returns the right stack for each family (e.g. `mono` → contains `monospace`). +- `stampFontScale(1)` ≈ 1/16; `(100)` === 1/3; monotonic; clamps `[1,100]`. +- `STAMP_PRESETS` includes all 10 labels; each has a valid `#hex` color. +- `stampGeometry`: + - `center` → `cx=W/2, cy=H/2`, rotation ≈ `-Math.PI/9` (-20°). + - each corner → correct `cx/cy` given margin & box size, rotation `0`. + - `boxW/boxH` derived from `textW/fontSize` + padding. + +`stampImage` and the island: build + manual smoke (upload → stamp → download). + +## Out of scope + +- Image-based/logo stamps (text only for now). +- Multiple stamps at once. +- Per-preset font/rotation presets (all presets share the same style controls). diff --git a/src/islands/image/ImageStamp.tsx b/src/islands/image/ImageStamp.tsx new file mode 100644 index 0000000..65f0931 --- /dev/null +++ b/src/islands/image/ImageStamp.tsx @@ -0,0 +1,234 @@ +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 { + stampImage, + STAMP_PRESETS, + type StampFont, + type StampPlacement, +} from '@/tools/image/stamp.lib'; +import { usePasteImage } from '@/hooks/usePasteImage'; + +const FONTS: { value: StampFont; label: string }[] = [ + { value: 'sans', label: 'Sans' }, + { value: 'serif', label: 'Serif' }, + { value: 'mono', label: 'Mono' }, + { value: 'condensed', label: 'Condensed' }, +]; + +const PLACEMENTS: { value: StampPlacement; label: string }[] = [ + { value: 'center', label: 'Center' }, + { 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 ImageStamp() { + const [file, setFile] = useState(null); + const [text, setText] = useState('CONFIDENTIAL'); + const [color, setColor] = useState('#c0392b'); + const [font, setFont] = useState('sans'); + const [bold, setBold] = useState(true); + const [italic, setItalic] = useState(false); + const [bordered, setBordered] = useState(true); + const [placement, setPlacement] = useState('center'); + const [scale, setScale] = useState(40); + const [opacity, setOpacity] = useState(85); + 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 applyPreset = (label: string, presetColor: string) => { + setText(label.toUpperCase()); + setColor(presetColor); + setResult(null); + }; + + const outName = file + ? file.name.replace(/\.[^.]+$/, '') + '-stamped.' + keepFormat(file.type).ext + : 'stamped.png'; + + const run = async () => { + if (!file || !text.trim()) return; + setBusy(true); + setError(''); + setResult(null); + try { + const { blob } = await stampImage(file, { + text: text.trim(), + color, + bold, + italic, + font, + bordered, + placement, + scale, + opacity, + }); + setResult(blob); + } catch (e) { + setError(e instanceof Error ? e.message : 'Stamp failed'); + } finally { + setBusy(false); + } + }; + + return ( +
+ +
+

Drop an image or click to browse

+

Stamp a status mark onto an image · or paste (⌘V)

+
+
+ + {file &&

{file.name}

} + +
+ + Presets + +
+ {STAMP_PRESETS.map(p => ( + + ))} +
+
+ + + +
+ + Placement + +
+ {PLACEMENTS.map(({ value, label }) => ( + + ))} +
+
+ +
+
+ + Font + +
+ {FONTS.map(({ value, label }) => ( + + ))} +
+
+ +
+ + Style + +
+ + + +
+
+ + +
+ +
+ + +
+ +
+ + +
+ + {error && {error}} + {result && } +
+ ); +} diff --git a/src/registry/tools.ts b/src/registry/tools.ts index 862ead6..912babb 100644 --- a/src/registry/tools.ts +++ b/src/registry/tools.ts @@ -421,6 +421,17 @@ export const tools: ToolDef[] = [ load: () => import('@/islands/image/ImageWatermark'), status: 'stable' }, + { + id: 'image-stamp', + name: 'Image Stamp', + category: 'Image', + route: '/tools/image-stamp', + keywords: ['image', 'stamp', 'confidential', 'paid', 'draft', 'approved', 'rubber stamp', 'status', 'mark'], + icon: Stamp, + summary: 'Stamp CONFIDENTIAL, PAID and other status marks onto an image', + load: () => import('@/islands/image/ImageStamp'), + status: 'beta' + }, { id: 'image-merge', name: 'Merge Images', diff --git a/src/tools/image/canvas.lib.ts b/src/tools/image/canvas.lib.ts index 5468164..4bb7b53 100644 --- a/src/tools/image/canvas.lib.ts +++ b/src/tools/image/canvas.lib.ts @@ -47,7 +47,7 @@ export function keepFormat(type: string): { mime: string; ext: string; quality?: return { mime: 'image/png', ext: 'png' }; } -async function encodeCanvas( +export async function encodeCanvas( canvas: HTMLCanvasElement, mimeType: string, quality?: number diff --git a/src/tools/image/stamp.lib.test.ts b/src/tools/image/stamp.lib.test.ts new file mode 100644 index 0000000..006e55e --- /dev/null +++ b/src/tools/image/stamp.lib.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from 'vitest'; +import { + STAMP_PRESETS, + fontStackFor, + stampFontScale, + stampGeometry, +} from './stamp.lib'; + +describe('fontStackFor', () => { + it('maps each family to a stack containing the expected generic', () => { + expect(fontStackFor('sans')).toMatch(/sans-serif/); + expect(fontStackFor('serif')).toMatch(/serif/); + expect(fontStackFor('mono')).toMatch(/monospace/); + expect(fontStackFor('condensed').toLowerCase()).toMatch(/narrow|condensed/); + }); +}); + +describe('stampFontScale', () => { + it('maps 1% to ~1/16 and 100% to 1/3', () => { + expect(stampFontScale(1)).toBeCloseTo(1 / 16, 5); + expect(stampFontScale(100)).toBeCloseTo(1 / 3, 5); + }); + it('is monotonic and clamps out of range', () => { + expect(stampFontScale(20)).toBeLessThan(stampFontScale(80)); + expect(stampFontScale(0)).toBe(stampFontScale(1)); + expect(stampFontScale(200)).toBe(stampFontScale(100)); + }); +}); + +describe('STAMP_PRESETS', () => { + it('ships the ten labels, each with a valid hex color', () => { + const labels = STAMP_PRESETS.map(p => p.label); + for (const l of ['Confidential', 'Paid', 'Draft', 'Approved', 'Void', 'Urgent', 'Copy', 'Original', 'Sample', 'For Review']) { + expect(labels).toContain(l); + } + for (const p of STAMP_PRESETS) { + expect(p.color).toMatch(/^#[0-9a-fA-F]{6}$/); + } + }); +}); + +describe('stampGeometry', () => { + const base = { canvasW: 1000, canvasH: 800, textW: 200, fontSize: 100 }; + const padding = 100 * 0.4; + const boxW = 200 + padding * 2; // 280 + const boxH = 100 + padding * 2; // 180 + + it('centers with a -20deg rotation for the center placement', () => { + const g = stampGeometry({ ...base, placement: 'center' }); + expect(g.cx).toBe(500); + expect(g.cy).toBe(400); + expect(g.rotation).toBeCloseTo(-Math.PI / 9, 5); // -20deg + expect(g.boxW).toBeCloseTo(boxW, 5); + expect(g.boxH).toBeCloseTo(boxH, 5); + }); + + it('places corners inset by the margin, upright (no rotation)', () => { + const margin = 100 * 0.6; // 60 + const tl = stampGeometry({ ...base, placement: 'top-left' }); + expect(tl.cx).toBeCloseTo(margin + boxW / 2, 5); + expect(tl.cy).toBeCloseTo(margin + boxH / 2, 5); + expect(tl.rotation).toBe(0); + + const br = stampGeometry({ ...base, placement: 'bottom-right' }); + expect(br.cx).toBeCloseTo(1000 - margin - boxW / 2, 5); + expect(br.cy).toBeCloseTo(800 - margin - boxH / 2, 5); + + const tr = stampGeometry({ ...base, placement: 'top-right' }); + expect(tr.cx).toBeCloseTo(1000 - margin - boxW / 2, 5); + expect(tr.cy).toBeCloseTo(margin + boxH / 2, 5); + + const bl = stampGeometry({ ...base, placement: 'bottom-left' }); + expect(bl.cx).toBeCloseTo(margin + boxW / 2, 5); + expect(bl.cy).toBeCloseTo(800 - margin - boxH / 2, 5); + }); +}); diff --git a/src/tools/image/stamp.lib.ts b/src/tools/image/stamp.lib.ts new file mode 100644 index 0000000..111ddf5 --- /dev/null +++ b/src/tools/image/stamp.lib.ts @@ -0,0 +1,162 @@ +import { keepFormat, encodeCanvas, type ProcessedImage } from './canvas.lib'; + +export type StampFont = 'sans' | 'serif' | 'mono' | 'condensed'; +export type StampPlacement = 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + +export interface StampOptions { + text: string; + color: string; // hex, e.g. '#c0392b' + bold: boolean; + italic: boolean; + font: StampFont; + bordered: boolean; + placement: StampPlacement; + scale: number; // 1–100 percent + opacity: number; // 1–100 percent +} + +/** Document-status presets. Clicking one fills the text + a sensible default color. */ +export const STAMP_PRESETS: { label: string; color: string }[] = [ + { label: 'Confidential', color: '#c0392b' }, + { label: 'Paid', color: '#1e8449' }, + { label: 'Draft', color: '#616161' }, + { label: 'Approved', color: '#1e8449' }, + { label: 'Void', color: '#c0392b' }, + { label: 'Urgent', color: '#c0392b' }, + { label: 'Copy', color: '#2471a3' }, + { label: 'Original', color: '#2471a3' }, + { label: 'Sample', color: '#b9770e' }, + { label: 'For Review', color: '#b9770e' }, +]; + +const FONT_STACKS: Record = { + sans: 'Arial, Helvetica, sans-serif', + serif: 'Georgia, "Times New Roman", serif', + mono: '"Courier New", monospace', + condensed: '"Arial Narrow", "Roboto Condensed", sans-serif', +}; + +export function fontStackFor(font: StampFont): string { + return FONT_STACKS[font] ?? FONT_STACKS.sans; +} + +/** + * Map a user-facing Scale percent (1–100) to a `fontScale` fraction of the + * image's shorter side. Stamps read larger than watermarks: 1/16 (small) → 1/3 (big). + */ +export function stampFontScale(percent: number): number { + const MIN_FS = 1 / 16; + const MAX_FS = 1 / 3; + const clamped = Math.min(100, Math.max(1, percent)); + return MIN_FS + ((clamped - 1) / 99) * (MAX_FS - MIN_FS); +} + +export interface StampGeometry { + cx: number; + cy: number; + boxW: number; + boxH: number; + rotation: number; // radians +} + +/** + * Compute where the stamp box sits and how it's rotated. Pure — no canvas. + * Center placement is rotated -20° (the classic diagonal rubber-stamp look); + * corners sit upright, inset from the edge by a margin. + */ +export function stampGeometry(args: { + canvasW: number; + canvasH: number; + textW: number; + fontSize: number; + placement: StampPlacement; +}): StampGeometry { + const { canvasW, canvasH, textW, fontSize, placement } = args; + const padding = fontSize * 0.4; + const boxW = textW + padding * 2; + const boxH = fontSize + padding * 2; + + if (placement === 'center') { + return { cx: canvasW / 2, cy: canvasH / 2, boxW, boxH, rotation: -Math.PI / 9 }; + } + + const margin = fontSize * 0.6; + const left = margin + boxW / 2; + const right = canvasW - margin - boxW / 2; + const top = margin + boxH / 2; + const bottom = canvasH - margin - boxH / 2; + const cx = placement === 'top-left' || placement === 'bottom-left' ? left : right; + const cy = placement === 'top-left' || placement === 'top-right' ? top : bottom; + return { cx, cy, boxW, boxH, rotation: 0 }; +} + +/** Stroke a centered rounded rectangle at the current canvas origin. */ +function strokeRoundedRect( + ctx: CanvasRenderingContext2D, + w: number, + h: number, + radius: number +): void { + const x = -w / 2; + const y = -h / 2; + 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.stroke(); +} + +/** Composite a rubber-stamp mark onto an image, preserving its format. */ +export async function stampImage(file: File, options: StampOptions): Promise { + const text = options.text.trim(); + if (!text) throw new Error('Enter stamp text'); + + 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 fontSize = Math.max(14, Math.round(Math.min(width, height) * stampFontScale(options.scale))); + const style = `${options.italic ? 'italic ' : ''}${options.bold ? 'bold ' : ''}`; + ctx.font = `${style}${fontSize}px ${fontStackFor(options.font)}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + + const textW = ctx.measureText(text).width; + const g = stampGeometry({ canvasW: width, canvasH: height, textW, fontSize, placement: options.placement }); + + ctx.save(); + ctx.globalAlpha = Math.min(1, Math.max(0.01, options.opacity / 100)); + ctx.translate(g.cx, g.cy); + ctx.rotate(g.rotation); + + if (options.bordered) { + ctx.strokeStyle = options.color; + ctx.lineWidth = Math.max(2, fontSize * 0.1); + strokeRoundedRect(ctx, g.boxW, g.boxH, fontSize * 0.25); + } + ctx.fillStyle = options.color; + ctx.fillText(text, 0, 0); + ctx.restore(); + + const { mime, quality } = keepFormat(file.type); + const blob = await encodeCanvas(canvas, mime, quality); + return { blob, width, height }; +}