dsh-design-studio 0.9.0 → 0.11.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.
Files changed (95) hide show
  1. package/README.en.md +6 -4
  2. package/README.md +6 -4
  3. package/assets/demo.gif +0 -0
  4. package/assets/hero-sm.jpg +0 -0
  5. package/cordis.patch.yml +5 -0
  6. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/_shots/menu.jpg +0 -0
  7. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/app.css +64 -0
  8. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/cart.html +120 -0
  9. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/design.json +56 -0
  10. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/menu.html +200 -0
  11. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/order.html +135 -0
  12. package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/tokens.css +43 -0
  13. package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/_shots/email.jpg +0 -0
  14. package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/design.json +20 -0
  15. package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/email.html +268 -0
  16. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/_shots/courses.jpg +0 -0
  17. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/courses.html +364 -0
  18. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/design.json +49 -0
  19. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/lesson.html +196 -0
  20. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/tokens.css +55 -0
  21. package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/ui.css +255 -0
  22. package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/_shots/poster.jpg +0 -0
  23. package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/design.json +21 -0
  24. package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/poster.html +165 -0
  25. package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/tokens.css +35 -0
  26. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/_shots/home.jpg +0 -0
  27. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/app.css +155 -0
  28. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/booking.html +232 -0
  29. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/design.json +57 -0
  30. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/health.html +221 -0
  31. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/home.html +225 -0
  32. package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/tokens.css +54 -0
  33. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/_shots/overview.jpg +0 -0
  34. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/app.css +79 -0
  35. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/design.json +51 -0
  36. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/entries.html +161 -0
  37. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/icons.svg +30 -0
  38. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/overview.html +181 -0
  39. package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/tokens.css +50 -0
  40. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/_shots/home.jpg +0 -0
  41. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/book.html +279 -0
  42. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/design.json +21 -0
  43. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/home.html +348 -0
  44. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/style.css +297 -0
  45. package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/tokens.css +49 -0
  46. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/01-cover.html +133 -0
  47. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/02-highlights.html +67 -0
  48. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/03-growth.html +89 -0
  49. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/04-milestones.html +92 -0
  50. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/05-next.html +99 -0
  51. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/06-thanks.html +61 -0
  52. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/_shots/01-cover.jpg +0 -0
  53. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/deck.css +67 -0
  54. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/design.json +25 -0
  55. package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/tokens.css +30 -0
  56. package/gallery-seed/index.json +1 -0
  57. package/gallery-seed/thumbs/orderg-x__alley-noodles.webp +0 -0
  58. package/gallery-seed/thumbs/orderg-x__autumn-sale.webp +0 -0
  59. package/gallery-seed/thumbs/orderg-x__little-painter.webp +0 -0
  60. package/gallery-seed/thumbs/orderg-x__moon-fair.webp +0 -0
  61. package/gallery-seed/thumbs/orderg-x__paw-diary.webp +0 -0
  62. package/gallery-seed/thumbs/orderg-x__pocket-ledger.webp +0 -0
  63. package/gallery-seed/thumbs/orderg-x__time-bookshop.webp +0 -0
  64. package/gallery-seed/thumbs/orderg-x__year-review.webp +0 -0
  65. package/lib/browser.js +23 -9
  66. package/lib/bundle.js +12 -3
  67. package/lib/client.js +3106 -111
  68. package/lib/core.js +44 -11
  69. package/lib/gallery/css.js +230 -0
  70. package/lib/gallery/html.js +313 -0
  71. package/lib/gallery/index.js +22 -0
  72. package/lib/gallery/market-routes.js +306 -0
  73. package/lib/gallery/market.js +623 -0
  74. package/lib/gallery/pack.js +311 -0
  75. package/lib/gallery/policy.js +1560 -0
  76. package/lib/gallery/qrfind.js +181 -0
  77. package/lib/gallery/render.js +148 -0
  78. package/lib/gallery/routes.js +360 -0
  79. package/lib/gallery/tar.js +118 -0
  80. package/lib/gallery/use.js +125 -0
  81. package/lib/gallery/zip.js +212 -0
  82. package/lib/handoff.js +291 -29
  83. package/lib/history.js +3 -1
  84. package/lib/index.js +118 -24
  85. package/lib/inspire.js +142 -0
  86. package/lib/offline.js +17 -0
  87. package/lib/picker.js +425 -32
  88. package/lib/serve.js +88 -11
  89. package/lib/works.js +238 -0
  90. package/locale/en.json +2 -1
  91. package/locale/offline/en.json +6 -0
  92. package/locale/offline/zh.json +6 -0
  93. package/locale/zh.json +2 -1
  94. package/package.json +10 -3
  95. package/skills/ui-design/SKILL.md +9 -0
@@ -0,0 +1,181 @@
1
+ /**
2
+ * 截图里像不像有二维码:找二维码的三个「回」字定位块(黑白 1:1:3:1:1),三个摆成直角、间距合理就算像。
3
+ * 不解码(插件不带解码库):分享前体检只提示「像是有二维码」;内容仓库的机器人再用 jsQR 真扫,能扫出来的才退回。
4
+ * 只依赖 Node 自带的 zlib。
5
+ *
6
+ * decodePng(buf) → { width, height, rgba } | null (8 位、不隔行,Chrome 截图就是这种)
7
+ * findQrLike(img) → { at: [[x, y] ×3], module } | null
8
+ */
9
+
10
+ import zlib from 'node:zlib'
11
+
12
+ const MAX_PX = 40e6
13
+
14
+ /** PNG → RGBA(透明的按白底合成);看不懂、太大返回 null */
15
+ export function decodePng(buf) {
16
+ if (!Buffer.isBuffer(buf) || buf.length < 45 || buf.readUInt32BE(0) !== 0x89504e47 || buf.readUInt32BE(4) !== 0x0d0a1a0a) return null
17
+ let i = 8
18
+ let w = 0
19
+ let h = 0
20
+ let depth = 0
21
+ let ctype = 0
22
+ let inter = 0
23
+ const idat = []
24
+ while (i + 12 <= buf.length) {
25
+ const len = buf.readUInt32BE(i)
26
+ const t = buf.toString('latin1', i + 4, i + 8)
27
+ if (i + 12 + len > buf.length) return null
28
+ const d = buf.subarray(i + 8, i + 8 + len)
29
+ if (t === 'IHDR') { w = d.readUInt32BE(0); h = d.readUInt32BE(4); depth = d[8]; ctype = d[9]; inter = d[12] } else if (t === 'IDAT') idat.push(d)
30
+ else if (t === 'IEND') break
31
+ i += 12 + len
32
+ }
33
+ const ch = { 0: 1, 2: 3, 4: 2, 6: 4 }[ctype]
34
+ if (!w || !h || depth !== 8 || !ch || inter || w * h > MAX_PX) return null
35
+ let raw
36
+ try { raw = zlib.inflateSync(Buffer.concat(idat), { maxOutputLength: (w * ch + 1) * h + 16 }) } catch { return null }
37
+ const stride = w * ch
38
+ if (raw.length < (stride + 1) * h) return null
39
+ const px = Buffer.alloc(stride * h)
40
+ for (let y = 0; y < h; y++) {
41
+ const f = raw[y * (stride + 1)]
42
+ const src = y * (stride + 1) + 1
43
+ const row = y * stride
44
+ for (let x = 0; x < stride; x++) {
45
+ const a = x >= ch ? px[row + x - ch] : 0
46
+ const b = y ? px[row - stride + x] : 0
47
+ const c = x >= ch && y ? px[row - stride + x - ch] : 0
48
+ let v = raw[src + x]
49
+ if (f === 1) v += a
50
+ else if (f === 2) v += b
51
+ else if (f === 3) v += (a + b) >> 1
52
+ else if (f === 4) { const p = a + b - c; const pa = Math.abs(p - a); const pb = Math.abs(p - b); const pc = Math.abs(p - c); v += pa <= pb && pa <= pc ? a : pb <= pc ? b : c }
53
+ px[row + x] = v & 255
54
+ }
55
+ }
56
+ const rgba = new Uint8ClampedArray(w * h * 4)
57
+ for (let k = 0, j = 0; k < w * h; k++, j += ch) {
58
+ const o = k * 4
59
+ const al = ch === 4 ? px[j + 3] : ch === 2 ? px[j + 1] : 255
60
+ const mix = (v) => Math.round((v * al + 255 * (255 - al)) / 255)
61
+ if (ch >= 3) { rgba[o] = mix(px[j]); rgba[o + 1] = mix(px[j + 1]); rgba[o + 2] = mix(px[j + 2]) } else rgba[o] = rgba[o + 1] = rgba[o + 2] = mix(px[j])
62
+ rgba[o + 3] = 255
63
+ }
64
+ return { width: w, height: h, rgba }
65
+ }
66
+
67
+ /** 五段黑白的长度合不合 1:1:3:1:1;合就返回一个模块多宽,不合返回 0 */
68
+ function ratio(c) {
69
+ const tot = c[0] + c[1] + c[2] + c[3] + c[4]
70
+ if (tot < 7) return 0
71
+ const m = tot / 7
72
+ const v = m / 2
73
+ return Math.abs(m - c[0]) < v && Math.abs(m - c[1]) < v && Math.abs(3 * m - c[2]) < 3 * v && Math.abs(m - c[3]) < v && Math.abs(m - c[4]) < v ? m : 0
74
+ }
75
+
76
+ /** 黑白图(局部阈值:比周围平均暗一截、或本来就很黑的算黑) */
77
+ function binarize({ width: w, height: h, rgba }) {
78
+ const n = w * h
79
+ const L = new Uint8Array(n)
80
+ for (let k = 0; k < n; k++) L[k] = (rgba[k * 4] * 299 + rgba[k * 4 + 1] * 587 + rgba[k * 4 + 2] * 114) / 1000
81
+ const W = w + 1
82
+ const S = new Float64Array(W * (h + 1))
83
+ for (let y = 0; y < h; y++) {
84
+ let row = 0
85
+ for (let x = 0; x < w; x++) { row += L[y * w + x]; S[(y + 1) * W + x + 1] = S[y * W + x + 1] + row }
86
+ }
87
+ const r = Math.max(7, Math.round(Math.min(w, h) / 16))
88
+ const dark = new Uint8Array(n)
89
+ for (let y = 0; y < h; y++) {
90
+ const y0 = Math.max(0, y - r)
91
+ const y1 = Math.min(h, y + r + 1)
92
+ for (let x = 0; x < w; x++) {
93
+ const x0 = Math.max(0, x - r)
94
+ const x1 = Math.min(w, x + r + 1)
95
+ const mean = (S[y1 * W + x1] - S[y0 * W + x1] - S[y1 * W + x0] + S[y0 * W + x0]) / ((y1 - y0) * (x1 - x0))
96
+ const v = L[y * w + x]
97
+ dark[y * w + x] = v < 60 || v < mean * 0.82 ? 1 : 0
98
+ }
99
+ }
100
+ return dark
101
+ }
102
+
103
+ const CAP_RUN = 400
104
+
105
+ /** 从 (x, y) 沿一个方向量五段(中间那段经过这一点)→ { m, mid } | null */
106
+ function cross(dark, w, h, x, y, dx, dy) {
107
+ const at = (k) => { const xx = x + dx * k; const yy = y + dy * k; return xx >= 0 && yy >= 0 && xx < w && yy < h ? dark[yy * w + xx] : -1 }
108
+ if (at(0) !== 1) return null
109
+ const c = [0, 0, 0, 0, 0]
110
+ let k = 0
111
+ while (at(k) === 1 && c[2] < CAP_RUN) { c[2]++; k-- }
112
+ while (at(k) === 0 && c[1] < CAP_RUN) { c[1]++; k-- }
113
+ while (at(k) === 1 && c[0] < CAP_RUN) { c[0]++; k-- }
114
+ k = 1
115
+ while (at(k) === 1 && c[2] < CAP_RUN) { c[2]++; k++ }
116
+ while (at(k) === 0 && c[3] < CAP_RUN) { c[3]++; k++ }
117
+ while (at(k) === 1 && c[4] < CAP_RUN) { c[4]++; k++ }
118
+ const m = ratio(c)
119
+ if (!m) return null
120
+ // 中间段的中点(相对 (x, y) 的步数)
121
+ const end = k - c[4] - c[3]
122
+ return { m, mid: end - c[2] / 2, tot: c.reduce((a, b) => a + b, 0) }
123
+ }
124
+
125
+ /**
126
+ * 找「像二维码」的地方。img:{ width, height, rgba }
127
+ * 返回第一个找到的 { at: [[x, y], [x, y], [x, y]], module } 或 null
128
+ */
129
+ export function findQrLike(img) {
130
+ if (!img || !img.width || !img.height || img.width * img.height > MAX_PX) return null
131
+ const { width: w, height: h } = img
132
+ const dark = binarize(img)
133
+ const found = []
134
+ for (let y = 0; y < h; y++) {
135
+ // 这一行的黑白段
136
+ const runs = []
137
+ let x = 0
138
+ while (x < w) {
139
+ const v = dark[y * w + x]
140
+ const s = x
141
+ while (x < w && dark[y * w + x] === v) x++
142
+ runs.push([v, x - s, s])
143
+ }
144
+ for (let i = 0; i + 4 < runs.length; i++) {
145
+ if (runs[i][0] !== 1) continue
146
+ const c = [runs[i][1], runs[i + 1][1], runs[i + 2][1], runs[i + 3][1], runs[i + 4][1]]
147
+ const mh = ratio(c)
148
+ if (!mh) continue
149
+ const cx = Math.round(runs[i + 2][2] + runs[i + 2][1] / 2)
150
+ const v = cross(dark, w, h, cx, y, 0, 1)
151
+ if (!v || Math.abs(v.tot - 7 * mh) > 7 * mh * 0.4) continue
152
+ const cy = Math.round(y + v.mid)
153
+ const hh = cross(dark, w, h, cx, cy, 1, 0)
154
+ if (!hh || Math.abs(hh.tot - v.tot) > v.tot * 0.4) continue
155
+ const fx = cx + hh.mid
156
+ const m = (mh + v.m + hh.m) / 3
157
+ const hit = found.find((f) => Math.abs(f.x - fx) <= m * 2 && Math.abs(f.y - cy) <= m * 2 && f.m / m < 1.4 && m / f.m < 1.4)
158
+ if (hit) { hit.x = (hit.x * hit.n + fx) / (hit.n + 1); hit.y = (hit.y * hit.n + cy) / (hit.n + 1); hit.m = (hit.m * hit.n + m) / (hit.n + 1); hit.n++ } else if (found.length < 400) found.push({ x: fx, y: cy, m, n: 1 })
159
+ }
160
+ }
161
+ const pts = found.filter((f) => f.n >= 2).sort((a, b) => b.n - a.n).slice(0, 60)
162
+ for (let a = 0; a < pts.length; a++) {
163
+ for (let b = a + 1; b < pts.length; b++) {
164
+ for (let c = b + 1; c < pts.length; c++) {
165
+ const P = [pts[a], pts[b], pts[c]]
166
+ const ms = P.map((p) => p.m)
167
+ const mm = (ms[0] + ms[1] + ms[2]) / 3
168
+ if (Math.max(...ms) / Math.min(...ms) > 1.4) continue
169
+ const d2 = (p, q) => (p.x - q.x) ** 2 + (p.y - q.y) ** 2
170
+ const s = [d2(P[0], P[1]), d2(P[0], P[2]), d2(P[1], P[2])].sort((x, y) => x - y)
171
+ if (s[0] / s[1] < 0.75) continue // 两条直角边差不多长
172
+ const hyp = s[2] / (s[0] + s[1])
173
+ if (hyp < 0.85 || hyp > 1.15) continue // 勾股:是直角
174
+ const side = Math.sqrt((s[0] + s[1]) / 2) / mm
175
+ if (side < 13 || side > 172) continue // 版本 1–40:定位块中心间距 14–170 个模块
176
+ return { at: P.map((p) => [Math.round(p.x), Math.round(p.y)]), module: Math.round(mm * 10) / 10 }
177
+ }
178
+ }
179
+ }
180
+ return null
181
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * 内容仓库的机器人用:一套设计断网开出来,跑体检(同 check_design)、每屏截一张图。
3
+ *
4
+ * - 用设计台自己的预览服务器(只听 127.0.0.1),每个 HTML 都按「别人做的」加严格规矩(CSP + nonce)
5
+ * - 浏览器断网:除了这台预览服务器,什么都不让拿
6
+ * - 体检结果分两档:hard(CI 里算失败)和 soft(标出来给人看)。
7
+ * CI 的字体和用户电脑不一样,截断 / 溢出 / 叠字这类先算 soft;被系统栏压住、图片坏了、跳转到不存在的页算 hard
8
+ */
9
+
10
+ import fs from 'node:fs'
11
+ import path from 'node:path'
12
+ import { createHub } from '../serve.js'
13
+ import { createBrowser } from '../browser.js'
14
+ import { measureAll, shootAll, prepare, KIND_SIZE, sizeOf } from '../look.js'
15
+ import { decodePng, findQrLike } from './qrfind.js'
16
+
17
+ const IMG_EXT = /\.(png|jpe?g|webp|gif|svg|avif)$/i
18
+ // 扫描预算:每屏最多几段、一共最多几段、最多几张图。超了的不算「查过」:记进 skipped,机器人标出来转人工
19
+ export const QR_BUDGET = { parts: 60, total: 300, images: 40 }
20
+
21
+ /** 设计目录里的图片(相对路径;软链接不跟)→ { list, more }(more:超过预算没列进来的) */
22
+ function imagesIn(dir, cap) {
23
+ const out = []
24
+ let more = false
25
+ const walk = (abs, rel, depth) => {
26
+ let ents = []
27
+ try { ents = fs.readdirSync(abs, { withFileTypes: true }) } catch { return }
28
+ for (const d of ents) {
29
+ if (d.name.startsWith('.')) continue
30
+ const r = rel ? `${rel}/${d.name}` : d.name
31
+ if (d.isDirectory()) { if (depth < 6) walk(path.join(abs, d.name), r, depth + 1); else more = true } else if (d.isFile() && IMG_EXT.test(d.name)) { if (out.length < cap) out.push(r); else more = true }
32
+ }
33
+ }
34
+ walk(dir, '', 0)
35
+ return { list: out, more }
36
+ }
37
+
38
+ /**
39
+ * 找二维码用的截图(无损 PNG、1 倍):每屏从上到下每半屏截一张(长页面中段也截),再加每张图片单独开一次
40
+ * → { shots: [{ file, png }], skipped: [{ file, why }] }(why:long 页面太长没截完 / budget 总段数用完 / images 图太多 / open 开不出来)
41
+ */
42
+ async function qrShots(b, { list, origin, dir, size, budget = QR_BUDGET }) {
43
+ const out = []
44
+ const skipped = []
45
+ let left = budget.total
46
+ const [w, h] = size
47
+ for (const sc of list) {
48
+ if (left <= 0) { skipped.push({ file: sc.key, why: 'budget' }); continue }
49
+ try {
50
+ await b.withPage({ url: sc.url, w, h, scale: 1 }, async (page) => {
51
+ await prepare(page, false)
52
+ const s0 = await page.eval('window.__dshDesign.scroll(0)')
53
+ const of = Math.max(1, Number(s0 && s0.of) || 1)
54
+ // 每次只往下滚半屏:相邻两张重叠一半,正好卡在接缝上的二维码在另一张里是整的
55
+ const at = []
56
+ for (let k = 0; k <= 2 * (of - 1); k++) at.push(k / 2)
57
+ const n = Math.min(at.length, budget.parts, left)
58
+ if (n < at.length) skipped.push({ file: sc.key, why: at.length > budget.parts ? 'long' : 'budget' })
59
+ for (let i = 0; i < n; i++) {
60
+ if (i) await page.eval(`window.__dshDesign.scroll(${at[i]})`)
61
+ out.push({ file: sc.key, png: await page.shot({ format: 'png' }) })
62
+ left--
63
+ }
64
+ })
65
+ } catch { skipped.push({ file: sc.key, why: 'open' }) /* 这一屏开不出来:上面的体检也会报 */ }
66
+ }
67
+ const imgs = imagesIn(dir, budget.images)
68
+ if (imgs.more) skipped.push({ file: '', why: 'images' })
69
+ for (const img of imgs.list) {
70
+ try {
71
+ await b.withPage({ url: `${origin}/${img.split('/').map(encodeURIComponent).join('/')}`, w: 1000, h: 1000, scale: 1 }, async (page) => {
72
+ out.push({ file: img, png: await page.shot({ format: 'png' }) })
73
+ })
74
+ } catch { skipped.push({ file: img, why: 'open' }) /* 打不开的图:体检里「图片坏了」会报 */ }
75
+ }
76
+ return { shots: out, skipped }
77
+ }
78
+
79
+ export const HARD = new Set(['covered', 'capsule', 'island', 'winctl', 'homebar', 'img', 'deadlink'])
80
+
81
+ /**
82
+ * @param dir 设计目录(已经检查过、规范化过)
83
+ * @param screens 屏的文件名,按顺序
84
+ * @param size design.json 里写的尺寸('1080x1920');不给 / 不认识就按 kind 的默认尺寸
85
+ * @param thumb 给第一屏另截一张多宽的小图(webp,卡片封面用);0 不截
86
+ * @param qr 找二维码:true 只给 qrLike(像二维码的文件);'shots' 另外把用来找的 PNG 截图也给出来(机器人拿去用 jsQR 真扫)
87
+ * @param qrBudget 扫描预算(默认 QR_BUDGET);超了的进 qrSkipped
88
+ * @param qrPages 不算屏、但也要扫二维码的页面(相对路径)
89
+ * @returns { rows, shots: [{ file, jpg }], thumb: webp | null, hard: [{ file, type, text }], soft: [...], errors: [{ file, error }], qrLike: [{ file }], qrSkipped: [{ file, why }], qrShots: [{ file, png }] }
90
+ */
91
+ export async function renderPack(dir, { screens, kind = 'web', size: want = '', dark = false, shots = true, thumb = 0, browser = null, bin, qr = false, qrBudget = QR_BUDGET, qrPages = [] } = {}) {
92
+ const hub = createHub({ strict: { html: () => true, root: () => true } })
93
+ const own = !browser
94
+ const b = browser || createBrowser(bin ? { bin } : {})
95
+ if (!b.available()) throw new Error('没找到 Chrome / Chromium(设 CHROME_PATH)')
96
+ const offline = { ...b, withPage: (o, fn) => b.withPage({ ...o, offline: true }, fn) }
97
+ try {
98
+ const { origin } = await hub.open({ kind: 'file', root: dir })
99
+ const list = screens.map((f) => ({ key: f, label: f, url: `${origin}/${f.split('/').map(encodeURIComponent).join('/')}` }))
100
+ // design.json 里写了尺寸(海报 1080x1920)就按它,不然按类型默认
101
+ const size = sizeOf(want, kind) || KIND_SIZE[kind] || KIND_SIZE.web
102
+ const rows = await measureAll(offline, { screens: list, sizes: [size], kind, dark })
103
+ const hard = []
104
+ const soft = []
105
+ const errors = []
106
+ for (const r of rows) {
107
+ if (r.error) { errors.push({ file: r.key, error: r.error }); continue }
108
+ for (const it of r.issues) (HARD.has(it.type) ? hard : soft).push({ file: r.key, type: it.type, text: String(it.text || it.src || it.sel || '').slice(0, 60) })
109
+ }
110
+ const pics = shots
111
+ ? (await shootAll(offline, { screens: list, size, at: 'top', dark, kind, max: list.length })).map((s) => ({ file: s.key, jpg: s.jpg || null, error: s.error || '' }))
112
+ : []
113
+ for (const s of pics) if (s.error) errors.push({ file: s.file, error: s.error })
114
+ let cover = null
115
+ if (thumb > 0 && list.length) {
116
+ const [w, h] = size
117
+ try {
118
+ await offline.withPage({ url: list[0].url, w, h, scale: thumb / w, dark }, async (page) => {
119
+ await prepare(page, dark)
120
+ if (kind === 'miniprogram') await page.eval("window.__dshDesign.capsule('solid')").catch(() => null)
121
+ if (kind === 'desktop') await page.eval("window.__dshDesign.winctl('solid')").catch(() => null)
122
+ cover = await page.shot({ format: 'webp', quality: 72 })
123
+ })
124
+ } catch (e) {
125
+ errors.push({ file: list[0].key, error: String((e && e.message) || e).slice(0, 160) })
126
+ }
127
+ }
128
+ const qrLike = []
129
+ let qrPngs = []
130
+ let qrSkipped = []
131
+ if (qr) {
132
+ // 不算屏的页面(_ 开头的、设计系统子目录里的)也扫:能从链接点进去
133
+ const more = (qrPages || []).filter((f) => !screens.includes(f)).map((f) => ({ key: f, label: f, url: `${origin}/${f.split('/').map(encodeURIComponent).join('/')}` }))
134
+ const q0 = await qrShots(offline, { list: [...list, ...more], origin, dir, size, budget: qrBudget })
135
+ qrPngs = q0.shots
136
+ qrSkipped = q0.skipped
137
+ for (const q of qrPngs) {
138
+ if (qrLike.some((x) => x.file === q.file)) continue
139
+ const hit = findQrLike(decodePng(q.png))
140
+ if (hit) qrLike.push({ file: q.file, at: hit.at })
141
+ }
142
+ }
143
+ return { rows, shots: pics, thumb: cover, hard, soft, errors, size, qrLike, qrSkipped, qrShots: qr === 'shots' ? qrPngs : [] }
144
+ } finally {
145
+ hub.closeAll()
146
+ if (own) b.destroy()
147
+ }
148
+ }