@yfwu2020/dsh-selection-explain 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,296 @@
1
+ /**
2
+ * 生成 README 用的小窗演示页(docs/demo/*.html)。
3
+ *
4
+ * 目的:README 里的功能截图必须**和真实面板长得一样**,所以样式不能手抄 ——
5
+ * 这里直接从 lib/client.js 里把模块级 `CSS` 常量抽出来注入演示页,
6
+ * 类名 / 结构 / 文案也照 src/client/index.js 的渲染器与真实输出写。
7
+ *
8
+ * 用法:node scripts/build-demo-pages.mjs
9
+ * 产物:docs/demo/stage1.html / stage2.html / code.html(再交给 Chrome 截图)
10
+ */
11
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
12
+ import { resolve, dirname } from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
14
+
15
+ const HERE = dirname(fileURLToPath(import.meta.url))
16
+ const ROOT = resolve(HERE, '..')
17
+
18
+ /** 从客户端 bundle 里取出模块级 CSS 常量(不手抄,保证与线上一致)。 */
19
+ function extractCss() {
20
+ const src = readFileSync(resolve(ROOT, 'lib', 'client.js'), 'utf8')
21
+ const m = src.match(/var CSS = \[([\s\S]*?)\]\s*\.join\(''\)/) || src.match(/var CSS = \[([\s\S]*?)\]\.join\('\\n'\)/)
22
+ if (!m) throw new Error('未能在 lib/client.js 里找到 CSS 常量')
23
+ // 元素是 '...' 单引号字符串字面量
24
+ const parts = []
25
+ const re = /'((?:[^'\\]|\\.)*)'/g
26
+ let e
27
+ while ((e = re.exec(m[1])) !== null) parts.push(e[1].replace(/\\'/g, "'").replace(/\\n/g, '\n'))
28
+ return parts.join('\n')
29
+ }
30
+
31
+ /** DSH 主题变量:用浅色主题的实际取值,让截图看起来像真的嵌在 DSH 里。 */
32
+ const THEME_VARS = `
33
+ --dsw-alias-bg-layer-1: #ffffff;
34
+ --dsw-alias-label-primary: #1a1a1a;
35
+ --dsw-alias-label-secondary: #6b7280;
36
+ --dsw-alias-label-primary-foreground: #ffffff;
37
+ --dsw-alias-interactive-bg-hover: rgba(140,140,140,.11);
38
+ --dsw-alias-border-l3: rgba(140,140,140,.35);
39
+ --dsw-alias-border-l4: rgba(140,140,140,.22);
40
+ --dsw-alias-button-primary-fill: #3b6ef5;
41
+ --sel-a1: #0d9488;
42
+ --sel-a2: #0f766e;
43
+ `
44
+
45
+ const PAGE = (title, css, body) => `<!doctype html>
46
+ <html lang="zh-CN"><head><meta charset="utf-8"><title>${title}</title>
47
+ <style>
48
+ /* 只渲染小窗本体:纯白底、无外边距,宽度固定 540 —— 截图后统一裁切 */
49
+ html,body{margin:0;padding:0;background:#ffffff;font-family:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif}
50
+ body{display:block;width:540px}
51
+ ${THEME_VARS}
52
+ ${css}
53
+ /* 演示页专用:把浮层定位改成静态排版、并显示面板(真实面板默认 display:none,由 JS 打开) */
54
+ .dsh-sel-layer{position:static !important;pointer-events:auto}
55
+ /* 截图只要"小窗本体":去掉投影、边框、圆角 —— 否则裁切后会在上下边缘留下一圈灰线
56
+ (真实面板的投影是给浮层用的,单独截图时只会变成灰边) */
57
+ .dsh-sel-panel{position:static !important;display:flex !important;max-height:none !important;width:540px !important;
58
+ box-shadow:none !important;border:0 !important;border-radius:0 !important}
59
+ /* 首轮:真实行为是「翻译一出来输入框就出现」(askRow.style.display = wide || translationReady),
60
+ 所以演示页也照实显示,不再隐藏 */
61
+ .dsh-sel-ask{display:flex !important;border:0 !important}
62
+ </style></head>
63
+ <body>${body}</body></html>
64
+ `
65
+
66
+ const panel = (stage, inner) => `<div class="dsh-sel-layer">
67
+ <div class="dsh-sel-panel" data-stage="${stage}" data-theme="light" role="dialog" aria-label="划词解读">
68
+ <div class="dsh-sel-head">
69
+ <span class="dsh-sel-mark"></span>
70
+ <span class="dsh-sel-title">划词解读</span>
71
+ <span class="dsh-sel-headspace"></span>
72
+ <button class="dsh-sel-action">🕘 最近</button>
73
+ <button class="dsh-sel-action">↗ 升格</button>
74
+ <button class="dsh-sel-icon" title="关闭">✕</button>
75
+ </div>
76
+ <div class="dsh-sel-body">
77
+ ${inner}
78
+ </div>
79
+ </div>
80
+ </div>`
81
+
82
+ const quote = (text) => ` <div class="dsh-sel-quote">${text}</div>`
83
+
84
+ const section = (key, title, content) => ` <div class="dsh-sel-sec" data-sec="${key}">
85
+ <div class="dsh-sel-sh"><i></i><span>${title}</span><span class="dsh-sel-hint"></span></div>
86
+ <div class="dsh-sel-c">
87
+ ${content}
88
+ </div>
89
+ </div>`
90
+
91
+ const p = (html) => ` <p>${html}</p>`
92
+ const list = (level, items) => ` <div class="dsh-sel-list" data-level="${level}">
93
+ ${items.map((it) => ` <div class="dsh-sel-item"${it.num ? ' data-num="1"' : ''}><span class="dsh-sel-bullet">${it.bullet || '•'}</span><span>${it.text}</span></div>`).join('\n')}
94
+ </div>`
95
+ const callout = (tag, text) => ` <div class="dsh-sel-callout"><span class="dsh-sel-callout-tag">${tag}</span>${text}</div>`
96
+
97
+ const expand = ` <button class="dsh-sel-expand"><span>↓ 展开详解</span></button>`
98
+
99
+ /** 真实图标(照 src/client/index.js 的 markdownIcon / windowIcon / sendIcon)。 */
100
+ const mdIcon = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 17.5V7.5l4.4 5 4.4-5v10"></path><path d="M17.4 7v9.6M14.3 13.6l3.1 3.1 3.1-3.1"></path></svg>`
101
+ const webIcon = `<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="4.5" width="18" height="15" rx="3"></rect><path d="M3 9.6h18"></path></svg>`
102
+ const sendIcon = `<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 13.2V3.4"></path><path d="M3.9 7.5 8 3.4l4.1 4.1"></path></svg>`
103
+
104
+ /**
105
+ * 输入区(结构照 src/client/index.js 的真实构建顺序):
106
+ * .dsh-sel-ask > textarea.dsh-sel-askbox + .dsh-sel-asktools
107
+ * asktools = 输出偏好(文字 + 分段开关 + 滑动药丸) + askspace(撑开) + 模型胶囊 + 发送键
108
+ */
109
+ const askRow = (webOn, model = 'deepseek-v4.1-flash', tier = '高') => ` <div class="dsh-sel-ask">
110
+ <textarea class="dsh-sel-askbox" rows="1" placeholder="就这段文字继续追问…(Enter 发送,Shift+Enter 换行)"></textarea>
111
+ <div class="dsh-sel-asktools">
112
+ <span class="dsh-sel-pref" data-on="${webOn ? '1' : '0'}">
113
+ <span class="dsh-sel-preftext">输出偏好</span>
114
+ <span class="dsh-sel-seg" role="radiogroup" aria-label="输出偏好">
115
+ <span class="dsh-sel-segpill"></span>
116
+ <button class="dsh-sel-segcell" role="radio" aria-label="Markdown:普通段落,最省时间"${webOn ? '' : ' data-on="1"'}>${mdIcon}</button>
117
+ <button class="dsh-sel-segcell" role="radio" aria-label="网页:复杂问题生成一个 HTML 页面"${webOn ? ' data-on="1"' : ''}>${webIcon}</button>
118
+ </span>
119
+ </span>
120
+ <span class="dsh-sel-askspace"></span>
121
+ <button class="dsh-sel-picker" type="button" aria-haspopup="menu" aria-expanded="false">
122
+ <span class="dsh-sel-picker-name">${model}</span>
123
+ <span class="dsh-sel-picker-tier">· ${tier}</span>
124
+ <span class="dsh-sel-picker-caret">▼</span>
125
+ </button>
126
+ <button class="dsh-sel-iconbtn dsh-sel-asksend" type="button" aria-label="发送">${sendIcon}</button>
127
+ </div>
128
+ </div>`
129
+
130
+ const chatBubble = (role, html) =>
131
+ ` <div class="dsh-sel-bubble ${role === 'user' ? 'dsh-sel-bubble-user' : 'dsh-sel-bubble-bot'}">${html}</div>`
132
+
133
+ // ───────────────────────── 状态一:首轮只出翻译 ─────────────────────────
134
+ const stage1 = panel(
135
+ 'translation',
136
+ [
137
+ quote('the migration ran long'),
138
+ section(
139
+ 'translation',
140
+ '翻译',
141
+ [
142
+ p('这句话里需要翻译的是 <b>the migration ran long</b>:'),
143
+ list(0, [{ text: '<b>migration</b> /maɪˈɡreɪʃn/ —— 迁移(把数据/服务从一处搬到另一处)' }]),
144
+ list(0, [{ text: '<b>ran long</b> —— 跑得比预期久(ran = run 的过去式,long 指耗时超出预期)' }]),
145
+ p('合起来:<b>迁移跑得比预期久</b>。'),
146
+ callout('在本句中', '迁移作业耗时超出了原计划——这是在陈述进度延迟的事实,不含"失败了"的意思。'),
147
+ ].join('\n'),
148
+ ),
149
+ expand,
150
+ askRow(false),
151
+ ].join('\n'),
152
+ )
153
+
154
+ // ───────────────────────── 状态二:展开详解 + 追问 ─────────────────────────
155
+ const stage2 = panel(
156
+ 'detail',
157
+ [
158
+ quote('the migration ran long'),
159
+ section(
160
+ 'translation',
161
+ '翻译',
162
+ [
163
+ p('这句话里需要翻译的是 <b>the migration ran long</b>:'),
164
+ list(0, [{ text: '<b>migration</b> /maɪˈɡreɪʃn/ —— 迁移(把数据/服务从一处搬到另一处)' }]),
165
+ list(0, [{ text: '<b>ran long</b> —— 跑得比预期久(ran = run 的过去式,long 指耗时超出预期)' }]),
166
+ callout('在本句中', '迁移作业耗时超出了原计划——这是在陈述进度延迟的事实,不含"失败了"的意思。'),
167
+ ].join('\n'),
168
+ ),
169
+ section(
170
+ 'detail',
171
+ '详解',
172
+ [
173
+ p('这句话出自一句对话,是开发者在向 PM 说明<b>进度为什么需要重新安排</b>——它不是报错,而是在为后面的决定做铺垫。'),
174
+ p('两个关键说法各指什么:'),
175
+ list(0, [
176
+ { text: '<b>migration</b>(迁移):把数据或服务从旧结构搬到新结构的一次作业。在数据库语境里通常指改表结构、搬数据这类"不能中途停"的操作。' },
177
+ { text: '<b>ran long</b>(跑久了):完成时态,说的是这次已经发生的执行<b>耗时超过预期</b>,而不是"即将变慢"。' },
178
+ ]),
179
+ p('承接关系:既然迁移比预期久,后面的 <b>freeze features</b>(冻结功能开发)就不是随口一说,而是把人力腾出来的实际动作。'),
180
+ callout('小结', '在说"迁移耗时超预期",用来解释接下来的功能冻结——重点在<b>排期</b>,不在迁移本身的对错。'),
181
+ ].join('\n'),
182
+ ),
183
+ ` <div class="dsh-sel-chatlog" style="display:flex">`,
184
+ chatBubble('user', '那 freeze features 是指完全停止开发吗?'),
185
+ chatBubble('assistant', '不是完全停止,而是<b>暂停新功能合入</b>:已有功能的修 bug 与收尾照常,只是不再往主干加新特性,好让迁移期间的分支收敛。'),
186
+ ' </div>',
187
+ askRow(false),
188
+ ].join('\n'),
189
+ )
190
+
191
+ /** 代码块:真实渲染器逐行输出 `.dsh-sel-pre-line`,注释行带 data-wrap(可折行),代码行不折。 */
192
+ const codeBlock = (lines) => ` <pre class="dsh-sel-pre" data-hl="light">${lines
193
+ .map((l) => `<div class="dsh-sel-pre-line"${l.wrap ? ' data-wrap="1"' : ''}>${l.wrap ? `<span class="dsh-sel-pre-cmt">${l.text}</span>` : l.text}</div>`)
194
+ .join('')}</pre>`
195
+
196
+ // ───────────────────────── 状态三:选中代码 → 出注释 ─────────────────────────
197
+ const codeDemo = panel(
198
+ 'translation',
199
+ [
200
+ quote('function debounce(fn, wait) { … }'),
201
+ section(
202
+ 'translation',
203
+ '注释',
204
+ [
205
+ codeBlock([
206
+ { wrap: true, text: '// 防抖:把连续触发压成「最后一次之后 wait 毫秒才真正执行」' },
207
+ { text: 'function debounce(fn, wait) {' },
208
+ { wrap: true, text: ' // 闭包变量:保存上一次的定时器,用来取消它' },
209
+ { text: ' let timer = null' },
210
+ { wrap: true, text: ' // 每次触发都先清掉上一个定时器,再重新计时' },
211
+ { text: ' return function (...args) {' },
212
+ { text: ' clearTimeout(timer)' },
213
+ { wrap: true, text: ' // wait 毫秒内没有新触发,才执行原函数' },
214
+ { text: ' timer = setTimeout(() =&gt; fn.apply(this, args), wait)' },
215
+ { text: ' }' },
216
+ { text: '}' },
217
+ ]),
218
+ callout('小结', '把高频触发收敛成一次执行——常用于搜索输入、窗口 resize 这类"只要最终结果"的场景。'),
219
+ ].join('\n'),
220
+ ),
221
+ ].join('\n'),
222
+ )
223
+
224
+ /** 网页模式:回答里嵌一张可交互的 HTML 页面(.dsh-sel-preview 结构照渲染器)。 */
225
+ const previewBlock = (srcdoc) => ` <div class="dsh-sel-preview" data-view="preview" data-full="1">
226
+ <div class="dsh-sel-previewbar">
227
+ <div class="dsh-sel-previewtabs">
228
+ <button class="dsh-sel-previewtab" data-on="1">预览</button>
229
+ <button class="dsh-sel-previewtab" data-on="0">源码</button>
230
+ </div>
231
+ <button class="dsh-sel-previewzoom">⤡ 还原</button>
232
+ </div>
233
+ <div class="dsh-sel-previewbody">
234
+ <iframe class="dsh-sel-previewframe" srcdoc="${srcdoc.replace(/"/g, '&quot;')}"></iframe>
235
+ </div>
236
+ </div>`
237
+
238
+ const DEMO_HTML = `<!doctype html><html><head><meta charset="utf-8"><style>
239
+ body{margin:0;padding:14px 16px;font:13px/1.6 -apple-system,"PingFang SC",sans-serif;color:#1a1a1a;background:#fff}
240
+ h3{margin:0 0 4px;font-size:14px}
241
+ p.sub{margin:0 0 12px;color:#6b7280;font-size:12px}
242
+ table{border-collapse:collapse;width:100%;font-size:12.5px}
243
+ th,td{border-bottom:1px solid #e6e8eb;padding:7px 8px;text-align:left}
244
+ th{background:#f6f8f9;font-weight:600;color:#374151}
245
+ td.y{color:#0f766e;font-weight:600}
246
+ td.n{color:#b45309}
247
+ .bars{display:flex;align-items:flex-end;gap:10px;height:74px;margin:14px 0 4px}
248
+ .bar{flex:1;border-radius:5px 5px 0 0;background:#0d9488;position:relative}
249
+ .bar span{position:absolute;bottom:-19px;left:0;right:0;text-align:center;font-size:11px;color:#6b7280}
250
+ .b1{height:34px;background:#99f6e4}.b2{height:58px}.b3{height:74px;background:#0f766e}
251
+ </style></head><body>
252
+ <h3>三种缓存策略的取舍</h3>
253
+ <p class="sub">按"命中率 / 一致性风险 / 实现成本"三个维度对比</p>
254
+ <table>
255
+ <tr><th>策略</th><th>命中率</th><th>一致性风险</th><th>实现成本</th></tr>
256
+ <tr><td>不缓存</td><td class="n">—</td><td class="y">无</td><td class="y">最低</td></tr>
257
+ <tr><td>进程内 LRU</td><td class="y">高</td><td class="n">中(多实例不一致)</td><td class="y">低</td></tr>
258
+ <tr><td>共享缓存 + TTL</td><td class="y">最高</td><td class="y">低(可配 TTL)</td><td class="n">中</td></tr>
259
+ </table>
260
+ <div class="bars"><div class="bar b1"><span>不缓存</span></div><div class="bar b2"><span>LRU</span></div><div class="bar b3"><span>共享缓存</span></div></div>
261
+ </body></html>`
262
+
263
+ // ───────────────────────── 状态四:网页模式(输出偏好拨到「网页」)─────────────────────────
264
+ // 场景要能体现"为什么该用网页":需要对比 + 图示的问题 → 直接给一页 HTML。
265
+ const webDemo = panel(
266
+ 'detail',
267
+ [
268
+ quote('缓存策略'),
269
+ section(
270
+ 'detail',
271
+ '详解',
272
+ [
273
+ p('三种做法的差别是并列的,画成一页更好看:'),
274
+ previewBlock(DEMO_HTML),
275
+ ].join('\n'),
276
+ ),
277
+ ` <div class="dsh-sel-chatlog" style="display:flex">`,
278
+ chatBubble('user', '把三种缓存策略的差别画成一页对比'),
279
+ ' </div>',
280
+ askRow(true, 'deepseek-v4.1-flash', '高'),
281
+ ].join('\n'),
282
+ )
283
+
284
+ const css = extractCss()
285
+ mkdirSync(resolve(ROOT, 'docs', 'demo'), { recursive: true })
286
+ const out = {
287
+ 'stage1.html': PAGE('划词解读 · 首轮翻译', css, stage1),
288
+ 'stage2.html': PAGE('划词解读 · 详解与追问', css, stage2),
289
+ 'code.html': PAGE('划词解读 · 代码注释', css, codeDemo),
290
+ 'web.html': PAGE('划词解读 · 网页模式', css, webDemo),
291
+ }
292
+ for (const [name, html] of Object.entries(out)) {
293
+ writeFileSync(resolve(ROOT, 'docs', 'demo', name), html, 'utf8')
294
+ console.log(`✓ docs/demo/${name} ${(html.length / 1024).toFixed(1)} kB`)
295
+ }
296
+ console.log(`\nCSS 来源:lib/client.js(${css.length} 字符,未手抄)`)
@@ -0,0 +1,142 @@
1
+ /**
2
+ * 裁掉截图四周的空白(仅用 Node 内置 zlib 解码/编码 PNG,不依赖 imagemagick)。
3
+ *
4
+ * 用途:README 的演示图需要紧凑。Chrome `--screenshot` 只能给固定窗口尺寸,
5
+ * 面板比窗口矮时底部会留下大片背景色 —— 这里把纯背景的边缘裁掉。
6
+ *
7
+ * 用法:node scripts/crop-png.mjs <in.png> <out.png> [边距px]
8
+ */
9
+ import { readFileSync, writeFileSync } from 'node:fs'
10
+ import { inflateSync, deflateSync } from 'node:zlib'
11
+
12
+ const [, , inPath, outPath, padArg, tolArg] = process.argv
13
+ if (!inPath || !outPath) {
14
+ console.error('用法: node scripts/crop-png.mjs <in.png> <out.png> [边距px] [背景容差]')
15
+ process.exit(1)
16
+ }
17
+ const PAD = Number(padArg ?? 0)
18
+ // 容差要能吃掉面板投影造成的渐变(实测同一张图背景在 247~255 之间浮动)
19
+ const TOL = Number(tolArg ?? 8)
20
+
21
+ // ── 读 PNG(假定 Chrome 输出的 8bit RGB/RGBA 非隔行)──
22
+ const buf = readFileSync(inPath)
23
+ if (buf.readUInt32BE(0) !== 0x89504e47) throw new Error('不是 PNG')
24
+ let pos = 8
25
+ let width = 0
26
+ let height = 0
27
+ let bitDepth = 0
28
+ let colorType = 0
29
+ const idat = []
30
+ while (pos < buf.length) {
31
+ const len = buf.readUInt32BE(pos)
32
+ const type = buf.toString('ascii', pos + 4, pos + 8)
33
+ const data = buf.subarray(pos + 8, pos + 8 + len)
34
+ if (type === 'IHDR') {
35
+ width = data.readUInt32BE(0)
36
+ height = data.readUInt32BE(4)
37
+ bitDepth = data[8]
38
+ colorType = data[9]
39
+ if (data[12] !== 0) throw new Error('不支持隔行 PNG')
40
+ } else if (type === 'IDAT') idat.push(data)
41
+ else if (type === 'IEND') break
42
+ pos += 12 + len
43
+ }
44
+ if (bitDepth !== 8 || (colorType !== 2 && colorType !== 6)) {
45
+ throw new Error(`不支持的 PNG 格式:bitDepth=${bitDepth} colorType=${colorType}`)
46
+ }
47
+ const bpp = colorType === 6 ? 4 : 3
48
+ const raw = inflateSync(Buffer.concat(idat))
49
+
50
+ // ── 反滤波 ──
51
+ const stride = width * bpp
52
+ const img = Buffer.alloc(height * stride)
53
+ for (let y = 0; y < height; y++) {
54
+ const filter = raw[y * (stride + 1)]
55
+ const line = raw.subarray(y * (stride + 1) + 1, y * (stride + 1) + 1 + stride)
56
+ const prev = y > 0 ? img.subarray((y - 1) * stride, y * stride) : null
57
+ const cur = img.subarray(y * stride, (y + 1) * stride)
58
+ for (let x = 0; x < stride; x++) {
59
+ const a = x >= bpp ? cur[x - bpp] : 0
60
+ const b = prev ? prev[x] : 0
61
+ const c = prev && x >= bpp ? prev[x - bpp] : 0
62
+ let v = line[x]
63
+ if (filter === 1) v += a
64
+ else if (filter === 2) v += b
65
+ else if (filter === 3) v += (a + b) >> 1
66
+ else if (filter === 4) {
67
+ const p = a + b - c
68
+ const pa = Math.abs(p - a)
69
+ const pb = Math.abs(p - b)
70
+ const pc = Math.abs(p - c)
71
+ v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c
72
+ }
73
+ cur[x] = v & 0xff
74
+ }
75
+ }
76
+
77
+ // ── 找内容边界:以左上角像素为背景色 ──
78
+ const bg = [img[0], img[1], img[2]]
79
+ const isBg = (x, y) => {
80
+ const o = y * stride + x * bpp
81
+ return (
82
+ Math.abs(img[o] - bg[0]) <= TOL && Math.abs(img[o + 1] - bg[1]) <= TOL && Math.abs(img[o + 2] - bg[2]) <= TOL
83
+ )
84
+ }
85
+ let top = 0
86
+ let bottom = height - 1
87
+ let left = 0
88
+ let right = width - 1
89
+ outer: for (; top < height; top++) for (let x = 0; x < width; x++) if (!isBg(x, top)) break outer
90
+ outer2: for (; bottom > top; bottom--) for (let x = 0; x < width; x++) if (!isBg(x, bottom)) break outer2
91
+ outer3: for (; left < width; left++) for (let y = top; y <= bottom; y++) if (!isBg(left, y)) break outer3
92
+ outer4: for (; right > left; right--) for (let y = top; y <= bottom; y++) if (!isBg(right, y)) break outer4
93
+
94
+ top = Math.max(0, top - PAD)
95
+ left = Math.max(0, left - PAD)
96
+ bottom = Math.min(height - 1, bottom + PAD)
97
+ right = Math.min(width - 1, right + PAD)
98
+ const w = right - left + 1
99
+ const h = bottom - top + 1
100
+
101
+ // ── 重新编码(每行用 filter 0)──
102
+ const outStride = w * bpp
103
+ const rawOut = Buffer.alloc(h * (outStride + 1))
104
+ for (let y = 0; y < h; y++) {
105
+ rawOut[y * (outStride + 1)] = 0
106
+ img.copy(rawOut, y * (outStride + 1) + 1, (top + y) * stride + left * bpp, (top + y) * stride + left * bpp + outStride)
107
+ }
108
+ const chunk = (type, data) => {
109
+ const len = Buffer.alloc(4)
110
+ len.writeUInt32BE(data.length)
111
+ const body = Buffer.concat([Buffer.from(type, 'ascii'), data])
112
+ const crc = Buffer.alloc(4)
113
+ crc.writeUInt32BE(crc32(body) >>> 0)
114
+ return Buffer.concat([len, body, crc])
115
+ }
116
+ let table = null
117
+ function crc32(b) {
118
+ if (!table) {
119
+ table = new Int32Array(256)
120
+ for (let n = 0; n < 256; n++) {
121
+ let c = n
122
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
123
+ table[n] = c
124
+ }
125
+ }
126
+ let c = 0xffffffff
127
+ for (let i = 0; i < b.length; i++) c = table[(c ^ b[i]) & 0xff] ^ (c >>> 8)
128
+ return c ^ 0xffffffff
129
+ }
130
+ const ihdr = Buffer.alloc(13)
131
+ ihdr.writeUInt32BE(w, 0)
132
+ ihdr.writeUInt32BE(h, 4)
133
+ ihdr[8] = 8
134
+ ihdr[9] = colorType
135
+ const png = Buffer.concat([
136
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
137
+ chunk('IHDR', ihdr),
138
+ chunk('IDAT', deflateSync(rawOut, { level: 9 })),
139
+ chunk('IEND', Buffer.alloc(0)),
140
+ ])
141
+ writeFileSync(outPath, png)
142
+ console.log(`✓ ${outPath} ${width}x${height} → ${w}x${h}`)