@vkontakte/vk-qr 3.0.0 → 3.1.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.
package/src/svg.ts CHANGED
@@ -1,82 +1,25 @@
1
1
  import type { QrCode } from './qr.ts';
2
2
  import type { RequiredQrOptions } from './types.ts';
3
-
4
- const MULTI = 1;
5
- const QR_BORDER = 7;
6
- const TILE_SIZE = 96 * MULTI;
7
- export const INC_TILE_SIZE = 96 * MULTI;
8
- const SMALL_QR_SIZE = 25;
9
-
10
- /**
11
- * Returns the color of the module (pixel) at the given coordinates, which is false
12
- * for white or true for black. The top left corner has the coordinates (x=0, y=0).
13
- * If the given coordinates are out of bounds, then false (white) is returned.
14
- */
15
- const getPixel = (
16
- x: number,
17
- y: number,
18
- size: number,
19
- modules: boolean[][],
20
- isLogoShowed: boolean,
21
- ) => {
22
- if (x < QR_BORDER && y < QR_BORDER) {
23
- return false;
24
- }
25
-
26
- if (x >= size - QR_BORDER && y < QR_BORDER) {
27
- return false;
28
- }
29
-
30
- if (x < QR_BORDER && y >= size - QR_BORDER) {
31
- return false;
32
- }
33
-
34
- if (isLogoShowed) {
35
- let imageTiles = QR_BORDER + 2;
36
- if (size <= SMALL_QR_SIZE) {
37
- imageTiles--;
38
- }
39
-
40
- let paddingTiles = (size - QR_BORDER * 2 - imageTiles) / 2 - 1;
41
- if (size <= SMALL_QR_SIZE) {
42
- paddingTiles++;
43
- }
44
-
45
- if (
46
- x > QR_BORDER + paddingTiles &&
47
- x < size - QR_BORDER - paddingTiles - 1 &&
48
- y > QR_BORDER + paddingTiles &&
49
- y < size - QR_BORDER - paddingTiles - 1
50
- ) {
51
- return false;
52
- }
53
-
54
- return 0 <= x && x < size && 0 <= y && y < size && modules[y][x];
55
- }
56
-
57
- return modules[y]?.[x];
58
- };
59
-
60
- const getNeighbors = (
61
- x: number,
62
- y: number,
63
- size: number,
64
- modules: boolean[][],
65
- isLogoShowed = true,
66
- ) => ({
67
- l: getPixel(x - 1, y, size, modules, isLogoShowed),
68
- r: getPixel(x + 1, y, size, modules, isLogoShowed),
69
- t: getPixel(x, y - 1, size, modules, isLogoShowed),
70
- b: getPixel(x, y + 1, size, modules, isLogoShowed),
71
- current: getPixel(x, y, size, modules, isLogoShowed),
72
- });
73
-
74
- /**
75
- * Converts QrCode instance to SVG code
76
- * @param qrCode QrCode instance
77
- * @param options Convertation options
78
- */
79
- export const convertSegmentsToSvgString = (qrCode: QrCode, options: RequiredQrOptions): string => {
3
+ import { extractFigures, isFinderPattern } from './figures.ts';
4
+ import { figureToSvgPath } from './svg-path.ts';
5
+ import {
6
+ FINDER_PATTERN_SIZE,
7
+ TILE_SIZE,
8
+ SMALL_QR_SIZE,
9
+ FINDER_CORNER_RADIUS,
10
+ } from './constants.ts';
11
+
12
+ export const INC_TILE_SIZE = TILE_SIZE;
13
+
14
+ interface BackgroundLayout {
15
+ size: number;
16
+ padding: number;
17
+ radius: number;
18
+ hideQr: boolean;
19
+ }
20
+
21
+ /** Converts a QR module matrix to SVG. */
22
+ export function convertSegmentsToSvgString(qrCode: QrCode, options: RequiredQrOptions): string {
80
23
  if (typeof options.qrSize !== 'number') {
81
24
  throw new Error('Size should be a number');
82
25
  }
@@ -85,223 +28,146 @@ export const convertSegmentsToSvgString = (qrCode: QrCode, options: RequiredQrOp
85
28
  throw new Error('Classname should be a string');
86
29
  }
87
30
 
88
- const _2 = 2 * MULTI;
89
- const _12_7 = 12.7 * MULTI;
90
- const _12_8 = 12.8 * MULTI;
91
- const _14_7 = 14.7 * MULTI;
92
- const _14_8 = 14.8 * MULTI;
93
- const _15_9 = 15.9 * MULTI;
94
- const _28_6 = 28.6 * MULTI;
95
- const _30_5 = 30.5 * MULTI;
96
- const _84_7 = 84.7776815 * MULTI;
97
- const _87_3 = 87.3 * MULTI;
98
- const _71_4 = 71.4 * MULTI;
99
- const _42_9 = 42.9 * MULTI;
100
- const _87_2 = 87.2 * MULTI;
101
- const _85_2 = 85.2 * MULTI;
102
- const _85_3 = 85.3 * MULTI;
103
- const _69_5 = 69.5 * MULTI;
104
- const _98 = 98 * MULTI;
105
- const _100 = 100 * MULTI;
31
+ const background = getBackgroundLayout(qrCode.size, options);
32
+ const parts = renderFinderPatterns(qrCode.size, options);
33
+ if (options.isShowLogo) parts.push(renderLogo(qrCode.size, options));
106
34
 
107
- const parts = [];
108
- let leftPadding = 0;
109
- let topPadding = 0;
110
- let xCoord = 0;
111
- let yCoord = 0;
112
-
113
- for (let y = 0; y < qrCode.size; y++) {
114
- leftPadding = 0;
115
- for (let x = 0; x < qrCode.size; x++) {
116
- xCoord = x + leftPadding;
117
- leftPadding += TILE_SIZE;
118
- yCoord = y + topPadding;
35
+ return `
36
+ <svg
37
+ version="1.1"
38
+ viewBox="${-background.padding} ${-background.padding} ${background.size} ${background.size}"
39
+ width="${options.qrSize}px"
40
+ height="${options.qrSize}px"
41
+ style="display: block"
42
+ ${options.className ? `class="${options.className}"` : ''}
43
+ xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
44
+ >
45
+ ${renderDefinitions(options)}
119
46
 
120
- const neighbors = getNeighbors(x, y, qrCode.size, qrCode.modules, options.isShowLogo);
47
+ ${renderBackground(background, options)}
121
48
 
122
- let path = '';
123
- let selector = '';
49
+ <g${background.hideQr ? ' display="none"' : ''}>
50
+ ${renderModules(qrCode, options)}
51
+ ${parts.join('\n')}
52
+ </g>
124
53
 
125
- if (neighbors.current) {
126
- selector =
127
- !selector && !neighbors.l && !neighbors.r && !neighbors.t && !neighbors.b ? 'empty' : '';
128
- selector =
129
- (!selector && neighbors.l && neighbors.r) || (neighbors.t && neighbors.b) ? 'rect' : '';
130
- if (!selector) {
131
- selector += neighbors.l ? 'l' : neighbors.r ? 'r' : '';
132
- selector += neighbors.t ? 't' : neighbors.b ? 'b' : '';
133
- if (!selector) {
134
- selector = 'empty';
135
- }
136
- }
137
- } else {
138
- selector =
139
- !selector &&
140
- neighbors.l &&
141
- neighbors.t &&
142
- getPixel(x - 1, y - 1, qrCode.size, qrCode.modules, options.isShowLogo)
143
- ? 'n_lt'
144
- : '';
145
- selector =
146
- !selector &&
147
- neighbors.l &&
148
- neighbors.b &&
149
- getPixel(x - 1, y + 1, qrCode.size, qrCode.modules, options.isShowLogo)
150
- ? 'n_lb'
151
- : '';
152
- selector =
153
- !selector &&
154
- neighbors.r &&
155
- neighbors.t &&
156
- getPixel(x + 1, y - 1, qrCode.size, qrCode.modules, options.isShowLogo)
157
- ? 'n_rt'
158
- : '';
159
- selector =
160
- !selector &&
161
- neighbors.r &&
162
- neighbors.b &&
163
- getPixel(x + 1, y + 1, qrCode.size, qrCode.modules, options.isShowLogo)
164
- ? 'n_rb'
165
- : '';
166
- }
54
+ </svg>`;
55
+ }
56
+
57
+ function getVisibleModules(qrCode: QrCode, isShowLogo: boolean): boolean[][] {
58
+ if (!isShowLogo) return qrCode.modules;
59
+
60
+ // Clear the logo area before tracing contours so transparent logos retain
61
+ // their quiet zone and neighboring modules do not connect through it.
62
+ const logoAreaSize = qrCode.size <= SMALL_QR_SIZE ? FINDER_PATTERN_SIZE : FINDER_PATTERN_SIZE + 2;
63
+ const logoAreaStart = (qrCode.size - logoAreaSize) / 2;
64
+ return qrCode.modules.map((row, y) =>
65
+ row.map(
66
+ (pixel, x) =>
67
+ pixel &&
68
+ !(
69
+ x >= logoAreaStart &&
70
+ x < logoAreaStart + logoAreaSize &&
71
+ y >= logoAreaStart &&
72
+ y < logoAreaStart + logoAreaSize
73
+ ),
74
+ ),
75
+ );
76
+ }
167
77
 
168
- if (!selector) {
169
- continue;
170
- }
171
- path = `<use xlink:href="#${selector}-${options.suffix}"/>`;
172
- parts.push(`<g transform="translate(${xCoord},${yCoord})">${path}</g>`);
173
- }
174
- topPadding += TILE_SIZE;
175
- }
78
+ function renderModules(qrCode: QrCode, options: RequiredQrOptions): string {
79
+ return extractFigures(getVisibleModules(qrCode, options.isShowLogo))
80
+ .filter((figure) => !isFinderPattern(figure, qrCode.size))
81
+ .map(
82
+ (figure) => `
83
+ <path
84
+ d="${figureToSvgPath(figure)}"
85
+ fill="${options.foregroundColor}"
86
+ fill-rule="evenodd"
87
+ />`,
88
+ )
89
+ .join('');
90
+ }
91
+
92
+ function renderFinderPatterns(size: number, options: RequiredQrOptions): string[] {
93
+ const pointPosition = (size - FINDER_PATTERN_SIZE) * TILE_SIZE;
94
+ return [
95
+ `<use fill-rule="evenodd" transform="translate(0,0)" xlink:href="#point-${options.suffix}"/>`,
96
+ `<use fill-rule="evenodd" transform="translate(${pointPosition},0)" xlink:href="#point-${options.suffix}"/>`,
97
+ `<use fill-rule="evenodd" transform="translate(0,${pointPosition})" xlink:href="#point-${options.suffix}"/>`,
98
+ ];
99
+ }
176
100
 
101
+ function renderLogo(size: number, options: RequiredQrOptions): string {
177
102
  let scale = '';
178
- let position = ((qrCode.size - QR_BORDER * 3) / 2) * TILE_SIZE + TILE_SIZE * QR_BORDER - 10;
179
- if (qrCode.size <= SMALL_QR_SIZE) {
103
+ let position =
104
+ ((size - FINDER_PATTERN_SIZE * 3) / 2) * TILE_SIZE + TILE_SIZE * FINDER_PATTERN_SIZE - 10;
105
+ if (size <= SMALL_QR_SIZE) {
180
106
  scale = 'scale(0.85)';
181
107
  position += 50;
182
108
  }
183
109
 
184
- const pointPosition = (qrCode.size - QR_BORDER) * options.incTileSize;
185
- parts.push(
186
- `<use fill-rule="evenodd" transform="translate(0,0)" xlink:href="#point-${options.suffix}"/>`,
187
- );
188
- parts.push(
189
- `<use fill-rule="evenodd" transform="translate(${pointPosition},0)" xlink:href="#point-${options.suffix}"/>`,
190
- );
191
- parts.push(
192
- `<use fill-rule="evenodd" transform="translate(0,${pointPosition})" xlink:href="#point-${options.suffix}"/>`,
193
- );
194
-
195
- if (options.isShowLogo) {
196
- if (!options.logoData) {
197
- parts.push(`
110
+ if (!options.logoData) {
111
+ return `
198
112
  <use style="width: 750px; height: 750px;" width="750" height="750"
199
113
  fill="none"
200
114
  fill-rule="evenodd"
201
115
  transform="translate(${position},${position}) ${scale}" xlink:href="#vk_logo-${options.suffix}"
202
116
  />
203
- `);
204
- } else {
205
- parts.push(`
117
+ `;
118
+ }
119
+
120
+ return `
206
121
  <image
207
122
  preserveAspectRatio="xMidYMid slice" clip-path="url(#logo-mask-${options.suffix})"
208
123
  style="width: 750px; height: 750px;" width="750" height="750"
209
124
  transform="translate(${position},${position}) ${scale}"
210
125
  xlink:href="${options.logoData}"
211
126
  />
212
- `);
213
- }
127
+ `;
128
+ }
129
+
130
+ function getBackgroundLayout(size: number, options: RequiredQrOptions): BackgroundLayout {
131
+ const contentSize = TILE_SIZE * size;
132
+ if (!options.isShowBackground) {
133
+ return { size: contentSize, padding: 0, radius: 0, hideQr: false };
214
134
  }
215
135
 
216
- const backgroundSize = 99 * qrCode.size;
217
- let qrBackground = '';
218
- let qrTransform = 'translate(0,0)';
136
+ const frameWidth = Math.min(options.backgroundPadding, options.qrSize / 2);
137
+ const contentWidth = options.qrSize - frameWidth * 2;
138
+ const hideQr = contentWidth <= 0;
139
+ const backgroundSize = hideQr ? contentSize : (contentSize * options.qrSize) / contentWidth;
140
+ const padding = (backgroundSize - contentSize) / 2;
141
+ const radius =
142
+ (Math.min(options.backgroundRadius, options.qrSize / 2) / options.qrSize) * backgroundSize;
143
+
144
+ return { size: backgroundSize, padding, radius, hideQr };
145
+ }
219
146
 
220
- if (options.isShowBackground) {
221
- const qrScale = (options.qrSize - 20 * 2) / options.qrSize; // 0.756972112
222
- const padding = (backgroundSize / options.qrSize) * 21;
223
- const radius = Math.ceil(backgroundSize / (options.qrSize / 36)); // 7.11111111 = 256px/36px radius
147
+ function renderBackground(layout: BackgroundLayout, options: RequiredQrOptions): string {
148
+ if (!options.isShowBackground) return '';
224
149
 
225
- qrBackground = `
150
+ const { size, padding, radius } = layout;
151
+ return `
226
152
  <rect
227
- x="0"
228
- width="${backgroundSize}"
229
- height="${backgroundSize}"
153
+ x="${-padding}"
154
+ y="${-padding}"
155
+ width="${size}"
156
+ height="${size}"
230
157
  rx="${radius}"
231
158
  fill="${options.backgroundColor}"
232
159
  />`;
233
- qrTransform = `translate(${padding}, ${padding}) scale(${qrScale})`;
234
- }
235
-
236
- // eslint-disable-next-line
237
- const oneSide = `M0,0 L66,0 C${_84_7},-3.44940413e-15 ${_100},15.2223185 ${_100},34 L${_100},66 C${_100},${_84_7} \
238
- ${_84_7},${_100} 66,${_100} L0,${_100} L0,0 Z`;
239
- const twoSides = `M0,0 L${_100},0 L${_100},66 C${_100},${_84_7} ${_84_7},${_100} 66,${_100} L0,${_100} L0,0 Z`;
160
+ }
240
161
 
241
- return `
242
- <svg
243
- version="1.1"
244
- viewBox="0 0 ${backgroundSize} ${backgroundSize}"
245
- width="${options.qrSize}px"
246
- height="${options.qrSize}px"
247
- ${options.className ? `class="${options.className}"` : ''}
248
- xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
249
- >
250
- <defs>
251
- <rect id="rect-${options.suffix}" width="100" height="100" fill="${options.foregroundColor}"/>
252
- <path
253
- id="empty-${options.suffix}"
254
- d="M0,${_28_6}v${_42_9}C0,${_87_3},${_12_8},${_100},${_28_6},${_100}h${_42_9}c${_15_9},0,${_28_6}-${_12_8},\
255
- ${_28_6}-${_28_6}V${_28_6}C${_100},${_12_7},${_87_2},0,${_71_4},0H${_28_6} C${_12_8},0,0,${_12_8},0,${_28_6}z"
256
- fill="${options.foregroundColor}"
257
- />
258
- <path id="b-${options.suffix}" d="${oneSide}" transform="rotate(-90 50 50)" fill="${options.foregroundColor}"/>
259
- <path id="r-${options.suffix}" d="${oneSide}" transform="rotate(-180 50 50)" fill="${options.foregroundColor}"/>
260
- <path id="l-${options.suffix}" d="${oneSide}" fill="${options.foregroundColor}"/>
261
- <path id="t-${options.suffix}" d="${oneSide}" transform="rotate(90 50 50)" fill="${options.foregroundColor}"/>
262
- <path id="l-${options.suffix}" d="${twoSides}" transform="rotate(-90 50 50)" fill="${options.foregroundColor}"/>
263
- <path id="lt-${options.suffix}" d="${twoSides}" fill="${options.foregroundColor}"/>
264
- <path id="lb-${options.suffix}" d="${twoSides}" transform="rotate(-90 50 50)" fill="${options.foregroundColor}"/>
265
- <path id="rb-${options.suffix}" d="${twoSides}" transform="rotate(-180 50 50)" fill="${options.foregroundColor}"/>
266
- <path id="rt-${options.suffix}" d="${twoSides}" transform="rotate(90 50 50)" fill="${options.foregroundColor}"/>
267
- <path
268
- id="n_lt-${options.suffix}"
269
- d="M${_30_5},${_2}V0H0v${_30_5}h${_2}C${_2},${_14_7},${_14_8},${_2},${_30_5},${_2}z"
270
- fill="${options.foregroundColor}"
271
- />
272
- <path
273
- id="n_lb-${options.suffix}"
274
- d="M${_2},${_69_5}H0V${_100}h${_30_5}v-${_2}C${_14_7},${_98},${_2},${_85_2},${_2},${_69_5}z"
275
- fill="${options.foregroundColor}"
276
- />
277
- <path
278
- id="n_rt-${options.suffix}"
279
- d="M${_98},${_30_5}h${_2}V0H${_69_5}v${_2}C${_85_3},${_2},${_98},${_14_8},${_98},${_30_5}z"
280
- fill="${options.foregroundColor}"
281
- />
282
- <path id="n_rb-${options.suffix}"
283
- d="M${_69_5},${_98}v${_2}H${_100}V${_69_5}h-${_2}C${_98},${_85_3},${_85_2},${_98},${_69_5},${_98}z"
284
- fill="${options.foregroundColor}"
285
- />
286
- <path
162
+ function renderDefinitions(options: RequiredQrOptions): string {
163
+ return `<defs>
164
+ <g
287
165
  id="point-${options.suffix}"
288
166
  fill="${options.foregroundColor}"
289
- d="M600.001786,457.329333 L600.001786,242.658167 C600.001786,147.372368 587.039517,124.122784 \
290
- 581.464617,118.535383 C575.877216,112.960483 552.627632,99.9982143 457.329333,99.9982143 \
291
- L242.670667,99.9982143 C147.372368,99.9982143 124.122784,112.960483 118.547883,118.535383 \
292
- C112.972983,124.122784 99.9982143,147.372368 99.9982143,242.658167 L99.9982143,457.329333 \
293
- C99.9982143,552.627632 112.972983,575.877216 118.547883,581.464617 C124.122784,587.027017 \
294
- 147.372368,600.001786 242.670667,600.001786 L457.329333,600.001786 C552.627632,600.001786 \
295
- 575.877216,587.027017 581.464617,581.464617 C587.039517,575.877216 600.001786,552.627632 \
296
- 600.001786,457.329333 Z M457.329333,0 C653.338333,0 700,46.6616668 700,242.658167 C700,438.667167 \
297
- 700,261.332833 700,457.329333 C700,653.338333 653.338333,700 457.329333,700 C261.332833,700 438.667167,700 \
298
- 242.670667,700 C46.6616668,700 0,653.338333 0,457.329333 C0,261.332833 0,352.118712 0,242.658167 \
299
- C0,46.6616668 46.6616668,0 242.670667,0 C438.667167,0 261.332833,0 457.329333,0 Z M395.996667,200 \
300
- C480.004166,200 500,220.008332 500,303.990835 C500,387.998334 500,312.001666 500,395.996667 \
301
- C500,479.991668 480.004166,500 395.996667,500 C312.001666,500 387.998334,500 304.003333,500 C220.008332,500 \
302
- 200,479.991668 200,395.996667 C200,312.001666 200,350.906061 200,303.990835 C200,220.008332 220.008332,200 \
303
- 304.003333,200 C387.998334,200 312.001666,200 395.996667,200 Z"
304
- />
167
+ >
168
+ <rect width="300" height="300" x="200" y="200" rx="${FINDER_CORNER_RADIUS}" />
169
+ <rect width="600" height="600" x="50" y="50" rx="${FINDER_CORNER_RADIUS * 2}" stroke-width="100" fill="none" stroke="${options.foregroundColor}" />
170
+ </g>
305
171
  <g id="vk_logo-${options.suffix}">
306
172
  <path
307
173
  fill="${options.logoColor}"
@@ -322,12 +188,5 @@ C500,479.991668 480.004166,500 395.996667,500 C312.001666,500 387.998334,500 304
322
188
  <clipPath id="logo-mask-${options.suffix}">
323
189
  <rect x="0" y="0" width="750" height="750" />
324
190
  </clipPath>
325
- </defs>
326
-
327
- ${qrBackground}
328
-
329
- <g transform="${qrTransform}">
330
- ${parts.join('\n')}
331
- </g>
332
- </svg>`;
333
- };
191
+ </defs>`;
192
+ }
package/src/types.ts CHANGED
@@ -28,6 +28,20 @@ export type QrOptions = {
28
28
  */
29
29
  backgroundColor?: string | undefined;
30
30
 
31
+ /**
32
+ * Background frame thickness in output pixels. Works if `isShowBackground` is enabled.
33
+ * Must be finite and non-negative. Clamped to half of `qrSize`.
34
+ * @default 20
35
+ */
36
+ backgroundPadding?: number | undefined;
37
+
38
+ /**
39
+ * Background corner radius in output pixels. Works if `isShowBackground` is enabled.
40
+ * Must be finite and non-negative. Clamped to half of `qrSize`.
41
+ * @default 36
42
+ */
43
+ backgroundRadius?: number | undefined;
44
+
31
45
  /**
32
46
  * QR code HEX color
33
47
  */
@@ -55,7 +69,7 @@ export type QrOptions = {
55
69
  ecc?: number | undefined;
56
70
 
57
71
  /**
58
- * Custom INC_TILE_SIZE
72
+ * @deprecated Ignored. QR module size is fixed internally.
59
73
  */
60
74
  incTileSize?: number | undefined;
61
75
  };