isahaq-barcode 1.9.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,77 @@
1
1
  /**
2
- * QR Code Builder - Advanced QR code generation with logo and watermark support
2
+ * QR Code Builder - Advanced QR code generation with logo, label and watermark
3
+ *
4
+ * Plain QR codes are produced by the `qrcode` package alone. The optional
5
+ * `canvas` dependency is only loaded when a logo, label or watermark has to be
6
+ * composited on top of the code.
3
7
  */
4
8
 
5
9
  const QRCode = require('qrcode');
6
- const { createCanvas, loadImage } = require('canvas');
7
-
8
- // Conditional fs import for Node.js environments only
9
- let fs;
10
- try {
11
- fs = require('fs').promises;
12
- } catch {
13
- // fs not available in browser environment
14
- fs = null;
10
+
11
+ /** Cached result of the optional canvas lookup */
12
+ let canvasModule;
13
+
14
+ /**
15
+ * Load the optional canvas dependency
16
+ * @param {string} feature - Feature requiring canvas, used in the error message
17
+ * @returns {Object} The canvas module
18
+ */
19
+ function requireCanvas(feature) {
20
+ if (canvasModule === undefined) {
21
+ try {
22
+ canvasModule = require('canvas');
23
+ } catch {
24
+ canvasModule = null;
25
+ }
26
+ }
27
+
28
+ if (!canvasModule) {
29
+ throw new Error(
30
+ `${feature} requires the optional "canvas" dependency. Install it with: npm install canvas`
31
+ );
32
+ }
33
+
34
+ return canvasModule;
35
+ }
36
+
37
+ /**
38
+ * Convert an RGB array or CSS/hex colour into a hex string
39
+ * @param {Array<number>|string} color - Colour value
40
+ * @param {string} fallback - Fallback hex colour
41
+ * @returns {string} Hex colour including the leading '#'
42
+ */
43
+ function toHexColor(color, fallback) {
44
+ if (Array.isArray(color) && color.length >= 3) {
45
+ return `#${color
46
+ .slice(0, 3)
47
+ .map(component => {
48
+ const value = Math.max(0, Math.min(255, Number(component) || 0));
49
+ return value.toString(16).padStart(2, '0');
50
+ })
51
+ .join('')}`;
52
+ }
53
+
54
+ if (typeof color === 'string' && color.trim()) {
55
+ return color.trim().startsWith('#') ? color.trim() : `#${color.trim()}`;
56
+ }
57
+
58
+ return fallback;
59
+ }
60
+
61
+ /**
62
+ * Normalise the requested output format
63
+ * @param {string} format - Requested format
64
+ * @returns {string} One of png, jpeg or svg
65
+ */
66
+ function normaliseFormat(format) {
67
+ const value = String(format || 'png').toLowerCase();
68
+ if (value === 'jpg' || value === 'jpeg') {
69
+ return 'jpeg';
70
+ }
71
+ if (value === 'svg') {
72
+ return 'svg';
73
+ }
74
+ return 'png';
15
75
  }
16
76
 
17
77
  class QrCodeBuilder {
@@ -24,7 +84,7 @@ class QrCodeBuilder {
24
84
  foregroundColor: [0, 0, 0],
25
85
  backgroundColor: [255, 255, 255],
26
86
  logoPath: null,
27
- logoSize: 60, // Percentage of QR code size
87
+ logoSize: 20, // percentage of the QR code size
28
88
  label: null,
29
89
  labelFont: null,
30
90
  labelFontSize: 16,
@@ -85,8 +145,8 @@ class QrCodeBuilder {
85
145
  }
86
146
 
87
147
  /**
88
- * Set the foreground color
89
- * @param {Array} color - RGB color array [r, g, b]
148
+ * Set the foreground colour
149
+ * @param {Array<number>|string} color - RGB array or hex colour
90
150
  * @returns {QrCodeBuilder} Builder instance
91
151
  */
92
152
  foregroundColor(color) {
@@ -95,8 +155,8 @@ class QrCodeBuilder {
95
155
  }
96
156
 
97
157
  /**
98
- * Set the background color
99
- * @param {Array} color - RGB color array [r, g, b]
158
+ * Set the background colour
159
+ * @param {Array<number>|string} color - RGB array or hex colour
100
160
  * @returns {QrCodeBuilder} Builder instance
101
161
  */
102
162
  backgroundColor(color) {
@@ -106,7 +166,7 @@ class QrCodeBuilder {
106
166
 
107
167
  /**
108
168
  * Set the logo path
109
- * @param {string} logoPath - Path to logo image
169
+ * @param {string} logoPath - Path to a logo image
110
170
  * @returns {QrCodeBuilder} Builder instance
111
171
  */
112
172
  logoPath(logoPath) {
@@ -116,7 +176,7 @@ class QrCodeBuilder {
116
176
 
117
177
  /**
118
178
  * Set the logo size
119
- * @param {number} logoSize - Logo size as percentage of QR code size
179
+ * @param {number} logoSize - Logo size as a percentage of the QR code size
120
180
  * @returns {QrCodeBuilder} Builder instance
121
181
  */
122
182
  logoSize(logoSize) {
@@ -136,8 +196,8 @@ class QrCodeBuilder {
136
196
 
137
197
  /**
138
198
  * Set the label font
139
- * @param {string} fontPath - Path to font file
140
- * @param {number} fontSize - Font size
199
+ * @param {string} fontPath - Font family name or path to a font file
200
+ * @param {number} fontSize - Font size in pixels
141
201
  * @returns {QrCodeBuilder} Builder instance
142
202
  */
143
203
  labelFont(fontPath, fontSize = 16) {
@@ -160,7 +220,7 @@ class QrCodeBuilder {
160
220
 
161
221
  /**
162
222
  * Set the output format
163
- * @param {string} format - Output format (png, svg, jpg)
223
+ * @param {string} format - Output format (png, jpeg, svg)
164
224
  * @returns {QrCodeBuilder} Builder instance
165
225
  */
166
226
  format(format) {
@@ -169,39 +229,36 @@ class QrCodeBuilder {
169
229
  }
170
230
 
171
231
  /**
172
- * Build the QR code
173
- * @returns {Object} QR code result
232
+ * Build an immutable result object from the current options
233
+ * @returns {QrCodeResult} QR code result
174
234
  */
175
235
  build() {
176
- return new QrCodeResult(this.options);
236
+ return new QrCodeResult({ ...this.options });
177
237
  }
178
238
 
179
239
  /**
180
- * Generate QR code and return as buffer
181
- * @returns {Promise<Buffer>} QR code buffer
240
+ * Generate the QR code
241
+ * @returns {Promise<Buffer|string>} QR code buffer, or SVG markup
182
242
  */
183
243
  async generate() {
184
- const result = this.build();
185
- return await result.generate();
244
+ return this.build().generate();
186
245
  }
187
246
 
188
247
  /**
189
- * Generate QR code and save to file
248
+ * Generate the QR code and save it to a file
190
249
  * @param {string} filePath - Output file path
191
- * @returns {Promise<void>}
250
+ * @returns {Promise<string>} The path the file was written to
192
251
  */
193
252
  async saveToFile(filePath) {
194
- const result = this.build();
195
- return await result.saveToFile(filePath);
253
+ return this.build().saveToFile(filePath);
196
254
  }
197
255
 
198
256
  /**
199
- * Generate QR code and return as data URI
257
+ * Generate the QR code and return it as a data URI
200
258
  * @returns {Promise<string>} Data URI
201
259
  */
202
260
  async getDataUri() {
203
- const result = this.build();
204
- return await result.getDataUri();
261
+ return this.build().getDataUri();
205
262
  }
206
263
  }
207
264
 
@@ -213,202 +270,308 @@ class QrCodeResult {
213
270
  this.options = options;
214
271
  }
215
272
 
273
+ /**
274
+ * Whether the result needs canvas compositing
275
+ * @returns {boolean} True when a logo, watermark or label is configured
276
+ */
277
+ needsCompositing() {
278
+ return !!(
279
+ this.options.logoPath ||
280
+ this.options.watermark ||
281
+ this.options.label
282
+ );
283
+ }
284
+
216
285
  /**
217
286
  * Generate the QR code
218
- * @returns {Promise<Buffer>} QR code buffer
287
+ * @returns {Promise<Buffer|string>} QR code buffer, or SVG markup for svg format
219
288
  */
220
289
  async generate() {
221
- try {
222
- // Create canvas
223
- const canvas = createCanvas(this.options.size, this.options.size);
224
- const ctx = canvas.getContext('2d');
225
-
226
- // Set background
227
- ctx.fillStyle = this.rgbToHex(this.options.backgroundColor);
228
- ctx.fillRect(0, 0, this.options.size, this.options.size);
229
-
230
- // Generate QR code
231
- const qrCodeDataURL = await QRCode.toDataURL(this.options.data, {
232
- width: this.options.size - this.options.margin * 2,
233
- margin: 0,
234
- color: {
235
- dark: this.rgbToHex(this.options.foregroundColor),
236
- light: this.rgbToHex(this.options.backgroundColor),
237
- },
238
- errorCorrectionLevel: this.options.errorCorrectionLevel,
239
- });
240
-
241
- // Load QR code image
242
- const qrCodeImage = await loadImage(qrCodeDataURL);
243
- ctx.drawImage(qrCodeImage, this.options.margin, this.options.margin);
244
-
245
- // Add logo if specified
246
- if (this.options.logoPath) {
247
- await this.addLogo(ctx);
248
- }
290
+ if (!this.options.data) {
291
+ throw new Error('QR code generation failed: data is required');
292
+ }
249
293
 
250
- // Add watermark if specified
251
- if (this.options.watermark) {
252
- await this.addWatermark(ctx);
294
+ const format = normaliseFormat(this.options.format);
295
+
296
+ try {
297
+ if (format === 'svg') {
298
+ if (this.needsCompositing()) {
299
+ throw new Error(
300
+ 'SVG output does not support logo, label or watermark compositing. Use png or jpeg instead.'
301
+ );
302
+ }
303
+ return await QRCode.toString(this.options.data, {
304
+ type: 'svg',
305
+ width: this.qrWidth(),
306
+ margin: 0,
307
+ color: this.qrColors(),
308
+ errorCorrectionLevel: this.options.errorCorrectionLevel,
309
+ });
253
310
  }
254
311
 
255
- // Add label if specified
256
- if (this.options.label) {
257
- this.addLabel(ctx);
312
+ if (!this.needsCompositing()) {
313
+ return await QRCode.toBuffer(this.options.data, {
314
+ type: 'png',
315
+ width: this.qrWidth(),
316
+ margin: this.marginModules(),
317
+ color: this.qrColors(),
318
+ errorCorrectionLevel: this.options.errorCorrectionLevel,
319
+ });
258
320
  }
259
321
 
260
- // Convert to buffer
261
- return canvas.toBuffer(`image/${this.options.format}`);
322
+ return await this.composite(format);
262
323
  } catch (error) {
263
324
  throw new Error(`QR code generation failed: ${error.message}`);
264
325
  }
265
326
  }
266
327
 
267
328
  /**
268
- * Add logo to QR code
269
- * @param {CanvasRenderingContext2D} ctx - Canvas context
329
+ * Composite the QR code with logo, watermark and label using canvas
330
+ * @param {string} format - Output image format (png or jpeg)
331
+ * @returns {Promise<Buffer>} Image buffer
270
332
  */
271
- async addLogo(ctx) {
272
- try {
273
- const logoImage = await loadImage(this.options.logoPath);
274
- const logoSize = (this.options.size * this.options.logoSize) / 100;
275
- const logoX = (this.options.size - logoSize) / 2;
276
- const logoY = (this.options.size - logoSize) / 2;
333
+ async composite(format) {
334
+ const { createCanvas, loadImage } = requireCanvas(
335
+ 'Rendering a QR code with a logo, label or watermark'
336
+ );
337
+
338
+ const size = Number(this.options.size) || 300;
339
+ const margin = Number(this.options.margin) || 0;
340
+ const canvas = createCanvas(size, size);
341
+ const ctx = canvas.getContext('2d');
342
+
343
+ ctx.fillStyle = toHexColor(this.options.backgroundColor, '#ffffff');
344
+ ctx.fillRect(0, 0, size, size);
345
+
346
+ const qrBuffer = await QRCode.toBuffer(this.options.data, {
347
+ type: 'png',
348
+ width: Math.max(1, size - margin * 2),
349
+ margin: 0,
350
+ color: this.qrColors(),
351
+ errorCorrectionLevel: this.options.errorCorrectionLevel,
352
+ });
353
+
354
+ const qrImage = await loadImage(qrBuffer);
355
+ ctx.drawImage(
356
+ qrImage,
357
+ margin,
358
+ margin,
359
+ size - margin * 2,
360
+ size - margin * 2
361
+ );
362
+
363
+ if (this.options.logoPath) {
364
+ await this.addLogo(ctx, loadImage);
365
+ }
277
366
 
278
- // Draw white background for logo
279
- ctx.fillStyle = this.rgbToHex(this.options.backgroundColor);
280
- ctx.fillRect(logoX - 5, logoY - 5, logoSize + 10, logoSize + 10);
367
+ if (this.options.watermark) {
368
+ await this.addWatermark(ctx, loadImage);
369
+ }
281
370
 
282
- // Draw logo
283
- ctx.drawImage(logoImage, logoX, logoY, logoSize, logoSize);
284
- } catch (error) {
285
- console.warn(`Failed to add logo: ${error.message}`);
371
+ if (this.options.label) {
372
+ this.addLabel(ctx);
286
373
  }
374
+
375
+ return format === 'jpeg'
376
+ ? canvas.toBuffer('image/jpeg')
377
+ : canvas.toBuffer('image/png');
287
378
  }
288
379
 
289
380
  /**
290
- * Add watermark to QR code
291
- * @param {CanvasRenderingContext2D} ctx - Canvas context
381
+ * QR code pixel width, excluding margins
382
+ * @returns {number} Width in pixels
292
383
  */
293
- async addWatermark(ctx) {
294
- try {
295
- // Check if watermark is an image path
296
- if (
297
- this.options.watermark.includes('.') &&
298
- this.options.watermark.includes('/')
299
- ) {
300
- const watermarkImage = await loadImage(this.options.watermark);
301
- const watermarkSize = this.options.size / 4;
302
- const position = this.getWatermarkPosition(watermarkSize);
303
-
304
- ctx.drawImage(
305
- watermarkImage,
306
- position.x,
307
- position.y,
308
- watermarkSize,
309
- watermarkSize
310
- );
311
- } else {
312
- // Text watermark
313
- ctx.font = `${this.options.labelFontSize}px Arial`;
314
- ctx.fillStyle = this.rgbToHex(this.options.foregroundColor);
315
- ctx.textAlign = 'center';
316
-
317
- const position = this.getWatermarkPosition(0);
318
- ctx.fillText(this.options.watermark, position.x, position.y);
319
- }
320
- } catch (error) {
321
- console.warn(`Failed to add watermark: ${error.message}`);
384
+ qrWidth() {
385
+ const size = Number(this.options.size) || 300;
386
+ const margin = Number(this.options.margin) || 0;
387
+ return Math.max(21, size - margin * 2);
388
+ }
389
+
390
+ /**
391
+ * Margin expressed in QR modules, as expected by the qrcode package
392
+ * @returns {number} Margin in modules
393
+ */
394
+ marginModules() {
395
+ const size = Number(this.options.size) || 300;
396
+ const margin = Number(this.options.margin) || 0;
397
+ if (!margin) {
398
+ return 0;
322
399
  }
400
+
401
+ // Roughly convert a pixel margin into modules (a 25x25 code is typical)
402
+ return Math.max(0, Math.round((margin / Math.max(size, 1)) * 25));
323
403
  }
324
404
 
325
405
  /**
326
- * Add label to QR code
327
- * @param {CanvasRenderingContext2D} ctx - Canvas context
406
+ * Colour options for the qrcode package
407
+ * @returns {{dark: string, light: string}} Colour options
328
408
  */
329
- addLabel(ctx) {
330
- ctx.font = `${this.options.labelFontSize}px Arial`;
331
- ctx.fillStyle = this.rgbToHex(this.options.foregroundColor);
409
+ qrColors() {
410
+ return {
411
+ dark: toHexColor(this.options.foregroundColor, '#000000'),
412
+ light: toHexColor(this.options.backgroundColor, '#ffffff'),
413
+ };
414
+ }
415
+
416
+ /**
417
+ * Draw the logo in the centre of the QR code
418
+ * @param {Object} ctx - Canvas context
419
+ * @param {Function} loadImage - canvas loadImage function
420
+ * @returns {Promise<void>}
421
+ */
422
+ async addLogo(ctx, loadImage) {
423
+ const logoImage = await loadImage(this.options.logoPath);
424
+ const size = Number(this.options.size) || 300;
425
+ const percent = Math.max(
426
+ 1,
427
+ Math.min(40, Number(this.options.logoSize) || 20)
428
+ );
429
+ const logoSize = (size * percent) / 100;
430
+ const logoX = (size - logoSize) / 2;
431
+ const logoY = (size - logoSize) / 2;
432
+ const padding = Math.max(2, Math.round(logoSize * 0.08));
433
+
434
+ ctx.fillStyle = toHexColor(this.options.backgroundColor, '#ffffff');
435
+ ctx.fillRect(
436
+ logoX - padding,
437
+ logoY - padding,
438
+ logoSize + padding * 2,
439
+ logoSize + padding * 2
440
+ );
441
+
442
+ ctx.drawImage(logoImage, logoX, logoY, logoSize, logoSize);
443
+ }
444
+
445
+ /**
446
+ * Draw the watermark (text or image)
447
+ * @param {Object} ctx - Canvas context
448
+ * @param {Function} loadImage - canvas loadImage function
449
+ * @returns {Promise<void>}
450
+ */
451
+ async addWatermark(ctx, loadImage) {
452
+ const size = Number(this.options.size) || 300;
453
+
454
+ if (/\.(png|jpe?g|gif|webp|svg)$/i.test(String(this.options.watermark))) {
455
+ const watermarkImage = await loadImage(this.options.watermark);
456
+ const watermarkSize = size / 4;
457
+ const position = this.getWatermarkPosition(watermarkSize);
458
+
459
+ ctx.drawImage(
460
+ watermarkImage,
461
+ position.x,
462
+ position.y,
463
+ watermarkSize,
464
+ watermarkSize
465
+ );
466
+ return;
467
+ }
468
+
469
+ const fontSize = Number(this.options.labelFontSize) || 16;
470
+ ctx.font = `${fontSize}px ${this.options.labelFont || 'sans-serif'}`;
471
+ ctx.fillStyle = toHexColor(this.options.foregroundColor, '#000000');
332
472
  ctx.textAlign = 'center';
473
+ ctx.textBaseline = 'middle';
474
+
475
+ const position = this.getWatermarkPosition(0);
476
+ ctx.fillText(String(this.options.watermark), position.x, position.y);
477
+ }
333
478
 
334
- const labelY = this.options.size - 10;
335
- ctx.fillText(this.options.label, this.options.size / 2, labelY);
479
+ /**
480
+ * Draw the label along the bottom of the QR code
481
+ * @param {Object} ctx - Canvas context
482
+ */
483
+ addLabel(ctx) {
484
+ const size = Number(this.options.size) || 300;
485
+ const fontSize = Number(this.options.labelFontSize) || 16;
486
+
487
+ ctx.font = `${fontSize}px ${this.options.labelFont || 'sans-serif'}`;
488
+ ctx.fillStyle = toHexColor(this.options.foregroundColor, '#000000');
489
+ ctx.textAlign = 'center';
490
+ ctx.textBaseline = 'alphabetic';
491
+ ctx.fillText(String(this.options.label), size / 2, size - 10);
336
492
  }
337
493
 
338
494
  /**
339
- * Get watermark position
340
- * @param {number} size - Watermark size
341
- * @returns {Object} Position coordinates
495
+ * Resolve watermark coordinates for the configured position
496
+ * @param {number} size - Watermark size in pixels
497
+ * @returns {{x: number, y: number}} Position coordinates
342
498
  */
343
499
  getWatermarkPosition(size) {
500
+ const canvasSize = Number(this.options.size) || 300;
501
+ const inset = 10;
502
+ const center = (canvasSize - size) / 2;
503
+ const far = canvasSize - size - inset;
504
+
344
505
  const positions = {
345
- 'top-left': { x: 10, y: 10 },
346
- 'top-right': { x: this.options.size - size - 10, y: 10 },
347
- 'bottom-left': { x: 10, y: this.options.size - size - 10 },
348
- 'bottom-right': {
349
- x: this.options.size - size - 10,
350
- y: this.options.size - size - 10,
351
- },
352
- center: {
353
- x: (this.options.size - size) / 2,
354
- y: (this.options.size - size) / 2,
355
- },
356
- 'top-center': { x: (this.options.size - size) / 2, y: 10 },
357
- 'bottom-center': {
358
- x: (this.options.size - size) / 2,
359
- y: this.options.size - size - 10,
360
- },
361
- 'left-center': { x: 10, y: (this.options.size - size) / 2 },
362
- 'right-center': {
363
- x: this.options.size - size - 10,
364
- y: (this.options.size - size) / 2,
365
- },
506
+ 'top-left': { x: inset, y: inset },
507
+ 'top-center': { x: center, y: inset },
508
+ 'top-right': { x: far, y: inset },
509
+ 'left-center': { x: inset, y: center },
510
+ center: { x: center, y: center },
511
+ 'right-center': { x: far, y: center },
512
+ 'bottom-left': { x: inset, y: far },
513
+ 'bottom-center': { x: center, y: far },
514
+ 'bottom-right': { x: far, y: far },
366
515
  };
367
516
 
368
517
  return positions[this.options.watermarkPosition] || positions.center;
369
518
  }
370
519
 
371
520
  /**
372
- * Convert RGB array to hex color
373
- * @param {Array} rgb - RGB color array
374
- * @returns {string} Hex color
521
+ * Convert an RGB array to a hex colour
522
+ * @param {Array<number>|string} rgb - RGB colour array
523
+ * @returns {string} Hex colour
375
524
  */
376
525
  rgbToHex(rgb) {
377
- return `#${rgb.map(c => c.toString(16).padStart(2, '0')).join('')}`;
526
+ return toHexColor(rgb, '#000000');
378
527
  }
379
528
 
380
529
  /**
381
- * Save QR code to file
530
+ * Save the QR code to a file
382
531
  * @param {string} filePath - Output file path
383
- * @returns {Promise<void>}
532
+ * @returns {Promise<string>} The path the file was written to
384
533
  */
385
534
  async saveToFile(filePath) {
386
- if (!fs) {
535
+ if (!filePath) {
536
+ throw new Error('A file path is required');
537
+ }
538
+
539
+ let fs;
540
+ try {
541
+ fs = require('fs').promises;
542
+ } catch {
387
543
  throw new Error(
388
- 'File system operations are not available in browser environments. Use generate() or getDataUri() methods instead.'
544
+ 'File system operations are not available in this environment. Use generate() or getDataUri() instead.'
389
545
  );
390
546
  }
391
- const buffer = await this.generate();
392
- await fs.writeFile(filePath, buffer);
547
+
548
+ await fs.writeFile(filePath, await this.generate());
549
+ return filePath;
393
550
  }
394
551
 
395
552
  /**
396
- * Get QR code as data URI
553
+ * Get the QR code as a data URI
397
554
  * @returns {Promise<string>} Data URI
398
555
  */
399
556
  async getDataUri() {
400
- const buffer = await this.generate();
401
- const base64 = buffer.toString('base64');
402
- return `data:image/${this.options.format};base64,${base64}`;
557
+ const format = normaliseFormat(this.options.format);
558
+ const output = await this.generate();
559
+
560
+ if (format === 'svg') {
561
+ return `data:image/svg+xml;base64,${Buffer.from(output).toString('base64')}`;
562
+ }
563
+
564
+ return `data:image/${format};base64,${output.toString('base64')}`;
403
565
  }
404
566
 
405
567
  /**
406
- * Get QR code as string
407
- * @returns {Promise<string>} QR code string
568
+ * Get the QR code output (alias of generate)
569
+ * @returns {Promise<Buffer|string>} QR code output
408
570
  */
409
571
  async getString() {
410
- return await this.generate();
572
+ return this.generate();
411
573
  }
412
574
  }
413
575
 
414
576
  module.exports = QrCodeBuilder;
577
+ module.exports.QrCodeResult = QrCodeResult;