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 (
+
+ {/* 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. */}
+
+ 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 && (