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.
- package/README.en.md +6 -4
- package/README.md +6 -4
- package/assets/demo.gif +0 -0
- package/assets/hero-sm.jpg +0 -0
- package/cordis.patch.yml +5 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/_shots/menu.jpg +0 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/app.css +64 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/cart.html +120 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/design.json +56 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/menu.html +200 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/order.html +135 -0
- package/gallery-seed/d/orderg-x__alley-noodles/08e9e40834f9/tokens.css +43 -0
- package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/_shots/email.jpg +0 -0
- package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/design.json +20 -0
- package/gallery-seed/d/orderg-x__autumn-sale/08e9e40834f9/email.html +268 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/_shots/courses.jpg +0 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/courses.html +364 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/design.json +49 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/lesson.html +196 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/tokens.css +55 -0
- package/gallery-seed/d/orderg-x__little-painter/08e9e40834f9/ui.css +255 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/_shots/poster.jpg +0 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/design.json +21 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/poster.html +165 -0
- package/gallery-seed/d/orderg-x__moon-fair/08e9e40834f9/tokens.css +35 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/_shots/home.jpg +0 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/app.css +155 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/booking.html +232 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/design.json +57 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/health.html +221 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/home.html +225 -0
- package/gallery-seed/d/orderg-x__paw-diary/08e9e40834f9/tokens.css +54 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/_shots/overview.jpg +0 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/app.css +79 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/design.json +51 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/entries.html +161 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/icons.svg +30 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/overview.html +181 -0
- package/gallery-seed/d/orderg-x__pocket-ledger/08e9e40834f9/tokens.css +50 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/_shots/home.jpg +0 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/book.html +279 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/design.json +21 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/home.html +348 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/style.css +297 -0
- package/gallery-seed/d/orderg-x__time-bookshop/08e9e40834f9/tokens.css +49 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/01-cover.html +133 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/02-highlights.html +67 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/03-growth.html +89 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/04-milestones.html +92 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/05-next.html +99 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/06-thanks.html +61 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/_shots/01-cover.jpg +0 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/deck.css +67 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/design.json +25 -0
- package/gallery-seed/d/orderg-x__year-review/08e9e40834f9/tokens.css +30 -0
- package/gallery-seed/index.json +1 -0
- package/gallery-seed/thumbs/orderg-x__alley-noodles.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__autumn-sale.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__little-painter.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__moon-fair.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__paw-diary.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__pocket-ledger.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__time-bookshop.webp +0 -0
- package/gallery-seed/thumbs/orderg-x__year-review.webp +0 -0
- package/lib/browser.js +23 -9
- package/lib/bundle.js +12 -3
- package/lib/client.js +3106 -111
- package/lib/core.js +44 -11
- package/lib/gallery/css.js +230 -0
- package/lib/gallery/html.js +313 -0
- package/lib/gallery/index.js +22 -0
- package/lib/gallery/market-routes.js +306 -0
- package/lib/gallery/market.js +623 -0
- package/lib/gallery/pack.js +311 -0
- package/lib/gallery/policy.js +1560 -0
- package/lib/gallery/qrfind.js +181 -0
- package/lib/gallery/render.js +148 -0
- package/lib/gallery/routes.js +360 -0
- package/lib/gallery/tar.js +118 -0
- package/lib/gallery/use.js +125 -0
- package/lib/gallery/zip.js +212 -0
- package/lib/handoff.js +291 -29
- package/lib/history.js +3 -1
- package/lib/index.js +118 -24
- package/lib/inspire.js +142 -0
- package/lib/offline.js +17 -0
- package/lib/picker.js +425 -32
- package/lib/serve.js +88 -11
- package/lib/works.js +238 -0
- package/locale/en.json +2 -1
- package/locale/offline/en.json +6 -0
- package/locale/offline/zh.json +6 -0
- package/locale/zh.json +2 -1
- package/package.json +10 -3
- 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
|
+
}
|