From Reed-Solomon to Canvas: Building a High-Quality QR Code Generator
From Reed-Solomon to Canvas: Building a High-Quality QR Code Generator#
A recent project required “nicer looking” QR codes. Most tools I found just produced black-and-white squares or slapped a logo on top. I wanted something more refined, so I dug into QR code internals.
QR Code: More Than Black and White#
QR Code (Quick Response Code) was invented by Denso Wave in 1994. A QR Code contains:
- Position Markers: Three corner squares for orientation
- Timing Patterns: Alternating black/white lines for alignment
- Version Info: QR code size (versions 1-40)
- Format Info: Error correction level and mask pattern
- Data Area: Actual content
The key is Reed-Solomon error correction. Even if part of the code is damaged, data can be recovered. That’s why QR codes can have logos or missing corners and still scan.
Error Correction: 7% to 30% Tolerance#
QR Code has four error correction levels:
| Level | Recovery | Use Case |
|---|---|---|
| L (Low) | 7% | Clean environment, no logo |
| M (Medium) | 15% | Recommended default |
| Q (Quartile) | 25% | Good for small logos |
| H (High) | 30% | Large logos, print media |
Higher correction means denser codes—more space for error correction data. For “Hello World”:
- Level L: 21×21 modules
- Level H: 25×25 modules
Implementation with the qrcode library:
import QRCode from 'qrcode'
const qrData = await QRCode.create(text, {
errorCorrectionLevel: 'H' // Use H for logos
})
const moduleCount = qrData.modules.size // Number of modules
Canvas Rendering: From Squares to Dots#
Default QR codes are black-and-white squares, but we can do better. The approach:
- Generate QR data (boolean matrix) with a library
- Render each module with Canvas
- Handle position markers separately (eye styles)
Basic Rendering#
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
// Calculate pixel size per module
const cellSize = Math.floor(size / moduleCount)
const actualSize = cellSize * moduleCount
canvas.width = actualSize
canvas.height = actualSize
// Fill background
ctx.fillStyle = bgColor
ctx.fillRect(0, 0, actualSize, actualSize)
// Render data area
for (let row = 0; row < moduleCount; row++) {
for (let col = 0; col < moduleCount; col++) {
const isDark = qrData.modules.get(row, col)
if (isDark) {
const x = col * cellSize
const y = row * cellSize
// Check if position marker area (three corners)
const isPositionMarker = (
(row < 8 && col < 8) || // Top-left
(row < 8 && col >= moduleCount - 8) || // Top-right
(row >= moduleCount - 8 && col < 8) // Bottom-left
)
if (!isPositionMarker) {
// Data area: draw dot
drawDot(ctx, x, y, cellSize, dotStyle, fgColor)
}
}
}
}
Dot Styles#
We implemented 5 dot styles:
type DotStyle = 'square' | 'dots' | 'rounded' | 'diamond' | 'circle'
function drawDot(
ctx: CanvasRenderingContext2D,
x: number, y: number, size: number,
style: DotStyle, color: string
) {
ctx.fillStyle = color
const padding = size * 0.1 // Spacing
const s = Math.max(1, size - padding * 2)
switch (style) {
case 'square':
ctx.fillRect(x + padding, y + padding, s, s)
break
case 'dots':
ctx.beginPath()
ctx.arc(x + size / 2, y + size / 2, s / 2, 0, Math.PI * 2)
ctx.fill()
break
case 'rounded':
ctx.beginPath()
ctx.roundRect(x + padding, y + padding, s, s, s * 0.3)
ctx.fill()
break
case 'diamond':
ctx.beginPath()
ctx.moveTo(x + size / 2, y + padding)
ctx.lineTo(x + size - padding, y + size / 2)
ctx.lineTo(x + size / 2, y + size - padding)
ctx.lineTo(x + padding, y + size / 2)
ctx.closePath()
ctx.fill()
break
case 'circle':
ctx.beginPath()
ctx.arc(x + size / 2, y + size / 2, s / 2.5, 0, Math.PI * 2)
ctx.fill()
break
}
}
roundRect is a newer Canvas API (Chrome 99+). For older browsers, use Path2D or draw arcs manually.
Eye Styles#
Position markers (eyes) are the QR code’s “face”—we render them separately:
type EyeStyle = 'square' | 'rounded' | 'circle' | 'leaf'
function drawPositionMarkers(
ctx: CanvasRenderingContext2D,
moduleCount: number, cellSize: number,
style: EyeStyle, fgColor: string, bgColor: string
) {
const markerSize = cellSize * 7 // Position marker is 7×7 modules
const positions = [
{ row: 0, col: 0 }, // Top-left
{ row: 0, col: moduleCount - 7 }, // Top-right
{ row: moduleCount - 7, col: 0 }, // Bottom-left
]
positions.forEach(pos => {
const x = pos.col * cellSize
const y = pos.row * cellSize
ctx.fillStyle = fgColor
switch (style) {
case 'square':
// Outer frame 7×7
ctx.fillRect(x, y, markerSize, markerSize)
// Inner white 5×5
ctx.fillStyle = bgColor
ctx.fillRect(x + cellSize, y + cellSize, cellSize * 5, cellSize * 5)
// Center black 3×3
ctx.fillStyle = fgColor
ctx.fillRect(x + cellSize * 2, y + cellSize * 2, cellSize * 3, cellSize * 3)
break
case 'circle':
// Outer circle
ctx.beginPath()
ctx.arc(x + markerSize / 2, y + markerSize / 2, markerSize / 2, 0, Math.PI * 2)
ctx.fill()
// Inner white circle
ctx.fillStyle = bgColor
ctx.beginPath()
ctx.arc(x + markerSize / 2, y + markerSize / 2, cellSize * 2.5, 0, Math.PI * 2)
ctx.fill()
// Center black dot
ctx.fillStyle = fgColor
ctx.beginPath()
ctx.arc(x + markerSize / 2, y + markerSize / 2, cellSize, 0, Math.PI * 2)
ctx.fill()
break
// ... other styles
}
})
}
Position markers are 7×7 modules with three layers: outer frame, inner white, center. Circular eyes use arc, leaf shapes use ellipse.
Gradient Colors: From Boring to Beautiful#
Solid colors are boring. Gradients add design appeal. Canvas’s createLinearGradient makes it easy:
// Create gradient
const gradient = ctx.createLinearGradient(0, 0, actualSize, actualSize)
gradient.addColorStop(0, '#3b82f6') // Start color
gradient.addColorStop(1, '#06b6d4') // End color
// Use gradient when drawing
drawDot(ctx, x, y, cellSize, dotStyle, gradient)
Key insight: gradients are canvas-wide, not per dot. Each dot shows a portion of the overall gradient, creating a unified effect.
Logo Overlay: The Art of Error Tolerance#
Adding logos is common, but there are gotchas:
1. Choose the Right Error Correction Level#
Logos cover data areas, so use high correction:
- Small logo (< 15%): Level Q (25%)
- Large logo (15-20%): Level H (30%)
2. Logo Size Limits#
Logos can’t be too large—beyond error correction capacity. Rule of thumb: logo ≤ 20% of QR code area.
const logoSize = actualSize * 0.2 // Max 20%
const logoX = (actualSize - logoSize) / 2
const logoY = (actualSize - logoSize) / 2
// White protection area (important!)
ctx.fillStyle = bgColor
ctx.fillRect(logoX - 4, logoY - 4, logoSize + 8, logoSize + 8)
// Draw logo
const logoImg = new Image()
logoImg.onload = () => {
ctx.drawImage(logoImg, logoX, logoY, logoSize, logoSize)
}
logoImg.src = logoDataUrl
3. White Protection Area#
Logos need white borders to avoid confusion with data areas. At least 4px padding.
Performance: Large QR Codes#
At 800×800 pixels, rendering module-by-module causes lag. Optimization strategies:
1. Offscreen Canvas#
const offscreen = document.createElement('canvas')
const offCtx = offscreen.getContext('2d')
// Render to offscreen canvas
offCtx.fillStyle = fgColor
offCtx.fillRect(0, 0, cellSize, cellSize)
// Batch copy to main canvas
for (let row = 0; row < moduleCount; row++) {
for (let col = 0; col < moduleCount; col++) {
if (qrData.modules.get(row, col)) {
ctx.drawImage(offscreen, col * cellSize, row * cellSize)
}
}
}
2. Pre-rendered Dots#
Pre-render each dot style to small canvases, then use drawImage:
// Pre-render circular dot
const dotCanvas = document.createElement('canvas')
dotCanvas.width = cellSize
dotCanvas.height = cellSize
const dotCtx = dotCanvas.getContext('2d')
dotCtx.fillStyle = fgColor
dotCtx.beginPath()
dotCtx.arc(cellSize / 2, cellSize / 2, cellSize / 2, 0, Math.PI * 2)
dotCtx.fill()
// Copy when needed
ctx.drawImage(dotCanvas, x, y)
drawImage is much faster than beginPath + arc every time.
Real-World Gotchas#
1. Wrong Module Count#
QR version determines module count: version 1 = 21×21, adding 4 modules per level. Formula: modules = 17 + version * 4.
Libraries auto-select version based on content length. Use Math.floor(size / moduleCount) for actual pixel size.
2. Blurry Canvas#
Canvas CSS size and pixel size must be set separately:
canvas.width = actualSize // Pixel size
canvas.height = actualSize
canvas.style.width = `${actualSize}px` // CSS size
canvas.style.height = `${actualSize}px`
Or use devicePixelRatio for high-DPI screens:
const dpr = window.devicePixelRatio || 1
canvas.width = actualSize * dpr
canvas.height = actualSize * dpr
ctx.scale(dpr, dpr)
3. Logo CORS Issues#
Network images need crossOrigin set:
const logoImg = new Image()
logoImg.crossOrigin = 'anonymous' // Must set before src
logoImg.src = logoUrl
Otherwise canvas.toDataURL() throws: Tainted canvases may not be exported.
The Result#
Based on these ideas, I built: QR Code Generator
Features:
- 12 preset styles (gradient, tech blue, vibrant orange, etc.)
- 5 dot shapes (square, dots, rounded, diamond, circle)
- 4 eye shapes (square, rounded, circle, leaf)
- 4 error correction levels (L/M/Q/H)
- Logo upload support
- Size range 200-800px
Implementation isn’t complex, but details matter. QR codes can be beautiful.
Related: Barcode Generator | Image Compress