/** SPDX-License-Identifier: MIT ****************************************************************************** * QRSVG * Version 1.0.1 * https://fietkau.software/qr * Copyright (c) Julian Fietkau * * This is a small JavaScript project to render a two-dimensional bitmask * (mostly assumed to be a QR code) with a fixed width and height to an SVG * element as a collection of SVG paths with defined purposes. The code * analyzes the bitmask geometrically and traces the contours of contiguous * shapes. It allows rendering QR codes in several stylized ways. Note that * this code does not contain an actual QR code creator – it expects to receive * the 2D QR code as a bitmask for its input. See the project website for a * demo and more information. ****************************************************************************** * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the “Software”), to * deal in the Software without restriction, including without limitation the * rights to use, copy, modify, merge, publish, distribute, sublicense, and/or * sell copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER * DEALINGS IN THE SOFTWARE. ****************************************************************************** */ 'use strict'; var qrsvg; (function (qrsvg) { // Data class holding four SVG pathspecs that make up a QR code pattern. // There are separate properties for the inner and outer parts of the position // detection pattern, for 1x1 blocks in the pattern and for all larger shapes. // This separation is for later ease in distinct coloration. // The pathspecs are held as arrays instead of strings so their component parts // can be more easily iterated and manipulated. This is necessary for the // application of shape styles. class Contour { constructor() { this.pdpOuter = []; this.pdpInner = []; this.dots = []; this.shapes = []; } } // Data class holding a rectangular bitmask, accessible as x/y coordinates // returning boolean values. class Bitmask { constructor(width, height) { if(!Number.isInteger(width) || !Number.isInteger(height)) { throw Error('Bitmask: width and height must be integers: ' + width + ', ' + height); } this.width = width; this.height = height; this._array = new Array(width * height); this.wipe(false); } get(x, y) { if(!Number.isInteger(x) || !Number.isInteger(y)) { throw Error('Bitmask: x and y must be integers: ' + x + ', ' + y); } if(x < 0 || x >= this.width || y < 0 || y >= this.height) { return false; } return this._array[y * this.width + x]; } set(x, y, value) { if(!Number.isInteger(x) || !Number.isInteger(y)) { throw Error('Bitmask: x and y must be integers: ' + x + ', ' + y); } if(x < 0 || x >= this.width) { throw Error('Bitmask: x must be at least 0 and less than width: ' + x); } if(y < 0 || y >= this.height) { throw Error('Bitmask: y must be at least 0 and less than height: ' + y); } this._array[y * this.width + x] = value; } // Fully overwrite the current data with a sequence of boolean // values. In the simplest case, call wipe(false) to set all // coordinates to false, or provide more values. They will be // repeated in sequence as often as needed. wipe(...pattern) { for(let i = 0; i < this._array.length; i++) { this._array[i] = pattern[i % pattern.length]; } } } // Mini pseudo-random number generator. Used because rerendering // jittered bitmasks is easier when we can do it deterministically. class PRNG { constructor(seed) { // LCG using GCC's constants this.m = 0x80000000; this.a = 1103515245; this.c = 12345; this.state = seed ? seed : Math.floor(Math.random() * (this.m - 1)); } next() { this.state = (this.a * this.state + this.c) % this.m; return this.state / (this.m - 1); } } // Take an existing segmented pathspec and round its corners. // Used by `rounded` and `dots` styles. function makePathSpecRound(oldPathSpec) { let isInnerContour; let newPathSpec = []; for(let i = 0; i < oldPathSpec.length; i++) { if(oldPathSpec[i].startsWith('M')) { let coords = oldPathSpec[i].substring(1).split(' ').map(c => parseInt(c, 10)); if(oldPathSpec[i+1].startsWith('h')) { coords[0] += 0.5; isInnerContour = false; } else if(oldPathSpec[i+1].startsWith('v')) { coords[1] += 0.5; isInnerContour = true; } newPathSpec.push('M' + coords[0] + ' ' + coords[1]); i++; // Skip the first horizontal line segment } if(oldPathSpec[i] == 'z') { if(isInnerContour) { newPathSpec.push('a0.5 0.5 0 0 0 -0.5 0.5'); } else { newPathSpec.push('a0.5 0.5 0 0 1 0.5 -0.5'); } newPathSpec.push('z'); // End this loop iteration here because (a) if this is the last path // segment, trying to access i+1 further down would cause errors, // and (b) because we might as well. continue; } if(oldPathSpec[i] == 'h1' && oldPathSpec[i+1] == 'h1') { newPathSpec.push('h1'); } if(oldPathSpec[i] == 'h-1' && oldPathSpec[i+1] == 'h-1') { newPathSpec.push('h-1'); } if(oldPathSpec[i] == 'v1' && oldPathSpec[i+1] == 'v1') { newPathSpec.push('v1'); } if(oldPathSpec[i] == 'v-1' && oldPathSpec[i+1] == 'v-1') { newPathSpec.push('v-1'); } if(oldPathSpec[i] == 'h1' && oldPathSpec[i+1] == 'v1') { newPathSpec.push('a0.5 0.5 0 0 1 0.5 0.5'); } if(oldPathSpec[i] == 'h1' && oldPathSpec[i+1] == 'v-1') { newPathSpec.push('a0.5 0.5 0 0 0 0.5 -0.5'); } if(oldPathSpec[i] == 'h-1' && oldPathSpec[i+1] == 'v1') { newPathSpec.push('a0.5 0.5 0 0 0 -0.5 0.5'); } if(oldPathSpec[i] == 'h-1' && oldPathSpec[i+1] == 'v-1') { newPathSpec.push('a0.5 0.5 0 0 1 -0.5 -0.5'); } if(oldPathSpec[i] == 'v1' && oldPathSpec[i+1] == 'h1') { newPathSpec.push('a0.5 0.5 0 0 0 0.5 0.5'); } if(oldPathSpec[i] == 'v1' && oldPathSpec[i+1] == 'h-1') { newPathSpec.push('a0.5 0.5 0 0 1 -0.5 0.5'); } if(oldPathSpec[i] == 'v-1' && oldPathSpec[i+1] == 'h1') { newPathSpec.push('a0.5 0.5 0 0 1 0.5 -0.5'); } if(oldPathSpec[i] == 'v-1' && oldPathSpec[i+1] == 'h-1') { newPathSpec.push('a0.5 0.5 0 0 0 -0.5 -0.5'); } let len = newPathSpec.length; if(len >= 2 && newPathSpec[len-1][0] == newPathSpec[len-2][0] && ['h', 'v'].includes(newPathSpec[len-1][0])) { let command = newPathSpec[len-1][0]; let delta1 = parseInt(newPathSpec.pop().slice(1), 10); let delta2 = parseInt(newPathSpec.pop().slice(1), 10); newPathSpec.push(command + (delta1 + delta2)); } } return newPathSpec; } function addJitterToPathSpec(oldPathSpec, jitterValue, prng) { let newPathSpec = [] let currentPos = [null, null]; for(let step of oldPathSpec) { if(step.startsWith('M')) { currentPos = step.slice(1).split(' ').map(c => parseInt(c, 10)); newPathSpec.push(step); } else if(step.startsWith('h') || step.startsWith('v')) { let posIndex = 0; // default: h if(step.startsWith('v')) { posIndex = 1; } let distance = parseInt(step.slice(1), 10); currentPos[posIndex] += distance; let jitteredPos = currentPos.map(c => c + (prng.next() * 2 - 1) * jitterValue); newPathSpec.push('L' + jitteredPos[0] + ' ' + jitteredPos[1]); } else { newPathSpec.push(step); } } return newPathSpec; } function compactPathSpec(oldPathSpec) { let newPathSpec = []; if(oldPathSpec.length == 0) { return newPathSpec; } newPathSpec.push(oldPathSpec[0]); for(let step of oldPathSpec.slice(1)) { let prev = newPathSpec[newPathSpec.length - 1]; if((step[0] == 'h' || step[0] == 'v') && step[0] == prev[0]) { let distance = parseFloat(prev.substring(1), 10) + parseFloat(step.substring(1), 10); newPathSpec[newPathSpec.length - 1] = step[0] + distance; } else if(step[0] == 'm' && (prev[0] == 'm' || prev[0] == 'M')) { let prevPos = prev.substring(1).split(' ').map(c => parseFloat(c)); let newDelta = step.substring(1).split(' ').map(c => parseFloat(c)); newPathSpec[newPathSpec.length - 1] = prev[0] + (prevPos[0] + newDelta[0]).toPrecision(3) + ' ' + (prevPos[1] + newDelta[1]).toPrecision(3); } else { newPathSpec.push(step); } } return newPathSpec; } // Special case method to calculate contours for the styles // (currently only scanlines) where shapes follow the horizontal // image grid. function calculateHorizontalStyleContour(bitmask, margin, style) { if(style != 'scanlines') { throw Error('Unsupported horizontal render style: ' + style); } let contour = new Contour(); let lineOffWidth = 0.06; let lineOnWidth = 0.9; for(let y = 0; y < bitmask.height; y++) { let start = 0; let end = bitmask.width; if(bitmask.width > 16 && bitmask.height > 16) { // Check if we are inside a PDP area, because they have already been handled separately. if(y < 8) { start = 8; end = bitmask.width - 8; } else if(y > bitmask.height - 8) { start = 8; } } // We need to slightly move the "off" scanline from the vertical middle to make // the code easier to scan. Vertical displacement: let vOffDisplace = 0.1; let newShapePathSpec = []; let reversePathSpec = []; let newDotPathSpec = []; let capRadius = lineOffWidth / 2; let vDisplace = vOffDisplace; if(bitmask.get(start, y)) { capRadius = lineOnWidth / 2; vDisplace = 0; } newShapePathSpec.push('M' + (start + capRadius + margin).toFixed(3) + ' ' + (y + 0.5 + capRadius + vDisplace + margin).toFixed(3)); newShapePathSpec.push('a' + capRadius + ' ' + capRadius + ' 0 0 1 0 ' + (capRadius * -2).toFixed(3)); newShapePathSpec.push('h' + (0.8 - capRadius).toFixed(3)); reversePathSpec.push('h-0.8'); for(let x = start + 1; x < end; x++) { let vDelta = (lineOnWidth - lineOffWidth) / 2; if(bitmask.get(x, y) && !bitmask.get(x - 1, y) && !bitmask.get(x + 1, y) && x < end - 1) { newDotPathSpec.push('M' + (x - 0.2 + margin).toFixed(3) + ' ' + (y + 0.5 - lineOffWidth / 2 + vOffDisplace + margin).toFixed(3)); newDotPathSpec.push('c0.2 0 0.2 ' + (vDelta * -1 - vOffDisplace).toFixed(3) + ' 0.4 ' + (vDelta * -1 - vOffDisplace).toFixed(3)); newDotPathSpec.push('h0.6'); newDotPathSpec.push('c0.2 0 0.2 ' + (vDelta + vOffDisplace).toFixed(3) + ' 0.4 ' + (vDelta + vOffDisplace).toFixed(3)); newDotPathSpec.push('v' + lineOffWidth); newDotPathSpec.push('c-0.2 0 -0.2 ' + (vDelta - vOffDisplace).toFixed(3) + ' -0.4 ' + (vDelta - vOffDisplace).toFixed(3)); newDotPathSpec.push('h-0.6'); newDotPathSpec.push('c-0.2 0 -0.2 ' + (vDelta * -1 + vOffDisplace).toFixed(3) + ' -0.4 ' + (vDelta * -1 + vOffDisplace).toFixed(3)); newDotPathSpec.push('z'); newShapePathSpec.push('v' + lineOffWidth); newShapePathSpec.push(...reversePathSpec.reverse()); reversePathSpec = []; newShapePathSpec.push('z'); newShapePathSpec.push('M' + (x + 1.2 + margin).toFixed(3) + ' ' + (y + 0.5 + lineOffWidth / 2 + vOffDisplace + margin).toFixed(3)); newShapePathSpec.push('v' + (-1 * lineOffWidth)); x += 1; } else if(bitmask.get(x, y) && !bitmask.get(x - 1, y)) { newShapePathSpec.push('c0.2 0 0.2 ' + (vDelta * -1 - vOffDisplace).toFixed(3) + ' 0.4 ' + (vDelta * -1 - vOffDisplace).toFixed(3)); reversePathSpec.push('c-0.2 0 -0.2 ' + (vDelta * -1 + vOffDisplace).toFixed(3) + ' -0.4 ' + (vDelta * -1 + vOffDisplace).toFixed(3)); } else if(!bitmask.get(x, y) && bitmask.get(x - 1, y)) { newShapePathSpec.push('c0.2 0 0.2 ' + (vDelta + vOffDisplace).toFixed(3) + ' 0.4 ' + (vDelta + vOffDisplace).toFixed(3)); reversePathSpec.push('c-0.2 0 -0.2 ' + (vDelta - vOffDisplace).toFixed(3) + ' -0.4 ' + (vDelta - vOffDisplace).toFixed(3)); } else { newShapePathSpec.push('h0.4'); reversePathSpec.push('h-0.4'); } newShapePathSpec.push('h0.6'); reversePathSpec.push('h-0.6'); } newShapePathSpec.push('h0.2'); reversePathSpec.push('h-0.2'); capRadius = lineOffWidth / 2; if(bitmask.get(end - 1, y)) { capRadius = lineOnWidth / 2; } newShapePathSpec.push('h' + (-1 * capRadius)); reversePathSpec.push('h' + capRadius); newShapePathSpec.push('a' + capRadius + ' ' + capRadius + ' 0 0 1 0 ' + (capRadius * 2)); newShapePathSpec.push(...reversePathSpec.reverse()); newShapePathSpec.push('z'); contour.shapes = contour.shapes.concat(newShapePathSpec); contour.dots = contour.dots.concat(newDotPathSpec); } return contour; } // Special case method to calculate contours for the styles // where shapes are not contiguous. This also skips the PDP. function calculateTileStyleContour(bitmask, margin, style) { if(!['dots', 'mosaic', 'confetti'].includes(style)) { throw Error('Unsupported tiled render style: ' + style); } let confettiShapes = [ [ // circle 'M 0.5 0.1', 'A 0.4 0.4 0 0 1 0.5 0.9', 'A 0.4 0.4 0 0 1 0.5 0.1', ], [ // rectangle 'M 0.1 0.15', 'L 0.9 0.15', 'L 0.9 0.85', 'L 0.1 0.85', 'L 0.1 0.15', ], [ // star 'M 0.68 0.254', 'Q 1.21 0.284 0.786 0.58', 'Q 0.9275 1.0925 0.498 0.801', 'Q 0.0545 1.1 0.212 0.606', 'Q -0.208 0.269 0.312 0.259', 'Q 0.5 -0.25 0.68 0.254', ], [ // heart 'M 0.5 0.3', 'C 1.2 -0.1 1 0.7 0.5 1', 'C 0 0.7 -0.2 -0.1 0.5 0.3', ], [ // diamond 'M 0.5 0', 'Q 0.65 0.35 1 0.5', 'Q 0.65 0.65 0.5 1', 'Q 0.35 0.65 0 0.5', 'Q 0.35 0.35 0.5 0', ], [ // shamrock 'M 0.45 0.45', 'A 0.23 0.23 0 1 1 0.55 0.45', 'A 0.23 0.23 0 1 1 0.54 0.48', 'L 0.7 0.95', 'L 0.3 0.95', 'L 0.46 0.48', 'A 0.23 0.23 0 1 1 0.45 0.45', ], [ // lemon 'M 0.5 0', 'Q 1.5 0.5 0.5 1', 'Q -0.5 0.5 0.5 0', ], ]; let contour = new Contour(); let prng = new PRNG(1); // Rotate a point around (0.5, 0.5) by angle in radians let rotatePoint = (x, y, angle) => { let rotatedX = 0.5 + (x - 0.5) * Math.cos(angle) - (y - 0.5) * Math.sin(angle); let rotatedY = 0.5 + (x - 0.5) * Math.sin(angle) + (y - 0.5) * Math.cos(angle); return [rotatedX, rotatedY]; }; for(let y = 0; y < bitmask.height; y++) { for(let x = 0; x < bitmask.width; x++) { if(bitmask.width > 16 && bitmask.height > 16) { // Check if we are inside a PDP area, because they have already been handled separately. if((x < 8 && y < 8) || (x < 8 && y > bitmask.height - 8) || (x > bitmask.width - 8 && y < 8)) { continue; } } if(bitmask.get(x, y)) { let newPathSpec = []; if(style == 'dots') { newPathSpec.push('M' + (x + margin + 0.5) + ' ' + (y + margin)); newPathSpec.push('a0.5 0.5 0 0 1 0.5 0.5'); newPathSpec.push('a0.5 0.5 0 0 1 -0.5 0.5'); newPathSpec.push('a0.5 0.5 0 0 1 -0.5 -0.5'); newPathSpec.push('a0.5 0.5 0 0 1 0.5 -0.5'); newPathSpec.push('z'); } else if(style == 'mosaic') { // For the mosaic style, we jury-rig a pseudo-random rotation for each pixel. let size = 0.9; // relative to grid size let maxAngle = Math.PI * 0.03; let angle = (prng.next() * 2 - 1) * maxAngle; newPathSpec.push('M' + (x + 1) + ' ' + (y + 1)); let tileCorners = [ rotatePoint(0.5 - (size / 2), 0.5 - (size / 2), angle), rotatePoint(0.5 + (size / 2), 0.5 - (size / 2), angle), rotatePoint(0.5 + (size / 2), 0.5 + (size / 2), angle), rotatePoint(0.5 - (size / 2), 0.5 + (size / 2), angle), ]; newPathSpec.push('m' + tileCorners[0][0].toPrecision(3) + ' ' + tileCorners[0][1].toPrecision(3)); newPathSpec.push('l' + (tileCorners[1][0] - tileCorners[0][0]).toPrecision(3) + ' ' + (tileCorners[1][1] - tileCorners[0][1]).toPrecision(3)); newPathSpec.push('l' + (tileCorners[2][0] - tileCorners[1][0]).toPrecision(3) + ' ' + (tileCorners[2][1] - tileCorners[1][1]).toPrecision(3)); newPathSpec.push('l' + (tileCorners[3][0] - tileCorners[2][0]).toPrecision(3) + ' ' + (tileCorners[3][1] - tileCorners[2][1]).toPrecision(3)); newPathSpec.push('z'); } else if(style == 'confetti') { newPathSpec.push('M' + (x + margin) + ' ' + (y + margin)); let currentShape = confettiShapes[Math.floor(prng.next() * confettiShapes.length)]; let previousPoint = [0, 0]; let angle = prng.next() * Math.PI * 2; for(let segment of currentShape) { segment = segment.split(' '); let formatRotatedPoint = (i, j) => { let rotated = rotatePoint(segment[i], segment[j], angle); return (rotated[0] - previousPoint[0]).toPrecision(3) + ' ' + (rotated[1] - previousPoint[1]).toPrecision(3); } if(segment[0] == 'M') { let rotated = rotatePoint(segment[1], segment[2], angle); newPathSpec.push('m' + formatRotatedPoint(1, 2)); previousPoint = rotated; } else if(segment[0] == 'L') { let rotated = rotatePoint(segment[1], segment[2], angle); newPathSpec.push('l' + formatRotatedPoint(1, 2)); previousPoint = rotated; } else if(segment[0] == 'Q') { newPathSpec.push('q' + formatRotatedPoint(1, 2) + ' ' + formatRotatedPoint(3, 4)); previousPoint = rotatePoint(segment[3], segment[4], angle) } else if(segment[0] == 'C') { newPathSpec.push('c' + formatRotatedPoint(1, 2) + ' ' + formatRotatedPoint(3, 4) + ' ' + formatRotatedPoint(5, 6)); previousPoint = rotatePoint(segment[5], segment[6], angle); } else if(segment[0] == 'A') { newPathSpec.push('a' + segment.slice(1, 6).join(' ') + ' ' + formatRotatedPoint(6, 7)); previousPoint = rotatePoint(segment[6], segment[7], angle); } } newPathSpec.push('z'); } if(!bitmask.get(x - 1, y) && !bitmask.get(x + 1, y) && !bitmask.get(x, y - 1) && !bitmask.get(x, y + 1)) { contour.dots = contour.dots.concat(newPathSpec); } else { contour.shapes = contour.shapes.concat(newPathSpec); } } } } return contour; } // For styles that do not use individual tiles, this method traces along // contiguous shapes in the bitmask and builds a contour. Still skips // the PDP and assumes it is handled separately. function calculateShapeContour(bitmask, margin, style) { let contour = new Contour(); let corners = []; let width = bitmask.width + 1; let height = bitmask.height + 1; for(let y = 0; y < height; y++) { for(let x = 0; x < width; x++) { corners.push({}); } } for(let y = 0; y < height; y++) { for(let x = 0; x < width; x++) { if(Object.keys(corners[y * width + x]).includes('e')) continue; if(bitmask.get(x, y) == bitmask.get(x - 1, y) && bitmask.get(x, y) == bitmask.get(x, y - 1) && bitmask.get(x, y) == bitmask.get(x - 1, y - 1)) continue; // This corner is not part of any edge. if(bitmask.get(x, y - 1) || !bitmask.get(x, y)) continue; let contourX = x; let contourY = y; let direction = 'e'; while(!corners[contourY * width + contourX][direction]) { let prevDirection = direction; if(direction == 'n') { if(bitmask.get(contourX, contourY - 1) && !bitmask.get(contourX - 1, contourY - 1)) { corners[contourY * width + contourX][direction] = [contourX, contourY - 1]; } else if(!bitmask.get(contourX, contourY - 1)) { corners[contourY * width + contourX][direction] = [contourX + 1, contourY]; direction = 'e'; } else if(bitmask.get(contourX - 1, contourY - 1) && bitmask.get(contourX, contourY - 1)) { corners[contourY * width + contourX][direction] = [contourX - 1, contourY]; direction = 'w'; } } else if(direction == 'e') { if(bitmask.get(contourX, contourY) && !bitmask.get(contourX, contourY - 1)) { corners[contourY * width + contourX][direction] = [contourX + 1, contourY]; } else if(!bitmask.get(contourX, contourY)) { corners[contourY * width + contourX][direction] = [contourX, contourY + 1]; direction = 's'; } else if(bitmask.get(contourX, contourY) && bitmask.get(contourX, contourY - 1)) { corners[contourY * width + contourX][direction] = [contourX, contourY - 1]; direction = 'n'; } } else if(direction == 's') { if(bitmask.get(contourX - 1, contourY) && !bitmask.get(contourX, contourY)) { corners[contourY * width + contourX][direction] = [contourX, contourY + 1]; } else if(!bitmask.get(contourX - 1, contourY)) { corners[contourY * width + contourX][direction] = [contourX - 1, contourY]; direction = 'w'; } else if(bitmask.get(contourX, contourY) && bitmask.get(contourX - 1, contourY)) { corners[contourY * width + contourX][direction] = [contourX + 1, contourY]; direction = 'e'; } } else if(direction == 'w') { if(bitmask.get(contourX - 1, contourY - 1) && !bitmask.get(contourX - 1, contourY)) { corners[contourY * width + contourX][direction] = [contourX - 1, contourY]; } else if(!bitmask.get(contourX - 1, contourY - 1)) { corners[contourY * width + contourX][direction] = [contourX, contourY - 1]; direction = 'n'; } else if(bitmask.get(contourX - 1, contourY) && bitmask.get(contourX - 1, contourY - 1)) { corners[contourY * width + contourX][direction] = [contourX, contourY + 1]; direction = 's'; } } let next = corners[contourY * width + contourX][prevDirection]; if(!next) break; contourX = next[0]; contourY = next[1]; } } } for(let y = 0; y < height; y++) { for(let x = 0; x < width; x++) { if(bitmask.width > 16 && bitmask.height > 16) { // Check if we are inside a PDP area, because they have already been handled separately. if((x < 8 && y < 8) || (x < 8 && y > bitmask.height - 8) || (x > bitmask.width - 8 && y < 8)) { continue; } } if(Object.keys(corners[y * width + x]).length == 0) continue; let direction = Object.keys(corners[y * width + x])[0]; let newPathSpec = []; newPathSpec.push('M' + (x + margin) + ' ' + (y + margin)); let contourX = x; let contourY = y; while(corners[contourY * width + contourX][direction]) { let next = corners[contourY * width + contourX][direction]; let prevSpecStep = newPathSpec[newPathSpec.length - 1]; delete corners[contourY * width + contourX][direction]; let pathCommand, pathDelta; if(next[0] > contourX) { direction = 'e'; pathCommand = 'h'; pathDelta = 1; } else if(next[0] < contourX) { direction = 'w'; pathCommand = 'h'; pathDelta = -1; } else if(next[1] > contourY) { direction = 's'; pathCommand = 'v'; pathDelta = 1; } else if(next[1] < contourY) { direction = 'n'; pathCommand = 'v'; pathDelta = -1; } newPathSpec.push(pathCommand + pathDelta); contourX = next[0]; contourY = next[1]; } // Skip non-shaped paths for serialization if(newPathSpec.length <= 2) continue; // Avoid double-pathing the initial segment if(newPathSpec.length % 2 == 0) { newPathSpec.pop(); } // Technically at this point we should have already returned to the start, // but adding an explicit `z` anyway helps with rendering under some // circumstances. For pure line segment paths we could use `z` to jump // back instead of making the last step before here explicit, but that can // interfere with the way we round corners in some styles. newPathSpec.push('z'); if(newPathSpec.length == 6 && !newPathSpec[1].startsWith('v')) { contour.dots = contour.dots.concat(newPathSpec); } else { contour.shapes = contour.shapes.concat(newPathSpec); } } } return contour; } // Overarching method that turns a 2D bitmask into a set of contour pathspecs. // `margin` is an offset that is added to all x and y coordinates in the output. // It defaults to 1 to accommodate jitter and mosaic styles that have elements // randomly extending slightly outside of the basic QR code area. function calculateContour(bitmask, margin = 1, style = 'basic') { let contour = new Contour(); if(bitmask.width > 16 && bitmask.height > 16) { // This is where we build the PDP contours, regardless of style. Skipped for // bitmasks below a size threshold - those are assumed to not be valid QR codes. // I also tried rendering the PDP paths as individual "pixels" in the dots and // mosaic styles, but that led to bad scanning compatibility, so we take care // to keep those more solid than the rest of the code. for(let offset of [[margin, margin], [bitmask.width + margin - 7, margin], [margin, bitmask.height + margin - 7]]) { contour.pdpOuter.push('M' + offset[0] + ' ' + offset[1]); contour.pdpOuter.push(...Array(7).fill('h1')); contour.pdpOuter.push(...Array(7).fill('v1')); contour.pdpOuter.push(...Array(7).fill('h-1')); contour.pdpOuter.push(...Array(7).fill('v-1')); contour.pdpOuter.push('z'); contour.pdpOuter.push('M' + (offset[0] + 1) + ' ' + (offset[1] + 1)); contour.pdpOuter.push(...Array(5).fill('v1')); contour.pdpOuter.push(...Array(5).fill('h1')); contour.pdpOuter.push(...Array(5).fill('v-1')); contour.pdpOuter.push(...Array(5).fill('h-1')); contour.pdpOuter.push('z'); contour.pdpInner.push('M' + (offset[0] + 2) + ' ' + (offset[1] + 2)); contour.pdpInner.push(...Array(3).fill('h1')); contour.pdpInner.push(...Array(3).fill('v1')); contour.pdpInner.push(...Array(3).fill('h-1')); contour.pdpInner.push(...Array(3).fill('v-1')); contour.pdpInner.push('z'); } if(['dots', 'rounded', 'confetti', 'scanlines'].includes(style)) { contour.pdpInner = makePathSpecRound(contour.pdpInner); contour.pdpOuter = makePathSpecRound(contour.pdpOuter); } } let newContour; if(style == 'scanlines') { newContour = calculateHorizontalStyleContour(bitmask, margin, style); } else if(['dots', 'mosaic', 'confetti'].includes(style)) { newContour = calculateTileStyleContour(bitmask, margin, style); } else { newContour = calculateShapeContour(bitmask, margin, style); } contour.dots = newContour.dots; contour.shapes = newContour.shapes; if(style == 'rounded') { contour.shapes = makePathSpecRound(contour.shapes); contour.dots = makePathSpecRound(contour.dots); } if(style.startsWith('jitter-')) { let jitterValue = 0.0; // Suitable jitter values that still lead to good scanning compatibility // have been derived experimentally. Customize as you see fit. if(style == 'jitter-heavy') { jitterValue = 0.15; } else if (style == 'jitter-light') { jitterValue = 0.07; } let prng = new PRNG(1); // It's important to jitterize the shapes and dots first, as they // are different for every QR code. Reusing the same PRNG afterwards // for the PDP paths ensures that they get different jitter values // for different QR codes and thus not look the same every time. contour.shapes = addJitterToPathSpec(contour.shapes, jitterValue, prng); contour.dots = addJitterToPathSpec(contour.dots, jitterValue, prng); contour.pdpInner = addJitterToPathSpec(contour.pdpInner, jitterValue, prng); contour.pdpOuter = addJitterToPathSpec(contour.pdpOuter, jitterValue, prng); } else { contour.shapes = compactPathSpec(contour.shapes); contour.dots = compactPathSpec(contour.dots); contour.pdpInner = compactPathSpec(contour.pdpInner); contour.pdpOuter = compactPathSpec(contour.pdpOuter); } return contour; } // This is the main callable method to render a bitmask into an SVG // element using a specific render style. The SVG element will be // cleared, the viewBox will be adjusted as needed, and four path // elements will be created within containing the PDP inner and // outer parts, dots, and other shapes. See a few lines below for // the list of valid styles. function render(bitmask, renderTarget, style = 'basic') { if(!['basic', 'rounded', 'dots', 'mosaic', 'confetti', 'scanlines', 'jitter-light', 'jitter-heavy'].includes(style)) { throw Error('Unsupported render style: ' + style); } renderTarget.setAttribute('viewBox', '0 0 ' + (bitmask.width + 2) + ' ' + (bitmask.height + 2)); while(renderTarget.firstChild) { renderTarget.firstChild.remove(); } let contours = qrsvg.calculateContour(bitmask, 1, style); for(let contourType in contours) { let path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', contours[contourType].join('')); // Add your customizations - e.g. `fill` colors, custom classes... - here. renderTarget.appendChild(path); } } qrsvg['Bitmask'] = Bitmask; qrsvg['Contour'] = Contour; qrsvg['PRNG'] = PRNG; qrsvg['makePathSpecRound'] = makePathSpecRound; qrsvg['addJitterToPathSpec'] = addJitterToPathSpec; qrsvg['compactPathSpec'] = compactPathSpec; qrsvg['calculateTileStyleContour'] = calculateTileStyleContour; qrsvg['calculateShapeContour'] = calculateShapeContour; qrsvg['calculateContour'] = calculateContour; qrsvg['render'] = render; })(qrsvg || (qrsvg = {}));