@pi-flash-plugins/pf-wxbot 0.0.0-stage → 0.1.3
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/assets/wx.svg +5 -0
- package/package.json +9 -4
- package/pf-plugin.toml +18 -0
- package/pi/config.mjs +26 -0
- package/pi/design-002.test.mjs +323 -0
- package/pi/fixtures/ec-golden.json +1 -0
- package/pi/fixtures/getupdates-real.json +37 -0
- package/pi/fixtures/qr-golden.json +1 -0
- package/pi/hook.mjs +231 -0
- package/pi/hook.test.mjs +351 -0
- package/pi/index.mjs +648 -0
- package/pi/index.test.mjs +685 -0
- package/pi/inject.mjs +41 -0
- package/pi/inject.test.mjs +55 -0
- package/pi/jsonx.mjs +64 -0
- package/pi/lock.mjs +171 -0
- package/pi/messages.mjs +160 -0
- package/pi/poller.mjs +364 -0
- package/pi/poller.test.mjs +191 -0
- package/pi/qr.mjs +454 -0
- package/pi/qr.test.mjs +101 -0
- package/pi/register.mjs +236 -0
- package/pi/register.test.mjs +232 -0
- package/pi/reply.mjs +49 -0
- package/pi/state.mjs +248 -0
- package/pi/state.test.mjs +138 -0
- package/pi/transport.mjs +335 -0
- package/pi/transport.test.mjs +327 -0
- package/pi/wire.mjs +219 -0
- package/pi/wire.test.mjs +29 -0
- package/plugin.wasm +0 -0
- package/README.md +0 -3
package/pi/qr.mjs
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// qr —— 零依赖 QR 编码器(pi ext 自持),对位 wxprobe src/qr.rs 用的 `qrcode` crate。
|
|
2
|
+
//
|
|
3
|
+
// 范围(协议实际需要):字节模式、EC 级别 M(qrcode 0.14 `QrCode::new` 的默认,
|
|
4
|
+
// 已核对其 lib.rs:76)、版本 1–40 自动选择、掩码按规范 penalty 自动选优。
|
|
5
|
+
// 正确实现的矩阵是规范确定的——测试用临时 cargo 项目生成的 golden 矩阵
|
|
6
|
+
// 逐字节对拍(同版本同 EC 同掩码 ⇒ 矩阵必须一致)。
|
|
7
|
+
//
|
|
8
|
+
// 产物:
|
|
9
|
+
// - qr_grid(url, quiet) → QrGrid(含静默区矩阵 + runs() 行程压缩)—— canvas
|
|
10
|
+
// 原语画方块用(J2 绑定页;半块字符在抗锯齿下有细缝,部分手机解不出)
|
|
11
|
+
// - qr_svg(url) → 320×320 SVG(浏览器打开备用通路)
|
|
12
|
+
|
|
13
|
+
// ---------------------------------------------------------------- Reed–Solomon
|
|
14
|
+
|
|
15
|
+
const EXP = new Uint8Array(256);
|
|
16
|
+
const LOG = new Uint8Array(256);
|
|
17
|
+
(() => {
|
|
18
|
+
let x = 1;
|
|
19
|
+
for (let i = 0; i < 255; i += 1) {
|
|
20
|
+
EXP[i] = x;
|
|
21
|
+
LOG[x] = i;
|
|
22
|
+
x <<= 1;
|
|
23
|
+
if (x & 0x100) x ^= 0x11d;
|
|
24
|
+
}
|
|
25
|
+
})();
|
|
26
|
+
|
|
27
|
+
const gmul = (a, b) => (a === 0 || b === 0 ? 0 : EXP[(LOG[a] + LOG[b]) % 255]);
|
|
28
|
+
|
|
29
|
+
// 生成多项式 log 表(对位 qrcode crate GENERATOR_POLYNOMIALS):
|
|
30
|
+
// GEN_LOG[n] = (x-α^0)(x-α^1)…(x-α^(n-1)) 展开的**去最高项**降幂 log 系数。
|
|
31
|
+
// 运行时从升幂值系数逐次 ×(x + α^(d-1)) 展开(与 crate 表逐项对拍验证)。
|
|
32
|
+
const GEN_LOG = (() => {
|
|
33
|
+
const tbl = [[], [0]]; // degree 1: g = x + 1 → 去首项 = [常数 log 0]
|
|
34
|
+
let vals = [1, 1]; // g_1 升幂值系数(常数→高次)
|
|
35
|
+
for (let d = 2; d <= 68; d++) {
|
|
36
|
+
const r = EXP[(d - 1) % 255];
|
|
37
|
+
const next = [];
|
|
38
|
+
for (let j = 0; j <= d; j++) {
|
|
39
|
+
const byX = vals[j - 1] ?? 0; // ×x
|
|
40
|
+
const byA = gmul(r, vals[j] ?? 0); // ×α^(d-1)
|
|
41
|
+
next.push(byX ^ byA);
|
|
42
|
+
}
|
|
43
|
+
vals = next;
|
|
44
|
+
tbl[d] = vals
|
|
45
|
+
.slice()
|
|
46
|
+
.reverse()
|
|
47
|
+
.slice(1)
|
|
48
|
+
.map((v) => (v === 0 ? 0 : LOG[v]));
|
|
49
|
+
}
|
|
50
|
+
return tbl;
|
|
51
|
+
})();
|
|
52
|
+
|
|
53
|
+
/** 数据多项式的 ECC 码字(多项式除法取余数,语义与 qrcode crate ec.rs 逐行对齐)。 */
|
|
54
|
+
export function rs_compute_ecc(data, degree) {
|
|
55
|
+
const log_den = GEN_LOG[degree];
|
|
56
|
+
const res = [...data, ...new Array(degree).fill(0)];
|
|
57
|
+
for (let i = 0; i < data.length; i += 1) {
|
|
58
|
+
const lead = res[i];
|
|
59
|
+
if (lead === 0) continue;
|
|
60
|
+
const logLead = LOG[lead];
|
|
61
|
+
for (let j = 0; j < degree; j += 1) {
|
|
62
|
+
res[i + 1 + j] ^= EXP[(log_den[j] + logLead) % 255];
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return Uint8Array.from(res.slice(data.length));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------- 版本表(规范附录,M 级)
|
|
69
|
+
|
|
70
|
+
const ECC_CODEWORDS_PER_BLOCK = {
|
|
71
|
+
// nayuki 权威表:版本 1..40,Medium 级 ECC 码字数/块
|
|
72
|
+
M: [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24,
|
|
73
|
+
28, 28, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28,
|
|
74
|
+
28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
|
|
75
|
+
};
|
|
76
|
+
const NUM_ERROR_CORRECTION_BLOCKS = {
|
|
77
|
+
M: [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10,
|
|
78
|
+
10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29,
|
|
79
|
+
31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const num_raw_data_modules = (ver) => {
|
|
83
|
+
let result = (16 * ver + 128) * ver + 64;
|
|
84
|
+
if (ver >= 2) {
|
|
85
|
+
const numAlign = Math.floor(ver / 7) + 2;
|
|
86
|
+
result -= (25 * numAlign - 10) * numAlign - 55;
|
|
87
|
+
if (ver >= 7) result -= 36;
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** 字节模式**字符计数指示符**位宽(规范表 3):v1-9 = 8 位,v10-40 = 16 位。
|
|
93
|
+
* 漏了这条会把 v10+ 的码流整体写错 —— 版本表/capacity 都对、码却解不出来。 */
|
|
94
|
+
export const count_bits = (ver) => (ver <= 9 ? 8 : 16);
|
|
95
|
+
|
|
96
|
+
/** 该版本字节模式容量(字节数)= 数据码字 × 8 − 4 位模式 − 计数位 的下取整。 */
|
|
97
|
+
export function byte_capacity(ver) {
|
|
98
|
+
const total = ECC_CODEWORDS_PER_BLOCK.M[ver] * NUM_ERROR_CORRECTION_BLOCKS.M[ver];
|
|
99
|
+
const dataCodewords = Math.floor(num_raw_data_modules(ver) / 8) - total;
|
|
100
|
+
const usable = dataCodewords * 8 - 4 - count_bits(ver);
|
|
101
|
+
return usable < 0 ? 0 : Math.floor(usable / 8);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function pick_version(byteLen) {
|
|
105
|
+
for (let ver = 1; ver <= 40; ver += 1) {
|
|
106
|
+
if (byteLen <= byte_capacity(ver)) return ver;
|
|
107
|
+
}
|
|
108
|
+
throw new Error(`二维码编码失败: 数据超出 QR 容量(${byteLen} 字节)`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------- 编码 → 码字序列
|
|
112
|
+
|
|
113
|
+
export function encode_codewords(bytes, ver) {
|
|
114
|
+
const totalCodewords = Math.floor(num_raw_data_modules(ver) / 8);
|
|
115
|
+
const eccLen = ECC_CODEWORDS_PER_BLOCK.M[ver];
|
|
116
|
+
const numBlocks = NUM_ERROR_CORRECTION_BLOCKS.M[ver];
|
|
117
|
+
const dataLen = totalCodewords - eccLen * numBlocks;
|
|
118
|
+
|
|
119
|
+
// 比特流:模式 0100 + 计数(v≤9 = 8 位 / v≥10 = 16 位)+ 数据 + 终止符 + padding
|
|
120
|
+
const bits = [];
|
|
121
|
+
const push = (val, n) => { for (let i = n - 1; i >= 0; i -= 1) bits.push((val >>> i) & 1); };
|
|
122
|
+
push(0b0100, 4);
|
|
123
|
+
push(bytes.length, count_bits(ver));
|
|
124
|
+
for (const b of bytes) push(b, 8);
|
|
125
|
+
const capacityBits = dataLen * 8;
|
|
126
|
+
push(0, Math.min(4, capacityBits - bits.length)); // 终止符
|
|
127
|
+
push(0, (8 - (bits.length % 8)) % 8); // 字节对齐
|
|
128
|
+
const pad = [0xEC, 0x11];
|
|
129
|
+
for (let i = 0; bits.length < capacityBits; i += 1) push(pad[i % 2], 8);
|
|
130
|
+
|
|
131
|
+
const data = new Uint8Array(dataLen);
|
|
132
|
+
for (let i = 0; i < dataLen; i += 1) {
|
|
133
|
+
data[i] = (bits[i * 8] << 7) | (bits[i * 8 + 1] << 6) | (bits[i * 8 + 2] << 5) |
|
|
134
|
+
(bits[i * 8 + 3] << 4) | (bits[i * 8 + 4] << 3) | (bits[i * 8 + 5] << 2) |
|
|
135
|
+
(bits[i * 8 + 6] << 1) | bits[i * 8 + 7];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 分块 ECC + 交错(规范 8.5:短块先补一个数据码字)
|
|
139
|
+
const blocks = [];
|
|
140
|
+
const eccBlocks = [];
|
|
141
|
+
const shortBlockLen = Math.floor(dataLen / numBlocks);
|
|
142
|
+
const numShortBlocks = numBlocks - (dataLen % numBlocks);
|
|
143
|
+
let offset = 0;
|
|
144
|
+
for (let i = 0; i < numBlocks; i += 1) {
|
|
145
|
+
const dataLenI = shortBlockLen + (i < numShortBlocks ? 0 : 1);
|
|
146
|
+
const dat = data.subarray(offset, offset + dataLenI);
|
|
147
|
+
offset += dataLenI;
|
|
148
|
+
blocks.push(dat);
|
|
149
|
+
eccBlocks.push(rs_compute_ecc(dat, eccLen));
|
|
150
|
+
}
|
|
151
|
+
const out = [];
|
|
152
|
+
const maxDataLen = shortBlockLen + 1;
|
|
153
|
+
for (let i = 0; i < maxDataLen; i += 1) {
|
|
154
|
+
for (let b = 0; b < numBlocks; b += 1) {
|
|
155
|
+
if (i < blocks[b].length) out.push(blocks[b][i]);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
for (let i = 0; i < eccLen; i += 1) {
|
|
159
|
+
for (let b = 0; b < numBlocks; b += 1) out.push(eccBlocks[b][i]);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ---------------------------------------------------------------- 矩阵绘制
|
|
165
|
+
|
|
166
|
+
export function make_matrix(ver) {
|
|
167
|
+
const size = ver * 4 + 17;
|
|
168
|
+
const modules = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
169
|
+
const isFunction = Array.from({ length: size }, () => new Array(size).fill(false));
|
|
170
|
+
|
|
171
|
+
const setFn = (x, y, dark) => {
|
|
172
|
+
modules[y][x] = dark;
|
|
173
|
+
isFunction[y][x] = true;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// 时序图案
|
|
177
|
+
for (let i = 0; i < size; i += 1) {
|
|
178
|
+
setFn(6, i, i % 2 === 0);
|
|
179
|
+
setFn(i, 6, i % 2 === 0);
|
|
180
|
+
}
|
|
181
|
+
// 定位图案 + 分隔符(三角色)
|
|
182
|
+
const drawFinder = (cx, cy) => {
|
|
183
|
+
for (let dy = -4; dy <= 4; dy += 1) {
|
|
184
|
+
for (let dx = -4; dx <= 4; dx += 1) {
|
|
185
|
+
const x = cx + dx, y = cy + dy;
|
|
186
|
+
if (x < 0 || x >= size || y < 0 || y >= size) continue;
|
|
187
|
+
const dist = Math.max(Math.abs(dx), Math.abs(dy));
|
|
188
|
+
setFn(x, y, dist !== 2 && dist !== 4);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
drawFinder(3, 3);
|
|
193
|
+
drawFinder(size - 4, 3);
|
|
194
|
+
drawFinder(3, size - 4);
|
|
195
|
+
// 校正图案(规范附录坐标表,nayuki 同表;**index = 版本号**,v1 无)
|
|
196
|
+
const ALIGN = [
|
|
197
|
+
undefined, // index 0 不用(版本从 1 起)
|
|
198
|
+
[], [6, 18], [6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42],
|
|
199
|
+
[6, 26, 46], [6, 28, 50], [6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66],
|
|
200
|
+
[6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78], [6, 30, 56, 82], [6, 30, 58, 86],
|
|
201
|
+
[6, 34, 62, 90], [6, 28, 50, 72, 94], [6, 26, 50, 74, 98], [6, 30, 54, 78, 102],
|
|
202
|
+
[6, 28, 54, 80, 106], [6, 32, 58, 84, 110], [6, 30, 58, 86, 114], [6, 34, 62, 90, 118],
|
|
203
|
+
[6, 26, 50, 74, 98, 122], [6, 30, 54, 78, 102, 126], [6, 26, 52, 78, 104, 130],
|
|
204
|
+
[6, 30, 56, 82, 108, 134], [6, 34, 60, 86, 112, 138], [6, 30, 58, 86, 114, 142],
|
|
205
|
+
[6, 34, 62, 90, 118, 146], [6, 30, 54, 78, 102, 126, 150], [6, 24, 50, 76, 102, 128, 154],
|
|
206
|
+
[6, 28, 54, 80, 106, 132, 158], [6, 32, 58, 84, 110, 136, 162], [6, 26, 54, 82, 110, 138, 166],
|
|
207
|
+
[6, 30, 58, 86, 114, 142, 170],
|
|
208
|
+
];
|
|
209
|
+
const pos = ALIGN[ver];
|
|
210
|
+
for (let i = 0; i < pos.length; i += 1) {
|
|
211
|
+
for (let j = 0; j < pos.length; j += 1) {
|
|
212
|
+
// 三个 finder 角不画(左上/右上/左下)
|
|
213
|
+
if ((i === 0 && j === 0) || (i === 0 && j === pos.length - 1) || (i === pos.length - 1 && j === 0)) continue;
|
|
214
|
+
const cy = pos[i], cx = pos[j];
|
|
215
|
+
for (let dy = -2; dy <= 2; dy += 1) {
|
|
216
|
+
for (let dx = -2; dx <= 2; dx += 1) {
|
|
217
|
+
setFn(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
// 版本信息(版本 ≥ 7)
|
|
223
|
+
if (ver >= 7) {
|
|
224
|
+
let rem = ver;
|
|
225
|
+
for (let i = 0; i < 12; i += 1) rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
|
|
226
|
+
const bits = (ver << 12) | rem;
|
|
227
|
+
for (let i = 0; i < 18; i += 1) {
|
|
228
|
+
const dark = ((bits >>> i) & 1) !== 0;
|
|
229
|
+
const a = size - 11 + (i % 3);
|
|
230
|
+
const b = Math.floor(i / 3);
|
|
231
|
+
setFn(a, b, dark);
|
|
232
|
+
setFn(b, a, dark);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// format 区(第 8 行/列 + 恒黑)预标 function —— crate draw_all_functional_patterns
|
|
236
|
+
// 同构:数据填入必须跳过这些格。只标 isFunction,**不动 modules**(其中
|
|
237
|
+
// (8,6)/(6,8) 是 timing 交叉点,已画交替色;format bits 由 draw_format 覆写)
|
|
238
|
+
for (let i = 0; i <= 8; i += 1) {
|
|
239
|
+
isFunction[i][8] = true;
|
|
240
|
+
isFunction[8][i] = true;
|
|
241
|
+
}
|
|
242
|
+
for (let i = size - 8; i < size; i += 1) {
|
|
243
|
+
isFunction[i][8] = true;
|
|
244
|
+
isFunction[8][i] = true;
|
|
245
|
+
}
|
|
246
|
+
return { modules, isFunction, size };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 格式信息(EC M = 0b00,掩码 mask),BCH(15,5)。 */
|
|
250
|
+
function format_bits(mask) {
|
|
251
|
+
const data = (0b00 << 3) | mask;
|
|
252
|
+
let rem = data;
|
|
253
|
+
for (let i = 0; i < 10; i += 1) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
|
254
|
+
return ((data << 10) | rem) ^ 0x5412;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function draw_format(modules, isFunction, size, mask) {
|
|
258
|
+
const bits = format_bits(mask);
|
|
259
|
+
for (let i = 0; i <= 5; i += 1) modules[i][8] = ((bits >>> i) & 1) !== 0;
|
|
260
|
+
modules[7][8] = ((bits >>> 6) & 1) !== 0;
|
|
261
|
+
modules[8][8] = ((bits >>> 7) & 1) !== 0;
|
|
262
|
+
modules[8][7] = ((bits >>> 8) & 1) !== 0;
|
|
263
|
+
for (let i = 9; i < 15; i += 1) modules[8][14 - i] = ((bits >>> i) & 1) !== 0;
|
|
264
|
+
for (let i = 0; i < 8; i += 1) modules[8][size - 1 - i] = ((bits >>> i) & 1) !== 0;
|
|
265
|
+
for (let i = 8; i < 15; i += 1) modules[size - 15 + i][8] = ((bits >>> i) & 1) !== 0;
|
|
266
|
+
modules[size - 8][8] = true; // 恒黑模块(x=8, y=size-8,format 列底部)
|
|
267
|
+
for (let y = 0; y < size; y += 1) {
|
|
268
|
+
for (let x = 0; x < size; x += 1) {
|
|
269
|
+
if ((x === 8 && (y <= 8 || y >= size - 8)) || (y === 8 && (x <= 8 || x >= size - 8))) {
|
|
270
|
+
isFunction[y][x] = true;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// 掩码函数(规范 8.8.2)
|
|
277
|
+
export const MASKS = [
|
|
278
|
+
(x, y) => (x + y) % 2 === 0,
|
|
279
|
+
(x, y) => y % 2 === 0,
|
|
280
|
+
(x, y) => x % 3 === 0,
|
|
281
|
+
(x, y) => (x + y) % 3 === 0,
|
|
282
|
+
(x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
|
|
283
|
+
(x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
|
|
284
|
+
(x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
285
|
+
(x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
function draw_codewords(modules, isFunction, size, codewords) {
|
|
289
|
+
let i = 0;
|
|
290
|
+
for (let right = size - 1; right >= 1; right -= 2) {
|
|
291
|
+
if (right === 6) right = 5;
|
|
292
|
+
for (let vert = 0; vert < size; vert += 1) {
|
|
293
|
+
for (let j = 0; j < 2; j += 1) {
|
|
294
|
+
const x = right - j;
|
|
295
|
+
const upward = ((right + 1) & 2) === 0;
|
|
296
|
+
const y = upward ? size - 1 - vert : vert;
|
|
297
|
+
if (!isFunction[y][x] && i < codewords.length * 8) {
|
|
298
|
+
modules[y][x] = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) !== 0;
|
|
299
|
+
i += 1;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** penalty 评分 —— **逐行复刻 qrcode crate** canvas.rs(非标准 ISO 版,差异:
|
|
307
|
+
* finder 分含 −360 抵消项、深色比线性无阶梯)。掩码选择与 crate 一致的前提。 */
|
|
308
|
+
export function penalty(modules, size) {
|
|
309
|
+
// 入口规范化为 0/1:调用方可能传 boolean 矩阵(encode_matrix)或 number 矩阵
|
|
310
|
+
// (诊断/测试),S3 的 PATTERN 严格比较对 true!==1 必须先归一
|
|
311
|
+
const m = modules.map((row) => Array.from(row, (v) => (v ? 1 : 0)));
|
|
312
|
+
let total = 0;
|
|
313
|
+
const at = (x, y) => m[y][x];
|
|
314
|
+
|
|
315
|
+
// S1 adjacent:run≥5 → +run−2(crate 的行尾虚拟 Empty 仅作哨兵,等价标准 N1)
|
|
316
|
+
const scanRuns = (isH, i) => {
|
|
317
|
+
const get = (j) => (isH ? at(j, i) : at(i, j));
|
|
318
|
+
let cur = get(0);
|
|
319
|
+
let len = 1;
|
|
320
|
+
for (let j = 1; j <= size; j += 1) {
|
|
321
|
+
// j == size → 反色哨兵,强制结算真实末段
|
|
322
|
+
const c = j < size ? get(j) : (cur === 1 ? 0 : 1);
|
|
323
|
+
if (c === cur) len += 1;
|
|
324
|
+
else {
|
|
325
|
+
if (len >= 5) total += len - 2;
|
|
326
|
+
cur = c;
|
|
327
|
+
len = 1;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
for (let i = 0; i < size; i += 1) {
|
|
332
|
+
scanRuns(true, i);
|
|
333
|
+
scanRuns(false, i);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// S2 block:每 2×2 同色 +3
|
|
337
|
+
for (let y = 0; y < size - 1; y += 1) {
|
|
338
|
+
for (let x = 0; x < size - 1; x += 1) {
|
|
339
|
+
const c = at(x, y);
|
|
340
|
+
if (c === at(x + 1, y) && c === at(x, y + 1) && c === at(x + 1, y + 1)) total += 3;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// S3 finder:#.###.# 且前4格/后4格内无深色(出界视浅)→ +40;每方向 −360
|
|
345
|
+
//(抵消三个真 finder 的固有命中,只留数据区假 pattern)
|
|
346
|
+
const PATTERN = [1, 0, 1, 1, 1, 0, 1];
|
|
347
|
+
const scanFinder = (isH, i) => {
|
|
348
|
+
const get = (k) => (isH ? at(k, i) : at(i, k));
|
|
349
|
+
let sub = 0;
|
|
350
|
+
for (let j = 0; j + 7 <= size; j += 1) {
|
|
351
|
+
let ok = true;
|
|
352
|
+
for (let k = 0; k < 7; k += 1) {
|
|
353
|
+
if (get(j + k) !== PATTERN[k]) { ok = false; break; }
|
|
354
|
+
}
|
|
355
|
+
if (!ok) continue;
|
|
356
|
+
const hasDark = (lo, hi) => {
|
|
357
|
+
for (let k = lo; k < hi; k += 1) {
|
|
358
|
+
if (k >= 0 && k < size && get(k)) return true;
|
|
359
|
+
}
|
|
360
|
+
return false;
|
|
361
|
+
};
|
|
362
|
+
if (!hasDark(j - 4, j) || !hasDark(j + 7, j + 11)) sub += 40;
|
|
363
|
+
}
|
|
364
|
+
return sub;
|
|
365
|
+
};
|
|
366
|
+
for (let i = 0; i < size; i += 1) total += scanFinder(true, i) - 360 + scanFinder(false, i) - 360;
|
|
367
|
+
|
|
368
|
+
// S4 balance:深色比线性偏差(crate 非标准版:无 5% 阶梯)
|
|
369
|
+
let dark = 0;
|
|
370
|
+
for (const row of m) for (const c of row) if (c) dark += 1;
|
|
371
|
+
const ratio = Math.floor((dark * 200) / (size * size));
|
|
372
|
+
total += ratio >= 100 ? ratio - 100 : 100 - ratio;
|
|
373
|
+
|
|
374
|
+
return total;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export function encode_matrix(text, forceMask = null) {
|
|
378
|
+
const bytes = Buffer.from(text, "utf8");
|
|
379
|
+
const ver = pick_version(bytes.length);
|
|
380
|
+
const codewords = encode_codewords(bytes, ver);
|
|
381
|
+
const { modules: base, isFunction: fnBase, size } = make_matrix(ver);
|
|
382
|
+
let best = null;
|
|
383
|
+
let bestScore = Infinity;
|
|
384
|
+
for (let mask = 0; mask < 8; mask += 1) {
|
|
385
|
+
if (forceMask !== null && mask !== forceMask) continue;
|
|
386
|
+
const modules = base.map((r) => r.slice());
|
|
387
|
+
const isFunction = fnBase.map((r) => r.slice());
|
|
388
|
+
draw_codewords(modules, isFunction, size, codewords);
|
|
389
|
+
for (let y = 0; y < size; y += 1) {
|
|
390
|
+
for (let x = 0; x < size; x += 1) {
|
|
391
|
+
if (!isFunction[y][x] && MASKS[mask](x, y)) modules[y][x] = !modules[y][x];
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
draw_format(modules, isFunction, size, mask);
|
|
395
|
+
const score = penalty(modules, size);
|
|
396
|
+
if (score < bestScore) {
|
|
397
|
+
bestScore = score;
|
|
398
|
+
best = modules;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return { modules: best, size };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---------------------------------------------------------------- 对外产物(对位 qr.rs)
|
|
405
|
+
|
|
406
|
+
export class QrGrid {
|
|
407
|
+
constructor(size, cells) {
|
|
408
|
+
this.size = size;
|
|
409
|
+
this.cells = cells;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
at(x, y) {
|
|
413
|
+
return this.cells[y * this.size + x] ?? false;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** 每行连续深色段 `(x, len)` —— canvas run-length rect 用的行程。 */
|
|
417
|
+
runs(y) {
|
|
418
|
+
const out = [];
|
|
419
|
+
let x = 0;
|
|
420
|
+
while (x < this.size) {
|
|
421
|
+
if (!this.at(x, y)) { x += 1; continue; }
|
|
422
|
+
const start = x;
|
|
423
|
+
while (x < this.size && this.at(x, y)) x += 1;
|
|
424
|
+
out.push([start, x - start]);
|
|
425
|
+
}
|
|
426
|
+
return out;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** 渲染成矩阵;`quiet` 四周静默区模块数(规范 4)。opts.forceMask = 对拍/调试。 */
|
|
431
|
+
export function qr_grid(url, quiet = 4, opts = {}) {
|
|
432
|
+
const { modules, size } = encode_matrix(url, opts.forceMask ?? null);
|
|
433
|
+
const total = size + quiet * 2;
|
|
434
|
+
const cells = new Array(total * total).fill(false);
|
|
435
|
+
for (let y = 0; y < size; y += 1) {
|
|
436
|
+
for (let x = 0; x < size; x += 1) {
|
|
437
|
+
if (modules[y][x]) cells[(y + quiet) * total + (x + quiet)] = true;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return new QrGrid(total, cells);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** SVG 文本(浏览器打开备用通路)。viewBox 用模块整数坐标,零舍入损失,
|
|
444
|
+
* 显示尺寸由 width/height 控制缩放。 */
|
|
445
|
+
export function qr_svg(url, dim = 320) {
|
|
446
|
+
const grid = qr_grid(url, 4);
|
|
447
|
+
const parts = [];
|
|
448
|
+
for (let y = 0; y < grid.size; y += 1) {
|
|
449
|
+
for (const [x, len] of grid.runs(y)) {
|
|
450
|
+
parts.push(`M${x},${y}h${len}v1h-${len}z`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<svg xmlns="http://www.w3.org/2000/svg" width="${dim}" height="${dim}" viewBox="0 0 ${grid.size} ${grid.size}"><rect width="100%" height="100%" fill="#ffffff"/><path d="${parts.join("")}" fill="#000000"/></svg>`;
|
|
454
|
+
}
|
package/pi/qr.test.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// qr 单测 —— 与 Rust `qrcode` crate(0.14.1,EC M)golden 矩阵**逐字节对拍**。
|
|
2
|
+
// golden 生成:临时 cargo 项目跑 qrcode crate 输出 cells JSON(对拍后即弃,
|
|
3
|
+
// golden 已落 pi/fixtures/qr-golden.json)。另含 qr.rs 单测同款断言
|
|
4
|
+
// (静默区/定位图案/runs 语义/确定性)。
|
|
5
|
+
|
|
6
|
+
import { test } from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { dirname, join } from "node:path";
|
|
11
|
+
|
|
12
|
+
import { byte_capacity, count_bits, qr_grid, qr_svg, QrGrid } from "./qr.mjs";
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const golden = JSON.parse(readFileSync(join(here, "fixtures", "qr-golden.json"), "utf8"));
|
|
16
|
+
const URL1 = "https://ilinkai.weixin.qq.com/ilink/bot/qr?x=1234567890";
|
|
17
|
+
|
|
18
|
+
test("matrix_matches_rust_golden_byte_for_byte", () => {
|
|
19
|
+
for (const g of golden.grids) {
|
|
20
|
+
const js = qr_grid(g.url, g.quiet);
|
|
21
|
+
assert.equal(js.size, g.size, `${g.url} 边长`);
|
|
22
|
+
assert.equal(js.cells.length, g.cells.length);
|
|
23
|
+
let diff = -1;
|
|
24
|
+
for (let i = 0; i < g.cells.length; i += 1) {
|
|
25
|
+
if (js.cells[i] !== g.cells[i]) { diff = i; break; }
|
|
26
|
+
}
|
|
27
|
+
assert.equal(diff, -1, `${g.url} 矩阵与 Rust qrcode crate 不一致(首个差异 cell ${diff})`);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("grid_matches_source_matrix_and_has_quiet_zone", () => {
|
|
32
|
+
// qr.rs 单测同款断言(左上定位图案/静默区)
|
|
33
|
+
const g = qr_grid(URL1, 4);
|
|
34
|
+
for (let i = 0; i < g.size; i += 1) {
|
|
35
|
+
assert.ok(!g.at(i, 0) && !g.at(i, 1), "上静默区必须为空");
|
|
36
|
+
assert.ok(!g.at(0, i) && !g.at(1, i), "左静默区必须为空");
|
|
37
|
+
}
|
|
38
|
+
assert.ok(g.at(4, 4), "定位图案左上角应为深色");
|
|
39
|
+
assert.ok(g.at(10, 4) && g.at(4, 10) && g.at(10, 10), "定位图案三角");
|
|
40
|
+
assert.ok(!g.at(5, 5), "定位图案中心应为空");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("runs_cover_exactly_the_dark_cells_of_each_row", () => {
|
|
44
|
+
const g = qr_grid(URL1, 4);
|
|
45
|
+
for (let y = 0; y < g.size; y += 1) {
|
|
46
|
+
let prevEnd = 0;
|
|
47
|
+
for (const [x, len] of g.runs(y)) {
|
|
48
|
+
assert.ok(x >= prevEnd, "段重叠");
|
|
49
|
+
assert.ok(len > 0, "空段");
|
|
50
|
+
assert.ok(g.at(x, y) && g.at(x + len - 1, y), "段两端必须深色");
|
|
51
|
+
prevEnd = x + len;
|
|
52
|
+
}
|
|
53
|
+
assert.ok(prevEnd <= g.size);
|
|
54
|
+
}
|
|
55
|
+
// runs 还原 = 原 cells(行程完整性)
|
|
56
|
+
for (let y = 0; y < g.size; y += 1) {
|
|
57
|
+
const rebuilt = new Array(g.size).fill(false);
|
|
58
|
+
for (const [x, len] of g.runs(y)) rebuilt.fill(true, x, x + len);
|
|
59
|
+
for (let x = 0; x < g.size; x += 1) assert.equal(rebuilt[x], g.at(x, y));
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("same_input_is_deterministic", () => {
|
|
64
|
+
const a = qr_grid(URL1, 4);
|
|
65
|
+
const b = qr_grid(URL1, 4);
|
|
66
|
+
assert.deepEqual(a.cells, b.cells);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("svg_output_looks_like_svg", () => {
|
|
70
|
+
const svg = qr_svg(URL1);
|
|
71
|
+
assert.ok(svg.startsWith("<?xml") || svg.includes("<svg"), "不是 SVG");
|
|
72
|
+
assert.ok(svg.includes("path") || svg.includes("rect"));
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("absurdly_long_input_is_rejected_not_panicking", () => {
|
|
76
|
+
assert.throws(() => qr_grid("x".repeat(6000), 4), /超出 QR 容量/);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("svg_round_trip_equals_grid", () => {
|
|
80
|
+
// SVG 的 path 面积还原 = 矩阵深色数(run-length 无损的另一种验证)
|
|
81
|
+
const svg = qr_svg(URL1);
|
|
82
|
+
const d = /d="([^"]+)"/.exec(svg)[1];
|
|
83
|
+
let area = 0;
|
|
84
|
+
for (const m of d.matchAll(/h([\d.]+)v([\d.]+)/g)) area += Number(m[1]) * Number(m[2]);
|
|
85
|
+
const grid = qr_grid(URL1, 4);
|
|
86
|
+
const dark = grid.cells.filter(Boolean).length;
|
|
87
|
+
// viewBox = 模块整数坐标 → 面积应精确等于深色模块数(零舍入)
|
|
88
|
+
assert.ok(
|
|
89
|
+
Math.abs(area - dark) < 1e-9,
|
|
90
|
+
`SVG 面积 ${area} ≠ 深色模块数 ${dark}`,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("byte_capacity_v10_plus_uses_16_bit_count", () => {
|
|
95
|
+
// v≤9 计数 8 位、v≥10 16 位(规范表 3)。写错的话码流整体偏移:版本表对、
|
|
96
|
+
// 码却解不出来 —— 2026-10-28 真机「二维码生成错误」根因(长 URL 落到 v10+)。
|
|
97
|
+
assert.equal(count_bits(9), 8);
|
|
98
|
+
assert.equal(count_bits(10), 16);
|
|
99
|
+
const want = { 1: 14, 5: 84, 9: 180, 10: 213, 11: 251, 15: 412, 20: 666 };
|
|
100
|
+
for (const [v, n] of Object.entries(want)) assert.equal(byte_capacity(Number(v)), n, `v${v} 字节容量`);
|
|
101
|
+
});
|