Skip to content

Commit

Permalink
Improvements to SVG rendering
Browse files Browse the repository at this point in the history
  • Loading branch information
WasabiThumb committed Jul 8, 2024
1 parent 0192bb1 commit 6c93244
Show file tree
Hide file tree
Showing 4 changed files with 320 additions and 17 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ This covers all methods that may return SVG data under any circumstances (`rende
| --: | :-: | :-- |
| trySVG | `boolean` | If true, and SVGs are supported in the current environment, the render operation may return SVG (otherwise PNG). |
| svgNoWhitespace | `boolean` | If true, no whitespace will be included between SVG tags. |
| svgCollation | ``0`` \| ``1`` \| ``2`` \| ``3`` | An optimization that determines how pixels will be collated into shapes. Default is 3. Higher collation tends to produce smaller SVGs. Collation levels 1 & 2 may be faster to render. |

### HTML Image
This applies only to `renderToImage`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "enqr",
"description": "Lightweight QR encoder for the browser & node loosely based on zxing",
"version": "1.1.2",
"version": "1.2.0",
"main": "dist/index.js",
"types": "types/index.d.ts",
"homepage": "https://github.com/WasabiThumb/enqr",
Expand Down
292 changes: 276 additions & 16 deletions src/renderer/impl/svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import {QRRenderDefaultOptions, QRRenderer, QRRenderOptions} from "../spec";
import {QRCode} from "../../qr/qrcode";
import {StringBuilder} from "../../util/string";
import {TernaryMatrix} from "../../collection/ternaryMatrix";
import {BitMatrix} from "../../collection/bitMatrix";
import {BitArray} from "../../collection/bitArray";


const MAGIC_1: string = `<?xml version="1.0" encoding="UTF-8"?>`;
Expand All @@ -13,11 +15,12 @@ const MAGIC_5: string = `" xmlns="http://www.w3.org/2000/svg">`;
const MAGIC_6: string = `<rect x="`;
const MAGIC_7: string = `" y="`;
const MAGIC_8: string = `" width="`;
// MAGIC_9 === MAGIC_3
const MAGIC_10: string = `" fill="`;
const MAGIC_11: string = `"/>`;

const MAGIC_12: string = `</svg>`;
const MAGIC_12: string = `<path d="`;
const MAGIC_14: string = `<style><![CDATA[* {shape-rendering: crispEdges;} path {fill-rule: evenodd;}]]></style>`;
const MAGIC_15: string = `</svg>`;


export class SVGQRRenderer implements QRRenderer<string, SVGQRRenderer.Options> {
Expand All @@ -35,9 +38,6 @@ export class SVGQRRenderer implements QRRenderer<string, SVGQRRenderer.Options>
const outerWidth: number = width + (quiet * 2);
const outerHeight: number = height + (quiet * 2);

const strips = new StripMatrix(matrix.width, matrix.height);
strips.addAll(matrix);

const out = new StringBuilder();
let newline: (() => void);
let space: (() => void);
Expand Down Expand Up @@ -65,6 +65,9 @@ export class SVGQRRenderer implements QRRenderer<string, SVGQRRenderer.Options>
.append(outerHeight)
.append(MAGIC_5);
newline();
space();
out.append(MAGIC_14);
newline();

function addRect(x: number, y: number, width: number, height: number, color: string): void {
space();
Expand All @@ -84,18 +87,57 @@ export class SVGQRRenderer implements QRRenderer<string, SVGQRRenderer.Options>

addRect(0, 0, outerWidth, outerHeight, bg);

for (let rect of strips.popRects()) {
function addFGRect(x: number, y: number, w: number, h: number): void {
addRect(
(rect.x * xScale) + quiet,
(rect.y * yScale) + quiet,
(rect.width * xScale),
(rect.height * yScale),
(x * xScale) + quiet,
(y * yScale) + quiet,
w * xScale,
h * yScale,
fg
);
}

let doubleStrip: boolean = true;
// noinspection FallThroughInSwitchStatementJS
switch (opts.svgCollation) {
case 0:
for (let y=0; y < matrix.height; y++) {
for (let x=0; x < matrix.width; x++) {
if (matrix.get(x, y) !== 1) continue;
addFGRect(x, y, 1, 1);
}
}
break;
case 1:
doubleStrip = false;
case 2:
const strips = new StripMatrix(matrix.width, matrix.height);
strips.addAll(matrix);
if (doubleStrip) {
for (let rect of strips.popRects())
addFGRect(rect.x, rect.y, rect.width, rect.height);
} else {
for (let strip of strips.strips)
addFGRect(strip.start, strip.y, strip.end - strip.start, 1);
}
break;
case 3:
const tracer = new Tracer(matrix);
let poll: string | -1;
while ((poll = tracer.pollString(xScale, yScale, quiet, quiet)) !== -1) {
space();
out.append(MAGIC_12)
.append(poll)
.append(MAGIC_10)
.append(fg)
.append(MAGIC_11);
newline();
}
break;
}

// Add closing tag
out.append(MAGIC_12);
out.append(MAGIC_15);
newline();

return out.toString();
Expand All @@ -111,16 +153,222 @@ export namespace SVGQRRenderer {
*
* Default is false
*/
svgNoWhitespace?: boolean
svgNoWhitespace?: boolean,
/**
* An optimization that determines how pixels will be collated into shapes. Default is 3.
*
* - **0** : Pixels (No collation)
* - **1** : Strips (Collate along X axis)
* - **2** : Double Strips (Collate along X axis, then along Y axis)
* - **3** : Perfect (All pixels with cardinal neighbors are collated)
*/
svgCollation?: 0 | 1 | 2 | 3
};

export const DefaultOptions: Required<Options> = {
...QRRenderDefaultOptions,
svgNoWhitespace: false
svgNoWhitespace: false,
svgCollation: 3
};

}

// Used for svgCollation = 3
type TracerPath = {
length: number,
xs: number[],
ys: number[]
}

type TracerLine = [ number, number, number, number, 0 | 1 | 2 | 3 ];

class Tracer {

readonly matrix: TernaryMatrix;
readonly traversed: BitMatrix;
constructor(matrix: TernaryMatrix) {
this.matrix = matrix;
this.traversed = new BitMatrix(matrix.width, matrix.height);
for (let y=0; y < matrix.height; y++) {
for (let x=0; x < matrix.width; x++) {
if (matrix.get(x, y) !== 1) this.traversed.set(x, y, true);
}
}
}

pollString(xScale: number = 1, yScale: number = 1, xOffset: number = 0, yOffset: number = 0): string | -1 {
let poll = this.poll();
if (poll === -1) return -1;

let ret = new StringBuilder();
for (let i=0; i < poll.length; i++) {
if (i > 0) ret.appendSpace();
ret.appendString(this.pathToString(poll[i], (i & 1) === 1, xScale, yScale, xOffset, yOffset));
}

return ret.toString();
}

// noinspection JSMethodCanBeStatic
private pathToString(path: TracerPath, reverse: boolean, xScale: number, yScale: number, xOffset: number, yOffset: number): string {
let ret = new StringBuilder();
ret.appendString("M ");

let ei: number;
for (let i=0; i < path.length; i++) {
ei = reverse ? (path.length - 1 - i) : i;
if (i !== 0) ret.appendString(" L ");
ret.append(path.xs[ei] * xScale + xOffset)
.append(",")
.append(path.ys[ei] * yScale + yOffset);
}

ret.appendString(" Z");

return ret.toString();
}

poll(): TracerPath[] | -1 {
const lines = this.pollLines();
if (lines === -1) return -1;

const lineGroup = new TracerLineGroup(lines, this.matrix.width);

let paths: TracerPath[] = [];
let path: TracerPath | null;
while (!!(path = lineGroup.popLoopAsPath())) paths.push(path);

return paths;
}

private pollLines(): TracerLine[] | -1 {
const coord = this.pollUnprocessed();
if (coord === -1) return -1;
let lines: TracerLine[] = [];
this.pullLines(coord[0], coord[1], lines);
return lines;
}

private pullLines(x: number, y: number, lines: TracerLine[]): void {
if (this.traversed.get(x, y)) return;
this.traversed.set(x, y, true);
let nx: number;
let ny: number = y;
nx = x - 1;
this.getAt(nx, ny) ? this.pullLines(nx, ny, lines) : lines.push([ x, y + 1, x, y, 0 ]); // LEFT
nx = x + 1;
this.getAt(nx, ny) ? this.pullLines(nx, ny, lines) : lines.push([ x + 1, y, x + 1, y + 1, 1 ]); // RIGHT
nx = x;
ny = y - 1;
this.getAt(nx, ny) ? this.pullLines(nx, ny, lines) : lines.push([ x, y, x + 1, y, 2 ]); // UP
ny = y + 1;
this.getAt(nx, ny) ? this.pullLines(nx, ny, lines) : lines.push([ x + 1, y + 1, x, y + 1, 3 ]); // DOWN
}

private getAt<T extends number>(x: number, y: number): boolean {
const { width, height } = this.matrix;
if (y < 0) return false;
if (y >= height) return false;
if (x < 0) return false;
if (x >= width) return false;
return this.matrix.get(x, y) === 1;
}

private pollUnprocessed(): [ number, number ] | -1 {
const { width, height } = this.matrix;

let row: BitArray = new BitArray(width);
let unset: number;
for (let y=0; y < height; y++) {
row = this.traversed.getRow(y, row);
unset = row.getNextUnset();
if (unset < width) return [ unset, y ];
}

return -1;
}

}

class TracerLineGroup {

readonly lines: TracerLine[];
readonly width: number;
readonly traversed: BitArray;
readonly starts: { [index: number]: number[] } = { };
constructor(lines: TracerLine[], width: number) {
this.lines = lines;
this.width = width;
this.traversed = new BitArray(lines.length);
this.starts = { };
for (let i=0; i < lines.length; i++) {
const index: number = (lines[i][1] * width) + lines[i][0];
(this.starts[index] || (this.starts[index] = [])).push(i);
}
}

popLoopAsPath(): TracerPath | null {
const lines = this.popLoop();
if (!lines) return null;

const length = lines.length;
const xs: number[] = new Array(length);
const ys: number[] = new Array(length);

let line: TracerLine;
for (let i=0; i < length; i++) {
line = lines[i];
xs[i] = line[0];
ys[i] = line[1];
}

return { xs, ys, length };
}

popLoop(): TracerLine[] | null {
const startIndex = this.traversed.getNextUnset();
if (startIndex >= this.traversed.length) return null;
this.traversed.set(startIndex);

let cur: TracerLine = this.lines[startIndex];
let ret: TracerLine[] = [];

let nextIndex: number;
let next: TracerLine;
while (true) {
let starting = this.getStartingAt(cur[2], cur[3]);
if (!starting || starting.length < 1) break;

[ nextIndex, next ] = starting[0];
if (nextIndex === startIndex) break;
this.traversed.set(nextIndex);

ret.push(cur);
cur = next;
}

ret.push(cur);
return ret;
}

getStartingAt(x: number, y: number): [number, TracerLine][] {
const index: number = (y * this.width) + x;
let starts: number[] = this.starts[index];
if (!starts) return [];

let ret: [number, TracerLine][] = new Array(starts.length);
let count: number = 0;
for (let idx of starts) {
if (this.traversed.get(idx)) continue;
ret[count++] = [idx, this.lines[idx]];
}
ret.length = count;
return ret;
}

}

// Used for svgCollation = 1 | 2
type Strip = {
y: number,
/**
Expand Down Expand Up @@ -169,21 +417,33 @@ class StripMatrix {
popStrip(strip: Strip): Rect {
const x: number = strip.start;
const y: number = strip.y;
const width: number = strip.end - strip.start;
const end: number = strip.end;
const width: number = end - x;
let sy: number = y;
let idx: number;
let toAdd: Strip[] = new Array(2);
let numToAdd: number = 0;
do {
idx = this.map[this.calcIndex(x, sy)];
if (typeof idx === "undefined") break;
strip = this.strips[idx]

if (sy !== y) {
if (strip.start !== x) break;
if ((strip.end - strip.start) !== width) break;
if (strip.start > x) break;
if (strip.end < end) break;
if (strip.start < x) {
toAdd[numToAdd++] = { start: strip.start, end: x, y: sy };
}
if (strip.end > end) {
toAdd[numToAdd++] = { start: end, end: strip.end, y: sy };
}
}

this.popStrip0(sy, strip);

for (let i=0; i < numToAdd; i++) this.addStrip(toAdd[i]);
numToAdd = 0;

sy++;
} while (sy < this.height);

Expand Down
Loading

0 comments on commit 6c93244

Please sign in to comment.