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:

  1. Generate QR data (boolean matrix) with a library
  2. Render each module with Canvas
  3. 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