dsh-vibe-math 2.3.14 → 2.3.15

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/README.md CHANGED
@@ -1,4 +1,6 @@
1
1
  # Vibe Mathematics — 多代理数学问题求解与验证框架(四架构)
2
+
3
+ [English](README.en.md) | 中文
2
4
 
3
5
  [![npm](https://img.shields.io/npm/v/dsh-vibe-math)](https://www.npmjs.com/package/dsh-vibe-math)
4
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
@@ -320,4 +320,46 @@ provider **直接拒绝**。于是"提示词/规格/状态表都写着这个参
320
320
 
321
321
  - 缺陷要分**类**:"这一整类此前没有审计维度"比"修了 N 个 bug"更重要。
322
322
  - 假绿/假红必须单独说明:**审计本身失效**是最严重的发现。
323
- - 每条结论都要给出**可复核的证据**(真实日志片段、语料原文、可重跑的命令与结果)。
323
+ - 每条结论都要给出**可复核的证据**(真实日志片段、语料原文、可重跑的命令与结果)。
324
+
325
+ ---
326
+
327
+ ## 6. 发布说明(Release Notes / GitHub Release)格式
328
+
329
+ **中英双语、先中文后英文,只写"给用户看的信息"。** 工程过程(探针结果、断言条数、事故复盘、行号与
330
+ 内部编号)属于 `_oneoff/audit-findings-*.md` 台账与提交信息,**不进**发布说明。
331
+
332
+ 固定骨架(只有版本号、标题与正文随版本变,**节名不变**):
333
+
334
+ ```markdown
335
+ # dsh-vibe-math <版本> — 发布说明(中文)
336
+
337
+ > 上一版:<版本>。<一句话:这一版是干什么的、是否影响行为。>
338
+
339
+ ## 概览
340
+ ## 变更
341
+ ## 兼容性
342
+ ## 升级
343
+
344
+ ---
345
+
346
+ # dsh-vibe-math <版本> — Release Notes (English)
347
+
348
+ > Previous: <version>. <One sentence: what this release is and whether behaviour changes.>
349
+
350
+ ## Overview
351
+ ## Changes
352
+ ## Compatibility
353
+ ## Upgrade
354
+ ```
355
+
356
+ - [ ] 两个 H1 都在;**英文那份的 H1 必须含 `English`**(发布门禁阶段 1c 会检查);
357
+ - [ ] 中文部分 ≥ 150 个汉字(门禁同阶段检查),英文部分不得残留未翻译段落;
358
+ - [ ] 每条变更都回答"**对使用者有什么影响**"——不写内部编号、探针名、断言条数;
359
+ - [ ] 「兼容性」明确回答:四套预设的字节/行为是否变化、参数与工具面是否变化;
360
+ - [ ] 「升级」给出可直接复制的命令(`npm i dsh-vibe-math@latest`;若 profile 里把版本钉死了,
361
+ 写出对应的 `dsh plugin --profile <name> add dsh-vibe-math@latest`);
362
+ - [ ] **发布前把草稿交给用户确认**(在对话里展示全文,或用提问工具);用户同意或给出修改意见后再发布;
363
+ - [ ] GitHub Release 的正文 = 这份文件本身(`gh release create --notes-file <该文件>`),标题中英并列。
364
+
365
+ ---
@@ -11,8 +11,13 @@
11
11
  // · 门槛:**全体常驻一致**(全 1 或全 0)才进 Verified/,没有 forced / flat / 近共识收口;
12
12
  // · Lean 形式化:formalVerify = off / encourage / require(passed 后审查对象变成"忠实性")。
13
13
  //
14
- // 需要 PNG 时用浏览器打开 SVG 另存,或无头浏览器截图:
15
- // msedge --headless=new --window-size=1760,1300 --screenshot=框架图-v4.png 框架图-v4.svg
14
+ // 需要 PNG(市场轮播 / 预览图)时**不要**直接照着"窗口尺寸 = 画布尺寸"截图:
15
+ // msedge --headless=new --window-size=1720,1260 --screenshot=示例图/框架图-v4.png 示例图/框架图-v4.svg ← 会静默裁掉底部
16
+ // 原因:`--screenshot` 截的是**窗口**,而页面**视口**比窗口矮约 96px(浏览器 UI 占位),
17
+ // 画布底部那一截根本没被绘制——而 PNG 的尺寸又恰好等于画布尺寸,所以从尺寸上完全看不出来。
18
+ // (2.3.15 修掉的真实缺陷:v4/v5 的 PNG 因此丢了最下面的图例行,且连续两版没人发现。)
19
+ // 正确做法(按 SVG 自身尺寸渲染、裁掉多余高度、并校验墨迹到达内容底边):
20
+ // node docs/render_framework_diagram_png.mjs 示例图/框架图-v4.svg 示例图/框架图-v4.png 2
16
21
  //
17
22
  // 版式约定(改布局时请遵守):
18
23
  // · 每个 band 的标题在 y+27、副标题在 y+47,**内容从 y+56 开始**;
@@ -7,8 +7,13 @@
7
7
  // v2/v3 用 matplotlib 脚本生成 PNG;v4/v5 用 Node 直接生成 **SVG**:
8
8
  // · 本仓库的运行时就包含 Node,不需要额外装 Python / matplotlib;
9
9
  // · SVG 是纯文本,diff 友好、可评审、任意缩放不糊。
10
- // 需要 PNG 时用浏览器打开 SVG 另存,或用无头浏览器截图:
11
- // chrome --headless=new --window-size=1720,1100 --screenshot=框架图-v5.png 框架图-v5.svg
10
+ // 需要 PNG(市场轮播 / 预览图)时**不要**直接照着"窗口尺寸 = 画布尺寸"截图:
11
+ // chrome --headless=new --window-size=1720,1204 --screenshot=示例图/框架图-v5.png 示例图/框架图-v5.svg ← 会静默裁掉底部
12
+ // 原因:`--screenshot` 截的是**窗口**,而页面**视口**比窗口矮约 96px(浏览器 UI 占位),
13
+ // 画布底部那一截根本没被绘制——而 PNG 的尺寸又恰好等于画布尺寸,所以从尺寸上完全看不出来。
14
+ // (2.3.15 修掉的真实缺陷:v4/v5 的 PNG 因此丢了最下面的图例行,且连续两版没人发现。)
15
+ // 正确做法(按 SVG 自身尺寸渲染、裁掉多余高度、并校验墨迹到达内容底边):
16
+ // node docs/render_framework_diagram_png.mjs 示例图/框架图-v5.svg 示例图/框架图-v5.png 2
12
17
  //
13
18
  // 版式约定(改布局时请遵守,否则会重叠):
14
19
  // · 每个 band 的标题在 y+27、副标题在 y+47,**内容从 y+56 开始**;
@@ -0,0 +1,137 @@
1
+ # dsh-vibe-math 2.3.15 — 发布说明(中文)
2
+
3
+ > 上一版:2.3.14。本版新增**英文 README 与中英切换**,把两张架构图重新导出为**完整的高清 PNG**,
4
+ > 并把发布说明固定为**中英双语**。四套预设的代码、提示词与语料**字节未变**,运行时行为不变。
5
+
6
+ ---
7
+
8
+ ## 概览
9
+
10
+ - **英文 README**:仓库现在同时提供中英文文档,页面顶部一行即可切换;中文版仍是默认的 `README.md`,
11
+ 所以 npm 页面与 GitHub 首页显示的内容**没有变化**。
12
+ - **架构图重新导出**:`示例图/框架图-v4.png` 与 `-v5.png` 此前**底部被裁掉一条**(图例整行不见了),
13
+ 现在重新导出为 **2× 高清、完整无裁切**的图(3440×2520 / 3440×2408)。
14
+ - **发布说明双语**:从现在起每一版都提供中文 + 英文两份,先中文后英文,节名固定。
15
+
16
+ ## 变更
17
+
18
+ ### 1. 英文 README 与中英切换
19
+
20
+ - 新增 `README.en.md`,与中文版**逐节对应**;两份 README 顶部各有切换链接
21
+ (`English | 中文`)。
22
+ - 中文版仍是 `README.md`,因此 npm 页面与 GitHub 首页的默认内容不变。
23
+ - 新增守卫:两份 README 的**章节结构、图片、本地链接、表格行数、内部锚点**必须一一对应,
24
+ 且英文版不得残留未翻译正文——以后只改一边会被测试直接检出。
25
+
26
+ ### 2. 架构图重新导出(底部不再被裁)
27
+
28
+ - **现象**:`框架图-v4.png` / `-v5.png` 最下面一条(图例行)不见了;而 PNG 的尺寸又恰好等于画布尺寸,
29
+ 所以从尺寸上完全看不出来。
30
+ - **原因**:导出用的是 `--window-size=<画布宽>,<画布高>` + `--headless=new`,而它截的是**窗口**,
31
+ 页面**视口**比窗口矮约 96px(浏览器 UI 占位),画布底部那一截根本没被绘制。
32
+ - **现在**:新增 [`docs/render_framework_diagram_png.mjs`](../../docs/render_framework_diagram_png.mjs),
33
+ 按 SVG 自身尺寸渲染、裁掉多余高度,并**校验墨迹到达内容底边**——被裁就报错退出,不会再悄悄发布。
34
+ - **新增守卫**:四张架构图 PNG 必须真的装得下它自己的画面(解 PNG 与 SVG 比对墨迹范围),
35
+ v2/v3 的 matplotlib 图同样纳入检查。
36
+
37
+ ### 3. 发布说明改为中英双语
38
+
39
+ - GitHub Release 正文固定为「中文 → English」两段,节名统一:
40
+ 概览 / 变更 / 兼容性 / 升级 ↔ Overview / Changes / Compatibility / Upgrade。
41
+ - 只写使用者需要知道的信息;工程过程与证据留在仓库的审计台账与提交信息里。
42
+ - 发布门禁新增检查:当版发布说明必须同时包含中文与英文,缺一半直接判红。
43
+
44
+ ## 兼容性
45
+
46
+ - 四套预设的代码、提示词与语料**字节未变**;参数、工具面与 `off` / `encourage` / `require` 行为不变。
47
+ - 安装器行为与 2.3.14 相同:版本一变即整体替换受管文件(手改原文先备份),同一版本不重写任何文件。
48
+ - 本版只改文档、图片与测试守卫,不涉及任何运行时行为。
49
+
50
+ ## 升级
51
+
52
+ ```sh
53
+ npm i dsh-vibe-math@latest
54
+ ```
55
+
56
+ 若你在 profile 里把版本钉死了,先升级那个依赖再重启 DSH:
57
+
58
+ ```sh
59
+ dsh plugin --profile <你的 profile> add dsh-vibe-math@latest
60
+ ```
61
+
62
+ 无需迁移。
63
+
64
+ ---
65
+
66
+ # dsh-vibe-math 2.3.15 — Release Notes (English)
67
+
68
+ > Previous: 2.3.14. This release adds an **English README with a language switcher**, re-exports two
69
+ > architecture diagrams as **complete high-resolution PNGs**, and fixes the release notes to a
70
+ > **bilingual** format. The four presets' code, prompts and corpora are byte-identical; runtime
71
+ > behaviour is unchanged.
72
+
73
+ ---
74
+
75
+ ## Overview
76
+
77
+ - **English README**: the repository now documents the project in both languages, switchable from one
78
+ line at the top of either file. The Chinese README is still the default `README.md`, so what the npm
79
+ page and the GitHub landing page show is unchanged.
80
+ - **Diagrams re-exported**: `示例图/框架图-v4.png` and `-v5.png` were **missing their bottom strip**
81
+ (an entire legend row). They are now re-exported **complete at 2× resolution** (3440×2520 / 3440×2408).
82
+ - **Bilingual release notes**: from now on every release ships Chinese and English, Chinese first,
83
+ with fixed section names.
84
+
85
+ ## Changes
86
+
87
+ ### 1. English README and language switching
88
+
89
+ - Added `README.en.md`, section-for-section parallel to the Chinese one; each file carries a switcher
90
+ link at the top (`English | 中文`).
91
+ - The Chinese README remains `README.md`, so npm and the GitHub landing page keep their current content.
92
+ - New guard: the two READMEs must agree on **heading structure, images, local links, table rows and
93
+ internal anchors**, and the English file must not contain untranslated prose — editing only one side
94
+ now fails the suite.
95
+
96
+ ### 2. Diagrams re-exported (nothing is cut off any more)
97
+
98
+ - **Symptom**: the bottom strip of `框架图-v4.png` / `-v5.png` (the legend row) was missing, while the
99
+ PNG still measured exactly the canvas size — so no size-based check could ever see it.
100
+ - **Cause**: the export used `--window-size=<canvas width>,<canvas height>` together with
101
+ `--headless=new`, which screenshots the **window** while the page **viewport** is ~96px shorter
102
+ (browser UI), so the bottom of the canvas was never painted.
103
+ - **Now**: [`docs/render_framework_diagram_png.mjs`](../../docs/render_framework_diagram_png.mjs) renders
104
+ at the SVG's own size, crops the extra height, and **verifies that the ink reaches the artwork's
105
+ bottom edge** — a short raster exits non-zero instead of shipping.
106
+ - **New guard**: each committed diagram PNG must actually contain its own artwork (pixel-extent
107
+ comparison against the SVG); the v2/v3 matplotlib diagrams are covered as well.
108
+
109
+ ### 3. Bilingual release notes
110
+
111
+ - The GitHub Release body is now fixed to a Chinese section followed by an English one, with stable
112
+ headings: 概览 / 变更 / 兼容性 / 升级 ↔ Overview / Changes / Compatibility / Upgrade.
113
+ - Only what a user needs to know; engineering evidence stays in the repository's audit ledger and
114
+ commit messages.
115
+ - The release gate now fails when the current version's notes lack either half.
116
+
117
+ ## Compatibility
118
+
119
+ - The four presets' code, prompts and corpora are **byte-identical**; parameters, tool surface and the
120
+ `off` / `encourage` / `require` behaviour are unchanged.
121
+ - Installer behaviour is the same as 2.3.14: a version change replaces the managed files (backing up
122
+ user edits first), and the same version rewrites nothing.
123
+ - This release changes documentation, images and test guards only — no runtime behaviour.
124
+
125
+ ## Upgrade
126
+
127
+ ```sh
128
+ npm i dsh-vibe-math@latest
129
+ ```
130
+
131
+ If you pinned the version in a profile, update that dependency first and restart DSH:
132
+
133
+ ```sh
134
+ dsh plugin --profile <your profile> add dsh-vibe-math@latest
135
+ ```
136
+
137
+ No migration needed.
@@ -0,0 +1,273 @@
1
+ #!/usr/bin/env node
2
+ // ============================================================
3
+ // 把生成好的框架图 SVG 光栅化为**完整**的高清 PNG(无头 Chrome)。
4
+ //
5
+ // node docs/render_framework_diagram_png.mjs 示例图/框架图-v5.svg 示例图/框架图-v5.png 2
6
+ //
7
+ // 为什么需要这个脚本(2.3.15 修掉的真实缺陷):过去那条一行命令
8
+ // chrome --headless=new --window-size=1720,1204 --screenshot=框架图-v5.png …
9
+ // 截的是**窗口**,而页面**视口**比窗口矮约 96px(浏览器 UI)——
10
+ // 于是 SVG 底部那一截(图例行)根本没被绘制,而 PNG 的尺寸又恰好等于 SVG 的画布尺寸,
11
+ // 所以"少了 96px"这件事从尺寸上看不出来,一直没人发现。
12
+ //
13
+ // 因此这里:
14
+ // 1. 用**多留一段窗口高度**的方式渲染(保证整个视口都被绘制);
15
+ // 2. 把顶部 `svg宽 × svg高 × scale` 区域裁出来;
16
+ // 3. **校验**结果:尺寸必须精确相等,且"墨迹"必须到达 SVG 自身内容的底边——
17
+ // 凡是比原图矮/被裁的产物都会**直接报错退出**,不会再悄悄发布出去。
18
+ // ============================================================
19
+ import { readFileSync, writeFileSync, copyFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'
20
+ import { spawnSync } from 'node:child_process'
21
+ import { deflateSync, inflateSync } from 'node:zlib'
22
+ import { tmpdir } from 'node:os'
23
+ import { basename, join } from 'node:path'
24
+
25
+ const CHROME_CANDIDATES = [
26
+ 'C:/Program Files/Google/Chrome/Application/chrome.exe',
27
+ 'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
28
+ 'C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
29
+ 'C:/Program Files/Microsoft/Edge/Application/msedge.exe',
30
+ '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser',
31
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
32
+ ]
33
+ const CHROME = process.env.CHROME_PATH || CHROME_CANDIDATES.find((p) => existsSync(p))
34
+ const PAD = 240 // 额外窗口高度:必须大于 new-headless 的浏览器 UI 高度
35
+
36
+ // ---------- SVG ----------
37
+ export function svgSize(file) {
38
+ const head = readFileSync(file, 'utf8').slice(0, 800)
39
+ const w = Number((head.match(/\bwidth="(\d+(?:\.\d+)?)"/) || [])[1])
40
+ const h = Number((head.match(/\bheight="(\d+(?:\.\d+)?)"/) || [])[1])
41
+ if (!w || !h) throw new Error('无法从 ' + file + ' 读出 width/height')
42
+ return { w, h }
43
+ }
44
+
45
+ /** SVG 内容(含嵌套 translate)的真实外接框,用来判断光栅是否被裁。 */
46
+ export function contentBox(file) {
47
+ const svg = readFileSync(file, 'utf8')
48
+ const stack = [{ x: 0, y: 0 }]
49
+ let maxBottom = -Infinity, maxRight = -Infinity
50
+ const re = /<(\/?)(g|rect|text|line|circle|polyline|polygon|path|image)\b([^>]*)>/g
51
+ let m
52
+ while ((m = re.exec(svg)) !== null) {
53
+ const [, close, tag, attrs] = m
54
+ if (tag === 'g') {
55
+ if (close) { if (stack.length > 1) stack.pop(); continue }
56
+ const t = /transform="translate\(\s*(-?[\d.]+)[ ,]+(-?[\d.]+)/.exec(attrs)
57
+ const cur = stack[stack.length - 1]
58
+ stack.push({ x: cur.x + (t ? Number(t[1]) : 0), y: cur.y + (t ? Number(t[2]) : 0) })
59
+ continue
60
+ }
61
+ if (close) continue
62
+ const cur = stack[stack.length - 1]
63
+ const num = (n) => { const r = new RegExp('\\b' + n + '="(-?[\\d.]+)"').exec(attrs); return r ? Number(r[1]) : null }
64
+ let bottom = null, right = null
65
+ if (tag === 'rect' || tag === 'image') {
66
+ const y = num('y'), h = num('height'), x = num('x'), w = num('width')
67
+ if (y !== null && h !== null) bottom = cur.y + y + h
68
+ if (x !== null && w !== null) right = cur.x + x + w
69
+ } else if (tag === 'text') {
70
+ const y = num('y')
71
+ if (y !== null) bottom = cur.y + y + (num('font-size') || 13) * 0.32
72
+ } else if (tag === 'line') {
73
+ const y1 = num('y1'), y2 = num('y2'), x1 = num('x1'), x2 = num('x2')
74
+ if (y1 !== null && y2 !== null) bottom = cur.y + Math.max(y1, y2)
75
+ if (x1 !== null && x2 !== null) right = cur.x + Math.max(x1, x2)
76
+ } else if (tag === 'circle') {
77
+ const cy = num('cy'), r = num('r')
78
+ if (cy !== null && r !== null) bottom = cur.y + cy + r
79
+ } else if (tag === 'polyline' || tag === 'polygon') {
80
+ const pts = /points="([^"]+)"/.exec(attrs)
81
+ if (pts) {
82
+ const v = pts[1].trim().split(/[\s,]+/).map(Number)
83
+ const ys = v.filter((_, i) => i % 2 === 1)
84
+ if (ys.length) bottom = cur.y + Math.max(...ys)
85
+ }
86
+ } else if (tag === 'path') {
87
+ const d = /d="([^"]+)"/.exec(attrs)
88
+ if (d) {
89
+ const nums = d[1].match(/-?\d+(?:\.\d+)?/g) || []
90
+ const ys = nums.map(Number).filter((_, i) => i % 2 === 1)
91
+ if (ys.length) bottom = cur.y + Math.max(...ys)
92
+ }
93
+ }
94
+ if (bottom !== null && bottom > maxBottom) maxBottom = bottom
95
+ if (right !== null && right > maxRight) maxRight = right
96
+ }
97
+ return { bottom: maxBottom, right: maxRight }
98
+ }
99
+
100
+ // ---------- PNG(只处理浏览器截图会产生的 8bit RGB/RGBA 非隔行) ----------
101
+ export function decodePng(buf) {
102
+ if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('不是 PNG')
103
+ let off = 8, w = 0, h = 0, depth = 0, color = 0, interlace = 0
104
+ const idat = []
105
+ while (off < buf.length) {
106
+ const len = buf.readUInt32BE(off)
107
+ const type = buf.toString('ascii', off + 4, off + 8)
108
+ const data = buf.subarray(off + 8, off + 8 + len)
109
+ if (type === 'IHDR') { w = data.readUInt32BE(0); h = data.readUInt32BE(4); depth = data[8]; color = data[9]; interlace = data[12] } else if (type === 'IDAT') idat.push(data)
110
+ else if (type === 'IEND') break
111
+ off += 12 + len
112
+ }
113
+ if (depth !== 8 || interlace !== 0 || (color !== 2 && color !== 6)) throw new Error(`不支持的 PNG(depth=${depth} color=${color} interlace=${interlace})`)
114
+ const bpp = color === 6 ? 4 : 3
115
+ const stride = w * bpp
116
+ const raw = inflateSync(Buffer.concat(idat))
117
+ const px = Buffer.alloc(h * stride)
118
+ let p = 0
119
+ for (let y = 0; y < h; y++) {
120
+ const ft = raw[p++]
121
+ const row = raw.subarray(p, p + stride); p += stride
122
+ const out = px.subarray(y * stride, (y + 1) * stride)
123
+ const prev = y > 0 ? px.subarray((y - 1) * stride, y * stride) : null
124
+ for (let i = 0; i < stride; i++) {
125
+ const a = i >= bpp ? out[i - bpp] : 0
126
+ const b = prev ? prev[i] : 0
127
+ const c = prev && i >= bpp ? prev[i - bpp] : 0
128
+ let v = row[i]
129
+ if (ft === 1) v = (v + a) & 0xff
130
+ else if (ft === 2) v = (v + b) & 0xff
131
+ else if (ft === 3) v = (v + ((a + b) >> 1)) & 0xff
132
+ else if (ft === 4) {
133
+ const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c)
134
+ v = (v + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c)) & 0xff
135
+ }
136
+ out[i] = v
137
+ }
138
+ }
139
+ return { w, h, bpp, px }
140
+ }
141
+
142
+ const CRC_TABLE = (() => {
143
+ const t = new Int32Array(256)
144
+ for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c }
145
+ return t
146
+ })()
147
+ const crc32 = (buf) => { let c = -1; for (const b of buf) c = CRC_TABLE[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ -1) >>> 0 }
148
+ function chunk(type, data) {
149
+ const out = Buffer.alloc(8 + data.length + 4)
150
+ out.writeUInt32BE(data.length, 0)
151
+ out.write(type, 4, 'ascii')
152
+ data.copy(out, 8)
153
+ out.writeUInt32BE(crc32(Buffer.concat([Buffer.from(type, 'ascii'), data])), 8 + data.length)
154
+ return out
155
+ }
156
+ function encodePng(w, h, bpp, px) {
157
+ const stride = w * bpp
158
+ const raw = Buffer.alloc(h * (stride + 1))
159
+ for (let y = 0; y < h; y++) {
160
+ raw[y * (stride + 1)] = 0
161
+ px.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride)
162
+ }
163
+ const ihdr = Buffer.alloc(13)
164
+ ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4)
165
+ ihdr[8] = 8; ihdr[9] = bpp === 4 ? 6 : 2
166
+ return Buffer.concat([
167
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
168
+ chunk('IHDR', ihdr), chunk('IDAT', deflateSync(raw, { level: 9 })), chunk('IEND', Buffer.alloc(0)),
169
+ ])
170
+ }
171
+
172
+ /** 墨迹范围(相对背景色):用来断言"图没被裁"。 */
173
+ function inkExtent(img) {
174
+ const { w, h, bpp, px } = img
175
+ const bg = [px[0], px[1], px[2]]
176
+ let lastInk = -1, lastX = -1
177
+ for (let y = 0; y < h; y++) {
178
+ for (let x = 0; x < w; x++) {
179
+ const i = y * w * bpp + x * bpp
180
+ if (Math.abs(px[i] - bg[0]) + Math.abs(px[i + 1] - bg[1]) + Math.abs(px[i + 2] - bg[2]) > 12) {
181
+ if (y > lastInk) lastInk = y
182
+ if (x > lastX) lastX = x
183
+ }
184
+ }
185
+ }
186
+ return { lastInk, lastX }
187
+ }
188
+
189
+ /**
190
+ * 同一个问题的**快速**版本:想知道"墨迹最低到哪一行、最右到哪一列",
191
+ * 从底边向上、从右边向左扫,命中即停——守卫里对 3400×2400 的图也只花毫秒级。
192
+ */
193
+ export function inkExtentFast(img) {
194
+ const { w, h, bpp, px } = img
195
+ const bg = [px[0], px[1], px[2]]
196
+ const isInk = (x, y) => {
197
+ const i = y * w * bpp + x * bpp
198
+ return Math.abs(px[i] - bg[0]) + Math.abs(px[i + 1] - bg[1]) + Math.abs(px[i + 2] - bg[2]) > 12
199
+ }
200
+ let lastInk = -1
201
+ for (let y = h - 1; y >= 0 && lastInk < 0; y--) {
202
+ for (let x = 0; x < w; x++) if (isInk(x, y)) { lastInk = y; break }
203
+ }
204
+ let lastX = -1
205
+ for (let x = w - 1; x >= 0 && lastX < 0; x--) {
206
+ for (let y = 0; y < h; y++) if (isInk(x, y)) { lastX = x; break }
207
+ }
208
+ return { lastInk, lastX }
209
+ }
210
+
211
+ // ---------- render ----------
212
+ function asciiTempDir() {
213
+ for (const c of [process.env.TEMP, process.env.TMP, tmpdir(), '/tmp']) {
214
+ if (c && /^[\x20-\x7e]+$/.test(c) && existsSync(c)) return c
215
+ }
216
+ throw new Error('找不到纯 ASCII 的临时目录;无头 Chrome 打不开非 ASCII 路径')
217
+ }
218
+
219
+ export function renderSvgToPng(svgPath, outPng, scale = 2) {
220
+ if (!CHROME) throw new Error('找不到 Chrome/Edge;可用 CHROME_PATH 指定。试过:\n ' + CHROME_CANDIDATES.join('\n '))
221
+ const { w, h } = svgSize(svgPath)
222
+ const { bottom, right } = contentBox(svgPath)
223
+ const dir = mkdtempSync(join(asciiTempDir(), 'svg-png-'))
224
+ try {
225
+ const inSvg = join(dir, 'in.svg')
226
+ const shot = join(dir, 'shot.png')
227
+ copyFileSync(svgPath, inSvg)
228
+ const args = ['--headless=new', '--disable-gpu', '--no-sandbox', '--no-first-run', '--hide-scrollbars',
229
+ `--force-device-scale-factor=${scale}`, `--window-size=${w},${h + PAD}`,
230
+ `--user-data-dir=${join(dir, 'profile')}`, `--screenshot=${shot}`, 'file:///' + inSvg.replace(/\\/g, '/')]
231
+ const r = spawnSync(CHROME, args, { encoding: 'utf8' })
232
+ if (!existsSync(shot)) throw new Error('Chrome 没有产出截图:' + String(r.stderr || '').slice(-300))
233
+
234
+ const img = decodePng(readFileSync(shot))
235
+ const needW = Math.round(w * scale), needH = Math.round(h * scale)
236
+ if (img.w < needW || img.h < needH) throw new Error(`截图 ${img.w}x${img.h} 小于所需 ${needW}x${needH}`)
237
+ // 裁掉多留的高度(以及 new-headless 的浏览器 UI 区域)
238
+ const stride = img.w * img.bpp
239
+ const cropped = Buffer.alloc(needW * img.bpp * needH)
240
+ for (let y = 0; y < needH; y++) {
241
+ img.px.copy(cropped, y * needW * img.bpp, y * stride, y * stride + needW * img.bpp)
242
+ }
243
+ const out = { w: needW, h: needH, bpp: img.bpp, px: cropped }
244
+ const ink = inkExtent(out)
245
+ const tol = 14 * scale
246
+ const problems = []
247
+ if (ink.lastInk < Math.round(bottom * scale) - tol) {
248
+ problems.push(`墨迹只到第 ${ink.lastInk} 行,而 SVG 内容底边在 ${Math.round(bottom * scale)} 行 —— 底部被裁掉了`)
249
+ }
250
+ if (right > 0 && ink.lastX < Math.round(right * scale) - tol) {
251
+ problems.push(`墨迹最右只到第 ${ink.lastX} 列,而 SVG 内容右边界在 ${Math.round(right * scale)} 列 —— 右侧被裁掉了`)
252
+ }
253
+ if (problems.length) throw new Error('光栅化结果不完整:\n - ' + problems.join('\n - '))
254
+ writeFileSync(outPng, encodePng(out.w, out.h, out.bpp, out.px))
255
+ return { svg: { w, h }, png: { w: out.w, h: out.h }, scale, ink, content: { bottom, right } }
256
+ } finally {
257
+ rmSync(dir, { recursive: true, force: true })
258
+ }
259
+ }
260
+
261
+ const invokedDirectly = process.argv[1] && basename(process.argv[1]) === 'render_framework_diagram_png.mjs'
262
+ if (invokedDirectly) {
263
+ const [svg, png, scale] = process.argv.slice(2)
264
+ if (!svg || !png) { console.error('用法: node docs/render_framework_diagram_png.mjs <svg> <png> [scale=2]'); process.exit(2) }
265
+ try {
266
+ const r = renderSvgToPng(svg, png, Number(scale || 2))
267
+ console.log(`wrote ${png}: svg ${r.svg.w}x${r.svg.h} × ${r.scale} → ${r.png.w}x${r.png.h}`
268
+ + ` (ink to row ${r.ink.lastInk}/${r.png.h - 1}, content bottom ${r.content.bottom.toFixed(0)})`)
269
+ } catch (e) {
270
+ console.error(String(e.message || e))
271
+ process.exit(1)
272
+ }
273
+ }
@@ -25,8 +25,10 @@ node tests/audit-v5-integrity.mjs # 静态:v5 完整性/理念门
25
25
 
26
26
  | 脚本 | 串行(sum) | 并行(wall) | 实测输出 |
27
27
  |---|---|---|---|
28
- | `tests/run-tests.mjs`(26 个套件) | 225.8 s | **109.7 s**(并发 4,speed-up x2.06) | 关键路径 = `e2e-v4-fixes` 95.9 s |
28
+ | `tests/run-tests.mjs`(28 个套件) | ≈ 230 s | **≈ 110 s**(并发 4) | 关键路径 = `e2e-v4-fixes` ≈ 96 s |
29
29
  | `tests/audit-installer-policy.test.mjs` | ≈ 1 s | — | 在临时 DSH_HOME 里驱动真实安装器(复制 4 套预设 × 2 个版本) |
30
+ | `tests/audit-readme-bilingual.test.mjs` | ≈ 0.2 s | — | 中英 README 的结构/链接/图片/锚点一致性(纯静态) |
31
+ | `tests/audit-diagram-assets.test.mjs` | ≈ 1 s | — | 四张架构图 PNG 必须装得下它自己的画面(解 PNG 与 SVG,纯静态) |
30
32
  | `audit-formal-sensitivity.mjs`(49 探针) | 612.0 s | **154.6 s**(并发 4,speed-up x3.96) | 关键路径 = 12 个 v2 探针(每个 ≈32 s) |
31
33
  | `audit-persona-sensitivity.mjs`(11 探针) | ≈ 5 s | — | 本身很快,不需要并行 |
32
34
  | `audit-prompt-invariants.mjs`(157 条,含 X5–X8b 扫描器自检) | 0.4 s | — | 静态 |
@@ -50,7 +52,7 @@ node tests/audit-v5-integrity.mjs # 静态:v5 完整性/理念门
50
52
  | `e2e-d9-d13.test.mjs` | ≈ 13 s | |
51
53
  | `e2e-v3.test.mjs` | ≈ 12 s | |
52
54
  | `formal-verify-v3.test.mjs` | ≈ 12 s | |
53
- | 其余 19 个 | ≤ 6 s | 其中 12 个 < 1 s |
55
+ | 其余 21 个 | ≤ 6 s | 其中绝大多数 < 1 s |
54
56
 
55
57
  ## 3. 已经做过的优化(别再重复踩)
56
58