Files
Scoreboard/web/src/shared/qrcode.ts
T
AskaEth 9ef68c2589 feat(console,admin): kiosk 不再点击穿透 + 压住任务栏;大屏控制页新增「局域网大屏」(链接/复制/二维码)dev_0.4.0
## ① 现场问题:kiosk 窗口点击穿透(原设计方向反了)
`displays.js` 原来对多屏 kiosk 调 `setIgnoreMouseEvents(true)`,注释写的是
「防止现场误触」——但该 API 的语义是「本窗口忽略鼠标」,点击会**落到它后面的
窗口或桌面**,现场想防的误触正好变成误点别的东西;被忽略鼠标的窗口也点不活。
现在窗口**默认吞掉鼠标、不穿透**。

配置项**改名**为 `display.allowClickThrough`(缺省 false;显式 true 才穿透)。
**旧键 `display.ignoreMouse` 完全不读**——老机器的配置文件里还写着旧版模板留下的
`ignoreMouse: true`,而配置策略是「已存在不覆盖」,只改默认值等于老机器升级后
照样穿透;改名才能让默认值真正生效。启动时若发现旧键会记一条中文提示(只记一次),
并在合并后的配置里把它剥掉,防止将来有人再读到那个陈年 true。
(不是自动改写用户配置文件:无法区分「操作员刻意写的 true」与「旧模板留下的 true」。)

## ② 现场问题:任务栏盖住 kiosk 窗口、被盖住后抢不回前台
原顺序是 `setKiosk(true)` 之后又 `setBounds(...)`,在 Windows 上会把窗口从真正的
全屏态拽回「恰好铺满该屏的无边框窗口」→ 不是 topmost,任务栏与拖过去的窗口都盖得住。
现在固定为 **先 `setBounds` 定位 → 再 `setKiosk(true)` → 最后 `setAlwaysOnTop(true,
'screen-saver')`**(默认层级 `floating` 在 Windows 上仍画在任务栏下面),并加了
「已达标就不重复进出 kiosk」的守卫避免重排白闪;非 kiosk 分支会摘掉置顶。
Win 真机行为需现场复核,代码/桩级已验(含顺序与层级的断言)。

## ③ 新功能:大屏控制页「局域网大屏」卡片
- 服务端新增 `GET /api/system/network`(recorder 及以上):回本机可用局域网 IPv4
  (排除回环/link-local/IPv6,去重,按 192.168/10/172.16-31 优先排序)+ 真实监听端口
  + 路径;无地址回 `[]` 不报错。
- 卡片:每个地址一条可点击链接 + 复制按钮,第一个地址配二维码(148px)。
- **二维码是离线自实现的纯 TS 编码器**(版本 1–40 自动、ECC L/M/Q/H、8 种掩码择优),
  正确性用 **Python OpenCV 真解码**验收:29/29 条解出原文(含中文与边界),
  SVG 渲染后再解码 4/4,并把验证过的矩阵冻结成仓库参照用例。
- 复制按**非安全上下文**处理:面板常从 `http://192.168.x.x` 打开,那里
  `navigator.clipboard` 是 undefined,自动回退 `execCommand('copy')`。

## ④ 真机联调抓出并修掉一个时序缺陷(真浏览器 + 真服务端)
`DisplayPage` 的 `refreshLan()` 在挂载时跑,而 `auth` 是异步加载的——那一刻
`auth.readOnly` 必然为 true,卡片把「当前为只读访客」写进一个**普通 ref** 就返回了,
身份落定后没有任何东西再触发它:**管理员登录后卡片永远停在访客态,
`/api/system/network` 一次都没被请求**。
改为「gate 由 computed 直接渲染 + watch gate 变 null 时重跑加载」,并补回归钉子。
(同时修正了一条把该错误结构当成正确行为的源码断言用例。)
端到端证据:真服务 + 局域网 IP(真非安全上下文)→ 卡片列出两个地址、点击复制变
「已复制」(走 execCommand 回退)→ 浏览器渲染的二维码截图经 cv2 解出
`http://10.50.101.85:19861/display`。

## 文档
部署指南配置表与多屏行为、使用手册「大屏控制」与局域网设备段、devdocs 05/07/10/13、
升级说明新增 dev_0.4.0(含旧键废弃与启动提示)。**无数据迁移**,旧赛事包照常导入。

自检:服务端 13 包 ×2;前端 vue-tsc + 645 例 ×2 + build;Console 20 文件 + 68 例。
2026-09-12 16:25:04 +08:00

550 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 纯 TypeScript 二维码编码器(字节模式 / UTF-8),零依赖。
*
* 为什么自己写:现场是离线局域网,装不了 npm 包;而「局域网大屏」卡片要的是一个
* 能扫的矢量码,不是一张贴图。实现按 ISO/IEC 18004 走:字节模式、1–40 版自动选版、
* L/M/Q/H 四档纠错、8 种掩码按惩罚分择优,最后输出模块矩阵,可渲染成任意尺寸的 SVG。
*
* 对外只暴露两件事:`encodeQr`(要矩阵)与 `qrToSvg`(要能直接塞进卡片的字符串)。
*/
/** 纠错级别:L≈7% / M≈15% / Q≈25% / H≈30% 的码字可被恢复。 */
export type QrEcc = 'L' | 'M' | 'Q' | 'H'
export interface QrEncodeOptions {
/** 纠错级别,默认 M:屏幕扫码距离近、容错够用,容量也不至于太小。 */
ecc?: QrEcc
/**
* 最低版本(1–40)。默认 1。
*
* 保留给「同一张卡片上多个码尺寸要一致」这类排版需求;正常情况下让它自动选版。
*/
minVersion?: number
}
export interface QrMatrix {
/** 二维码版本 1–40,边长 = version * 4 + 17。 */
version: number
/** 边长(模块数,不含静区)。 */
size: number
ecc: QrEcc
/** 模块矩阵:`modules[y][x]` 为 true 表示深色。不含静区。 */
modules: boolean[][]
}
export interface QrSvgOptions extends QrEncodeOptions {
/** 每个模块占多少像素(SVG 用户单位),默认 4。矢量渲染,放大不糊。 */
scale?: number
/** 静区宽度(模块数),默认 4;低于 4 会显著影响识别率。 */
margin?: number
}
export const ECC_LEVELS: readonly QrEcc[] = ['L', 'M', 'Q', 'H']
/** 版本 1–40 的字节模式容量表(数据码字数、可容纳字节数),由下面的表推出,仅用于报错信息。 */
const MIN_VERSION = 1
const MAX_VERSION = 40
const MODE_BYTE = 0x4
const PENALTY_N1 = 3
const PENALTY_N2 = 3
const PENALTY_N3 = 40
const PENALTY_N4 = 10
// ── 规格表(ISO/IEC 18004 表 9 与表 13–22) ─────────────────────────────────
// 下标是版本号,[0] 是占位 -1。行序与 ECC_LEVELS 一致:L / M / Q / H。
const ECC_CODEWORDS_PER_BLOCK: readonly (readonly number[])[] = [
// L
[-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
// M
[-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
// Q
[-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
// H
[-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
]
const NUM_ERROR_CORRECTION_BLOCKS: readonly (readonly number[])[] = [
// L
[-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
// M
[-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
// Q
[-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
// H
[-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81],
]
/** 格式信息里的纠错指示位(L=01 / M=00 / Q=11 / H=10),与矩阵下标不是同一套编号。 */
const ECC_FORMAT_BITS: readonly number[] = [1, 0, 3, 2]
function eccIndex(ecc: QrEcc): number {
const index = ECC_LEVELS.indexOf(ecc)
if (index < 0) throw new RangeError(`不支持的纠错级别:${String(ecc)}`)
return index
}
function eccCodewordsPerBlock(version: number, index: number): number {
return ECC_CODEWORDS_PER_BLOCK[index]![version]!
}
function numErrorCorrectionBlocks(version: number, index: number): number {
return NUM_ERROR_CORRECTION_BLOCKS[index]![version]!
}
/**
* 该版本「不参与功能的模块」总数(位)。
*
* 由几何推算而不是查表:每加一个版本多出的定位/计时/对齐/版本信息都能用式子表达,
* 少一张 40 行的表就少一处抄错的机会。
*/
function numRawDataModules(version: number): number {
let result = (16 * version + 128) * version + 64
if (version >= 2) {
const numAlign = Math.floor(version / 7) + 2
result -= (25 * numAlign - 10) * numAlign - 55
if (version >= 7) result -= 36
}
return result
}
/** 该版本 + 纠错级别下的数据码字数(纠错码字不算)。 */
function numDataCodewords(version: number, index: number): number {
return (
Math.floor(numRawDataModules(version) / 8) -
eccCodewordsPerBlock(version, index) * numErrorCorrectionBlocks(version, index)
)
}
/** 对齐图案中心坐标;版本 1 没有对齐图案。 */
function alignmentPatternPositions(version: number): number[] {
if (version === 1) return []
const numAlign = Math.floor(version / 7) + 2
// 版本 32 的步长是规格里的特例,按公式算会得到 28 而正确值是 26。
const step = version === 32 ? 26 : Math.ceil((version * 4 + 4) / (numAlign * 2 - 2)) * 2
const result = [6]
for (let pos = version * 4 + 10; result.length < numAlign; pos -= step) result.splice(1, 0, pos)
return result
}
function getBit(value: number, index: number): boolean {
return ((value >>> index) & 1) !== 0
}
// ── Reed–Solomon(GF(256),本原多项式 0x11D) ───────────────────────────────
function reedSolomonMultiply(x: number, y: number): number {
let z = 0
for (let i = 7; i >= 0; i--) {
z = (z << 1) ^ ((z >>> 7) * 0x11d)
z ^= ((y >>> i) & 1) * x
}
return z & 0xff
}
/** 生成多项式 (x - α^0)(x - α^1)…(x - α^(degree-1)) 的系数,最高次项省略。 */
function reedSolomonComputeDivisor(degree: number): Uint8Array {
const result = new Uint8Array(degree)
result[degree - 1] = 1
let root = 1
for (let i = 0; i < degree; i++) {
for (let j = 0; j < degree; j++) {
result[j] = reedSolomonMultiply(result[j]!, root)
if (j + 1 < degree) result[j] ^= result[j + 1]!
}
root = reedSolomonMultiply(root, 0x02)
}
return result
}
/** 多项式除法取余,得到该块的纠错码字。 */
function reedSolomonComputeRemainder(data: Uint8Array, divisor: Uint8Array): Uint8Array {
const result = new Uint8Array(divisor.length)
for (const byte of data) {
const factor = byte ^ result[0]!
result.copyWithin(0, 1)
result[result.length - 1] = 0
for (let i = 0; i < divisor.length; i++) {
result[i] ^= reedSolomonMultiply(divisor[i]!, factor)
}
}
return result
}
// ── 数据码字 ────────────────────────────────────────────────────────────────
/** 字节模式字符计数指示符的位宽:版本 1–9 是 8 位,10–40 是 16 位。 */
function byteCountBits(version: number): number {
return version <= 9 ? 8 : 16
}
/** 该版本 + 级别下能装下的最大字节数。 */
function byteCapacity(version: number, index: number): number {
const bits = numDataCodewords(version, index) * 8 - 4 - byteCountBits(version)
return Math.max(0, Math.floor(bits / 8))
}
function chooseVersion(byteLength: number, index: number, minVersion: number): number {
const start = Math.max(MIN_VERSION, Math.min(MAX_VERSION, Math.floor(minVersion)))
for (let version = start; version <= MAX_VERSION; version++) {
if (byteLength <= byteCapacity(version, index)) return version
}
throw new RangeError(
`内容过长:${byteLength} 字节超出 ${ECC_LEVELS[index]} 级第 ${MAX_VERSION} 版的容量(${byteCapacity(MAX_VERSION, index)} 字节)`,
)
}
/** 位流拼装:模式指示符 + 字符计数 + 数据 + 终止符 + 补齐字节(0xEC / 0x11 交替)。 */
function buildDataCodewords(bytes: Uint8Array, version: number, index: number): Uint8Array {
const capacityBits = numDataCodewords(version, index) * 8
const result: number[] = []
let accumulator = 0
let bitCount = 0
const pushBits = (value: number, count: number): void => {
for (let i = count - 1; i >= 0; i--) {
accumulator = (accumulator << 1) | ((value >>> i) & 1)
bitCount++
if (bitCount === 8) {
result.push(accumulator)
accumulator = 0
bitCount = 0
}
}
}
pushBits(MODE_BYTE, 4)
pushBits(bytes.length, byteCountBits(version))
for (const byte of bytes) pushBits(byte, 8)
// 终止符最多 4 个 0;剩余空间不足 4 位时按剩余位数补。
pushBits(0, Math.min(4, capacityBits - result.length * 8 - bitCount))
if (bitCount > 0) pushBits(0, 8 - bitCount)
for (let pad = 0xec; result.length < capacityBits / 8; pad ^= 0xec ^ 0x11) result.push(pad)
return Uint8Array.from(result)
}
/**
* 分块算纠错再交错:二维码的纠错是按块做的,读出端逐块恢复,
* 单块损坏不会报销整张码,所以块划分必须与规格表完全一致。
*/
function addEccAndInterleave(data: Uint8Array, version: number, index: number): Uint8Array {
const numBlocks = numErrorCorrectionBlocks(version, index)
const blockEccLength = eccCodewordsPerBlock(version, index)
const rawCodewords = Math.floor(numRawDataModules(version) / 8)
const numShortBlocks = numBlocks - (rawCodewords % numBlocks)
const shortBlockLength = Math.floor(rawCodewords / numBlocks)
const divisor = reedSolomonComputeDivisor(blockEccLength)
const blocks: number[][] = []
for (let i = 0, offset = 0; i < numBlocks; i++) {
const dataLength = shortBlockLength - blockEccLength + (i < numShortBlocks ? 0 : 1)
const dat = Array.from(data.subarray(offset, offset + dataLength))
offset += dataLength
const ecc = reedSolomonComputeRemainder(Uint8Array.from(dat), divisor)
// 短块补一个占位,让所有块等长,交错时再按位置跳过它。
if (i < numShortBlocks) dat.push(0)
blocks.push(dat.concat(Array.from(ecc)))
}
const result: number[] = []
for (let i = 0; i < blocks[0]!.length; i++) {
for (let j = 0; j < blocks.length; j++) {
if (i !== shortBlockLength - blockEccLength || j >= numShortBlocks) result.push(blocks[j]![i]!)
}
}
return Uint8Array.from(result)
}
// ── 掩码与惩罚分 ────────────────────────────────────────────────────────────
function maskBit(mask: number, x: number, y: number): boolean {
switch (mask) {
case 0:
return (x + y) % 2 === 0
case 1:
return y % 2 === 0
case 2:
return x % 3 === 0
case 3:
return (x + y) % 3 === 0
case 4:
return (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0
case 5:
return ((x * y) % 2) + ((x * y) % 3) === 0
case 6:
return (((x * y) % 2) + ((x * y) % 3)) % 2 === 0
default:
return (((x + y) % 2) + ((x * y) % 3)) % 2 === 0
}
}
/** 规格里的「1:1:3:1:1 定位图案」扫描历史窗口,长度固定 7。 */
function finderPenaltyAddHistory(runLength: number, runHistory: number[], size: number): void {
// 首段深色之前没有浅色边界,补上整条静区宽度,否则开头/结尾的伪定位图案会漏判。
if (runHistory[0] === 0) runLength += size
runHistory.pop()
runHistory.unshift(runLength)
}
function finderPenaltyCountPatterns(runHistory: number[]): number {
const n = runHistory[1]!
const core = n > 0 && runHistory[2] === n && runHistory[3] === n * 3 && runHistory[4] === n && runHistory[5] === n
if (!core) return 0
return (runHistory[0]! >= n * 4 && runHistory[6]! >= n ? 1 : 0) + (runHistory[6]! >= n * 4 && runHistory[0]! >= n ? 1 : 0)
}
function finderPenaltyTerminateAndCount(currentRunColor: boolean, currentRunLength: number, runHistory: number[], size: number): number {
let length = currentRunLength
if (currentRunColor) {
finderPenaltyAddHistory(length, runHistory, size)
length = 0
}
length += size // 末尾补静区宽度的浅色
finderPenaltyAddHistory(length, runHistory, size)
return finderPenaltyCountPatterns(runHistory)
}
function penaltyScore(modules: boolean[][], size: number): number {
let result = 0
const scoreLine = (get: (i: number) => boolean): void => {
let runColor = false
let runLength = 0
const runHistory = [0, 0, 0, 0, 0, 0, 0]
for (let i = 0; i < size; i++) {
if (get(i) === runColor) {
runLength++
if (runLength === 5) result += PENALTY_N1
else if (runLength > 5) result++
} else {
finderPenaltyAddHistory(runLength, runHistory, size)
if (!runColor) result += finderPenaltyCountPatterns(runHistory) * PENALTY_N3
runColor = get(i)
runLength = 1
}
}
result += finderPenaltyTerminateAndCount(runColor, runLength, runHistory, size) * PENALTY_N3
}
for (let y = 0; y < size; y++) scoreLine((x) => modules[y]![x]!)
for (let x = 0; x < size; x++) scoreLine((y) => modules[y]![x]!)
// 2×2 同色块
for (let y = 0; y < size - 1; y++) {
for (let x = 0; x < size - 1; x++) {
const color = modules[y]![x]!
if (color === modules[y]![x + 1] && color === modules[y + 1]![x] && color === modules[y + 1]![x + 1]) {
result += PENALTY_N2
}
}
}
// 黑白比例偏离 50% 的惩罚
let dark = 0
for (const row of modules) for (const cell of row) if (cell) dark++
const total = size * size
const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1
return result + k * PENALTY_N4
}
// ── 矩阵构建 ────────────────────────────────────────────────────────────────
function formatBits(eccFormatBits: number, mask: number): number {
const data = (eccFormatBits << 3) | mask
let rem = data
for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537)
return ((data << 10) | rem) ^ 0x5412
}
function versionBits(version: number): number {
let rem = version
for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25)
return (version << 12) | rem
}
function buildMatrix(version: number, eccFormatBits: number, codewords: Uint8Array): boolean[][] {
const size = version * 4 + 17
const modules: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false))
const isFunction: boolean[][] = Array.from({ length: size }, () => new Array<boolean>(size).fill(false))
const setFunction = (x: number, y: number, dark: boolean): void => {
modules[y]![x] = dark
isFunction[y]![x] = true
}
/** 定位图案连静区一起画:以中心为原点 9×9,半径 4 是分隔区(浅色)。 */
const drawFinderPattern = (centerX: number, centerY: number): void => {
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
const x = centerX + dx
const y = centerY + dy
if (x < 0 || x >= size || y < 0 || y >= size) continue
const distance = Math.max(Math.abs(dx), Math.abs(dy))
setFunction(x, y, distance !== 2 && distance !== 4)
}
}
}
const drawAlignmentPattern = (centerX: number, centerY: number): void => {
for (let dy = -2; dy <= 2; dy++) {
for (let dx = -2; dx <= 2; dx++) {
setFunction(centerX + dx, centerY + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1)
}
}
}
// 计时图案先画:第 6 行/列交替。三个定位图案随后覆盖它的两端,
// 顺序反了会把定位图案底边/右边那一排误刷成浅色,整张码就直接废了。
for (let i = 0; i < size; i++) {
setFunction(6, i, i % 2 === 0)
setFunction(i, 6, i % 2 === 0)
}
drawFinderPattern(3, 3)
drawFinderPattern(size - 4, 3)
drawFinderPattern(3, size - 4)
const alignments = alignmentPatternPositions(version)
const last = alignments.length - 1
for (let i = 0; i < alignments.length; i++) {
for (let j = 0; j < alignments.length; j++) {
// 三个定位图案的角上不放对齐图案
if ((i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0)) continue
drawAlignmentPattern(alignments[i]!, alignments[j]!)
}
}
const drawFormat = (mask: number): void => {
const data = formatBits(eccFormatBits, mask)
for (let i = 0; i <= 5; i++) setFunction(8, i, getBit(data, i))
setFunction(8, 7, getBit(data, 6))
setFunction(8, 8, getBit(data, 7))
setFunction(7, 8, getBit(data, 8))
for (let i = 9; i < 15; i++) setFunction(14 - i, 8, getBit(data, i))
for (let i = 0; i < 8; i++) setFunction(size - 1 - i, 8, getBit(data, i))
for (let i = 8; i < 15; i++) setFunction(8, size - 15 + i, getBit(data, i))
setFunction(8, size - 8, true) // 固定的深色模块
}
// 先按掩码 0 占位,把格式信息区域标记成功能模块,数据码字才不会写进去。
drawFormat(0)
if (version >= 7) {
const bits = versionBits(version)
for (let i = 0; i < 18; i++) {
const bit = getBit(bits, i)
const a = size - 11 + (i % 3)
const b = Math.floor(i / 3)
setFunction(a, b, bit)
setFunction(b, a, bit)
}
}
// 数据码字从右下角起,每两列一组蛇形向上/向下填。
let bitIndex = 0
for (let right = size - 1; right >= 1; right -= 2) {
if (right === 6) right = 5 // 第 6 列是计时图案,跳过
for (let vert = 0; vert < size; vert++) {
for (let j = 0; j < 2; j++) {
const x = right - j
const upward = ((right + 1) & 2) === 0
const y = upward ? size - 1 - vert : vert
if (!isFunction[y]![x] && bitIndex < codewords.length * 8) {
modules[y]![x] = getBit(codewords[bitIndex >>> 3]!, 7 - (bitIndex & 7))
bitIndex++
}
}
}
}
const applyMask = (mask: number): void => {
for (let y = 0; y < size; y++) {
for (let x = 0; x < size; x++) {
if (!isFunction[y]![x] && maskBit(mask, x, y)) modules[y]![x] = !modules[y]![x]
}
}
}
let bestMask = 0
let minPenalty = Number.POSITIVE_INFINITY
for (let mask = 0; mask < 8; mask++) {
applyMask(mask)
drawFormat(mask)
const penalty = penaltyScore(modules, size)
if (penalty < minPenalty) {
bestMask = mask
minPenalty = penalty
}
applyMask(mask) // 撤销,试下一个
}
applyMask(bestMask)
drawFormat(bestMask)
return modules
}
// ── 对外接口 ────────────────────────────────────────────────────────────────
/**
* 把文本编成二维码模块矩阵(字节模式 / UTF-8),自动选版。
*
* 内容超出第 40 版容量时抛 RangeError:与其画一张扫不出来的码,不如让调用方
* 知道「这条内容放不下」(URL 场景下这几乎只会发生在误传整段文本时)。
*/
export function encodeQr(text: string, options: QrEncodeOptions = {}): QrMatrix {
const ecc = options.ecc ?? 'M'
const index = eccIndex(ecc)
const bytes = new TextEncoder().encode(text)
const version = chooseVersion(bytes.length, index, options.minVersion ?? MIN_VERSION)
const data = buildDataCodewords(bytes, version, index)
const codewords = addEccAndInterleave(data, version, index)
const modules = buildMatrix(version, ECC_FORMAT_BITS[index]!, codewords)
return { version, size: modules.length, ecc, modules }
}
/** 该纠错级别下最多能编多少个字节(第 40 版)。 */
export function qrByteCapacity(ecc: QrEcc = 'M'): number {
return byteCapacity(MAX_VERSION, eccIndex(ecc))
}
const SVG_NS = 'http://www.w3.org/2000/svg'
/** 无障碍标签里要放原文,URL 常带 `&` / 引号,不转义会把 aria-label 属性截断。 */
const XML_ESCAPES: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }
function escapeXml(text: string): string {
return text.replace(/[&<>"]/g, (ch) => XML_ESCAPES[ch]!)
}
/** 把矩阵渲染成 SVG 字符串:一个白底 `<rect>` + 一条深色 `<path>`,任意尺寸都清晰。 */
export function qrToSvg(text: string, options: QrSvgOptions = {}): string {
const scale = options.scale ?? 4
const margin = options.margin ?? 4
const matrix = encodeQr(text, options)
const dimension = matrix.size + margin * 2
let path = ''
for (let y = 0; y < matrix.size; y++) {
const row = matrix.modules[y]!
for (let x = 0; x < matrix.size; x++) {
if (row[x]) path += `M${x + margin} ${y + margin}h1v1h-1z`
}
}
return (
`<svg xmlns="${SVG_NS}" width="${dimension * scale}" height="${dimension * scale}" ` +
`viewBox="0 0 ${dimension} ${dimension}" shape-rendering="crispEdges" role="img" ` +
`aria-label="扫码打开 ${escapeXml(text)}">` +
`<rect width="${dimension}" height="${dimension}" fill="#FFFFFF"/>` +
`<path d="${path}" fill="#000000"/>` +
`</svg>`
)
}
/** 矩阵转成 `1`/`0` 逐行文本,便于测试与冻结参照比对。 */
export function qrMatrixToRows(matrix: QrMatrix): string[] {
return matrix.modules.map((row) => row.map((cell) => (cell ? '1' : '0')).join(''))
}