@taboo-avalanche/andesite-compiler 1.0.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,2257 @@
1
+ import puppeteer, { type Browser, type Page, type ElementHandle } from 'puppeteer'
2
+ import * as fs from 'fs'
3
+ import * as path from 'path'
4
+ import {
5
+ DEFAULT_PROGRESS_STEPS,
6
+ HUD_ANCHORS,
7
+ type ButtonStateDecl,
8
+ type ButtonLabelDecl,
9
+ type LabelDecl,
10
+ type TextInputCompletionDecl,
11
+ type TextInputDecl,
12
+ type SlotDecl,
13
+ type ProgressDecl,
14
+ type SpriteDecl,
15
+ type ScrollViewDecl,
16
+ type HudAnchor,
17
+ type HudMountDecl,
18
+ type ViewStackDecl,
19
+ } from '@taboo-avalanche/andesite'
20
+
21
+ // 普通面板/按钮导出 4x 源图,再通过 .gf 逻辑尺寸按原始尺寸显示,避免高 GUI scale 放大后发糊。
22
+ // 倍率越高越清晰但 mcpack/显存按平方增长;链路倍率无关(origin 取 PNG 像素,logical 取 CSS 尺寸)。
23
+ // 进度条不走截图,仍在 renderProgressPng 中按 CSS 像素直接绘制。
24
+ const RASTER_SCALE = 4
25
+
26
+ interface PanelRaster {
27
+ id: string
28
+ mountId?: string
29
+ texturePath: string
30
+ /** 面板边框盒(本体)在页面内的坐标与尺寸;导入器据此把控件定位到与 dev 一致的位置。 */
31
+ x: number
32
+ y: number
33
+ width: number
34
+ height: number
35
+ /** 贴图相对边框盒的四向外扩量(box-shadow 投影);仅外扩>0 时存在,导入器据此外扩渲染。 */
36
+ overflow?: VisualOverflow
37
+ defaultVisible?: boolean
38
+ png: Uint8Array
39
+ }
40
+
41
+ interface ButtonRaster {
42
+ id: string
43
+ mountId?: string
44
+ defaultTexturePath: string
45
+ pressedTexturePath: string
46
+ x: number
47
+ y: number
48
+ width: number
49
+ height: number
50
+ pressDuration: number
51
+ clickable: boolean
52
+ layer?: number
53
+ defaultVisible?: boolean
54
+ states: ButtonStateDecl[]
55
+ labels: ButtonLabelDecl[]
56
+ defaultPng: Uint8Array
57
+ pressedPng: Uint8Array
58
+ }
59
+
60
+ interface DomBox {
61
+ x: number
62
+ y: number
63
+ width: number
64
+ height: number
65
+ }
66
+
67
+ interface RepeaterCellBox extends DomBox {
68
+ index: number
69
+ }
70
+
71
+ export interface AndesiteRasterResult {
72
+ kind: 'menu' | 'hud'
73
+ // 页面整体包围盒(内容根 px),供运行时居中摆放
74
+ width: number
75
+ height: number
76
+ mounts: HudMountDecl[]
77
+ viewStacks: ViewStackDecl[]
78
+ panels: PanelRaster[]
79
+ buttons: ButtonRaster[]
80
+ labels: LabelDecl[]
81
+ inputs: TextInputDecl[]
82
+ slots: SlotDecl[]
83
+ progresses: ProgressDecl[]
84
+ sprites: SpriteDecl[]
85
+ scrollViews: ScrollViewDecl[]
86
+ }
87
+
88
+ const PUPPETEER_LAUNCH_ARGS = [
89
+ `--force-device-scale-factor=${RASTER_SCALE}`,
90
+ '--no-sandbox',
91
+ '--disable-setuid-sandbox',
92
+ '--disable-dev-shm-usage',
93
+ ]
94
+
95
+ export interface RasterizeOptions {
96
+ /** 为 true 时输出浏览器控制台与请求失败日志 */
97
+ verbose?: boolean
98
+ }
99
+
100
+ /**
101
+ * 启动可复用的 Puppeteer 实例,供 build-all 多页栅格化共用,避免每页重复 launch。
102
+ */
103
+ export async function launchRasterBrowser(): Promise<Browser> {
104
+ return puppeteer.launch({ headless: true, args: PUPPETEER_LAUNCH_ARGS })
105
+ }
106
+
107
+ /**
108
+ * 在已有 Browser 上栅格化单个页面(独立 Tab,用毕关闭 Page)。
109
+ */
110
+ export async function rasterizeAllInBrowser(
111
+ browser: Browser,
112
+ pageUrl: string,
113
+ outputDir: string,
114
+ options?: RasterizeOptions,
115
+ ): Promise<AndesiteRasterResult> {
116
+ const page = await browser.newPage()
117
+ const verbose = options?.verbose === true
118
+ try {
119
+ if (verbose) {
120
+ page.on('console', msg => console.log('[browser console]', msg.type(), msg.text()))
121
+ page.on('pageerror', err => console.log('[browser error]', err.message))
122
+ page.on('requestfailed', req => console.log('[request failed]', req.url(), req.failure()?.errorText))
123
+ }
124
+ await page.setViewport({ width: 176, height: 222, deviceScaleFactor: RASTER_SCALE })
125
+ await page.goto(pageUrl, { waitUntil: 'domcontentloaded' })
126
+ await page.evaluate('globalThis.__name = (fn) => fn')
127
+ await page.waitForFunction(
128
+ () => document.querySelector('[data-andesite-canvas]') !== null,
129
+ { timeout: 15000 },
130
+ )
131
+ const canvasSize = await page.$eval('[data-andesite-canvas]', node => {
132
+ const rect = (node as HTMLElement).getBoundingClientRect()
133
+ return {
134
+ width: rect.width,
135
+ height: rect.height,
136
+ }
137
+ })
138
+ await page.setViewport({
139
+ width: Math.max(176, Math.ceil(canvasSize.width)),
140
+ height: Math.max(222, Math.ceil(canvasSize.height)),
141
+ deviceScaleFactor: RASTER_SCALE,
142
+ })
143
+ const kind: AndesiteRasterResult['kind'] = await page.$eval('[data-andesite-canvas]', node => {
144
+ return (node as HTMLElement).getAttribute('data-andesite-page-kind') === 'hud' ? 'hud' : 'menu'
145
+ })
146
+ // 页面整体包围盒:菜单以内容根居中;HUD 控件另按最近 mount 作为局部坐标基准。
147
+ const pageBox = await page.$eval('[data-andesite-content-root]', node => {
148
+ const rect = (node as HTMLElement).getBoundingClientRect()
149
+ return { width: Math.round(rect.width), height: Math.round(rect.height) }
150
+ })
151
+ // 布局体检前置到栅格化之前,一次浏览器往返查两类会让控件"看着好、导出丢/错位"的隐性错误:
152
+ // 1) 塌陷:控件边框盒宽或高 ≤1px,几乎总是 absolute 脱离文档流反向撑父容器、或父容器高度为 0
153
+ // 所致。编译器对 0 尺寸控件本应按 isCollectable 跳过,但静默丢弃会让 sprites 数组凭空变空、
154
+ // 作者难以定位,故在此显式报错。
155
+ // 2) 出屏:菜单页把控件移出内容包围盒(误用 inset:0 全屏遮罩 / transform 居中 frame),AndesiteCanvas
156
+ // 按内容包围盒导出、引擎 anchor=center 自动居中,任何自行定位都会把内容移出导出区;不在此拦截,
157
+ // 这类页面会在截图阶段以难懂的 clip 宽高错误崩溃。检测用元素边框盒(getBoundingClientRect,不含
158
+ // box-shadow 外溢),故合法投影不误判。出屏判定仅菜单页(HUD 控件按各自 mount 局部坐标,其锚点
159
+ // 本就允许相对屏幕边角偏移);塌陷判定两类页面都做。
160
+ const layout = await page.evaluate(() => {
161
+ const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
162
+ const collapsed: string[] = []
163
+ const outOfBounds: string[] = []
164
+ const rootRect = root ? root.getBoundingClientRect() : null
165
+ // 允许 1px 舍入误差;只拦真正越界,不把细线/1px 装饰误判为塌陷
166
+ const tol = 1
167
+ for (const el of Array.from(document.querySelectorAll<HTMLElement>('[data-andesite-id]'))) {
168
+ // 视图栈稍后逐视图量尺寸并体检,不能拿默认隐藏视图的零矩形判塌陷。
169
+ if (el.closest('[data-andesite-viewstack]')) continue
170
+ // 声明 defaultVisible=false 的控件以 display:none 默认隐藏(翻页/页签的隐藏组),getBoundingClientRect 全为 0,
171
+ // 这是预期而非布局塌陷——跳过塌陷与出屏检测,照常栅格化(截图时临时恢复可见)。
172
+ if (el.getAttribute('data-andesite-default-visible') === 'false') {
173
+ continue
174
+ }
175
+ const r = el.getBoundingClientRect()
176
+ const id = el.getAttribute('data-andesite-id') ?? '?'
177
+ if (r.width <= tol || r.height <= tol) {
178
+ collapsed.push(`${id} (宽=${Math.round(r.width)}, 高=${Math.round(r.height)})`)
179
+ continue
180
+ }
181
+ if (rootRect) {
182
+ const x = r.left - rootRect.left
183
+ const y = r.top - rootRect.top
184
+ if (x < -tol || y < -tol || x + r.width > rootRect.width + tol || y + r.height > rootRect.height + tol) {
185
+ outOfBounds.push(`${id} (x=${Math.round(x)}, y=${Math.round(y)}, ${Math.round(r.width)}×${Math.round(r.height)}, 页面 ${Math.round(rootRect.width)}×${Math.round(rootRect.height)})`)
186
+ }
187
+ }
188
+ }
189
+ return { collapsed, outOfBounds }
190
+ })
191
+ // 布局体检仅输出警告,不中断编译。只要浏览器能渲染,就原样烘焙;
192
+ // 塌陷或出屏由作者自行在预览中确认,编译器不替作者做审美判断。
193
+ if (layout.collapsed.length && verbose) {
194
+ console.warn(`[andesite-compiler] 布局塌陷警告: ${layout.collapsed.slice(0, 8).join(';')}${layout.collapsed.length > 8 ? ` 等 ${layout.collapsed.length} 个` : ''}`)
195
+ }
196
+ if (kind !== 'hud' && layout.outOfBounds.length && verbose) {
197
+ console.warn(`[andesite-compiler] 控件出屏警告: ${layout.outOfBounds.slice(0, 8).join(';')}${layout.outOfBounds.length > 8 ? ` 等 ${layout.outOfBounds.length} 个` : ''}`)
198
+ }
199
+ const mounts = kind === 'hud' ? await collectHudMounts(page) : []
200
+ const viewStacks: ViewStackDecl[] = []
201
+ for (const stack of await page.$$('[data-andesite-viewstack]')) {
202
+ const info = await stack.evaluate(node => ({ id: node.getAttribute('data-andesite-id')!, defaultView: node.getAttribute('data-andesite-default-view')! }))
203
+ const views: ViewStackDecl['views'] = []
204
+ for (const el of await stack.$$(':scope > [data-andesite-view]')) {
205
+ const box = await measureAndesiteBox(el)
206
+ const info = await el.evaluate(node => ({
207
+ id: node.getAttribute('data-andesite-id')!,
208
+ name: node.getAttribute('data-andesite-view-name')!,
209
+ parentView: node.parentElement?.closest('[data-andesite-view]')?.getAttribute('data-andesite-id') ?? undefined,
210
+ controls: Array.from(node.querySelectorAll('[data-andesite-id]'))
211
+ .filter(child => child.closest('[data-andesite-view]') === node && !child.hasAttribute('data-andesite-viewstack'))
212
+ .map(child => child.getAttribute('data-andesite-id')!),
213
+ }))
214
+ if (box.width <= 0 || box.height <= 0) throw new Error(`Collapsed AView: ${info.id}`)
215
+ const layer = optionalNumber(await stack.evaluate(node => node.getAttribute('data-andesite-layer')))
216
+ views.push({ ...info, ...box, layer, mountId: await collectHudMountId(el), controls: [info.id + '/__background', ...info.controls] })
217
+ }
218
+ viewStacks.push({ ...info, views })
219
+ }
220
+ const labels = await collectLabels(page)
221
+ const inputs = await rasterizeTextInputs(page, outputDir)
222
+ const backgroundPanels = kind === 'hud'
223
+ ? await rasterizeHudStaticBackgrounds(page, outputDir)
224
+ : await rasterizeStaticBackground(page, outputDir)
225
+ const panels = await rasterizePanels(page, outputDir)
226
+ const buttons = await rasterizeButtons(page, outputDir)
227
+ const progresses = await rasterizeProgresses(page, outputDir)
228
+ const sprites = await rasterizeSprites(page, outputDir)
229
+ const scrollViews = await rasterizeScrollViews(page, outputDir)
230
+ // HUD 是 Bedrock-only,不能把 ASlot 导出成 Java chest slot。
231
+ const slots = kind === 'hud' ? [] : await collectSlots(page)
232
+ // 所有视图都必须参与体检,非默认视图不得静默丢控件或以零尺寸导出。
233
+ const exported = [...panels, ...buttons, ...labels, ...inputs, ...progresses, ...sprites, ...scrollViews, ...slots]
234
+ const exportedIds = new Set(exported.map(control => control.id))
235
+ for (const stack of viewStacks) for (const view of stack.views) {
236
+ view.controls = view.controls.filter(id => exportedIds.has(id))
237
+ for (const control of exported.filter(control => view.controls.includes(control.id))) {
238
+ if (control.width <= 0 || control.height <= 0) throw new Error(`Collapsed AView control: ${control.id}`)
239
+ if (kind !== 'hud' && (control.x < -1 || control.y < -1 || control.x + control.width > pageBox.width + 1 || control.y + control.height > pageBox.height + 1)) {
240
+ throw new Error(`AView control outside page: ${control.id}`)
241
+ }
242
+ }
243
+ }
244
+ return { kind, width: pageBox.width, height: pageBox.height, mounts, viewStacks, panels: [...backgroundPanels, ...panels], buttons, labels, inputs, slots, progresses, sprites, scrollViews }
245
+ } finally {
246
+ await page.close()
247
+ }
248
+ }
249
+
250
+ /**
251
+ * 单页便捷入口:内部 launch + 栅格化 + close。
252
+ */
253
+ export async function rasterizeAll(pageUrl: string, outputDir: string, options?: RasterizeOptions): Promise<AndesiteRasterResult> {
254
+ const browser = await launchRasterBrowser()
255
+ try {
256
+ return await rasterizeAllInBrowser(browser, pageUrl, outputDir, options)
257
+ } finally {
258
+ await browser.close()
259
+ }
260
+ }
261
+
262
+ async function collectHudMounts(page: Page): Promise<HudMountDecl[]> {
263
+ const elements = await page.$$('[data-andesite-hud-mount]')
264
+ const results: HudMountDecl[] = []
265
+ for (const el of elements) {
266
+ const mount = await el.evaluate(node => {
267
+ const target = node as HTMLElement
268
+ return {
269
+ id: target.getAttribute('data-andesite-id') ?? '',
270
+ x: target.getAttribute('data-andesite-x') ?? '0',
271
+ y: target.getAttribute('data-andesite-y') ?? '0',
272
+ anchor: target.getAttribute('data-andesite-anchor') ?? 'top-left',
273
+ offsetX: target.getAttribute('data-andesite-offset-x'),
274
+ offsetY: target.getAttribute('data-andesite-offset-y'),
275
+ scale: target.getAttribute('data-andesite-scale'),
276
+ layer: target.getAttribute('data-andesite-layer'),
277
+ visibleByDefault: target.getAttribute('data-andesite-visible-by-default'),
278
+ }
279
+ })
280
+ const offsetX = optionalNumber(mount.offsetX)
281
+ const offsetY = optionalNumber(mount.offsetY)
282
+ const scale = optionalNumber(mount.scale)
283
+ const layer = optionalNumber(mount.layer)
284
+ const visibleByDefault = mount.visibleByDefault == null || mount.visibleByDefault === ''
285
+ ? undefined
286
+ : mount.visibleByDefault === 'true' || mount.visibleByDefault === '1'
287
+ results.push({
288
+ id: mount.id,
289
+ x: clampPercent(Number(mount.x)),
290
+ y: clampPercent(Number(mount.y)),
291
+ anchor: normalizeHudAnchor(mount.anchor),
292
+ ...(offsetX == null ? {} : { offsetX }),
293
+ ...(offsetY == null ? {} : { offsetY }),
294
+ ...(scale == null ? {} : { scale }),
295
+ ...(layer == null ? {} : { layer }),
296
+ ...(visibleByDefault == null ? {} : { visibleByDefault }),
297
+ })
298
+ }
299
+ return results
300
+ }
301
+
302
+ function normalizeHudAnchor(value: string): HudAnchor {
303
+ return (HUD_ANCHORS as readonly string[]).includes(value) ? (value as HudAnchor) : 'top-left'
304
+ }
305
+
306
+ function optionalNumber(value: string | null): number | undefined {
307
+ if (value == null || value === '') {
308
+ return undefined
309
+ }
310
+ const number = Number(value)
311
+ return Number.isFinite(number) ? number : undefined
312
+ }
313
+
314
+ async function collectButtonClickable(target: Awaited<ReturnType<Page['$']>>): Promise<boolean> {
315
+ if (!target) {
316
+ return true
317
+ }
318
+ return await target.evaluate((node: Element) => node.getAttribute('data-andesite-clickable') !== 'false')
319
+ }
320
+
321
+ function clampPercent(value: number): number {
322
+ if (!Number.isFinite(value)) {
323
+ return 0
324
+ }
325
+ return Math.max(0, Math.min(100, value))
326
+ }
327
+
328
+ async function rasterizeStaticBackground(page: Page, outputDir: string): Promise<PanelRaster[]> {
329
+ const root = await page.$('[data-andesite-content-root]')
330
+ if (!root) {
331
+ return []
332
+ }
333
+ const box = await measureAndesiteBox(root)
334
+ if (box.width <= 0 || box.height <= 0) {
335
+ return []
336
+ }
337
+ const texturePath = 'textures/__page_background.png'
338
+ const screenshot = await withHiddenAndesiteControls(page, async () => {
339
+ return await root.screenshot({ type: 'png', omitBackground: true })
340
+ })
341
+ const outputPath = path.join(outputDir, texturePath)
342
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
343
+ fs.writeFileSync(outputPath, screenshot)
344
+ return [{ id: '__page_background', texturePath, x: box.x, y: box.y, width: box.width, height: box.height, png: screenshot }]
345
+ }
346
+
347
+ async function rasterizeHudStaticBackgrounds(page: Page, outputDir: string): Promise<PanelRaster[]> {
348
+ const elements = await page.$$('[data-andesite-hud-mount]')
349
+ const results: PanelRaster[] = []
350
+ for (const el of elements) {
351
+ const mountId = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
352
+ if (mountId === '') {
353
+ continue
354
+ }
355
+
356
+ const rect = await el.evaluate((node: Element) => {
357
+ const bounds = (node as HTMLElement).getBoundingClientRect()
358
+ return {
359
+ width: Math.round(bounds.width),
360
+ height: Math.round(bounds.height),
361
+ }
362
+ })
363
+ if (rect.width <= 0 || rect.height <= 0) {
364
+ continue
365
+ }
366
+
367
+ const safeMountId = mountId.replace(/[:\/.-]/g, '_') || 'default'
368
+ const id = `__hud_mount_${safeMountId}_background`
369
+ const texturePath = `textures/${id}.png`
370
+ // 原位截图,不用克隆 overlay:克隆成 fixed 会破坏 mount 内 flex stretch 布局,
371
+ // 说明条这类撑满全宽的普通 div 在克隆里收缩成内容宽,导致背景图右半缺失。
372
+ // withIsolatedCapture 原位保留布局,仅隐藏独立 A* 控件、清理祖先背景。
373
+ const screenshot = await withIsolatedCapture(page, el, async () => {
374
+ return await el.screenshot({ type: 'png', omitBackground: true })
375
+ })
376
+ const outputPath = path.join(outputDir, texturePath)
377
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
378
+ fs.writeFileSync(outputPath, screenshot)
379
+ results.push({ id, mountId, texturePath, x: 0, y: 0, width: rect.width, height: rect.height, png: screenshot })
380
+ }
381
+ return results
382
+ }
383
+
384
+
385
+ async function isInsideScrollView(target: Awaited<ReturnType<Page['$']>>): Promise<boolean> {
386
+ if (!target) {
387
+ return false
388
+ }
389
+ return await target.evaluate((node: Element) => node.closest('[data-andesite-type="scroll-view"]') != null)
390
+ }
391
+
392
+ async function isInsideRepeater(target: Awaited<ReturnType<Page['$']>>): Promise<boolean> {
393
+ if (!target) {
394
+ return false
395
+ }
396
+ return await target.evaluate((node: Element) => node.closest('[data-andesite-type="repeater"]') != null)
397
+ }
398
+
399
+ async function measureBoxRelativeToScrollChild(
400
+ target: Awaited<ReturnType<Page['$']>>,
401
+ scrollEl: Awaited<ReturnType<Page['$']>>,
402
+ ): Promise<DomBox> {
403
+ return await measureBoxRelativeToAncestor(target, scrollEl)
404
+ }
405
+
406
+ async function measureBoxRelativeToAncestor(
407
+ target: Awaited<ReturnType<Page['$']>>,
408
+ ancestor: Awaited<ReturnType<Page['$']>>,
409
+ ): Promise<DomBox> {
410
+ if (!target || !ancestor) {
411
+ return { x: 0, y: 0, width: 0, height: 0 }
412
+ }
413
+ return await target.evaluate((node: Element, ancestorNode: Element) => {
414
+ const ancestorRect = (ancestorNode as HTMLElement).getBoundingClientRect()
415
+ const rect = (node as HTMLElement).getBoundingClientRect()
416
+ return {
417
+ x: Math.round(rect.left - ancestorRect.left),
418
+ y: Math.round(rect.top - ancestorRect.top),
419
+ width: Math.round(rect.width),
420
+ height: Math.round(rect.height),
421
+ }
422
+ }, ancestor)
423
+ }
424
+
425
+ async function rasterizeScrollViews(page: Page, outputDir: string): Promise<ScrollViewDecl[]> {
426
+ const elements = await page.$$('[data-andesite-type="scroll-view"]')
427
+ const results: ScrollViewDecl[] = []
428
+ for (const scrollEl of elements) {
429
+ if (!await isCollectableAndesiteElement(scrollEl)) {
430
+ continue
431
+ }
432
+ const id = await scrollEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
433
+ const mountId = await collectHudMountId(scrollEl)
434
+ const box = await measureAndesiteBox(scrollEl)
435
+ const defaultVisible = await scrollEl.evaluate(
436
+ (node: Element) => node.getAttribute('data-andesite-default-visible') !== 'false',
437
+ )
438
+ const scrollPanels: ScrollViewDecl['panels'] = []
439
+ const panelEls = await scrollEl.$$('[data-andesite-type="panel"]')
440
+ for (const panelEl of panelEls) {
441
+ if (!await isCollectableAndesiteElement(panelEl)) {
442
+ continue
443
+ }
444
+ if (await isInsideRepeater(panelEl)) {
445
+ continue
446
+ }
447
+ const panelId = await panelEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
448
+ const panelBox = await measureBoxRelativeToScrollChild(panelEl, scrollEl)
449
+ const texturePath = `textures/${panelId}.png`
450
+ const screenshot = await withUnclippedScrollCapture(page, panelEl, async () => {
451
+ return await withIsolatedCapture(page, panelEl, async () => panelEl.screenshot({ type: 'png', omitBackground: true }))
452
+ })
453
+ const outputPath = path.join(outputDir, texturePath)
454
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
455
+ fs.writeFileSync(outputPath, screenshot)
456
+ scrollPanels.push({
457
+ id: panelId,
458
+ texture: texturePath,
459
+ x: panelBox.x,
460
+ y: panelBox.y,
461
+ width: panelBox.width,
462
+ height: panelBox.height,
463
+ })
464
+ }
465
+ const scrollButtons: ScrollViewDecl['buttons'] = []
466
+ const buttonEls = await scrollEl.$$('[data-andesite-type="button"]')
467
+ for (const btnEl of buttonEls) {
468
+ if (!await isCollectableAndesiteElement(btnEl)) {
469
+ continue
470
+ }
471
+ if (await isInsideRepeater(btnEl)) {
472
+ continue
473
+ }
474
+ const btnId = await btnEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
475
+ const btnBox = await measureBoxRelativeToScrollChild(btnEl, scrollEl)
476
+ const texturePath = `textures/${btnId}.png`
477
+ const defaultPng = await withUnclippedScrollCapture(page, btnEl, async () => {
478
+ return await withIsolatedCapture(page, btnEl, async () => {
479
+ await setButtonCaptureState(btnEl, 'default', true)
480
+ return await captureButtonPng(page, btnEl)
481
+ })
482
+ })
483
+ const pressedPng = await withUnclippedScrollCapture(page, btnEl, async () => {
484
+ return await withIsolatedCapture(page, btnEl, async () => {
485
+ await setButtonCaptureState(btnEl, 'pressed', true)
486
+ return await captureButtonPng(page, btnEl)
487
+ })
488
+ })
489
+ const atlas = await combinePngsVertically(page, [defaultPng, pressedPng])
490
+ const outputPath = path.join(outputDir, texturePath)
491
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
492
+ fs.writeFileSync(outputPath, atlas)
493
+ await resetButtonPreviewState(btnEl)
494
+ scrollButtons.push({
495
+ id: btnId,
496
+ defaultTexture: texturePath,
497
+ pressedTexture: texturePath,
498
+ x: btnBox.x,
499
+ y: btnBox.y,
500
+ width: btnBox.width,
501
+ height: btnBox.height,
502
+ })
503
+ }
504
+ const scrollLabels: ScrollViewDecl['labels'] = []
505
+ const labelEls = await scrollEl.$$('[data-andesite-type="label"]')
506
+ for (const labelEl of labelEls) {
507
+ if (!await isCollectableAndesiteElement(labelEl)) {
508
+ continue
509
+ }
510
+ if (await isInsideRepeater(labelEl)) {
511
+ continue
512
+ }
513
+ const labelBox = await measureBoxRelativeToScrollChild(labelEl, scrollEl)
514
+ const labelId = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
515
+ const text = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-text') ?? '')
516
+ const size = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-size') ?? 'NORMAL')
517
+ const align = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-align') ?? 'LEFT')
518
+ const color = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-color') ?? 'ffffff')
519
+ scrollLabels.push({
520
+ id: labelId,
521
+ text,
522
+ x: labelBox.x,
523
+ y: labelBox.y,
524
+ width: labelBox.width,
525
+ height: labelBox.height,
526
+ size: size as ScrollViewDecl['labels'][0]['size'],
527
+ align: align as ScrollViewDecl['labels'][0]['align'],
528
+ color,
529
+ })
530
+ }
531
+ const scrollSlots: ScrollViewDecl['slots'] = []
532
+ const slotEls = await scrollEl.$$('[data-andesite-type="slot"]')
533
+ for (const slotEl of slotEls) {
534
+ if (!await isCollectableAndesiteElement(slotEl)) {
535
+ continue
536
+ }
537
+ if (await isInsideRepeater(slotEl)) {
538
+ continue
539
+ }
540
+ const slotId = await slotEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
541
+ const slotMountId = await collectHudMountId(slotEl)
542
+ const slotIndex = await slotEl.evaluate((node: Element) => node.getAttribute('data-andesite-slot-index') ?? '')
543
+ const slotBox = await measureBoxRelativeToScrollChild(slotEl, scrollEl)
544
+ scrollSlots.push({
545
+ id: slotId,
546
+ mountId: slotMountId,
547
+ slot: slotIndex === '' ? scrollSlots.length : Number(slotIndex),
548
+ x: slotBox.x,
549
+ y: slotBox.y,
550
+ width: slotBox.width,
551
+ height: slotBox.height,
552
+ })
553
+ }
554
+ const scrollRepeaters = await rasterizeScrollRepeaters(page, scrollEl, outputDir)
555
+ const contentFromDom = await scrollEl.evaluate((node: Element) => {
556
+ const content = node.querySelector<HTMLElement>('[data-andesite-scroll-content]')
557
+ return content ? Math.round(content.scrollHeight) : 0
558
+ })
559
+ const contentHeight = Math.max(
560
+ contentFromDom,
561
+ box.height,
562
+ scrollPanels.reduce((max, panel) => Math.max(max, panel.y + panel.height), 0),
563
+ scrollButtons.reduce((max, btn) => Math.max(max, btn.y + btn.height), 0),
564
+ scrollLabels.reduce((max, label) => Math.max(max, label.y + label.height), 0),
565
+ scrollSlots.reduce((max, slot) => Math.max(max, slot.y + slot.height), 0),
566
+ scrollRepeaters.reduce((max, repeater) => Math.max(max, repeater.y + repeater.contentHeight), 0),
567
+ )
568
+ results.push({
569
+ id,
570
+ mountId,
571
+ x: box.x,
572
+ y: box.y,
573
+ width: box.width,
574
+ height: box.height,
575
+ contentHeight,
576
+ defaultVisible,
577
+ panels: scrollPanels,
578
+ buttons: scrollButtons,
579
+ labels: scrollLabels,
580
+ slots: scrollSlots,
581
+ repeaters: scrollRepeaters,
582
+ })
583
+ }
584
+ return results
585
+ }
586
+
587
+ async function rasterizeScrollRepeaters(
588
+ page: Page,
589
+ scrollEl: Awaited<ReturnType<Page['$']>>,
590
+ outputDir: string,
591
+ ): Promise<ScrollViewDecl['repeaters']> {
592
+ if (!scrollEl) {
593
+ return []
594
+ }
595
+ const repeaterEls = await scrollEl.$$('[data-andesite-type="repeater"]')
596
+ const results: ScrollViewDecl['repeaters'] = []
597
+ for (const repeaterEl of repeaterEls) {
598
+ if (!await isCollectableAndesiteElement(repeaterEl)) {
599
+ continue
600
+ }
601
+ const id = await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
602
+ const source = await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-source') ?? id)
603
+ const repeaterBox = await measureBoxRelativeToScrollChild(repeaterEl, scrollEl)
604
+ const cellBoxes = await collectRepeaterCellBoxes(repeaterEl)
605
+ const declaredColumns = optionalNumber(await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-columns')))
606
+ const declaredItemWidth = optionalNumber(await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-item-width')))
607
+ const declaredItemHeight = optionalNumber(await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-item-height')))
608
+ const declaredMaxItems = optionalNumber(await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-max-items')))
609
+ const declaredRowGap = optionalNumber(await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-row-gap')))
610
+ const declaredColumnGap = optionalNumber(await repeaterEl.evaluate((node: Element) => node.getAttribute('data-andesite-column-gap')))
611
+ const columns = Math.max(1, Math.floor(declaredColumns ?? inferRepeaterColumns(cellBoxes)))
612
+ const itemWidth = Math.max(1, Math.round(declaredItemWidth ?? cellBoxes[0]?.width ?? repeaterBox.width))
613
+ const itemHeight = Math.max(1, Math.round(declaredItemHeight ?? cellBoxes[0]?.height ?? repeaterBox.height))
614
+ const maxItems = declaredMaxItems ?? 0
615
+ const rowGap = Math.max(0, Math.round(declaredRowGap ?? inferRepeaterRowGap(cellBoxes, columns)))
616
+ const columnGap = Math.max(0, Math.round(declaredColumnGap ?? inferRepeaterColumnGap(cellBoxes, columns)))
617
+ const safeColumns = Math.max(1, Math.floor(columns))
618
+ const maxRows = maxItems > 0 ? Math.ceil(maxItems / safeColumns) : 1
619
+ const contentHeight = Math.max(itemHeight, maxRows * itemHeight + Math.max(0, maxRows - 1) * rowGap)
620
+ const templatePanels: ScrollViewDecl['panels'] = []
621
+ const panelEls = await repeaterEl.$$('[data-andesite-type="panel"]')
622
+ for (const panelEl of panelEls) {
623
+ if (!await isCollectableAndesiteElement(panelEl) || !await isInsideRepeaterFirstRow(panelEl, columns)) {
624
+ continue
625
+ }
626
+ const panelId = await panelEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
627
+ const panelBox = await measureBoxRelativeToAncestor(panelEl, repeaterEl)
628
+ const texturePath = `textures/${panelId}.png`
629
+ const screenshot = await withUnclippedScrollCapture(page, panelEl, async () => {
630
+ return await withIsolatedCapture(page, panelEl, async () => panelEl.screenshot({ type: 'png', omitBackground: true }))
631
+ })
632
+ const outputPath = path.join(outputDir, texturePath)
633
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
634
+ fs.writeFileSync(outputPath, screenshot)
635
+ templatePanels.push({
636
+ id: panelId,
637
+ texture: texturePath,
638
+ x: panelBox.x,
639
+ y: panelBox.y,
640
+ width: panelBox.width,
641
+ height: panelBox.height,
642
+ })
643
+ }
644
+ const templateButtons: ScrollViewDecl['buttons'] = []
645
+ const buttonEls = await repeaterEl.$$('[data-andesite-type="button"]')
646
+ for (const btnEl of buttonEls) {
647
+ if (!await isCollectableAndesiteElement(btnEl) || !await isInsideRepeaterFirstRow(btnEl, columns)) {
648
+ continue
649
+ }
650
+ const btnId = await btnEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
651
+ const btnBox = await measureBoxRelativeToAncestor(btnEl, repeaterEl)
652
+ const texturePath = `textures/${btnId}.png`
653
+ const defaultPng = await withUnclippedScrollCapture(page, btnEl, async () => {
654
+ return await withIsolatedCapture(page, btnEl, async () => {
655
+ await setButtonCaptureState(btnEl, 'default', true)
656
+ return await captureButtonPng(page, btnEl)
657
+ })
658
+ })
659
+ const pressedPng = await withUnclippedScrollCapture(page, btnEl, async () => {
660
+ return await withIsolatedCapture(page, btnEl, async () => {
661
+ await setButtonCaptureState(btnEl, 'pressed', true)
662
+ return await captureButtonPng(page, btnEl)
663
+ })
664
+ })
665
+ const atlas = await combinePngsVertically(page, [defaultPng, pressedPng])
666
+ const outputPath = path.join(outputDir, texturePath)
667
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
668
+ fs.writeFileSync(outputPath, atlas)
669
+ await resetButtonPreviewState(btnEl)
670
+ templateButtons.push({
671
+ id: btnId,
672
+ defaultTexture: texturePath,
673
+ pressedTexture: texturePath,
674
+ x: btnBox.x,
675
+ y: btnBox.y,
676
+ width: btnBox.width,
677
+ height: btnBox.height,
678
+ })
679
+ }
680
+ const templateLabels: ScrollViewDecl['labels'] = []
681
+ const labelEls = await repeaterEl.$$('[data-andesite-type="label"]')
682
+ for (const labelEl of labelEls) {
683
+ if (!await isCollectableAndesiteElement(labelEl) || !await isInsideRepeaterFirstRow(labelEl, columns)) {
684
+ continue
685
+ }
686
+ const labelBox = await measureBoxRelativeToAncestor(labelEl, repeaterEl)
687
+ const labelId = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
688
+ const text = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-text') ?? '')
689
+ const size = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-size') ?? 'NORMAL')
690
+ const align = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-align') ?? 'LEFT')
691
+ const color = await labelEl.evaluate((node: Element) => node.getAttribute('data-andesite-color') ?? 'ffffff')
692
+ templateLabels.push({
693
+ id: labelId,
694
+ text,
695
+ x: labelBox.x,
696
+ y: labelBox.y,
697
+ width: labelBox.width,
698
+ height: labelBox.height,
699
+ size: size as ScrollViewDecl['labels'][0]['size'],
700
+ align: align as ScrollViewDecl['labels'][0]['align'],
701
+ color,
702
+ })
703
+ }
704
+ const templateSlots: ScrollViewDecl['slots'] = []
705
+ const slotEls = await repeaterEl.$$('[data-andesite-type="slot"]')
706
+ for (const slotEl of slotEls) {
707
+ if (!await isCollectableAndesiteElement(slotEl)) {
708
+ continue
709
+ }
710
+ const slotId = await slotEl.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
711
+ const slotMountId = await collectHudMountId(slotEl)
712
+ const slotMeta = await slotEl.evaluate((node: Element) => {
713
+ const declared = node.getAttribute('data-andesite-slot-index') ?? ''
714
+ const cell = node.closest('[data-andesite-repeater-cell]')
715
+ return {
716
+ slot: declared !== '' ? declared : cell?.getAttribute('data-andesite-repeater-slot') ?? '',
717
+ itemIndex: cell?.getAttribute('data-andesite-repeater-cell') ?? '',
718
+ virtual: node.getAttribute('data-andesite-virtual') === 'true',
719
+ }
720
+ })
721
+ const slotBox = await measureBoxRelativeToAncestor(slotEl, repeaterEl)
722
+ templateSlots.push({
723
+ id: slotId,
724
+ mountId: slotMountId,
725
+ slot: slotMeta.slot === '' ? templateSlots.length : Number(slotMeta.slot),
726
+ ...(slotMeta.itemIndex === '' ? {} : { itemIndex: Number(slotMeta.itemIndex) }),
727
+ ...(slotMeta.virtual ? { virtual: true } : {}),
728
+ x: slotBox.x,
729
+ y: slotBox.y,
730
+ width: slotBox.width,
731
+ height: slotBox.height,
732
+ })
733
+ }
734
+ results.push({
735
+ id,
736
+ source,
737
+ x: repeaterBox.x,
738
+ y: repeaterBox.y,
739
+ width: Math.max(repeaterBox.width, columns * itemWidth + Math.max(0, columns - 1) * columnGap),
740
+ height: itemHeight,
741
+ columns,
742
+ itemWidth,
743
+ itemHeight,
744
+ ...(maxItems > 0 ? { maxItems: Math.floor(maxItems) } : {}),
745
+ contentHeight,
746
+ rowGap,
747
+ columnGap,
748
+ panels: templatePanels,
749
+ buttons: templateButtons,
750
+ labels: templateLabels,
751
+ slots: templateSlots,
752
+ })
753
+ }
754
+ return results
755
+ }
756
+
757
+ async function collectRepeaterCellBoxes(repeaterEl: Awaited<ReturnType<Page['$']>>): Promise<RepeaterCellBox[]> {
758
+ if (!repeaterEl) {
759
+ return []
760
+ }
761
+ const cells = await repeaterEl.$$('[data-andesite-repeater-cell]')
762
+ const boxes: RepeaterCellBox[] = []
763
+ for (const cell of cells) {
764
+ const index = await cell.evaluate((node: Element) => Number((node as HTMLElement).dataset.andesiteRepeaterCell ?? '-1'))
765
+ if (!Number.isFinite(index) || index < 0) {
766
+ continue
767
+ }
768
+ const box = await measureBoxRelativeToAncestor(cell, repeaterEl)
769
+ boxes.push({ index, ...box })
770
+ }
771
+ boxes.sort((a, b) => a.index - b.index)
772
+ return boxes
773
+ }
774
+
775
+ function inferRepeaterColumns(cells: RepeaterCellBox[]): number {
776
+ if (cells.length <= 1) {
777
+ return 1
778
+ }
779
+ const first = cells[0]
780
+ const firstRowY = first.y
781
+ return Math.max(1, cells.filter(cell => Math.abs(cell.y - firstRowY) <= 1).length)
782
+ }
783
+
784
+ function inferRepeaterColumnGap(cells: RepeaterCellBox[], columns: number): number {
785
+ if (columns <= 1 || cells.length <= 1) {
786
+ return 0
787
+ }
788
+ const firstRow = cells.slice(0, columns).sort((a, b) => a.x - b.x)
789
+ if (firstRow.length <= 1) {
790
+ return 0
791
+ }
792
+ return firstRow[1].x - firstRow[0].x - firstRow[0].width
793
+ }
794
+
795
+ function inferRepeaterRowGap(cells: RepeaterCellBox[], columns: number): number {
796
+ if (cells.length <= columns) {
797
+ return 0
798
+ }
799
+ const first = cells[0]
800
+ const secondRow = cells.find(cell => cell.index >= columns)
801
+ if (!secondRow) {
802
+ return 0
803
+ }
804
+ return secondRow.y - first.y - first.height
805
+ }
806
+
807
+ async function isInsideRepeaterFirstRow(target: Awaited<ReturnType<Page['$']>>, columns: number): Promise<boolean> {
808
+ if (!target) {
809
+ return false
810
+ }
811
+ return await target.evaluate((node: Element, columnCount: number) => {
812
+ const cell = node.closest('[data-andesite-repeater-cell]') as HTMLElement | null
813
+ if (!cell) {
814
+ return false
815
+ }
816
+ const index = Number(cell.getAttribute('data-andesite-repeater-cell') ?? '-1')
817
+ return Number.isFinite(index) && index >= 0 && index < Math.max(1, Math.floor(columnCount))
818
+ }, columns)
819
+ }
820
+
821
+ async function rasterizePanels(page: Page, outputDir: string): Promise<PanelRaster[]> {
822
+ const elements = await page.$$('[data-andesite-type="panel"], [data-andesite-view]')
823
+ const results: PanelRaster[] = []
824
+ for (const el of elements) {
825
+ if (await isInsideScrollView(el)) {
826
+ continue
827
+ }
828
+ const id = await el.evaluate((node: Element) => (node.getAttribute('data-andesite-id') ?? '') + (node.hasAttribute('data-andesite-view') ? '/__background' : ''))
829
+ const mountId = await collectHudMountId(el)
830
+ const defaultVisible = await readDefaultVisible(el)
831
+ const box = await measureAndesiteBox(el)
832
+ // box-shadow 外溢(如面板底部投影)不在边框盒内,page.screenshot 按边框盒 clip 会把它裁掉。
833
+ // 量出四向外溢,贴图按含阴影的视觉包围盒外扩截取,保留完整投影;导出 overflow 供导入器
834
+ // 把控件按边框盒定位、贴图按外扩尺寸渲染——本体对齐边框盒、投影自然溢出控件框外。
835
+ // blur 不是像素外溢上界;面板随后会逐像素裁剪,先留足模糊尾部,避免把真实阴影截掉。
836
+ const estimatedOverflow = await measureShadowOverflow(el, 2)
837
+ const texturePath = `textures/${encodeURIComponent(id)}.png`
838
+ const captured = await withIsolatedCapture(page, el, async () => {
839
+ // clip 原点必须在预处理(目标已显示)后实时量:defaultVisible=false 的目标被单独显示时,
840
+ // 其在网格/flex 中的实际落位可能不同于"全部兄弟都显示"时的排版位置(隐藏兄弟不再占轨道),
841
+ // 用预处理前量的坐标会错位截空。这里量的是目标当前真实渲染位置。
842
+ // 负 clip 会让 Chromium 的实际取样原点与声明尺寸脱节;仅平移还不足以保留右/下阴影,
843
+ // 需用透明占位撑开文档绘制范围。保持视口不变,避免 vw、媒体查询及百分比布局重新排版。
844
+ const captureSpace = await el.evaluateHandle((node: Element, pad: VisualOverflow) => {
845
+ const html = document.documentElement
846
+ const rect = (node as HTMLElement).getBoundingClientRect()
847
+ const state = { style: html.getAttribute('style'), x: window.scrollX, y: window.scrollY, width: rect.width, height: rect.height, spacer: document.createElement('div') }
848
+ const dx = Math.max(0, Math.ceil(pad.left - rect.left - state.x))
849
+ const dy = Math.max(0, Math.ceil(pad.top - rect.top - state.y))
850
+ const right = Math.ceil(Math.max(html.scrollWidth, rect.right + state.x + pad.right))
851
+ const bottom = Math.ceil(Math.max(html.scrollHeight, rect.bottom + state.y + pad.bottom))
852
+ const transform = getComputedStyle(html).transform
853
+ state.spacer.style.cssText = `all: initial; position: absolute; left: ${right}px; top: ${bottom}px; width: 1px; height: 1px; visibility: hidden; pointer-events: none;`
854
+ html.appendChild(state.spacer)
855
+ if (dx > 0 || dy > 0) {
856
+ html.style.setProperty('transition', 'none', 'important')
857
+ html.style.setProperty('transform', `translate(${dx}px, ${dy}px)${transform === 'none' ? '' : ` ${transform}`}`, 'important')
858
+ }
859
+ return state
860
+ }, estimatedOverflow)
861
+ try {
862
+ const abs = await el.evaluate((node: Element) => {
863
+ const rect = (node as HTMLElement).getBoundingClientRect()
864
+ return { left: rect.left + window.scrollX, top: rect.top + window.scrollY, width: rect.width, height: rect.height }
865
+ })
866
+ const originalSize = await captureSpace.evaluate(state => ({ width: state.width, height: state.height }))
867
+ if (Math.abs(abs.width - originalSize.width) > 0.01 || Math.abs(abs.height - originalSize.height) > 0.01) {
868
+ throw new Error(`Panel layout changed while preparing shadow capture: ${id}`)
869
+ }
870
+ // 主动把 clip 对齐到整数 CSS 像素(左上 floor、右下 ceil),供 trimShadowOverflow 校验与反推。
871
+ const left = Math.floor(abs.left - estimatedOverflow.left)
872
+ const top = Math.floor(abs.top - estimatedOverflow.top)
873
+ const right = Math.ceil(abs.left + abs.width + estimatedOverflow.right)
874
+ const bottom = Math.ceil(abs.top + abs.height + estimatedOverflow.bottom)
875
+ const clip: DomBox = { x: left, y: top, width: right - left, height: bottom - top }
876
+ if (clip.x < 0 || clip.y < 0) {
877
+ throw new Error(`Panel shadow capture is outside the padded page: ${id}`)
878
+ }
879
+ const borderBoxInClip: DomBox = { x: abs.left - left, y: abs.top - top, width: abs.width, height: abs.height }
880
+ const png = await page.screenshot({
881
+ type: 'png',
882
+ omitBackground: true,
883
+ captureBeyondViewport: true,
884
+ clip,
885
+ })
886
+ return { png, clip, borderBoxInClip }
887
+ } finally {
888
+ try {
889
+ await captureSpace.evaluate(state => {
890
+ state.spacer.remove()
891
+ const html = document.documentElement
892
+ if (state.style === null) {
893
+ html.removeAttribute('style')
894
+ } else {
895
+ html.setAttribute('style', state.style)
896
+ }
897
+ window.scrollTo(state.x, state.y)
898
+ })
899
+ } finally {
900
+ await captureSpace.dispose()
901
+ }
902
+ }
903
+ })
904
+ // 截图后扫真实 alpha 包围盒,裁掉边框盒外的纯透明(如 overflow-hidden 裁掉的投影),反推真实 overflow。
905
+ const trimmed = await trimShadowOverflow(page, captured.png, captured.clip, captured.borderBoxInClip, RASTER_SCALE)
906
+ const outputPath = path.join(outputDir, texturePath)
907
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
908
+ fs.writeFileSync(outputPath, trimmed.png)
909
+ const overflow = trimmed.overflow
910
+ const hasOverflow = overflow.left > 0 || overflow.top > 0 || overflow.right > 0 || overflow.bottom > 0
911
+ results.push({ id, mountId, texturePath, x: box.x, y: box.y, width: captured.borderBoxInClip.width, height: captured.borderBoxInClip.height, ...(hasOverflow ? { overflow } : {}), ...(defaultVisible == null ? {} : { defaultVisible }), png: trimmed.png })
912
+ }
913
+ return results
914
+ }
915
+
916
+ async function rasterizeButtons(page: Page, outputDir: string): Promise<ButtonRaster[]> {
917
+ const elements = await page.$$('[data-andesite-type="button"]')
918
+ const results: ButtonRaster[] = []
919
+ for (const el of elements) {
920
+ if (await isInsideScrollView(el)) {
921
+ continue
922
+ }
923
+ const id = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
924
+ const mountId = await collectHudMountId(el)
925
+ const defaultVisible = await readDefaultVisible(el)
926
+ const box = await measureAndesiteBox(el)
927
+ const pressDuration = Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-press-duration') ?? '2'))
928
+ const clickable = await collectButtonClickable(el)
929
+ const layer = optionalNumber(await el.evaluate((node: Element) => node.getAttribute('data-andesite-layer')))
930
+ const statesJson = await el.evaluate((node: Element) => node.getAttribute('data-andesite-states') ?? '[]')
931
+ const labelsJson = await el.evaluate((node: Element) => node.getAttribute('data-andesite-labels') ?? '[]')
932
+ const states = JSON.parse(statesJson) as ButtonStateDecl[]
933
+ const labels = JSON.parse(labelsJson) as ButtonLabelDecl[]
934
+ const texturePath = `textures/${id}.png`
935
+ // 外层容器 box-shadow 外溢需在导出坐标与尺寸中体现,与截图外扩保持一致,
936
+ // 否则放大后的贴图按边框盒坐标摆放会偏移、投影对不齐按钮体。
937
+ const overflow = await measureShadowOverflow(el)
938
+ const frames: Uint8Array[] = []
939
+ // 截取默认态
940
+ await setButtonCaptureState(el, 'default', true)
941
+ if (el) {
942
+ const screenshot = await withIsolatedCapture(page, el, async () => {
943
+ await setButtonCaptureState(el, 'default', true)
944
+ return await captureButtonPng(page, el)
945
+ })
946
+ frames.push(screenshot)
947
+ }
948
+ // 截取按下态
949
+ await setButtonCaptureState(el, 'pressed', true)
950
+ if (el) {
951
+ const screenshot = await withIsolatedCapture(page, el, async () => {
952
+ await setButtonCaptureState(el, 'pressed', true)
953
+ return await captureButtonPng(page, el)
954
+ })
955
+ frames.push(screenshot)
956
+ }
957
+ if (frames.length === 2) {
958
+ const atlas = await combinePngsVertically(page, frames)
959
+ const outputPath = path.join(outputDir, texturePath)
960
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
961
+ fs.writeFileSync(outputPath, atlas)
962
+ results.push({
963
+ id,
964
+ mountId,
965
+ defaultTexturePath: texturePath,
966
+ pressedTexturePath: texturePath,
967
+ x: box.x - overflow.left,
968
+ y: box.y - overflow.top,
969
+ width: box.width + overflow.left + overflow.right,
970
+ height: box.height + overflow.top + overflow.bottom,
971
+ pressDuration,
972
+ clickable,
973
+ ...(layer == null ? {} : { layer }),
974
+ ...(defaultVisible == null ? {} : { defaultVisible }),
975
+ states,
976
+ labels,
977
+ defaultPng: frames[0],
978
+ pressedPng: frames[1],
979
+ })
980
+ }
981
+ await resetButtonPreviewState(el)
982
+ }
983
+ return results
984
+ }
985
+
986
+ async function rasterizeTextInputs(page: Page, outputDir: string): Promise<TextInputDecl[]> {
987
+ const elements = await page.$$('[data-andesite-type="text-input"]')
988
+ const results: TextInputDecl[] = []
989
+ for (const el of elements) {
990
+ if (!await isCollectableAndesiteElement(el)) {
991
+ continue
992
+ }
993
+ const id = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
994
+ const mountId = await collectHudMountId(el)
995
+ const box = await measureAndesiteBox(el)
996
+ const value = await el.evaluate((node: Element) => node.getAttribute('data-andesite-value') ?? '')
997
+ const placeholder = await el.evaluate((node: Element) => node.getAttribute('data-andesite-placeholder') ?? '')
998
+ const maxLength = Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-max-length') ?? '32'))
999
+ const size = await el.evaluate((node: Element) => node.getAttribute('data-andesite-size') ?? 'NORMAL')
1000
+ const align = await el.evaluate((node: Element) => node.getAttribute('data-andesite-align') ?? 'LEFT')
1001
+ const color = await el.evaluate((node: Element) => node.getAttribute('data-andesite-color') ?? 'ffffff')
1002
+ const placeholderColor = await el.evaluate((node: Element) => node.getAttribute('data-andesite-placeholder-color') ?? '8a8a8a')
1003
+ const completionJson = await el.evaluate((node: Element) => node.getAttribute('data-andesite-completion') ?? '{}')
1004
+ const texturePath = `textures/${id}.png`
1005
+ const screenshot = await withIsolatedCapture(page, el, async () => {
1006
+ return await el.screenshot({ type: 'png', omitBackground: true })
1007
+ })
1008
+ const outputPath = path.join(outputDir, texturePath)
1009
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
1010
+ fs.writeFileSync(outputPath, screenshot)
1011
+ results.push({
1012
+ id,
1013
+ mountId,
1014
+ texture: texturePath,
1015
+ value,
1016
+ placeholder,
1017
+ x: box.x,
1018
+ y: box.y,
1019
+ width: box.width,
1020
+ height: box.height,
1021
+ maxLength: Number.isFinite(maxLength) ? maxLength : 32,
1022
+ size: size as TextInputDecl['size'],
1023
+ align: align as TextInputDecl['align'],
1024
+ color,
1025
+ placeholderColor,
1026
+ completion: normalizeTextInputCompletion(completionJson),
1027
+ })
1028
+ }
1029
+ return results
1030
+ }
1031
+
1032
+ function normalizeTextInputCompletion(json: string): TextInputCompletionDecl {
1033
+ const fallback: TextInputCompletionDecl = {
1034
+ server: false,
1035
+ minChars: 0,
1036
+ maxItems: 0,
1037
+ debounceTicks: 0,
1038
+ }
1039
+ const parsed = safeJson(json)
1040
+ if (parsed.server !== true) {
1041
+ return fallback
1042
+ }
1043
+ return {
1044
+ server: true,
1045
+ minChars: nonNegativeNumber(parsed.minChars, 1),
1046
+ maxItems: nonNegativeNumber(parsed.maxItems, 5),
1047
+ debounceTicks: nonNegativeNumber(parsed.debounceTicks, 2),
1048
+ }
1049
+ }
1050
+
1051
+ function safeJson(json: string): Partial<TextInputCompletionDecl> {
1052
+ try {
1053
+ return JSON.parse(json) as Partial<TextInputCompletionDecl>
1054
+ } catch {
1055
+ return {}
1056
+ }
1057
+ }
1058
+
1059
+ function nonNegativeNumber(value: unknown, fallback: number): number {
1060
+ const number = Number(value ?? fallback)
1061
+ return Number.isFinite(number) ? Math.max(0, number) : fallback
1062
+ }
1063
+
1064
+ async function captureButtonPng(page: Page, el: Awaited<ReturnType<Page['$']>>): Promise<Uint8Array> {
1065
+ if (!el) {
1066
+ return new Uint8Array()
1067
+ }
1068
+ const cloneHandle = await el.evaluateHandle((node: Element) => {
1069
+ const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
1070
+ const html = document.documentElement
1071
+ const body = document.body
1072
+ // 声明 defaultVisible=false 的按钮以 display:none 默认隐藏,需先恢复 display 才能量出真实尺寸,
1073
+ // 克隆到 overlay 前再还原,避免污染实时页面。
1074
+ const selfHidden = (node as HTMLElement).getAttribute('data-andesite-default-visible') === 'false' && (node as HTMLElement).style.display === 'none'
1075
+ if (selfHidden) {
1076
+ (node as HTMLElement).style.display = ''
1077
+ }
1078
+ if (root) {
1079
+ root.dataset.andesiteButtonCaptureVisibility = root.style.visibility
1080
+ root.style.visibility = 'hidden'
1081
+ // 显式 visibility:visible 的状态层可穿透祖先 visibility;opacity 隔离整棵原树,克隆不受影响。
1082
+ root.dataset.andesiteButtonCaptureOpacity = root.style.opacity
1083
+ root.style.opacity = '0'
1084
+ }
1085
+ html.dataset.andesiteButtonCaptureBackground = html.style.background
1086
+ html.dataset.andesiteButtonCaptureBackgroundColor = html.style.backgroundColor
1087
+ body.dataset.andesiteButtonCaptureBackground = body.style.background
1088
+ body.dataset.andesiteButtonCaptureBackgroundColor = body.style.backgroundColor
1089
+ html.style.background = 'transparent'
1090
+ html.style.backgroundColor = 'transparent'
1091
+ body.style.background = 'transparent'
1092
+ body.style.backgroundColor = 'transparent'
1093
+
1094
+ const wrapper = document.createElement('div')
1095
+ wrapper.dataset.andesiteButtonCaptureOverlay = 'true'
1096
+ wrapper.style.position = 'fixed'
1097
+ wrapper.style.left = '0'
1098
+ wrapper.style.top = '0'
1099
+ wrapper.style.margin = '0'
1100
+ wrapper.style.padding = '0'
1101
+ wrapper.style.background = 'transparent'
1102
+ wrapper.style.pointerEvents = 'none'
1103
+ wrapper.style.zIndex = '2147483647'
1104
+
1105
+ const rect = (node as HTMLElement).getBoundingClientRect()
1106
+ const clone = node.cloneNode(true) as HTMLElement
1107
+ // 按钮常用 w-full / absolute 子层,独立截图时必须脱离原布局后固化测量尺寸。
1108
+ clone.style.position = 'relative'
1109
+ clone.style.left = '0'
1110
+ clone.style.top = '0'
1111
+ clone.style.right = 'auto'
1112
+ clone.style.bottom = 'auto'
1113
+ clone.style.width = `${Math.max(1, Math.round(rect.width))}px`
1114
+ clone.style.height = `${Math.max(1, Math.round(rect.height))}px`
1115
+ clone.style.margin = '0'
1116
+ // 外层容器 box-shadow(底座/扩散投影)溢出边框盒,克隆截图需按外扩 clip 保留,
1117
+ // 与面板路径一致;否则按钮底部投影在编译产物里被裁掉(dev 实时渲染不受影响)。
1118
+ const cloneBoxShadow = getComputedStyle(node as HTMLElement).boxShadow
1119
+ clone.style.boxShadow = cloneBoxShadow
1120
+ clone.querySelectorAll<HTMLElement>('[data-andesite-type]').forEach(controlNode => {
1121
+ controlNode.style.opacity = '0'
1122
+ })
1123
+ clone.querySelectorAll<HTMLElement>('[data-andesite-type="label"], [data-andesite-preview]').forEach(previewNode => {
1124
+ previewNode.style.visibility = 'hidden'
1125
+ previewNode.style.opacity = '0'
1126
+ })
1127
+ wrapper.appendChild(clone)
1128
+ body.appendChild(wrapper)
1129
+ if (selfHidden) {
1130
+ (node as HTMLElement).style.display = 'none'
1131
+ }
1132
+ return clone
1133
+ })
1134
+ const cloneElement = cloneHandle.asElement() as ElementHandle<HTMLElement> | null
1135
+ try {
1136
+ if (!cloneElement) {
1137
+ return new Uint8Array()
1138
+ }
1139
+ // 外层容器 box-shadow(底座/扩散投影)溢出边框盒,克隆元素自身 screenshot 会裁掉。
1140
+ // 量出四向外溢后给克隆留出 margin,再用 page.screenshot 按外扩 clip 截取,保留投影。
1141
+ const overflow = await measureShadowOverflow(cloneElement)
1142
+ if (overflow.left > 0 || overflow.top > 0 || overflow.right > 0 || overflow.bottom > 0) {
1143
+ await cloneElement.evaluate((node: Element, pad: { left: number; top: number }) => {
1144
+ const el = node as HTMLElement
1145
+ el.style.marginLeft = `${pad.left}px`
1146
+ el.style.marginTop = `${pad.top}px`
1147
+ }, { left: overflow.left, top: overflow.top })
1148
+ const box = await cloneElement.boundingBox()
1149
+ if (!box) {
1150
+ return new Uint8Array()
1151
+ }
1152
+ return await page.screenshot({
1153
+ type: 'png',
1154
+ omitBackground: true,
1155
+ clip: {
1156
+ x: Math.max(0, box.x - overflow.left),
1157
+ y: Math.max(0, box.y - overflow.top),
1158
+ width: box.width + overflow.left + overflow.right,
1159
+ height: box.height + overflow.top + overflow.bottom,
1160
+ },
1161
+ })
1162
+ }
1163
+ return await cloneElement.screenshot({ type: 'png', omitBackground: true })
1164
+ } finally {
1165
+ await page.evaluate(() => {
1166
+ document.querySelectorAll<HTMLElement>('[data-andesite-button-capture-overlay]').forEach(node => {
1167
+ node.remove()
1168
+ })
1169
+ const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
1170
+ if (root) {
1171
+ root.style.visibility = root.dataset.andesiteButtonCaptureVisibility ?? ''
1172
+ delete root.dataset.andesiteButtonCaptureVisibility
1173
+ root.style.opacity = root.dataset.andesiteButtonCaptureOpacity ?? ''
1174
+ delete root.dataset.andesiteButtonCaptureOpacity
1175
+ }
1176
+ const html = document.documentElement
1177
+ const body = document.body
1178
+ html.style.background = html.dataset.andesiteButtonCaptureBackground ?? ''
1179
+ html.style.backgroundColor = html.dataset.andesiteButtonCaptureBackgroundColor ?? ''
1180
+ body.style.background = body.dataset.andesiteButtonCaptureBackground ?? ''
1181
+ body.style.backgroundColor = body.dataset.andesiteButtonCaptureBackgroundColor ?? ''
1182
+ delete html.dataset.andesiteButtonCaptureBackground
1183
+ delete html.dataset.andesiteButtonCaptureBackgroundColor
1184
+ delete body.dataset.andesiteButtonCaptureBackground
1185
+ delete body.dataset.andesiteButtonCaptureBackgroundColor
1186
+ })
1187
+ await cloneHandle.dispose()
1188
+ }
1189
+ }
1190
+
1191
+ // 仅改变编译专用页面:沿目标祖先逐层选中视图,兄弟仍 display:none,保持真实布局而非把全部视图摊开。
1192
+ async function selectCaptureView(target: Awaited<ReturnType<Page['$']>>): Promise<void> {
1193
+ if (!target) return
1194
+ await target.evaluate(node => {
1195
+ const selected = new Map<Element, Element>()
1196
+ let view = node.closest('[data-andesite-view]')
1197
+ while (view) {
1198
+ selected.set(view.parentElement!, view)
1199
+ view = view.parentElement?.closest('[data-andesite-view]') ?? null
1200
+ }
1201
+ for (const stack of document.querySelectorAll('[data-andesite-viewstack]')) {
1202
+ for (const child of stack.querySelectorAll<HTMLElement>(':scope > [data-andesite-view]')) {
1203
+ const active = selected.has(stack) ? selected.get(stack) === child : child.dataset.andesiteViewName === stack.getAttribute('data-andesite-default-view')
1204
+ child.style.display = active ? child.dataset.andesiteViewDisplay ?? '' : 'none'
1205
+ }
1206
+ }
1207
+ })
1208
+ }
1209
+
1210
+ async function measureAndesiteBox(target: Awaited<ReturnType<Page['$']>>): Promise<DomBox> {
1211
+ if (!target) {
1212
+ return { x: 0, y: 0, width: 0, height: 0 }
1213
+ }
1214
+ await selectCaptureView(target)
1215
+ return await target.evaluate((node: Element) => {
1216
+ const canvas = document.querySelector<HTMLElement>('[data-andesite-canvas]')
1217
+ const isHud = canvas?.getAttribute('data-andesite-page-kind') === 'hud'
1218
+ const root = isHud
1219
+ ? (node.closest('[data-andesite-hud-mount]') as HTMLElement | null) ?? document.querySelector<HTMLElement>('[data-andesite-content-root]')
1220
+ : document.querySelector<HTMLElement>('[data-andesite-content-root]')
1221
+ if (!root) {
1222
+ return { x: 0, y: 0, width: 0, height: 0 }
1223
+ }
1224
+ // 声明 defaultVisible=false 的控件以 display:none 默认隐藏,getBoundingClientRect 全为 0。
1225
+ // 只恢复自身 display 不够——兄弟隐藏控件仍 display:none 会改变网格/flex 轨道分配,
1226
+ // 父级 display:none 则自身仍无布局。须把页面上所有此类隐藏控件一并临时恢复,按真实排版测量后统一还原。
1227
+ const hiddenSiblings = Array.from(
1228
+ (root as HTMLElement).querySelectorAll<HTMLElement>('[data-andesite-default-visible="false"]'),
1229
+ ).filter(n => n.style.display === 'none')
1230
+ hiddenSiblings.forEach(n => { n.style.display = '' })
1231
+ const rootRect = root.getBoundingClientRect()
1232
+ const rect = (node as HTMLElement).getBoundingClientRect()
1233
+ hiddenSiblings.forEach(n => { n.style.display = 'none' })
1234
+ return {
1235
+ x: Math.round(rect.left - rootRect.left),
1236
+ y: Math.round(rect.top - rootRect.top),
1237
+ width: Math.round(rect.width),
1238
+ height: Math.round(rect.height),
1239
+ }
1240
+ })
1241
+ }
1242
+
1243
+ // progress 被圆角容器包裹时取容器包围盒(与 renderProgressPng 的胶囊外形同一参照),否则取自身。
1244
+ async function measureProgressCapsuleBox(target: Awaited<ReturnType<Page['$']>>): Promise<DomBox> {
1245
+ if (!target) {
1246
+ return { x: 0, y: 0, width: 0, height: 0 }
1247
+ }
1248
+ return await target.evaluate((node: Element) => {
1249
+ const canvas = document.querySelector<HTMLElement>('[data-andesite-canvas]')
1250
+ const isHud = canvas?.getAttribute('data-andesite-page-kind') === 'hud'
1251
+ const root = isHud
1252
+ ? (node.closest('[data-andesite-hud-mount]') as HTMLElement | null) ?? document.querySelector<HTMLElement>('[data-andesite-content-root]')
1253
+ : document.querySelector<HTMLElement>('[data-andesite-content-root]')
1254
+ if (!root) {
1255
+ return { x: 0, y: 0, width: 0, height: 0 }
1256
+ }
1257
+ let capsule: HTMLElement = node as HTMLElement
1258
+ let current: HTMLElement | null = (node as HTMLElement).parentElement
1259
+ while (current && current !== document.body) {
1260
+ const style = getComputedStyle(current)
1261
+ const radius = Number.parseFloat(style.borderTopLeftRadius || '0')
1262
+ const clipped = style.overflow === 'hidden' || style.overflowX === 'hidden' || style.overflowY === 'hidden'
1263
+ if (clipped && Number.isFinite(radius) && radius > 0) {
1264
+ capsule = current
1265
+ break
1266
+ }
1267
+ current = current.parentElement
1268
+ }
1269
+ const rootRect = root.getBoundingClientRect()
1270
+ const rect = capsule.getBoundingClientRect()
1271
+ return {
1272
+ x: Math.round(rect.left - rootRect.left),
1273
+ y: Math.round(rect.top - rootRect.top),
1274
+ width: Math.round(rect.width),
1275
+ height: Math.round(rect.height),
1276
+ }
1277
+ })
1278
+ }
1279
+
1280
+ // 元素边框盒不含 box-shadow 外溢;截图与导出尺寸需按视觉包围盒外扩,否则面板底部投影被裁掉。
1281
+ interface VisualOverflow { left: number; top: number; right: number; bottom: number }
1282
+
1283
+ async function measureShadowOverflow(target: Awaited<ReturnType<Page['$']>>, blurScale = 1): Promise<VisualOverflow> {
1284
+ if (!target) {
1285
+ return { left: 0, top: 0, right: 0, bottom: 0 }
1286
+ }
1287
+ return await target.evaluate((node: Element, blurScale: number) => {
1288
+ // 自身 overflow:hidden 只裁剪内容,不裁自身 box-shadow;祖先裁剪交由真实截图和 alpha 测量处理。
1289
+ const shadow = getComputedStyle(node as HTMLElement).boxShadow
1290
+ const overflow = { left: 0, top: 0, right: 0, bottom: 0 }
1291
+ if (!shadow || shadow === 'none') {
1292
+ return overflow
1293
+ }
1294
+ // 按逗号拆分层阴影,但 rgba() 内含逗号,需跳过括号内的逗号。
1295
+ const layers: string[] = []
1296
+ let depth = 0
1297
+ let current = ''
1298
+ for (const ch of shadow) {
1299
+ if (ch === '(') depth++
1300
+ if (ch === ')') depth--
1301
+ if (ch === ',' && depth === 0) { layers.push(current); current = ''; continue }
1302
+ current += ch
1303
+ }
1304
+ layers.push(current)
1305
+ for (const layer of layers) {
1306
+ if (/\binset\b/.test(layer)) continue // 内阴影不外溢
1307
+ const nums = layer.match(/-?\d+(?:\.\d+)?px/g)?.map(v => parseFloat(v)) ?? []
1308
+ const [offsetX = 0, offsetY = 0, blur = 0, spread = 0] = nums
1309
+ const reach = blur * blurScale + Math.max(0, spread)
1310
+ overflow.left = Math.max(overflow.left, -offsetX + reach - Math.min(0, spread))
1311
+ overflow.top = Math.max(overflow.top, -offsetY + reach - Math.min(0, spread))
1312
+ overflow.right = Math.max(overflow.right, offsetX + reach - Math.min(0, spread))
1313
+ overflow.bottom = Math.max(overflow.bottom, offsetY + reach - Math.min(0, spread))
1314
+ }
1315
+ return {
1316
+ left: Math.max(0, Math.ceil(overflow.left)),
1317
+ top: Math.max(0, Math.ceil(overflow.top)),
1318
+ right: Math.max(0, Math.ceil(overflow.right)),
1319
+ bottom: Math.max(0, Math.ceil(overflow.bottom)),
1320
+ }
1321
+ }, blurScale)
1322
+ }
1323
+
1324
+ // measureShadowOverflow 按 box-shadow 声明算理论外溢,但不知道元素自身 overflow:hidden 会把投影裁进
1325
+ // 边框盒(如 wish_panel 自身 rounded + overflow-hidden,投影根本渲染不出去)。结果理论外溢虚高,
1326
+ // clip 外扩出一块实际没有任何像素的纯透明区,导入器又按虚高 overflow 外扩控件框 → 本体在框内占比变小、
1327
+ // 游戏里显得图小。这里截图后扫真实 alpha 包围盒,把边框盒之外的纯透明裁掉,并用真实包围盒反推 overflow。
1328
+ // 更正上述裁剪原因:元素自身 overflow:hidden 不会裁掉自身投影;祖先可以裁剪投影,负 clip 则会造成取样坐标脱节。
1329
+ // 裁剪框 = alpha 包围盒 ∪ 边框盒(边框盒必须完整保留,圆角/透明控件也不例外),只删纯透明(alpha=0)。
1330
+ async function trimShadowOverflow(
1331
+ page: Page,
1332
+ png: Uint8Array,
1333
+ clip: DomBox,
1334
+ borderBoxInClip: DomBox,
1335
+ scale: number,
1336
+ ): Promise<{ png: Uint8Array; overflow: VisualOverflow }> {
1337
+ const geometry = [
1338
+ clip.x, clip.y, clip.width, clip.height,
1339
+ borderBoxInClip.x, borderBoxInClip.y, borderBoxInClip.width, borderBoxInClip.height,
1340
+ scale,
1341
+ ]
1342
+ if (!geometry.every(Number.isFinite) || scale <= 0 || clip.x < 0 || clip.y < 0 || clip.width <= 0 || clip.height <= 0 || borderBoxInClip.width <= 0 || borderBoxInClip.height <= 0) {
1343
+ throw new Error('Invalid shadow capture geometry')
1344
+ }
1345
+ // Puppeteer 会对 clip 做 CSS 像素舍入,调用方须先固定最终整数截图矩形。
1346
+ if (![clip.x, clip.y, clip.width, clip.height].every(Number.isInteger)) {
1347
+ throw new Error('Shadow capture clip must use integer CSS coordinates')
1348
+ }
1349
+
1350
+ const result = await page.evaluate(async (input) => {
1351
+ const image = new Image()
1352
+ const loaded = new Promise<void>((resolve, reject) => {
1353
+ image.onload = () => resolve()
1354
+ image.onerror = () => reject(new Error('Failed to decode shadow capture'))
1355
+ })
1356
+ image.src = `data:image/png;base64,${input.base64}`
1357
+ await loaded
1358
+
1359
+ const width = image.naturalWidth
1360
+ const height = image.naturalHeight
1361
+ // 不从错误的 PNG 尺寸反推 scale,否则把截图异常掩盖成合法缩放。
1362
+ if (width !== Math.round(input.clip.width * input.scale) || height !== Math.round(input.clip.height * input.scale)) {
1363
+ throw new Error(`Shadow capture PNG ${width}x${height} does not match clip ${input.clip.width}x${input.clip.height} @${input.scale}`)
1364
+ }
1365
+
1366
+ const box = input.borderBoxInClip
1367
+ const bx0 = box.x * input.scale
1368
+ const by0 = box.y * input.scale
1369
+ const bx1 = (box.x + box.width) * input.scale
1370
+ const by1 = (box.y + box.height) * input.scale
1371
+ if (bx0 < 0 || by0 < 0 || bx1 > width || by1 > height) {
1372
+ throw new Error('Border box is outside shadow capture')
1373
+ }
1374
+
1375
+ const canvas = document.createElement('canvas')
1376
+ canvas.width = width
1377
+ canvas.height = height
1378
+ const ctx = canvas.getContext('2d', { willReadFrequently: true })
1379
+ if (!ctx) {
1380
+ throw new Error('Failed to create shadow scan context')
1381
+ }
1382
+ ctx.drawImage(image, 0, 0)
1383
+ const pixels = ctx.getImageData(0, 0, width, height).data
1384
+
1385
+ // 从完整边框盒向外扩展(右/下用 exclusive 边界),透明控件也保留其布局尺寸。
1386
+ let left = Math.floor(bx0)
1387
+ let top = Math.floor(by0)
1388
+ let right = Math.ceil(bx1)
1389
+ let bottom = Math.ceil(by1)
1390
+ for (let y = 0, alphaIndex = 3; y < height; y++) {
1391
+ for (let x = 0; x < width; x++, alphaIndex += 4) {
1392
+ // 只删纯透明,保留所有低 alpha 阴影像素。
1393
+ if (pixels[alphaIndex] === 0) {
1394
+ continue
1395
+ }
1396
+ if (x < left) left = x
1397
+ if (y < top) top = y
1398
+ if (x + 1 > right) right = x + 1
1399
+ if (y + 1 > bottom) bottom = y + 1
1400
+ }
1401
+ }
1402
+
1403
+ // overflow = 边框盒各边到裁剪框各边的距离(CSS px),天然非负,与裁剪后贴图尺寸自洽。
1404
+ const overflow = {
1405
+ left: (bx0 - left) / input.scale,
1406
+ top: (by0 - top) / input.scale,
1407
+ right: (right - bx1) / input.scale,
1408
+ bottom: (bottom - by1) / input.scale,
1409
+ }
1410
+
1411
+ if (left === 0 && top === 0 && right === width && bottom === height) {
1412
+ return { base64: null, overflow }
1413
+ }
1414
+
1415
+ const cropped = document.createElement('canvas')
1416
+ cropped.width = right - left
1417
+ cropped.height = bottom - top
1418
+ const croppedCtx = cropped.getContext('2d')
1419
+ if (!croppedCtx) {
1420
+ throw new Error('Failed to create shadow crop context')
1421
+ }
1422
+ croppedCtx.imageSmoothingEnabled = false
1423
+ croppedCtx.drawImage(image, left, top, cropped.width, cropped.height, 0, 0, cropped.width, cropped.height)
1424
+ const dataUrl = cropped.toDataURL('image/png')
1425
+ return { base64: dataUrl.substring(dataUrl.indexOf(',') + 1), overflow }
1426
+ }, {
1427
+ base64: Buffer.from(png).toString('base64'),
1428
+ clip,
1429
+ borderBoxInClip,
1430
+ scale,
1431
+ })
1432
+
1433
+ return {
1434
+ png: result.base64 === null ? png : Buffer.from(result.base64, 'base64'),
1435
+ overflow: result.overflow,
1436
+ }
1437
+ }
1438
+
1439
+ async function collectHudMountId(target: Awaited<ReturnType<Page['$']>>): Promise<string | undefined> {
1440
+ if (!target) {
1441
+ return undefined
1442
+ }
1443
+ return await target.evaluate((node: Element) => {
1444
+ const canvas = document.querySelector<HTMLElement>('[data-andesite-canvas]')
1445
+ if (canvas?.getAttribute('data-andesite-page-kind') !== 'hud') {
1446
+ return undefined
1447
+ }
1448
+
1449
+ const mount = node.closest('[data-andesite-hud-mount]') as HTMLElement | null
1450
+ const mountId = mount?.getAttribute('data-andesite-id') ?? ''
1451
+ return mountId === '' ? undefined : mountId
1452
+ })
1453
+ }
1454
+
1455
+ async function isCollectableAndesiteElement(target: Awaited<ReturnType<Page['$']>>): Promise<boolean> {
1456
+ if (!target) {
1457
+ return false
1458
+ }
1459
+ const box = await measureAndesiteBox(target)
1460
+ if (box.width <= 0 || box.height <= 0) return false
1461
+ return await target.evaluate((node: Element) => {
1462
+ // 视图层级由 viewStacks 单独导出,采集时祖先视图已经切到当前目标。
1463
+ if (node.closest('[data-andesite-viewstack]') && !node.closest('[data-andesite-view]')) {
1464
+ return false
1465
+ }
1466
+
1467
+ // AButton 会为默认/按下态复制 children;隐藏克隆层里的 A* 控件只用于按钮贴图导出,不应重复注册为运行时控件。
1468
+ let current: HTMLElement | null = node as HTMLElement
1469
+ while (current) {
1470
+ const style = getComputedStyle(current)
1471
+ if (current.getAttribute('data-andesite-preview') === 'button-size' || current.hasAttribute('data-andesite-button-export')) {
1472
+ return false
1473
+ }
1474
+ if (style.display === 'none' || style.visibility === 'hidden' || style.visibility === 'collapse') {
1475
+ // 声明了 data-andesite-default-visible="false" 的控件,其 hidden 是"默认隐藏待运行时切换",
1476
+ // 不是要剔除——照常导出(并在结果里记 defaultVisible=false,由运行时控制初始显隐)。
1477
+ // 未声明该标记的 hidden 控件维持旧行为:视为真隐藏,跳过。
1478
+ if (current.getAttribute('data-andesite-default-visible') !== 'false') {
1479
+ return false
1480
+ }
1481
+ }
1482
+ if (current.hasAttribute('data-andesite-content-root')) {
1483
+ break
1484
+ }
1485
+ current = current.parentElement
1486
+ }
1487
+ return true
1488
+ })
1489
+ }
1490
+
1491
+ // 读取控件声明的默认可见性。仅当显式标记 data-andesite-default-visible="false" 时返回 false(默认隐藏,
1492
+ // 待运行时切换);未标记或标记为 true 都视为默认可见,返回 undefined 以保持 andesite.json 简洁、向后兼容。
1493
+ async function readDefaultVisible(target: Awaited<ReturnType<Page['$']>>): Promise<boolean | undefined> {
1494
+ if (!target) {
1495
+ return undefined
1496
+ }
1497
+ const value = await target.evaluate((node: Element) => node.getAttribute('data-andesite-default-visible'))
1498
+ return value === 'false' ? false : undefined
1499
+ }
1500
+
1501
+ async function withIsolatedCapture<T>(page: Page, target: Awaited<ReturnType<Page['$']>>, capture: () => Promise<T>, hideTargetLabels = true): Promise<T> {
1502
+ if (!target) {
1503
+ return await capture()
1504
+ }
1505
+ await page.evaluate((targetNode: Element, hideLabels: boolean) => {
1506
+ const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
1507
+ const nodes = Array.from((root ?? document).querySelectorAll<HTMLElement>('*'))
1508
+ nodes.forEach(node => {
1509
+ node.dataset.andesiteCaptureVisibility = node.style.visibility
1510
+ node.dataset.andesiteCaptureOpacity = node.style.opacity
1511
+ node.dataset.andesiteCaptureDisplay = node.style.display
1512
+ node.style.visibility = 'hidden'
1513
+ })
1514
+
1515
+ // Puppeteer 截图的是屏幕合成结果,必须只显示当前目标,避免普通 sibling overlay 被烘进 PNG。
1516
+ // 目标若声明 defaultVisible=false,其初始 display 是 none(默认隐藏),须临时恢复为可见才能截到内容,
1517
+ // 截完由下方恢复逻辑还原。祖先链恢复其原始值即可(目标自身随后单独强制)。
1518
+ let current: HTMLElement | null = targetNode as HTMLElement
1519
+ while (current) {
1520
+ current.style.visibility = current.dataset.andesiteCaptureVisibility ?? ''
1521
+ current = current.parentElement
1522
+ }
1523
+ if (targetNode.getAttribute('data-andesite-default-visible') === 'false') {
1524
+ (targetNode as HTMLElement).style.visibility = 'visible'
1525
+ // display:none 时 visibility 不生效。目标自身恢复 display 之外,其 defaultVisible=false 的祖先
1526
+ // (如整组隐藏的卡片,按钮嵌在其内)也必须一并恢复,否则目标仍无布局、截不到内容。
1527
+ ;(targetNode as HTMLElement).style.display = ''
1528
+ let ancestor = (targetNode as HTMLElement).parentElement
1529
+ while (ancestor && ancestor !== document.documentElement) {
1530
+ if (ancestor.getAttribute('data-andesite-default-visible') === 'false') {
1531
+ ancestor.style.display = ''
1532
+ }
1533
+ ancestor = ancestor.parentElement
1534
+ }
1535
+ }
1536
+ targetNode.querySelectorAll<HTMLElement>('*').forEach(node => {
1537
+ if (node === targetNode || !node.matches('[data-andesite-type]')) {
1538
+ node.style.visibility = node.dataset.andesiteCaptureVisibility ?? ''
1539
+ }
1540
+ })
1541
+ // 按钮贴图只烘普通 DOM 装饰;嵌套的 A* 控件独立导出,必须整棵透明,避免 ASprite 子状态 visibility 穿透。
1542
+ targetNode.querySelectorAll<HTMLElement>('[data-andesite-type]').forEach(node => {
1543
+ node.style.opacity = '0'
1544
+ })
1545
+ // 控件贴图只截自身像素;祖先容器(HUD mount、content-root、预览舞台 #1a1a1a、body 等)的背景
1546
+ // 不能烘进 PNG,否则面板圆角/半透明边缘会被填上舞台底色(黑)。黑色舞台在 content-root 之上,
1547
+ // 必须一路清到 body,不能在 content-root 停下。此前仅 sprite 清理且止于 content-root,现统一。
1548
+ {
1549
+ let current = targetNode.parentElement
1550
+ while (current && current !== document.documentElement) {
1551
+ current.dataset.andesiteCaptureBackground = current.style.background
1552
+ current.dataset.andesiteCaptureBackgroundColor = current.style.backgroundColor
1553
+ current.dataset.andesiteCaptureBorderColor = current.style.borderColor
1554
+ current.dataset.andesiteCaptureBoxShadow = current.style.boxShadow
1555
+ current.dataset.andesiteCaptureOutline = current.style.outline
1556
+ current.style.background = 'transparent'
1557
+ current.style.backgroundColor = 'transparent'
1558
+ current.style.borderColor = 'transparent'
1559
+ current.style.boxShadow = 'none'
1560
+ current.style.outline = 'none'
1561
+ current = current.parentElement
1562
+ }
1563
+ }
1564
+ if (hideLabels) {
1565
+ targetNode.querySelectorAll<HTMLElement>('[data-andesite-type="label"], [data-andesite-preview]').forEach(node => {
1566
+ node.style.visibility = 'hidden'
1567
+ node.style.opacity = '0'
1568
+ })
1569
+ }
1570
+ }, target, hideTargetLabels)
1571
+ try {
1572
+ return await capture()
1573
+ } finally {
1574
+ await page.evaluate(() => {
1575
+ document.querySelectorAll<HTMLElement>('[data-andesite-capture-visibility]').forEach(node => {
1576
+ node.style.visibility = node.dataset.andesiteCaptureVisibility ?? ''
1577
+ node.style.opacity = node.dataset.andesiteCaptureOpacity ?? ''
1578
+ if (node.dataset.andesiteCaptureDisplay != null) {
1579
+ node.style.display = node.dataset.andesiteCaptureDisplay
1580
+ delete node.dataset.andesiteCaptureDisplay
1581
+ }
1582
+ if (node.dataset.andesiteCaptureBackground != null) {
1583
+ node.style.background = node.dataset.andesiteCaptureBackground
1584
+ node.style.backgroundColor = node.dataset.andesiteCaptureBackgroundColor ?? ''
1585
+ node.style.borderColor = node.dataset.andesiteCaptureBorderColor ?? ''
1586
+ node.style.boxShadow = node.dataset.andesiteCaptureBoxShadow ?? ''
1587
+ node.style.outline = node.dataset.andesiteCaptureOutline ?? ''
1588
+ delete node.dataset.andesiteCaptureBackground
1589
+ delete node.dataset.andesiteCaptureBackgroundColor
1590
+ delete node.dataset.andesiteCaptureBorderColor
1591
+ delete node.dataset.andesiteCaptureBoxShadow
1592
+ delete node.dataset.andesiteCaptureOutline
1593
+ }
1594
+ delete node.dataset.andesiteCaptureVisibility
1595
+ delete node.dataset.andesiteCaptureOpacity
1596
+ })
1597
+ })
1598
+ }
1599
+ }
1600
+
1601
+ async function withUnclippedScrollCapture<T>(page: Page, target: Awaited<ReturnType<Page['$']>>, capture: () => Promise<T>): Promise<T> {
1602
+ if (!target) {
1603
+ return await capture()
1604
+ }
1605
+ await page.evaluate((targetNode: Element) => {
1606
+ const scrollNode = targetNode.closest<HTMLElement>('[data-andesite-type="scroll-view"]')
1607
+ if (!scrollNode) {
1608
+ return
1609
+ }
1610
+ const contentNode = scrollNode.querySelector<HTMLElement>('[data-andesite-scroll-content]')
1611
+ const nodes = [scrollNode, contentNode]
1612
+ nodes.forEach(node => {
1613
+ if (!node) {
1614
+ return
1615
+ }
1616
+ node.dataset.andesiteScrollCaptureOverflow = node.style.overflow
1617
+ node.dataset.andesiteScrollCaptureOverflowX = node.style.overflowX
1618
+ node.dataset.andesiteScrollCaptureOverflowY = node.style.overflowY
1619
+ node.style.overflow = 'visible'
1620
+ node.style.overflowX = 'visible'
1621
+ node.style.overflowY = 'visible'
1622
+ })
1623
+ }, target)
1624
+ try {
1625
+ return await capture()
1626
+ } finally {
1627
+ await page.evaluate(() => {
1628
+ document.querySelectorAll<HTMLElement>('[data-andesite-scroll-capture-overflow]').forEach(node => {
1629
+ node.style.overflow = node.dataset.andesiteScrollCaptureOverflow ?? ''
1630
+ node.style.overflowX = node.dataset.andesiteScrollCaptureOverflowX ?? ''
1631
+ node.style.overflowY = node.dataset.andesiteScrollCaptureOverflowY ?? ''
1632
+ delete node.dataset.andesiteScrollCaptureOverflow
1633
+ delete node.dataset.andesiteScrollCaptureOverflowX
1634
+ delete node.dataset.andesiteScrollCaptureOverflowY
1635
+ })
1636
+ })
1637
+ }
1638
+ }
1639
+
1640
+ async function withHiddenAndesiteControls<T>(page: Page, capture: () => Promise<T>, target?: Awaited<ReturnType<Page['$']>>): Promise<T> {
1641
+ await page.evaluate((targetNode: Element | null) => {
1642
+ const root = (targetNode as HTMLElement | null) ?? document.querySelector<HTMLElement>('[data-andesite-content-root]')
1643
+ if (!root) {
1644
+ return
1645
+ }
1646
+
1647
+ const rootRect = root.getBoundingClientRect()
1648
+ const carrier = targetNode == null ? (root.firstElementChild as HTMLElement | null) ?? root : root
1649
+ const carrierRect = carrier.getBoundingClientRect()
1650
+ const overlay = carrier.cloneNode(true) as HTMLElement
1651
+ overlay.dataset.andesiteStaticCaptureOverlay = 'true'
1652
+ overlay.style.position = 'fixed'
1653
+ overlay.style.left = `${carrierRect.left}px`
1654
+ overlay.style.top = `${carrierRect.top}px`
1655
+ overlay.style.width = `${carrierRect.width}px`
1656
+ overlay.style.height = `${carrierRect.height}px`
1657
+ overlay.style.margin = '0'
1658
+ overlay.style.zIndex = '2147483647'
1659
+ overlay.style.pointerEvents = 'none'
1660
+ overlay.style.visibility = 'visible'
1661
+ const hiddenControls = [
1662
+ ...(overlay.matches('[data-andesite-type], [data-andesite-preview]') ? [overlay] : []),
1663
+ ...Array.from(overlay.querySelectorAll<HTMLElement>('[data-andesite-type], [data-andesite-preview]')),
1664
+ ]
1665
+ hiddenControls.forEach(node => {
1666
+ // 静态背景要保留布局占位;opacity 会隐藏整棵子树,避免子节点 visibility 覆盖导致按钮被烘进背景。
1667
+ node.style.opacity = '0'
1668
+ })
1669
+ root.dataset.andesiteCaptureVisibility = root.style.visibility
1670
+ root.dataset.andesiteStaticCapture = 'true'
1671
+ root.style.visibility = 'hidden'
1672
+
1673
+ // 静态背景需要保留普通 DOM 装饰,但不能把按钮、Label、Panel 等独立控件重复烘进背景图。
1674
+ if (carrierRect.left !== rootRect.left || carrierRect.top !== rootRect.top) {
1675
+ const wrapper = document.createElement('div')
1676
+ wrapper.dataset.andesiteStaticCaptureOverlay = 'true'
1677
+ wrapper.style.position = 'fixed'
1678
+ wrapper.style.left = `${rootRect.left}px`
1679
+ wrapper.style.top = `${rootRect.top}px`
1680
+ wrapper.style.width = `${rootRect.width}px`
1681
+ wrapper.style.height = `${rootRect.height}px`
1682
+ wrapper.style.margin = '0'
1683
+ wrapper.style.zIndex = '2147483647'
1684
+ wrapper.style.pointerEvents = 'none'
1685
+ wrapper.appendChild(overlay)
1686
+ document.body.appendChild(wrapper)
1687
+ } else {
1688
+ document.body.appendChild(overlay)
1689
+ }
1690
+ }, target ?? null)
1691
+ // 只合成静态副本;omitBackground 不会移除预览舞台和 body 的 CSS 底色。
1692
+ // 预览舞台 .andesite-preview-stage 带 #1a1a1a 底色与背景图,opacity:0 不阻止其背景被合成,
1693
+ // 必须显式把背景清成 transparent,否则 HUD mount 背景图会带上一层深灰底。
1694
+ const captureStyle = await page.addStyleTag({ content: `
1695
+ html, body, .andesite-preview-stage, .andesite-preview-canvas-wrapper { background: transparent !important; background-image: none !important; }
1696
+ body > :not([data-andesite-static-capture-overlay]) { opacity: 0 !important; }
1697
+ ` })
1698
+ try {
1699
+ return await capture()
1700
+ } finally {
1701
+ await captureStyle.evaluate(node => node.remove())
1702
+ await page.evaluate(() => {
1703
+ document.querySelectorAll<HTMLElement>('[data-andesite-capture-visibility]').forEach(node => {
1704
+ node.style.visibility = node.dataset.andesiteCaptureVisibility ?? ''
1705
+ delete node.dataset.andesiteCaptureVisibility
1706
+ })
1707
+ document.querySelectorAll<HTMLElement>('[data-andesite-static-capture]').forEach(node => {
1708
+ delete node.dataset.andesiteStaticCapture
1709
+ })
1710
+ document.querySelectorAll<HTMLElement>('[data-andesite-static-capture-overlay]').forEach(node => {
1711
+ node.remove()
1712
+ })
1713
+ })
1714
+ }
1715
+ }
1716
+
1717
+ async function setButtonCaptureState(el: Awaited<ReturnType<Page['$']>>, state: 'default' | 'pressed', showPreview: boolean): Promise<void> {
1718
+ if (!el) {
1719
+ return
1720
+ }
1721
+ await el.evaluate((node: Element, targetState: 'default' | 'pressed', shouldShowPreview: boolean) => {
1722
+ const defaultNode = node.querySelector<HTMLElement>('[data-andesite-state="default"]')
1723
+ const pressedNode = node.querySelector<HTMLElement>('[data-andesite-state="pressed"]')
1724
+ const contentNode = node.querySelector<HTMLElement>('[data-andesite-preview="button-content"]')
1725
+ const previewNode = node.querySelector<HTMLElement>('[data-andesite-preview="button-labels"]')
1726
+ if (defaultNode) {
1727
+ defaultNode.style.visibility = 'visible'
1728
+ defaultNode.style.opacity = targetState === 'default' ? '1' : '0'
1729
+ }
1730
+ if (pressedNode) {
1731
+ pressedNode.style.visibility = 'visible'
1732
+ pressedNode.style.opacity = targetState === 'pressed' ? '1' : '0'
1733
+ }
1734
+ if (contentNode) {
1735
+ contentNode.style.visibility = 'hidden'
1736
+ contentNode.style.opacity = '0'
1737
+ }
1738
+ if (previewNode) {
1739
+ previewNode.style.visibility = shouldShowPreview ? 'visible' : 'hidden'
1740
+ }
1741
+ }, state, showPreview)
1742
+ }
1743
+
1744
+ async function resetButtonPreviewState(el: Awaited<ReturnType<Page['$']>>): Promise<void> {
1745
+ if (!el) {
1746
+ return
1747
+ }
1748
+ await el.evaluate((node: Element) => {
1749
+ const defaultNode = node.querySelector<HTMLElement>('[data-andesite-state="default"]')
1750
+ const pressedNode = node.querySelector<HTMLElement>('[data-andesite-state="pressed"]')
1751
+ const contentNode = node.querySelector<HTMLElement>('[data-andesite-preview="button-content"]')
1752
+ if (defaultNode) {
1753
+ defaultNode.style.visibility = 'visible'
1754
+ defaultNode.style.opacity = '0'
1755
+ }
1756
+ if (pressedNode) {
1757
+ pressedNode.style.visibility = 'visible'
1758
+ pressedNode.style.opacity = '0'
1759
+ }
1760
+ if (contentNode) {
1761
+ contentNode.style.visibility = 'visible'
1762
+ contentNode.style.opacity = '1'
1763
+ }
1764
+ })
1765
+ }
1766
+
1767
+ async function rasterizeProgresses(page: Page, outputDir: string): Promise<ProgressDecl[]> {
1768
+ const elements = await page.$$('[data-andesite-type="progress"]')
1769
+ const results: ProgressDecl[] = []
1770
+ for (const el of elements) {
1771
+ if (!await isCollectableAndesiteElement(el)) {
1772
+ continue
1773
+ }
1774
+ const id = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
1775
+ const mountId = await collectHudMountId(el)
1776
+ // 圆角容器包裹 progress 时,导出包围盒取容器(与 renderProgressPng 的胶囊外形一致),
1777
+ // 否则取 progress 自身;保证贴图尺寸、坐标与胶囊外形对齐。
1778
+ const box = await measureProgressCapsuleBox(el)
1779
+ const value = Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-value') ?? '0'))
1780
+ const max = Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-max') ?? '100'))
1781
+ const steps = Math.max(1, Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-steps') ?? `${DEFAULT_PROGRESS_STEPS}`)))
1782
+ const currentPercent = max <= 0 ? 0 : Math.max(0, Math.min(100, value / max * 100))
1783
+ const defaultIndex = Math.round(currentPercent / 100 * steps)
1784
+ const states: ProgressDecl['states'] = []
1785
+ const frames: Uint8Array[] = []
1786
+ const texturePath = `textures/${id}.png`
1787
+ for (let i = 0; i <= steps; i++) {
1788
+ const percent = Math.round(i * 1000 / steps) / 10
1789
+ const percentId = progressPercentId(percent)
1790
+ const screenshot = await renderProgressPng(el, percent)
1791
+ frames.push(screenshot)
1792
+ states.push({
1793
+ id: `p_${percentId}`,
1794
+ texture: texturePath,
1795
+ value: percent,
1796
+ defaultState: i === defaultIndex,
1797
+ })
1798
+ }
1799
+ const atlas = await combinePngsVertically(page, frames)
1800
+ const outputPath = path.join(outputDir, texturePath)
1801
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
1802
+ fs.writeFileSync(outputPath, atlas)
1803
+ results.push({ id, mountId, x: box.x, y: box.y, width: box.width, height: box.height, states })
1804
+ }
1805
+ return results
1806
+ }
1807
+
1808
+ async function rasterizeSprites(page: Page, outputDir: string): Promise<SpriteDecl[]> {
1809
+ const elements = await page.$$('[data-andesite-type="sprite"]')
1810
+ const results: SpriteDecl[] = []
1811
+ for (const el of elements) {
1812
+ if (!await isCollectableAndesiteElement(el)) {
1813
+ continue
1814
+ }
1815
+ const id = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
1816
+ const mountId = await collectHudMountId(el)
1817
+ const box = await measureAndesiteBox(el)
1818
+ const statesJson = await el.evaluate((node: Element) => node.getAttribute('data-andesite-states') ?? '[]')
1819
+ const stateDecls = JSON.parse(statesJson) as Array<{ id: string; defaultState?: boolean }>
1820
+ const texturePath = `textures/${id}.png`
1821
+ const frames: Uint8Array[] = []
1822
+ const states: SpriteDecl['states'] = []
1823
+ for (let i = 0; i < stateDecls.length; i++) {
1824
+ const state = stateDecls[i]
1825
+ const screenshot = await captureSpriteStatePng(page, el, state.id)
1826
+ frames.push(screenshot)
1827
+ states.push({
1828
+ id: state.id,
1829
+ texture: texturePath,
1830
+ defaultState: state.defaultState === true || (i === 0 && !stateDecls.some(item => item.defaultState === true)),
1831
+ })
1832
+ }
1833
+ if (frames.length === 0) {
1834
+ continue
1835
+ }
1836
+
1837
+ const atlas = await combinePngsVertically(page, frames)
1838
+ const outputPath = path.join(outputDir, texturePath)
1839
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
1840
+ fs.writeFileSync(outputPath, atlas)
1841
+ results.push({ id, mountId, x: box.x, y: box.y, width: box.width, height: box.height, states })
1842
+ }
1843
+ return results
1844
+ }
1845
+
1846
+ async function captureSpriteStatePng(page: Page, el: Awaited<ReturnType<Page['$']>>, stateId: string): Promise<Uint8Array> {
1847
+ if (!el) {
1848
+ return new Uint8Array()
1849
+ }
1850
+ const cloneHandle = await el.evaluateHandle((node: Element, targetStateId: string) => {
1851
+ const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
1852
+ const html = document.documentElement
1853
+ const body = document.body
1854
+ if (root) {
1855
+ root.dataset.andesiteSpriteCaptureVisibility = root.style.visibility
1856
+ root.style.visibility = 'hidden'
1857
+ // 与按钮克隆一致,阻止原页面显式可见的状态层污染透明帧。
1858
+ root.dataset.andesiteSpriteCaptureOpacity = root.style.opacity
1859
+ root.style.opacity = '0'
1860
+ }
1861
+ html.dataset.andesiteSpriteCaptureBackground = html.style.background
1862
+ html.dataset.andesiteSpriteCaptureBackgroundColor = html.style.backgroundColor
1863
+ body.dataset.andesiteSpriteCaptureBackground = body.style.background
1864
+ body.dataset.andesiteSpriteCaptureBackgroundColor = body.style.backgroundColor
1865
+ html.style.background = 'transparent'
1866
+ html.style.backgroundColor = 'transparent'
1867
+ body.style.background = 'transparent'
1868
+ body.style.backgroundColor = 'transparent'
1869
+
1870
+ const wrapper = document.createElement('div')
1871
+ wrapper.dataset.andesiteSpriteCaptureOverlay = 'true'
1872
+ wrapper.style.position = 'fixed'
1873
+ wrapper.style.left = '0'
1874
+ wrapper.style.top = '0'
1875
+ wrapper.style.margin = '0'
1876
+ wrapper.style.padding = '0'
1877
+ wrapper.style.background = 'transparent'
1878
+ wrapper.style.pointerEvents = 'none'
1879
+ wrapper.style.zIndex = '2147483647'
1880
+
1881
+ const rect = (node as HTMLElement).getBoundingClientRect()
1882
+ const clone = node.cloneNode(true) as HTMLElement
1883
+ // 状态层常用 absolute inset-0 继承父级尺寸;克隆到独立 wrapper 后必须固化尺寸,否则截图宽高会归零。
1884
+ clone.style.position = 'relative'
1885
+ clone.style.left = '0'
1886
+ clone.style.top = '0'
1887
+ clone.style.right = 'auto'
1888
+ clone.style.bottom = 'auto'
1889
+ clone.style.width = `${Math.max(1, Math.round(rect.width))}px`
1890
+ clone.style.height = `${Math.max(1, Math.round(rect.height))}px`
1891
+ clone.style.margin = '0'
1892
+ clone.querySelectorAll<HTMLElement>('[data-andesite-state]').forEach(stateNode => {
1893
+ stateNode.style.visibility = stateNode.getAttribute('data-andesite-state') === targetStateId ? 'visible' : 'hidden'
1894
+ })
1895
+ wrapper.appendChild(clone)
1896
+ body.appendChild(wrapper)
1897
+ return clone
1898
+ }, stateId)
1899
+ const cloneElement = cloneHandle.asElement()
1900
+ try {
1901
+ if (!cloneElement) {
1902
+ return new Uint8Array()
1903
+ }
1904
+ return await cloneElement.screenshot({ type: 'png', omitBackground: true })
1905
+ } finally {
1906
+ await page.evaluate(() => {
1907
+ document.querySelectorAll<HTMLElement>('[data-andesite-sprite-capture-overlay]').forEach(node => {
1908
+ node.remove()
1909
+ })
1910
+ const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
1911
+ if (root) {
1912
+ root.style.visibility = root.dataset.andesiteSpriteCaptureVisibility ?? ''
1913
+ delete root.dataset.andesiteSpriteCaptureVisibility
1914
+ root.style.opacity = root.dataset.andesiteSpriteCaptureOpacity ?? ''
1915
+ delete root.dataset.andesiteSpriteCaptureOpacity
1916
+ }
1917
+ const html = document.documentElement
1918
+ const body = document.body
1919
+ html.style.background = html.dataset.andesiteSpriteCaptureBackground ?? ''
1920
+ html.style.backgroundColor = html.dataset.andesiteSpriteCaptureBackgroundColor ?? ''
1921
+ body.style.background = body.dataset.andesiteSpriteCaptureBackground ?? ''
1922
+ body.style.backgroundColor = body.dataset.andesiteSpriteCaptureBackgroundColor ?? ''
1923
+ delete html.dataset.andesiteSpriteCaptureBackground
1924
+ delete html.dataset.andesiteSpriteCaptureBackgroundColor
1925
+ delete body.dataset.andesiteSpriteCaptureBackground
1926
+ delete body.dataset.andesiteSpriteCaptureBackgroundColor
1927
+ })
1928
+ await cloneHandle.dispose()
1929
+ }
1930
+ }
1931
+
1932
+ async function combinePngsVertically(page: Page, frames: Uint8Array[]): Promise<Uint8Array> {
1933
+ if (frames.length === 0) {
1934
+ return new Uint8Array()
1935
+ }
1936
+ const dataUrls = frames.map(frame => `data:image/png;base64,${Buffer.from(frame).toString('base64')}`)
1937
+ const dataUrl = await page.evaluate(async (sources: string[]) => {
1938
+ const images = await Promise.all(sources.map(source => new Promise<HTMLImageElement>((resolve, reject) => {
1939
+ const image = new Image()
1940
+ image.onload = () => resolve(image)
1941
+ image.onerror = () => reject(new Error(`Failed to load atlas frame: ${source.substring(0, 32)}`))
1942
+ image.src = source
1943
+ })))
1944
+ const width = Math.max(...images.map(image => image.width))
1945
+ const height = images.reduce((sum, image) => sum + image.height, 0)
1946
+ const canvas = document.createElement('canvas')
1947
+ canvas.width = width
1948
+ canvas.height = height
1949
+ const ctx = canvas.getContext('2d')!
1950
+ ctx.imageSmoothingEnabled = false
1951
+
1952
+ // Andesite 源 atlas 纵向排列,避免 progress 档位较多时生成过宽图片。
1953
+ let y = 0
1954
+ images.forEach(image => {
1955
+ ctx.drawImage(image, 0, y)
1956
+ y += image.height
1957
+ })
1958
+ return canvas.toDataURL('image/png')
1959
+ }, dataUrls)
1960
+ return Buffer.from(dataUrl.substring(dataUrl.indexOf(',') + 1), 'base64')
1961
+ }
1962
+
1963
+ function progressPercentId(percent: number): string {
1964
+ const normalized = Math.round(percent * 10) / 10
1965
+ return Number.isInteger(normalized) ? `${normalized}` : normalized.toFixed(1).replace('.', '_')
1966
+ }
1967
+
1968
+ async function renderProgressPng(el: Awaited<ReturnType<Page['$']>>, percent: number): Promise<Uint8Array> {
1969
+ if (!el) {
1970
+ return new Uint8Array()
1971
+ }
1972
+ const dataUrl = await el.evaluate((node: Element, value: number) => {
1973
+ const target = node as HTMLElement
1974
+ // 圆角/边框常做在包裹 progress 的容器上(容器 overflow:hidden 把直角 fill 裁成胶囊)。
1975
+ // 单独栅格化 progress 会丢失容器裁剪、暴露直角;向上找最近的圆角裁剪容器,用它作胶囊外形,
1976
+ // 让导出贴图自带与 web 一致的圆角与边框。
1977
+ let capsule: HTMLElement = target
1978
+ let current: HTMLElement | null = target.parentElement
1979
+ while (current && current !== document.body) {
1980
+ const style = getComputedStyle(current)
1981
+ const radius = Number.parseFloat(style.borderTopLeftRadius || '0')
1982
+ const clipped = style.overflow === 'hidden' || style.overflowX === 'hidden' || style.overflowY === 'hidden'
1983
+ if (clipped && Number.isFinite(radius) && radius > 0) {
1984
+ capsule = current
1985
+ break
1986
+ }
1987
+ current = current.parentElement
1988
+ }
1989
+ const rect = capsule.getBoundingClientRect()
1990
+ const width = Math.max(1, Math.round(rect.width))
1991
+ const height = Math.max(1, Math.round(rect.height))
1992
+ const capsuleStyle = getComputedStyle(capsule)
1993
+ const track = target.children.item(0) as HTMLElement | null
1994
+ const fill = target.children.item(1) as HTMLElement | null
1995
+ const trackStyle = track ? getComputedStyle(track) : getComputedStyle(target)
1996
+ const fillStyle = fill ? getComputedStyle(fill) : trackStyle
1997
+ const fillWidth = Math.max(0, Math.min(width, Math.round(width * value / 100)))
1998
+ // 胶囊外形容器的圆角同时约束底槽与填充;填充自身圆角取容器值,保证任意进度下两端圆弧一致。
1999
+ const capsuleRadius = borderRadius(capsuleStyle, width, height)
2000
+ const capsuleBorder = Number.parseFloat(capsuleStyle.borderTopWidth || '0')
2001
+ const capsuleBorderColor = capsuleStyle.borderTopColor
2002
+ const canvas = document.createElement('canvas')
2003
+ canvas.width = width
2004
+ canvas.height = height
2005
+ const ctx = canvas.getContext('2d')!
2006
+ ctx.imageSmoothingEnabled = false
2007
+ // AProgress 不能依赖截图路径,否则动态进度每一帧会互相污染;Canvas 绘制时必须还原 CSS 圆角。
2008
+ // 底槽:优先取容器底色(无则退回 track 色),再画容器边框,与 web 叠放顺序一致。
2009
+ ctx.fillStyle = solidColor(capsuleStyle.backgroundColor, solidColor(trackStyle.backgroundColor, '#00000000'))
2010
+ fillRoundedRect(ctx, 0, 0, width, height, capsuleRadius)
2011
+ if (capsuleBorder > 0 && capsuleBorderColor && capsuleBorderColor !== 'rgba(0, 0, 0, 0)') {
2012
+ ctx.lineWidth = capsuleBorder
2013
+ ctx.strokeStyle = capsuleBorderColor
2014
+ strokeRoundedRect(ctx, capsuleBorder / 2, capsuleBorder / 2, width - capsuleBorder, height - capsuleBorder, Math.max(0, capsuleRadius - capsuleBorder / 2))
2015
+ }
2016
+ if (fillWidth > 0) {
2017
+ ctx.fillStyle = fillPaint(ctx, fillStyle.backgroundImage, fillStyle.backgroundColor, fillWidth, height)
2018
+ fillRoundedRect(ctx, 0, 0, fillWidth, height, capsuleRadius)
2019
+ }
2020
+ return canvas.toDataURL('image/png')
2021
+
2022
+ function solidColor(value: string, fallback: string): string {
2023
+ return value && value !== 'rgba(0, 0, 0, 0)' ? value : fallback
2024
+ }
2025
+
2026
+ function fillPaint(context: CanvasRenderingContext2D, backgroundImage: string, backgroundColor: string, fillWidth: number, fillHeight: number): string | CanvasGradient {
2027
+ // 解析 linear-gradient 的方向与颜色停靠点;computed style 会把方向归一成角度或省略(默认 180deg 自上而下)。
2028
+ const gradientMatch = backgroundImage.match(/linear-gradient\((.*)\)$/)
2029
+ if (gradientMatch) {
2030
+ const body = gradientMatch[1]
2031
+ const stops = parseGradientStops(body)
2032
+ if (stops.length >= 2) {
2033
+ const [x0, y0, x1, y1] = gradientAxis(body, fillWidth, fillHeight)
2034
+ const gradient = context.createLinearGradient(x0, y0, x1, y1)
2035
+ for (const stop of stops) {
2036
+ gradient.addColorStop(stop.offset, stop.color)
2037
+ }
2038
+ return gradient
2039
+ }
2040
+ }
2041
+ return solidColor(backgroundColor, '#ffffff')
2042
+ }
2043
+
2044
+ // 解析 linear-gradient 的角度/方向为 canvas 起止坐标。角度以顺时针、0deg 指向正上方为准(CSS 规范)。
2045
+ function gradientAxis(body: string, width: number, height: number): [number, number, number, number] {
2046
+ const first = body.slice(0, body.indexOf(',')).trim().toLowerCase()
2047
+ let deg = 180
2048
+ if (/^-?\d+(?:\.\d+)?deg$/.test(first)) {
2049
+ deg = Number.parseFloat(first)
2050
+ } else if (first.startsWith('to ')) {
2051
+ if (first.includes('top')) deg = 0
2052
+ else if (first.includes('bottom')) deg = 180
2053
+ else if (first.includes('left')) deg = 270
2054
+ else if (first.includes('right')) deg = 90
2055
+ }
2056
+ const rad = (deg - 90) * Math.PI / 180
2057
+ const dx = Math.cos(rad)
2058
+ const dy = Math.sin(rad)
2059
+ // 渐变轴长度取矩形对角在方向上的投影,保证 CSS 角到角语义。
2060
+ const half = Math.abs(width * dx) / 2 + Math.abs(height * dy) / 2
2061
+ const cx = width / 2
2062
+ const cy = height / 2
2063
+ return [cx - dx * half, cy - dy * half, cx + dx * half, cy + dy * half]
2064
+ }
2065
+
2066
+ // 解析颜色停靠点为 {color, offset};无显式百分比时按索引均摊。rgba() 内含逗号需整体匹配。
2067
+ function parseGradientStops(body: string): Array<{ color: string; offset: number }> {
2068
+ const colorRe = /(rgba?\([^)]*\))(?:\s+(\d+(?:\.\d+)?)%)?/g
2069
+ const stops: Array<{ color: string; offset: number }> = []
2070
+ let match: RegExpExecArray | null
2071
+ while ((match = colorRe.exec(body)) !== null) {
2072
+ const percent = match[2] !== undefined ? Number.parseFloat(match[2]) / 100 : -1
2073
+ stops.push({ color: match[1], offset: percent })
2074
+ }
2075
+ const n = stops.length
2076
+ // 省略角度的方向前缀不是颜色,colorRe 不会误匹配;补齐未声明的停靠点。
2077
+ for (let i = 0; i < n; i++) {
2078
+ if (stops[i].offset < 0) {
2079
+ stops[i].offset = n <= 1 ? 0 : i / (n - 1)
2080
+ }
2081
+ }
2082
+ return stops
2083
+ }
2084
+
2085
+ function borderRadius(style: CSSStyleDeclaration, rectWidth: number, rectHeight: number): number {
2086
+ const value = Number.parseFloat(style.borderTopLeftRadius || style.borderRadius || '0')
2087
+ if (!Number.isFinite(value) || value <= 0) {
2088
+ return 0
2089
+ }
2090
+ return Math.min(value, rectWidth / 2, rectHeight / 2)
2091
+ }
2092
+
2093
+ function fillRoundedRect(context: CanvasRenderingContext2D, x: number, y: number, rectWidth: number, rectHeight: number, radius: number): void {
2094
+ roundedRectPath(context, x, y, rectWidth, rectHeight, radius)
2095
+ context.fill()
2096
+ }
2097
+
2098
+ function strokeRoundedRect(context: CanvasRenderingContext2D, x: number, y: number, rectWidth: number, rectHeight: number, radius: number): void {
2099
+ roundedRectPath(context, x, y, rectWidth, rectHeight, radius)
2100
+ context.stroke()
2101
+ }
2102
+
2103
+ function roundedRectPath(context: CanvasRenderingContext2D, x: number, y: number, rectWidth: number, rectHeight: number, radius: number): void {
2104
+ if (radius <= 0) {
2105
+ context.beginPath()
2106
+ context.rect(x, y, rectWidth, rectHeight)
2107
+ return
2108
+ }
2109
+ context.beginPath()
2110
+ context.moveTo(x + radius, y)
2111
+ context.lineTo(x + rectWidth - radius, y)
2112
+ context.quadraticCurveTo(x + rectWidth, y, x + rectWidth, y + radius)
2113
+ context.lineTo(x + rectWidth, y + rectHeight - radius)
2114
+ context.quadraticCurveTo(x + rectWidth, y + rectHeight, x + rectWidth - radius, y + rectHeight)
2115
+ context.lineTo(x + radius, y + rectHeight)
2116
+ context.quadraticCurveTo(x, y + rectHeight, x, y + rectHeight - radius)
2117
+ context.lineTo(x, y + radius)
2118
+ context.quadraticCurveTo(x, y, x + radius, y)
2119
+ context.closePath()
2120
+ }
2121
+ }, percent)
2122
+ return Buffer.from(dataUrl.substring(dataUrl.indexOf(',') + 1), 'base64')
2123
+ }
2124
+
2125
+ async function collectLabels(page: Page): Promise<LabelDecl[]> {
2126
+ const elements = await page.$$('[data-andesite-type="label"]')
2127
+ const results: LabelDecl[] = []
2128
+ for (const el of elements) {
2129
+ if (!await isCollectableAndesiteElement(el)) {
2130
+ continue
2131
+ }
2132
+ if (await isInsideScrollView(el)) {
2133
+ continue
2134
+ }
2135
+ const id = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
2136
+ const mountId = await collectHudMountId(el)
2137
+ const defaultVisible = await readDefaultVisible(el)
2138
+ const rotatedBy = await findRotatingLabelAncestor(el)
2139
+ if (rotatedBy != null) {
2140
+ throw new Error(`[Andesite] ALabel 不支持旋转 transform:${id} 受到 ${rotatedBy} 影响。请移除 rotate-*,或把该文本改为静态 DOM 烘进背景。`)
2141
+ }
2142
+ const box = await measureAndesiteBox(el)
2143
+ const text = await el.evaluate((node: Element) => node.getAttribute('data-andesite-text') ?? '')
2144
+ const size = await el.evaluate((node: Element) => node.getAttribute('data-andesite-size') ?? 'NORMAL')
2145
+ const align = await el.evaluate((node: Element) => node.getAttribute('data-andesite-align') ?? 'LEFT')
2146
+ const color = await el.evaluate((node: Element) => node.getAttribute('data-andesite-color') ?? 'ffffff')
2147
+ const shadowColor = await el.evaluate((node: Element) => node.getAttribute('data-andesite-shadow-color') ?? '')
2148
+ const shadowOffset = Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-shadow-offset') ?? '1'))
2149
+ const shadowVerticalOffset = Number(await el.evaluate((node: Element) => node.getAttribute('data-andesite-shadow-vertical-offset') ?? '1'))
2150
+ results.push({
2151
+ id, mountId, text, x: box.x, y: box.y, width: box.width, height: box.height,
2152
+ size: size as LabelDecl['size'],
2153
+ align: align as LabelDecl['align'],
2154
+ color,
2155
+ shadowColor: shadowColor || undefined,
2156
+ shadowOffset,
2157
+ shadowVerticalOffset,
2158
+ ...(defaultVisible == null ? {} : { defaultVisible }),
2159
+ })
2160
+ }
2161
+ return results
2162
+ }
2163
+
2164
+ async function findRotatingLabelAncestor(el: Awaited<ReturnType<Page['$']>>): Promise<string | null> {
2165
+ if (!el) {
2166
+ return null
2167
+ }
2168
+ return await el.evaluate((node: Element) => {
2169
+ const root = document.querySelector<HTMLElement>('[data-andesite-content-root]')
2170
+ let current: HTMLElement | null = node as HTMLElement
2171
+ while (current) {
2172
+ const style = getComputedStyle(current)
2173
+ // Bedrock 动态 label 只能还原轴对齐盒模型;旋转会让文本与已烘焙背景错位。
2174
+ if (hasNonAxisAlignedTransform(style)) {
2175
+ return describeNode(current)
2176
+ }
2177
+ if (current === root) {
2178
+ break
2179
+ }
2180
+ current = current.parentElement
2181
+ }
2182
+ return null
2183
+
2184
+ function hasNonAxisAlignedTransform(style: CSSStyleDeclaration): boolean {
2185
+ if (hasRotateProperty(style.getPropertyValue('rotate'))) {
2186
+ return true
2187
+ }
2188
+ const transform = style.transform
2189
+ if (transform == null || transform === '' || transform === 'none') {
2190
+ return false
2191
+ }
2192
+ const matrix = transform.match(/^matrix\(([^)]+)\)$/)
2193
+ if (matrix != null) {
2194
+ const values = matrix[1].split(',').map(value => Number.parseFloat(value.trim()))
2195
+ return values.length >= 4 && (Math.abs(values[1]) > 0.0001 || Math.abs(values[2]) > 0.0001)
2196
+ }
2197
+ const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/)
2198
+ if (matrix3d != null) {
2199
+ const values = matrix3d[1].split(',').map(value => Number.parseFloat(value.trim()))
2200
+ return values.length >= 16 && (Math.abs(values[1]) > 0.0001 || Math.abs(values[4]) > 0.0001)
2201
+ }
2202
+ return /rotate|skew/i.test(transform)
2203
+ }
2204
+
2205
+ function hasRotateProperty(value: string): boolean {
2206
+ if (value == null || value === '' || value === 'none') {
2207
+ return false
2208
+ }
2209
+ return value.split(/\s+/).some(part => {
2210
+ const n = Number.parseFloat(part)
2211
+ return Number.isFinite(n) && Math.abs(n) > 0.0001
2212
+ })
2213
+ }
2214
+
2215
+ function describeNode(target: HTMLElement): string {
2216
+ const tag = target.tagName.toLowerCase()
2217
+ const id = target.id ? `#${target.id}` : ''
2218
+ const className = typeof target.className === 'string' && target.className !== ''
2219
+ ? `.${target.className.trim().split(/\s+/).join('.')}`
2220
+ : ''
2221
+ return `${tag}${id}${className}`
2222
+ }
2223
+ })
2224
+ }
2225
+
2226
+ /**
2227
+ * 收集页面中所有原生 ASlot 的坐标和尺寸,不截图。
2228
+ */
2229
+ async function collectSlots(page: Page): Promise<SlotDecl[]> {
2230
+ const elements = await page.$$('[data-andesite-type="slot"]')
2231
+ const results: SlotDecl[] = []
2232
+ let visibleIndex = 0
2233
+ for (let i = 0; i < elements.length; i++) {
2234
+ const el = elements[i]
2235
+ if (!await isCollectableAndesiteElement(el)) {
2236
+ continue
2237
+ }
2238
+ if (await isInsideScrollView(el)) {
2239
+ continue
2240
+ }
2241
+ if (await isInsideRepeater(el)) {
2242
+ continue
2243
+ }
2244
+ const id = await el.evaluate((node: Element) => node.getAttribute('data-andesite-id') ?? '')
2245
+ const mountId = await collectHudMountId(el)
2246
+ const slotIndex = await el.evaluate((node: Element) => node.getAttribute('data-andesite-slot-index') ?? '')
2247
+ const slot = slotIndex === '' ? visibleIndex : Number(slotIndex)
2248
+ const box = await measureAndesiteBox(el)
2249
+ const x = box.x
2250
+ const y = box.y
2251
+ const width = box.width
2252
+ const height = box.height
2253
+ results.push({ id, mountId, slot, x, y, width, height })
2254
+ visibleIndex++
2255
+ }
2256
+ return results
2257
+ }