diff --git a/README.md b/README.md index 0a9c5b3..e61e461 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ A clean Progressive Web App for viewing, annotating, and signing PDFs — works - **Sign** by drawing on a pad; signatures are auto-cropped, saved to your device, and re-usable across PDFs - **Sign on your phone** — the pad can show a UNI·SIM QR + PIN; scan it, draw on your phone, enter the PIN, and the signature lands on the desktop ready to place (works from the desktop app too) - **Edit** placed annotations — drag to move, resize handles on shapes and signatures, double-click text to retype, change colour and size of selected text on the fly +- **Add a QR code** — the QR button in the toolbar generates one from any link or text, in six styles (square, rounded, dots, circle, star…), and drops it on the page like any image. Codes you've saved in [Universal QR](https://opensource.unisim.co.uk/qr) show up in the dialog ready to place, with nothing to sign into - **Export** the annotated PDF; all annotations and signatures are baked into the saved file - **Recents** are remembered locally so you can reopen a PDF with one tap, even offline - **Installable** PWA — add to home screen on phone or install on desktop, works offline after first load diff --git a/docs/README.md b/docs/README.md index ad1a35c..a5a99a0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -107,6 +107,66 @@ it is equally a point of no return for redactions. Any future surface that launches the dialog inherits the gate for free, which is the point of it living on the action rather than the launcher. +## QR codes (Add QR code) + +The **QR button** in the toolbar (desktop: beside the image button; mobile: +beside *Image*) opens a cut-down Universal QR — a link box and six style +presets — and drops the generated code onto the page. + +**It is an image annotation, not a new type.** "Add to page" renders a +1024 px PNG, hands it to `setUploadedImageSrc` and arms the existing `image` +tool, so the code is placed, moved, resized, undone and baked into the export +by machinery that already existed. Placed at the default ~200 pt that works out +around 360 dpi, so the code still scans off a printed page. + +### Sharing a design model with Universal QR + +`src/lib/qr/` is a port of Universal QR's renderer, kept deliberately faithful: + +| File | From | Notes | +|---|---|---| +| `design.ts` | its `lib/qr.ts` | `QrDesign` is a field-for-field copy of its `QrConfig`; the six presets are its `PRESETS` verbatim | +| `frames.ts` | its `lib/frames.ts` | shaped plates (circle/star/hexagon/…), canvas path only | +| `decor.ts` | its `lib/decor.ts` | the burst/scatter marks a shaped plate is filled with | +| `render.ts` | its `lib/compose.ts` | one canvas composite for plain and shaped codes alike | + +The *editor* is what is simplified, not the format — because a design imported +from Universal QR is restored whole, and a code that rendered differently in the +two apps would be the version of this feature nobody trusts. Verified by +rendering all six presets through both apps' pipelines and diffing the pixels: +**identical**, the sole delta being the centre mark's antialiasing (Universal QR +inlines a 256 px data URI of the icon; here the shipped `unisim-icon.png` is +downscaled by the browser). + +⚠️ The one rule the geometry keeps: **the code itself is never clipped** to a +shape. A silhouette is only ever the *plate* the code sits on — the code is +rendered smaller and centred in the largest square that fits. See `frames.ts`. + +### Your saved codes, with no backend + +Universal QR keeps designs in `localStorage` under `unisim.qr.designs.v1`, and +in production the two apps are the **same origin** — `opensource.unisim.co.uk/pdf` +and `/qr`, both behind the opensource-portal Worker — so that store is simply +readable from here. Open the dialog and the codes designed next door are already +listed; clicking one restores it whole (its link, colours, plate and any uploaded +logo). No account, no API, no round trip. + +`src/lib/qr/library.ts` is **read-only** by design: it is another app's store, +capped at 12 entries, and evicting someone's saved design because they added a +QR to a PDF would be a bad trade. The origin is also not guaranteed — +`pdf.unisim.co.uk` and the Electron build are separate origins with their own +empty storage — so the dialog also imports Universal QR's `.uniqr.json` backup, +which works anywhere. + +### Colours + +`qrContrastIssue` warns on an **inverted** code (light modules on dark — strict +decoders reject those outright) or a **low-contrast** one (right polarity, too +thin a ratio: it passes a desk test and fails in print). The six presets all +pass; the check exists for designs arriving from Universal QR's full studio, +because baking an unscannable code into an exported PDF is the failure nobody +notices until the poster is printed. + ## Suite context This repo is one part of the **Universal Simulation suite** (the open-source diff --git a/src/App.tsx b/src/App.tsx index 218088f..556897b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import OcrModal from './components/Ocr/OcrModal' import MergeDialog from './components/Convert/MergeDialog' import ConvertDialog from './components/Convert/ConvertDialog' import MetadataDialog from './components/Metadata/MetadataDialog' +import QrDialog from './components/Qr/QrDialog' import MobileWelcomeToast from './components/Onboarding/MobileWelcomeToast' import { UniversalAppsNavBar, UniversalBar, ChangelogMenu } from '@unisim/sdk' @@ -307,6 +308,7 @@ export default function App() { + {ocrOpen && sourceBytes && ( (o + v * s) * 32 + + const plate = + p.frameShape === 'circle' ? ( + + ) : p.frameShape === 'star' ? ( + { + const r = i % 2 === 0 ? 16 : 16 * 0.62 + const a = -Math.PI / 2 + (i * Math.PI) / 5 + return `${16 + r * Math.cos(a)},${16 + r * Math.sin(a)}` + }).join(' ')} + fill={p.bgColor} + /> + ) : ( + + ) + + // Module rounding follows the preset's dot style, so Classic reads square and + // Dots reads round at a glance. + const dotR = p.dotType === 'dots' ? 1.6 : p.dotType === 'square' ? 0 : 1 + const eyeR = p.cornerSquareType === 'square' ? 0 : p.cornerSquareType === 'dot' ? 4.5 : 2.5 + const MODULES = [ + [0.62, 0.62], [0.78, 0.62], [0.62, 0.78], [0.9, 0.78], [0.78, 0.9], + [0.46, 0.14], [0.46, 0.3], [0.46, 0.62], [0.14, 0.46], [0.3, 0.46], [0.62, 0.46], [0.9, 0.46] + ] + + return ( + + ) +} + +export default function QrDialog() { + const open = usePdfStore((s) => s.qrOpen) + const setOpen = usePdfStore((s) => s.setQrOpen) + const setUploadedImageSrc = useAnnotationStore((s) => s.setUploadedImageSrc) + const setTool = useAnnotationStore((s) => s.setTool) + + const [design, setDesign] = useState(DEFAULT_DESIGN) + const [presetName, setPresetName] = useState('Rounded') + const [preview, setPreview] = useState(null) + const [rendering, setRendering] = useState(false) + const [error, setError] = useState(null) + const [saved, setSaved] = useState([]) + const [adding, setAdding] = useState(false) + const backupInputRef = useRef(null) + + const data = design.data.trim() + const issue = data ? qrContrastIssue(design) : null + + // Fresh dialog every time, seeded with the app's default look and whatever + // Universal QR has saved on this device. + useEffect(() => { + if (!open) return + setDesign({ ...DEFAULT_DESIGN, ...(QR_PRESETS.find((p) => p.name === 'Rounded')?.patch ?? {}) }) + setPresetName('Rounded') + setPreview(null) + setError(null) + setAdding(false) + setSaved(loadSavedQrDesigns()) + }, [open]) + + useEffect(() => { + if (!open) return + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') setOpen(false) + } + document.addEventListener('keydown', onKey) + return () => document.removeEventListener('keydown', onKey) + }, [open, setOpen]) + + // Live preview, debounced. `cancelled` guards the async gap: a fast typist + // can start several renders, and without it the slowest one wins. + useEffect(() => { + if (!open || !data) { + setPreview(null) + setRendering(false) + return + } + let cancelled = false + setRendering(true) + const timer = window.setTimeout(() => { + renderQrPng(design, PREVIEW_SIZE * 2) + .then((png) => { + if (cancelled) return + setPreview(png) + setError(null) + }) + .catch((e: Error) => { + if (cancelled) return + setPreview(null) + setError(e.message || 'Could not draw that code.') + }) + .finally(() => { + if (!cancelled) setRendering(false) + }) + }, PREVIEW_DEBOUNCE_MS) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [open, data, design]) + + if (!open) return null + + function applyPreset(preset: QrPreset) { + setDesign((d) => ({ ...d, ...preset.patch })) + setPresetName(preset.name) + } + + // A saved design is restored whole — its own link, colours, plate and logo — + // so what lands on the page is the code the user designed next door, not an + // approximation of it. + function pickSaved(entry: SavedQrDesign) { + setDesign(entry.design) + setPresetName(null) + setError(null) + } + + async function onBackupFile(e: React.ChangeEvent) { + const file = e.target.files?.[0] + e.target.value = '' + if (!file) return + try { + const { design: imported } = await readQrBackupFile(file) + setDesign(imported) + setPresetName(null) + setError(null) + } catch (err) { + setError((err as Error).message) + } + } + + /** Render at placement resolution and arm it for click-to-place. */ + async function addToPage() { + if (!data) return + setAdding(true) + try { + const png = await renderQrPng(design, PLACEMENT_SIZE) + setUploadedImageSrc(png) + setTool('image') + setOpen(false) + } catch (err) { + setError((err as Error).message || 'Could not draw that code.') + } finally { + setAdding(false) + } + } + + return ( +
{ + if (e.target === e.currentTarget) setOpen(false) + }} + > +
+
+

Add a QR code

+ +
+ +
+ {/* Preview */} +
+
+ {preview ? ( + QR code preview + ) : ( + + {data ? 'Drawing…' : 'Enter a link or some text to see the code'} + + )} +
+ {data && ( +
+ {qrDisplayName(design)} +
+ )} +
+ + {/* Controls */} +
+
+ + setDesign((d) => ({ ...d, data: e.target.value }))} + placeholder="https://example.com" + className="w-full border border-slate-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-orange-400" + /> +
+ +
+
Style
+ {/* Two up on a phone — three columns truncates every name to + "Cla…", which is worse than one more row of scroll. */} +
+ {QR_PRESETS.map((preset) => ( + + ))} +
+
+ + + + {issue && ( +

+ {issue.kind === 'inverted' + ? 'These colours make an inverted code (light on dark). Some scanners refuse those — try a preset.' + : `Low contrast on the ${issue.where} (${issue.ratio.toFixed(1)}:1). It may scan on screen and fail in print — try a preset.`} +

+ )} + + {error &&

{error}

} +
+
+ + {/* Your Universal QR codes — read straight out of this browser. */} +
+
+
+ Your Universal QR codes +
+ + Design one in Universal QR ↗ + +
+ + {saved.length > 0 ? ( +
+ {saved.map((entry) => ( + + ))} +
+ ) : ( +

+ Codes you save in Universal QR on this browser show up here — the two apps share + storage on opensource.unisim.co.uk. Elsewhere, + import the .uniqr.json backup it saves. +

+ )} + + + +
+ +
+ Then click the page to place it. +
+ + +
+
+
+
+ ) +} diff --git a/src/components/Toolbar/Toolbar.tsx b/src/components/Toolbar/Toolbar.tsx index 2133ad6..ac79828 100644 --- a/src/components/Toolbar/Toolbar.tsx +++ b/src/components/Toolbar/Toolbar.tsx @@ -31,6 +31,28 @@ function HighlighterIcon({ className = 'w-6 h-6' }: { className?: string }) { ) } +function QrIcon({ className = 'w-6 h-6' }: { className?: string }) { + // Finder eyes in the suite orange, modules in a light slate — the same + // arrangement the generator's own default wears, so the button looks like + // what it makes. No active state: the button opens a dialog rather than + // selecting a tool (what it arms afterwards is the image tool). + const eye = '#fb923c' + const dot = '#e2e8f0' + return ( + + ) +} + function PictureFrameIcon({ active = false, className = 'w-6 h-6' }: { active?: boolean; className?: string }) { const frame = active ? '#fff' : '#fbbf24' const sky = '#7dd3fc' @@ -250,6 +272,7 @@ export function ToolbarDesktopTools() { const fontFamily = useAnnotationStore((s) => s.fontFamily) const setFontFamily = useAnnotationStore((s) => s.setFontFamily) const setUploadedImageSrc = useAnnotationStore((s) => s.setUploadedImageSrc) + const setQrOpen = usePdfStore((s) => s.setQrOpen) const [openPanel, setOpenPanel] = useState(null) // Reveals the extra built-in fonts (Georgia, Verdana, …) in the text panel. @@ -589,6 +612,16 @@ export function ToolbarDesktopTools() { /> + {/* Generate a QR code — it lands as an image annotation, so it sits next + to the image button rather than in the tool groups. */} + + {/* Delete (only when an annotation is selected) */} {selectedId && ( +
diff --git a/src/lib/qr/decor.ts b/src/lib/qr/decor.ts new file mode 100644 index 0000000..d009df6 --- /dev/null +++ b/src/lib/qr/decor.ts @@ -0,0 +1,192 @@ +// Plate decoration — the marks that fill the space a shaped plate leaves around +// the code, so the shape reads as designed rather than as a square code sitting +// on a round background. +// +// Ported from Universal QR (`src/lib/decor.ts`), canvas path only. It is here +// rather than skipped because a design saved in Universal QR carries its +// decoration: without this, importing a "Radial" or "Star" code would quietly +// render as something the user never designed. +// +// Every mark is generated OUTSIDE the code's own square boundary at its angle +// (innerAt), plus a margin, so nothing can land on a module or in its quiet +// zone. The geometry is deterministic — a seeded PRNG, never Math.random — so a +// given size always draws the same marks, and the preview matches what lands on +// the page. + +import { frameRadiusAt, type FrameShape } from './frames' + +export type DecorStyle = 'none' | 'burst' | 'scatter' + +/** How much the code shrinks to open up room for decoration. */ +export const DECOR_CODE_SCALE = 0.8 + +/** A decorative mark: a radial dash or a dot. */ +type DecorMark = + | { kind: 'line'; x1: number; y1: number; x2: number; y2: number; w: number } + | { kind: 'dot'; cx: number; cy: number; r: number } + +/** Deterministic PRNG (mulberry32). Same inputs, same decoration, every time. */ +function seeded(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) >>> 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const SEED = 0x5eed + +/** + * Where the decoration may start, along `angle`. + * + * The code's own SQUARE edge, not its circumscribed circle — the circle + * reserves the code's whole corner radius at every angle, leaving a dead gap + * against its flat edges. A square's boundary at angle t is + * (half-side)/max(|cos t|, |sin t|), which hugs the code and still cannot touch + * it: the margin below is added on top, and the QR's quiet zone is inside + * `codeInner` already. + */ +function innerAt(codeInner: number, size: number, angle: number): number { + const half = codeInner / 2 + const c = Math.abs(Math.cos(angle)) + const sn = Math.abs(Math.sin(angle)) + return half / Math.max(c, sn, 1e-6) + size * 0.003 +} + +/** + * How far past `innerAt` a given mark actually starts. + * + * Without this the decoration begins at exactly the same distance from the code + * all the way round, which draws a perfectly straight-edged square hole — and a + * straight edge is precisely what makes the eye read "square code, separate + * decoration around it". Biased towards zero (rand³) so most marks still crowd + * the code and only a few sit back. + */ +function ragged(rand: () => number, size: number): number { + const r = rand() + return size * 0.055 * r * r * r +} + +/** Radial dashes, thinning and shortening outward. */ +function burstMarks(shape: FrameShape, size: number, codeInner: number): DecorMark[] { + const cx = size / 2 + const cy = size / 2 + const rand = seeded(SEED) + const SPOKES = 150 + const BANDS = 4 + const marks: DecorMark[] = [] + + for (let i = 0; i < SPOKES; i++) { + // Jitter WITHIN a slot rather than a free random angle: free angles clump + // and leave gaps, which reads as a mistake rather than as a pattern. + const slot = (i + 0.5) / SPOKES + const angle = slot * Math.PI * 2 + (rand() - 0.5) * ((Math.PI * 2) / SPOKES) * 0.9 + const r0 = innerAt(codeInner, size, angle) + ragged(rand, size) + const r1 = frameRadiusAt(shape, size, angle) - size * 0.02 + if (r1 <= r0) continue + const span = r1 - r0 + + for (let band = 0; band < BANDS; band++) { + const bandT = band / (BANDS - 1) + // Thinning outward is what makes it read as a burst rather than a + // striped ring; the innermost band is solid, the outermost sparse. + if (band > 0 && rand() < 0.5 * bandT) continue + const b0 = r0 + (span * band) / BANDS + const b1 = r0 + (span * (band + 1)) / BANDS + const len = (b1 - b0) * (0.35 + 0.45 * rand()) + const s0 = b0 + (b1 - b0 - len) * rand() + const w = Math.max(1, size * (0.012 - 0.005 * bandT)) + + // The innermost band is DOTS, not dashes, and the band beyond it is + // mixed. A dash is a different material from a QR module, so matching the + // module vocabulary where the two meet — then growing into dashes + // outward — makes the code dissolve into the burst rather than sit in a + // cut-out inside it. + const asDot = band === 0 || (band === 1 && rand() < 0.45) + if (asDot) { + const rm = b0 + (b1 - b0) * rand() + marks.push({ + kind: 'dot', + cx: cx + rm * Math.cos(angle), + cy: cy + rm * Math.sin(angle), + r: w * 0.62 + }) + continue + } + + marks.push({ + kind: 'line', + x1: cx + s0 * Math.cos(angle), + y1: cy + s0 * Math.sin(angle), + x2: cx + (s0 + len) * Math.cos(angle), + y2: cy + (s0 + len) * Math.sin(angle), + w + }) + } + } + return marks +} + +/** Confetti: dots of varying size, densest against the code. */ +function scatterMarks(shape: FrameShape, size: number, codeInner: number): DecorMark[] { + const cx = size / 2 + const cy = size / 2 + const rand = seeded(SEED ^ 0x9e37) + const marks: DecorMark[] = [] + const COUNT = 700 + + for (let i = 0; i < COUNT; i++) { + const angle = rand() * Math.PI * 2 + const r0 = innerAt(codeInner, size, angle) + ragged(rand, size) + const r1 = frameRadiusAt(shape, size, angle) - size * 0.02 + if (r1 <= r0) continue + // sqrt-distributed radius gives an EVEN area density; a linear one piles + // everything against the code and leaves the rim bare. + const r = Math.sqrt(r0 * r0 + rand() * (r1 * r1 - r0 * r0)) + const t = (r - r0) / (r1 - r0) + // Thin out with radius so the edge fades rather than ending abruptly. + if (rand() < t * 0.5) continue + marks.push({ + kind: 'dot', + cx: cx + r * Math.cos(angle), + cy: cy + r * Math.sin(angle), + r: size * (0.005 + 0.011 * rand() * (1 - 0.5 * t)) + }) + } + return marks +} + +/** Paint the decoration onto a 2D context. The caller has already clipped to + * the frame silhouette. */ +export function drawDecor( + ctx: CanvasRenderingContext2D, + style: DecorStyle, + shape: FrameShape, + size: number, + codeInner: number, + colour: string +): void { + if (style === 'none') return + const marks = style === 'burst' ? burstMarks(shape, size, codeInner) : scatterMarks(shape, size, codeInner) + if (marks.length === 0) return + ctx.save() + ctx.fillStyle = colour + ctx.strokeStyle = colour + ctx.lineCap = 'round' + for (const m of marks) { + if (m.kind === 'line') { + ctx.beginPath() + ctx.lineWidth = m.w + ctx.moveTo(m.x1, m.y1) + ctx.lineTo(m.x2, m.y2) + ctx.stroke() + } else { + ctx.beginPath() + ctx.arc(m.cx, m.cy, m.r, 0, Math.PI * 2) + ctx.fill() + } + } + ctx.restore() +} diff --git a/src/lib/qr/design.ts b/src/lib/qr/design.ts new file mode 100644 index 0000000..9491e82 --- /dev/null +++ b/src/lib/qr/design.ts @@ -0,0 +1,431 @@ +// The QR design model — a field-for-field copy of Universal QR's `QrConfig` +// (its `src/lib/qr.ts`). +// +// The shape is copied deliberately rather than simplified: a design saved in +// Universal QR is restored here VERBATIM (see ./library), so the two apps have +// to agree on what a design is. What Universal PDF simplifies is the *editor* — +// six presets and a content field instead of the full studio — not the format. + +import type { + Options as QrOptions, + DotType, + CornerSquareType, + CornerDotType, + ErrorCorrectionLevel +} from 'qr-code-styling' +import type { FrameShape } from './frames' +import type { DecorStyle } from './decor' + +export type { DotType, CornerSquareType, CornerDotType, ErrorCorrectionLevel } + +/** The full, serialisable description of a QR code. */ +export interface QrDesign { + /** Human label — shown under the preview and used as the saved-design name. */ + name: string + /** The encoded payload (a URL, but any text works). */ + data: string + + // ── Geometry ────────────────────────────────────────────────────────────── + /** Rendered size in px (square). */ + size: number + /** Quiet-zone margin in px. */ + margin: number + /** QR error-correction level. Fixed at 'H' (the highest) so the code stays + * scannable even when a centre logo obscures part of it. */ + ecLevel: ErrorCorrectionLevel + + // ── Colours ─────────────────────────────────────────────────────────────── + fgColor: string + bgColor: string + /** Knock the background out (transparent PNG) — useful over a coloured page. */ + bgTransparent: boolean + /** Blend the modules from fgColor → gradientColor. */ + useGradient: boolean + gradientColor: string + /** Gradient angle in degrees. */ + gradientRotation: number + /** When true the finder corners follow the module colour; otherwise use + * cornerColor for a two-tone look. */ + matchCornerColor: boolean + cornerColor: string + + // ── Module shapes ───────────────────────────────────────────────────────── + dotType: DotType + cornerSquareType: CornerSquareType + cornerDotType: CornerDotType + + /** The silhouette of the whole code — a circle, hexagon or star instead of + * the usual square. This shapes the PLATE the code sits on; the code itself + * is rendered smaller and centred inside it, never clipped (see frames.ts). */ + frameShape: FrameShape + + /** Marks filling the space a shaped plate leaves around the code. Turning it + * on SHRINKS the code to make room (see decor.ts), and it does nothing at + * all on a square plate, where there is no space to fill. */ + decorStyle: DecorStyle + /** Decoration follows the module colour. Off to give it its own. */ + matchDecorColor: boolean + decorColor: string + + // ── Logo / branding ─────────────────────────────────────────────────────── + /** A user-supplied brand logo (data URI), placed in the centre. Universal PDF + * has no logo upload of its own — this arrives with an imported design. */ + logoDataUrl: string | null + /** Centre-logo size as a fraction of the QR (0.1–0.5). */ + logoSize: number + /** Padding in px between the logo and the surrounding modules. */ + logoMargin: number + /** Clear the modules sitting directly behind the logo. */ + hideBackgroundDots: boolean + /** Include the UNI·SIM mark — as the centre logo when no brand logo is set, + * or as a small bottom-right stamp when one is. */ + unisimMark: boolean +} + +export const DEFAULT_DESIGN: QrDesign = { + name: '', + // Empty, unlike Universal QR — which prefills its own address so a fresh + // generator always has something to show. That reasoning does not carry over: + // there the code is the thing you came for and is one select-all from being + // replaced, whereas here it would be a default URL baked into someone's + // document. The dialog shows a placeholder plate and disables Add instead. + data: '', + size: 512, + margin: 12, + // Always highest correction so a centre logo never breaks scanning. + ecLevel: 'H', + // Near-black modules on white, orange finder eyes — the suite scheme in the + // one arrangement that scans. See Universal QR's src/lib/qr.ts for the + // measurements behind it: orange modules are a 2.3:1 contrast against white, + // under the 3:1 floor a decoder needs, and light-on-dark is an inverted code + // that strict readers reject outright. + fgColor: '#1c1917', + bgColor: '#ffffff', + bgTransparent: false, + useGradient: false, + gradientColor: '#e05504', + gradientRotation: 45, + matchCornerColor: false, + cornerColor: '#e05504', + dotType: 'rounded', + cornerSquareType: 'extra-rounded', + cornerDotType: 'dot', + frameShape: 'square', + decorStyle: 'none', + matchDecorColor: true, + decorColor: '#e05504', + logoDataUrl: null, + logoSize: 0.28, + logoMargin: 6, + hideBackgroundDots: true, + unisimMark: true +} + +/** One-click starting points, shown as a row of chips in the dialog. + * + * These are Universal QR's presets, names and patches verbatim, so a code + * designed in one app is recognisable in the other. Every preset pins the + * fields another preset might have changed — background, frameShape, + * decorStyle, logo size, gradient stops — not just the ones it cares about: a + * patch merges onto whatever the user already had, so without pinning them + * "Classic" would quietly keep a star silhouette and a burst around it. + * + * Every preset also sets an explicit, opaque background alongside its module + * colours, guaranteeing a module↔background contrast comfortably above what a + * scanner needs whatever the design was before. */ +export interface QrPreset { + name: string + /** One-word hint at the silhouette, for the chip caption. */ + shape: string + patch: Partial +} + +export const QR_PRESETS: QrPreset[] = [ + { + name: 'Classic', + shape: 'Square', + patch: { + matchDecorColor: true, + decorStyle: 'none', + dotType: 'square', + cornerSquareType: 'square', + cornerDotType: 'square', + fgColor: '#000000', + bgColor: '#ffffff', + bgTransparent: false, + useGradient: false, + gradientColor: '#e05504', + gradientRotation: 45, + matchCornerColor: true, + logoSize: 0.28, + frameShape: 'square' + } + }, + { + name: 'Rounded', + shape: 'Square', + patch: { + matchDecorColor: true, + decorStyle: 'none', + dotType: 'rounded', + cornerSquareType: 'extra-rounded', + cornerDotType: 'dot', + fgColor: '#0f172a', + bgColor: '#ffffff', + bgTransparent: false, + useGradient: false, + gradientColor: '#e05504', + gradientRotation: 45, + matchCornerColor: true, + logoSize: 0.28, + frameShape: 'square' + } + }, + { + name: 'Dots', + shape: 'Square', + patch: { + matchDecorColor: true, + decorStyle: 'none', + dotType: 'dots', + cornerSquareType: 'dot', + cornerDotType: 'dot', + fgColor: '#1e293b', + bgColor: '#ffffff', + bgTransparent: false, + useGradient: false, + gradientColor: '#e05504', + gradientRotation: 45, + matchCornerColor: false, + cornerColor: '#e05504', + logoSize: 0.28, + frameShape: 'square' + } + }, + { + name: 'Sunset', + shape: 'Square', + patch: { + matchDecorColor: true, + decorStyle: 'none', + dotType: 'extra-rounded', + cornerSquareType: 'extra-rounded', + cornerDotType: 'dot', + useGradient: true, + // Deep warm modules on cream — dark-side-DOWN. The obvious pairing (warm + // modules on dusk) has a fine contrast ratio and is still an inverted + // code; it failed to decode at every size in Universal QR's harness. + fgColor: '#c2410c', + gradientColor: '#9f1239', + gradientRotation: 30, + bgColor: '#fff7ed', + bgTransparent: false, + matchCornerColor: true, + logoSize: 0.28, + frameShape: 'square' + } + }, + { + // The look the branded circular codes people point at have: dotted modules, + // round finder eyes, a large centre mark, and the ring around the code + // filled in rather than left as blank background. + name: 'Radial', + shape: 'Circle', + patch: { + matchDecorColor: true, + dotType: 'dots', + cornerSquareType: 'dot', + cornerDotType: 'dot', + fgColor: '#1c1917', + bgColor: '#ffffff', + bgTransparent: false, + useGradient: false, + gradientColor: '#e05504', + gradientRotation: 45, + matchCornerColor: false, + cornerColor: '#e05504', + frameShape: 'circle', + decorStyle: 'burst', + logoSize: 0.3, + hideBackgroundDots: true + } + }, + { + // BLACK on orange, not white on orange — white modules on an orange plate + // is an inverted code. Black on #e05504 is 5.5:1 with the dark side down. + name: 'Star', + shape: 'Star', + patch: { + matchDecorColor: true, + decorStyle: 'burst', + dotType: 'extra-rounded', + cornerSquareType: 'extra-rounded', + cornerDotType: 'dot', + fgColor: '#000000', + bgColor: '#e05504', + bgTransparent: false, + useGradient: false, + gradientColor: '#e05504', + gradientRotation: 45, + matchCornerColor: true, + logoSize: 0.28, + frameShape: 'star' + } + } +] + +/** The UNI·SIM mark used as the centre logo / corner stamp. Universal QR + * inlines a 256px data URI so its SVG exports stay self-contained; here the + * output is always a PNG that gets embedded in the PDF, so the icon the app + * already ships is enough (and it is the same source image). */ +export function unisimMarkUrl(): string { + return `${import.meta.env.BASE_URL}unisim-icon.png` +} + +/** Resolve the image that belongs in the centre of the QR, if any. */ +export function centerImage(design: QrDesign): string | undefined { + if (design.logoDataUrl) return design.logoDataUrl + if (design.unisimMark) return unisimMarkUrl() + return undefined +} + +/** True when the UNI·SIM mark should be stamped in the corner (i.e. the centre + * is already taken by the user's own brand logo). */ +export function showsCornerMark(design: QrDesign): boolean { + return design.unisimMark && !!design.logoDataUrl +} + +/** Geometry of the corner UNI·SIM stamp, in px, for a given rendered size. */ +export function cornerStampGeometry(size: number, margin: number) { + const badge = Math.max(28, Math.round(size * 0.16)) + const inset = margin + Math.round(size * 0.03) + const pos = size - badge - inset + return { badge, inset, x: pos, y: pos } +} + +/** The colour the decoration is actually drawn in. */ +export function decorColour(design: QrDesign): string { + return design.matchDecorColor ? design.fgColor : design.decorColor +} + +/** Best-effort hostname from the encoded data (empty for non-URL text). */ +export function hostnameOf(data: string): string { + try { + return new URL(data.trim()).hostname.replace(/^www\./, '') + } catch { + return '' + } +} + +/** The label shown under the preview, falling back to the URL's hostname when + * the code has no name of its own. */ +export function qrDisplayName(design: QrDesign): string { + return design.name.trim() || hostnameOf(design.data) || 'QR code' +} + +/** WCAG relative luminance (0–1) of a `#rrggbb` colour. Gamma-corrected: the + * naive channel average rates brand orange at 0.60 against white and calls it + * a comfortable gap, where the correct figure is 0.40 — a 2.3:1 ratio, under + * the 3:1 a decoder needs. */ +function luminance(hex: string): number { + const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()) + if (!m) return 0 + const n = parseInt(m[1], 16) + const channel = (c: number) => { + const s = c / 255 + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4) + } + return ( + 0.2126 * channel((n >> 16) & 0xff) + + 0.7152 * channel((n >> 8) & 0xff) + + 0.0722 * channel(n & 0xff) + ) +} + +/** WCAG contrast ratio between two `#rrggbb` colours, 1–21. */ +function contrastRatio(a: string, b: string): number { + const la = luminance(a) + const lb = luminance(b) + return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05) +} + +/** The minimum module↔background ratio a decoder can rely on. */ +const MIN_QR_CONTRAST = 3 + +export type ContrastIssue = + | { kind: 'inverted' } + | { kind: 'low'; ratio: number; where: 'modules' | 'corners' } + | null + +/** What is wrong with this design's colours, if anything. + * + * The six presets here all pass — this exists for designs arriving from + * Universal QR, where the full studio can produce a code that scans on a + * screen and fails on paper. Baking one of those into an exported PDF is the + * version of this feature nobody notices until the poster is printed. + * + * - `inverted` — the modules are LIGHTER than the background. The QR standard + * is dark-on-light and strict decoders reject the inverse rather than + * guessing. + * - `low` — the polarity is right but the ratio is too thin, which is the + * quieter failure: it passes a casual desk test and then fails in the wild. */ +export function qrContrastIssue(design: QrDesign): ContrastIssue { + if (design.bgTransparent) return null + const bg = luminance(design.bgColor) + const fgs = [design.fgColor, ...(design.useGradient ? [design.gradientColor] : [])] + if (fgs.some((c) => luminance(c) > bg + 0.02)) return { kind: 'inverted' } + + const worstModule = Math.min(...fgs.map((c) => contrastRatio(c, design.bgColor))) + if (worstModule < MIN_QR_CONTRAST) return { kind: 'low', ratio: worstModule, where: 'modules' } + + if (!design.matchCornerColor) { + if (luminance(design.cornerColor) > bg + 0.02) return { kind: 'inverted' } + const corner = contrastRatio(design.cornerColor, design.bgColor) + if (corner < MIN_QR_CONTRAST) return { kind: 'low', ratio: corner, where: 'corners' } + } + return null +} + +/** Map a QrDesign into the options object understood by qr-code-styling. */ +export function buildQrOptions(design: QrDesign): QrOptions { + const gradient = design.useGradient + ? { + type: 'linear' as const, + rotation: (design.gradientRotation * Math.PI) / 180, + colorStops: [ + { offset: 0, color: design.fgColor }, + { offset: 1, color: design.gradientColor } + ] + } + : undefined + + const cornerColor = design.matchCornerColor ? design.fgColor : design.cornerColor + + return { + type: 'canvas', + width: design.size, + height: design.size, + margin: design.margin, + // qr-code-styling throws on empty data; callers guard against this, but keep + // a single-space fallback so a transient empty string never crashes a render. + data: design.data || ' ', + image: centerImage(design), + qrOptions: { errorCorrectionLevel: design.ecLevel }, + imageOptions: { + hideBackgroundDots: design.hideBackgroundDots, + imageSize: design.logoSize, + margin: design.logoMargin, + crossOrigin: 'anonymous' + }, + dotsOptions: { type: design.dotType, color: design.fgColor, gradient }, + cornersSquareOptions: { + type: design.cornerSquareType, + color: cornerColor, + gradient: design.matchCornerColor ? gradient : undefined + }, + cornersDotOptions: { type: design.cornerDotType, color: cornerColor }, + backgroundOptions: { + color: design.bgTransparent ? 'rgba(255,255,255,0)' : design.bgColor + } + } +} diff --git a/src/lib/qr/frames.ts b/src/lib/qr/frames.ts new file mode 100644 index 0000000..fec6232 --- /dev/null +++ b/src/lib/qr/frames.ts @@ -0,0 +1,221 @@ +// Shaped QR plates — a circle, a hexagon, a star instead of the usual square. +// +// Ported from Universal QR (`src/lib/frames.ts`), trimmed to the canvas path: +// Universal PDF only ever needs a raster PNG to drop onto a page, so the SVG +// twins (`framePathData`, `frameSizeNote`) are left behind in that repo. +// +// ⚠️ The one rule this file exists to keep: **the code itself is never +// clipped.** A QR is only readable if every module and its quiet zone are +// present, so the shape can only ever be the *plate the code sits on*. The +// code is rendered smaller and centred inside the largest square that fits +// within the shape; the shape is what gets drawn around it. Anything that +// trims the silhouette out of the modules produces a picture of a QR code, not +// a QR code. + +export type FrameShape = 'square' | 'rounded' | 'circle' | 'squircle' | 'hexagon' | 'star' + +/** A closed polygon in the unit square [0,1]², y down (canvas convention). */ +type UnitPolygon = [number, number][] + +const CURVE_SEGMENTS = 256 + +function circlePolygon(): UnitPolygon { + const pts: UnitPolygon = [] + for (let i = 0; i < CURVE_SEGMENTS; i++) { + const t = (i / CURVE_SEGMENTS) * Math.PI * 2 + pts.push([0.5 + 0.5 * Math.cos(t), 0.5 + 0.5 * Math.sin(t)]) + } + return pts +} + +// Superellipse |x|^n + |y|^n = 1 with n = 4 — the "squircle" corner most people +// recognise from app icons. Signed powers keep all four quadrants. +function squirclePolygon(): UnitPolygon { + const n = 4 + const pts: UnitPolygon = [] + for (let i = 0; i < CURVE_SEGMENTS; i++) { + const t = (i / CURVE_SEGMENTS) * Math.PI * 2 + const c = Math.cos(t) + const s = Math.sin(t) + const x = Math.sign(c) * Math.abs(c) ** (2 / n) + const y = Math.sign(s) * Math.abs(s) ** (2 / n) + pts.push([0.5 + 0.5 * x, 0.5 + 0.5 * y]) + } + return pts +} + +const ROUNDED_RADIUS = 0.18 + +function roundedPolygon(): UnitPolygon { + const r = ROUNDED_RADIUS + const per = Math.round(CURVE_SEGMENTS / 4) + const corners: [number, number, number][] = [ + // [centre x, centre y, start angle] going clockwise from top-right + [1 - r, r, -Math.PI / 2], + [1 - r, 1 - r, 0], + [r, 1 - r, Math.PI / 2], + [r, r, Math.PI] + ] + const pts: UnitPolygon = [] + for (const [cx, cy, a0] of corners) { + for (let i = 0; i <= per; i++) { + const a = a0 + (i / per) * (Math.PI / 2) + pts.push([cx + r * Math.cos(a), cy + r * Math.sin(a)]) + } + } + return pts +} + +// Pointy-top regular hexagon, height 1, horizontally centred. Its bounding box +// is narrower than tall (width = √3/2), which is fine: the plate stays square +// and the hexagon is centred in it. +function hexagonPolygon(): UnitPolygon { + const pts: UnitPolygon = [] + for (let i = 0; i < 6; i++) { + const a = -Math.PI / 2 + (i * Math.PI) / 3 + pts.push([0.5 + 0.5 * Math.cos(a), 0.5 + 0.5 * Math.sin(a)]) + } + return pts +} + +// A five-point star. The inner radius is deliberately generous (0.62 of the +// outer, rather than the ~0.38 of a classic thin star): the inscribed square — +// and therefore the code — grows with it, and a thin star leaves so little room +// that the modules become unscannably small at ordinary export sizes. +const STAR_INNER_RATIO = 0.62 + +function starPolygon(): UnitPolygon { + const pts: UnitPolygon = [] + for (let i = 0; i < 10; i++) { + const r = i % 2 === 0 ? 0.5 : 0.5 * STAR_INNER_RATIO + const a = -Math.PI / 2 + (i * Math.PI) / 5 + pts.push([0.5 + r * Math.cos(a), 0.5 + r * Math.sin(a)]) + } + return pts +} + +const UNIT_POLYGONS: Record = { + square: [[0, 0], [1, 0], [1, 1], [0, 1]], + rounded: roundedPolygon(), + circle: circlePolygon(), + squircle: squirclePolygon(), + hexagon: hexagonPolygon(), + star: starPolygon() +} + +/** Ray-cast point-in-polygon. */ +function inside(poly: UnitPolygon, x: number, y: number): boolean { + let hit = false + for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) { + const [xi, yi] = poly[i] + const [xj, yj] = poly[j] + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit + } + return hit +} + +/** True when a centred axis-aligned square of half-side `a` lies wholly inside. */ +function squareFits(poly: UnitPolygon, a: number): boolean { + // Sample the square's PERIMETER, not just its four corners. Four corners is + // enough for a convex shape but wrong for the star, whose inward notches can + // cut through an edge while leaving every corner inside. + const N = 240 + for (let i = 0; i < N; i++) { + const t = (i / N) * 4 + const side = Math.floor(t) + const f = t - side + const lo = 0.5 - a + const hi = 0.5 + a + let x: number, y: number + if (side === 0) { x = lo + f * 2 * a; y = lo } + else if (side === 1) { x = hi; y = lo + f * 2 * a } + else if (side === 2) { x = hi - f * 2 * a; y = hi } + else { x = lo; y = hi - f * 2 * a } + if (!inside(poly, x, y)) return false + } + return true +} + +/** Side of the largest centred square that fits in `shape`, as a fraction of + * the plate. Binary-searched from the shape's own polygon rather than + * hand-derived per shape, so adding a shape cannot land a wrong constant. */ +function computeInscribedFactor(shape: FrameShape): number { + const poly = UNIT_POLYGONS[shape] + if (shape === 'square') return 1 + let lo = 0 + let hi = 0.5 + for (let i = 0; i < 30; i++) { + const mid = (lo + hi) / 2 + if (squareFits(poly, mid)) lo = mid + else hi = mid + } + // Shave a hair off so a rounding-up of `inner` cannot poke a module over the + // edge of the plate. + return lo * 2 * 0.995 +} + +const INSCRIBED: Record = { + square: 1, + rounded: computeInscribedFactor('rounded'), + circle: computeInscribedFactor('circle'), + squircle: computeInscribedFactor('squircle'), + hexagon: computeInscribedFactor('hexagon'), + star: computeInscribedFactor('star') +} + +/** Where the code goes inside a `size`-square plate of the given shape. + * + * `decorScale` shrinks the code to open up the ring that plate decoration + * draws in — a circle's inscribed square touches it at every corner, so + * without this there is literally no room. */ +export function frameGeometry( + shape: FrameShape, + size: number, + decorScale = 1 +): { inner: number; offset: number } { + const inner = Math.max(64, Math.round(size * INSCRIBED[shape] * decorScale)) + return { inner, offset: Math.round((size - inner) / 2) } +} + +/** The shape's outline at a given plate size, in device pixels. */ +function framePolygon(shape: FrameShape, size: number): [number, number][] { + return UNIT_POLYGONS[shape].map(([x, y]) => [x * size, y * size] as [number, number]) +} + +/** Distance from the plate centre to the outline, along `angle` (radians, y + * down). Ray-casts the shape's own polygon, so a hexagon and a star answer + * honestly instead of being approximated by their bounding circle. + * + * This is what lets plate decoration FILL a shape rather than sit in a ring + * inside it: generate to the corner radius and clip, and a hexagon keeps only + * the marks that happened to fall inside, leaving it sparse near the points + * and bare in the flats. */ +export function frameRadiusAt(shape: FrameShape, size: number, angle: number): number { + const pts = framePolygon(shape, size) + const cx = size / 2 + const cy = size / 2 + const dx = Math.cos(angle) + const dy = Math.sin(angle) + let best = 0 + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const [x1, y1] = pts[j] + const [x2, y2] = pts[i] + // Ray (c + t·d) against segment (p1 + u·(p2-p1)), t >= 0, 0 <= u <= 1. + const ex = x2 - x1 + const ey = y2 - y1 + const den = dx * ey - dy * ex + if (Math.abs(den) < 1e-9) continue + const t = ((x1 - cx) * ey - (y1 - cy) * ex) / den + const u = ((x1 - cx) * dy - (y1 - cy) * dx) / den + if (t >= 0 && u >= 0 && u <= 1 && t > best) best = t + } + return best +} + +/** Trace the outline onto a 2D context (caller fills or clips). */ +export function traceFrame(ctx: CanvasRenderingContext2D, shape: FrameShape, size: number): void { + const pts = framePolygon(shape, size) + ctx.beginPath() + pts.forEach(([x, y], i) => (i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y))) + ctx.closePath() +} diff --git a/src/lib/qr/library.ts b/src/lib/qr/library.ts new file mode 100644 index 0000000..287b069 --- /dev/null +++ b/src/lib/qr/library.ts @@ -0,0 +1,88 @@ +// Your saved Universal QR codes, read straight out of the browser. +// +// Universal QR keeps the codes you design in localStorage under +// `unisim.qr.designs.v1` — the whole design plus a thumbnail, nothing on a +// server (its `src/lib/localDesigns.ts`). Universal PDF and Universal QR are +// served from the SAME ORIGIN in production — opensource.unisim.co.uk/pdf and +// /qr, both behind the opensource-portal Worker — so that store is simply +// readable from here. No account, no API, no round trip: open the QR dialog and +// the codes you designed next door are already listed. +// +// READ ONLY, deliberately. This app never writes to that key: it is another +// app's store, capped at 12 entries, and quietly evicting someone's saved +// design because they added a QR to a PDF would be a bad trade. +// +// The origin is not guaranteed — pdf.unisim.co.uk and the Electron build are +// different origins with their own (empty) localStorage — so the dialog also +// takes Universal QR's `.uniqr.json` backup file, which works anywhere. + +import { DEFAULT_DESIGN, type QrDesign } from './design' + +/** Universal QR's saved-designs key. Matching it is the whole trick — keep it + * in step with that app's `localDesigns.ts` if it ever versions up. */ +const DESIGNS_KEY = 'unisim.qr.designs.v1' + +/** Magic string in a Universal QR backup file (its `qrBackup.ts`). */ +const BACKUP_MAGIC = 'universal-qr-backup' + +export const UNIVERSAL_QR_URL = 'https://opensource.unisim.co.uk/qr' + +export interface SavedQrDesign { + id: string + name: string + /** The full design — restored verbatim, including any uploaded centre logo. */ + design: QrDesign + /** Small PNG data URL rendered by Universal QR at save time. */ + thumbnail: string + createdAt: string +} + +/** Merge a stored design over the current defaults rather than trusting it + * whole. A design saved before a field existed comes back missing it, and the + * renderer looks `frameShape` up in a table — an undefined there is NaN + * geometry and a blank code, not a cosmetic difference. */ +function hydrate(config: unknown): QrDesign { + return { ...DEFAULT_DESIGN, ...(config as Partial) } +} + +/** The QR codes saved in Universal QR on this device, newest first. Returns an + * empty list on a different origin, in private mode, or with storage disabled — + * never throws. */ +export function loadSavedQrDesigns(): SavedQrDesign[] { + try { + const raw = localStorage.getItem(DESIGNS_KEY) + if (!raw) return [] + const parsed: unknown = JSON.parse(raw) + if (!Array.isArray(parsed)) return [] + return parsed + .filter((d): d is Record => !!d && typeof d === 'object' && !!(d as { config?: unknown }).config) + .map((d, i) => ({ + id: typeof d.id === 'string' ? d.id : `qr_${i}`, + name: typeof d.name === 'string' ? d.name : '', + design: hydrate(d.config), + thumbnail: typeof d.thumbnail === 'string' ? d.thumbnail : '', + createdAt: typeof d.createdAt === 'string' ? d.createdAt : '' + })) + .filter((d) => !!d.design.data) + } catch { + return [] + } +} + +/** Read a Universal QR `.uniqr.json` backup back into a design. Throws a + * user-facing message if the file isn't one. */ +export async function readQrBackupFile(file: File): Promise<{ name: string; design: QrDesign }> { + let json: unknown + try { + json = JSON.parse(await file.text()) + } catch { + throw new Error("That file isn't a Universal QR backup (it isn't valid JSON).") + } + const payload = json as { app?: unknown; config?: unknown } + if (payload?.app !== BACKUP_MAGIC || !payload.config || typeof payload.config !== 'object') { + throw new Error("That file isn't a Universal QR backup.") + } + const design = hydrate(payload.config) + if (!design.data) throw new Error('That backup has no code in it.') + return { name: design.name.trim() || file.name.replace(/\.(uniqr\.)?json$/i, ''), design } +} diff --git a/src/lib/qr/render.ts b/src/lib/qr/render.ts new file mode 100644 index 0000000..10adbd2 --- /dev/null +++ b/src/lib/qr/render.ts @@ -0,0 +1,153 @@ +// Render a QrDesign to a PNG data URL, ready to place on a page. +// +// Everything goes through one canvas composite — plain square codes included — +// so the plate, the decoration and the corner stamp cannot drift apart from +// each other the way two parallel render paths would. qr-code-styling is +// browser-only and heavy, so it is imported lazily at call time to keep it out +// of the main bundle (the same treatment ../brandedQr gives it). + +import { + buildQrOptions, + cornerStampGeometry, + decorColour, + showsCornerMark, + unisimMarkUrl, + type QrDesign +} from './design' +import { frameGeometry, traceFrame } from './frames' +import { DECOR_CODE_SCALE, drawDecor } from './decor' + +/** The size a QR is rendered at when it goes onto a page. Generous on purpose: + * placed at the default ~200pt it works out around 360 dpi, so the code still + * scans off a printed page rather than only off a screen. */ +export const PLACEMENT_SIZE = 1024 + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => resolve(img) + img.onerror = () => reject(new Error('Failed to load image')) + img.src = src + }) +} + +/** The scale the code is drawn at — 1 unless decoration needs room. */ +function decorScaleOf(design: QrDesign): number { + return design.decorStyle && design.decorStyle !== 'none' ? DECOR_CODE_SCALE : 1 +} + +/** The same design at a different rendered size. The quiet-zone margin is in + * pixels, so it has to travel with the size — a fixed 12px margin on a 1024px + * render is a proportionally smaller quiet zone than the design was drawn + * with, and the quiet zone is not decoration. */ +function atSize(design: QrDesign, size: number, transparent?: boolean): QrDesign { + return { + ...design, + size, + margin: Math.max(4, Math.round((design.margin / (design.size || size)) * size)), + ...(transparent === undefined ? {} : { bgTransparent: transparent }) + } +} + +/** Draw the white-tiled UNI·SIM corner stamp — used when the centre is already + * taken by an imported design's own brand logo. */ +function drawCornerStamp( + ctx: CanvasRenderingContext2D, + mark: HTMLImageElement, + x: number, + y: number, + badge: number +) { + const pad = Math.round(badge * 0.08) + const r = Math.round(badge * 0.16) + ctx.save() + ctx.fillStyle = '#ffffff' + ctx.strokeStyle = 'rgba(0,0,0,0.06)' + ctx.lineWidth = Math.max(1, Math.round(badge * 0.02)) + if (typeof ctx.roundRect === 'function') { + ctx.beginPath() + ctx.roundRect(x, y, badge, badge, r) + ctx.fill() + ctx.stroke() + } else { + ctx.fillRect(x, y, badge, badge) + ctx.strokeRect(x, y, badge, badge) + } + ctx.restore() + ctx.drawImage(mark, x + pad, y + pad, badge - 2 * pad, badge - 2 * pad) +} + +/** Rasterise just the code (no plate, no decoration) at `size`. */ +async function codeImage(design: QrDesign, size: number, transparent?: boolean): Promise { + const QRCodeStyling = (await import('qr-code-styling')).default + const qr = new QRCodeStyling(buildQrOptions(atSize(design, size, transparent))) + const raw = await qr.getRawData('png') + if (!(raw instanceof Blob)) throw new Error('QR render produced no image') + const url = URL.createObjectURL(raw) + try { + return await loadImage(url) + } finally { + URL.revokeObjectURL(url) + } +} + +/** + * Render `design` to a PNG data URL, `size` px square. + * + * On a shaped plate the code is rendered smaller and centred inside the largest + * square that fits the silhouette — it is never clipped to it, because a QR + * with a bite out of its modules or quiet zone is a picture of a QR code rather + * than one. See ./frames. + */ +export async function renderQrPng(design: QrDesign, size = PLACEMENT_SIZE): Promise { + if (!design.data.trim()) throw new Error('Enter a link or some text to encode.') + + const shaped = design.frameShape !== 'square' + // Decoration only exists on a shaped plate — a square one has no space around + // the code to fill — so a square design never gives up room for it, however + // its `decorStyle` happens to be set. + const { inner, offset } = frameGeometry(design.frameShape, size, shaped ? decorScaleOf(design) : 1) + + // Inside a plate the code's own background is switched off so the plate shows + // through; a plain square code keeps whatever background it was designed with. + const qrImg = await codeImage(design, inner, shaped ? true : undefined) + + const canvas = document.createElement('canvas') + canvas.width = size + canvas.height = size + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Canvas is not available in this browser.') + + if (shaped && !design.bgTransparent) { + // With a transparent background the plate is skipped entirely, which gives + // the genuinely useful result: a circular (or star, or hexagon) sticker on + // transparency rather than a shape you cannot see. + ctx.save() + traceFrame(ctx, design.frameShape, size) + ctx.clip() + ctx.fillStyle = design.bgColor + ctx.fillRect(0, 0, size, size) + ctx.restore() + } + + if (shaped && design.decorStyle !== 'none') { + // Decoration goes UNDER the code and is clipped to the silhouette, so the + // same marks fill a circle, a hexagon or a star without decor.ts knowing + // which. + ctx.save() + traceFrame(ctx, design.frameShape, size) + ctx.clip() + drawDecor(ctx, design.decorStyle, design.frameShape, size, inner, decorColour(design)) + ctx.restore() + } + + ctx.drawImage(qrImg, offset, offset, inner, inner) + + if (showsCornerMark(design)) { + const { badge, x, y } = cornerStampGeometry(inner, atSize(design, inner).margin) + drawCornerStamp(ctx, await loadImage(unisimMarkUrl()), offset + x, offset + y, badge) + } + + return canvas.toDataURL('image/png') +} diff --git a/src/stores/pdfStore.ts b/src/stores/pdfStore.ts index 4b5aca8..30e110e 100644 --- a/src/stores/pdfStore.ts +++ b/src/stores/pdfStore.ts @@ -87,6 +87,8 @@ interface PdfState { mergeOpen: boolean convertOpen: boolean metadataOpen: boolean + // The "Add QR code" generator (toolbar, next to the image button). + qrOpen: boolean recents: RecentMeta[] loadFile: (file: File) => Promise loadFromSlug: (slug: string) => Promise @@ -103,6 +105,7 @@ interface PdfState { setMergeOpen: (open: boolean) => void setConvertOpen: (open: boolean) => void setMetadataOpen: (open: boolean) => void + setQrOpen: (open: boolean) => void /** Strip the Info dictionary + XMP packet from the open document, in place. */ scrubMetadata: () => Promise refreshRecents: () => Promise @@ -131,6 +134,7 @@ export const usePdfStore = create((set, get) => ({ mergeOpen: false, convertOpen: false, metadataOpen: false, + qrOpen: false, recents: [], togglePageNav: () => set((s) => ({ pageNavOpen: !s.pageNavOpen })), setPageNavOpen: (pageNavOpen) => set({ pageNavOpen }), @@ -143,6 +147,7 @@ export const usePdfStore = create((set, get) => ({ setMergeOpen: (mergeOpen) => set({ mergeOpen }), setConvertOpen: (convertOpen) => set({ convertOpen }), setMetadataOpen: (metadataOpen) => set({ metadataOpen }), + setQrOpen: (qrOpen) => set({ qrOpen }), scrubMetadata: async () => { const bytes = get().sourceBytes const fileName = get().fileName @@ -232,7 +237,7 @@ export const usePdfStore = create((set, get) => ({ reset: () => { get().doc?.destroy() clearDocumentState() - set({ doc: null, numPages: 0, fileName: null, sourceBytes: null, isXfa: false, previewOpen: false, presentOpen: false, ocrOpen: false, mergeOpen: false, convertOpen: false, metadataOpen: false }) + set({ doc: null, numPages: 0, fileName: null, sourceBytes: null, isXfa: false, previewOpen: false, presentOpen: false, ocrOpen: false, mergeOpen: false, convertOpen: false, metadataOpen: false, qrOpen: false }) setHashSlug(null) }, refreshRecents: async () => {