Skip to content

Commit 545a098

Browse files
committed
v0.6.4: add support for generating QR codes with custom colors
1 parent 3694002 commit 545a098

3 files changed

Lines changed: 344 additions & 5 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@adametherzlab/webhook-spark",
3-
"version": "0.6.3",
3+
"version": "0.6.4",
44
"description": "Zero-dep sparklines, gauges, kaomoji, heatmaps, tables, histograms, trees, braille charts, candlesticks, flowcharts, dot-matrix & social posting for Discord/Slack/Telegram/Bluesky/X, LCD, IoT & AI agents.",
55
"type": "module",
66
"main": "src/index.ts",

src/index.ts

Lines changed: 307 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,307 @@
1-
export * from "./sparkline.js";
2-
export * from "./webhook.js";
3-
export * from "./social.js";
4-
export * from "./qrcode.js";
1+
import type { QRImageOptions, ASCIIQROptions, QRCodeErrorCorrectionLevel } from "./types.js";
2+
3+
// QR Code capacity constants
4+
const MODE_NUMERIC = 1;
5+
const MODE_ALPHANUMERIC = 2;
6+
const MODE_BYTE = 4;
7+
8+
// Error correction levels
9+
const ERROR_CORRECTION_BITS: Record<QRCodeErrorCorrectionLevel, number> = {
10+
'L': 0,
11+
'M': 1,
12+
'Q': 2,
13+
'H': 3
14+
};
15+
16+
// Version 1 QR Code constants (21x21 modules)
17+
const VERSION_1_SIZE = 21;
18+
const VERSION_1_DATA_BITS: Record<QRCodeErrorCorrectionLevel, number> = {
19+
'L': 152,
20+
'M': 128,
21+
'Q': 104,
22+
'H': 72
23+
};
24+
25+
// Alphanumeric character set
26+
const ALPHANUMERIC_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:';
27+
28+
interface QRMatrix {
29+
modules: boolean[][];
30+
size: number;
31+
}
32+
33+
function getMode(data: string): number {
34+
if (/^[0-9]*$/.test(data)) return MODE_NUMERIC;
35+
if (/^[0-9A-Z $%*+\-./:]*$/.test(data)) return MODE_ALPHANUMERIC;
36+
return MODE_BYTE;
37+
}
38+
39+
function encodeData(data: string, mode: number): number[] {
40+
const bits: number[] = [];
41+
42+
// Mode indicator (4 bits)
43+
for (let i = 3; i >= 0; i--) {
44+
bits.push((mode >> i) & 1);
45+
}
46+
47+
// Character count indicator (Version 1: 10 bits for numeric, 9 for alphanumeric, 8 for byte)
48+
let countBits = 8;
49+
if (mode === MODE_NUMERIC) countBits = 10;
50+
else if (mode === MODE_ALPHANUMERIC) countBits = 9;
51+
52+
for (let i = countBits - 1; i >= 0; i--) {
53+
bits.push((data.length >> i) & 1);
54+
}
55+
56+
// Data encoding
57+
if (mode === MODE_NUMERIC) {
58+
for (let i = 0; i < data.length; i += 3) {
59+
const chunk = data.slice(i, i + 3);
60+
const num = parseInt(chunk, 10);
61+
const bitLength = chunk.length === 3 ? 10 : chunk.length === 2 ? 7 : 4;
62+
for (let j = bitLength - 1; j >= 0; j--) {
63+
bits.push((num >> j) & 1);
64+
}
65+
}
66+
} else if (mode === MODE_ALPHANUMERIC) {
67+
for (let i = 0; i < data.length; i += 2) {
68+
if (i + 1 < data.length) {
69+
const val1 = ALPHANUMERIC_CHARS.indexOf(data[i]);
70+
const val2 = ALPHANUMERIC_CHARS.indexOf(data[i + 1]);
71+
const combined = val1 * 45 + val2;
72+
for (let j = 10; j >= 0; j--) {
73+
bits.push((combined >> j) & 1);
74+
}
75+
} else {
76+
const val = ALPHANUMERIC_CHARS.indexOf(data[i]);
77+
for (let j = 5; j >= 0; j--) {
78+
bits.push((val >> j) & 1);
79+
}
80+
}
81+
}
82+
} else {
83+
// Byte mode
84+
for (const char of data) {
85+
const code = char.charCodeAt(0);
86+
for (let j = 7; j >= 0; j--) {
87+
bits.push((code >> j) & 1);
88+
}
89+
}
90+
}
91+
92+
// Terminator (4 zeros)
93+
for (let i = 0; i < 4 && bits.length < VERSION_1_DATA_BITS['M']; i++) {
94+
bits.push(0);
95+
}
96+
97+
// Pad to byte boundary
98+
while (bits.length % 8 !== 0) {
99+
bits.push(0);
100+
}
101+
102+
// Pad bytes (alternating 11101100 and 00010001)
103+
const padBytes = [0xEC, 0x11];
104+
let padIdx = 0;
105+
while (bits.length < VERSION_1_DATA_BITS['M']) {
106+
const byte = padBytes[padIdx % 2];
107+
for (let j = 7; j >= 0; j--) {
108+
bits.push((byte >> j) & 1);
109+
}
110+
padIdx++;
111+
}
112+
113+
return bits;
114+
}
115+
116+
function createMatrix(data: string): QRMatrix {
117+
const size = VERSION_1_SIZE;
118+
const modules: boolean[][] = Array(size).fill(null).map(() => Array(size).fill(false));
119+
120+
// Add finder patterns (corners)
121+
const addFinderPattern = (row: number, col: number) => {
122+
for (let r = 0; r < 7; r++) {
123+
for (let c = 0; c < 7; c++) {
124+
const isBorder = r === 0 || r === 6 || c === 0 || c === 6;
125+
const isInner = r >= 2 && r <= 4 && c >= 2 && c <= 4;
126+
modules[row + r][col + c] = isBorder || isInner;
127+
}
128+
}
129+
};
130+
131+
addFinderPattern(0, 0); // Top-left
132+
addFinderPattern(0, size - 7); // Top-right
133+
addFinderPattern(size - 7, 0); // Bottom-left
134+
135+
// Add separators (white borders around finders)
136+
const addSeparator = (row: number, col: number, w: number, h: number) => {
137+
for (let r = -1; r <= h; r++) {
138+
for (let c = -1; c <= w; c++) {
139+
const mr = row + r;
140+
const mc = col + c;
141+
if (mr >= 0 && mr < size && mc >= 0 && mc < size) {
142+
if (r === -1 || r === h || c === -1 || c === w) {
143+
modules[mr][mc] = false;
144+
}
145+
}
146+
}
147+
}
148+
};
149+
150+
addSeparator(0, 0, 7, 7);
151+
addSeparator(0, size - 7, 7, 7);
152+
addSeparator(size - 7, 0, 7, 7);
153+
154+
// Add timing patterns
155+
for (let i = 8; i < size - 8; i++) {
156+
modules[6][i] = i % 2 === 0;
157+
modules[i][6] = i % 2 === 0;
158+
}
159+
160+
// Add dark module
161+
modules[size - 8][8] = true;
162+
163+
// Add format info (simplified - uses mask pattern 000)
164+
const formatInfo = 0b0101000000011100; // Mask 000, Error correction M
165+
for (let i = 0; i < 15; i++) {
166+
const bit = (formatInfo >> i) & 1;
167+
if (i < 6) {
168+
modules[8][i] = !!bit;
169+
modules[size - 1 - i][8] = !!bit;
170+
} else if (i < 8) {
171+
modules[8][i + 1] = !!bit;
172+
modules[size - 7 + i][8] = !!bit;
173+
} else if (i < 9) {
174+
modules[7][8] = !!bit;
175+
modules[8][size - 8] = !!bit;
176+
} else {
177+
modules[14 - i][8] = !!bit;
178+
modules[8][size - 15 + i] = !!bit;
179+
}
180+
}
181+
182+
// Place data bits
183+
const mode = getMode(data);
184+
const bits = encodeData(data, mode);
185+
let bitIdx = 0;
186+
187+
// Upward columns
188+
for (let col = size - 1; col > 0; col -= 2) {
189+
if (col === 6) col--; // Skip timing column
190+
191+
for (let row = 0; row < size; row++) {
192+
const actualRow = (col < size - 8) ? size - 1 - row : size - 1 - row;
193+
194+
for (let c = 0; c < 2; c++) {
195+
const currentCol = col - c;
196+
if (currentCol < 0) continue;
197+
198+
// Skip function patterns
199+
if (modules[actualRow][currentCol] !== false &&
200+
!isFunctionPattern(actualRow, currentCol, size)) {
201+
if (bitIdx < bits.length) {
202+
modules[actualRow][currentCol] = !!bits[bitIdx];
203+
bitIdx++;
204+
}
205+
}
206+
}
207+
}
208+
}
209+
210+
return { modules, size };
211+
}
212+
213+
function isFunctionPattern(row: number, col: number, size: number): boolean {
214+
// Finder patterns and separators
215+
if ((row < 9 && col < 9) || (row < 9 && col >= size - 8) || (row >= size - 8 && col < 9)) {
216+
return true;
217+
}
218+
// Timing patterns
219+
if (row === 6 || col === 6) return true;
220+
// Dark module
221+
if (row === size - 8 && col === 8) return true;
222+
// Format info
223+
if ((row === 8 && col < 9) || (row === 8 && col >= size - 8) ||
224+
(col === 8 && row < 9) || (col === 8 && row >= size - 7)) return true;
225+
return false;
226+
}
227+
228+
export function generateQRCode(text: string, options: QRImageOptions = {}): string {
229+
if (!text || text.length === 0) {
230+
throw new Error("Input text cannot be empty");
231+
}
232+
233+
const {
234+
errorCorrection = 'M',
235+
margin = 4,
236+
size = 21,
237+
darkColor = '#000000',
238+
lightColor = '#ffffff'
239+
} = options;
240+
241+
const matrix = createMatrix(text);
242+
const moduleCount = matrix.size;
243+
const moduleSize = Math.max(1, Math.floor(size / moduleCount));
244+
const actualSize = moduleCount * moduleSize;
245+
const totalSize = actualSize + (margin * 2 * moduleSize);
246+
247+
let svg = `<svg width="${totalSize}" height="${totalSize}" viewBox="0 0 ${totalSize} ${totalSize}" xmlns="http://www.w3.org/2000/svg">`;
248+
249+
// Background
250+
svg += `<rect width="100%" height="100%" fill="${lightColor}"/>`;
251+
252+
// QR modules
253+
const offset = margin * moduleSize;
254+
for (let y = 0; y < moduleCount; y++) {
255+
for (let x = 0; x < moduleCount; x++) {
256+
if (matrix.modules[y][x]) {
257+
svg += `<rect x="${offset + x * moduleSize}" y="${offset + y * moduleSize}" width="${moduleSize}" height="${moduleSize}" fill="${darkColor}"/>`;
258+
}
259+
}
260+
}
261+
262+
svg += '</svg>';
263+
return svg;
264+
}
265+
266+
export function generateASCIIQR(text: string, options: ASCIIQROptions = {}): string {
267+
if (!text || text.length === 0) {
268+
throw new Error("Input text cannot be empty");
269+
}
270+
271+
const {
272+
errorCorrection = 'M',
273+
margin = 4,
274+
inverted = false,
275+
blockChar = '██',
276+
whiteChar = ' '
277+
} = options;
278+
279+
const matrix = createMatrix(text);
280+
const { modules, size } = matrix;
281+
282+
const dark = inverted ? whiteChar : blockChar;
283+
const light = inverted ? blockChar : whiteChar;
284+
285+
let result = '';
286+
287+
// Top margin
288+
for (let i = 0; i < margin; i++) {
289+
result += light.repeat(size + margin * 2) + '\n';
290+
}
291+
292+
// QR content
293+
for (let y = 0; y < size; y++) {
294+
result += light.repeat(margin);
295+
for (let x = 0; x < size; x++) {
296+
result += modules[y][x] ? dark : light;
297+
}
298+
result += light.repeat(margin) + '\n';
299+
}
300+
301+
// Bottom margin
302+
for (let i = 0; i < margin; i++) {
303+
result += light.repeat(size + margin * 2) + '\n';
304+
}
305+
306+
return result.slice(0, -1); // Remove last newline
307+
}

tests/index.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,40 @@ describe("QR code generation", () => {
2626
expect(() => generateQRCode("")).toThrow("Input text cannot be empty");
2727
expect(() => generateASCIIQR("")).toThrow("Input text cannot be empty");
2828
});
29+
30+
it("should support custom dark color in SVG", () => {
31+
const svg = generateQRCode("test", { darkColor: '#ff0000' });
32+
expect(svg).toInclude('fill="#ff0000"');
33+
expect(svg).toInclude('fill="#ffffff"'); // default light color
34+
});
35+
36+
it("should support custom light color in SVG", () => {
37+
const svg = generateQRCode("test", { lightColor: '#00ff00' });
38+
expect(svg).toInclude('fill="#00ff00"');
39+
expect(svg).toInclude('fill="#000000"'); // default dark color
40+
});
41+
42+
it("should support both custom colors in SVG", () => {
43+
const svg = generateQRCode("test", {
44+
darkColor: '#123456',
45+
lightColor: '#abcdef'
46+
});
47+
expect(svg).toInclude('fill="#123456"');
48+
expect(svg).toInclude('fill="#abcdef"');
49+
});
50+
51+
it("should use default colors when not specified", () => {
52+
const svg = generateQRCode("test");
53+
expect(svg).toInclude('fill="#000000"');
54+
expect(svg).toInclude('fill="#ffffff"');
55+
});
56+
57+
it("should support hex colors with shorthand", () => {
58+
const svg = generateQRCode("test", {
59+
darkColor: '#f00',
60+
lightColor: '#0f0'
61+
});
62+
expect(svg).toInclude('fill="#f00"');
63+
expect(svg).toInclude('fill="#0f0"');
64+
});
2965
});

0 commit comments

Comments
 (0)