post: QR pre-download decode gate (EN+ZH) + Argon batch-10 review
Deploy / build (push) Successful in 30s

- 69-module symbol at 20 mm = 0.29 mm/module, refused below a 0.40 mm floor
- 30% centre image on a 29-module symbol knocks out 19% of area, capped at 15%
- maroon #800000 passes WCAG at 10.9:1 but is 1.9:1 on the red channel, so warn only
- 300 dpi export quoted at 50.1 x 56.9 mm with the caption band and pHYs block
- gate reads every code back with jsQR at two densities before the download unlocks
- batch-10 external review: 94/96/88/100, 10 items applied, 2 rejected with reasons
This commit is contained in:
hoelee committed 2026-11-10 09:31:43 +08:00
1 parent b75899c875
commit b7a53af943
12 files changed
+1001 -24

No files matched your search

@@ -0,0 +1,483 @@
# Batch-10 外部顾问审阅资料包 — 二维码生成器的解码闸门(Blog EN + ZH)
> **致顾问 Argon**:本轮为 blog.hoelee.com 的第 10 批次(Batch-10)技术文章审阅,只有一篇,中英双语双生。
> 取材于今日真实生产工程:`tools.hoelee.com` 的通用二维码生成器(`qr-code-generator`),其核心机制是「jsQR 未把即将导出的符号解码成原载荷之前,下载按钮一直是关闭的」。
> 请按前置规范《00-外部顾问审阅须知与背景规范》进行严格把关,本轮重点只看三件事:
> 1. **实测数字能否被读者一条公式验算**:0.29 mm/模块、0.40/0.50 mm 下限、19.2% 面积、10.9:1 与 1.9:1、50.1 mm、80 KB 预算与 ~53 KB。凡是读者拿计算器算不平的,请直接判错。
> 2. **人设与语调纯洁性**:严格杜绝 `homelab / 家用 / 小主机 / 折腾`;技术正文严禁第二人称(`you / your / 你 / 您`);严禁店主/创始人/教程作者语气,锚定资深全栈与平台工程师。
> 3. **是否值得公开发布**:这篇的读者是「要印二维码的马来西亚小商家」和「看技术深度的招聘方/CTO」,请判断现有结构能不能同时喂饱这两端,还是只喂饱了其中一端。
---
## 篇目信息
- **Slug**: `qr-gate-that-decodes-before-the-download`
- **Category**: `engineering`
- **Blueprint**: `sme-digital-suite`(`L3-application-runtime`)
- **Tags**: `qr-codes`, `verification`, `jsqr`, `canvas`, `print`, `accessibility`, `performance-budget`
- **当前状态**: `draft: true`(尚未出美术资源、未推送、未部署;正文与结构可自由改)
- **收尾 CTA**: `dynamicCta: true`(主题自动注入双轨表单,正文**没有**手写 CTA —— 这是本站规范)
---
## 已核实事实清单(供核算,不必怀疑出处)
以下几项是我从产品源码与构建闸门里直接读出来的,**可以直接用来判我的正文是否算错**:
| 事实 | 数值 | 出处 |
|---|---|---|
| 打印 DPI | 300 | `PRINT_DPI` |
| 静区 | 两侧各 4 个模块,不可调 | `QUIET_MODULES = 4` |
| 模块像素 | `ceil(box / (modules + 2*quiet))`,向上取整 | `planQr()` |
| 最小模块下限 | 0.40 mm(实心)/ 0.50 mm(点状、柔和圆角) | `MIN_MODULE_MM` / `MIN_MODULE_MM_DOTS` |
| 中心图上限 | 边长 30%,**面积 15%(含两侧各 2 模块白色挖空留白)** | `CENTRE_MAX_RATIO` / `CENTRE_MAX_AREA` |
| 对比度闸门 | WCAG 相对亮度 4.5:1(取渐变较浅的一端) | `checkColours()` |
| 红色通道告警 | 红主导且红通道比 < 3 时警告,不拒绝 | `warmInk()` |
| 回读密度 | 访问者密度(上限 24 px/模块)与 8 px/模块,两者都失败才拒绝 | `PROBE_PITCH_MAX` / `PROBE_PITCH_MIN` |
| 最近一轮闸门 | 238 + 954 测试(二维码引擎 67 条,本轮 +10)、157 页面、`astro check` 0 错误、bundle 预算绿、`probe_qr_code` 300 项、`probe_a11y` 234 项 | 仓库状态文档 §45 |
| 路由体积 | 三条二维码路由从 80.1–81.4 KB 降到 ~53 KB;预算 80 KB;站上最重路由现在是 WhatsApp 生成器 75.7 KB | 同上 |
| 泄漏源 | `libphonenumber-js` 在模块顶层被引入,28.7 KB | 同上 |
我正文里出现的每一个数字都必须能从这张表或一条公式推出来。**如果哪句推不出来,那是我错,不是表错。**
---
## 篇目 1:博客英文版(Blog EN Draft)
```markdown
---
title: "The QR Gate: Every Code Is Decoded Before It Is Downloadable"
description: "A QR generator on my tools site refuses to hand over a file until jsQR reads the payload back exactly. What the read-back could not see — a 0.29 mm module on paper, a logo cap measured in area, red ink that a 660 nm scanner treats as blank paper — and the gates that now catch each one."
pubDate: 2026-11-10
category: engineering
blueprint: sme-digital-suite
blueprintLayer: L3-application-runtime
tags: ["qr-codes", "verification", "jsqr", "canvas", "print", "accessibility", "performance-budget"]
ogImage: "/og/qr-gate-that-decodes-before-the-download.jpg"
banner: "/banners/qr-gate-that-decodes-before-the-download.jpg"
draft: true
summary:
audience: "Front-End & Platform Engineers, Business Owners Printing QR Codes"
painPoint: "A QR code that decodes on the preview canvas can still be unscannable on paper, and a generator that draws whatever was asked for has no way to know the difference."
impact:
- metric: "0.29 mm vs 0.40 mm"
detail: "A 61-module vCard at 20 mm prints below the smallest module a printer can be trusted with, and the decoder still accepted it. The floor is now measured on the printed millimetres, not the preview pixels."
- metric: "19.2% → 15% Area"
detail: "A 30% centre image plus its two-module knockout pad wiped 19.2% of the symbol's area. The cap moved from a side percentage to an area percentage, pad included."
- metric: "300 Probe Checks"
detail: "The independent probe decodes the page's own canvas with its own jsQR import, on the built site and on the live site, and refuses a download it cannot read."
stack: ["TypeScript", "Astro 5", "jsQR", "Canvas 2D", "PNG pHYs", "Playwright/CDP probe"]
jumpLinks:
business: "#why-a-qr-code-fails-on-paper"
architecture: "#five-blind-spots-of-a-clean-bitmap"
result: "#the-result"
dynamicCta: true
---
## Why a QR Code Fails on Paper <a id="why-a-qr-code-fails-on-paper"></a>
I run a set of free tools for small businesses — calculators, e-invoice helpers, PDF utilities — and the one that keeps coming back is the QR code. A shop wants a code on a table tent, a name card, a lorry decal, a receipt. The request is always the same sentence: this code works, print it.
The honest version of that job is not drawing squares. It is handing over a file that scans on the surface it is heading for, and the two most expensive failures in this class both pass every check a browser can perform. A symbol that decodes perfectly on a 400 px canvas can be 0.29 mm per module on paper, which no phone camera centre-zone will find. A maroon code scores 10.9:1 on contrast, passes accessibility tooling without a note, and reads as blank paper to the red-LED scanner bolted to a shop counter.
So the tool I built has one non-negotiable rule: **the download stays locked until jsQR decodes the symbol that is about to be exported and gets the payload back exactly.** Not a preview of it — the geometry that gets written to the file, re-inked from the same vector path, read by a decoder import that is deliberately not the one the page uses for its own verdict.
The engine itself is unremarkable: six payload types (text or URL, Wi-Fi, vCard 3.0, `tel:`, `SMSTO:`, `mailto:`), four module shapes, three finder-pattern shapes, foreground and background plus an optional two-stop gradient, sizes in millimetres, error correction L to H, an optional centre image, an optional caption, PNG at 300 dpi with a real `pHYs` chunk and SVG.
The gate is the part worth writing down, because the gate was wrong four separate ways before it was right.
## The Gate Is Easy; Trusting It Is the Work <a id="the-gate-is-easy-trusting-it-is-the-work"></a>
The first version decoded the exported bitmap. That failed immediately, and instructively: a smooth or nearest-neighbour rescale blurs the one-pixel gaps between dot-style modules, so a code that prints perfectly was refused because of how the test resampled it. The gate has to re-ink the symbol from `shapePath` — the same vector geometry the exporters use — at a chosen pixel pitch, instead of scaling a picture of it.
The second version then did the obvious thing and probed at the print's own resolution. At a 300 mm export that is 95 px per module and 3,500 px across, and at 150, 200 and 300 mm the same dot modules that decoded at every size below started failing above it. jsQR is a decoder written for photographs — a few hundred pixels across — and it has a working range. Probing at 95 px per module was testing the decoder's comfort, not the design's correctness.
The third version probed at a single low pitch, 8 px per module, which is what a 25 mm card gives. That broke the opposite way: a dot pattern that reads cleanly at 8 px per module can miss at 20, because the gaps between dots sit either side of the decoder's internal block size. Refusing on one density means blaming a good design for a decoder blind spot.
The gate that shipped offers the symbol to the decoder at **two** pitches — the visitor's own, capped at 24 px per module, and 8 px per module — and refuses only when both fail:
```ts
const nativePitch = Math.max(1, Math.floor(canvas.width / geo.total));
const pitches = [...new Set([
Math.min(nativePitch, PROBE_PITCH_MAX),
Math.min(nativePitch, PROBE_PITCH_MIN),
])];
let found: string | null = null;
let tried = 0;
for (const pitch of pitches) {
const side = geo.total * pitch;
if (side < 21) continue;
tried += 1;
probe.width = side;
probe.height = side;
pctx.imageSmoothingEnabled = false;
const scaled = centre ? rescaleCentre(centre, pitch / modulePx) : null;
paintSymbol(pctx, pitch, geo, gradient, scaled, inkDark, inkLight);
const pixels = pctx.getImageData(0, 0, side, side);
const hit = decode(pixels.data, side, side, { inversionAttempts: 'dontInvert' });
if (!hit) continue;
found = hit.data;
if (verifyDecode(expected, found) === 'ok') return 'ok';
}
if (tried === 0) return 'empty';
return found === null ? 'mismatch' : verifyDecode(expected, found);
```
One more exclusion belongs in this section: the caption band is not drawn into the probe. It is type under the code, not part of it, and at 80 mm and above its letterforms gave jsQR enough false finder candidates that a round-eyed design which decoded at 50 mm failed at 80 mm. A gate that fails on the caption is measuring the wrong object.
## Five Blind Spots of a Clean Bitmap <a id="five-blind-spots-of-a-clean-bitmap"></a>
With the gate honest, the tool still shipped codes that would fail in the physical world, because a clean bitmap at 8 px per module has a very specific set of things it cannot see. Each of the five below is now a rule with a measured number behind it.
**1. The module floor.** A 61-module payload — a vCard with a phone number and an address — at 20 mm gives 69 modules including the fixed four-module quiet zone each side: **0.29 mm per module** on paper. The gate re-inks that same symbol at 8 px per module, decodes it, and unlocks the download, and the resulting sticker is unreadable on a laminated table tent. Ink spread, toner scatter and macro focus all live below the line the decoder never crosses. `pitchCheck` now refuses a print finer than **0.40 mm** per module, and **0.50 mm** for dots and soft corners, which lose ink at every gap, quoting the millimetres it measured rather than the limit it applied.
**2. The centre image is an area problem, not a side problem.** Level H recovers roughly 30% of codewords, and that budget is not only for the logo — it is what lets the code survive a thumbprint, glare, a crease and a phone held at an angle. A 30% image on a 29-module symbol looks conservative until the two-module white knockout pad is counted: 43.8% of the side, **19.2% of the area**, deleted from a symbol whose read-back margin was measured on a clean bitmap. The cap is now **15% of the area, pad included** (`CENTRE_MAX_AREA`), which means the same 30% slider is allowed on a dense vCard and refused on a short URL. That is the physics rather than a taste.
**3. Red ink that a till scanner sees as paper.** WCAG relative luminance weights green at 0.7152 and blue at 0.0722, so maroon `#800000` on white scores **10.9:1** and sails through the 4.5:1 guard. On the red channel alone it scores **1.9:1** — and a 660 nm red-LED scanner, the kind still bolted to a shop counter, is effectively colour-blind in exactly that band. Phones read red ink without trouble, so this is a warning and never a refusal: the file stays downloadable, the sentence names the ratio, and the choice belongs to whoever is printing.
**4. A caption changes the paper, not the code.** The quoted file size used to be the symbol's side. A caption adds a band, so a "50 mm" file is really 50.1 mm wide and 56.9 mm tall. The page now quotes both numbers and says which one is the code.
**5. Whole pixels, so the promise is the printer's, not the arithmetic's.** At 300 dpi a module is an integer number of pixels, and the planner rounds **up**, not down: `ceil(box / (modules + 2 × quiet))`. Flooring would print the 25 mm option at 21.9 mm, under the very floor the page warns about; ceiling can only make the code larger than asked, which is the safe direction for a camera. A requested 50 mm comes out as **50.1 mm**, and that is the number the interface shows. The `pHYs` chunk matters for the same reason: a 50 mm code is 592 px at 300 dpi, and without `pHYs` a word processor assumes 96 dpi and places it at 157 mm — a table tent with a code three times the size it asked for.
<details class="tech-deep-dive">
<summary>🔍 The two rules the decoder could not see: pitchCheck and the centre-image area cap (TypeScript, 30 lines)</summary>
```ts
export const MIN_MODULE_MM = 0.4;
export const MIN_MODULE_MM_DOTS = 0.5;
export interface PitchCheck {
ok: boolean;
mm: number;
floor: number;
}
/** Judge the module pitch of the print, not of the preview. */
export function pitchCheck(totalModules: number, actualMm: number, style: QrStyle): PitchCheck {
const floor = style === 'dots' || style === 'soft' ? MIN_MODULE_MM_DOTS : MIN_MODULE_MM;
const mm = totalModules > 0 ? actualMm / totalModules : 0;
return { ok: mm + 1e-9 >= floor, mm: Number(mm.toFixed(3)), floor };
}
export const CENTRE_MAX_RATIO = 0.3;
/** Share of the symbol's *area* the knockout may cover, pad included. */
export const CENTRE_MAX_AREA = 0.15;
export const CENTRE_PAD_MODULES = 2;
// inside planCentre()
const ratio = percent / 100;
const sidePx = Math.max(1, Math.round(input.codePx * ratio));
const padPx = Math.max(0, input.modulePx) * CENTRE_PAD_MODULES;
if (ratio > CENTRE_MAX_RATIO + 1e-9) problems.push('too-large');
// The pad is not decoration: it is four more modules of code that no longer exist.
const knockoutSide = sidePx + padPx * 2;
const occupied = input.codePx > 0 ? (knockoutSide / input.codePx) ** 2 : 1;
if (occupied > CENTRE_MAX_AREA + 1e-9) problems.push('too-much-area');
```
</details>
## Proving the Gate Closes <a id="proving-the-gate-closes"></a>
A gate nobody has watched close is not a gate. The engine tests prove the arithmetic; a Playwright/CDP probe proves the page, and it deliberately does not trust the page's own verdict. It finds the code's black bounding box, verifies the **timing row and column** — a scaled, stretched, cropped or mistranslated drawing fails there even when jsQR still guesses a payload — and decodes the canvas with its own fresh import of the site's lazy jsQR chunk, from pixels it read itself. A symbol only its author can read fails here.
The closure test is a seam built for exactly this: with `data-simulate-mismatch="1"` the widget's read-back is fed a wrong string, and the probe asserts the download disappears. The gate has to withhold the file, not merely say it would.
That probe found three things the happy path could not:
- A centre image refused at load — 900 px against a 600 px cap — fell back to a code **without** the image and still offered the download. The silent drop the payload rules forbid. A refused file now withholds the export until it is cleared or replaced.
- The vCard's CRLF cannot be seen in a textarea, because browsers normalise the value to LF on read. The probe folds newlines for the box and proves CRLF from the decoded canvas instead.
- A rebuild mid-run (`astro build` replacing `dist/` while `astro preview` serves it) turned a check red and then crashed the run on a missing geometry. Do not rebuild while a probe is running, and a probe should report a missing page as a failure rather than a traceback.
**The check that was flattering the layout.** The image tools on the same site keep the stage beside the form, so their contract is "the download is in the first screen". This tool puts the code under a six-type form, and the first version of the probe asserted the same rule by calling `scrollIntoView` on the download block before measuring it — which measures "fits in a viewport once already scrolled there", not "is on the first screen". The honest property is that the buttons sit one glance under the code, measured in page coordinates so scrolling cannot help: at 390 px the block is 66 px below the code and 48 px tall, with 0 px of sideways overflow; at 1440 px the layout is two 518 px columns with the code at x = 719. A gate that scrolls the thing it is measuring into view is not a gate.
<details class="tech-deep-dive">
<summary>🔍 The probe's own decoder: independent of the widget's read-back (Python + injected JS, 24 lines)</summary>
```python
# The probe's own decoder: the page's own lazy jsQR chunk, imported fresh. The canvas is drawn
# from the page's pixels at their native resolution — nothing resampled, nothing smoothed — and
# only the symbol square is read, because the caption band below it is type, not code.
DECODE_JS = """(async () => {
try {
const src = document.querySelector('[data-qr]');
if (!src || !src.width) return { found: null, note: 'no canvas' };
const w = src.width, h = src.width;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d', { willReadFrequently: true });
ctx.imageSmoothingEnabled = false;
ctx.drawImage(src, 0, 0, w, h, 0, 0, w, h);
const img = ctx.getImageData(0, 0, w, h);
const chunk = performance.getEntriesByType('resource').map(e => e.name).find(n => /jsQR/i.test(n));
if (!chunk) return { found: null, note: 'no jsqr chunk' };
const mod = await import(chunk);
const fn = mod.default;
const hit = fn(img.data, w, h);
return { found: hit ? hit.data : null, size: [w, h] };
} catch (e) {
return { found: null, note: 'decode threw: ' + String(e) };
}
})()"""
```
</details>
## A Phone Parser Paid for by a Page That Only Draws <a id="a-phone-parser-paid-for-by-a-page-that-only-draws"></a>
The last failure in this round had nothing to do with QR codes and everything to do with how budgets fail. The three QR routes were the heaviest on the site — 78.7 to 79.6 KB against an 80 KB budget — and adding four sentences of new warning copy pushed them to 80.1–81.4 KB and failed `npm run verify`. Copy did not cost 2 KB.
The cause was the import graph. The QR generator's engine imported its drawing helpers from the WhatsApp link builder's engine, and that module pulls in `libphonenumber-js` at module scope: **28.7 KB** of phone parsing, paid for by a page that only draws a code. The drawing half moved into `whatsapp-link-builder/qr-core.ts`, re-exported from `engine.ts` so every existing import kept working, and the QR tool imports `qr-core` directly. The three routes dropped to roughly **53 KB**, and the heaviest route on the site is now the WhatsApp builder at 75.7 KB.
The transferable part: a bundle budget with 1.3 KB of headroom is not a budget, it is a countdown. It will fail on a text edit, and the diff will point at the text. The import graph has to be read before the sentence is blamed.
## The Result <a id="the-result"></a>
- **238 + 954 tests** pass, 67 of them in the QR engine (10 added this round).
- **157 pages** build, `astro check` reports 0 errors, bundle budget green.
- `probe_qr_code` runs **300 checks** green on the built site and again on the live site.
- `probe_a11y` covers **234 checks**, including the three QR locales.
- `probe_whatsapp_link` is unchanged after the module split, which is the check that proves the refactor moved code without moving behaviour.
- The heaviest QR route is now **~53 KB**, down from 80.1–81.4 KB, against an 80 KB budget.
## What I Would Build Again <a id="what-i-would-build-again"></a>
The gate-earns-its-keep part of this was not jsQR. It was accepting that a decoder answers a narrower question than the one that matters. jsQR can tell me the symbol is internally consistent — right modules, right payload, no shear, no mistranslation. It cannot tell me whether a printer will place those modules far enough apart to survive ink, whether a logo's knockout leaves enough error-correction budget for a thumbprint, or whether the ink absorbs light at the wavelength of the scanner on the counter.
So the rules that made this usable are all floors and budgets measured on paper: 0.40 mm per module, 15% of the area, four modules of quiet zone that are not a control, red-channel contrast as a warning rather than a wall. Every refusal quotes the number it measured — "0.29 mm per module" rather than "too small" — because a limit stated as a limit teaches nothing, and the person holding the file needs to know how far off the print is.
The probe's lesson is the one I keep re-learning in other shapes: a verification that can be satisfied by the code doing the verifying is not a verification. Feed the gate a wrong string and watch the download vanish. Measure the layout the way the reader gets it, not the way the assertion prefers to arrange it first.
```
---
## 篇目 2:博客中文版(Blog ZH Draft)
```markdown
---
title: "二维码闸门:先解码,再给下载"
description: "我在自己的工具箱站点上做的二维码生成器,只要 jsQR 没有把载荷原样读回来,就不给文件。回读看不见的东西——纸上 0.29 毫米的模块、按面积而不是按边长计算的中心图上限、660 纳米扫描枪当成白纸的红色油墨——现在各有一道闸门。"
pubDate: 2026-11-10
category: engineering
blueprint: sme-digital-suite
blueprintLayer: L3-application-runtime
tags: ["二维码", "校验", "jsqr", "canvas", "打印", "无障碍", "性能预算"]
ogImage: "/og/zh-qr-gate-that-decodes-before-the-download.jpg"
banner: "/banners/qr-gate-that-decodes-before-the-download.jpg"
draft: true
summary:
audience: "前端与平台工程师、需要打印二维码的企业主"
painPoint: "在预览画布上能解码的二维码,印到纸上可能根本扫不出来;而一个“要什么画什么”的生成器没有能力分辨这两者。"
impact:
- metric: "0.29 毫米 vs 0.40 毫米"
detail: "61 个模块的 vCard 载荷在 20 毫米尺寸下,每个模块只有 0.29 毫米,远低于打印机可信的底线,而解码器照样放行。现在底线量的是纸上的毫米,不是预览的像素。"
- metric: "19.2% 降到 15% 面积"
detail: "30% 的中心图加上两侧各两个模块的挖空留白,一共挖掉符号面积的 19.2%。上限从“边长百分比”改成“含留白的面积百分比”。"
- metric: "300 项探针校验"
detail: "独立探针用自己导入的 jsQR 解码页面画布,构建产物和线上站点各跑一遍,读不回来就不给下载。"
stack: ["TypeScript", "Astro 5", "jsQR", "Canvas 2D", "PNG pHYs", "Playwright/CDP 探针"]
jumpLinks:
business: "#why-a-qr-code-fails-on-paper"
architecture: "#five-blind-spots-of-a-clean-bitmap"
result: "#the-result"
dynamicCta: true
---
## 二维码是死在纸上,不是死在屏幕上 <a id="why-a-qr-code-fails-on-paper"></a>
我为小商家做了一组免费工具——各类计算器、电子发票辅助、PDF 工具——其中被反复要的只有一个:二维码。店主要把码印在桌面立牌上、名片上、货车车身上、收据上。需求永远是同一句话:这个码能用,帮我印。
这份工作的诚实版本并不是画方块,而是交出一个在它要去的那个表面上扫得出来的文件。而这类需求里最贵的两种失败,浏览器能做的检查全都看不出来。一个在 400 像素画布上解码完美的符号,印到纸上每个模块只有 0.29 毫米,任何手机相机的中央区域都找不到它。一个深红色的码,对比度算下来 10.9:1,可访问性工具一声不响地放行,而柜台那把红光扫描枪看到的是一张白纸。
所以这个工具只有一条不可协商的规则:**jsQR 没有把即将导出的符号解码、并且把载荷一字不差地读回来之前,下载按钮就是灰的。** 不是预览一张“应该能读”的图,而是从导出用的同一条矢量路径重新上墨,并用一个刻意不同于页面自身结论的 decoder import 来读它。
引擎本身没什么稀奇:六种载荷(文本/网址、Wi-Fi、vCard 3.0、`tel:`、`SMSTO:`、`mailto:`)、四种模块形状、三种定位图案形状、前景色与背景色加可选的双色渐变、以毫米计的尺寸、L 到 H 的纠错等级、可选中心图、可选说明文字,导出为带真实 `pHYs` 块的 300 dpi PNG 和 SVG。
值得写下来的是闸门,因为这道闸门在变对之前,错了四次。
## 闸门容易,可信才难 <a id="the-gate-is-easy-trusting-it-is-the-work"></a>
第一版直接解码导出的位图。它立刻失败,而且失败得很有教育意义:平滑重采样或最近邻缩放会把点状模块之间那一像素的缝隙抹掉,于是一个印出来完美无缺的码,被“测试怎么重采样它”这件事给拒了。闸门必须用导出器用的同一个 `shapePath`,按选定的像素密度重新上墨,而不是缩放一张它的照片。
第二版做了最直觉的事:按打印分辨率去探测。300 毫米的导出就是每个模块 95 像素、横向 3500 像素,而在 150、200、300 毫米三档实测里,同一种点状模块在更小的每一档都能解码,超过这附近就开始解不出来。jsQR 是给照片写的解码器——几百像素见方——它有自己的工作区间。按 95 像素/模块去探测,测的是解码器的舒适度,不是设计的正确性。
第三版反过来只用一个低密度,8 像素/模块,那是一个 25 毫米名片实际给出的密度。它从另一边坏掉:一个在 8 像素/模块下读得干净的点阵,在 20 像素/模块下可能解不出来,因为点与点之间的缝隙正好落在解码器内部块大小的两侧。只用一个密度就判死刑,等于让好的设计替解码器的盲区背锅。
上线的那版把符号用**两个**密度交给解码器——访问者自己选的密度(上限 24 像素/模块)和 8 像素/模块——只有两个都失败才拒绝:
```ts
const nativePitch = Math.max(1, Math.floor(canvas.width / geo.total));
const pitches = [...new Set([
Math.min(nativePitch, PROBE_PITCH_MAX),
Math.min(nativePitch, PROBE_PITCH_MIN),
])];
let found: string | null = null;
let tried = 0;
for (const pitch of pitches) {
const side = geo.total * pitch;
if (side < 21) continue;
tried += 1;
probe.width = side;
probe.height = side;
pctx.imageSmoothingEnabled = false;
const scaled = centre ? rescaleCentre(centre, pitch / modulePx) : null;
paintSymbol(pctx, pitch, geo, gradient, scaled, inkDark, inkLight);
const pixels = pctx.getImageData(0, 0, side, side);
const hit = decode(pixels.data, side, side, { inversionAttempts: 'dontInvert' });
if (!hit) continue;
found = hit.data;
if (verifyDecode(expected, found) === 'ok') return 'ok';
}
if (tried === 0) return 'empty';
return found === null ? 'mismatch' : verifyDecode(expected, found);
```
这一节还有一条排除规则:说明文字的条带不画进探针。码下面的字是字,不是码的一部分;在 80 毫米及以上,那些字形的笔画给 jsQR 提供了足够多的伪定位候选,以至于一个在 50 毫米能解的圆眼设计,到 80 毫米就解不出来。因为说明文字而失败的闸门,量错了对象。
## 干净位图看不见的五件事 <a id="five-blind-spots-of-a-clean-bitmap"></a>
闸门诚实之后,工具仍然会送出在现实世界里注定失败的码,因为一张 8 像素/模块的干净位图,有一组非常具体的盲区。下面每一条现在都是带实测数字的规则。
**一、模块下限。** 一个 61 模块的载荷——带电话和地址的 vCard——印在 20 毫米上,加上两侧各四个模块的固定静区就是 69 个模块:纸面上每个模块 **0.29 毫米**。闸门把同一个符号按 8 像素/模块重新上墨,解得出来,于是放行,而做出来的贴纸在过塑立牌上无人可扫。油墨扩散、碳粉散点、微距对焦全都在解码器永远不会越过的这条线以下。现在 `pitchCheck` 会拒绝每模块细于 **0.40 毫米**的打印件,点状与柔和圆角则为 **0.50 毫米**——它们在每个缝隙处都漏墨——并且引用它量到的毫米数,而不是它套用的限值。
**二、中心图是面积问题,不是边长问题。** H 级纠错大约能找回 30% 的码字,而这份预算不只给 logo:它还要让这个码扛住一个指纹、一片反光、一道折痕、一只斜着举起的手机。29 模块的符号放 30% 的中心图,看着很保守,直到把两侧各两个模块的白色挖空留白算进去:占边长 43.8%,**占面积 19.2%**——从一个“回读余量是在干净位图上量出来的”符号里被直接删掉。现在上限是 **含留白在内的 15% 面积**(`CENTRE_MAX_AREA`),于是同一个 30% 的滑杆,在密集的 vCard 上放行,在短网址上拒绝。这是物理,不是口味。
**三、被柜台枪当成白纸的红墨。** WCAG 相对亮度给绿色 0.7152、给蓝色 0.0722 的权重,所以白底上的深红 `#800000` 拿到 **10.9:1**,轻松越过 4.5:1 的闸门。只看红色通道是 **1.9:1**——而 660 纳米红光扫描枪,那种至今还锁在柜台上的,恰好在这个波段上是色盲。手机读红墨毫无困难,所以这一条只警告、绝不拒绝:文件照样可下载,句子会写出那个比值,选择权归打印的人。
**四、说明文字改变的是纸,不是码。** 以前报的文件尺寸是符号的边长。加了说明文字就多了一条带,所以一个“50 毫米”的文件实际是 50.1 毫米宽、56.9 毫米高。界面现在两个数都报,并且说明哪一个才是码。
**五、整数像素,所以承诺属于打印机,不属于算术。** 300 dpi 下一个模块只能是整数个像素,规划器**向上**取整,不向下:`ceil(box / (modules + 2 × quiet))`。向下取整会把 25 毫米那一档印成 21.9 毫米,正好落在这页自己警告的下限之下;向上取整只会让码比要求的大,而这对相机来说是安全的方向。要求 50 毫米,出来的是 **50.1 毫米**,界面报的也是这个数。`pHYs` 块出于同一个原因重要:50 毫米的码在 300 dpi 下是 592 像素,没有它,文字处理器会按 96 dpi 理解,摆出来就是 157 毫米——立牌上出现一个比预订大三倍的码。
<details class="tech-deep-dive">
<summary>🔍 解码器看不见的两条规则:pitchCheck 与中心图面积上限(TypeScript,30 行)</summary>
```ts
export const MIN_MODULE_MM = 0.4;
export const MIN_MODULE_MM_DOTS = 0.5;
export interface PitchCheck {
ok: boolean;
mm: number;
floor: number;
}
/** Judge the module pitch of the print, not of the preview. */
export function pitchCheck(totalModules: number, actualMm: number, style: QrStyle): PitchCheck {
const floor = style === 'dots' || style === 'soft' ? MIN_MODULE_MM_DOTS : MIN_MODULE_MM;
const mm = totalModules > 0 ? actualMm / totalModules : 0;
return { ok: mm + 1e-9 >= floor, mm: Number(mm.toFixed(3)), floor };
}
export const CENTRE_MAX_RATIO = 0.3;
/** Share of the symbol's *area* the knockout may cover, pad included. */
export const CENTRE_MAX_AREA = 0.15;
export const CENTRE_PAD_MODULES = 2;
// inside planCentre()
const ratio = percent / 100;
const sidePx = Math.max(1, Math.round(input.codePx * ratio));
const padPx = Math.max(0, input.modulePx) * CENTRE_PAD_MODULES;
if (ratio > CENTRE_MAX_RATIO + 1e-9) problems.push('too-large');
// The pad is not decoration: it is four more modules of code that no longer exist.
const knockoutSide = sidePx + padPx * 2;
const occupied = input.codePx > 0 ? (knockoutSide / input.codePx) ** 2 : 1;
if (occupied > CENTRE_MAX_AREA + 1e-9) problems.push('too-much-area');
```
</details>
## 证明闸门真的关得上 <a id="proving-the-gate-closes"></a>
没人看着关上的闸门,不算闸门。引擎测试证明算术,Playwright/CDP 探针证明页面,而且它刻意不信任页面自己的结论:它先找出码的黑色外接框,校验**时序行与时序列**——缩放、拉伸、裁切或翻译错位的图在这里会失败,哪怕 jsQR 还能猜出载荷——然后用自己新导入的、站点按需加载的 jsQR chunk 解码画布,像素是它自己读的。只有作者自己能读的符号,在这里过不了。
闭合性测试是为这件事专门留的接缝:给控件设 `data-simulate-mismatch="1"`,回读会被喂一个错字符串,探针断言下载消失。闸门必须扣住文件,而不只是嘴上说要扣。
这个探针抓到三件顺风路径看不见的事:
- 一张在加载时被拒的中心图——900 像素,超过 600 像素上限——会静默退回“没有中心图”的码,并且照样给出下载。这正是载荷规则禁止的静默丢弃。现在被拒的文件在清除或替换之前,一直扣着导出。
- vCard 的 CRLF 在 textarea 里看不见,因为浏览器读取时把值规范成了 LF。探针改成对输入框折叠换行,转而在解码后的画布上证明 CRLF。
- 探针跑到一半时重新构建(`astro build` 在 `astro preview` 供页时替换 `dist/`)会让一个检查报红,然后因为拿不到几何数据崩掉整轮。探针运行期间不要重新构建;探针应当把缺失的页面报成失败,而不是抛 traceback。
**那条在替版面说好话的检查。** 同站的图像工具把舞台放在表单旁边,所以它们的约定是“下载在第一屏”。这个工具把码放在六类表单下面,而探针的第一版调用 `scrollIntoView` 把下载块滚进视野再测量,以此断言同一条规则——那量的是“已经滚到那里之后能不能放进视口”,不是“在第一屏”。诚实的属性是:按钮就在码下方一眼的位置,用页面坐标测量,滚动帮不上忙——390 像素宽时,下载块在码下方 66 像素、高 48 像素,横向溢出 0 像素;1440 像素宽时是两栏各 518 像素,码位于 x = 719。把被测对象先滚进视野再量的闸门,不算闸门。
<details class="tech-deep-dive">
<summary>🔍 探针自己的解码器:不依赖控件的回读(Python + 注入 JS,24 行)</summary>
```python
# The probe's own decoder: the page's own lazy jsQR chunk, imported fresh. The canvas is drawn
# from the page's pixels at their native resolution — nothing resampled, nothing smoothed — and
# only the symbol square is read, because the caption band below it is type, not code.
DECODE_JS = """(async () => {
try {
const src = document.querySelector('[data-qr]');
if (!src || !src.width) return { found: null, note: 'no canvas' };
const w = src.width, h = src.width;
const c = document.createElement('canvas');
c.width = w; c.height = h;
const ctx = c.getContext('2d', { willReadFrequently: true });
ctx.imageSmoothingEnabled = false;
ctx.drawImage(src, 0, 0, w, h, 0, 0, w, h);
const img = ctx.getImageData(0, 0, w, h);
const chunk = performance.getEntriesByType('resource').map(e => e.name).find(n => /jsQR/i.test(n));
if (!chunk) return { found: null, note: 'no jsqr chunk' };
const mod = await import(chunk);
const fn = mod.default;
const hit = fn(img.data, w, h);
return { found: hit ? hit.data : null, size: [w, h] };
} catch (e) {
return { found: null, note: 'decode threw: ' + String(e) };
}
})()"""
```
</details>
## 一个只画码的页面,替一个电话解析器付了钱 <a id="a-phone-parser-paid-for-by-a-page-that-only-draws"></a>
这一轮最后一项失败和二维码毫无关系,和预算怎么失败关系很大。三条二维码路由是站上最重的——78.7 到 79.6 KB,对 80 KB 预算——而新加的四句警告文案把它们推到 80.1–81.4 KB,`npm run verify` 直接失败。文案不可能值 2 KB。
原因是 import 图。二维码生成器的引擎从 WhatsApp 链接生成器的引擎里导入绘图辅助函数,而那个模块在顶层就引了 `libphonenumber-js`:**28.7 KB** 的电话解析,由一个只画码的页面付账。绘图那一半移进了 `whatsapp-link-builder/qr-core.ts`,再从 `engine.ts` 重新导出,让所有既有 import 照旧工作,二维码工具直接引 `qr-core`。三条路由降到大约 **53 KB**,站上最重的路由现在是 WhatsApp 链接生成器的 75.7 KB。
可迁移的那部分是:只剩 1.3 KB 余量的预算不是预算,是倒计时。它一定会栽在一次文案修改上,而 diff 会指向那段文案。在怪句子之前,先读 import 图。
## 结果 <a id="the-result"></a>
- **238 + 954 个测试**通过,其中 67 个属于二维码引擎(本轮新增 10 个)。
- **157 个页面**构建成功,`astro check` 0 错误,bundle 预算绿色。
- `probe_qr_code` 的 **300 项校验**在构建产物上全绿,在线上站点再全绿一次。
- `probe_a11y` 的 **234 项校验**全绿,包含三个语种的二维码页。
- 模块拆分之后 `probe_whatsapp_link` 结果不变——这条检查证明这次重构搬了代码、没搬行为。
- 最重的二维码路由从 80.1–81.4 KB 降到 **约 53 KB**,预算仍是 80 KB。
## 我会再做一次的选择 <a id="what-i-would-build-again"></a>
这件事里真正的价值不在于 jsQR。而在于接受一件事:解码器回答的,是一个比真正重要的问题更窄的问题。jsQR 能告诉我这个符号内部自洽——模块对、载荷对、没被剪切、没被译错。它没法告诉我打印机是否把这些模块放得够开,能不能扛住油墨;没法告诉我 logo 的挖空是否还留给纠错预算足够余量去应付一个指纹;也没法告诉我这种油墨在柜台上那把扫描枪的波长上是否吸光。
所以让这个工具能用的规则,全是量在纸上的下限与预算:每模块 0.40 毫米、15% 面积、四个模块且不可调节的静区、红色通道对比只作为警告而不设墙。每一次拒绝都引用它量到的数字——“每模块 0.29 毫米”,而不是“太小了”——因为把限制说成限制教不会任何东西,而拿着文件的那个人需要知道离印得出来还差多远。
探针那一条,是我在别的形状里反复重新学到的:一个能被“做校验的那段代码”自己满足的校验,不是校验。给闸门喂一个错字符串,看下载消失。按读者拿到它的方式去量版面,而不是按断言偏好的方式先把版面摆好。
```
---
## 本轮刻意做的取舍(评审时可直接推翻,请说明理由)
1. **标题选了「The QR Gate: Every Code Is Decoded Before It Is Downloadable」**,把机制而不是品类放进标题。担心点是搜索意图:搜「qr code not scanning when printed」的人未必点这个标题。是否要换?
2. **五条盲区并列**(模块下限、中心图面积、红墨、说明文字、整数像素)。并列是为了「一次看全」,代价是节奏平。是否砍到三条?
3. **pHYs 那段我改了仓库注释的口径**。仓库原注释写「缺 pHYs 会印成 15 mm」,我按 592 px ÷ 96 dpi 算出来是 157 mm,方向反了,所以正文写的是 157 mm。请复核这个数和我对 Word 行为的描述。
4. **没有贴任何真实门店照片或打印件**,只有代码与数字。这是不是这篇最缺的一块?
5. **收尾用「What I Would Build Again」做反思**,没有第二条 CTA。符合本站规范(`dynamicCta: true`)。
---
## 请回答的问题(编号回答,每条给「结论 + 依据 + 可直接替换的终稿句子」)
- **Q1 算术**:逐条核 EN 正文里的数字(0.29 mm、0.40/0.50 mm、19.2% 面积、10.9:1 与 1.9:1、50.1 × 56.9 mm、157 mm、80.1–81.4 KB → ~53 KB)。哪条算错、哪条口径用错(例如把静区算进模块数)?给出正确数值。
- **Q2 技术反例**:两密度回读 + 「两者都失败才拒绝」是否还有反例(某种设计在两个密度都解不出、但真机可扫;或反之)?jsQR 在点状模块下「缝隙落在块大小两侧」的解释站得住吗?
- **Q3 pHYs**:592 px @300 dpi、缺 `pHYs` 时按 96 dpi 摆放为 157 mm —— 这个推导与 Word / InDesign / 浏览器的实际行为一致吗?若不一致,正文该怎么写。
- **Q4 人设**:指出任何读起来像店主、教程作者或自荐求职的句子(给行号或原句)。标题是否够强?若不够,给 2 个替代标题(保留 QR 关键词,Title Case)。
- **Q5 结构**:五条盲区是否该砍到三条?砍哪两条、为什么?开头是否缺一句「什么人该读这篇」的判断句?给可直接插入的一句。
- **Q6 CRO**:这篇要同时服务「印码的小商家」和「看深度的招聘方」。现结构下,两端各能拿到什么?指出最该补的**一处**商业锚点(不得新增未验证的承诺、不得写价格)。
- **Q7 OPSEC**:正文里的内部读数(80 KB 预算、238 + 954 测试、探针项数、`libphonenumber-js` 体积)是否适合公开?有无该脱敏的路径、账号、供应商信息?
- **Q8 中文版**:术语一致性(闸门 / 回读 / 挖空 / 静区 / 上墨)是否有漂移或翻译腔?给 3 处最该改的句子 + 终稿。
- **Q9 配图**:如果要加一张表或一张图,最小可用的是什么?给列名/图题。
最后请按四维打分(各 100 分)并给一句总评:**事实与证据严密性 / 工程师人设纯洁性 / 商业与招聘转化 / OPSEC 与脱敏合规**。允许直接回答「这条不用改」。