cardbuddy 0.14.3 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/preview-editor/assets/index-BkbBxDTT.css +1 -0
  3. package/dist/preview-editor/assets/index-Bro07DTa.js +313 -0
  4. package/dist/preview-editor/index.html +2 -2
  5. package/dist/standalone.js +3 -0
  6. package/dist/vendored/card-editor/runtime/adapter.js +2 -2
  7. package/dist/vendored/mcp/client.mjs +9 -2
  8. package/dist/vendored/server/core/ai-chat.js +1702 -134
  9. package/dist/vendored/server/core/browser.js +12 -3
  10. package/dist/vendored/server/core/cardFiles.js +3 -0
  11. package/dist/vendored/server/core/cardHistory.js +1 -0
  12. package/dist/vendored/server/core/cardImportMeta.js +7 -6
  13. package/dist/vendored/server/core/cardOpenFolder.js +1 -0
  14. package/dist/vendored/server/core/cardSave.js +1 -0
  15. package/dist/vendored/server/core/cardSync.js +3 -0
  16. package/dist/vendored/server/core/cardValidate.js +16 -5
  17. package/dist/vendored/server/core/codeBridge.js +5 -1
  18. package/dist/vendored/server/core/logStream.js +1 -0
  19. package/dist/vendored/server/core/sliceStatic.js +24 -4
  20. package/dist/vendored/server/core/sliceUpload.js +1 -0
  21. package/dist/vendored/server/core/turnJournal.js +6 -0
  22. package/dist/vendored/skills/card-skill-trainer/SKILL.md +257 -201
  23. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +14 -82
  24. package/dist/vendored/skills/card-skill-trainer/scripts/lib/diagnosis.mjs +207 -0
  25. package/dist/vendored/skills/card-skill-trainer/scripts/lib/layout-correction-sim.mjs +62 -0
  26. package/dist/vendored/skills/card-skill-trainer/scripts/lib/paradigm-router.mjs +76 -0
  27. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker-runner.mjs +31 -0
  28. package/dist/vendored/skills/card-skill-trainer/scripts/lib/pixel-worker.mjs +138 -0
  29. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +24 -20
  30. package/dist/vendored/skills/card-skill-trainer/scripts/lib/telemetry.mjs +46 -0
  31. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +4 -1
  32. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +279 -90
  33. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -2
  34. package/dist/vendored/skills/card-skill-trainer/scripts/sink_rules.mjs +127 -0
  35. package/dist/vendored/skills/dsl-card-ai/SKILL.md +1 -1
  36. package/dist/vendored/skills/dsl-card-generator/SKILL.md +208 -380
  37. package/dist/vendored/skills/dsl-card-generator/references/dsl_structure.md +1 -15
  38. package/dist/vendored/skills/dsl-card-generator/references/expression_engine.md +11 -20
  39. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +233 -61
  40. package/dist/vendored/skills/dsl-card-generator/references/ir-format.md +202 -4
  41. package/dist/vendored/skills/dsl-card-generator/references/iteration_protocol.md +87 -0
  42. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +1 -1
  43. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +42 -33
  44. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/title-underline-overlay.json +59 -11
  45. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +44 -8
  46. package/dist/vendored/skills/dsl-card-generator/references/rules.md +26 -6
  47. package/dist/vendored/skills/dsl-card-generator/references/scripts_reference.md +54 -0
  48. package/dist/vendored/skills/dsl-card-generator/references/semantic_adjudication.md +23 -0
  49. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +6 -6
  50. package/dist/vendored/skills/dsl-card-generator/references/spec_to_schema_patterns.md +33 -4
  51. package/dist/vendored/skills/dsl-card-generator/references/ui_fidelity_rules.md +13 -1
  52. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -14
  53. package/dist/vendored/skills/dsl-card-generator/scripts/autofix_schema.py +133 -2
  54. package/dist/vendored/skills/dsl-card-generator/scripts/check_fidelity.mjs +226 -19
  55. package/dist/vendored/skills/dsl-card-generator/scripts/check_magic_values.mjs +6 -1
  56. package/dist/vendored/skills/dsl-card-generator/scripts/check_topology.py +0 -35
  57. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +92 -6
  58. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +277 -27
  59. package/dist/vendored/skills/dsl-card-generator/scripts/gate.mjs +147 -0
  60. package/dist/vendored/skills/dsl-card-generator/scripts/generate_cards.mjs +12 -8
  61. package/dist/vendored/skills/dsl-card-generator/scripts/ir_merge.mjs +288 -0
  62. package/dist/vendored/skills/dsl-card-generator/scripts/ir_restore.mjs +175 -0
  63. package/dist/vendored/skills/dsl-card-generator/scripts/iterate.mjs +1015 -54
  64. package/dist/vendored/skills/dsl-card-generator/scripts/lib/component_contract.mjs +315 -0
  65. package/dist/vendored/skills/dsl-card-generator/scripts/lib/geometry_audit.mjs +131 -0
  66. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout-tokens.mjs +1 -1
  67. package/dist/vendored/skills/dsl-card-generator/scripts/lib/layout_rules.mjs +160 -0
  68. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.mjs +23 -0
  69. package/dist/vendored/skills/dsl-card-generator/scripts/lib/paths.py +0 -37
  70. package/dist/vendored/skills/dsl-card-generator/scripts/lib/python.mjs +99 -0
  71. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/adaptive-width.mjs +105 -0
  72. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/bindings.mjs +260 -0
  73. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/expr.mjs +52 -0
  74. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/image.mjs +96 -0
  75. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/registry.mjs +166 -0
  76. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/structure.mjs +127 -0
  77. package/dist/vendored/skills/dsl-card-generator/scripts/lib/rules/walk.mjs +61 -0
  78. package/dist/vendored/skills/dsl-card-generator/scripts/lib/scope_alias.mjs +125 -0
  79. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slicePath.mjs +17 -5
  80. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_dedupe.mjs +102 -0
  81. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_assemble.mjs +5727 -0
  82. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_extract.mjs +4214 -0
  83. package/dist/vendored/skills/dsl-card-generator/scripts/lib/v3_shared.mjs +1110 -0
  84. package/dist/vendored/skills/dsl-card-generator/scripts/lint_card.mjs +322 -71
  85. package/dist/vendored/skills/dsl-card-generator/scripts/preflight.mjs +87 -43
  86. package/dist/vendored/skills/dsl-card-generator/scripts/readiness.mjs +634 -0
  87. package/dist/vendored/skills/dsl-card-generator/scripts/register_card_template.mjs +5 -8
  88. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +91 -43
  89. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -11
  90. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +272 -46
  91. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +43 -10499
  92. package/dist/vendored/skills/dsl-card-generator/scripts/v3_regression.mjs +188 -0
  93. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +23 -15
  94. package/dist/vendored/skills/shared/exit-codes.md +79 -0
  95. package/dist/vendored/skills/shared/spec-contract.md +118 -0
  96. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +43 -20
  97. package/dist/vendored/skills/sketch-spec-to-card/references/spec-schema.md +4 -4
  98. package/dist/vendored/skills/sketch-spec-to-card/scripts/build_prompt.py +47 -9
  99. package/dist/vendored/skills/sketch-spec-to-card/scripts/cluster_states.py +14 -25
  100. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +546 -91
  101. package/dist/vendored/skills/sketch-spec-to-card/scripts/png_utils.py +51 -5
  102. package/dist/vendored/skills/sketch-spec-to-card/scripts/test_text_only_artboard.py +217 -0
  103. package/dist/vendored/skills/sketch-spec-to-card/scripts/vision.py +64 -34
  104. package/package.json +1 -1
  105. package/dist/preview-editor/assets/index-Cm0USikc.js +0 -307
  106. package/dist/preview-editor/assets/index-cbTCHp8J.css +0 -1
  107. package/dist/vendored/skills/card-skill-trainer/scripts/test_missing_slice.py +0 -122
  108. package/dist/vendored/skills/dsl-card-generator/scripts/analyze_component_usage.mjs +0 -153
@@ -0,0 +1,4214 @@
1
+ /**
2
+ * v3_extract.mjs — 语义识别层(启发式提名;2026-09-21 自 spec_to_schema_v3.mjs 拆出)。
3
+ * extractSemantics:spec.json 几何聚类 → 语义决策(Phase 2 的"提名者")。
4
+ * semDecisionsView / applySemDecisions:AI 终审决策回路(白名单覆盖)。
5
+ * 消费方:lib/v3_assemble.mjs(generate 无 semOverride 时调用)、CLI。
6
+ */
7
+ import fs from 'node:fs'
8
+ import { T } from './layout-tokens.mjs'
9
+ import { sampleOutlineRing } from './design_sample.mjs'
10
+ import { resolveSliceExport, sliceRefPath } from './slicePath.mjs'
11
+ import {
12
+ FORM_ROW_BASE_GAP,
13
+ LABEL_MAX_NUM_FONT,
14
+ RIGHT_ACTION_X_RATIO,
15
+ ROLE_BASE_NAME,
16
+ classifyValue,
17
+ container,
18
+ detectChartClusters,
19
+ detectEnumStatStrip,
20
+ displayPrefixRe,
21
+ entityKeyFor,
22
+ hexToRgba,
23
+ isBg,
24
+ lum,
25
+ parseAmount,
26
+ radiusMax,
27
+ rect,
28
+ rectInside,
29
+ roleFieldKey,
30
+ sameRow,
31
+ splitValueTokens,
32
+ textOf,
33
+ ts,
34
+ uniqueKey,
35
+ unmask,
36
+ valueExpr,
37
+ } from './v3_shared.mjs'
38
+ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
39
+ // 设计稿聚焦图(可选):标注缺陷交叉校验(小 pill 描边环采样等),视觉优先;缺省跳过校验。
40
+ const _designImage = opts.designImage || null
41
+ const card = (spec.cards || [])[Number(cardIndex) || 0] || {}
42
+ const all = (card.elements || []).length ? card.elements : (spec.elements || [])
43
+ let content = all.filter((e) => !isBg(e))
44
+ // 图表矢量簇(柱+网格线+轴/值标签)先整体摘出:下游无图表消费通道,不摘出会被静默丢弃/错排
45
+ const chartClusters = detectChartClusters(content)
46
+ if (chartClusters.length) {
47
+ const absorbed = new Set(chartClusters.flatMap((c) => [...c.members, ...c.texts]))
48
+ content = content.filter((e) => !absorbed.has(e))
49
+ }
50
+ const rawTexts = content
51
+ .filter((e) => e.type === 'text' && textOf(e))
52
+ .sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
53
+ // 上游标注偶发「合并重复层」:同一位置既有"排队:5"又有"排队:"+"5"(粒度层)。
54
+ // 若 A 与 B 同位且 A.text 以 B.text 为前缀、且余下片段在同一行存在独立文本层 → 丢弃合并层 A,
55
+ // 否则下游会同时渲染"排队:"与"排队:5"造成重复文本。
56
+ const mergedDup = new Set()
57
+ for (const a of rawTexts) {
58
+ const ta = textOf(a)
59
+ for (const b of rawTexts) {
60
+ if (a === b || mergedDup.has(a)) continue
61
+ const tb = textOf(b)
62
+ if (!ta.startsWith(tb) || ta.length <= tb.length) continue
63
+ if (Math.abs((rect(a).x || 0) - (rect(b).x || 0)) > 4 || Math.abs((rect(a).y || 0) - (rect(b).y || 0)) > 4) continue
64
+ const rest = ta.slice(tb.length)
65
+ const hasGranular = rawTexts.some((c) => c !== a && c !== b && textOf(c) === rest
66
+ && sameRow(c, b) && (rect(c).x || 0) >= (rect(b).x || 0))
67
+ if (hasGranular) { mergedDup.add(a); break }
68
+ }
69
+ }
70
+ const _preMerged = mergedDup.size ? rawTexts.filter((e) => !mergedDup.has(e)) : rawTexts
71
+ // 同行重叠层去重:同 y 行(高度差 ≤4)两文本层重叠面积 ≥50% 且文案互为包含(超串)时,
72
+ // 丢弃较短层(如 "¥9,293.25" 与 "9,293.25" 是同一份金额的重复标注,并存会被双重绑定)。
73
+ const containedFrag = new Set()
74
+ for (const a of _preMerged) {
75
+ const ta = textOf(a)
76
+ for (const b of _preMerged) {
77
+ if (a === b || containedFrag.has(a) || containedFrag.has(b)) continue
78
+ const tb = textOf(b)
79
+ if (!ta || !tb || ta === tb) continue
80
+ if (!(ta.includes(tb) || tb.includes(ta))) continue
81
+ if (!sameRow(a, b, 6)) continue
82
+ const ra = rect(a); const rb = rect(b)
83
+ if (Math.abs((ra.height || 0) - (rb.height || 0)) > 4) continue
84
+ const ox = Math.min((ra.x || 0) + (ra.width || 0), (rb.x || 0) + (rb.width || 0)) - Math.max(ra.x || 0, rb.x || 0)
85
+ const oy = Math.min((ra.y || 0) + (ra.height || 0), (rb.y || 0) + (rb.height || 0)) - Math.max(ra.y || 0, rb.y || 0)
86
+ if (ox <= 0 || oy <= 0) continue
87
+ const shorter = ta.length <= tb.length ? a : b
88
+ const shorterArea = (rect(shorter).width || 0) * (rect(shorter).height || 0)
89
+ if ((ox * oy) / Math.max(1, shorterArea) >= 0.5) containedFrag.add(shorter)
90
+ }
91
+ }
92
+ const _preStitch = containedFrag.size ? _preMerged.filter((e) => !containedFrag.has(e)) : _preMerged
93
+ // 同行数字碎片拼接:标注偶发把同一金额拆成连续碎片("3,821"+"."+"50"),
94
+ // 同基线(±2)、同学号、x 间隙 ≤3、拼接结果为纯数字/货币/标点 → 合成一个虚拟文本元素。
95
+ // 碎片不拼接则各自渲染/绑定("50" 曾被当成独立主金额)。
96
+ const stitchedOut = []
97
+ const stitchConsumed = new Set()
98
+ {
99
+ const fragRe = /^[\d.,,%%\p{Sc}\-]$/u
100
+ const numericish = /^[\p{Sc}\-]?[\d.,,%%]+$/u
101
+ const byLine = new Map()
102
+ for (const t of _preStitch) {
103
+ const key = Math.round((rect(t).y || 0) / 2) + ':' + (ts(t).fontSize || 0)
104
+ if (!byLine.has(key)) byLine.set(key, [])
105
+ byLine.get(key).push(t)
106
+ }
107
+ for (const [, line] of byLine) {
108
+ const sortedLine = line.sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
109
+ let chain = []
110
+ const flushChain = () => {
111
+ if (chain.length >= 2) {
112
+ const joined = chain.map((c) => textOf(c)).join('')
113
+ if (numericish.test(joined) && /\d/.test(joined) && chain.some((c) => fragRe.test(textOf(c)))) {
114
+ const first = chain[0]
115
+ const last = chain[chain.length - 1]
116
+ const rx = Math.min(...chain.map((c) => rect(c).x || 0))
117
+ const ry = Math.min(...chain.map((c) => rect(c).y || 0))
118
+ const rr = Math.max(...chain.map((c) => (rect(c).x || 0) + (rect(c).width || 0)))
119
+ const rb = Math.max(...chain.map((c) => (rect(c).y || 0) + (rect(c).height || 0)))
120
+ stitchedOut.push({
121
+ ...first,
122
+ text: joined,
123
+ rect: { x: rx, y: ry, width: rr - rx, height: rb - ry },
124
+ _stitched: true,
125
+ })
126
+ chain.forEach((c) => stitchConsumed.add(c))
127
+ }
128
+ }
129
+ chain = []
130
+ }
131
+ for (const t of sortedLine) {
132
+ if (!chain.length) { chain.push(t); continue }
133
+ const prev = chain[chain.length - 1]
134
+ const gap = (rect(t).x || 0) - ((rect(prev).x || 0) + (rect(prev).width || 0))
135
+ if (gap >= -1 && gap <= 3) chain.push(t)
136
+ else { flushChain(); chain.push(t) }
137
+ }
138
+ flushChain()
139
+ }
140
+ }
141
+ const dedupedTexts = stitchConsumed.size
142
+ ? [..._preStitch.filter((e) => !stitchConsumed.has(e)), ...stitchedOut]
143
+ : _preStitch
144
+ // —— 富文本段落:大容器文本层被 ≥2 个碎片层逐行铺满(碎片拼接 ≈ 容器文本)——
145
+ // 容器与碎片同文同源,并存会重复渲染(曾出现"截止至"被渲染 4 次)。
146
+ // 保留碎片(位置/字重精确,粗体金额片段得以还原),整体移除容器与碎片出通用 text 流。
147
+ const richBlocks = (() => {
148
+ const inside = (big, small) => {
149
+ const b = rect(big), s = rect(small)
150
+ return (s.x || 0) >= (b.x || 0) - 2 && (s.y || 0) >= (b.y || 0) - 2
151
+ && ((s.x || 0) + (s.width || 0)) <= (b.x || 0) + (b.width || 0) + 2
152
+ && ((s.y || 0) + (s.height || 0)) <= (b.y || 0) + (b.height || 0) + 2
153
+ }
154
+ const blocks = []
155
+ for (const big of dedupedTexts) {
156
+ const br = rect(big)
157
+ if ((br.height || 0) < (ts(big).fontSize || 14) * 2.2) continue
158
+ const bigText = textOf(big)
159
+ const inner = dedupedTexts.filter((t) => {
160
+ if (t === big || !inside(big, t)) return false
161
+ // 微型文本(≤2 字符)若并非容器文案的子串,是压在段落区域上的独立元素
162
+ // (如序号徽章数字),不是富文本碎片——排除以免被段落吞掉
163
+ const fragText = (textOf(t) || '').trim()
164
+ if (fragText.length > 0 && fragText.length <= 2 && bigText && !bigText.includes(fragText)) return false
165
+ return true
166
+ })
167
+ if (inner.length < 2) continue
168
+ // 至少两行铺开
169
+ const lineYs = new Set(inner.map((t) => Math.round((rect(t).y || 0) / 8)))
170
+ if (lineYs.size < 2) continue
171
+ // 碎片拼接文本与容器文本的字符集重合度 ≥80%
172
+ const bigChars = new Set([...textOf(big)])
173
+ const innerText = inner.map((t) => textOf(t)).join('')
174
+ const cover = [...innerText].filter((ch) => bigChars.has(ch)).length / Math.max(1, innerText.length)
175
+ if (cover < 0.8) continue
176
+ // 按行分组(同 y ±4)
177
+ const lineMap = new Map()
178
+ for (const frag of inner) {
179
+ const key = Math.round((rect(frag).y || 0) / 4)
180
+ if (!lineMap.has(key)) lineMap.set(key, [])
181
+ lineMap.get(key).push(frag)
182
+ }
183
+ const lines = [...lineMap.entries()]
184
+ .sort((a, b) => a[0] - b[0])
185
+ .map(([, frags]) => frags.sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0)))
186
+ blocks.push({ container: big, fragments: inner, lines, y: br.y || 0, x: br.x || 0 })
187
+ }
188
+ return blocks
189
+ })()
190
+ const richAll = new Set(richBlocks.flatMap((b) => [b.container, ...b.fragments]))
191
+ const texts = richAll.size ? dedupedTexts.filter((e) => !richAll.has(e)) : dedupedTexts
192
+ const shapes = content.filter((e) => e.type === 'shape')
193
+ const slices = content.filter((e) => e.type === 'slice')
194
+ // 纯矢量 logo 组(shapes 组、无切图导出):上游已按 rect 画板裁切(annotationDefect.cropSlice),
195
+ // 凭 cropSlice 提权进切片池,让行图标/值内前置图标/行尾 affordance 等消费通道认领;
196
+ // 不提权则这些媒体被静默丢弃(付款卡黄色行标曾整批缺失)。
197
+ for (const groupEl of content) {
198
+ if (groupEl.type === 'group' && groupEl.annotationDefect && groupEl.annotationDefect.cropSlice) {
199
+ slices.push(groupEl)
200
+ }
201
+ }
202
+ const symbols = content.filter((e) => e.type === 'symbol')
203
+ // 标注重复媒体去重:同一图标可能同时以 symbol 与 slice 形态出现(rect 几乎重合),
204
+ // 二者都会被各自消费通道认领一份,未消费的副本会渲染成孤立行——保留 symbol 移除 slice 副本。
205
+ {
206
+ const sameRect = (a, b) => Math.abs((rect(a).x || 0) - (rect(b).x || 0)) <= 1
207
+ && Math.abs((rect(a).y || 0) - (rect(b).y || 0)) <= 1
208
+ && Math.abs((rect(a).width || 0) - (rect(b).width || 0)) <= 1
209
+ && Math.abs((rect(a).height || 0) - (rect(b).height || 0)) <= 1
210
+ for (let i = slices.length - 1; i >= 0; i -= 1) {
211
+ if (symbols.some((sy) => sameRect(slices[i], sy))) slices.splice(i, 1)
212
+ }
213
+ }
214
+
215
+ // —— Tab 切换头:顶部 2-3 个并列短文本 ——
216
+ const tabs = []
217
+ if (texts.length >= 2) {
218
+ const top = texts[0]
219
+ const sameLine = texts.filter((t) => sameRow(t, top) && t !== top)
220
+ if (sameLine.length >= 1 && sameLine.length <= 3) {
221
+ const group = [top, ...sameLine].sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
222
+ const allShort = group.every((t) => textOf(t).length <= 5)
223
+ const spread = (rect(group[group.length - 1]).x || 0) - (rect(group[0]).x || 0)
224
+ if (allShort && spread >= 100) {
225
+ group.forEach((t, i) => {
226
+ const T = ts(t)
227
+ tabs.push({
228
+ value: textOf(t),
229
+ color: T.color || '#111111',
230
+ fontSize: T.fontSize || 16,
231
+ fontWeight: T.fontWeight || '500',
232
+ height: rect(t).height || 0,
233
+ x: rect(t).x || 0,
234
+ width: rect(t).width || 0,
235
+ y: rect(t).y || 0,
236
+ })
237
+ })
238
+ }
239
+ }
240
+ }
241
+ const tabY = tabs.length ? rect(texts.find((t) => textOf(t) === tabs[0].value) || texts[0]).y || 0 : 0
242
+
243
+ // —— 选项组:同高、近似同宽、横向等距、含居中文本的短壳。 ——
244
+ // 只依赖几何重复性,不识别选项文案;≥2 个才成组,避免把独立主按钮误判为选项。
245
+ const optionGroups = (() => {
246
+ const chipCandidates = shapes.filter((shape) => {
247
+ const r = rect(shape)
248
+ const width = r.width || 0
249
+ const height = r.height || 0
250
+ if (width < 56 || width > 180 || height < 26 || height > 60) return false
251
+ const radius = shape.style?.radius?.borderRadius || 0
252
+ const hasVisualShell = !!shape.style?.fill?.color || !!shape.style?.border || radius >= 4
253
+ const hasCenterText = texts.some((text) => {
254
+ const tr = rect(text)
255
+ return true
256
+ && (tr.x || 0) >= (r.x || 0) - 4 && (tr.x || 0) + (tr.width || 0) <= (r.x || 0) + width + 4
257
+ && (tr.y || 0) >= (r.y || 0) - 4 && (tr.y || 0) + (tr.height || 0) <= (r.y || 0) + height + 4
258
+ })
259
+ return hasVisualShell && hasCenterText
260
+ }).sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0) || (rect(a).x || 0) - (rect(b).x || 0))
261
+
262
+ const rows = []
263
+ for (const shape of chipCandidates) {
264
+ const current = rect(shape)
265
+ const row = rows.find((items) => {
266
+ const first = rect(items[0])
267
+ return Math.abs((current.y || 0) - (first.y || 0)) <= 4
268
+ && Math.abs((current.height || 0) - (first.height || 0)) <= Math.max(4, (first.height || 0) * 0.2)
269
+ })
270
+ if (row) row.push(shape)
271
+ else rows.push([shape])
272
+ }
273
+
274
+ const groups = []
275
+ for (const row of rows) {
276
+ row.sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
277
+ let segment = []
278
+ const flush = () => {
279
+ if (segment.length < 2) { segment = []; return }
280
+ const rects = segment.map((shape) => rect(shape))
281
+ const widths = rects.map((item) => item.width || 0)
282
+ const gaps = []
283
+ for (let index = 1; index < segment.length; index += 1) {
284
+ gaps.push((rects[index].x || 0) - ((rects[index - 1].x || 0) + (rects[index - 1].width || 0)))
285
+ }
286
+ const regularGaps = gaps.length && Math.max(...gaps) - Math.min(...gaps) <= Math.max(...gaps) * 0.5
287
+ const similarWidths = Math.max(...widths) - Math.min(...widths) <= Math.max(...widths) * 0.3
288
+ const solidPair = segment.length === 2 && segment.every((shape) =>
289
+ !!shape.style?.fill?.color && !shape.style?.fill?.gradient)
290
+ if (!regularGaps || !similarWidths || (segment.length === 2 && !solidPair)) { segment = []; return }
291
+ const left = Math.min(...rects.map((item) => item.x || 0))
292
+ const top = Math.min(...rects.map((item) => item.y || 0))
293
+ const right = Math.max(...rects.map((item) => (item.x || 0) + (item.width || 0)))
294
+ const bottom = Math.max(...rects.map((item) => (item.y || 0) + (item.height || 0)))
295
+ const members = segment.map((shape) => {
296
+ const sr = rect(shape)
297
+ const inner = texts.find((text) => {
298
+ const tr = rect(text)
299
+ return (tr.x || 0) >= (sr.x || 0) - 4 && (tr.x || 0) + (tr.width || 0) <= (sr.x || 0) + (sr.width || 0) + 4
300
+ && (tr.y || 0) >= (sr.y || 0) - 4 && (tr.y || 0) + (tr.height || 0) <= (sr.y || 0) + (sr.height || 0) + 4
301
+ })
302
+ const style = ts(inner)
303
+ return {
304
+ text: textOf(inner),
305
+ color: style.color || '#111111',
306
+ fontSize: style.fontSize || 14,
307
+ fontWeight: style.fontWeight || '400',
308
+ fillColor: shape.style?.fill?.color || null,
309
+ gradient: shape.style?.fill?.gradient || null,
310
+ radius: shape.style?.radius?.borderRadius || 6,
311
+ width: sr.width || 0,
312
+ height: sr.height || 0,
313
+ x: sr.x || 0,
314
+ y: sr.y || 0,
315
+ sourceShapeEl: shape,
316
+ sourceTextEl: inner,
317
+ }
318
+ })
319
+ const memberTexts = new Set(members.map((member) => member.sourceTextEl).filter(Boolean))
320
+ const labelCands = texts.filter((text) => {
321
+ const tr = rect(text)
322
+ const bottom = (tr.y || 0) + (tr.height || 0)
323
+ return !memberTexts.has(text) && bottom <= top && top - bottom <= 48
324
+ && (tr.x || 0) >= left - 24 && (tr.x || 0) <= left + 24
325
+ && (ts(text).fontSize || 0) >= 14
326
+ }).sort((a, b) => (rect(b).y || 0) - (rect(a).y || 0))
327
+ let labelEl = labelCands[0] || null
328
+ let _sideLabel = false
329
+ if (!labelEl) {
330
+ // Side label: text at same y (vertically overlapping), to the LEFT of the group
331
+ const _grpCY = top + (bottom - top) / 2
332
+ const _sideCands = texts.filter((text) => {
333
+ const tr = rect(text)
334
+ const tcY = (tr.y || 0) + (tr.height || 0) / 2
335
+ return !memberTexts.has(text)
336
+ && Math.abs(tcY - _grpCY) <= Math.max(15, (bottom - top) * 0.6)
337
+ && (tr.x || 0) + (tr.width || 0) <= left + 4
338
+ && (ts(text).fontSize || 0) >= 13
339
+ }).sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
340
+ if (_sideCands[0]) { labelEl = _sideCands[0]; _sideLabel = true }
341
+ }
342
+ const labelStyle = labelEl ? ts(labelEl) : null
343
+ groups.push({
344
+ x: left,
345
+ y: top,
346
+ labelY: labelEl ? rect(labelEl).y || 0 : top,
347
+ width: right - left,
348
+ height: bottom - top,
349
+ label: labelEl ? {
350
+ text: textOf(labelEl),
351
+ color: labelStyle.color || '#111111',
352
+ fontSize: labelStyle.fontSize || 16,
353
+ fontWeight: labelStyle.fontWeight || '500',
354
+ height: rect(labelEl).height || (labelStyle.fontSize || 16) * 1.4,
355
+ x: rect(labelEl).x || 0,
356
+ y: rect(labelEl).y || 0,
357
+ width: rect(labelEl).width || 0,
358
+ } : null,
359
+ members,
360
+ sourceLabelEl: labelEl, sideLabel: _sideLabel,
361
+ sourceTextEls: members.map((m) => m.sourceTextEl).filter(Boolean),
362
+ })
363
+ segment = []
364
+ }
365
+ for (const shape of row) {
366
+ if (!segment.length) { segment.push(shape); continue }
367
+ const previous = rect(segment[segment.length - 1])
368
+ const current = rect(shape)
369
+ const gap = (current.x || 0) - ((previous.x || 0) + (previous.width || 0))
370
+ if (gap < 4 || gap > 48 || Math.abs((current.width || 0) - (previous.width || 0)) > Math.max(previous.width || 0) * 0.3) {
371
+ flush()
372
+ }
373
+ segment.push(shape)
374
+ }
375
+ flush()
376
+ }
377
+ // —— 竖排选项组(整行 pill 纵向等距堆叠,如身份/金额维度选择列表)——
378
+ // 横向 chip 判定 width≤180 会漏掉整行选项(宽 311 > 180),导致选项被误吞为按钮/列表标题。
379
+ const cardW0 = rect(card).width || (spec.size && spec.size.width) || 375
380
+ const memberOf = (shape) => {
381
+ const sr = rect(shape)
382
+ const inner = texts.find((text) => {
383
+ const tr = rect(text)
384
+ return (tr.x || 0) >= (sr.x || 0) - 4 && (tr.x || 0) + (tr.width || 0) <= (sr.x || 0) + (sr.width || 0) + 4
385
+ && (tr.y || 0) >= (sr.y || 0) - 4 && (tr.y || 0) + (tr.height || 0) <= (sr.y || 0) + (sr.height || 0) + 4
386
+ })
387
+ if (!inner) return null
388
+ const style = ts(inner)
389
+ return {
390
+ text: textOf(inner),
391
+ color: style.color || '#111111',
392
+ fontSize: style.fontSize || 14,
393
+ fontWeight: style.fontWeight || '400',
394
+ fillColor: shape.style?.fill?.color || null,
395
+ gradient: shape.style?.fill?.gradient || null,
396
+ radius: shape.style?.radius?.borderRadius || 6,
397
+ width: sr.width || 0,
398
+ height: sr.height || 0,
399
+ x: sr.x || 0,
400
+ y: sr.y || 0,
401
+ textInset: Math.max(0, Math.round((rect(inner).x || 0) - (sr.x || 0))),
402
+ sourceTextEl: inner,
403
+ sourceShapeEl: shape,
404
+ }
405
+ }
406
+ const cardFillUpper = String(card.cardFill || '#FFFFFF').toUpperCase()
407
+ const vCands = shapes.filter((shape) => {
408
+ const r = rect(shape)
409
+ const w = r.width || 0, h = r.height || 0
410
+ if (w < cardW0 * 0.5 || h < 26 || h > 64) return false
411
+ const radius = shape.style?.radius?.borderRadius || 0
412
+ // 选项 pill 必须可见(填充色 ≠ 卡底色)——同底色的整行壳是表单行/内容行(如地址表单行),
413
+ // 且表单行壳内含 label+value 两个文本,选项 pill 只含一个文本。两个信号缺一不可。
414
+ const fillColor = String(shape.style?.fill?.color || '').toUpperCase()
415
+ if (!fillColor || fillColor === cardFillUpper) return false
416
+ if (!shape.style?.fill?.color && !shape.style?.border && radius < 4) return false
417
+ const insideTexts = texts.filter((text) => {
418
+ const tr = rect(text)
419
+ return (tr.x || 0) >= (r.x || 0) - 4 && (tr.x || 0) + (tr.width || 0) <= (r.x || 0) + w + 4
420
+ && (tr.y || 0) >= (r.y || 0) - 4 && (tr.y || 0) + (tr.height || 0) <= (r.y || 0) + h + 4
421
+ })
422
+ return insideTexts.length === 1
423
+ }).sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
424
+ let vSeg = []
425
+ const vFlush = () => {
426
+ if (vSeg.length < 2) { vSeg = []; return }
427
+ const gaps = []
428
+ for (let i = 1; i < vSeg.length; i++) {
429
+ gaps.push((rect(vSeg[i]).y || 0) - ((rect(vSeg[i - 1]).y || 0) + (rect(vSeg[i - 1]).height || 0)))
430
+ }
431
+ const uniform = gaps.length && gaps.every((g) => Math.abs(g - gaps[0]) <= Math.max(4, Math.abs(gaps[0]) * 0.5))
432
+ if (!uniform) { vSeg = []; return }
433
+ const members = vSeg.map(memberOf).filter(Boolean)
434
+ if (members.length < 2) { vSeg = []; return }
435
+ const left = Math.min(...vSeg.map((s) => rect(s).x || 0))
436
+ const top = Math.min(...vSeg.map((s) => rect(s).y || 0))
437
+ const right = Math.max(...vSeg.map((s) => (rect(s).x || 0) + (rect(s).width || 0)))
438
+ const bottom = Math.max(...vSeg.map((s) => (rect(s).y || 0) + (rect(s).height || 0)))
439
+ const labelCands = texts.filter((text) => {
440
+ const tr = rect(text)
441
+ const bottom = (tr.y || 0) + (tr.height || 0)
442
+ return !members.some((m) => m.sourceTextEl === text) && bottom <= top && top - bottom <= 48
443
+ && (tr.x || 0) >= left - 24 && (tr.x || 0) <= left + 24
444
+ && (ts(text).fontSize || 0) >= 14
445
+ }).sort((a, b) => (rect(b).y || 0) - (rect(a).y || 0))
446
+ const labelEl = labelCands[0] || null
447
+ const labelStyle = labelEl ? ts(labelEl) : null
448
+ groups.push({
449
+ x: left, y: top,
450
+ labelY: labelEl ? rect(labelEl).y || 0 : top,
451
+ width: right - left,
452
+ height: bottom - top,
453
+ label: labelEl ? {
454
+ text: textOf(labelEl),
455
+ color: labelStyle.color || '#111111',
456
+ fontSize: labelStyle.fontSize || 16,
457
+ fontWeight: labelStyle.fontWeight || '500',
458
+ height: rect(labelEl).height || (labelStyle.fontSize || 16) * 1.4,
459
+ } : null,
460
+ members,
461
+ memberGap: Math.round(gaps[0] || 8),
462
+ vertical: true,
463
+ sourceLabelEl: labelEl,
464
+ sourceTextEls: members.map((m) => m.sourceTextEl),
465
+ })
466
+ vSeg = []
467
+ }
468
+ for (const shape of vCands) {
469
+ if (!vSeg.length) { vSeg.push(shape); continue }
470
+ const prev = rect(vSeg[vSeg.length - 1])
471
+ const curr = rect(shape)
472
+ const sameCol = Math.abs((curr.x || 0) - (prev.x || 0)) <= 4
473
+ && Math.abs((curr.width || 0) - (prev.width || 0)) <= Math.max(4, (prev.width || 0) * 0.05)
474
+ && Math.abs((curr.height || 0) - (prev.height || 0)) <= Math.max(4, (prev.height || 0) * 0.2)
475
+ if (!sameCol) vFlush()
476
+ vSeg.push(shape)
477
+ }
478
+ vFlush()
479
+ return groups.sort((a, b) => a.y - b.y)
480
+ })()
481
+ // 成员已携带源 shape 引用,直接汇集——几何重匹配对竖排组必然失配
482
+ // (成员 y 各不相同,按组顶 y±2 匹配为 0 → 选项壳漏判 → 选项被重复渲染)。
483
+ const optionShapeSet = new Set(optionGroups.flatMap((group) =>
484
+ group.members.map((m) => m.sourceShapeEl).filter(Boolean)))
485
+ const optionTextSet = new Set(optionGroups.flatMap((group) => group.sourceTextEls || []))
486
+ const optionLabelSet = new Set(optionGroups.map((group) => group.sourceLabelEl).filter(Boolean))
487
+ if (optionGroups.length) {
488
+ for (const group of optionGroups) delete group.sourceTextEls
489
+ }
490
+
491
+ // —— 按钮:宽 80-220 高 30-70 的圆角 shape + 内嵌居中文本;小 pill(行内操作件)放宽到 40-80×20-36 ——
492
+ const _cardRForPill = rect(card)
493
+ const _pillRingCache = new Map()
494
+ const _pillRingColor = (sr) => {
495
+ const key = `${sr.x}:${sr.y}:${sr.width}:${sr.height}`
496
+ if (_pillRingCache.has(key)) return _pillRingCache.get(key)
497
+ const color = _designImage
498
+ ? sampleOutlineRing(_designImage, Math.round(_cardRForPill.width || 375), {
499
+ x: (sr.x || 0) - (_cardRForPill.x || 0),
500
+ y: (sr.y || 0) - (_cardRForPill.y || 0),
501
+ width: sr.width || 0,
502
+ height: sr.height || 0,
503
+ })
504
+ : null
505
+ _pillRingCache.set(key, color)
506
+ return color
507
+ }
508
+ const buttons = []
509
+ const tabYs = tabs.map((t) => rect(texts.find((e) => textOf(e) === t.value) || { y: -1 }).y || -1)
510
+ const buttonShapes = shapes.filter((s) => {
511
+ const sr = rect(s)
512
+ const w = sr.width || 0, h = sr.height || 0
513
+ const _radius = radiusMax(s.style)
514
+ // 小 pill(行内操作件,如 修改/重发):宽 40-80 高 20-36——主按钮尺寸带(w>=80)会漏判。
515
+ const smallPill = w >= 40 && w < 80 && h >= 20 && h <= 36
516
+ if (!(w >= 80 && w <= 360 && h >= 30 && h <= 80) && !smallPill) return false
517
+ const f = (s.style || {}).fill || {}
518
+ const fillUpper = String(f.color || '').toUpperCase().trim()
519
+ const hasFillEvidence = f.gradient
520
+ || (f.color && !['#FFFFFF','#F8F8F8','#FAFAFA','#F0F0F0','#EFF0F6'].includes(fillUpper))
521
+ const hasBorderEvidence = !!(s.style?.border?.isBorder)
522
+ const ok = hasFillEvidence
523
+ || hasBorderEvidence
524
+ || (_radius >= 10 && h >= 30 && w >= 80)
525
+ // 无填充/无描边标注的小 pill:以图像环采样为证据(视觉优先),无图则保守放过
526
+ || (smallPill && _radius >= 8 && _pillRingColor(sr) != null)
527
+ if (!ok) return false
528
+ if (tabYs.some((y) => Math.abs((sr.y || 0) - y) < 30)) return false
529
+ const innerTexts = texts.filter((t) => {
530
+ const tr = rect(t)
531
+ return (tr.x || 0) >= (sr.x || 0) && (tr.y || 0) >= (sr.y || 0)
532
+ && ((tr.x || 0) + (tr.width || 0)) <= ((sr.x || 0) + w)
533
+ && ((tr.y || 0) + (tr.height || 0)) <= ((sr.y || 0) + h)
534
+ })
535
+ const textRowCount = new Set(innerTexts.map((t) => Math.round((rect(t).y || 0) / 18))).size
536
+ if (textRowCount > 1) return false
537
+ return !optionShapeSet.has(s)
538
+ })
539
+ for (const s of buttonShapes) {
540
+ const sr = rect(s)
541
+ const inner = texts.filter((t) => {
542
+ const tr = rect(t)
543
+ const T = ts(t)
544
+ // 文本框几何居中即可:设计工具的 textAlign 描述的是文本层内部对齐(常为 right),
545
+ // 不代表按钮文案未居中(曾有"确定"按钮因此漏识别,黄底全丢)。
546
+ const geoCentered = Math.abs(((tr.x || 0) + (tr.width || 0) / 2) - ((sr.x || 0) + (sr.width || 0) / 2)) <= Math.max(8, (sr.width || 0) * 0.08)
547
+ return (T.textAlign === 'center' || geoCentered)
548
+ && (tr.x || 0) >= (sr.x || 0) - 4 && (tr.x || 0) + (tr.width || 0) <= (sr.x || 0) + (sr.width || 0) + 4
549
+ && (tr.y || 0) >= (sr.y || 0) - 4 && (tr.y || 0) + (tr.height || 0) <= (sr.y || 0) + (sr.height || 0) + 4
550
+ })
551
+ if (!inner.length) continue
552
+ const t = inner[0]
553
+ const T = ts(t)
554
+ const fill = (s.style || {}).fill || {}
555
+ const r = (s.style || {}).radius || {}
556
+ buttons.push({
557
+ text: textOf(t),
558
+ color: T.color || '#FFFFFF',
559
+ fontSize: T.fontSize || 17,
560
+ fontWeight: T.fontWeight || '500',
561
+ fillColor: fill.color || null,
562
+ gradient: fill.gradient || null,
563
+ opacity: s.style?.opacity ?? 1,
564
+ radius: radiusMax(s.style),
565
+ borderColor: (s.style?.border?.isBorder && s.style?.border?.borderColor)
566
+ ? s.style.border.borderColor
567
+ : ((!fill.color && !fill.gradient) ? _pillRingColor(sr) : null),
568
+ })
569
+ }
570
+ buttons.sort((a, b) => 0) // 保持 spec 顺序(已在 buttonShapes 里)
571
+ // 按 x 坐标排序,左侧在前
572
+ const buttonShapeSorted = buttonShapes.sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
573
+ buttons.length = 0
574
+ for (const s of buttonShapeSorted) {
575
+ const sr = rect(s)
576
+ const inner = texts.find((t) => {
577
+ const tr = rect(t)
578
+ const T = ts(t)
579
+ const geoCentered = Math.abs(((tr.x || 0) + (tr.width || 0) / 2) - ((sr.x || 0) + (sr.width || 0) / 2)) <= Math.max(8, (sr.width || 0) * 0.08)
580
+ return (T.textAlign === 'center' || geoCentered)
581
+ && (tr.x || 0) >= (sr.x || 0) - 4 && (tr.x || 0) + (tr.width || 0) <= (sr.x || 0) + (sr.width || 0) + 4
582
+ && (tr.y || 0) >= (sr.y || 0) - 4 && (tr.y || 0) + (tr.height || 0) <= (sr.y || 0) + (sr.height || 0) + 4
583
+ })
584
+ if (!inner) continue
585
+ const T = ts(inner)
586
+ const fill = (s.style || {}).fill || {}
587
+ const r = (s.style || {}).radius || {}
588
+ buttons.push({
589
+ text: textOf(inner),
590
+ color: T.color || '#FFFFFF',
591
+ fontSize: T.fontSize || 17,
592
+ fontWeight: T.fontWeight || '500',
593
+ align: 'center', // 按钮文案几何居中才通过检测;textAlign 是文本层内部对齐(常为 right),不能用于按钮对齐
594
+ fillColor: fill.color || null,
595
+ gradient: fill.gradient || null,
596
+ opacity: s.style?.opacity ?? 1,
597
+ radius: radiusMax(s.style),
598
+ borderColor: (s.style?.border?.isBorder && s.style?.border?.borderColor)
599
+ ? s.style.border.borderColor
600
+ : ((!fill.color && !fill.gradient) ? _pillRingColor(sr) : null),
601
+ sourceEl: inner,
602
+ height: sr.height || 46,
603
+ width: sr.width || 0,
604
+ x: sr.x || 0,
605
+ y: sr.y || 0, // 按钮几何(footer 文案第一项上方留白推导用)
606
+ })
607
+ }
608
+
609
+ // 空间散布的图表顶点标签(如雷达图五边形周围的类别 pill):
610
+ // 动作按钮必然横向成排(y-span ≤ 30px),若 ≥3 个候选按钮的 y 跨度 > 40px
611
+ // 且分布在 ≥3 个 y 带,判为空间散布的图表顶点标签而非按钮排。
612
+ // 检出后:收集这些 pill + 其周围的文本/形状为一个 vertexRegion(AbsoluteLayout 整块渲染),
613
+ // 从 buttons / auxTexts / postAmountTexts / isolatedBlocks 等路径排除以防重影。
614
+ let vertexClusters = null
615
+ const _vertexConsumedEls = new Set()
616
+ if (buttons.length >= 3) {
617
+ const ys = buttons.map((b) => b.y || 0)
618
+ const xs = buttons.map((b) => b.x || 0)
619
+ const ySpan = Math.max(...ys) - Math.min(...ys)
620
+ const xSpan = Math.max(...xs) - Math.min(...xs)
621
+ if (ySpan > 40 && xSpan > 80) {
622
+ const yBands = new Set(ys.map((y) => Math.floor(y / 20)))
623
+ if (yBands.size >= 3) {
624
+ vertexClusters = buttons.map((btn, bi) => {
625
+ const bx = btn.x || 0, by = btn.y || 0
626
+ const bw = btn.width || 0, bh = btn.height || 0
627
+ const pillShape = buttonShapeSorted[bi]
628
+ if (pillShape) _vertexConsumedEls.add(pillShape)
629
+ if (btn.sourceEl) _vertexConsumedEls.add(btn.sourceEl)
630
+ const clusterEls = content.filter((e) => {
631
+ if (e === btn.sourceEl || (pillShape && e === pillShape)) return false
632
+ if (_vertexConsumedEls.has(e)) return false
633
+ const r = rect(e)
634
+ const ex = (r.x || 0) + (r.width || 0) / 2
635
+ const ey = (r.y || 0) + (r.height || 0) / 2
636
+ return ex >= bx - 15 && ex <= bx + bw + 15
637
+ && ey >= by - 8 && ey <= by + bh + 35
638
+ })
639
+ clusterEls.forEach((e) => _vertexConsumedEls.add(e))
640
+ return {
641
+ pill: {
642
+ text: btn.text, color: btn.color, fontSize: btn.fontSize,
643
+ fontWeight: btn.fontWeight || '400',
644
+ fillColor: btn.fillColor, radius: btn.radius,
645
+ shape: pillShape, sourceEl: btn.sourceEl,
646
+ },
647
+ texts: clusterEls.filter((e) => textOf(e)),
648
+ shapes: clusterEls.filter((e) => !textOf(e)),
649
+ x: bx, y: by, width: bw, height: bh,
650
+ }
651
+ })
652
+ buttons.length = 0
653
+ for (let ti = texts.length - 1; ti >= 0; ti--)
654
+ if (_vertexConsumedEls.has(texts[ti])) texts.splice(ti, 1)
655
+ for (let si = shapes.length - 1; si >= 0; si--)
656
+ if (_vertexConsumedEls.has(shapes[si])) shapes.splice(si, 1)
657
+ for (let ci2 = content.length - 1; ci2 >= 0; ci2--)
658
+ if (_vertexConsumedEls.has(content[ci2])) content.splice(ci2, 1)
659
+ for (let bs2 = buttonShapes.length - 1; bs2 >= 0; bs2--)
660
+ if (_vertexConsumedEls.has(buttonShapes[bs2])) buttonShapes.splice(bs2, 1)
661
+ }
662
+ for (let si3 = slices.length - 1; si3 >= 0; si3--)
663
+ if (_vertexConsumedEls.has(slices[si3])) slices.splice(si3, 1)
664
+ }
665
+ }
666
+ // 重复面板组(同尺寸壳纵向等距重复、壳内结构同构)→ 面板列表 ——
667
+ // 纯几何识别,无任何业务词:
668
+ // ① ≥2 个同尺寸(±4px)带填充/渐变的壳(非按钮、非全卡背景)
669
+ // ② 纵向 pitch 一致(±8px)
670
+ // ③ 每壳成员文本数一致且字号多重集一致(结构同构:同产品不同内容)
671
+ // ④ 壳内无切片(含切片的壳结构复杂,回退既有逐语义路径)
672
+ // 命中后:壳成员文本/按钮从主金额/表单行/列表/辅助文案等所有单例语义路径排除,
673
+ // 由 panelGroup 整体接管渲染(否则同一面板内容被多条路径重复渲染)。
674
+ const _panelGroupShells = []
675
+ const _panelGroupMemberEls = new Set()
676
+ const _panelGroupButtonShapeEls = new Set()
677
+ const _panelGroupShellSet = new Set()
678
+ let panelGroup = null
679
+ {
680
+ const cardR = rect(card)
681
+ const centerIn = (el, sr) => {
682
+ const er = rect(el)
683
+ const cx = (er.x || 0) + (er.width || 0) / 2
684
+ const cy = (er.y || 0) + (er.height || 0) / 2
685
+ return cx >= (sr.x || 0) && cx <= (sr.x || 0) + (sr.width || 0)
686
+ && cy >= (sr.y || 0) && cy <= (sr.y || 0) + (sr.height || 0)
687
+ }
688
+ const shellCands = shapes.filter((sh) => {
689
+ const sr = rect(sh)
690
+ const w = sr.width || 0, h = sr.height || 0
691
+ if (!w || !h) return false
692
+ if (w < (cardR.width || 375) * 0.6 || h < 60 || h > (cardR.height || 600) * 0.6) return false
693
+ if (buttonShapes.includes(sh)) return false
694
+ const f = (sh.style || {}).fill || {}
695
+ return !!(f.gradient || f.color)
696
+ }).sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
697
+ const sizeGroups = []
698
+ for (const sh of shellCands) {
699
+ const sr = rect(sh)
700
+ const grp = sizeGroups.find((g) => Math.abs(g.w - (sr.width || 0)) <= 4 && Math.abs(g.h - (sr.height || 0)) <= 4)
701
+ if (grp) grp.items.push(sh)
702
+ else sizeGroups.push({ w: sr.width || 0, h: sr.height || 0, items: [sh] })
703
+ }
704
+ for (const grp of sizeGroups) {
705
+ if (grp.items.length < 2) continue
706
+ const sorted = grp.items
707
+ const pitch = (rect(sorted[1]).y || 0) - (rect(sorted[0]).y || 0)
708
+ if (pitch <= 0) continue
709
+ if (!sorted.every((sh, i) => i < 2
710
+ || Math.abs(((rect(sh).y || 0) - (rect(sorted[i - 1]).y || 0)) - pitch) <= 8)) continue
711
+ const memberTexts = sorted.map((sh) => texts.filter((t) => centerIn(t, rect(sh))))
712
+ if (memberTexts.some((mt) => mt.length < 3)) continue
713
+ const fsSig = (mt) => mt.map((t) => ts(t).fontSize || 0).sort((a, b) => b - a).join(',')
714
+ if (!memberTexts.every((mt) => fsSig(mt) === fsSig(memberTexts[0]))) continue
715
+ if (sorted.some((sh) => slices.some((sl) => centerIn(sl, rect(sh))))) continue
716
+ _panelGroupShells.push(...sorted)
717
+ }
718
+ if (_panelGroupShells.length >= 2) {
719
+ const panels = []
720
+ for (const sh of _panelGroupShells) {
721
+ const sr = rect(sh)
722
+ const btnShape = buttonShapes.find((b) => centerIn(b, sr)) || null
723
+ const btnEntry = btnShape
724
+ ? buttons.find((b) => Math.abs((b.x || 0) - (rect(btnShape).x || 0)) <= 2
725
+ && Math.abs((b.y || 0) - (rect(btnShape).y || 0)) <= 2)
726
+ : null
727
+ const members = texts.filter((t) => centerIn(t, sr)
728
+ && !(btnShape && centerIn(t, rect(btnShape))))
729
+ // 行聚类:垂直重叠 ≥50% 归同一行(大字号行会包裹小字号的基线同行单位,如金额+万元),
730
+ // 行内按 x 排序
731
+ const rows = []
732
+ for (const t of [...members].sort((a, b) =>
733
+ ((rect(a).y || 0) - (rect(b).y || 0)) || ((rect(a).x || 0) - (rect(b).x || 0)))) {
734
+ const tr = rect(t)
735
+ const tTop = tr.y || 0
736
+ const tBottom = tTop + (tr.height || (ts(t).fontSize || 14) * 1.4)
737
+ const row = rows.find((r) => {
738
+ const overlap = Math.min(r.bottom, tBottom) - Math.max(r.y, tTop)
739
+ return overlap >= Math.min(tBottom - tTop, r.bottom - r.y) * 0.5
740
+ })
741
+ if (row) { row.items.push(t); row.bottom = Math.max(row.bottom, tBottom) }
742
+ else rows.push({ y: tTop, bottom: tBottom, items: [t] })
743
+ }
744
+ rows.sort((a, b) => a.y - b.y)
745
+ if (rows.length < 2) continue
746
+ // 角色按几何层级分配:首行最大字号=标题,同行其余=副标题;
747
+ // 剩余行中字号最大者=金额,其同行小字号=单位;再下一行=说明。
748
+ const titleRow = rows[0]
749
+ const titleEl = [...titleRow.items].sort((a, b) => (ts(b).fontSize || 0) - (ts(a).fontSize || 0))[0]
750
+ const subtitleEls = titleRow.items.filter((t) => t !== titleEl)
751
+ const restRows = rows.slice(1)
752
+ let amountRow = null; let amountEl = null
753
+ for (const r of restRows) {
754
+ for (const t of r.items) {
755
+ if (!amountEl || (ts(t).fontSize || 0) > (ts(amountEl).fontSize || 0)) { amountEl = t; amountRow = r }
756
+ }
757
+ }
758
+ const unitEls = amountRow ? amountRow.items.filter((t) => t !== amountEl) : []
759
+ const captionRow = restRows.find((r) => r !== amountRow && amountRow && r.y > amountRow.y)
760
+ || restRows.find((r) => r !== amountRow) || null
761
+ // 行内并排双列检测(column-compare tile):行内最大横向 gap ≥12 且 ≥2×次大 gap + 4
762
+ // → 该 gap 是列间缝(纯几何,不靠业务词)。左簇保持原角色(单位/说明),
763
+ // 右簇进 rightStack(逐行),否则右列文案被拼进 unit/caption(v6「%1万元起存」塌缩)。
764
+ const splitRowByColumnGap = (row) => {
765
+ if (!row || row.items.length < 2) return null
766
+ const sorted = [...row.items].sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
767
+ const gaps = sorted.slice(1).map((t, i) =>
768
+ (rect(t).x || 0) - ((rect(sorted[i]).x || 0) + (rect(sorted[i]).width || 0)))
769
+ let best = -1
770
+ for (let i = 0; i < gaps.length; i += 1) {
771
+ const others = gaps.filter((_, j) => j !== i)
772
+ const second = others.length ? Math.max(...others) : 0
773
+ if (gaps[i] >= 12 && gaps[i] >= 2 * second + 4 && (best < 0 || gaps[i] > gaps[best])) best = i
774
+ }
775
+ if (best < 0) return null
776
+ return { left: sorted.slice(0, best + 1), right: sorted.slice(best + 1), gap: Math.round(gaps[best]) }
777
+ }
778
+ const amountSplit = splitRowByColumnGap(amountRow)
779
+ const captionSplit = splitRowByColumnGap(captionRow)
780
+ const unitElsFinal = amountSplit ? amountSplit.left.filter((t) => t !== amountEl) : unitEls
781
+ const captionItemsFinal = captionSplit ? captionSplit.left : (captionRow ? captionRow.items : [])
782
+ const rightStack = [amountSplit, captionSplit]
783
+ .filter(Boolean)
784
+ .map((split) => {
785
+ const head = split.right[0]
786
+ return {
787
+ text: split.right.map((t) => textOf(t)).join(' '),
788
+ color: ts(head).color, fontSize: ts(head).fontSize,
789
+ fontWeight: ts(head).fontWeight, height: rect(head).height || 0,
790
+ }
791
+ })
792
+ const columnGaps = [amountSplit, captionSplit].filter(Boolean).map((s) => s.gap)
793
+ const rightHeads = [amountSplit, captionSplit].filter(Boolean).map((s) => rect(s.right[0]).x || 0)
794
+ const padLeftV = Math.max(0, Math.round((rect(titleEl).x || 0) - (sr.x || 0)))
795
+ const f = (sh.style || {}).fill || {}
796
+ const titleRowH = Math.max(...titleRow.items.map((t) => rect(t).height || (ts(t).fontSize || 14) * 1.4))
797
+ const amountRowBottom = amountRow
798
+ ? amountRow.y + Math.max(...amountRow.items.map((t) => rect(t).height || (ts(t).fontSize || 14) * 1.4))
799
+ : titleRow.y + titleRowH
800
+ panels.push({
801
+ shellEl: sh,
802
+ x: sr.x || 0, y: sr.y || 0, width: sr.width || 0, height: sr.height || 0,
803
+ gradient: f.gradient || null, fill: f.color || null,
804
+ opacity: sh.style?.opacity ?? 1,
805
+ radius: (sh.style || {}).radius?.borderRadius || 0,
806
+ padTop: Math.max(0, Math.round(titleRow.y - (sr.y || 0))),
807
+ padLeft: padLeftV,
808
+ // 右列左沿相对内容左沿的实测偏移(列缝定位用,纯几何)
809
+ rightOffset: rightHeads.length
810
+ ? Math.max(0, Math.round(Math.min(...rightHeads) - ((sr.x || 0) + padLeftV))) : 0,
811
+ padRight: btnShape ? Math.max(0, Math.round(((sr.x || 0) + (sr.width || 0))
812
+ - ((rect(btnShape).x || 0) + (rect(btnShape).width || 0)))) : 0,
813
+ gapTitleAmount: amountRow ? Math.max(0, Math.round(amountRow.y - (titleRow.y + titleRowH))) : 0,
814
+ gapAmountCaption: captionRow ? Math.max(0, Math.round(captionRow.y - amountRowBottom)) : 0,
815
+ gapTitleSubtitle: subtitleEls.length
816
+ ? Math.max(0, Math.round((rect(subtitleEls[0]).x || 0)
817
+ - ((rect(titleEl).x || 0) + (rect(titleEl).width || 0)))) : 0,
818
+ gapAmountUnit: unitElsFinal.length
819
+ ? Math.max(0, Math.round((rect(unitElsFinal[0]).x || 0)
820
+ - ((rect(amountEl).x || 0) + (rect(amountEl).width || 0)))) : 0,
821
+ columnGap: columnGaps.length
822
+ ? columnGaps.slice().sort((a, b) => a - b)[Math.floor(columnGaps.length / 2)] : 0,
823
+ rightStack,
824
+ title: textOf(titleEl),
825
+ titleColor: ts(titleEl).color, titleFontSize: ts(titleEl).fontSize, titleFontWeight: ts(titleEl).fontWeight,
826
+ titleHeight: rect(titleEl).height || 0,
827
+ subtitle: subtitleEls.map((t) => textOf(t)).join(' '),
828
+ subtitleColor: subtitleEls[0] ? ts(subtitleEls[0]).color : undefined,
829
+ subtitleFontSize: subtitleEls[0] ? ts(subtitleEls[0]).fontSize : undefined,
830
+ subtitleHeight: subtitleEls[0] ? (rect(subtitleEls[0]).height || 0) : 0,
831
+ amount: amountEl ? textOf(amountEl) : '',
832
+ amountColor: amountEl ? ts(amountEl).color : undefined,
833
+ amountFontSize: amountEl ? ts(amountEl).fontSize : undefined,
834
+ amountFontWeight: amountEl ? ts(amountEl).fontWeight : undefined,
835
+ amountHeight: amountEl ? (rect(amountEl).height || 0) : 0,
836
+ unit: unitElsFinal.map((t) => textOf(t)).join(''),
837
+ unitColor: unitElsFinal[0] ? ts(unitElsFinal[0]).color : undefined,
838
+ unitFontSize: unitElsFinal[0] ? ts(unitElsFinal[0]).fontSize : undefined,
839
+ unitFontWeight: unitElsFinal[0] ? ts(unitElsFinal[0]).fontWeight : undefined,
840
+ unitHeight: unitElsFinal[0] ? (rect(unitElsFinal[0]).height || 0) : 0,
841
+ caption: captionItemsFinal.map((t) => textOf(t)).join(' '),
842
+ captionColor: captionItemsFinal[0] ? ts(captionItemsFinal[0]).color : undefined,
843
+ captionFontSize: captionItemsFinal[0] ? ts(captionItemsFinal[0]).fontSize : undefined,
844
+ captionHeight: captionItemsFinal.length
845
+ ? Math.max(...captionItemsFinal.map((t) => rect(t).height || 0)) : 0,
846
+ button: btnEntry ? {
847
+ text: btnEntry.text, color: btnEntry.color, fontSize: btnEntry.fontSize,
848
+ fontWeight: btnEntry.fontWeight, fillColor: btnEntry.fillColor,
849
+ gradient: btnEntry.gradient, radius: btnEntry.radius,
850
+ width: btnEntry.width, height: btnEntry.height,
851
+ } : null,
852
+ })
853
+ for (const t of members) _panelGroupMemberEls.add(t)
854
+ if (btnEntry && btnEntry.sourceEl) _panelGroupMemberEls.add(btnEntry.sourceEl)
855
+ if (btnShape) _panelGroupButtonShapeEls.add(btnShape)
856
+ }
857
+ if (panels.length >= 2) {
858
+ const pitch = panels[1].y - panels[0].y
859
+ panelGroup = { items: panels, gap: Math.max(0, Math.round(pitch - panels[0].height)), y: panels[0].y }
860
+ for (const sh of _panelGroupShells) _panelGroupShellSet.add(sh)
861
+ // 面板成员按钮从全局按钮列表移除(由面板项模板渲染)
862
+ for (let i = buttons.length - 1; i >= 0; i -= 1) {
863
+ if (buttons[i].sourceEl && _panelGroupMemberEls.has(buttons[i].sourceEl)) buttons.splice(i, 1)
864
+ }
865
+ } else {
866
+ _panelGroupMemberEls.clear()
867
+ _panelGroupButtonShapeEls.clear()
868
+ _panelGroupShells.length = 0
869
+ }
870
+ }
871
+ }
872
+
873
+ // —— 重复波段探测:同一 label 文案出现在 ≥2 个垂直分离的并列组(如多行账户各自的
874
+ // 「活期余额/可用余额」列对)→ 这是列表行的重复结构,不是一组副金额。
875
+ // 命中时:主金额/副金额不得捕获波段内文本(否则全部行的金额被揉进一个金额块)。
876
+ const isAmountish = (value) => /\p{Sc}|元|\.\d|\d{1,3}(,\d{3})+/u.test(value) ||
877
+ value.replace(/[,,\p{Sc}\s\-]/gu, '').length >= 3
878
+ const _bandSrc = texts.filter((t) => {
879
+ const T = ts(t)
880
+ return (T.fontSize || 0) >= 12 && (T.fontSize || 0) <= 16
881
+ && !tabs.some((tab) => tab.value === textOf(t))
882
+ })
883
+ const _bandYGroups = new Map()
884
+ for (const lab of _bandSrc) {
885
+ const ly = rect(lab).y || 0
886
+ let hitGroup = false
887
+ for (const [key, group] of _bandYGroups) {
888
+ if (Math.abs(ly - key) <= 12) { group.push(lab); hitGroup = true; break }
889
+ }
890
+ if (!hitGroup) _bandYGroups.set(ly, [lab])
891
+ }
892
+ const _bands = [..._bandYGroups.entries()].sort((a, b) => a[0] - b[0])
893
+ .map(([top, g]) => [top, g.filter((lab) => {
894
+ const lr = rect(lab)
895
+ return texts.some((v) => {
896
+ if (v === lab || !isAmountish(textOf(v))) return false
897
+ const vr = rect(v)
898
+ const vdy = (vr.y || 0) - (lr.y || 0)
899
+ return vdy >= 10 && vdy <= 50 && Math.abs((vr.x || 0) - (lr.x || 0)) <= 24
900
+ })
901
+ })])
902
+ .filter(([, g]) => g.length >= 2)
903
+ const _repeatLabelTexts = new Set()
904
+ for (let bi = 0; bi < _bands.length; bi += 1) {
905
+ for (let bj = bi + 1; bj < _bands.length; bj += 1) {
906
+ if (_bands[bj][0] - _bands[bi][0] <= 30) continue
907
+ for (const lab of _bands[bj][1]) {
908
+ if (_bands[bi][1].some((o) => textOf(o) === textOf(lab))) _repeatLabelTexts.add(textOf(lab))
909
+ }
910
+ }
911
+ }
912
+ const repeatRowMode = _repeatLabelTexts.size > 0
913
+ const _repeatBandTops = _bands
914
+ .filter(([, g]) => g.some((lab) => _repeatLabelTexts.has(textOf(lab))))
915
+ .map(([top]) => top)
916
+ // 全量波段(含少数派行:标签不重复但几何同构——≥2 标签且各有金额值在正下方)。
917
+ // 用途:复合行模式下守卫主金额/表单行/行标题候选不被少数派行成员污染。
918
+ const _allBands = _bands.filter(([, g]) => g.length >= 2)
919
+ const _allBandTops = _allBands.map(([top]) => top)
920
+ const _allBandLabelEls = new Set(_allBands.flatMap(([, g]) => g))
921
+
922
+ // 卡片内同文案计数:同一文本在卡内出现 ≥2 次,是「重复行/面板成员」的强信号,
923
+ // 不能作为主金额/表单行标签/列表标题(这些都是单例结构节点)。纯文本计数,不依赖业务词。
924
+ const _contentTextCounts = new Map()
925
+ for (const t of texts) {
926
+ const k = (textOf(t) || '').trim()
927
+ if (!k) continue
928
+ _contentTextCounts.set(k, (_contentTextCounts.get(k) || 0) + 1)
929
+ }
930
+ const _isRepeatedRowText = (t) => (_contentTextCounts.get((textOf(t) || '').trim()) || 0) >= 2
931
+
932
+ // —— 主金额:fontSize ≥ 24 的数值文本 ——
933
+ // 金额特征(¥/元/小数点/千分位)或去符号后 ≥3 位,排除倒计时/序号/角标等 1-2 位短数字被误判为金额(如状态卡倒计时 "2")。
934
+ const sortedByFs = [...texts].sort((a, b) => (ts(b).fontSize || 0) - (ts(a).fontSize || 0))
935
+ let primaryAmountEl
936
+ sortedByFs.find((candidate) => {
937
+ if ((ts(candidate).fontSize || 0) < 24) return false
938
+ if (_panelGroupMemberEls.has(candidate)) return false // 面板成员:由面板列表模板渲染
939
+ // 复合行模式:落在标签波段(标签行+其下值行)内的大字号是行成员,不是全卡主金额。
940
+ // 注意:少数派行(标签文本不重复,如异类账户行)不构成"重复"波段,
941
+ // 但其几何结构(≥2 标签且各有金额值在正下方)与重复波段一致,同样应守卫。
942
+ if (repeatRowMode) {
943
+ const cy = rect(candidate).y || 0
944
+ if (_allBandTops.some((bt) => cy >= bt - 8 && cy <= bt + 70)) return false
945
+ }
946
+ const value = textOf(candidate)
947
+ // 重复出现的金额是行/面板成员(如产品面板列表每行同款金额),不是全卡唯一主视觉
948
+ // 但仅当候选位于某个「子壳」(小于卡片 98% 的填充 shape)内部时才算行成员——
949
+ // 壳外的顶部重复金额(总额与某面板数值巧合相同,如 ¥1,008.63 同现于头部与贷款面板)
950
+ // 仍是全卡主视觉,否则主金额会被错误降级到面板内部金额。
951
+ if (_isRepeatedRowText(candidate)) {
952
+ const cr = rect(candidate)
953
+ const ccx = (cr.x || 0) + (cr.width || 0) / 2
954
+ const ccy = (cr.y || 0) + (cr.height || 0) / 2
955
+ const cardW0 = rect(card).width || 375
956
+ const inSubShell = shapes.some((sh) => {
957
+ const sr = rect(sh)
958
+ if ((sr.width || 0) >= cardW0 * 0.98) return false // 卡片背景壳不算
959
+ const f = (sh.style || {}).fill || {}
960
+ if (!f.color && !f.gradient) return false
961
+ return ccx >= (sr.x || 0) && ccx <= (sr.x || 0) + (sr.width || 0)
962
+ && ccy >= (sr.y || 0) && ccy <= (sr.y || 0) + (sr.height || 0)
963
+ })
964
+ if (inSubShell) return false
965
+ }
966
+ if (isAmountish(value) && !/^\p{Sc}$/u.test(value)) {
967
+ primaryAmountEl = candidate
968
+ return true
969
+ }
970
+ const cr = rect(candidate)
971
+ const center = (cr.y || 0) + (cr.height || 0) / 2
972
+ const neighbour = texts.find((other) => {
973
+ if (other === candidate) return false
974
+ // 单位后缀(如"万元"/"元"/"%")字号显著小于主数字(fs≥11 即可),
975
+ // 与货币符号同属排版单位字符,不涉及业务语义;倒计时"秒"等不在其列。
976
+ const ofs = ts(other).fontSize || 0
977
+ const isUnitSuffix = /^[\u4e00-\u9fa5%%]{1,4}$/.test(textOf(other))
978
+ && /[元万%%]/.test(textOf(other))
979
+ && ofs >= 11 && ofs < (ts(candidate).fontSize || 0)
980
+ if (ofs < 20 && !isUnitSuffix) return false
981
+ const or = rect(other)
982
+ const otherCenter = (or.y || 0) + (or.height || 0) / 2
983
+ const gap = (or.x || 0) > ((cr.x || 0) + (cr.width || 0))
984
+ ? (or.x || 0) - ((cr.x || 0) + (cr.width || 0))
985
+ : ((cr.x || 0) > ((or.x || 0) + (or.width || 0))
986
+ ? (cr.x || 0) - ((or.x || 0) + (or.width || 0))
987
+ : 0)
988
+ return Math.abs(center - otherCenter) <= 12 && gap <= 36
989
+ && isAmountish(textOf(other)) && !/^\p{Sc}$/u.test(textOf(other))
990
+ })
991
+ if (!neighbour) return false
992
+ const nr = rect(neighbour)
993
+ // 单位后缀邻居(小字号 元/万/% 等):主金额只取数字本体,
994
+ // 单位由渲染期 unitText 路径以原始小字号单独渲染——合并成单文本会丢失字号差异。
995
+ const nIsUnitSuffix = /^[\u4e00-\u9fa5%%]{1,4}$/.test(textOf(neighbour))
996
+ && /[元万%%]/.test(textOf(neighbour))
997
+ && (ts(neighbour).fontSize || 0) < (ts(candidate).fontSize || 0)
998
+ if (nIsUnitSuffix && (nr.x || 0) >= (cr.x || 0) + (cr.width || 0) - 4) {
999
+ primaryAmountEl = candidate
1000
+ return true
1001
+ }
1002
+ const left = (rect(candidate).x || 0) <= (nr.x || 0) ? candidate : neighbour
1003
+ const right = left === candidate ? neighbour : candidate
1004
+ const lr = rect(left)
1005
+ const rr = rect(right)
1006
+ const base = (ts(left).fontSize || 0) >= (ts(right).fontSize || 0) ? left : right
1007
+ primaryAmountEl = {
1008
+ ...base,
1009
+ text: textOf(left) + textOf(right),
1010
+ rect: {
1011
+ x: Math.min(lr.x || 0, rr.x || 0),
1012
+ y: Math.min(lr.y || 0, rr.y || 0),
1013
+ width: Math.max((lr.x || 0) + (lr.width || 0), (rr.x || 0) + (rr.width || 0)) -
1014
+ Math.min(lr.x || 0, rr.x || 0),
1015
+ height: Math.max(lr.height || 0, rr.height || 0),
1016
+ },
1017
+ }
1018
+ return true
1019
+ })
1020
+ const primaryAmountIcon = primaryAmountEl ? slices.find((slice) => {
1021
+ const sr = rect(slice)
1022
+ const pr = rect(primaryAmountEl)
1023
+ return (sr.width || 0) >= 16 && (sr.width || 0) <= 44
1024
+ && Math.abs(((sr.y || 0) + (sr.height || 0) / 2) -
1025
+ ((pr.y || 0) + (pr.height || 0) / 2)) <= 16
1026
+ // 碎片合并后的层框右沿比可视字形宽(标注字体窄于渲染字体),
1027
+ // 允许图标左沿落在层框右沿内侧一小段(≤max(16, 12%宽)),但必须位于金额右半。
1028
+ && (sr.x || 0) >= ((pr.x || 0) + (pr.width || 0)) - Math.max(16, (pr.width || 0) * 0.12)
1029
+ && (sr.x || 0) + (sr.width || 0) > (pr.x || 0) + (pr.width || 0) * 0.6
1030
+ }) || null : null
1031
+ const primaryAmount = primaryAmountEl ? {
1032
+ text: textOf(primaryAmountEl),
1033
+ color: ts(primaryAmountEl).color,
1034
+ fontSize: ts(primaryAmountEl).fontSize,
1035
+ fontWeight: ts(primaryAmountEl).fontWeight,
1036
+ editable: !!primaryAmountIcon &&
1037
+ /^\p{Sc}/u.test(textOf(primaryAmountEl)) &&
1038
+ /\d/.test(textOf(primaryAmountEl)),
1039
+ y: rect(primaryAmountEl).y || 0,
1040
+ x: rect(primaryAmountEl).x || 0,
1041
+ width: rect(primaryAmountEl).width || 0,
1042
+ height: rect(primaryAmountEl).height || 0,
1043
+ iconSlice: primaryAmountIcon,
1044
+ // 窄体/非标字体(如 RobotoCondensed)平台无法表达:渲染字形比设计宽 ~15%,
1045
+ // Input 单行盒会裁掉末位并露出输入框白底——此类金额降级为绑定文本还原视觉。
1046
+ unsupportedFont: (() => {
1047
+ const family = String(ts(primaryAmountEl).fontFamily || ts(primaryAmountEl).fontFace || '')
1048
+ return !!family && !/^PingFang/i.test(family)
1049
+ })(),
1050
+ } : null
1051
+ for (const group of optionGroups) {
1052
+ const labelEl = group.sourceLabelEl
1053
+ const isPrimaryAmountFragment = primaryAmountEl && labelEl
1054
+ && textOf(primaryAmountEl).includes(textOf(labelEl))
1055
+ && Math.abs((rect(labelEl).y || 0) - (rect(primaryAmountEl).y || 0)) <= 8
1056
+ if (isPrimaryAmountFragment) {
1057
+ group.label = null
1058
+ group.labelY = group.y
1059
+ }
1060
+ delete group.sourceLabelEl
1061
+ }
1062
+
1063
+ // —— 副金额对:13-14px 居中标签 + 紧随其后的 16px 居中值(纯几何/尺寸驱动,不靠业务关键词)——
1064
+
1065
+ const subAmounts = []
1066
+ const labels = texts.filter((t) => {
1067
+ const T = ts(t)
1068
+ if (_panelGroupMemberEls.has(t)) return false
1069
+ if (repeatRowMode && _repeatLabelTexts.has(textOf(t))) return false
1070
+ return (T.fontSize || 0) >= 12 && (T.fontSize || 0) <= 16
1071
+ && !tabs.some((tab) => tab.value === textOf(t))
1072
+ })
1073
+ // Filter out text fragments that are spatially part of the primary amount (split ¥ + number)
1074
+ const primaryRect = primaryAmountEl ? rect(primaryAmountEl) : null
1075
+ const isPrimaryFragment = (t) => {
1076
+ if (!primaryRect || t === primaryAmountEl) return true
1077
+ const r = rect(t)
1078
+ const xOverlap = (r.x || 0) < (primaryRect.x || 0) + (primaryRect.width || 0)
1079
+ && (r.x || 0) + (r.width || 0) > (primaryRect.x || 0)
1080
+ return Math.abs((r.y || 0) - (primaryRect.y || 0)) <= 8 && xOverlap
1081
+ }
1082
+ // Group labels by Y baseline - subAmounts are parallel columns at the same Y
1083
+ const labelYGroups = new Map()
1084
+ for (const lab of labels) {
1085
+ if (lab === primaryAmountEl || isPrimaryFragment(lab)) continue
1086
+ const ly = rect(lab).y || 0
1087
+ let grouped = false
1088
+ for (const [key, group] of labelYGroups) {
1089
+ if (Math.abs(ly - key) <= 12) { group.push(lab); grouped = true; break }
1090
+ }
1091
+ if (!grouped) labelYGroups.set(ly, [lab])
1092
+ }
1093
+ // Only accept Y groups with 2+ parallel labels (true subAmount columns)
1094
+ const parallelLabels = new Set()
1095
+ for (const [, group] of labelYGroups) {
1096
+ if (group.length >= 2) group.forEach((lab) => parallelLabels.add(lab))
1097
+ }
1098
+ // 与主金额标签同带的并列汇总列(总支出/总收入)不是副金额:留给面板通道整带渲染,
1099
+ // 否则次列被副金额通道甩到卡底(汇总区只剩单列)。
1100
+ const primaryLabelEl = primaryAmountEl ? texts.find((t2) => {
1101
+ const r2 = rect(t2)
1102
+ const dy2 = (primaryRect.y || 0) - ((r2.y || 0) + (r2.height || 0))
1103
+ return dy2 >= -6 && dy2 <= 40 && Math.abs((r2.x || 0) - (primaryRect.x || 0)) <= 40
1104
+ }) || null : null
1105
+ for (const lab of labels) {
1106
+ if (!parallelLabels.has(lab)) continue
1107
+ if (primaryLabelEl && Math.abs((rect(lab).y || 0) - (rect(primaryLabelEl).y || 0)) <= 12) continue
1108
+ // 副金额列标签必须位于主金额值之下:与主值同带的是并列汇总列(总支出/总收入),归面板通道。
1109
+ if (primaryRect && (rect(lab).y || 0) <= (primaryRect.y || 0) + (primaryRect.height || 0)) continue
1110
+ const labR = rect(lab)
1111
+ const valCands = texts.filter((t) => {
1112
+ if (isPrimaryFragment(t)) return false
1113
+ const r = rect(t)
1114
+ const T = ts(t)
1115
+ return t !== lab && t !== primaryAmountEl &&
1116
+ (T.fontSize || 0) >= 16 &&
1117
+ Math.abs((r.x || 0) - (labR.x || 0)) <= 40 &&
1118
+ (r.y || 0) >= ((labR.y || 0) + (labR.height || 0) - 8) &&
1119
+ (r.y || 0) <= ((labR.y || 0) + (labR.height || 0) + 36)
1120
+ })
1121
+ // 金额值必须含数字:设计稿常把"¥"与数字拆成两个文本层,纯符号层不是值
1122
+ // (曾把"¥"当值 → 绑定默认 0 → 渲染成"¥0",真实金额丢失)。
1123
+ valCands.sort((a, b) =>
1124
+ (/\d/.test(textOf(b)) ? 1 : 0) - (/\d/.test(textOf(a)) ? 1 : 0)
1125
+ || Math.abs((rect(a).x || 0) - (labR.x || 0)) - Math.abs((rect(b).x || 0) - (labR.x || 0)))
1126
+ const valEl = valCands[0]
1127
+ if (valEl) {
1128
+ // 值左侧紧邻的裸货币符号层("¥"/"¥")并入值文本(视觉上是同一个金额)。
1129
+ let symEl = null
1130
+ if (!/^\p{Sc}/u.test(textOf(valEl))) {
1131
+ symEl = texts.find((t) => {
1132
+ if (t === valEl || isPrimaryFragment(t)) return false
1133
+ const r = rect(t)
1134
+ const gap = (rect(valEl).x || 0) - ((r.x || 0) + (r.width || 0))
1135
+ return /^\p{Sc}$/u.test(textOf(t))
1136
+ && Math.abs((rect(t).y || 0) - (rect(valEl).y || 0)) <= 8
1137
+ && gap >= -4 && gap <= 20
1138
+ }) || null
1139
+ }
1140
+ subAmounts.push({
1141
+ label: textOf(lab),
1142
+ value: (symEl ? textOf(symEl) : '') + textOf(valEl),
1143
+ labelColor: ts(lab).color,
1144
+ valueColor: ts(valEl).color,
1145
+ valueFontSize: ts(valEl).fontSize,
1146
+ labelEl: lab,
1147
+ valueEl: valEl,
1148
+ symbolEl: symEl,
1149
+ })
1150
+ }
1151
+ }
1152
+ // 副金额元素身份集合(含并入的货币符号层):下游归属判定一律用元素身份,
1153
+ // 不做文案比较——符号并入后 value 文案与任何单层文本都不相等,字符串比对必然漏判。
1154
+ const subAmountEls = new Set(subAmounts.flatMap((item) => [item.labelEl, item.valueEl, item.symbolEl]).filter(Boolean))
1155
+ // 副金额列标签的行内后缀(如「可用余额」右侧的币种选择器「美元⌄」):
1156
+ // 与标签同行(±8)、紧贴标签右沿(≤48px)的未消费短文本归入该列标签后缀随标签行渲染;
1157
+ // 其右侧紧邻的小图标切片(下拉箭头等)一并归入,否则图标会丢失或错行渲染。
1158
+ for (const sub of subAmounts) {
1159
+ if (!sub.labelEl) continue
1160
+ const lr = rect(sub.labelEl)
1161
+ const labelRight = (lr.x || 0) + (lr.width || 0)
1162
+ const suffix = texts.find((t) => {
1163
+ if (t === sub.labelEl || subAmountEls.has(t)) return false
1164
+ if (_panelGroupMemberEls.has(t)) return false
1165
+ const tr = rect(t)
1166
+ return Math.abs((tr.y || 0) - (lr.y || 0)) <= 8
1167
+ && (tr.x || 0) >= labelRight - 2 && (tr.x || 0) <= labelRight + 48
1168
+ && (tr.width || 0) <= Math.max(80, (lr.width || 0) * 1.5)
1169
+ })
1170
+ if (!suffix) continue
1171
+ sub.labelSuffixEl = suffix
1172
+ sub.labelSuffix = {
1173
+ text: textOf(suffix), color: ts(suffix).color,
1174
+ fontSize: ts(suffix).fontSize, fontWeight: ts(suffix).fontWeight,
1175
+ }
1176
+ subAmountEls.add(suffix)
1177
+ const suffixR = rect(suffix)
1178
+ const suffixSlice = slices.find((s) => {
1179
+ const sr = rect(s)
1180
+ const w = sr.width || 0, h = sr.height || 0
1181
+ return w >= 8 && w <= 28 && h >= 8 && h <= 28
1182
+ && Math.abs((sr.y || 0) - (suffixR.y || 0)) <= 10
1183
+ && (sr.x || 0) >= (suffixR.x || 0) + (suffixR.width || 0) - 4
1184
+ && (sr.x || 0) <= (suffixR.x || 0) + (suffixR.width || 0) + 32
1185
+ }) || null
1186
+ if (suffixSlice) sub.labelSuffixSlice = suffixSlice
1187
+ }
1188
+
1189
+ // —— 序号徽章行(编号列表):小尺寸近方形壳(10-28px,带边框/填充)内嵌 ≤2 字符微型文本,
1190
+ // 右侧同线挂内容文本,纵向同列(壳左沿 ±4) ≥2 行成立。纯几何信号,不依赖业务词。 ——
1191
+ // 典型:①②③④⑤ 步骤说明行;壳与数字由徽章容器渲染,内容文本右侧排列。
1192
+ const badgeRows = []
1193
+ const _badgeRowTextEls = new Set()
1194
+ {
1195
+ const badgeShells = shapes.filter((sh) => {
1196
+ const sr = rect(sh)
1197
+ const w = sr.width || 0, h = sr.height || 0
1198
+ if (w < 10 || w > 28 || Math.abs(w - h) > 4) return false
1199
+ const st = sh.style || {}
1200
+ return !!(st.border?.isBorder || st.fill?.color || st.fill?.gradient)
1201
+ })
1202
+ const badgeConsumed = new Set()
1203
+ const badgeConsumedRich = new Set()
1204
+ const candRows = []
1205
+ for (const shell of badgeShells) {
1206
+ const sr = rect(shell)
1207
+ const scx = (sr.x || 0) + (sr.width || 0) / 2
1208
+ const scy = (sr.y || 0) + (sr.height || 0) / 2
1209
+ const digit = texts.find((t) => {
1210
+ if (badgeConsumed.has(t) || subAmountEls.has(t) || _panelGroupMemberEls.has(t)
1211
+ || optionTextSet.has(t) || optionLabelSet.has(t)) return false
1212
+ if ((textOf(t) || '').trim().length > 2) return false
1213
+ const tr = rect(t)
1214
+ return Math.abs((tr.x || 0) + (tr.width || 0) / 2 - scx) <= 6
1215
+ && Math.abs((tr.y || 0) + (tr.height || 0) / 2 - scy) <= 6
1216
+ })
1217
+ if (!digit) continue
1218
+ const content = texts.filter((t) => {
1219
+ if (t === digit || badgeConsumed.has(t) || subAmountEls.has(t)
1220
+ || _panelGroupMemberEls.has(t) || optionTextSet.has(t) || optionLabelSet.has(t)) return false
1221
+ const tr = rect(t)
1222
+ const gap = (tr.x || 0) - ((sr.x || 0) + (sr.width || 0))
1223
+ if (gap < 0 || gap > 24) return false
1224
+ // 多行文本以首行中心对齐徽章中心
1225
+ const firstLineH = Math.min(tr.height || 0, (ts(t).fontSize || 14) * 1.4)
1226
+ const tcy = (tr.y || 0) + firstLineH / 2
1227
+ return Math.abs(tcy - scy) <= 12
1228
+ }).sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
1229
+ // 行内容是富文本段落(混合字重被抽成 richBlock,不在通用 text 流里)时先标记,
1230
+ // 待同列成组确认后按徽章行切分段落行(一个段落块可能纵跨多行徽章)
1231
+ const richCand = !content.length
1232
+ if (!content.length && !richCand) continue
1233
+ badgeConsumed.add(digit)
1234
+ content.forEach((t) => badgeConsumed.add(t))
1235
+ candRows.push({ shellEl: shell, digitEl: digit, contentEls: content, richCand, y: sr.y || 0, x: sr.x || 0 })
1236
+ }
1237
+ const colRows = candRows.filter((row) => Math.abs(row.x - (candRows[0]?.x ?? 0)) <= 4)
1238
+ if (colRows.length >= 2) {
1239
+ const sortedRows = colRows.sort((a, b) => a.y - b.y)
1240
+ // 富文本行内容分配:段落块纵向覆盖徽章壳中心才相关;行按 y 桶分到最近上方徽章行
1241
+ const richRows = sortedRows.filter((row) => row.richCand)
1242
+ if (richRows.length) {
1243
+ for (const row of richRows) {
1244
+ const shellR = rect(row.shellEl)
1245
+ const scy = (shellR.y || 0) + (shellR.height || 0) / 2
1246
+ const rb = (richBlocks || []).find((blk) =>
1247
+ blk.lines.some((line) => {
1248
+ const lineY = rect(line[0]).y || 0
1249
+ const lineH = Math.max(...line.map((f) => rect(f).height || 0))
1250
+ return scy >= lineY - 8 && scy <= lineY + lineH + 8
1251
+ }))
1252
+ if (rb) row._richBlock = rb
1253
+ }
1254
+ const assigned = richRows.filter((row) => row._richBlock)
1255
+ for (let i = 0; i < assigned.length; i++) {
1256
+ const row = assigned[i]
1257
+ const rb = row._richBlock
1258
+ const shellR = rect(row.shellEl)
1259
+ const nextShellY = i + 1 < assigned.length && assigned[i + 1]._richBlock === rb
1260
+ ? rect(assigned[i + 1].shellEl).y || 0 : Infinity
1261
+ const rowLines = rb.lines.filter((line) => {
1262
+ const lineY = rect(line[0]).y || 0
1263
+ return lineY >= (shellR.y || 0) - 8 && lineY < nextShellY - 8
1264
+ })
1265
+ if (rowLines.length) {
1266
+ row.contentRichLines = rowLines
1267
+ row.contentRichBlock = rb
1268
+ badgeConsumedRich.add(rb)
1269
+ }
1270
+ }
1271
+ // 未分到内容的 richCand 行整行放弃(避免渲染裸徽章)
1272
+ for (const row of richRows) {
1273
+ if (!row.contentRichLines) {
1274
+ const idx = sortedRows.indexOf(row)
1275
+ if (idx >= 0) sortedRows.splice(idx, 1)
1276
+ }
1277
+ }
1278
+ }
1279
+ if (sortedRows.length < 2) sortedRows.length = 0
1280
+ badgeRows.push(...sortedRows)
1281
+ for (const row of badgeRows) {
1282
+ _badgeRowTextEls.add(row.digitEl)
1283
+ row.contentEls.forEach((t) => _badgeRowTextEls.add(t))
1284
+ }
1285
+ badgeRows._consumedRich = badgeConsumedRich
1286
+ }
1287
+ }
1288
+
1289
+ // —— 标题与主金额之间的同 y 文本簇(如「名称文本」+「(code)」)+ 同位切片图标(纯几何聚类,不暗示业务含义) ——
1290
+ // 纯几何判定:标题底沿与主金额之间、同行(±8px)文本 ≥2 个即聚类;**无主金额时不识别**
1291
+ // (否则主金额不存在时 y 上界=∞,把下方表单行/字段行误收进 midCluster 导致重复渲染与绑定混乱)。
1292
+ const midCluster = (() => {
1293
+ if (!primaryAmountEl) return null
1294
+ const titleBottom = texts.length ? (rect(texts[0]).y || 0) + (rect(texts[0]).height || 20) : 0
1295
+ const amountY = primaryAmountEl ? rect(primaryAmountEl).y || 9999 : 9999
1296
+ const cands = texts.filter((t) => {
1297
+ const y = rect(t).y || 0
1298
+ return y > titleBottom && y < amountY && !tabs.some((tab) => tab.value === textOf(t))
1299
+ && !subAmountEls.has(t)
1300
+ && !_badgeRowTextEls.has(t)
1301
+ })
1302
+ if (cands.length < 2) return null
1303
+ const clusters = []
1304
+ let cur = [cands[0]]
1305
+ for (let i = 1; i < cands.length; i++) {
1306
+ if (sameRow(cands[i], cands[i - 1], 8)) { cur.push(cands[i]) }
1307
+ else { if (cur.length >= 2) clusters.push(cur); cur = [cands[i]] }
1308
+ }
1309
+ if (cur.length >= 2) clusters.push(cur)
1310
+ if (!clusters.length) return null
1311
+ const group = clusters[0].sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
1312
+ const grpY = rect(group[0]).y || 0
1313
+ const logoSlice = slices.find((s) => Math.abs((rect(s).y || 0) - grpY) < 15 && (rect(s).x || 0) < rect(group[0]).x)
1314
+ // 标签簇下方同列数值行(并行汇总双列「总支出/总收入」下一行的大字号数值):
1315
+ // 每个标签 x±24、位于标签带底沿之下 24-56px、含数字 → 归为该簇的值(随带整块渲染)。
1316
+ const grpBottom = Math.max(...group.map((t) => (rect(t).y || 0) + (rect(t).height || 0)))
1317
+ const clusterValues = group.map((t) => {
1318
+ const tr = rect(t)
1319
+ const cand = texts.find((v) => {
1320
+ if (v === t || group.includes(v)) return false
1321
+ const vr = rect(v)
1322
+ const dy = (vr.y || 0) - grpBottom
1323
+ return /\d/.test(textOf(v)) && dy >= 8 && dy <= 56
1324
+ && Math.abs((vr.x || 0) - (tr.x || 0)) <= 24 && (vr.width || 0) <= 160
1325
+ })
1326
+ return cand ? {
1327
+ text: textOf(cand), color: ts(cand).color, fontSize: ts(cand).fontSize,
1328
+ fontWeight: ts(cand).fontWeight, x: rect(cand).x || 0, y: rect(cand).y || 0,
1329
+ width: rect(cand).width || 0, height: rect(cand).height || 0,
1330
+ } : null
1331
+ })
1332
+ return {
1333
+ logoSlice: logoSlice || null,
1334
+ items: group.map((t) => ({
1335
+ text: textOf(t), color: ts(t).color, fontSize: ts(t).fontSize,
1336
+ fontWeight: ts(t).fontWeight, x: rect(t).x || 0, width: rect(t).width || 0,
1337
+ y: rect(t).y || 0, height: rect(t).height || 0,
1338
+ })),
1339
+ values: clusterValues.some(Boolean) ? clusterValues : null,
1340
+ y: grpY,
1341
+ }
1342
+ })()
1343
+
1344
+ // —— 顶部辅助文案(标题 / 副标题等)——
1345
+ // 主视觉区底沿:有主金额用主金额;否则用最大字号文本兜底(头部主数字块,如未读消息数
1346
+ // "12" 30px)——否则 mainBottom=0 会把整个卡片都当表单带/列表区域,头部被误吞。
1347
+ // 复合行模式无全卡主视觉(最大的数字是列表行成员),锚点置空,
1348
+ // 由 listTitle/复合行路径接管纵向结构。
1349
+ const mainVisualEl = repeatRowMode ? null
1350
+ : (primaryAmountEl || sortedByFs.find((t) => !_panelGroupMemberEls.has(t)) || null)
1351
+ const standaloneHeaderPeer = (t) => !primaryAmountEl && !tabs.length && texts[0] &&
1352
+ t !== texts[0] && sameRow(t, texts[0], 12)
1353
+ const mainY = mainVisualEl ? rect(mainVisualEl).y || 0 : 0
1354
+ const mainBottom = mainVisualEl ? (rect(mainVisualEl).y || 0) + (rect(mainVisualEl).height || 0) : 0
1355
+ // 无主视觉元素(纯信息卡)时,用首个表单行 y 作为上下界,
1356
+ // 让表单行上方的文案进入 auxTexts 而非 postAmountTexts
1357
+ // 无主视觉时,用首个以「:」结尾的文本(表单行标签特征)y 值作为分界线
1358
+ const _formLabelYs = texts
1359
+ .filter((t) => /:$/.test(textOf(t)) && (rect(t).y || 0) > (rect(card).y || 0))
1360
+ .map((t) => rect(t).y || 0)
1361
+ .sort((a, b) => a - b)
1362
+ const _formRowTopY = _formLabelYs.length ? _formLabelYs[0] : null
1363
+ // 仅真正的 primaryAmountEl 才能定义主视觉分界线;fallback 到 sortedByFs[0](最大字号文本)
1364
+ // 只能视为标题,不应阻止其下方文案进入 auxTexts
1365
+ const _hasRealPrimary = !!primaryAmountEl
1366
+ // 无主金额但有主视觉(大字号元素,如产品名/大数字)时,以主视觉 y 作为 auxTexts 上界——
1367
+ // 否则标题区文案(主视觉上方)既非 aux 又非 postAmount,整体掉入死区不渲染。
1368
+ const _anchorY = mainY || (rect(card).y || 0)
1369
+ const _effectiveMainY = _hasRealPrimary ? mainY : (_formRowTopY ?? _anchorY)
1370
+ const _effectiveMainBottom = _hasRealPrimary ? mainBottom : (_formRowTopY ?? _anchorY)
1371
+ const backgroundShellFor = (sourceEl) => {
1372
+ const tr = rect(sourceEl)
1373
+ return (card.elements || []).find((element) => {
1374
+ const fill = element.style?.fill || {}
1375
+ if (element.type !== 'shape' || !fill.color) return false
1376
+ const sr = rect(element)
1377
+ const grow = (dimension, textLength, shellLength) =>
1378
+ shellLength >= textLength && shellLength - textLength <= Math.max(8, textLength * 0.25)
1379
+ return grow('width', tr.width || 0, sr.width || 0)
1380
+ && grow('height', tr.height || 0, sr.height || 0)
1381
+ && Math.abs((sr.x || 0) - (tr.x || 0)) <= 6
1382
+ && Math.abs((sr.y || 0) - (tr.y || 0)) <= 6
1383
+ }) || null
1384
+ }
1385
+ // 描边 pill 壳:文本完整包含在 isBorder+圆角 shape 内(如 标题行右侧的描边操作 pill)。
1386
+ // 与 backgroundShell(填充壳)互补:描边壳 fill 为空,仅靠 border/radius 表达。
1387
+ // 尺寸上限约束壳不显著大于文本(排除面板级描边容器)。
1388
+ const outlineShellFor = (sourceEl) => {
1389
+ const tr = rect(sourceEl)
1390
+ return (card.elements || []).find((element) => {
1391
+ const border = element.style?.border || {}
1392
+ const radius = Number(element.style?.radius?.borderRadius)
1393
+ if (element.type !== 'shape' || !border.isBorder || !(radius >= 4)) return false
1394
+ const sr = rect(element)
1395
+ // 卡壳自排除:覆盖整卡的描边圆角壳是卡片本体(白描边在浅底上不可见),
1396
+ // 认成段落描边 pill 会在卡中部画出一圈可见圆角框(大段落文本曾把 2×h+20 上限撑穿)。
1397
+ const cR = rect(card)
1398
+ if ((sr.width || 0) >= (cR.width || 0) * 0.9 && (sr.height || 0) >= (cR.height || 0) * 0.9) return false
1399
+ return (tr.x || 0) >= (sr.x || 0) - 2 && (tr.y || 0) >= (sr.y || 0) - 2
1400
+ && ((tr.x || 0) + (tr.width || 0)) <= ((sr.x || 0) + (sr.width || 0)) + 2
1401
+ && ((tr.y || 0) + (tr.height || 0)) <= ((sr.y || 0) + (sr.height || 0)) + 2
1402
+ && (sr.width || 0) <= (tr.width || 0) * 2 + 40
1403
+ && (sr.height || 0) <= (tr.height || 0) * 2 + 20
1404
+ }) || null
1405
+ }
1406
+ const highlightShellFor = (sourceEl) => {
1407
+ const textRect = rect(sourceEl)
1408
+ return (card.elements || []).find((element) => {
1409
+ const fill = element.style?.fill || {}
1410
+ if (element.type !== 'shape' || !fill.color) return false
1411
+ const shapeRect = rect(element)
1412
+ const shapeHeight = shapeRect.height || 0
1413
+ const widthRatio = (shapeRect.width || 0) / Math.max(1, textRect.width || 0)
1414
+ return shapeHeight >= 4 && shapeHeight <= Math.max(8, (textRect.height || 0) * 0.5)
1415
+ && widthRatio >= 0.7 && widthRatio <= 1.3
1416
+ && Math.abs(((shapeRect.x || 0) + (shapeRect.width || 0) / 2) -
1417
+ ((textRect.x || 0) + (textRect.width || 0) / 2)) <= 6
1418
+ && Math.abs(((shapeRect.y || 0) + shapeHeight) -
1419
+ ((textRect.y || 0) + (textRect.height || 0))) <= 6
1420
+ }) || null
1421
+ }
1422
+ const auxTexts = texts.filter((t) => {
1423
+ const y = rect(t).y || 0
1424
+ const standaloneHeader = t === texts[0] && !primaryAmountEl && !tabs.length
1425
+
1426
+ return (y > tabY && (y < _effectiveMainY || standaloneHeader || standaloneHeaderPeer(t)))
1427
+ && !tabs.some((tab) => tab.value === textOf(t))
1428
+ && !optionTextSet.has(t) && !optionLabelSet.has(t)
1429
+ && !subAmountEls.has(t)
1430
+ && !_panelGroupMemberEls.has(t)
1431
+ // 按钮文案不得再作辅助文本渲染(与 postAmountTexts 同款排除,防 文本+按钮 重影)
1432
+ && !buttons.some((b) => b.text === textOf(t))
1433
+ // 排除已被 midCluster 归类的文本(避免重复渲染为独立 auxText)
1434
+ && !(midCluster && midCluster.items.some((b) => b.text === textOf(t)))
1435
+ && !_badgeRowTextEls.has(t)
1436
+ }).map((t) => ({
1437
+ text: textOf(t),
1438
+ color: ts(t).color,
1439
+ fontSize: ts(t).fontSize,
1440
+ fontWeight: ts(t).fontWeight,
1441
+ align: ts(t).textAlign,
1442
+ sliceEl: slices.find((slice) => {
1443
+ const sr = rect(slice)
1444
+ const tr = rect(t)
1445
+ const sliceCenter = (sr.y || 0) + (sr.height || 0) / 2
1446
+ const textCenter = (tr.y || 0) + (tr.height || 0) / 2
1447
+ const gapLeft = (tr.x || 0) - ((sr.x || 0) + (sr.width || 0))
1448
+ const gapRight = (sr.x || 0) - ((tr.x || 0) + (tr.width || 0))
1449
+ // 竖直容差随切片高度放宽:大尺寸主视觉(吉祥物/头像)与多行文本的中心差可达
1450
+ // 十几 px,固定 8px 只认小图标 → 主视觉掉进孤立媒体块单独成行(卡头被拆两行)。
1451
+ const vTol = Math.max(8, Math.min((sr.height || 0) * 0.35, (tr.height || 0) * 0.6))
1452
+ const vOverlap = Math.min((sr.y || 0) + (sr.height || 0), (tr.y || 0) + (tr.height || 0))
1453
+ - Math.max(sr.y || 0, tr.y || 0)
1454
+ return Math.abs(sliceCenter - textCenter) <= vTol && vOverlap > 0
1455
+ && ((gapLeft >= -4 && gapLeft <= 24) || (gapRight >= -4 && gapRight <= 24))
1456
+ }) || null,
1457
+ decoSliceEl: slices.find((slice) => {
1458
+ const sr = rect(slice)
1459
+ const tr = rect(t)
1460
+ const sliceCenterX = (sr.x || 0) + (sr.width || 0) / 2
1461
+ const textCenter = (tr.y || 0) + (tr.height || 0) / 2
1462
+ // 文本底带装饰切片(下划波浪/高亮笔触):水平落在文本宽度内、竖直贴文本下半段
1463
+ // → 随文本行渲染;否则薄切片(高 <20)进不了孤立媒体块被静默丢弃。
1464
+ return (sr.height || 0) >= 4 && (sr.height || 0) <= Math.max(8, (tr.height || 0) * 0.5)
1465
+ && (sr.width || 0) <= (tr.width || 0)
1466
+ && (sr.y || 0) >= textCenter && (sr.y || 0) <= (tr.y || 0) + (tr.height || 0) + 8
1467
+ && sliceCenterX >= (tr.x || 0) && sliceCenterX <= (tr.x || 0) + (tr.width || 0)
1468
+ }) || null,
1469
+ backgroundShell: backgroundShellFor(t),
1470
+ outlineShell: outlineShellFor(t),
1471
+ highlightShell: highlightShellFor(t),
1472
+ y: rect(t).y || 0, // 几何 y(顶部/底部块归属判定用:y < 顶部区底沿 → 顶部容器)
1473
+ x: rect(t).x || 0,
1474
+ width: rect(t).width || 0,
1475
+ height: rect(t).height || (ts(t).fontSize || 14) * 1.4,
1476
+ sourceEl: t,
1477
+ }))
1478
+
1479
+ // —— 表单行(label + 同行 companion):「左标签 / 右簇值」「附言 / 30字以内…」——
1480
+ // 结构判定优先于颜色:本 spec 逐文本 color 多为 null,无法用灰度判定 placeholder;
1481
+ // Input placeholder 由值文本相对标签文本的亮度差判定;缺少可比较颜色时不成 Input。
1482
+ // —— 表单行(结构优先:先按空间拓扑聚类「左标签 + 右簇」,再区分值、子值、右侧行操作件)——
1483
+ // 拓扑驱动:完全基于元素 x/y 空间关系,不依赖字段名词典(避免把「可用余额:」误判成独立 label)。
1484
+ // 一个表单行 = 表单带内「左锚定文本(label)」+ 其右侧同一竖直窗口内的全部文本与图标切片(右簇)。
1485
+ // 右簇内:基线一致的最上文本为 value;下方短副行为 subValue;最右几何媒体为 rightAffordance。
1486
+ const formRows = []
1487
+ const formConsumed = new Set()
1488
+ const releasedListRows = []
1489
+ // —— 枚举统计行预扫描(Enum-Stat Strip,文本自明优先于几何聚类)——
1490
+ // 文本内嵌「类型标签+数字」多段枚举(如「动账 5 · 转账 2 · 站内 1 · 其他 4」)时,该文本的
1491
+ // 结构由**文本自身**决定(≥2 段标签+数字+统一分隔符),不应被几何聚类误吞为表单行 label/value。
1492
+ // 命中文本:① 从表单行 label 候选排除;② 从右簇候选排除——但不能标记进 formConsumed
1493
+ // (postAmountTexts 用 !formConsumed 过滤,误标会让文本被静默丢弃)。用独立 enumStatConsumed。
1494
+ // 最终会进入 postAmountTexts → midAux 渲染时走枚举统计绑定(detectEnumStatStrip 二次命中)。
1495
+ // 兜底映射:raw 文本 → {items, sep},供渲染复用(避免重复解析)。
1496
+ const enumStatConsumed = new Set()
1497
+ const enumStatMap = {}
1498
+ for (const t of texts) {
1499
+ const raw = (textOf(t) || '').trim()
1500
+ if (!raw) continue
1501
+ const stat = detectEnumStatStrip(raw)
1502
+ if (stat) {
1503
+ enumStatMap[raw] = stat
1504
+ enumStatConsumed.add(t)
1505
+ }
1506
+ }
1507
+ // 按钮底沿(表单行必须位于按钮上方,避免把按钮内文本误判为 label)
1508
+ // 表单带下界只看主尺寸按钮(w>=80):行内小 pill(修改/预约)位置高,
1509
+ // 若计入会把其下方整片表单区判为「按钮之下」而丢失全部表单行。
1510
+ const primaryButtonShapes = buttonShapes.filter((b) => (rect(b).width || 0) >= 80)
1511
+ const btnBottom = primaryButtonShapes.length ? Math.max(...primaryButtonShapes.map((b) => (rect(b).y || 0) + (rect(b).height || 0))) : 9999
1512
+ const formBandTop = mainBottom + 6
1513
+ // —— 对比面板组预抽取(并排小卡:壳内左对齐「标签行 + 值行」,可带圆点/状态 tag)——
1514
+ // 通用「label + 同行右簇 value」配对会跨壳把并排面板的两条标签配成一行
1515
+ // (持仓比例 ↔ 建议比例)、值行同理;壳底色/圆角/圆点/状态 tag 也全部丢失。
1516
+ // 故先按壳几何把面板行摘出(每壳一行)并消费其文本,禁止通用配对再碰。
1517
+ const panelShellCands = shapes.filter((s2) => {
1518
+ const f2 = s2.style?.fill
1519
+ if (!f2 || (!f2.color && !f2.gradient)) return false
1520
+ if (radiusMax(s2.style) < 4) return false
1521
+ const r2 = rect(s2)
1522
+ const w2 = r2.width || 0; const h2 = r2.height || 0
1523
+ if (w2 < 100 || w2 > 260 || h2 < 48 || h2 > 120) return false
1524
+ if ((r2.y || 0) <= formBandTop || (r2.y || 0) >= btnBottom) return false
1525
+ const inside = texts.filter((t2) => rectInside(r2, rect(t2), 0))
1526
+ if (inside.length < 2) return false
1527
+ const lineKeys = new Set(inside.map((t2) => Math.round((rect(t2).y || 0) / 6)))
1528
+ return lineKeys.size >= 2
1529
+ })
1530
+ // 同位双层标注(编组+矩形)与内层装饰壳去重:保留最外层且有实体填充色的 shape
1531
+ const panelShells = panelShellCands.filter((a) => {
1532
+ const ra = rect(a)
1533
+ const aSolid = !!(a.style?.fill?.color)
1534
+ return !panelShellCands.some((b) => {
1535
+ if (b === a) return false
1536
+ const rb = rect(b)
1537
+ if (!rectInside(rb, ra, 1)) return false
1538
+ const sameRect = Math.abs((rb.width || 0) - (ra.width || 0)) <= 2
1539
+ && Math.abs((rb.height || 0) - (ra.height || 0)) <= 2
1540
+ if (!sameRect) return true
1541
+ const bSolid = !!(b.style?.fill?.color)
1542
+ return !aSolid || (bSolid && panelShellCands.indexOf(b) < panelShellCands.indexOf(a))
1543
+ })
1544
+ })
1545
+ for (const shellEl of panelShells) {
1546
+ const sr2 = rect(shellEl)
1547
+ const insideTexts = texts
1548
+ .filter((t2) => !formConsumed.has(t2) && !enumStatConsumed.has(t2) && rectInside(sr2, rect(t2), 0))
1549
+ .sort((p, q) => (rect(p).y || 0) - (rect(q).y || 0))
1550
+ if (insideTexts.length < 2) continue
1551
+ const labEl = insideTexts[0]
1552
+ const labR = rect(labEl)
1553
+ const restLines = insideTexts.slice(1)
1554
+ const valEl = restLines[0]
1555
+ const valR = rect(valEl)
1556
+ const tagCand = restLines.find((t2) => t2 !== valEl
1557
+ && (rect(t2).width || 0) <= 60 && (rect(t2).height || 0) <= 24
1558
+ && (rect(t2).x || 0) > (valR.x || 0) + (valR.width || 0) - 2
1559
+ && Math.abs((rect(t2).y || 0) - (valR.y || 0)) <= 12) || null
1560
+ const tagShapeOf = (t2) => shapes.find((s3) => {
1561
+ const r3 = rect(s3); const tr3 = rect(t2)
1562
+ return (r3.width || 0) >= (tr3.width || 0) && (r3.width || 0) <= (tr3.width || 0) + 16
1563
+ && (r3.height || 0) >= (tr3.height || 0) && (r3.height || 0) <= (tr3.height || 0) + 12
1564
+ && Math.abs((r3.x || 0) - (tr3.x || 0)) <= 8 && Math.abs((r3.y || 0) - (tr3.y || 0)) <= 8
1565
+ }) || null
1566
+ const tagShapeEl = tagCand ? tagShapeOf(tagCand) : null
1567
+ // 无壳短文本按子值处理(第二值),有壳才是状态 tag
1568
+ const tagEl = tagShapeEl ? tagCand : null
1569
+ const subEls2 = restLines.filter((t2) => t2 !== valEl && t2 !== tagEl)
1570
+ const dotEl = shapes.find((s3) => {
1571
+ const f3 = s3.style?.fill
1572
+ if (!f3 || !f3.color) return false
1573
+ const r3 = rect(s3)
1574
+ const w3 = r3.width || 0; const h3 = r3.height || 0
1575
+ if (w3 < 6 || w3 > 14 || h3 < 6 || h3 > 14) return false
1576
+ return rectInside(sr2, r3, 0)
1577
+ && (r3.x || 0) + w3 <= (labR.x || 0)
1578
+ && Math.abs(((r3.y || 0) + h3 / 2) - ((labR.y || 0) + (labR.height || 0) / 2)) <= 12
1579
+ }) || null
1580
+ formRows.push({
1581
+ panelShell: {
1582
+ x: sr2.x || 0, y: sr2.y || 0, width: sr2.width || 0, height: sr2.height || 0,
1583
+ fill: shellEl.style?.fill?.color || null,
1584
+ gradient: shellEl.style?.fill?.gradient || null,
1585
+ radius: radiusMax(shellEl.style),
1586
+ opacity: shellEl.style?.opacity ?? 1,
1587
+ },
1588
+ dot: dotEl ? {
1589
+ color: dotEl.style.fill.color,
1590
+ x: rect(dotEl).x || 0, y: rect(dotEl).y || 0,
1591
+ w: Math.round(rect(dotEl).width || 8), h: Math.round(rect(dotEl).height || 8),
1592
+ gap: Math.max(0, Math.round((labR.x || 0) - ((rect(dotEl).x || 0) + (rect(dotEl).width || 0)))),
1593
+ } : null,
1594
+ tag: tagEl ? {
1595
+ text: textOf(tagEl), color: ts(tagEl).color, fontSize: ts(tagEl).fontSize,
1596
+ borderColor: tagShapeEl.style?.border?.isBorder ? tagShapeEl.style.border.color : null,
1597
+ fill: tagShapeEl.style?.fill?.color || null,
1598
+ radius: radiusMax(tagShapeEl.style),
1599
+ w: Math.round(rect(tagShapeEl).width || 24),
1600
+ h: Math.round(rect(tagShapeEl).height || 14),
1601
+ right: Math.round((rect(tagShapeEl).x || 0) + (rect(tagShapeEl).width || 0)),
1602
+ } : null,
1603
+ label: { text: textOf(labEl), color: ts(labEl).color, fontSize: ts(labEl).fontSize, fontWeight: ts(labEl).fontWeight, x: labR.x || 0, y: labR.y || 0, width: labR.width || 0, height: labR.height || (ts(labEl).fontSize || 14) * 1.4 },
1604
+ leftIconSlice: null,
1605
+ value: { text: textOf(valEl), color: ts(valEl).color, fontSize: ts(valEl).fontSize, fontWeight: ts(valEl).fontWeight, x: valR.x || 0, y: valR.y || 0, width: valR.width || 0, height: valR.height || (ts(valEl).fontSize || 14) * 1.4 },
1606
+ subValues: subEls2.map((t2) => ({ text: textOf(t2), color: ts(t2).color, fontSize: ts(t2).fontSize, fontWeight: ts(t2).fontWeight, x: rect(t2).x || 0, y: rect(t2).y || 0, width: rect(t2).width || 0, height: rect(t2).height || (ts(t2).fontSize || 14) * 1.4 })),
1607
+ rightAffordanceSlice: null,
1608
+ isPlaceholder: false,
1609
+ labelEl: labEl,
1610
+ valueEl: valEl,
1611
+ subEls: subEls2,
1612
+ })
1613
+ formConsumed.add(labEl)
1614
+ formConsumed.add(valEl)
1615
+ if (tagEl) formConsumed.add(tagEl)
1616
+ subEls2.forEach((t2) => formConsumed.add(t2))
1617
+ }
1618
+ // 候选左标签:表单带内、左锚定(x < 120)的文本;枚举统计行文本已排除;
1619
+ // 另排除「大字号纯数字」头部主数字(如未读消息数 12)——标题区数字块不是表单 label。
1620
+ const labelCands = texts.filter((t) => {
1621
+ const y = rect(t).y || 0; const x = rect(t).x || 0
1622
+ const T = ts(t)
1623
+ const bigNum = (T.fontSize || 0) >= LABEL_MAX_NUM_FONT && /^\d+$/.test(textOf(t).trim())
1624
+ return x < 120 && y > formBandTop && y < btnBottom && !formConsumed.has(t)
1625
+ && !enumStatConsumed.has(t) && !bigNum
1626
+ && !_isRepeatedRowText(t)
1627
+ && !_panelGroupMemberEls.has(t)
1628
+ && !buttons.some((button) => button.text === textOf(t))
1629
+ && !optionTextSet.has(t) && !optionLabelSet.has(t)
1630
+ && !subAmountEls.has(t)
1631
+ && !_badgeRowTextEls.has(t)
1632
+ // 复合行模式:波段标签本身与其上方的行标题都是列表行成员,不得被表单行吞掉
1633
+ && !(repeatRowMode && _repeatLabelTexts.has(textOf(t)))
1634
+ && !(repeatRowMode && _allBandTops.some((bt) => y >= bt - 4 && y <= bt + 80))
1635
+ && !(repeatRowMode && _allBandTops.some((bt) => {
1636
+ const dy = bt - y
1637
+ return dy >= 18 && dy <= 110
1638
+ }))
1639
+ })
1640
+ for (const lab of labelCands) {
1641
+ const ly = rect(lab).y || 0; const lx = rect(lab).x || 0; const lw = rect(lab).width || 0
1642
+ const labelRight = lx + lw
1643
+ // 右簇文本:y ∈ [ly-16, ly+35]、右边缘超出标签右沿(用右边缘判定,兼容右对齐占位文本左缘落在标签包围盒内的情况)
1644
+ // 窗口必须收窄(曾用 +60 跨行捕获:字段行距 ~40px 时把下一行值/卡号误当本行子值 → 重复渲染 + 对象字段错乱)
1645
+ // 枚举统计行文本除外(双重保险)
1646
+ // P0 升级:① value 必须与 label 视觉基线(y+fontSize)对齐(≤ FORM_ROW_BASE_GAP),
1647
+ // 「上下两行的标题-描述」文本(基线差 16-26px)不得误判为 value;
1648
+ const cardW = (spec.size && spec.size.width) || 375
1649
+ const lBase = ly + (ts(lab).fontSize || 0)
1650
+ // 行尾操作件候选池:slice 与 symbol 同等对待——同一视觉图标在标注里可能是任一形态
1651
+ // (曾只收 slice:行尾箭头被标成 symbol 时整行 affordance 丢失,图标掉进孤立块渲染到卡片底部)。
1652
+ const rightAffordanceCandidates = [...slices, ...symbols].filter((s) => {
1653
+ const sr = rect(s)
1654
+ const width = sr.width || 0
1655
+ const height = sr.height || 0
1656
+ const verticalCenter = (sr.y || 0) + height / 2
1657
+ return width >= 12 && width <= 44 && height >= 12 && height <= 44
1658
+ && Math.abs(width - height) <= Math.max(10, width * 0.5)
1659
+ && ((sr.x || 0) + width) >= (rect(card).x || 0) + cardW - 72
1660
+ && Math.abs(verticalCenter - ((ly || 0) + (ts(lab).fontSize || 0) / 2)) <= 20
1661
+ })
1662
+ const rt = texts.filter((t) => {
1663
+ if (t === lab || formConsumed.has(t) || enumStatConsumed.has(t)) return false
1664
+ if (_panelGroupMemberEls.has(t)) return false
1665
+ if (buttons.some((button) => button.text === textOf(t))) return false
1666
+ if (optionTextSet.has(t) || optionLabelSet.has(t)) return false
1667
+ if (subAmountEls.has(t)) return false
1668
+ const y = rect(t).y || 0; const x = rect(t).x || 0
1669
+ const rEdge = x + (rect(t).width || 0)
1670
+ const vBase = y + (ts(t).fontSize || 0)
1671
+ const isBindableValue = ['amount', 'cardNumber', 'phone', 'code', 'date']
1672
+ .includes(classifyValue(textOf(t)))
1673
+ const isRightAction = !isBindableValue && x >= cardW * RIGHT_ACTION_X_RATIO
1674
+ && rightAffordanceCandidates.some((s) => Math.abs((rect(s).y || 0) - y) <= 20)
1675
+ return y >= ly - 16 && y <= ly + 45 && rEdge > labelRight
1676
+
1677
+ })
1678
+ // 右侧媒体切片:同竖直窗口、右边缘超出标签右沿,再由几何 candidates 确认。
1679
+ const sl = [...slices, ...symbols].filter((s) => {
1680
+ const y = rect(s).y || 0; const x = rect(s).x || 0
1681
+ const rEdge = x + (rect(s).width || 0)
1682
+ return y >= ly - 20 && y <= ly + 35 && rEdge > labelRight
1683
+ })
1684
+ // 表单行必须「label + 文本 value」同行配对:rt 为空(无同行文本 value)时不成行——
1685
+ // P0 基线判定后,列表项标题/描述(上下两行)的右簇文本已全部被排除。若仅有
1686
+ // 右侧媒体而无同行可绑定文本,则不成立空表单行,避免吞掉后续列表内容。
1687
+ // 写进 formRows 并 formConsumed 吞掉列表项,导致列表识别再次失效。
1688
+ if (rt.length === 0) continue
1689
+ let alignedRightTexts = rt.filter((t) =>
1690
+ Math.abs(((rect(t).y || 0) + (ts(t).fontSize || 0)) -
1691
+ ((rect(lab).y || 0) + (ts(lab).fontSize || 0))) <= FORM_ROW_BASE_GAP)
1692
+ // 多行右栏回退:右簇无同基线文本、但存在「竖直连续、右缘同列、整体垂直居中于 label」
1693
+ // 的多行堆叠时(值行+副值行居中于左标签),按多行值收编,避免整行退化为平铺孤立块。
1694
+ if (alignedRightTexts.length === 0 && rt.length >= 2) {
1695
+ const stacked = [...rt].sort((a, c) => (rect(a).y || 0) - (rect(c).y || 0))
1696
+ const stackTop = rect(stacked[0]).y || 0
1697
+ const stackBottom = Math.max(...stacked.map((t) => (rect(t).y || 0) + (rect(t).height || 0)))
1698
+ const labelVCenter = ly + (rect(lab).height || (ts(lab).fontSize || 14) * 1.4) / 2
1699
+ const contiguous = stacked.every((t, i) => i === 0 ||
1700
+ (rect(t).y || 0) <= (rect(stacked[i - 1]).y || 0) + (rect(stacked[i - 1]).height || 0) + 8)
1701
+ // 右缘同列按「行」度量:一行可能由多个异色 run 拼成(红色警示+灰色补充),
1702
+ // 逐文本度量会把行内左段 run 的右缘混进来,误判整列散开而拒收。
1703
+ const lineRights = new Map()
1704
+ for (const t of stacked) {
1705
+ const key = Math.round((rect(t).y || 0) / 4)
1706
+ const right = (rect(t).x || 0) + (rect(t).width || 0)
1707
+ lineRights.set(key, Math.max(lineRights.get(key) || 0, right))
1708
+ }
1709
+ const rightEdges = [...lineRights.values()]
1710
+ // 右缘同列容忍放宽到 64px:右对齐堆叠里副行(如红色错误提示)比值行宽,
1711
+ // 24px 容忍会把「值行+提示行」整列拒收,整行退化为平铺孤立块。
1712
+ const sameColumn = Math.max(...rightEdges) - Math.min(...rightEdges) <= 64
1713
+ if (contiguous && sameColumn && stackTop - 6 <= labelVCenter && labelVCenter <= stackBottom + 6) {
1714
+ alignedRightTexts = stacked
1715
+ }
1716
+ }
1717
+ if (alignedRightTexts.length === 0) continue
1718
+ // 值 = 与标签同基线的可绑定文本;紧随其下的短副行才作为子值,
1719
+ // 避免把下一表单行误收进来,也不因副行基线不同而丢失「值 + 说明」结构。
1720
+ const valueEl = [...alignedRightTexts].sort((a, c) => (rect(a).y || 0) - (rect(c).y || 0))[0] || null
1721
+ const valueBottom = valueEl ? (rect(valueEl).y || 0) + (rect(valueEl).height || 0) : 0
1722
+ const subEls = rt.filter((t) => t !== valueEl
1723
+ && (rect(t).y || 0) >= valueBottom - 2
1724
+ && (rect(t).y || 0) <= valueBottom + 28)
1725
+ .filter((t) => !labelCands.some((label) => label !== lab && !formConsumed.has(label)
1726
+ && Math.abs((rect(label).y || 0) - (rect(t).y || 0)) <= 8))
1727
+ .sort((a, c) => (rect(a).x || 0) - (rect(c).x || 0))
1728
+ const rightAffordanceSlice = [...sl]
1729
+ .sort((left, right) => ((rect(right).x || 0) + (rect(right).width || 0))
1730
+ - ((rect(left).x || 0) + (rect(left).width || 0)))
1731
+ .find((s) => rightAffordanceCandidates.includes(s)) || null
1732
+ // placeholder 判定:值文本明显比 label 浅时视为输入占位,只消费设计稿自身的视觉对比信号。
1733
+ // 但强调色值(如橙色电话/地址,高饱和度)不是占位符——占位符是浅灰色(低饱和)。
1734
+ // 曾把 #FF8401 橙色值误判为 placeholder → 渲染成空 Input(值整体丢失)。
1735
+ const colorSat = (hex) => {
1736
+ const h = String(hex || '').replace('#', '')
1737
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) return 0
1738
+ const r = parseInt(h.slice(0, 2), 16) / 255, g = parseInt(h.slice(2, 4), 16) / 255, b = parseInt(h.slice(4, 6), 16) / 255
1739
+ const mx = Math.max(r, g, b), mn = Math.min(r, g, b)
1740
+ return mx === 0 ? 0 : (mx - mn) / mx
1741
+ }
1742
+ const isPlaceholder = valueEl
1743
+ ? lum(ts(valueEl).color) - lum(ts(lab).color) > 0.3 && colorSat(ts(valueEl).color) < 0.25
1744
+ : false
1745
+ // 行左图标:label 左侧、竖直中心对齐的小切片(如 营业/排队/电话/地址 行图标)。
1746
+ // 此前左图标无任何归属路径,被静默丢弃。
1747
+ const leftIconSlice = slices.find((s) => {
1748
+ const sr = rect(s)
1749
+ const sw = sr.width || 0, sh = sr.height || 0
1750
+ const vCenter = (sr.y || 0) + sh / 2
1751
+ const lCenter = ly + (ts(lab).fontSize || 0) / 2
1752
+ return sw >= 10 && sw <= 32 && sh >= 10 && sh <= 32
1753
+ && (sr.x || 0) + sw <= lx + 2 && (sr.x || 0) >= (rect(card).x || 0) - 4
1754
+ && Math.abs(vCenter - lCenter) <= 12
1755
+ }) || null
1756
+ // 对比面板壳/圆点/行内状态 tag:壳承行底色/圆角/高度与内缩,圆点进 label 左簇,tag 随值行
1757
+ const labRect0 = rect(lab)
1758
+ const labCenter0 = { x: (labRect0.x || 0) + (labRect0.width || 0) / 2, y: (labRect0.y || 0) + (labRect0.height || 0) / 2 }
1759
+ const panelShellEl = shapes.find((s2) => {
1760
+ const f2 = s2.style?.fill
1761
+ if (!f2 || (!f2.color && !f2.gradient)) return false
1762
+ if (radiusMax(s2.style) < 4) return false
1763
+ const r2 = rect(s2)
1764
+ const w2 = r2.width || 0; const h2 = r2.height || 0
1765
+ if (w2 < 100 || w2 > 260 || h2 < 48 || h2 > 120) return false
1766
+ return labCenter0.x >= (r2.x || 0) && labCenter0.x <= (r2.x || 0) + w2
1767
+ && labCenter0.y >= (r2.y || 0) && labCenter0.y <= (r2.y || 0) + h2
1768
+ }) || null
1769
+ const dotEl = panelShellEl ? shapes.find((s2) => {
1770
+ const f2 = s2.style?.fill
1771
+ if (!f2 || !f2.color) return false
1772
+ const r2 = rect(s2)
1773
+ const w2 = r2.width || 0; const h2 = r2.height || 0
1774
+ if (w2 < 6 || w2 > 14 || h2 < 6 || h2 > 14) return false
1775
+ const pr2 = rect(panelShellEl)
1776
+ return (r2.x || 0) >= (pr2.x || 0) && (r2.x || 0) + w2 <= (pr2.x || 0) + (pr2.width || 0)
1777
+ && (r2.y || 0) >= (pr2.y || 0) && (r2.y || 0) + h2 <= (pr2.y || 0) + (pr2.height || 0)
1778
+ && (r2.x || 0) + w2 <= (labRect0.x || 0)
1779
+ && Math.abs(((r2.y || 0) + h2 / 2) - labCenter0.y) <= 12
1780
+ }) || null : null
1781
+ const tagTextEl = panelShellEl ? texts.find((t2) => {
1782
+ if (t2 === lab || t2 === valueEl || subEls.includes(t2)) return false
1783
+ const r2 = rect(t2)
1784
+ const w2 = r2.width || 0; const h2 = r2.height || 0
1785
+ if (w2 < 8 || w2 > 60 || h2 < 8 || h2 > 24) return false
1786
+ const pr2 = rect(panelShellEl)
1787
+ return (r2.x || 0) >= (pr2.x || 0) && (r2.x || 0) + w2 <= (pr2.x || 0) + (pr2.width || 0)
1788
+ && (r2.y || 0) >= (pr2.y || 0) && (r2.y || 0) + h2 <= (pr2.y || 0) + (pr2.height || 0)
1789
+ }) || null : null
1790
+ const tagShapeEl = tagTextEl ? shapes.find((s2) => {
1791
+ const r2 = rect(s2)
1792
+ const tr2 = rect(tagTextEl)
1793
+ return (r2.width || 0) >= (tr2.width || 0) && (r2.width || 0) <= (tr2.width || 0) + 16
1794
+ && (r2.height || 0) >= (tr2.height || 0) && (r2.height || 0) <= (tr2.height || 0) + 12
1795
+ && Math.abs((r2.x || 0) - (tr2.x || 0)) <= 8 && Math.abs((r2.y || 0) - (tr2.y || 0)) <= 8
1796
+ }) || null : null
1797
+ formRows.push({
1798
+ panelShell: panelShellEl ? {
1799
+ x: rect(panelShellEl).x || 0, y: rect(panelShellEl).y || 0,
1800
+ width: rect(panelShellEl).width || 0, height: rect(panelShellEl).height || 0,
1801
+ fill: panelShellEl.style?.fill?.color || null,
1802
+ gradient: panelShellEl.style?.fill?.gradient || null,
1803
+ radius: radiusMax(panelShellEl.style),
1804
+ opacity: panelShellEl.style?.opacity ?? 1,
1805
+ } : null,
1806
+ dot: dotEl ? {
1807
+ color: dotEl.style.fill.color,
1808
+ x: rect(dotEl).x || 0, y: rect(dotEl).y || 0,
1809
+ w: Math.round(rect(dotEl).width || 8), h: Math.round(rect(dotEl).height || 8),
1810
+ gap: Math.max(0, Math.round((labRect0.x || 0) - ((rect(dotEl).x || 0) + (rect(dotEl).width || 0)))),
1811
+ } : null,
1812
+ tag: tagTextEl ? {
1813
+ text: textOf(tagTextEl), color: ts(tagTextEl).color, fontSize: ts(tagTextEl).fontSize,
1814
+ borderColor: tagShapeEl && tagShapeEl.style?.border?.isBorder ? tagShapeEl.style.border.color : null,
1815
+ fill: tagShapeEl ? (tagShapeEl.style?.fill?.color || null) : null,
1816
+ radius: tagShapeEl ? radiusMax(tagShapeEl.style) : 2,
1817
+ w: Math.round(rect(tagShapeEl || tagTextEl).width || 24),
1818
+ h: Math.round(rect(tagShapeEl || tagTextEl).height || 14),
1819
+ right: Math.round((rect(tagShapeEl || tagTextEl).x || 0) + (rect(tagShapeEl || tagTextEl).width || 0)),
1820
+ } : null,
1821
+ label: { text: textOf(lab), color: ts(lab).color, fontSize: ts(lab).fontSize, fontWeight: ts(lab).fontWeight, x: lx, y: ly, width: rect(lab).width || 0, height: rect(lab).height || (ts(lab).fontSize || 14) * 1.4 },
1822
+ leftIconSlice,
1823
+ value: valueEl ? { text: textOf(valueEl), color: ts(valueEl).color, fontSize: ts(valueEl).fontSize, fontWeight: ts(valueEl).fontWeight, x: rect(valueEl).x || 0, y: rect(valueEl).y || 0, width: rect(valueEl).width || 0, height: rect(valueEl).height || (ts(valueEl).fontSize || 14) * 1.4 } : null,
1824
+ subValues: subEls.map((t) => ({ text: textOf(t), color: ts(t).color, fontSize: ts(t).fontSize, fontWeight: ts(t).fontWeight, x: rect(t).x || 0, y: rect(t).y || 0, width: rect(t).width || 0, height: rect(t).height || (ts(t).fontSize || 14) * 1.4 })),
1825
+ rightAffordanceSlice,
1826
+ isPlaceholder,
1827
+ labelEl: lab,
1828
+ valueEl,
1829
+ subEls,
1830
+ })
1831
+ formConsumed.add(lab)
1832
+ if (valueEl) formConsumed.add(valueEl)
1833
+ subEls.forEach((t) => formConsumed.add(t))
1834
+ }
1835
+
1836
+ // 周期同构行护栏:≥3 个表单行共享同一几何签名(label 列 x/fs + value fs)且行距等周期时,
1837
+ // 它们**可能**是列表行而非独立表单字段——释放消费标记交给列表识别,否则列表退化为平铺表单行、
1838
+ // 行内描述行失去归属被甩到列表之后(账单/记录类卡片高发)。
1839
+ // 但「等周期 + 大行距」不足以判定列表:地址/验证码类表单自带分隔线与行尾编辑件,
1840
+ // 行距可达 55~100px,一旦释放右栏值就失去归属而整段丢失(曾造成地址管理卡回归)。
1841
+ // 判据收紧为「多行行」:行带内除标题外还有左栏描述行,或行首带图标;且组内全部行都满足,
1842
+ // 混合结构一律保留为表单行。
1843
+ {
1844
+ const rowSignature = (row) => [
1845
+ Math.round((row.label.x || 0) / 8),
1846
+ row.label.fontSize || 0,
1847
+ row.value ? (row.value.fontSize || 0) : 0,
1848
+ ].join(':')
1849
+ const signatureGroups = new Map()
1850
+ for (const row of formRows) {
1851
+ const key = rowSignature(row)
1852
+ if (!signatureGroups.has(key)) signatureGroups.set(key, [])
1853
+ signatureGroups.get(key).push(row)
1854
+ }
1855
+ for (const rows of signatureGroups.values()) {
1856
+ if (rows.length < 3) continue
1857
+ const ys = rows.map((row) => row.label.y || 0).sort((a, b) => a - b)
1858
+ const pitches = ys.slice(1).map((y, i) => y - ys[i])
1859
+ if (!pitches.every((pitch) => Math.abs(pitch - pitches[0]) <= 8)) continue
1860
+ // 行距下限:紧凑字段栈(结果卡 转账金额/收款人/收款账户,行距 ~24px)也等周期,
1861
+ // 但它是独立表单字段而非列表行;列表行含描述行,行距 ≥40px。
1862
+ if (pitches[0] < 40) continue
1863
+ const bandPitch = pitches[0]
1864
+ // 多行行判定:标题下方、下一行标题上方,左栏(x 与标题同列 ±24px)还有文本 = 描述行。
1865
+ const isMultiLineRow = (row) => {
1866
+ if (row.leftIconSlice) return true
1867
+ const labelX = row.label.x || 0
1868
+ const labelBottom = (row.label.y || 0) + (row.label.height || (row.label.fontSize || 14) * 1.4)
1869
+ const bandBottom = (row.label.y || 0) + bandPitch
1870
+ const owned = new Set([row.labelEl, row.valueEl, ...(row.subEls || [])])
1871
+ return texts.some((t) => {
1872
+ if (owned.has(t) || !textOf(t).trim()) return false
1873
+ const tr = rect(t)
1874
+ const tx = tr.x || 0
1875
+ const ty = tr.y || 0
1876
+ return ty >= labelBottom - 2 && ty < bandBottom - 2 && Math.abs(tx - labelX) <= 24
1877
+ })
1878
+ }
1879
+ if (!rows.every(isMultiLineRow)) continue
1880
+ for (const row of rows) {
1881
+ const idx = formRows.indexOf(row)
1882
+ if (idx >= 0) formRows.splice(idx, 1)
1883
+ formConsumed.delete(row.labelEl)
1884
+ if (row.valueEl) formConsumed.delete(row.valueEl)
1885
+ for (const sub of row.subEls || []) formConsumed.delete(sub)
1886
+ releasedListRows.push(row)
1887
+ }
1888
+ }
1889
+ }
1890
+
1891
+ // 同名 label 护栏:表单带内同一 label 文本出现 ≥2 次,说明这些行是重复结构的列表行
1892
+ // (如名册中的多个人名行),而非表单行——移除并释放消费标记,让列表识别接管。
1893
+ {
1894
+ const formLabelCounts = new Map()
1895
+ for (const row of formRows) {
1896
+ const key = (row.label?.text || '').trim()
1897
+ if (key) formLabelCounts.set(key, (formLabelCounts.get(key) || 0) + 1)
1898
+ }
1899
+ for (let rowIndex = formRows.length - 1; rowIndex >= 0; rowIndex -= 1) {
1900
+ const row = formRows[rowIndex]
1901
+ if ((formLabelCounts.get((row.label?.text || '').trim()) || 0) >= 2) {
1902
+ if (row.labelEl) formConsumed.delete(row.labelEl)
1903
+ if (row.valueEl) formConsumed.delete(row.valueEl)
1904
+ ;(row.subEls || []).forEach((el) => formConsumed.delete(el))
1905
+ formRows.splice(rowIndex, 1)
1906
+ }
1907
+ }
1908
+ }
1909
+
1910
+ // 行内尾随图标:值文本右侧紧邻的小图标(如 排队:5 后的刷新图标)。
1911
+ // 与行尾 affordance 的区别:贴值右沿而非卡片右缘,是值流的一部分,渲染时随值走。
1912
+ {
1913
+ const trailingPool = [...slices, ...symbols].filter((el) => {
1914
+ const er = rect(el)
1915
+ const w = er.width || 0, h = er.height || 0
1916
+ return w >= 10 && w <= 28 && h >= 10 && h <= 28
1917
+ })
1918
+ for (const row of formRows) {
1919
+ if (row.panelShell) continue
1920
+ if (row.trailingIconSlice || row.rightAffordanceSlice || row.rightActionButton) continue
1921
+ const anchor = row.value
1922
+ if (!anchor) continue
1923
+ const valueRight = (anchor.x || 0) + (anchor.width || 0)
1924
+ const lineCenter = (anchor.y || 0) + (anchor.height || (anchor.fontSize || 14) * 1.4) / 2
1925
+ row.trailingIconSlice = trailingPool
1926
+ .filter((el) => {
1927
+ const er = rect(el)
1928
+ const iconCenter = (er.y || 0) + (er.height || 0) / 2
1929
+ const gap = (er.x || 0) - valueRight
1930
+ return gap >= 0 && gap <= 40 && Math.abs(iconCenter - lineCenter) <= 14
1931
+ })
1932
+ .sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))[0] || null
1933
+ }
1934
+ // 行内前置图标:值文本左侧紧邻的小媒体(如行内 logo + 卡号),是值流的一部分;
1935
+ // 无归属路径时会被静默丢弃,右栏只剩裸文本。
1936
+ const claimed = new Set(formRows.flatMap((row) =>
1937
+ [row.rightAffordanceSlice, row.leftIconSlice, row.trailingIconSlice].filter(Boolean)))
1938
+ for (const row of formRows) {
1939
+ if (row.panelShell) continue
1940
+ const anchor = row.value
1941
+ if (!anchor || row.valueLeadSlice) continue
1942
+ const valueLeft = anchor.x || 0
1943
+ const lineCenter = (anchor.y || 0) + (anchor.height || (anchor.fontSize || 14) * 1.4) / 2
1944
+ row.valueLeadSlice = trailingPool
1945
+ .filter((el) => !claimed.has(el))
1946
+ .filter((el) => {
1947
+ const er = rect(el)
1948
+ const iconCenter = (er.y || 0) + (er.height || 0) / 2
1949
+ const gap = valueLeft - ((er.x || 0) + (er.width || 0))
1950
+ return gap >= -2 && gap <= 40 && Math.abs(iconCenter - lineCenter) <= 14
1951
+ })
1952
+ .sort((a, b) => (rect(b).x || 0) - (rect(a).x || 0))[0] || null
1953
+ if (row.valueLeadSlice) claimed.add(row.valueLeadSlice)
1954
+ }
1955
+ }
1956
+
1957
+ // Symbol 组件实例是可见媒体:用包含关系剔除外壳后,剩余实例按同行文本聚成媒体行。
1958
+ const containsRect = (outer, inner, tolerance = 2) => {
1959
+ const o = rect(outer)
1960
+ const n = rect(inner)
1961
+ return (o.x || 0) - tolerance <= (n.x || 0)
1962
+ && (o.y || 0) - tolerance <= (n.y || 0)
1963
+ && ((o.x || 0) + (o.width || 0)) + tolerance >= ((n.x || 0) + (n.width || 0))
1964
+ && ((o.y || 0) + (o.height || 0)) + tolerance >= ((n.y || 0) + (n.height || 0))
1965
+ }
1966
+ const overlapRatio = (a, b) => {
1967
+ const ar = rect(a); const br = rect(b)
1968
+ const width = Math.min((ar.x || 0) + (ar.width || 0), (br.x || 0) + (br.width || 0))
1969
+ - Math.max(ar.x || 0, br.x || 0)
1970
+ const height = Math.min((ar.y || 0) + (ar.height || 0), (br.y || 0) + (br.height || 0))
1971
+ - Math.max(ar.y || 0, br.y || 0)
1972
+ if (width <= 0 || height <= 0) return 0
1973
+ return (width * height) / Math.min((ar.width || 1) * (ar.height || 1), (br.width || 1) * (br.height || 1))
1974
+ }
1975
+ // 覆盖率:交集占 outer 面积的比例。用于判断"inner 是否几乎占满 outer"(outer 是 inner 的壳),
1976
+ // 与 overlapRatio 的 min 面积归一不同——小元素落在大壳内时覆盖率低,不会误杀壳候选。
1977
+ const coverRatio = (outer, inner) => {
1978
+ const ar = rect(outer); const br = rect(inner)
1979
+ const width = Math.min((ar.x || 0) + (ar.width || 0), (br.x || 0) + (br.width || 0))
1980
+ - Math.max(ar.x || 0, br.x || 0)
1981
+ const height = Math.min((ar.y || 0) + (ar.height || 0), (br.y || 0) + (br.height || 0))
1982
+ - Math.max(ar.y || 0, br.y || 0)
1983
+ if (width <= 0 || height <= 0) return 0
1984
+ return (width * height) / ((ar.width || 1) * (ar.height || 1))
1985
+ }
1986
+ const leafSymbols = symbols.filter((symbol) =>
1987
+ !symbols.some((other) => other !== symbol && containsRect(symbol, other)))
1988
+ const mediaCardWidth = rect(card).width || (spec.size && spec.size.width) || 375
1989
+ // 与选项/面板壳同位的 symbol 是同一视觉的重复标注(如 pill 同时存在 symbol 实例和矩形壳),
1990
+ // 不剔除会双份渲染(symbol 占位图 + 选项按钮叠在一起)。
1991
+ const shellCovered = (el) => [...optionShapeSet].some((s) => overlapRatio(el, s) > 0.6)
1992
+ // 行壳重复标注:symbol 与同矩形带填充 shape 共存(shape 承载填充色)→ symbol 侧不得进媒体行,
1993
+ // 否则行内容被占位图认领、列表识别被打散(交易行曾裂成「静态行 + 单行列表」)。
1994
+ const shapeShellDup = (el) => {
1995
+ const er = rect(el)
1996
+ return shapes.some((sh) => Math.abs((rect(sh).x || 0) - (er.x || 0)) <= 2
1997
+ && Math.abs((rect(sh).y || 0) - (er.y || 0)) <= 2
1998
+ && Math.abs((rect(sh).width || 0) - (er.width || 0)) <= 2 && Math.abs((rect(sh).height || 0) - (er.height || 0)) <= 2)
1999
+ }
2000
+ const symbolMediaRows = leafSymbols.filter((symbol) =>
2001
+ !shellCovered(symbol) && !shapeShellDup(symbol) &&
2002
+ !buttonShapes.some((button) => overlapRatio(symbol, button) > 0.5) && !(() => {
2003
+ const sr = rect(symbol)
2004
+ return (sr.width || 0) >= mediaCardWidth * 0.8 && (sr.height || 0) >= 100
2005
+ })()).map((symbol) => {
2006
+ const shell = symbols.find((other) => other !== symbol && containsRect(other, symbol)) || symbol
2007
+ const shellRect = rect(shell)
2008
+ const sr = rect(symbol)
2009
+ const symbolBottom = (sr.y || 0) + (sr.height || 0)
2010
+ const paired = texts.filter((t) => {
2011
+ const tr = rect(t)
2012
+ const center = (tr.y || 0) + (tr.height || 0) / 2
2013
+ return (tr.x || 0) >= ((sr.x || 0) + (sr.width || 0) * 0.35)
2014
+ && center >= (sr.y || 0) - 6 && center <= symbolBottom + 6
2015
+ && !formConsumed.has(t)
2016
+ && !buttons.some((button) => button.text === textOf(t))
2017
+ }).sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
2018
+ paired.forEach((t) => formConsumed.add(t))
2019
+ const annotatedGaps = paired
2020
+ .map((text) => (rect(text).x || 0) - ((sr.x || 0) + (sr.width || 0)))
2021
+ .filter((gap) => Number.isFinite(gap) && gap >= 0)
2022
+ return {
2023
+ symbolEl: symbol,
2024
+ x: sr.x || 0,
2025
+ y: shellRect.y || sr.y || 0,
2026
+ width: sr.width || 0,
2027
+ height: shellRect.height || sr.height || 0,
2028
+ texts: paired.map((t) => ({
2029
+ text: textOf(t),
2030
+ color: ts(t).color,
2031
+ fontSize: ts(t).fontSize,
2032
+ fontWeight: ts(t).fontWeight,
2033
+ align: ts(t).textAlign,
2034
+ y: rect(t).y || 0,
2035
+ x: rect(t).x || 0,
2036
+ width: rect(t).width || 0,
2037
+ sourceEl: t,
2038
+ })),
2039
+ gap: annotatedGaps.length ? Math.min(...annotatedGaps) : null,
2040
+ }
2041
+ }).filter((row) => row.width > 0 && row.height > 0 && row.texts.length > 0)
2042
+ .sort((a, b) => a.y - b.y)
2043
+ const symbolMediaConsumed = new Set(symbolMediaRows.flatMap((row) =>
2044
+ (row.texts || []).map((text) => text.sourceEl)).filter(Boolean))
2045
+
2046
+ // —— 进度/占比条:同 y 的长轨道 + 一段或多段短填充。 ——
2047
+ // 只用宽度、高度、包含关系等几何信号;分隔线通常等宽且高度极小,不会形成长短对。
2048
+ const layoutCardWidth = rect(card).width || (spec.size && spec.size.width) || 375
2049
+ const barShapes = shapes.filter((shape) => {
2050
+ const sr = rect(shape)
2051
+ return (sr.height || 0) > 0 && (sr.height || 0) <= 8 &&
2052
+ (sr.width || 0) >= 40 && (sr.width || 0) < layoutCardWidth * 0.98
2053
+ })
2054
+ const progressBars = []
2055
+ for (const track of barShapes) {
2056
+ const tr = rect(track)
2057
+ const trackRight = (tr.x || 0) + (tr.width || 0)
2058
+ const fills = barShapes.filter((shape) => {
2059
+ const sr = rect(shape)
2060
+ if (shape === track || (sr.width || 0) >= (tr.width || 0) * 0.95) return false
2061
+ return Math.abs((sr.y || 0) - (tr.y || 0)) <= 2
2062
+ && Math.abs((sr.height || 0) - (tr.height || 0)) <= 4
2063
+ && (sr.x || 0) >= (tr.x || 0) - 2
2064
+ && ((sr.x || 0) + (sr.width || 0)) <= trackRight + 2
2065
+ }).sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
2066
+ if (!fills.length) continue
2067
+ progressBars.push({
2068
+ x: tr.x || 0,
2069
+ y: tr.y || 0,
2070
+ width: tr.width || 0,
2071
+ height: tr.height || 0,
2072
+ trackColor: track.style?.fill?.color,
2073
+ trackEl: track,
2074
+ segmentEls: fills,
2075
+ segments: fills.map((fill) => ({
2076
+ ratio: Math.min(1, Math.max(0, (rect(fill).width || 0) / (tr.width || 1))),
2077
+ color: fill.style?.fill?.color || track.style?.fill?.color,
2078
+ })),
2079
+ })
2080
+ }
2081
+ // 无轨道多段拼接条(如 蓝段+黄段 相邻平铺的比例条):同 y 同高、横向相邻(≤8px)的
2082
+ // bar 形组以「首左沿→末右沿」为轨道合成多段进度条;否则两段被当孤立色块丢弃。
2083
+ const usedBarEls = new Set()
2084
+ for (const bar of progressBars) {
2085
+ if (bar.trackEl) usedBarEls.add(bar.trackEl)
2086
+ for (const el of (bar.segmentEls || [])) usedBarEls.add(el)
2087
+ }
2088
+ const freeBarShapes = barShapes.filter((shape) => !usedBarEls.has(shape))
2089
+ .sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
2090
+ const tiledGroups = []
2091
+ for (const shape of freeBarShapes) {
2092
+ const sr = rect(shape)
2093
+ const group = tiledGroups.find((gg) => Math.abs((gg.y || 0) - (sr.y || 0)) <= 2
2094
+ && Math.abs((gg.h || 0) - (sr.height || 0)) <= 2
2095
+ && (sr.x || 0) - (gg.right || 0) <= 8 && (sr.x || 0) >= (gg.right || 0) - 2)
2096
+ if (group) {
2097
+ group.members.push(shape)
2098
+ group.right = Math.max(group.right, (sr.x || 0) + (sr.width || 0))
2099
+ } else {
2100
+ tiledGroups.push({ y: sr.y || 0, h: sr.height || 0, x: sr.x || 0,
2101
+ right: (sr.x || 0) + (sr.width || 0), members: [shape] })
2102
+ }
2103
+ }
2104
+ for (const group of tiledGroups) {
2105
+ if (group.members.length < 2) continue
2106
+ const span = group.right - group.x
2107
+ if (span < layoutCardWidth * 0.4) continue
2108
+ progressBars.push({
2109
+ x: group.x,
2110
+ y: group.y,
2111
+ width: span,
2112
+ height: group.h,
2113
+ trackColor: null,
2114
+ segments: group.members.map((member) => ({
2115
+ ratio: Math.min(1, Math.max(0, (rect(member).width || 0) / (span || 1))),
2116
+ color: member.style?.fill?.color || null,
2117
+ })),
2118
+ })
2119
+ }
2120
+
2121
+ // —— 内嵌面板行:外层卡内的几何壳(列表项/入口卡/选择行)必须作为整体渲染。 ——
2122
+ // 判定只依赖:宽壳 + 圆角 + 内部堆叠文本 + 可选左右媒体;不识别业务文案。
2123
+ const cardWidth = (spec.size && spec.size.width) || rect(card).width || 375
2124
+ const cardHeight = rect(card).height || (spec.size && spec.size.height) || 600
2125
+ const subAmountElements = subAmounts.flatMap((item) => [item.labelEl, item.valueEl, item.symbolEl]).filter(Boolean)
2126
+ const subAmountElementSet = new Set(subAmountElements)
2127
+ const panelTextConsumed = new Set()
2128
+ const panelCandidates = shapes
2129
+ .filter((shape) => {
2130
+ const sr = rect(shape)
2131
+ const radius = radiusMax(shape.style)
2132
+ const ownsPrimaryAmount = !!primaryAmountEl && overlapRatio(primaryAmountEl, shape) > 0.8
2133
+ const sameFillAsCard = card.cardFill
2134
+ && String(shape.style?.fill?.color || '').toUpperCase() === String(card.cardFill).toUpperCase()
2135
+ // 排除卡片自身底壳:接近全卡尺寸的 shape 是卡片背景(标题/选项都是它的"内容"),
2136
+ // 误当面板会把整卡文本吞成普通行,与选项组/表单重复渲染(曾双份渲染标题+选项)。
2137
+ const coversCard = sr.width >= (rect(card).width || cardWidth) * 0.98 && sr.height >= cardHeight * 0.9
2138
+ // 复合行模式:壳内含重复波段(标签+值行)→ 是列表行容器/卡头壳,不是信息面板
2139
+ const containsRepeatBand = repeatRowMode && _repeatBandTops.some((bt) =>
2140
+ bt >= (sr.y || 0) - 2 && bt <= (sr.y || 0) + (sr.height || 0) + 2)
2141
+ return !coversCard && !containsRepeatBand
2142
+ && sr.width >= cardWidth * 0.6 && sr.height <= Math.max(200, cardHeight * 0.5) && radius >= 6
2143
+ // 入口/链接行(高 36-50):单条左齐文本 + 右端小亲和件(箭头/勾选切片或矢量形)。
2144
+ // 高度下限原为 50,44px 白底入口行全部漏判 → 整行底色+文本丢失(像素热区整带)。
2145
+ && (sr.height >= 50 || (sr.height >= 36 && (() => {
2146
+ const innerTexts = texts.filter((t2) => {
2147
+ const tr2 = rect(t2)
2148
+ return (tr2.x || 0) >= (sr.x || 0) - 2 && (tr2.y || 0) >= (sr.y || 0) - 2
2149
+ && ((tr2.x || 0) + (tr2.width || 0)) <= (sr.x || 0) + (sr.width || 0) + 2
2150
+ && ((tr2.y || 0) + (tr2.height || 0)) <= (sr.y || 0) + (sr.height || 0) + 2
2151
+ })
2152
+ if (innerTexts.length !== 1) return false
2153
+ const tr2 = rect(innerTexts[0])
2154
+ const leftAligned = ((tr2.x || 0) - (sr.x || 0)) <= (sr.width || 0) * 0.15
2155
+ const affordance = [...shapes, ...(slices || [])].some((e2) => {
2156
+ if (e2 === shape) return false
2157
+ const er2 = rect(e2)
2158
+ const ew = er2.width || 0
2159
+ const eh = er2.height || 0
2160
+ if (ew < 4 || ew > 32 || eh < 4 || eh > 32) return false
2161
+ return (er2.x || 0) + ew >= (sr.x || 0) + (sr.width || 0) - 28
2162
+ && Math.abs(((er2.y || 0) + eh / 2) - ((sr.y || 0) + (sr.height || 0) / 2)) <= (sr.height || 0) / 2
2163
+ })
2164
+ return leftAligned && affordance
2165
+ })()))
2166
+ && !(ownsPrimaryAmount && sameFillAsCard)
2167
+ && !buttonShapes.some((button) => {
2168
+ // 主尺寸按钮(w>=80,CTA/主操作):壳整体含按钮 → 复合容器(产品卡/CTA 块),
2169
+ // 交还 主按钮/渐变容器 专用路径(min 面积归一,原语义);小 pill 是行内操作件,
2170
+ // 落在壳内不排除壳(覆盖率归一,[媒体|文本栈|pill] 复合行仍是面板)。
2171
+ // 壳自身落进 buttonShapes(形状级)但未成为按钮(文本非居中,如入口行)时,不得自排除:
2172
+ // 否则入口行壳永远进不了 panelRows,整行底色+文本丢失。
2173
+ if (button === shape) return false
2174
+ const isPrimarySized = (rect(button).width || 0) >= 80
2175
+ return isPrimarySized ? overlapRatio(shape, button) > 0.5 : coverRatio(shape, button) > 0.5
2176
+ })
2177
+ && !symbolMediaRows.some((row) => coverRatio(shape, row.symbolEl) > 0.5)
2178
+ })
2179
+ const panelShapes = panelCandidates.filter((shape) =>
2180
+ // 面板组壳已由面板列表模板接管(panelGroup → ListLayout);panelRows 再认领同一批壳
2181
+ // 会把列表行再静态渲染一遍(v6 产品 tile 重影,duplicate-content ×23)。与 gradientPanels
2182
+ // 的 _panelGroupShellSet 排除同族。
2183
+ !_panelGroupShellSet.has(shape) &&
2184
+ !panelCandidates.some((other) => other !== shape && containsRect(shape, other)))
2185
+ const panelRows = panelShapes
2186
+ .map((shape) => {
2187
+ const sr = rect(shape)
2188
+ const right = (sr.x || 0) + (sr.width || 0)
2189
+ const bottom = (sr.y || 0) + (sr.height || 0)
2190
+ const inside = (e) => {
2191
+ const er = rect(e)
2192
+ return (er.x || 0) >= (sr.x || 0) - 2 && (er.y || 0) >= (sr.y || 0) - 2
2193
+ && ((er.x || 0) + (er.width || 0)) <= right + 2
2194
+ && ((er.y || 0) + (er.height || 0)) <= bottom + 2
2195
+ }
2196
+ const buttonTextEls = new Set(buttons.map((button) => button.sourceEl).filter(Boolean))
2197
+ const paired = texts.filter((text) => !symbolMediaConsumed.has(text) && !(subAmountElementSet.has(text) && !inside(text))
2198
+ && !optionTextSet.has(text) && !optionLabelSet.has(text)
2199
+ && !buttonTextEls.has(text)).filter(inside).sort((a, b) =>
2200
+ (rect(a).y || 0) - (rect(b).y || 0) || (rect(a).x || 0) - (rect(b).x || 0))
2201
+ // 段落壳归富文本通道:壳内含 richBlock 碎片/容器时 panelRows 再认领会把段落链接行
2202
+ // (查看详细建议)抢成面板行插到段落之前,DOM 顺序颠倒且灰底壳重复归属。
2203
+ const richInside = (richBlocks || []).some((blk) => inside(blk.container)
2204
+ || (blk.fragments || []).some((frag) => inside(frag)))
2205
+ if (!paired.length || richInside) return null
2206
+ const overlapsExisting = paired.some((t) => {
2207
+ const value = textOf(t)
2208
+ return subAmounts.some((item) => item.label === value || item.value === value)
2209
+ || formRows.some((row) => row.label?.text === value
2210
+ || row.value?.text === value
2211
+ || (row.subValues || []).some((sub) => sub.text === value))
2212
+ })
2213
+ const panelSlices = [...slices, ...symbols.filter((sy) =>
2214
+ !symbolMediaRows.some((row) => row.symbolEl === sy))].filter(inside)
2215
+ const firstText = paired[0]
2216
+ const lastText = paired[paired.length - 1]
2217
+ const leftMedia = panelSlices.find((s) => (rect(s).x || 0) < (rect(firstText).x || 0)) || null
2218
+ const rightMedia = panelSlices.find((s) => (rect(s).x || 0) > ((rect(lastText).x || 0) + (rect(lastText).width || 0))) || null
2219
+ // 几何强度:多行文本栈(>=4 文本),或 >=2 文本 + 壳内有媒体/按钮佐证。
2220
+ // 后者覆盖 [媒体|文本栈|操作件] 复合行——壳、媒体、按钮都是几何证据,
2221
+ // 不能因文本被 formRow 先行认领就放弃面板(否则右操作件被甩出面板竖排)。
2222
+ const containsButton = buttons.some((b) => inside({ rect: b.rect || b }))
2223
+ // 放宽分支只认领「单行复合行」:无渐变(渐变壳由 gradientPanels 专用路径接管,
2224
+ // 抢过来会把 名称+口号+金额+按钮 的多块结构压成普通面板)、高度像行(<=120px)。
2225
+ const isGradientShell = !!shape.style?.fill?.gradient
2226
+ const rowLikeShell = (sr.height || 0) <= 120
2227
+ // 与卡同色的壳不是视觉面板(多是卡身背景延续,如白底白壳底部圆角补片)。
2228
+ // 仅当它是「单行复合行」(高度像行、壳内切片全部可作左右媒体)才按面板组织;
2229
+ // 多块内容(图标+文本多行、名称+金额+按钮)走面板会丢行内图标并压平布局。
2230
+ const sameFillShell = card.cardFill
2231
+ && String(shape.style?.fill?.color || '').toUpperCase() === String(card.cardFill).toUpperCase()
2232
+ // 只有「贴满卡左右缘」的同色壳才是卡身延续嫌疑;内缩壳(行卡/选项行)即使同色也是真实面板。
2233
+ const cardRx = rect(card).x || 0
2234
+ const fullWidthShell = Math.abs((sr.x || 0) - cardRx) <= 2
2235
+ && Math.abs((sr.width || 0) - (rect(card).width || 0)) <= 4
2236
+ if (sameFillShell && fullWidthShell) {
2237
+ const mediaSet = new Set([leftMedia, rightMedia].filter(Boolean))
2238
+ const innerSlicesSimple = panelSlices.every((s) => mediaSet.has(s))
2239
+ if (!(rowLikeShell && innerSlicesSimple)) return null
2240
+ }
2241
+ const strongPanel = new Set(paired.map((text) => Math.round((rect(text).y || 0) / 8))).size >= 2
2242
+ && (paired.length >= 4
2243
+ || (paired.length >= 2 && !isGradientShell && rowLikeShell && (leftMedia || rightMedia || containsButton)))
2244
+ if (overlapsExisting && !strongPanel) return null
2245
+ // 通过全部准入门槛后才消费文本:提前消费会让被拒壳内的文本既不渲染又被标记(丢失)。
2246
+ paired.forEach((t) => {
2247
+ formConsumed.add(t)
2248
+ panelTextConsumed.add(t)
2249
+ })
2250
+ const contentLeft = Math.min(...[leftMedia, ...paired].filter(Boolean).map((e) => rect(e).x || 0))
2251
+ const contentRight = Math.max(...[rightMedia, ...paired].filter(Boolean).map((e) => (rect(e).x || 0) + (rect(e).width || 0)))
2252
+ const contentTop = Math.min(...[leftMedia, ...paired].filter(Boolean).map((e) => rect(e).y || 0))
2253
+ const contentBottom = Math.max(...[rightMedia, ...paired].filter(Boolean).map((e) => (rect(e).y || 0) + (rect(e).height || 0)))
2254
+ const pairedSet = new Set(paired)
2255
+ const nextOverlappingTop = Math.min(...[...texts, ...slices, ...symbols]
2256
+ .filter((element) => !pairedSet.has(element))
2257
+ .map((element) => rect(element).y || Number.POSITIVE_INFINITY)
2258
+ .filter((top) => top > (sr.y || 0) + 2 && top < bottom - 2))
2259
+ const overlapTolerance = Math.max(16, (sr.height || 0) * 0.25)
2260
+ const flowBottom = Number.isFinite(nextOverlappingTop)
2261
+ && nextOverlappingTop >= bottom - overlapTolerance
2262
+ ? Math.min(bottom, nextOverlappingTop)
2263
+ : bottom
2264
+ const flowHeight = Math.max(0, Math.round(flowBottom - (sr.y || 0)))
2265
+ return {
2266
+ shapeEl: shape,
2267
+ y: sr.y || 0,
2268
+ x: sr.x || 0,
2269
+ width: sr.width || 0,
2270
+ height: flowHeight,
2271
+ fill: shape.style?.fill?.color || null,
2272
+ gradient: shape.style?.fill?.gradient || null,
2273
+ opacity: shape.style?.opacity ?? 1,
2274
+ radius: radiusMax(shape.style),
2275
+ leftSlice: leftMedia,
2276
+ rightSlice: rightMedia,
2277
+ sourceEls: paired,
2278
+ texts: paired.map((t) => ({
2279
+ text: textOf(t),
2280
+ color: ts(t).color,
2281
+ fontSize: ts(t).fontSize,
2282
+ fontWeight: ts(t).fontWeight,
2283
+ align: ts(t).textAlign,
2284
+ x: rect(t).x || 0,
2285
+ width: rect(t).width || 0,
2286
+ y: rect(t).y || 0,
2287
+ height: rect(t).height || (ts(t).fontSize || 14) * 1.4,
2288
+ })),
2289
+ paddingLeft: Math.max(0, Math.round(contentLeft - (sr.x || 0))),
2290
+ paddingRight: Math.max(0, Math.round(right - contentRight)),
2291
+ paddingTop: Math.max(0, Math.round(contentTop - (sr.y || 0))),
2292
+ paddingBottom: Math.max(0, Math.round(flowBottom - contentBottom)),
2293
+ }
2294
+ })
2295
+ .filter(Boolean).sort((a, b) => a.y - b.y)
2296
+
2297
+ const panelOwnedTexts = new Set(panelRows.flatMap((row) =>
2298
+ (row.sourceEls || []).map((element) => textOf(element))))
2299
+ for (let rowIndex = formRows.length - 1; rowIndex >= 0; rowIndex -= 1) {
2300
+ const row = formRows[rowIndex]
2301
+ const owned = [row.label?.text, row.value?.text, ...(row.subValues || []).map((item) => item.text)]
2302
+ .filter(Boolean)
2303
+ if (owned.some((text) => panelOwnedTexts.has(text))) formRows.splice(rowIndex, 1)
2304
+ }
2305
+
2306
+ // 面板右侧操作件挂接:未消费按钮整体落在面板内、且中心在面板文本右缘之外 → 面板 rightAction。
2307
+ // 按钮独立成行会被甩到面板之下(曾致 [logo|文本栈|修改] 媒体行竖向散架、位置分 0.76)。
2308
+ for (let bi = buttons.length - 1; bi >= 0; bi -= 1) {
2309
+ const btn = buttons[bi]
2310
+ const btnCx = (btn.x || 0) + (btn.width || 0) / 2
2311
+ const btnCy = (btn.y || 0) + (btn.height || 0) / 2
2312
+ const panel = panelRows.find((row) => {
2313
+ const textsRight = Math.max(0, ...(row.texts || []).map((t) => (t.x || 0) + (t.width || 0)))
2314
+ return btnCx > textsRight
2315
+ && btnCx >= (row.x || 0) && btnCx <= (row.x || 0) + (row.width || 0)
2316
+ && btnCy >= (row.y || 0) - 4 && btnCy <= (row.y || 0) + (row.height || 0) + 4
2317
+ })
2318
+ if (!panel) continue
2319
+ panel.rightActionButton = btn
2320
+ // 按钮文本随按钮入面板一并消费:按钮已从 buttons 摘除,postAmountTexts 的
2321
+ // buttons 排除随之失效,不标记会把按钮文案再渲染成游离辅助文本(重影)。
2322
+ if (btn.sourceEl) formConsumed.add(btn.sourceEl)
2323
+ // 右 padding 按按钮右缘重算(按钮入面板,contentRight 覆盖到按钮右沿)
2324
+ panel.paddingRight = Math.max(0, Math.round(
2325
+ ((panel.x || 0) + (panel.width || 0)) - ((btn.x || 0) + (btn.width || 0))))
2326
+ buttons.splice(bi, 1)
2327
+ }
2328
+
2329
+ // 面板夺走的文本从 midCluster 剔除:簇与面板壳同区时两条路径会双份渲染同一文本(重影)。
2330
+ if (midCluster && panelOwnedTexts.size) {
2331
+ midCluster.items = (midCluster.items || []).filter((it) => !panelOwnedTexts.has(it.text))
2332
+ }
2333
+
2334
+ // 副金额组常被外层浅色面板包裹;该壳属于组件结构,不能因文本已被金额语义消费而丢弃。
2335
+ const subAmountPanelShape = panelShapes.find((shape) => subAmountElements.length >= 4
2336
+ && subAmountElements.every((element) => overlapRatio(element, shape) > 0.8)) || null
2337
+ const subAmountPanel = (() => {
2338
+ if (!subAmountPanelShape) return null
2339
+ const sr = rect(subAmountPanelShape)
2340
+ const right = (sr.x || 0) + (sr.width || 0)
2341
+ const bottom = (sr.y || 0) + (sr.height || 0)
2342
+ const left = Math.min(...subAmountElements.map((element) => rect(element).x || 0))
2343
+ const contentRight = Math.max(...subAmountElements.map((element) =>
2344
+ (rect(element).x || 0) + (rect(element).width || 0)))
2345
+ const top = Math.min(...subAmountElements.map((element) => rect(element).y || 0))
2346
+ const contentBottom = Math.max(...subAmountElements.map((element) =>
2347
+ (rect(element).y || 0) + (rect(element).height || 0)))
2348
+ return {
2349
+ y: sr.y || 0,
2350
+ x: sr.x || 0,
2351
+ width: sr.width || 0,
2352
+ height: sr.height || 0,
2353
+ fill: subAmountPanelShape.style?.fill?.color || null,
2354
+ radius: subAmountPanelShape.style?.radius?.borderRadius || 0,
2355
+ paddingTop: Math.max(0, Math.round(top - (sr.y || 0))),
2356
+ paddingBottom: Math.max(0, Math.round(bottom - contentBottom)),
2357
+ paddingLeft: Math.max(0, Math.round(left - (sr.x || 0))),
2358
+ paddingRight: Math.max(0, Math.round(right - contentRight)),
2359
+ }
2360
+ })()
2361
+
2362
+ for (let rowIndex = formRows.length - 1; rowIndex >= 0; rowIndex -= 1) {
2363
+ const row = formRows[rowIndex]
2364
+ const rowEls = [row.labelEl, row.valueEl, ...(row.subEls || [])]
2365
+ if (rowEls.some((el) => el && subAmountEls.has(el))) formRows.splice(rowIndex, 1)
2366
+ }
2367
+
2368
+ // —— 孤立媒体/图形块:无文本配对、不属于按钮/面板/进度条的可见切片或几何图标。 ——
2369
+ // 缺失导出时用近似纯色占位;这属于设计稿标注缺陷的降级,而不是丢弃用户可见结构。
2370
+ const progressShapes = progressBars.flatMap((bar) => [])
2371
+ const formSliceEls = new Set((formRows || []).flatMap((row) => [row.rightAffordanceSlice, row.leftIconSlice, row.trailingIconSlice, row.valueLeadSlice]).filter(Boolean))
2372
+ const auxSliceEls = new Set([
2373
+ ...auxTexts.map((aux) => aux.decoSliceEl),
2374
+ ...auxTexts.map((aux) => aux.sliceEl),
2375
+ // 副金额列标签后缀图标(如币种选择器箭头):随标签行渲染,不得落入孤立媒体块
2376
+ ...subAmounts.map((sub) => sub.labelSuffixSlice),
2377
+ ].filter(Boolean))
2378
+ const primaryIconEl = primaryAmount?.iconSlice || null
2379
+ const cardRectForAffordance = rect(card)
2380
+ const affordanceCardRight = (cardRectForAffordance.x || 0) + (cardRectForAffordance.width || 0)
2381
+ const affordanceCandidates = [...slices, ...leafSymbols].filter((element) => {
2382
+ const er = rect(element)
2383
+ const width = er.width || 0
2384
+ const height = er.height || 0
2385
+ return width >= 16 && width <= 40 && height >= 16 && height <= 40
2386
+ && (er.x || 0) + width >= affordanceCardRight - Math.max(32, width * 2)
2387
+ })
2388
+ const affordanceGroups = new Map()
2389
+ for (const element of affordanceCandidates) {
2390
+ const er = rect(element)
2391
+ const key = [er.x || 0, er.width || 0, er.height || 0].join(':')
2392
+ if (!affordanceGroups.has(key)) affordanceGroups.set(key, [])
2393
+ affordanceGroups.get(key).push(element)
2394
+ }
2395
+ const repeatedRightAffordanceEls = new Set()
2396
+ for (const group of affordanceGroups.values()) {
2397
+ const sorted = group.sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
2398
+ const gaps = sorted.slice(1).map((element, index) =>
2399
+ (rect(element).y || 0) - (rect(sorted[index]).y || 0))
2400
+ const repeated = sorted.length >= 2 && gaps.length && gaps.every((gap) =>
2401
+ gap >= 40 && gap <= 140 && Math.abs(gap - gaps[0]) <= 4)
2402
+ if (repeated) sorted.forEach((element) => repeatedRightAffordanceEls.add(element))
2403
+ }
2404
+ const semanticShapes = [...buttonShapes, ...panelShapes, ...progressShapes]
2405
+ if (subAmountPanelShape) semanticShapes.push(subAmountPanelShape)
2406
+ const hasInsideContent = (shape) => [...texts, ...slices, ...symbols]
2407
+ .some((element) => overlapRatio(element, shape) > 0.5)
2408
+ const isolatedBlocks = [...slices, ...leafSymbols, ...shapes]
2409
+ .filter((el) => !(el.type === 'symbol' && shapeShellDup(el)))
2410
+ .filter((element) => {
2411
+ const er = rect(element)
2412
+ const width = er.width || 0
2413
+ const height = er.height || 0
2414
+ if (width < 20 || height < 20 || width > 220 || height > 220) return false
2415
+ if (Math.max(width, height) / Math.min(width, height) > 5) return false
2416
+ if (semanticShapes.some((shape) => overlapRatio(element, shape) > 0.5)) return false
2417
+ if (symbolMediaRows.some((row) => overlapRatio(element, row.symbolEl) > 0.5)) return false
2418
+ if (element.type !== 'shape' && [...optionShapeSet, ...panelShapes].some((s) => overlapRatio(element, s) > 0.6)) return false
2419
+ if (formSliceEls.has(element) || auxSliceEls.has(element) || element === primaryIconEl) return false
2420
+ // midCluster 行内 logo 由 midCluster 行渲染接管,不得再作孤立媒体块单列渲染(重影)
2421
+ if (midCluster && midCluster.logoSlice === element) return false
2422
+ // 行尾重复 affordance 不再预排除:列表存在时由列表回收(itemMediaEls 之后),
2423
+ // 无列表时挂到对齐语义行右侧渲染,避免「眼睛图标」等行尾控件静默丢失。
2424
+ if (element.type === 'shape' && hasInsideContent(element)) return false
2425
+ return true
2426
+ })
2427
+ .filter((element, index, list) => !list.some((other) =>
2428
+ other !== element && element.type === other.type && containsRect(other, element)))
2429
+ .map((element) => {
2430
+ const er = rect(element)
2431
+ return {
2432
+ element,
2433
+ x: er.x || 0,
2434
+ y: er.y || 0,
2435
+ width: er.width || 0,
2436
+ height: er.height || 0,
2437
+ }
2438
+ })
2439
+ .sort((a, b) => a.y - b.y || a.x - b.x)
2440
+ // 浮动装饰切片回收:isolatedBlocks 先于 orphanSlices 消费切片(卡头月亮等贴缘叠装饰),
2441
+ // 命中「与文本带竖直重叠 + 贴卡缘」者改走绝对定位叠层,不占流式行高。
2442
+ // Chart center media (radar/ring vector group): when vertexClusters exist,
2443
+ // pull it from isolatedBlocks into vertexClusters.chartMedia so assemble can
2444
+ // render both in the SAME AbsoluteLayout (shared coordinate system).
2445
+ if (vertexClusters && vertexClusters.length >= 3) {
2446
+ const vcXs = vertexClusters.map((vc) => vc.x || 0)
2447
+ const vcXRights = vertexClusters.map((vc, i) => (vc.x || 0) + (vc.width || 88))
2448
+ const vcYs = vertexClusters.map((vc) => vc.y || 0)
2449
+ const vcYBottoms = vertexClusters.map((vc, i) => (vc.y || 0) + (vc.height || 30))
2450
+ const vcCx = (Math.min(...vcXs) + Math.max(...vcXRights)) / 2
2451
+ const vcCy = (Math.min(...vcYs) + Math.max(...vcYBottoms)) / 2
2452
+ for (let ib = isolatedBlocks.length - 1; ib >= 0; ib--) {
2453
+ const blk = isolatedBlocks[ib]
2454
+ const bw = blk.width || 0, bh = blk.height || 0
2455
+ if (bw < 50 || bh < 50 || bw > 200 || bh > 200) continue
2456
+ if (Math.abs(bw - bh) > 30) continue
2457
+ const bcx = (blk.x || 0) + bw / 2
2458
+ const bcy = (blk.y || 0) + bh / 2
2459
+ if (Math.hypot(bcx - vcCx, bcy - vcCy) > 80) continue
2460
+ vertexClusters.chartMedia = blk
2461
+ isolatedBlocks.splice(ib, 1)
2462
+ _vertexConsumedEls.add(blk.element)
2463
+ break
2464
+ }
2465
+ }
2466
+
2467
+ const floatDecos = []
2468
+ {
2469
+ const cRd = rect(card)
2470
+ const isFloatDeco = (el) => {
2471
+ const sr = rect(el)
2472
+ const w = sr.width || 0, h = sr.height || 0
2473
+ if (!w || !h || w > (cRd.width || 375) * 0.5) return false
2474
+ const overlapsText = texts.some((t) => {
2475
+ const tr = rect(t)
2476
+ const ov = Math.min((sr.y || 0) + h, (tr.y || 0) + (tr.height || 0)) - Math.max(sr.y || 0, tr.y || 0)
2477
+ return ov >= h * 0.2 && h >= (tr.height || 0) * 2.2
2478
+ })
2479
+ const edgeAnchored = ((sr.x || 0) + w) >= ((cRd.x || 0) + (cRd.width || 0)) - 24
2480
+ return overlapsText && edgeAnchored
2481
+ }
2482
+ for (let k = isolatedBlocks.length - 1; k >= 0; k -= 1) {
2483
+ const el = isolatedBlocks[k].element
2484
+ if (el && (el.type === 'slice') && isFloatDeco(el)) {
2485
+ floatDecos.push(el)
2486
+ isolatedBlocks.splice(k, 1)
2487
+ }
2488
+ }
2489
+ }
2490
+
2491
+ // 面板/符号媒体行消费文本后,同步回收先生成的 auxTexts,避免同一标注被重复渲染
2492
+ // (曾仅回收 panelTextConsumed:符号媒体行吞掉的文本又被 auxTexts 渲染一次 → 重影)。
2493
+ for (let index = auxTexts.length - 1; index >= 0; index -= 1) {
2494
+ if (panelTextConsumed.has(auxTexts[index].sourceEl) || symbolMediaConsumed.has(auxTexts[index].sourceEl)) auxTexts.splice(index, 1)
2495
+ }
2496
+
2497
+ // —— 列表标题 ——
2498
+ const buttonY = buttonShapes.length ? Math.max(...buttonShapes.map((s) => (rect(s).y || 0) + (rect(s).height || 0))) : 0
2499
+ // 判定:标题尺寸 + **下方存在 ≥2 个未被表单消费的文本元素(真正列表行特征)**——
2500
+ // 无列表的标题(状态卡"交易正在处理中")或下方是表单行(反馈卡"还款成功"+字段行)不得误判为列表标题,
2501
+ // 否则丢失 textAlign、挤压 postAmountTexts 上界且渲染位置错(标题应在上方而非表单/按钮后)。
2502
+ const listTitleCands = texts.filter((t) => {
2503
+ const y = rect(t).y || 0
2504
+ const T = ts(t)
2505
+ // 列表标题须在「内容区」(主视觉底沿之后)——不限于按钮下方(本卡列表在按钮上方:
2506
+ // 列表项 464-666、按钮 723)。仅限制 y <= buttonY 会把按钮上方的列表识别全部排除。
2507
+ if (y <= mainBottom || (T.fontSize || 0) < 15 || (T.fontSize || 0) > 18
2508
+ || textOf(t).length > 8 || tabs.some((tab) => tab.value === textOf(t))) return false
2509
+ if (optionTextSet.has(t) || optionLabelSet.has(t)) return false
2510
+ if (buttons.some((button) => button.sourceEl === t)) return false
2511
+ if (formConsumed.has(t) || panelTextConsumed.has(t)) return false
2512
+ if (subAmountEls.has(t)) return false
2513
+ if (_panelGroupMemberEls.has(t)) return false
2514
+ // 复合行模式:行标题判定 = 候选下方 18-110px 有波段,且【另一文本】在相同相对距离(±4px)也有波段
2515
+ // (行结构周期性重复)。卡标题(如「我的账户」距首波段 88px,无周期同伴)不受影响。
2516
+ if (repeatRowMode) {
2517
+ const dyBands = _repeatBandTops.filter((bt) => bt - y >= 18 && bt - y <= 110).map((bt) => bt - y)
2518
+ const isRowTitle = dyBands.some((dy) => texts.some((o) => o !== t
2519
+ && _repeatBandTops.some((bt2) => Math.abs((bt2 - (rect(o).y || 0)) - dy) <= 4)))
2520
+ if (isRowTitle) return false
2521
+ }
2522
+ const belowUnconsumed = texts.filter((o) => o !== t && (rect(o).y || 0) > y + 20
2523
+ && !formConsumed.has(o) && !panelTextConsumed.has(o)
2524
+ && !subAmountEls.has(o))
2525
+ // 复发护栏(对称版):同一文案在内容区出现 ≥2 次(无论上下方),说明它是重复行内容
2526
+ // 而非列表标题(名册卡「张三」×3:旧单向护栏漏掉最后一个张三 → 误当标题吞掉整个列表)。
2527
+ if (_isRepeatedRowText(t)) return false
2528
+ // 金额值不是列表标题:含货币符号或「数字+千分位/小数」的文本是行右金额/主金额,
2529
+ // 误当标题会把真首行抬成 listTitle(首行 desc/amount 无人消费 → 绑定兜底双渲染重影)。
2530
+ if (/\p{Sc}/u.test(textOf(t))) return false
2531
+ if (/^[-+]?[\d.,\s%]+$/.test(textOf(t)) && /\.\d|,\d{3}/.test(textOf(t))) return false
2532
+ // 周期行护栏(结构版):候选下方存在 ≥2 个同列、同字号、同色且等距(±10)的同伴 →
2533
+ // 它是重复行的行标题而非区块标题(交易列表首行被抬成标题会让列表少一行)。
2534
+ // 双向护栏:上方+下方合计 ≥2 个同列、同字号、同色且等距(±12)同伴即判定为周期行
2535
+ // (中间行仅有 1 上 1 下同伴,旧单向护栏漏判把它当标题 → 列表被拦腰截断)。
2536
+ const titlePeersAll = texts.filter((o) => o !== t
2537
+ && Math.abs((rect(o).x || 0) - (rect(t).x || 0)) <= 2
2538
+ && (ts(o).fontSize || 0) === (T.fontSize || 0)
2539
+ && String(ts(o).color || '').toLowerCase() === String(T.color || '').toLowerCase()
2540
+ && Math.abs((rect(o).y || 0) - y) >= 20 && Math.abs((rect(o).y || 0) - y) <= 240)
2541
+ if (titlePeersAll.length >= 2) {
2542
+ // 步长投票:取最近同伴距离为候选步长,统计匹配该步长整数倍的同伴数;
2543
+ // ≥2 个匹配即判周期行(非周期同伴如汇总标签混入不影响判定)。
2544
+ const peerDys = titlePeersAll.map((o) => (rect(o).y || 0) - y).sort((a, b) => Math.abs(a) - Math.abs(b))
2545
+ const candSteps = [...new Set([Math.abs(peerDys[0]), Math.abs(peerDys[1] || peerDys[0])])].filter((s) => s >= 20)
2546
+ for (const candStep of candSteps) {
2547
+ const matched = peerDys.filter((g) => {
2548
+ const remainder = Math.abs(g) % candStep
2549
+ return Math.min(remainder, candStep - remainder) <= 12
2550
+ }).length
2551
+ if (matched >= 2) return false
2552
+ }
2553
+ }
2554
+ return belowUnconsumed.length >= 2
2555
+ })
2556
+ const listTitle = listTitleCands.length ? {
2557
+ text: textOf(listTitleCands[0]),
2558
+ color: ts(listTitleCands[0]).color,
2559
+ fontSize: ts(listTitleCands[0]).fontSize,
2560
+ fontWeight: ts(listTitleCands[0]).fontWeight,
2561
+ align: ts(listTitleCands[0]).textAlign, // 必须保留:居中标题(center)在渲染时 respect
2562
+ height: rect(listTitleCands[0]).height || (ts(listTitleCands[0]).fontSize || 16) * 1.4,
2563
+ y: rect(listTitleCands[0]).y || 0,
2564
+ x: rect(listTitleCands[0]).x || 0,
2565
+ width: rect(listTitleCands[0]).width || 0,
2566
+ } : null
2567
+
2568
+ // 列表标题优先于头部 auxText:同一元素被 listTitle 接管后从 auxTexts 移除,避免双渲染
2569
+ if (listTitle) {
2570
+ for (let ai = auxTexts.length - 1; ai >= 0; ai -= 1) {
2571
+ if (auxTexts[ai].sourceEl === listTitleCands[0]) auxTexts.splice(ai, 1)
2572
+ }
2573
+ }
2574
+
2575
+ // —— 环形倒计时(同心圆环 + 中心大数字 + 单位短文本;纯几何/格式信号,泛化)——
2576
+ // 识别信号(不依赖业务词):
2577
+ // ① ≥2 个近似正方形(w≈h)的 shape 两两同心(中心距 <10px)→ 圆环组(外环/内环/进度弧)
2578
+ // ② 环中心 ±(半径+8) 内的独立数字文本(fontSize≥20,1-3 位纯数字)→ 倒计时数值
2579
+ // ③ 数字附近(±40x/±30y)的单位短文本(S/s/秒/分)→ 单位
2580
+ // 命中后:数字/单位文本从常规渲染排除,改由 Canvas 自绘(环形进度 0%→100% 步进)。
2581
+ const countdown = (() => {
2582
+ const centerOf = (e) => { const r = rect(e); return { x: (r.x || 0) + (r.width || 0) / 2, y: (r.y || 0) + (r.height || 0) / 2 } }
2583
+ const roundShapes = shapes.filter((s) => {
2584
+ const r = rect(s)
2585
+ const w = r.width || 0, h = r.height || 0
2586
+ return w >= 24 && Math.abs(w - h) < 8
2587
+ })
2588
+ const clusters = []
2589
+ const usedS = new Set()
2590
+ for (let i = 0; i < roundShapes.length; i++) {
2591
+ if (usedS.has(i)) continue
2592
+ const c1 = centerOf(roundShapes[i])
2593
+ const grp = [i]
2594
+ usedS.add(i)
2595
+ for (let j = i + 1; j < roundShapes.length; j++) {
2596
+ if (usedS.has(j)) continue
2597
+ const c2 = centerOf(roundShapes[j])
2598
+ if (Math.abs(c1.x - c2.x) < 10 && Math.abs(c1.y - c2.y) < 10) { grp.push(j); usedS.add(j) }
2599
+ }
2600
+ if (grp.length >= 2) clusters.push(grp.map((g) => roundShapes[g]))
2601
+ }
2602
+ if (!clusters.length) return null
2603
+ const ring = clusters[0]
2604
+ const r0 = rect(ring[0])
2605
+ const cx = (r0.x || 0) + (r0.width || 0) / 2
2606
+ const cy = (r0.y || 0) + (r0.height || 0) / 2
2607
+ const size = Math.max(r0.width || 0, r0.height || 0)
2608
+ const half = size / 2 + 8
2609
+ const numEl = texts.find((t) => {
2610
+ const T = ts(t)
2611
+ const r = rect(t)
2612
+ const tx = textOf(t)
2613
+ if ((T.fontSize || 0) < 20 || !/^\d{1,3}$/.test(tx)) return false
2614
+ const tc = centerOf(t)
2615
+ return Math.abs(tc.x - cx) < half && Math.abs(tc.y - cy) < half
2616
+ })
2617
+ if (!numEl) return null
2618
+ const nr = rect(numEl)
2619
+ const unitEl = texts.find((t) => t !== numEl && /^[Ss秒分]$/.test(textOf(t))
2620
+ && Math.abs((rect(t).x || 0) - (nr.x || 0)) < 40 && Math.abs((rect(t).y || 0) - (nr.y || 0)) < 30)
2621
+ // 环形配色:ring 中 border.color 非浅灰描边者 → 进度色;其余 → 背景环色;缺省兜底
2622
+ const ringColors = ring.map((s) => { const b = (s.style || {}).border || {}; return b.isBorder ? String(b.color || '') : '' }).filter((c) => c && c.toUpperCase() !== '#F0F0F0')
2623
+ const progressColor = ringColors[0] || '#00A46D'
2624
+ const bgColor = (() => { const b = ((ring.find((s) => ((s.style || {}).border || {}).isBorder && String(((s.style || {}).border || {}).color || '').toUpperCase() === '#F0F0F0')) || {}).style || {}; const bc = (b.border || {}).color; return bc || '#E6F8F0' })()
2625
+ // 环形线宽:取描边 shape 的 border.width(设计稿环通常 1-2px);缺省按直径 1.5% 兜底
2626
+ const strokeWidth = (() => {
2627
+ const ws = ring.map((s) => { const b = (s.style || {}).border || {}; return b.isBorder ? (Number(b.width) || 0) : 0 }).filter((w) => w > 0)
2628
+ return (ws.length ? Math.min(...ws) : Math.max(2, Math.round(size * 0.025)))
2629
+ })()
2630
+ return {
2631
+ size: Math.round(size),
2632
+ y: r0.y || 0,
2633
+ strokeWidth,
2634
+ numberText: textOf(numEl),
2635
+ unitText: unitEl ? textOf(unitEl) : 'S',
2636
+ color: ts(numEl).color || '#06AB67',
2637
+ unitColor: unitEl ? (ts(unitEl).color || '#999999') : '#999999',
2638
+ progressColor,
2639
+ bgColor,
2640
+ consumed: [numEl, unitEl].filter(Boolean),
2641
+ }
2642
+ })()
2643
+ const countdownConsumed = new Set((countdown && countdown.consumed) || [])
2644
+
2645
+ // —— 卡片顶部位图装饰层:优先消费上游 segments,否则按素材类型与几何聚类识别。
2646
+ // 位图装饰不可用基础组件复现;必须引用已导出素材,缺失时先画板裁切、再占位图。
2647
+ // 全卡渐变形是卡片背景本身,不属于局部装饰层;映射为 CSS 渐变可避免
2648
+ // 固定高度内容容器裁切下方内容,也能保留标注给出的颜色过渡。
2649
+ const fullCardGradientShape = (card.elements || []).find((element) => {
2650
+ const shapeRect = rect(element)
2651
+ const cardRect = rect(card)
2652
+ const coverageX = Math.min((shapeRect.x || 0) + (shapeRect.width || 0),
2653
+ (cardRect.x || 0) + (cardRect.width || 0)) - Math.max(shapeRect.x || 0, cardRect.x || 0)
2654
+ const coverageY = Math.min((shapeRect.y || 0) + (shapeRect.height || 0),
2655
+ (cardRect.y || 0) + (cardRect.height || 0)) - Math.max(shapeRect.y || 0, cardRect.y || 0)
2656
+ return element.type === 'shape'
2657
+ && !!element.style?.fill?.gradient
2658
+ && coverageX >= (cardRect.width || 0) * 0.9
2659
+ && coverageY >= (cardRect.height || 0) * 0.9
2660
+ }) || null
2661
+ // —— 渐变背景壳(非全卡):局部渐变色块(如营销卡奶油色渐变区)。 ——
2662
+ // 只作背景包裹,不吞内容:壳内文本/按钮仍由各自语义路径生成(panelRows 路径会丢金额绑定、重复按钮)。
2663
+ const gradientPanels = (card.elements || []).filter((element) => {
2664
+ if (element.type !== 'shape' || !element.style?.fill?.gradient) return false
2665
+ if (_panelGroupShellSet.has(element)) return false // 面板组壳由面板列表接管
2666
+ if (element === fullCardGradientShape) return false
2667
+ const sr = rect(element)
2668
+ const cR = rect(card)
2669
+ const w = sr.width || 0, h = sr.height || 0
2670
+ if (w < (cR.width || 0) * 0.6 || h < 40 || h > (cR.height || 0) * 0.75) return false
2671
+ if ((sr.y || 0) < (cR.y || 0) - 2) return false // 顶部装饰归 topDecoration
2672
+ // 排除「自身就是按钮」的渐变块(壳可以含按钮,但不能是按钮)
2673
+ const area = w * h
2674
+ if (buttonShapes.some((button) => overlapRatio(element, button) > 0.5
2675
+ && area <= 4 * (rect(button).width || 0) * (rect(button).height || 0))) return false
2676
+ return true
2677
+ }).map((shape) => {
2678
+ const sr = rect(shape)
2679
+ return {
2680
+ shapeEl: shape,
2681
+ x: sr.x || 0, y: sr.y || 0, width: sr.width || 0, height: sr.height || 0,
2682
+ gradient: shape.style?.fill?.gradient || null,
2683
+ opacity: shape.style?.opacity ?? 1,
2684
+ radius: shape.style?.radius?.borderRadius || 12,
2685
+ }
2686
+ })
2687
+
2688
+ const topRhythm = (decorationRect, iconSlice) => {
2689
+ if (!iconSlice || !decorationRect) return {}
2690
+ const iconRect = rect(iconSlice)
2691
+ const iconBottom = (iconRect.y || 0) + (iconRect.height || 0)
2692
+ const decorationBottom = (decorationRect.y || 0) + (decorationRect.height || 0)
2693
+ const caption = (card.elements || []).filter((element) => {
2694
+ const textRect = rect(element)
2695
+ const center = (textRect.y || 0) + (textRect.height || 0) / 2
2696
+ return !!textOf(element) && center >= iconBottom && center <= decorationBottom + 40
2697
+ }).sort((left, right) => (rect(left).y || 0) - (rect(right).y || 0))[0]
2698
+ if (!caption) return { iconTop: (iconRect.y || 0) - (decorationRect.y || 0) }
2699
+ const captionRect = rect(caption)
2700
+ return {
2701
+ iconTop: (iconRect.y || 0) - (decorationRect.y || 0),
2702
+ textGap: (captionRect.y || 0) - iconBottom,
2703
+ }
2704
+ }
2705
+ // 顶部半透明大椭圆装饰(弥散底色):采样硬停色带是矩形平涂,还原不出椭圆曲边,
2706
+ // 像素热带集中在带底沿(实测 y≈106 单带 32%)→ 记 radial-gradient 参数逐颗还原。
2707
+ const topTintBlobs = (() => {
2708
+ const cR = rect(card)
2709
+ return shapes.filter((shape) => {
2710
+ const sr = rect(shape)
2711
+ const opacity = Number(shape.style?.opacity ?? 1)
2712
+ return opacity > 0 && opacity <= 0.5
2713
+ && (sr.width || 0) >= (cR.width || 0) * 0.4 && (sr.height || 0) >= (cR.height || 0) * 0.4
2714
+ && (sr.y || 0) <= (cR.y || 0) + 4
2715
+ && !radiusMax(shape.style)
2716
+ }).map((shape) => {
2717
+ const sr = rect(shape)
2718
+ return {
2719
+ cx: Math.round((sr.x || 0) + (sr.width || 0) / 2 - (cR.x || 0)),
2720
+ cy: Math.round((sr.y || 0) + (sr.height || 0) / 2 - (cR.y || 0)),
2721
+ rx: Math.round((sr.width || 0) / 2),
2722
+ ry: Math.round((sr.height || 0) / 2),
2723
+ // 不透明卡身壳罩在色块之上(磨砂玻璃壳):设计稿里色块被白壳罩淡一层,
2724
+ // 直接按标注 alpha 平涂会过浓(实测像素差 +5.6pp)→ 罩淡系数 0.5 校准。
2725
+ rgba: hexToRgba(shape.style?.fill?.color || '#FFFFFF', Number(shape.style?.opacity ?? 1) * 0.5),
2726
+ }
2727
+ })
2728
+ })()
2729
+ const topDecoration = (() => {
2730
+ const cr = rect(card)
2731
+ const cardTop = cr.y || 0
2732
+ const cardW = cr.width || 375
2733
+ // ① 上游 segments 优先:decor-bg 且 y(卡内)≤30
2734
+ const segs = (card.segments || [])
2735
+ const bgSeg = segs.find((s) => s.hint === 'decor-bg' && (s.y || 0) <= 30)
2736
+ if (bgSeg) {
2737
+ const absY = cardTop + (bgSeg.y || 0)
2738
+ const sliceEl = (card.elements || []).find((x) => x.type === 'slice'
2739
+ && Math.abs((rect(x).y || 0) - absY) < 25 && (rect(x).width || 0) >= cardW * 0.8) || null
2740
+ const cardH = cr.height || 400
2741
+ const iconSlice = (card.elements || []).find((x) => x.type === 'slice'
2742
+ && (() => { const r = rect(x); const w = r.width || 0, h = r.height || 0
2743
+ return w >= 40 && w <= 130 && h >= 40 && h <= 130 && Math.abs(w - h) <= 30
2744
+ && (r.y || 0) >= (cr.y || 0) && (r.y || 0) < (cr.y || 0) + cardH * 0.45 })()) || null
2745
+ // 无切片时按「首个内容文本在带内垂直居中」估算带高(居中假设:上下 padding 对称),
2746
+ // 避免 140 回退高把后续内容的顶距全吞掉(带边界会盖住列表标题/首行)。
2747
+ const firstContent = texts
2748
+ .filter((t) => (rect(t).y || 0) >= absY - 4)
2749
+ .sort((ta, tb) => (rect(ta).y || 0) - (rect(tb).y || 0))[0]
2750
+ const centeredHeight = firstContent
2751
+ ? Math.round(2 * (((rect(firstContent).y || 0) + (rect(firstContent).height
2752
+ || (ts(firstContent).fontSize || 14) * 1.4) / 2) - absY))
2753
+ : null
2754
+ return {
2755
+ sliceEl,
2756
+ y: absY,
2757
+ height: Math.round(rect(sliceEl).height || bgSeg.height
2758
+ || (centeredHeight != null ? Math.min(160, Math.max(32, centeredHeight)) : 140)),
2759
+ iconSlice,
2760
+ ...topRhythm(sliceEl
2761
+ ? rect(sliceEl)
2762
+ : { x: cr.x, y: absY, width: cardW, height: bgSeg.height || 140 }, iconSlice),
2763
+ fromSegments: true,
2764
+ }
2765
+ }
2766
+ // ② 自算兜底
2767
+ const sliceCands = (card.elements || []).filter((e) => {
2768
+ const r = rect(e)
2769
+ const w = r.width || 0, h = r.height || 0
2770
+ const nearTop = cardTop ? Math.abs((r.y || 0) - cardTop) <= 30 : (r.y || 0) <= 30
2771
+ return e.type === 'slice' && w >= cardW * 0.8 && h >= 60 && h <= 220 && nearTop
2772
+ })
2773
+ const groupCands = (card.elements || []).filter((e) => {
2774
+ const r = rect(e)
2775
+ const w = r.width || 0, h = r.height || 0
2776
+ const nearTop = cardTop ? Math.abs((r.y || 0) - cardTop) <= 30 : (r.y || 0) <= 30
2777
+ return e.type === 'group' && w >= cardW * 0.8 && h >= 60 && h <= 220 && nearTop
2778
+ })
2779
+ const shapeCands = (card.elements || []).filter((e) => {
2780
+ const r = rect(e)
2781
+ const w = r.width || 0
2782
+ const h = r.height || 0
2783
+ const nearTop = cardTop ? Math.abs((r.y || 0) - cardTop) <= 30 : (r.y || 0) <= 30
2784
+ const fill = e.style?.fill || {}
2785
+ const hasVectorFill = !!fill.color || !!fill.image
2786
+ const hasGradientOverlay = (card.elements || []).some((overlay) =>
2787
+ overlay !== e && overlay.type === 'shape' && !!overlay.style?.fill?.gradient
2788
+ && Math.abs((rect(overlay).x || 0) - (r.x || 0)) < 2
2789
+ && Math.abs((rect(overlay).y || 0) - (r.y || 0)) < 2
2790
+ && Math.abs((rect(overlay).width || 0) - w) < 2
2791
+ && (rect(overlay).height || 0) >= h - 2)
2792
+ const overlays = (card.elements || []).filter((candidate) =>
2793
+ candidate.type === 'shape' && !!candidate.style?.fill?.gradient
2794
+ && Math.abs((rect(candidate).x || 0) - (r.x || 0)) < 2
2795
+ && Math.abs((rect(candidate).y || 0) - (r.y || 0)) < 2
2796
+ && Math.abs((rect(candidate).width || 0) - w) < 2
2797
+ && (rect(candidate).height || 0) >= h - 2)
2798
+ const overlay = overlays.sort((left, right) =>
2799
+ (rect(right).height || 0) - (rect(left).height || 0))[0]
2800
+ const gradientStops = [...(overlay?.style?.fill?.gradient || '').matchAll(/rgba\([^)]+\)\s*\d+%\s*\)?/gi)]
2801
+ const gradientTerminus = gradientStops.at(-1)?.[0]
2802
+ const fallbackFill = gradientTerminus?.match(/rgba\(([^)]+)\)/i)?.[1]
2803
+ ?.split(',').slice(0, 3).map(Number).map((value) => value.toString(16).padStart(2, '0')).join('')
2804
+ const decorationFill = fallbackFill ? `#${fallbackFill.toUpperCase()}` : fill.color
2805
+ e.backgroundFallback = { type: 'solid', color: decorationFill }
2806
+ return e.type === 'shape' && (hasVectorFill || fallbackFill) && w >= cardW * 0.9
2807
+ && Math.abs(((r.x || 0) + w / 2) - ((cr.x || 0) + cardW / 2)) <= 4
2808
+ && h >= 60 && h <= 220 && nearTop && hasGradientOverlay
2809
+ })
2810
+ const cands = [...sliceCands, ...shapeCands, ...groupCands]
2811
+ .sort((left, right) => {
2812
+ const priority = { slice: 0, shape: 1, group: 2 }
2813
+ return (priority[left.type] ?? 9) - (priority[right.type] ?? 9)
2814
+ })
2815
+ if (!cands.length) return null
2816
+ // 优先 slice(有导出文件);group 兜底(同区域匹配 slice 元素)
2817
+ const first = cands[0]
2818
+ const fr = rect(first)
2819
+ const sliceEl = first.type === 'slice' ? first
2820
+ : (card.elements || []).find((x) => x.type === 'slice'
2821
+ && Math.abs((rect(x).y || 0) - (fr.y || 0)) < 20 && Math.abs((rect(x).width || 0) - (fr.width || 0)) < 20) || null
2822
+ const mediaElement = sliceEl || (first.type === 'shape' ? first : null)
2823
+ // 顶部独立图标:卡片顶部 45% 区域内的近似方形独立 slice。
2824
+ const cardH = cr.height || 400
2825
+ const iconSlice = (card.elements || []).find((x) => x.type === 'slice'
2826
+ && (() => { const r = rect(x); const w = r.width || 0, h = r.height || 0
2827
+ return w >= 40 && w <= 130 && h >= 40 && h <= 130 && Math.abs(w - h) <= 30
2828
+ && (r.y || 0) >= (cr.y || 0) && (r.y || 0) < (cr.y || 0) + cardH * 0.45 })()) || null
2829
+ if (!mediaElement) return null
2830
+ return {
2831
+ sliceEl: mediaElement,
2832
+ y: fr.y || 0,
2833
+ height: Math.round(fr.height || 140),
2834
+ iconSlice,
2835
+ ...topRhythm(fr, iconSlice),
2836
+ } // y=装饰层绝对 y(顶部区底沿= y+height)
2837
+ })()
2838
+
2839
+ const postAmountTexts = texts.filter((t) => {
2840
+ const y = rect(t).y || 0
2841
+ const _effectiveBottom = mainVisualEl ? mainBottom : (_formRowTopY != null ? _formRowTopY : mainBottom)
2842
+ return y >= _effectiveBottom && y < (listTitle ? rect(listTitleCands[0] || {}).y || 9999 : 9999)
2843
+ && !tabs.some((tab) => tab.value === textOf(t))
2844
+ && !auxTexts.some((a) => a.text === textOf(t))
2845
+ && !buttons.some((b) => b.text === textOf(t))
2846
+ && !optionTextSet.has(t) && !optionLabelSet.has(t)
2847
+ && !subAmountEls.has(t)
2848
+ && !formConsumed.has(t)
2849
+ && !countdownConsumed.has(t)
2850
+ && !(textOf(t) === (listTitle?.text || ''))
2851
+ && !_panelGroupMemberEls.has(t)
2852
+ && !_badgeRowTextEls.has(t)
2853
+ // 复合行模式:波段区文本由列表项模板整体接管
2854
+ && !(repeatRowMode && _repeatBandTops.some((bt) => y >= bt - 4 && y <= bt + 80))
2855
+ }).map((t) => ({
2856
+ text: textOf(t),
2857
+ color: ts(t).color,
2858
+ fontSize: ts(t).fontSize,
2859
+ fontWeight: ts(t).fontWeight,
2860
+ align: ts(t).textAlign,
2861
+ sourceEl: t,
2862
+ x: rect(t).x || 0,
2863
+ width: rect(t).width || 0,
2864
+ y: rect(t).y || 0,
2865
+ // 多行文案必须携带标注高度:语义边界 bottom 依赖它计算,
2866
+ // 丢失后边界塌缩成单行高(fs*1.5),后续元素顶距被放大(分隔线/按钮整段下漂)。
2867
+ height: rect(t).height || 0,
2868
+ backgroundShell: backgroundShellFor(t),
2869
+ outlineShell: outlineShellFor(t),
2870
+ highlightShell: highlightShellFor(t),
2871
+ }))
2872
+
2873
+ // —— 列表项:检测重复 row(首列左对齐文本)——
2874
+ // 无列表标题时不检测列表项:buttonY=0 时 rowsBelow 会吞掉所有文本,
2875
+ // 导致 formRow/auxText 文本被误收为伪列表项
2876
+ const listTitleY = listTitle ? rect(listTitleCands[0]).y || 0 : Infinity
2877
+ // 无列表标题时也能成列表:名册/账单类卡片的标题是卡头,列表只有行重复结构。
2878
+ // 行候选 = 主视觉之下、未被任何语义路径消费的文本(表单/面板/辅助文案/选项/副金额/按钮)。
2879
+ const auxConsumedEls = new Set(auxTexts.map((aux) => aux.sourceEl).filter(Boolean))
2880
+ const rowsBelow = (listTitle
2881
+ ? texts.filter((t) => (rect(t).y || 0) > listTitleY)
2882
+ : texts.filter((t) => (rect(t).y || 0) > mainBottom
2883
+ && !panelTextConsumed.has(t)
2884
+ && !auxConsumedEls.has(t) && !subAmountEls.has(t)
2885
+ && !buttons.some((button) => button.sourceEl === t)))
2886
+ .filter((t) => !optionTextSet.has(t) && !optionLabelSet.has(t))
2887
+ .filter((t) => !_panelGroupMemberEls.has(t))
2888
+ const aboveMainPeriodic = texts.filter((t) => {
2889
+ const y = rect(t).y || 0
2890
+ const T0 = ts(t)
2891
+ if (y >= mainBottom) return false
2892
+ if ((T0.fontSize || 0) < 13 || T0.textAlign !== 'left' || (rect(t).x || 0) >= 100) return false
2893
+ if (formConsumed.has(t) || panelTextConsumed.has(t) || enumStatConsumed.has(t)) return false
2894
+ const peers = texts.filter((o) => o !== t
2895
+ && Math.abs((rect(o).x || 0) - (rect(t).x || 0)) <= 4
2896
+ && (ts(o).fontSize || 0) === (T0.fontSize || 0)
2897
+ && (rect(o).y || 0) > y && (rect(o).y || 0) - y <= 140)
2898
+ if (peers.length < 1) return false
2899
+ const gaps = peers.map((o) => (rect(o).y || 0) - y)
2900
+ return gaps.every((g) => g >= 40 && g <= 140 && Math.abs(g - gaps[0]) <= 10)
2901
+ })
2902
+ const firstColTexts = [...new Set([...rowsBelow, ...aboveMainPeriodic])]
2903
+ .sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0)).filter((t) => {
2904
+ const T = ts(t)
2905
+ return (T.fontSize || 0) >= 13 && T.textAlign === 'left' && (rect(t).x || 0) < 100
2906
+ // 排除 section header:文本节点以全角冒号结尾,是结构性结束信号。
2907
+ && !/:$/.test(textOf(t))
2908
+ // P1:只排除枚举统计行(enumStatConsumed),不再排除 formConsumed——
2909
+ // P0 已让「标题+描述」文本对不再被 formRow 吞掉(基线同行判定),列表项得以回流识别;
2910
+ // 若仍排除 formConsumed,列表识别会被表单行清空(items=0 → 列表项平铺成 formRow,布局倒退)。
2911
+ && !enumStatConsumed.has(t)
2912
+ && !panelTextConsumed.has(t)
2913
+ && !optionTextSet.has(t) && !optionLabelSet.has(t)
2914
+ // 复合行模式:重复波段内(标签行+其下值行)的文本是列表行成员,不得成为行标题候选
2915
+ && !(repeatRowMode && _repeatBandTops.some((bt) => {
2916
+ const ty = rect(t).y || 0
2917
+ return ty >= bt - 4 && ty <= bt + 80
2918
+ }))
2919
+ // 面板组成员:由面板列表模板整体接管
2920
+ && !_panelGroupMemberEls.has(t)
2921
+ })
2922
+ // 同 y(±10)的 first-col 文本聚合为一项;按 y 排序
2923
+ // P0/P1 升级:列表项必须存在「标题下方 18-36px 的描述文本」(desc)——否则摘要区
2924
+ // 「标题+说明」单组文本(如 重点摘要/按强重要…)会被误收为列表项(无下邻描述 → 跳过)。
2925
+ // 描述文本标记 itemDescConsumed,防止被 viewMore 误捕 / postAmountTexts 重复渲染。
2926
+ const items = []
2927
+ const itemDescConsumed = new Set()
2928
+
2929
+ const itemTextConsumed = new Set()
2930
+ const compositeRowRegions = []
2931
+ let lastY = -100
2932
+ for (const t of firstColTexts) {
2933
+ if (itemDescConsumed.has(t)) continue
2934
+ const y = rect(t).y || 0
2935
+ if (compositeRowRegions.some(([rt, rb]) => y >= rt && y <= rb)) continue
2936
+ if (y - lastY > 30) {
2937
+ // 新起一行:取该行的所有文本(标题/金额/时间)
2938
+ const rowTexts = rowsBelow.filter((r) => Math.abs((rect(r).y || 0) - y) < 30 || Math.abs((rect(r).y || 0) - (y + 24)) < 30)
2939
+ const title = textOf(t)
2940
+ // 描述 = 标题正下方 18-36px 的同列文本(上下两行、左对齐:列表项特征)
2941
+ // 描述行 = 标题正下方 18-36px 且与标题同列(左沿最近)的文本;
2942
+ // 右侧时间行与描述同 y 时按元素序会抢先命中,把时间当描述、真描述被甩到列表外。
2943
+ let descEl = rowTexts
2944
+ .filter((r) => {
2945
+ const ry = rect(r).y || 0
2946
+ // 与标题同列、同字号、同色的「描述」其实是下一条等距单行(选项/清单行):
2947
+ // 真描述通常更小/更灰或缩进。误认会把下一行吞成描述、再下一行被甩出列表丢失。
2948
+ if (Math.abs((rect(r).x || 0) - (rect(t).x || 0)) <= 2
2949
+ && (ts(r).fontSize || 0) === (ts(t).fontSize || 0)
2950
+ && String(ts(r).color || '').toLowerCase() === String(ts(t).color || '').toLowerCase()) return false
2951
+ return ry - y >= 18 && ry - y <= 36
2952
+ })
2953
+ .sort((a, b) => Math.abs((rect(a).x || 0) - (rect(t).x || 0))
2954
+ - Math.abs((rect(b).x || 0) - (rect(t).x || 0)))[0] || null
2955
+ // —— 复合行(重复波段标签列组):标题行 + ≥2 组「标签+值」纵列 ——
2956
+ // 触发:repeatRowMode 且(无 desc 或 desc 本身是波段标签)→ 行内容不是「标题+描述」。
2957
+ // 几何驱动:标签在标题下方 18-110px、值在标签正下方 10-50px、x 对齐(±24)。
2958
+ let compositePairs = null
2959
+ let titleRightEl = null
2960
+ if (repeatRowMode && (!descEl || _repeatLabelTexts.has(textOf(descEl)) || _allBandLabelEls.has(descEl))) {
2961
+ const tRect = rect(t)
2962
+ const bandLabels = texts.filter((lab) => {
2963
+ if (!_repeatLabelTexts.has(textOf(lab)) && !_allBandLabelEls.has(lab)) return false
2964
+ const dy = (rect(lab).y || 0) - y
2965
+ return dy >= 18 && dy <= 110
2966
+ }).sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
2967
+ const pairs = []
2968
+ for (const lab of bandLabels) {
2969
+ const lr = rect(lab)
2970
+ if (pairs.some((pp) => Math.abs((rect(pp.labelEl).x || 0) - (lr.x || 0)) <= 24)) continue
2971
+ const valueCand = texts.filter((v) => {
2972
+ if (v === lab || !isAmountish(textOf(v))) return false
2973
+ const vr = rect(v)
2974
+ const vdy = (vr.y || 0) - (lr.y || 0)
2975
+ return vdy >= 10 && vdy <= 50 && Math.abs((vr.x || 0) - (lr.x || 0)) <= 24
2976
+ }).sort((a, b) => (rect(b).width || 0) - (rect(a).width || 0))[0]
2977
+ if (valueCand) pairs.push({ labelEl: lab, valueEl: valueCand })
2978
+ }
2979
+ if (pairs.length >= 2) {
2980
+ compositePairs = pairs.slice(0, 4)
2981
+ // 标签行内联 tag 壳(币种选择 pill 类):贴标签右沿的小填充/描边壳 + 壳内短文本/小切片。
2982
+ // 逐行数据驱动($amtTagN/$amtTagIconN),无 tag 的行 v-show 隐藏,避免静态模板每行滥发。
2983
+ for (const pp of compositePairs) {
2984
+ const lr2 = rect(pp.labelEl)
2985
+ const labelCY = (lr2.y || 0) + (lr2.height || 0) / 2
2986
+ const shell = (shapes || []).find((sh) => {
2987
+ const r = rect(sh)
2988
+ const w = r.width || 0
2989
+ const h = r.height || 0
2990
+ if (w < 28 || w > 120 || h < 12 || h > 28) return false
2991
+ if (!sh.style?.fill?.color && !sh.style?.border?.isBorder) return false
2992
+ if (Math.abs(((r.y || 0) + h / 2) - labelCY) > 8) return false
2993
+ const gx = (r.x || 0) - ((lr2.x || 0) + (lr2.width || 0))
2994
+ return gx >= -2 && gx <= 16
2995
+ }) || null
2996
+ pp.tagShapeEl = shell
2997
+ if (!shell) continue
2998
+ const sr = rect(shell)
2999
+ const inside = (e) => {
3000
+ const er = rect(e)
3001
+ return (er.x || 0) >= (sr.x || 0) - 2 && (er.y || 0) >= (sr.y || 0) - 2
3002
+ && (er.x || 0) + (er.width || 0) <= (sr.x || 0) + (sr.width || 0) + 2
3003
+ && (er.y || 0) + (er.height || 0) <= (sr.y || 0) + (sr.height || 0) + 2
3004
+ }
3005
+ const tagTextEl = texts.find((tt2) => inside(tt2)) || null
3006
+ const tagIconEl = (slices || []).find((sl) => inside(sl)) || null
3007
+ const tagExp = tagIconEl
3008
+ ? (resolveSliceExport(spec.slices || [], tagIconEl.name) || tagIconEl.annotationDefect?.cropSlice)
3009
+ : null
3010
+ pp.tagText = tagTextEl ? textOf(tagTextEl) : ''
3011
+ pp.tagTextColor = tagTextEl ? ts(tagTextEl).color : undefined
3012
+ pp.tagFontSize = tagTextEl ? ts(tagTextEl).fontSize : undefined
3013
+ pp.tagIcon = tagExp ? sliceRefPath(tagExp) : ''
3014
+ pp.tagFill = shell.style?.fill?.color || null
3015
+ pp.tagOpacity = shell.style?.opacity ?? 1
3016
+ pp.tagBorder = shell.style?.border?.isBorder ? (shell.style?.border?.color || null) : null
3017
+ pp.tagRadius = shell.style?.radius?.borderRadius || 0
3018
+ pp.tagW = Math.round(sr.width || 0)
3019
+ pp.tagH = Math.round(sr.height || 0)
3020
+ pp.tagGap = Math.max(2, Math.round((sr.x || 0) - ((lr2.x || 0) + (lr2.width || 0))))
3021
+ pp.tagPadLeft = Math.max(0, Math.round(((tagTextEl ? rect(tagTextEl).x : sr.x) || 0) - (sr.x || 0)))
3022
+ pp.tagPadRight = Math.max(0, Math.round(((sr.x || 0) + (sr.width || 0))
3023
+ - ((tagIconEl ? (rect(tagIconEl).x || 0) + (rect(tagIconEl).width || 0)
3024
+ : (tagTextEl ? (rect(tagTextEl).x || 0) + (rect(tagTextEl).width || 0)
3025
+ : (sr.x || 0) + (sr.width || 0))))))
3026
+ pp.tagIconGap = tagIconEl && tagTextEl
3027
+ ? Math.max(0, Math.round((rect(tagIconEl).x || 0) - ((rect(tagTextEl).x || 0) + (rect(tagTextEl).width || 0))))
3028
+ : 2
3029
+ pp.tagIconW = tagIconEl ? Math.round(rect(tagIconEl).width || 16) : 0
3030
+ pp.tagIconH = tagIconEl ? Math.round(rect(tagIconEl).height || 16) : 0
3031
+ }
3032
+ const cardRectC = rect(card)
3033
+ titleRightEl = texts.find((tt) => {
3034
+ if (tt === t || compositePairs.some((pp) => pp.labelEl === tt || pp.valueEl === tt)) return false
3035
+ const tr = rect(tt)
3036
+ return Math.abs((tr.y || 0) - y) <= 6
3037
+ && (tr.x || 0) >= (tRect.x || 0) + (tRect.width || 0) + 8
3038
+ && (tr.x || 0) + (tr.width || 0) <= (cardRectC.x || 0) + (cardRectC.width || 375) - 8
3039
+ }) || null
3040
+ }
3041
+ }
3042
+ if (!descEl && !compositePairs) {
3043
+ // 周期行兜底:无标准 desc(18-36px)但标题存在 ≥2 个同列等距周期同伴时
3044
+ // (支出占比分类行:类别名+金额+百分比+进度条,百分比仅 0-8px 下方而非标准 18-36px),
3045
+ // 取近邻小字号文本(0-16px 下方)作为 desc,使行仍被识别为列表项。
3046
+ // 双向检测:上下方合计 ≥2 个同列等距同伴即判周期行(中间行 1 上 1 下)。
3047
+ const periodicPeersAll = firstColTexts.filter((o) => o !== t
3048
+ && Math.abs((rect(o).x || 0) - (rect(t).x || 0)) <= 4
3049
+ && (ts(o).fontSize || 0) === (ts(t).fontSize || 0)
3050
+ && String(ts(o).color || '').toLowerCase() === String(ts(t).color || '').toLowerCase()
3051
+ && Math.abs((rect(o).y || 0) - y) >= 20 && Math.abs((rect(o).y || 0) - y) <= 200)
3052
+ if (periodicPeersAll.length >= 2) {
3053
+ const peerDys = periodicPeersAll.map((o) => (rect(o).y || 0) - y).sort((a, b) => Math.abs(a) - Math.abs(b))
3054
+ const candSteps = [...new Set([Math.abs(peerDys[0]), Math.abs(peerDys[1] || peerDys[0])])].filter((s) => s >= 20)
3055
+ const hasPeriodic = candSteps.some((candStep) => {
3056
+ const matched = peerDys.filter((g) => {
3057
+ const remainder = Math.abs(g) % candStep
3058
+ return Math.min(remainder, candStep - remainder) <= 12
3059
+ }).length
3060
+ return matched >= 2
3061
+ })
3062
+ if (hasPeriodic) {
3063
+ descEl = rowTexts.find((r) => {
3064
+ const ry = rect(r).y || 0
3065
+ return r !== t && ry >= y && ry - y <= 16
3066
+ && (ts(r).fontSize || 0) < (ts(t).fontSize || 0)
3067
+ && Math.abs((rect(r).x || 0) - (rect(t).x || 0)) <= (rect(t).width || 0) + 40
3068
+ }) || null
3069
+ }
3070
+ }
3071
+ if (!descEl) { lastY = y; continue }
3072
+ }
3073
+ // 时间行:日期分隔符含点/斜杠形态(2025.10.10 14:28:16)。此前只匹配横杠,
3074
+ // 点分时间行永远不被列表行消费,被 auxTexts 当孤立文本甩到卡底重渲染。
3075
+ const time = rowTexts.find((r) => r !== descEl
3076
+ && /\d{4}[-/.]\d{1,2}[-/.]\d{1,2}/.test(textOf(r))) || null
3077
+ // 行右金额候选必须含数字或货币符号:纯币种名(如 pill 标签「美元」)含「元」字但不是金额,误捕会把标签塞进数值表达式(渲染占位文案)并用其右沿算行右内边距挤窄标题行。
3078
+ const isRowAmountText = (v) => /\p{Sc}/u.test(v) || (/\d/.test(v) && /[.元]/.test(v))
3079
+ // 行第二行右侧文本(交易行「余额:¥20,077.00」):与 desc 行同基线、右半锚定,
3080
+ // 且非 desc/time → 行右列第二行,数据驱动 $descRight(先于 amount 计算,
3081
+ // 否则余额行被 amount 通道抢先造成右列双份)。
3082
+ const descRightEl = rowTexts.find((r) => r !== descEl && r !== time && r !== t
3083
+ && Math.abs((rect(r).y || 0) - (descEl ? (rect(descEl).y || 0) : y + 22)) <= 6
3084
+ && (rect(r).x || 0) >= (rect(card).x || 0) + (rect(card).width || 375) / 2) || null
3085
+ const amount = rowTexts.find((r) => r !== time && r !== descEl && r !== descRightEl && isRowAmountText(textOf(r)))
3086
+ // 标题同行后缀(如「张三」右侧灰色小字「(小张)」):同基线、紧随标题右沿
3087
+ const titleRightEdge = (rect(t).x || 0) + (rect(t).width || 0)
3088
+ // 行内联 affordance(紧跟账号的行尾眼睛图标):不锚行右沿而是紧贴标题右沿,
3089
+ // 逐行挂接后由模板按几何在标题行内联渲染(区别于右对齐行尾件)。
3090
+ const inlineAffordEl = [...(slices || []), ...symbols].find((s2) => {
3091
+ const r2 = rect(s2)
3092
+ const w2 = r2.width || 0
3093
+ const h2 = r2.height || 0
3094
+ if (w2 < 14 || w2 > 32 || h2 < 14 || h2 > 32) return false
3095
+ if (Math.abs((r2.y || 0) - y) > 8) return false
3096
+ const gap2 = (r2.x || 0) - titleRightEdge
3097
+ return gap2 >= 2 && gap2 <= 48
3098
+ }) || null
3099
+ if (inlineAffordEl) {
3100
+ const bi2 = isolatedBlocks.findIndex((b) => b.element === inlineAffordEl)
3101
+ if (bi2 >= 0) isolatedBlocks.splice(bi2, 1)
3102
+ }
3103
+ const suffixEl = rowTexts.find((r) => r !== t && r !== descEl
3104
+ && Math.abs((rect(r).y || 0) - y) <= 6
3105
+ && (rect(r).x || 0) >= titleRightEdge - 2 && (rect(r).x || 0) <= titleRightEdge + 16) || null
3106
+ // 标题行同行右侧文本(交易行金额,基线可能比标题高几 px):非复合行下
3107
+ // rowTexts 带不含它(y 略小于标题),不补 titleRight 通道行右金额就丢失。
3108
+ if (!compositePairs) {
3109
+ titleRightEl = texts.find((tt) => {
3110
+ if (tt === t || tt === descEl || tt === suffixEl || tt === descRightEl) return false
3111
+ const tr = rect(tt)
3112
+ return Math.abs((tr.y || 0) - y) <= 8
3113
+ && (tr.x || 0) >= titleRightEdge + 8
3114
+ && (tr.x || 0) >= (rect(card).x || 0) + (rect(card).width || 375) * 0.5
3115
+ }) || null
3116
+ }
3117
+ // 行图标:标题左侧、行带垂直范围内的切片(逐行不同 → 数据驱动)
3118
+ const rowBandTop = y - 12
3119
+ const rowBandBottom = (rect(descEl).y || 0) + (rect(descEl).height || 0) + 12
3120
+ const iconEl = (slices || []).find((s) => {
3121
+ const r = rect(s)
3122
+ const w = r.width || 0; const h = r.height || 0
3123
+ return w >= 16 && w <= 48 && h >= 16 && h <= 48
3124
+ && (r.x || 0) < (rect(t).x || 0) - 2
3125
+ && (r.y || 0) >= rowBandTop && (r.y || 0) + h <= rowBandBottom
3126
+ }) || null
3127
+ // 行内操作 pill:右侧锚定的描边/填充小壳(宽 40-120 高 22-48)+ 单文本(如「删除」)
3128
+ const cardR0 = rect(card)
3129
+ const cardRight0 = (cardR0.x || 0) + (cardR0.width || 0)
3130
+ // 行右大图标(行壳右沿锚定的 32-72px 切片/符号,如保险产品 48x48):数据驱动 $rightIcon
3131
+ const rightIconEl = ((content || []).filter((e) => e.type === 'slice' || e.type === 'symbol')).find((s) => {
3132
+ if (s === iconEl) return false
3133
+ const r = rect(s)
3134
+ const w = r.width || 0; const h = r.height || 0
3135
+ if (w < 32 || w > 72 || h < 32 || h > 72) return false
3136
+ if ((r.x || 0) <= (rect(t).x || 0)) return false
3137
+ return (r.x || 0) + w >= cardRight0 - 48
3138
+ && (r.y || 0) >= rowBandTop && (r.y || 0) + h <= rowBandBottom + 8
3139
+ }) || null
3140
+ const actionShapeEl = (shapes || []).find((s) => {
3141
+ if (optionShapeSet.has(s)) return false
3142
+ const r = rect(s)
3143
+ const w = r.width || 0; const h = r.height || 0
3144
+ if (w < 40 || w > 120 || h < 22 || h > 48) return false
3145
+ if (!s.style?.fill?.color && !s.style?.border?.isBorder) return false
3146
+ const vCenter = (r.y || 0) + h / 2
3147
+ return vCenter >= rowBandTop && vCenter <= rowBandBottom
3148
+ && (r.x || 0) + w >= cardRight0 - Math.max(72, (cardR0.width || 375) * 0.2)
3149
+ }) || null
3150
+ const actionTextEl = actionShapeEl ? texts.find((tt) => {
3151
+ const tr = rect(tt)
3152
+ const ar = rect(actionShapeEl)
3153
+ return (tr.x || 0) >= (ar.x || 0) - 2 && (tr.y || 0) >= (ar.y || 0) - 2
3154
+ && (tr.x || 0) + (tr.width || 0) <= (ar.x || 0) + (ar.width || 0) + 2
3155
+ && (tr.y || 0) + (tr.height || 0) <= (ar.y || 0) + (ar.height || 0) + 2
3156
+ }) || null : null
3157
+ // 行内操作 pill 与按钮检测是同一份视觉证据的两条路径:item 模板已渲染该 pill
3158
+ // (actionShapeEl),buttons 中同源按钮必须同步消费,否则通用按钮区把每行 pill
3159
+ // 再渲染一遍(删除×3 → 渲染×6 重影 + 底部多出按钮行撑高卡片)。
3160
+ if (actionTextEl) {
3161
+ const consumedBtnIdx = buttons.findIndex((b) => b.sourceEl === actionTextEl)
3162
+ if (consumedBtnIdx >= 0) buttons.splice(consumedBtnIdx, 1)
3163
+ }
3164
+ items.push({
3165
+ title,
3166
+ // 行内容左右沿(spec 实测):行横向内边距按此驱动,避免通用 12px 与设计 16px 错位
3167
+ rowLeftEdge: Math.round(Math.min(rect(t).x || 0, iconEl ? (rect(iconEl).x || 0) : Infinity)),
3168
+ // 行内容真实右沿:行壳内边距推导用(全部行文本 + 图标 + 操作件右沿最大值)
3169
+ rowContentRight: Math.round(Math.max(0, ...[...rowTexts, iconEl, rightIconEl, actionShapeEl]
3170
+ .filter(Boolean).map((e) => (rect(e).x || 0) + (rect(e).width || 0)))) || null,
3171
+ // 只统计右栏内容(金额/时间/行内操作)的右沿;标题行后缀/右侧同行文本属于
3172
+ // 左栏标题流,混入会把行右内边距算成巨大值挤窄左栏(名册卡曾因此换行回归)。
3173
+ rowRightEdge: Math.round(Math.max(0, ...[amount, time, actionShapeEl]
3174
+ .filter(Boolean).map((e) => (rect(e).x || 0) + (rect(e).width || 0)))) || null,
3175
+ titleSuffix: suffixEl ? textOf(suffixEl) : '',
3176
+ titleSuffixColor: suffixEl ? ts(suffixEl).color : undefined,
3177
+ titleSuffixFontSize: suffixEl ? ts(suffixEl).fontSize : undefined,
3178
+ icon: iconEl
3179
+ ? ((resolveSliceExport(spec.slices || [], iconEl.name) || iconEl.annotationDefect?.cropSlice)
3180
+ ? sliceRefPath(resolveSliceExport(spec.slices || [], iconEl.name) || iconEl.annotationDefect?.cropSlice)
3181
+ : `http://iph.href.lu/${Math.round(rect(iconEl).width || 32)}x${Math.round(rect(iconEl).height || 32)}?text=${encodeURIComponent('缺素材')}`)
3182
+ : null,
3183
+ iconEl,
3184
+ iconW: iconEl ? Math.round(rect(iconEl).width || 0) : 0,
3185
+ iconH: iconEl ? Math.round(rect(iconEl).height || 0) : 0,
3186
+ rightIcon: rightIconEl
3187
+ ? ((resolveSliceExport(spec.slices || [], rightIconEl.name) || rightIconEl.annotationDefect?.cropSlice)
3188
+ ? sliceRefPath(resolveSliceExport(spec.slices || [], rightIconEl.name) || rightIconEl.annotationDefect?.cropSlice)
3189
+ : `http://iph.href.lu/${Math.round(rect(rightIconEl).width || 48)}x${Math.round(rect(rightIconEl).height || 48)}?text=${encodeURIComponent('缺素材')}`)
3190
+ : null,
3191
+ rightIconEl,
3192
+ rightIconW: rightIconEl ? Math.round(rect(rightIconEl).width || 0) : 0,
3193
+ rightIconH: rightIconEl ? Math.round(rect(rightIconEl).height || 0) : 0,
3194
+ action: actionTextEl ? textOf(actionTextEl) : '',
3195
+ affordInline: !!inlineAffordEl,
3196
+ affordInlineGap: inlineAffordEl
3197
+ ? Math.max(4, Math.round((rect(inlineAffordEl).x || 0) - titleRightEdge))
3198
+ : undefined,
3199
+ affordanceEl: inlineAffordEl || null,
3200
+ affordanceUrl: inlineAffordEl
3201
+ ? ((resolveSliceExport(spec.slices || [], inlineAffordEl.name) || inlineAffordEl.annotationDefect?.cropSlice)
3202
+ ? sliceRefPath(resolveSliceExport(spec.slices || [], inlineAffordEl.name) || inlineAffordEl.annotationDefect?.cropSlice)
3203
+ : `http://iph.href.lu/${Math.round(rect(inlineAffordEl).width || 20)}x${Math.round(rect(inlineAffordEl).height || 20)}?text=${encodeURIComponent('缺素材')}`)
3204
+ : '',
3205
+ actionShapeEl,
3206
+ actionColor: actionTextEl ? ts(actionTextEl).color : undefined,
3207
+ actionFontSize: actionTextEl ? ts(actionTextEl).fontSize : undefined,
3208
+ actionBorderColor: actionShapeEl ? (actionShapeEl.style?.border?.color || null) : null,
3209
+ actionFill: actionShapeEl ? (actionShapeEl.style?.fill?.color || null) : null,
3210
+ actionRadius: actionShapeEl ? (actionShapeEl.style?.radius?.borderRadius || 0) : 0,
3211
+ actionW: actionShapeEl ? Math.round(rect(actionShapeEl).width || 0) : 0,
3212
+ actionH: actionShapeEl ? Math.round(rect(actionShapeEl).height || 0) : 0,
3213
+ desc: compositePairs ? '' : textOf(descEl),
3214
+ descRight: descRightEl ? textOf(descRightEl) : '',
3215
+ descRightColor: descRightEl ? ts(descRightEl).color : undefined,
3216
+ descRightFontSize: descRightEl ? ts(descRightEl).fontSize : undefined,
3217
+ amounts: compositePairs ? compositePairs.map((pp) => ({
3218
+ label: textOf(pp.labelEl), value: textOf(pp.valueEl),
3219
+ tagText: pp.tagText || '', tagIcon: pp.tagIcon || '',
3220
+ tagFill: pp.tagFill || null, tagOpacity: pp.tagOpacity ?? 1, tagBorder: pp.tagBorder || null, tagRadius: pp.tagRadius || 0,
3221
+ tagW: pp.tagW || 0, tagH: pp.tagH || 0, tagGap: pp.tagGap, tagPadLeft: pp.tagPadLeft,
3222
+ tagPadRight: pp.tagPadRight, tagIconGap: pp.tagIconGap, tagIconW: pp.tagIconW, tagIconH: pp.tagIconH,
3223
+ tagTextColor: pp.tagTextColor, tagFontSize: pp.tagFontSize,
3224
+ labelColor: ts(pp.labelEl).color, labelFontSize: ts(pp.labelEl).fontSize,
3225
+ labelFontWeight: ts(pp.labelEl).fontWeight,
3226
+ labelX: Math.round(rect(pp.labelEl).x || 0), labelW: Math.round(rect(pp.labelEl).width || 0),
3227
+ labelY: Math.round(rect(pp.labelEl).y || 0), labelH: Math.round(rect(pp.labelEl).height || 0),
3228
+ valueY: Math.round(rect(pp.valueEl).y || 0),
3229
+ valueColor: ts(pp.valueEl).color, valueFontSize: ts(pp.valueEl).fontSize,
3230
+ valueFontWeight: ts(pp.valueEl).fontWeight, valueW: Math.round(rect(pp.valueEl).width || 0),
3231
+ })) : undefined,
3232
+ titleRight: titleRightEl ? textOf(titleRightEl) : '',
3233
+ titleRightColor: titleRightEl ? ts(titleRightEl).color : undefined,
3234
+ titleRightFontSize: titleRightEl ? ts(titleRightEl).fontSize : undefined,
3235
+ titleRightGap: titleRightEl
3236
+ ? Math.max(4, Math.round((rect(titleRightEl).x || 0) - ((rect(t).x || 0) + (rect(t).width || 0))))
3237
+ : undefined,
3238
+ labelGapTop: compositePairs
3239
+ ? Math.max(0, Math.round((rect(compositePairs[0].labelEl).y || 0) - (y + (rect(t).height || 0))))
3240
+ : undefined,
3241
+ iconGap: iconEl
3242
+ ? Math.max(0, Math.round((rect(t).x || 0) - ((rect(iconEl).x || 0) + (rect(iconEl).width || 0))))
3243
+ : undefined,
3244
+ amount: amount ? textOf(amount) : '',
3245
+ time: time ? textOf(time) : '',
3246
+ timeColor: time ? ts(time).color : undefined,
3247
+ timeFontSize: time ? ts(time).fontSize : undefined,
3248
+ timeFontWeight: time ? ts(time).fontWeight : undefined,
3249
+ titleColor: ts(t).color,
3250
+ titleFontSize: ts(t).fontSize,
3251
+ titleFontWeight: ts(t).fontWeight,
3252
+ descColor: descEl ? ts(descEl).color : undefined,
3253
+ descFontSize: descEl ? ts(descEl).fontSize : undefined,
3254
+ descFontWeight: descEl ? ts(descEl).fontWeight : undefined,
3255
+ descHeight: descEl ? (rect(descEl).height || 0) : 0,
3256
+ descY: descEl ? (rect(descEl).y || 0) : undefined,
3257
+ descX: descEl ? (rect(descEl).x || 0) : undefined,
3258
+ amountColor: amount ? ts(amount).color : undefined,
3259
+ amountFontSize: amount ? ts(amount).fontSize : undefined,
3260
+ amountFontWeight: amount ? ts(amount).fontWeight : undefined,
3261
+ y,
3262
+ titleX: Math.round(rect(t).x || 0),
3263
+ titleW: Math.round(rect(t).width || 0),
3264
+ contentHeight: compositePairs
3265
+ ? Math.round(Math.max(...compositePairs.map((pp) => (rect(pp.valueEl).y || 0) + (rect(pp.valueEl).height || 0))) - y)
3266
+ : Math.round((rect(descEl).y || 0) + (rect(descEl).height || 0) - y),
3267
+ })
3268
+ if (descEl) itemDescConsumed.add(descEl)
3269
+ itemTextConsumed.add(t)
3270
+ if (compositePairs) {
3271
+ for (const pp of compositePairs) { itemTextConsumed.add(pp.labelEl); itemTextConsumed.add(pp.valueEl) }
3272
+ const minLabelY = Math.min(...compositePairs.map((pp) => rect(pp.labelEl).y || 0))
3273
+ const maxValueBottom = Math.max(...compositePairs.map((pp) => (rect(pp.valueEl).y || 0) + (rect(pp.valueEl).height || 0)))
3274
+ compositeRowRegions.push([minLabelY - 4, maxValueBottom + 4])
3275
+ }
3276
+ if (titleRightEl) itemTextConsumed.add(titleRightEl)
3277
+ if (descEl) itemTextConsumed.add(descEl)
3278
+ if (descRightEl) itemTextConsumed.add(descRightEl)
3279
+ if (suffixEl) itemTextConsumed.add(suffixEl)
3280
+ if (actionTextEl) itemTextConsumed.add(actionTextEl)
3281
+ if (amount) itemTextConsumed.add(amount)
3282
+ if (time) itemTextConsumed.add(time)
3283
+ lastY = y
3284
+ }
3285
+ }
3286
+ // 行图标/行内操作壳已被列表项消费:从孤立媒体块中回收,防止二次渲染
3287
+ const itemMediaEls = new Set(items.flatMap((item) => [item.iconEl, item.actionShapeEl]).filter(Boolean))
3288
+ if (itemMediaEls.size) {
3289
+ for (let bi = isolatedBlocks.length - 1; bi >= 0; bi -= 1) {
3290
+ if (itemMediaEls.has(isolatedBlocks[bi].element)) isolatedBlocks.splice(bi, 1)
3291
+ }
3292
+ }
3293
+ // 行尾右侧操作件(眼睛/箭头图标)归属——按「是否被列表接管」分流:
3294
+ // 有列表:重复同名实例由列表项模板逐行渲染(见 listAffordanceSlice),回收防重影;
3295
+ // 无列表:挂到垂直对齐的语义行(aux 文本行 / midCluster 行)右侧渲染;
3296
+ // 无对齐行:留在孤立媒体块按 spec 位置兜底(与旧行为一致)。
3297
+ const panelRowSliceEls = new Set((panelRows || []).flatMap((panel) => [panel.leftSlice, panel.rightSlice]).filter(Boolean))
3298
+ const rightAffordancePool = affordanceCandidates.filter((element) =>
3299
+ !formSliceEls.has(element) && !auxSliceEls.has(element) && element !== primaryIconEl
3300
+ && !(midCluster && midCluster.logoSlice === element) && !panelRowSliceEls.has(element))
3301
+ if (rightAffordancePool.length) {
3302
+ const detachIsolated = (element) => {
3303
+ const bi = isolatedBlocks.findIndex((b) => b.element === element)
3304
+ if (bi >= 0) isolatedBlocks.splice(bi, 1)
3305
+ }
3306
+ // 挂到垂直对齐的文本语义行右侧(listTitle / midCluster / auxTexts),成功返回 true
3307
+ const attachToTextRow = (element) => {
3308
+ const er = rect(element)
3309
+ const tol = Math.max(12, (er.height || 0) * 0.75)
3310
+ let best = null
3311
+ if (midCluster) {
3312
+ const diff = Math.abs((midCluster.y || 0) - (er.y || 0))
3313
+ if (diff <= tol) best = { kind: 'mid', diff }
3314
+ }
3315
+ if (listTitle) {
3316
+ const diff = Math.abs((listTitle.y || 0) - (er.y || 0))
3317
+ if (diff <= tol && (!best || diff < best.diff)) best = { kind: 'title', diff }
3318
+ }
3319
+ for (const aux of auxTexts) {
3320
+ const diff = Math.abs((aux.y || 0) - (er.y || 0))
3321
+ if (diff <= tol && (!best || diff < best.diff)) best = { kind: 'aux', aux, diff }
3322
+ }
3323
+ if (!best) return false
3324
+ if (best.kind === 'mid') midCluster.rightSliceEl = element
3325
+ else if (best.kind === 'title') listTitle.rightSliceEl = element
3326
+ else best.aux.rightSliceEl = element
3327
+ return true
3328
+ }
3329
+ for (const element of rightAffordancePool) {
3330
+ const er = rect(element)
3331
+ const tol = Math.max(12, (er.height || 0) * 0.75)
3332
+ // 1) 列表行归属:行尾 affordance 与行标题同线(顶沿对齐),逐行挂接 → 数据驱动渲染,
3333
+ // 无 affordance 的行(如末行)$afford 为空串由 v-show 隐藏。
3334
+ let hitItem = null
3335
+ if (items.length) {
3336
+ let bestDy = Infinity
3337
+ for (const item of items) {
3338
+ if (item.affordanceEl) continue
3339
+ // 行右大图标内嵌小徽标(如 icon 角上的 ¥ badge)不是行尾 affordance:重叠即跳过,
3340
+ // 否则徽标挤占左栏宽度导致 desc 换行(保险保障行实测)
3341
+ const riR = item.rightIconEl ? rect(item.rightIconEl) : null
3342
+ if (riR && (er.x || 0) >= (riR.x || 0) - 6 && (er.y || 0) >= (riR.y || 0) - 6
3343
+ && (er.x || 0) + (er.width || 0) <= (riR.x || 0) + (riR.width || 0) + 6
3344
+ && (er.y || 0) + (er.height || 0) <= (riR.y || 0) + (riR.height || 0) + 6) continue
3345
+ const dy = Math.abs((item.y || 0) - (er.y || 0))
3346
+ if (dy <= tol && dy < bestDy) { bestDy = dy; hitItem = item }
3347
+ }
3348
+ }
3349
+ if (hitItem) {
3350
+ hitItem.affordanceEl = element
3351
+ const affordExport = resolveSliceExport(spec.slices || [], element.name) || element.annotationDefect?.cropSlice
3352
+ hitItem.affordanceUrl = affordExport
3353
+ ? sliceRefPath(affordExport)
3354
+ : `http://iph.href.lu/${Math.round(er.width || 20)}x${Math.round(er.height || 20)}?text=${encodeURIComponent('缺素材')}`
3355
+ detachIsolated(element)
3356
+ continue
3357
+ }
3358
+ // 2) 文本语义行右侧挂接(标题行的眼睛图标等)
3359
+ if (attachToTextRow(element)) { detachIsolated(element); continue }
3360
+ // 3) 有列表时的旧兜底:重复实例由模板静态渲染(见 listAffordanceSlice),回收防重影
3361
+ if (items.length && repeatedRightAffordanceEls.has(element)) detachIsolated(element)
3362
+ // 4) 其余留在孤立媒体块按 spec 位置兜底渲染
3363
+ }
3364
+ }
3365
+ // 无标题列表时 postAmountTexts 上界为 ∞,会把列表行文本再渲染一遍——
3366
+ // 同步回收被列表项消费的文本(与 auxTexts/isolatedBlocks 回收同构)。
3367
+ if (itemTextConsumed.size && Array.isArray(postAmountTexts)) {
3368
+ for (let pi = postAmountTexts.length - 1; pi >= 0; pi -= 1) {
3369
+ if (itemTextConsumed.has(postAmountTexts[pi].sourceEl)) postAmountTexts.splice(pi, 1)
3370
+ }
3371
+ }
3372
+ // auxTexts 早于列表识别计算:行内时间/描述被列表接管后必须同步回收,
3373
+ // 否则它们在列表之后再渲染一遍(账单卡时间行堆到卡底)。
3374
+ if (itemTextConsumed.size && Array.isArray(auxTexts)) {
3375
+ for (let ai = auxTexts.length - 1; ai >= 0; ai -= 1) {
3376
+ if (itemTextConsumed.has(auxTexts[ai].sourceEl)) auxTexts.splice(ai, 1)
3377
+ }
3378
+ }
3379
+ // 同表模板一致性护栏(纯结构签名,不靠业务词):签名不同的行并进同一 ListLayout 会让
3380
+ // 模板字段错位(desc 落 title 槽竖排换行、缺键渲染占位文案),且少数行与静态语义块双份渲染。
3381
+ // 保留多数签名行成表;少数签名行退出列表,交还静态语义通道渲染。
3382
+ if (items.length >= 3) {
3383
+ const itemSig = (it) => [
3384
+ it.iconEl ? 1 : 0,
3385
+ (it.amounts && it.amounts.length) ? 1 : 0,
3386
+ it.rowRightEdge != null ? 1 : 0,
3387
+ it.actionShapeEl ? 1 : 0,
3388
+ ].join('')
3389
+ const sigCounts = new Map()
3390
+ for (const it of items) sigCounts.set(itemSig(it), (sigCounts.get(itemSig(it)) || 0) + 1)
3391
+ const majority = [...sigCounts.entries()].sort((a, b) => b[1] - a[1])[0]
3392
+ if (sigCounts.size >= 2 && majority[1] >= 2 && majority[1] < items.length) {
3393
+ const kept = items.filter((it) => itemSig(it) === majority[0])
3394
+ items.splice(0, items.length, ...kept)
3395
+ }
3396
+ }
3397
+ const sampleItems = items.slice(0, 3)
3398
+ // 表单行早于列表识别计算:行内 desc/descRight 被列表接管后必须同步回收,
3399
+ // 否则表单通道把同一批标注再渲染一遍(交易行余额行重影)。
3400
+ if (itemTextConsumed.size && Array.isArray(formRows)) {
3401
+ for (let fi = formRows.length - 1; fi >= 0; fi -= 1) {
3402
+ const ownedEls = [formRows[fi].labelEl, formRows[fi].valueEl, ...(formRows[fi].subEls || [])]
3403
+ .filter(Boolean)
3404
+ if (ownedEls.some((el) => itemTextConsumed.has(el))) formRows.splice(fi, 1)
3405
+ }
3406
+ }
3407
+ // richBlocks 早于列表识别按全量文本计算:tile/段落区域被列表接管后必须同步回收,
3408
+ // 否则富文本段落路径把同一批标注再渲染一遍(v6 产品 tile 重影,duplicate-content ×23)。
3409
+ // 双判据:① 碎片被列表消费覆盖率 ≥80%;② 几何落在列表区(与分隔线列表区护栏同族,
3410
+ // 纯几何不靠业务词)——列表区的富文本块必然与列表迭代双份渲染。
3411
+ // 列表壳 span:item 壳顶(rowTop,缺省退标题 y 上留 24px 壳垫)→ 壳底。
3412
+ // 任何「整块包住列表行」的富文本块/面板壳都是列表壳本体:与列表迭代必然双份渲染,回收。
3413
+ const listItemSpans = sampleItems.map((it) => {
3414
+ const gaps = sampleItems.slice(1).map((other, index) =>
3415
+ ((other.y || 0) - (sampleItems[index].y || 0))).filter((gap) => gap > 0)
3416
+ const fallbackH = gaps.length ? gaps.sort((a, b) => a - b)[Math.floor(gaps.length / 2)] : 40
3417
+ const top = (it.rowTop ?? (it.y || 0) - 24)
3418
+ const bottom = top + (it.shellHeight || it.rowHeight || it.height || fallbackH)
3419
+ return [top, bottom]
3420
+ })
3421
+ const overlapsListRow = (y, height) => listItemSpans.some(([top, bottom]) =>
3422
+ (y || 0) < bottom + 4 && ((y || 0) + (height || 0)) > top - 4)
3423
+ if (Array.isArray(richBlocks) && sampleItems.length >= 2) {
3424
+ for (let ri = richBlocks.length - 1; ri >= 0; ri -= 1) {
3425
+ const block = richBlocks[ri]
3426
+ const frags = block.fragments || []
3427
+ const consumed = frags.filter((f) => itemTextConsumed.has(f)).length
3428
+ const covered = frags.length > 0 && consumed / frags.length >= 0.8
3429
+ if (covered || overlapsListRow(block.y, block.height || 0)) richBlocks.splice(ri, 1)
3430
+ }
3431
+ }
3432
+ // panelRows(面板壳 + 壳内文本,渲染见 emit 段 panelRows 循环)被列表接管后同步回收:
3433
+ // 否则面板路径与列表迭代把同一批标注再渲染一遍(v6 产品 tile 重影,duplicate-content ×23)。
3434
+ // 双判据与 richBlocks 护栏同构:壳内文本被列表消费覆盖率 ≥80%,或几何落在列表区(纯几何)。
3435
+ if (Array.isArray(panelRows) && sampleItems.length >= 2) {
3436
+ for (let pi = panelRows.length - 1; pi >= 0; pi -= 1) {
3437
+ const panel = panelRows[pi]
3438
+ const owned = panel.sourceEls || []
3439
+ const consumed = owned.filter((el) => itemTextConsumed.has(el)).length
3440
+ const covered = owned.length > 0 && consumed / owned.length >= 0.8
3441
+ if (covered || overlapsListRow(panel.y, panel.height || 0)) panelRows.splice(pi, 1)
3442
+ }
3443
+ }
3444
+ // 列表数据记录应覆盖全部检测到的行(采样仅供样式中位数等统计),
3445
+ // 否则设计稿 5 行只渲染 3 行,底部留白塌陷。上限 8 行防止异常检测爆量。
3446
+ const allItems = items.slice(0, 8)
3447
+ const shellCardWidth = (spec.size && spec.size.width) || (card.rect && card.rect.width) || 375
3448
+ for (let index = 0; index < items.length; index += 1) {
3449
+ const next = items[index + 1]
3450
+ items[index].rowHeight = next
3451
+ ? Math.max(0, Math.round(next.y - items[index].y))
3452
+ : (index > 0 ? items[index - 1].rowHeight : items[index].contentHeight)
3453
+ const shellCandidates = content.filter((element) => {
3454
+ const elementRect = rect(element)
3455
+ const width = elementRect.width || 0
3456
+ const height = elementRect.height || 0
3457
+ return ['shape', 'group', 'symbol'].includes(element.type)
3458
+ && width >= shellCardWidth * 0.8 && height >= 32 && height <= 160
3459
+ && (elementRect.y || 0) <= items[index].y
3460
+ && items[index].y < (elementRect.y || 0) + height
3461
+ && (elementRect.y || 0) + height <= items[index].y + items[index].rowHeight + 4
3462
+ && Math.abs(height - items[index].rowHeight) <= Math.max(12, items[index].rowHeight * 0.25)
3463
+ }).sort((left, right) => Math.abs((rect(left).height || 0) - items[index].rowHeight)
3464
+ - Math.abs((rect(right).height || 0) - items[index].rowHeight))
3465
+ // 有填充的壳优先:同尺寸包裹层(group/symbol)无 fill,选中它行底色/圆角恒丢
3466
+ shellCandidates.sort((left, right) => {
3467
+ const lf = left.style?.fill
3468
+ const rf = right.style?.fill
3469
+ const lNo = !(lf && (lf.color || lf.gradient)) ? 1 : 0
3470
+ const rNo = !(rf && (rf.color || rf.gradient)) ? 1 : 0
3471
+ return lNo - rNo
3472
+ })
3473
+ const shell = shellCandidates[0]
3474
+ // 视觉行壳判定:满宽 + 无圆角 + 无渐变 = 分区背景板(非行壳)。误认领会把
3475
+ // 内边距/底色/分割线全搅乱(我的账户卡实测 padRight 303 把左栏压没、pos 崩 0.33)
3476
+ const shellVisual = !!shell && ((radiusMax(shell.style) || 0) >= 4
3477
+ || !!shell.style?.fill?.gradient
3478
+ || (rect(shell).width || 0) <= ((rect(card).width || shellCardWidth) - 8))
3479
+ items[index].rowTop = shell ? (rect(shell).y || 0)
3480
+ : (items[index].iconEl ? Math.min(items[index].y, rect(items[index].iconEl).y || items[index].y) : items[index].y)
3481
+ items[index].shellHeight = shell ? (rect(shell).height || items[index].rowHeight) : items[index].rowHeight
3482
+ // 行壳样式/内边距传给列表模板(emit 读 first.fill/gradient/radius/pad*,此前从未赋值 → 行底色恒丢)
3483
+ if (shell) {
3484
+ const sr2 = rect(shell)
3485
+ items[index].shellEl = shell
3486
+ items[index].shellVisual = shellVisual
3487
+ if (shellVisual) {
3488
+ items[index].fill = shell.style?.fill?.color || null
3489
+ items[index].gradient = shell.style?.fill?.gradient || null
3490
+ items[index].opacity = shell.style?.opacity ?? 1
3491
+ items[index].radius = radiusMax(shell.style)
3492
+ // 行内容真实右沿(全部行文本 + 左右图标 + 操作件):只按金额/时间右沿会把
3493
+ // 标题行卡的 padRight 算成几百 px 挤垮左栏
3494
+ const contentRight = items[index].rowContentRight || ((sr2.x || 0) + (sr2.width || 0) - 16)
3495
+ items[index].padLeft = Math.max(0, Math.round((items[index].rowLeftEdge || 0) - (sr2.x || 0)))
3496
+ items[index].padRight = Math.min(Math.max(0, Math.round(((sr2.x || 0) + (sr2.width || 0)) - contentRight)), Math.round((sr2.width || 0) / 2))
3497
+ items[index].padTop = Math.max(0, Math.round(((sr2.height || 0) - (items[index].contentHeight || 0)) / 2))
3498
+ }
3499
+ items[index].height = Math.round(sr2.height || items[index].rowHeight)
3500
+ } else {
3501
+ items[index].height = items[index].shellHeight || items[index].rowHeight
3502
+ items[index].padLeft = 16
3503
+ items[index].padRight = 16
3504
+ items[index].padTop = 8
3505
+ }
3506
+ // 进度条归属列表行:进度条 y 落在列表行带内时挂到该列表项(逐行渲染),
3507
+ // 否则作为独立语义块渲染(insertSemantic 全堆在卡底/同一 y 位→重叠)。
3508
+ const _listBarEls = new Set()
3509
+ if (items.length && progressBars.length) {
3510
+ for (const bar of progressBars) {
3511
+ const by = bar.y ?? 0
3512
+ const matched = items.find((item) => {
3513
+ const top = item.rowTop ?? item.y ?? 0
3514
+ const bottom = top + (item.shellHeight || item.rowHeight || item.height || 0)
3515
+ return by >= top && by < bottom
3516
+ })
3517
+ if (matched) {
3518
+ if (!matched.bars) matched.bars = []
3519
+ matched.bars.push(bar)
3520
+ if (bar.trackEl) _listBarEls.add(bar.trackEl)
3521
+ for (const el of (bar.segmentEls || [])) _listBarEls.add(el)
3522
+ }
3523
+ }
3524
+ if (_listBarEls.size) {
3525
+ for (let bi = progressBars.length - 1; bi >= 0; bi -= 1) {
3526
+ const bar = progressBars[bi]
3527
+ if ((bar.trackEl && _listBarEls.has(bar.trackEl))
3528
+ || (bar.segmentEls || []).some((el) => _listBarEls.has(el))) {
3529
+ progressBars.splice(bi, 1)
3530
+ }
3531
+ }
3532
+ }
3533
+ }
3534
+ }
3535
+
3536
+ // —— 查看更多 ——
3537
+ const lastItemY = items.length ? lastY + 80 : 0
3538
+ const viewMoreCand = texts.find((t) => {
3539
+ const y = rect(t).y || 0
3540
+ const T = ts(t)
3541
+ const tr = rect(t)
3542
+ const textRight = (tr.x || 0) + (tr.width || 0)
3543
+ const nearRight = textRight >= ((card.rect?.x || 0) + shellCardWidth) - Math.max(64, shellCardWidth * 0.25)
3544
+ const centeredAction = Math.abs(((tr.x || 0) + (tr.width || 0) / 2) -
3545
+ ((card.rect?.x || 0) + shellCardWidth / 2)) <= shellCardWidth * 0.2
3546
+ return items.length > 0 && y > lastY + 40 && (T.color || '').toLowerCase() !== '#000000'
3547
+ && (tr.width || 0) <= shellCardWidth * 0.5 && (nearRight || centeredAction)
3548
+ // 排除已被列表项消费的描述文本(如「可能涉及…请查看确认」含"查看",不得误当查看更多)
3549
+ && !itemDescConsumed.has(t)
3550
+ // 排除按钮文本(如「全部设为已读」含"全部",按钮已有独立语义块)
3551
+ && !buttons.some((b) => b.text === textOf(t))
3552
+ && !optionTextSet.has(t) && !optionLabelSet.has(t)
3553
+ && !auxTexts.some((aux) => aux.sourceEl === t)
3554
+ && !postAmountTexts.some((aux) => aux.sourceEl === t)
3555
+ && !itemTextConsumed.has(t)
3556
+ && !panelTextConsumed.has(t) && !formConsumed.has(t)
3557
+ })
3558
+ // 推导"查看更多"与正上方元素的上下间距:从 spec 几何坐标算,不要硬编码
3559
+ // 正上方最近元素底沿(排除全屏背景容器 top<=0/height 接近画板高)与 viewMore.y 之差
3560
+ let viewMoreGapTop = null
3561
+ if (viewMoreCand) {
3562
+ const vmY = rect(viewMoreCand).y || 0
3563
+ const ch = (spec.size && spec.size.height) || 812
3564
+ const allEls = []
3565
+ const collect = (o) => {
3566
+ if (Array.isArray(o)) { o.forEach(collect); return }
3567
+ if (o && typeof o === 'object') {
3568
+ if (o.rect && typeof o.rect.y === 'number') allEls.push(o)
3569
+ for (const k in o) collect(o[k])
3570
+ }
3571
+ }
3572
+ collect(spec)
3573
+ const above = allEls.filter((e) => {
3574
+ const r = e.rect
3575
+ const bottom = (r.y || 0) + (r.height || 0)
3576
+ return bottom <= vmY && (r.y || 0) > 2 && (r.height || 0) < ch * 0.9
3577
+ })
3578
+ if (above.length) {
3579
+ const prevBottom = Math.max(...above.map((e) => (e.rect.y || 0) + (e.rect.height || 0)))
3580
+ viewMoreGapTop = Math.round(vmY - prevBottom)
3581
+ }
3582
+ }
3583
+ const viewMore = viewMoreCand ? {
3584
+ text: textOf(viewMoreCand),
3585
+ color: ts(viewMoreCand).color,
3586
+ fontSize: ts(viewMoreCand).fontSize,
3587
+ height: rect(viewMoreCand).height || (ts(viewMoreCand).fontSize || 14) * 1.4,
3588
+ gapTop: viewMoreGapTop,
3589
+ y: rect(viewMoreCand).y || 0,
3590
+ } : null
3591
+
3592
+ // —— 底部控制件:只用“按钮区下方 + 居中 + 小尺寸近似方形媒体”判定;低置信度的纯文本不建模。
3593
+ const collapseBtn = (() => {
3594
+ const btnRegionBottom = buttonShapes.length
3595
+ ? Math.max(...buttonShapes.map((s) => (rect(s).y || 0) + (rect(s).height || 0)))
3596
+ : 0
3597
+ const mediaSlice = slices.find((s) => {
3598
+ const sr = rect(s)
3599
+ const width = sr.width || 0
3600
+ const height = sr.height || 0
3601
+ const center = (sr.x || 0) + width / 2
3602
+ const cardCenter = (rect(card).x || 0) + ((rect(card).width || 0) / 2)
3603
+ return width >= 12 && width <= 36 && height >= 12 && height <= 36
3604
+ && Math.abs(width - height) <= Math.max(10, width * 0.5)
3605
+ && (sr.y || 0) > btnRegionBottom
3606
+ && Math.abs(center - cardCenter) <= Math.max(24, (rect(card).width || 0) * 0.12)
3607
+ })
3608
+ const textEl = null
3609
+ if (!mediaSlice) return null
3610
+ return {
3611
+ text: textEl ? textOf(textEl) : null,
3612
+ mediaSlice: mediaSlice || null,
3613
+ y: (mediaSlice ? rect(mediaSlice).y : rect(textEl).y) || 0,
3614
+ color: textEl ? ts(textEl).color : '#999999',
3615
+ fontSize: textEl ? ts(textEl).fontSize : 12,
3616
+ }
3617
+ })()
3618
+
3619
+ // —— 通用值绑定扫描器(format + geometry,语义化命名)——
3620
+ // ★ 命名铁律(泛化,非业务硬编码):
3621
+ // 1. 只认两类信号:① 值「格式」(amount/cardNumber/phone/code/date/name) → 决定绑定表达式;② 空间「共簇」→ 决定哪些值合并为同一对象参数。
3622
+ // 2. 绝不做「这是转账卡的收款人/转出账户」之类业务判定;也不依赖「转账给/收款人/转出/银行/余额」等业务词做识别。
3623
+ // 3. 字段命名双层机制:① 标签词典(references/field_name_dict.json,**领域通用词表**,非卡片硬编码)→ 语义化英文驼峰 key;
3624
+ // ② 值格式角色兜底(cardNumber/amount/phone/name/date/code)→ 词典未命中时按值格式命名,
3625
+ // 保证永不出现 obj_N / amount_0 序号名。卡片 id 永不进入命名。
3626
+ // 4. 脱敏是「展示层表达式」关注点:数据层只存完整值(* 占位由 unmask 还原),脱敏表达式仅在源文本含 * 时启用,保证展示态=设计态;不另存脱敏字段。
3627
+ const BINDABLE = new Set(['amount', 'cardNumber', 'phone', 'code', 'date'])
3628
+ const bindings = { entities: [], literalBind: {}, multiToken: {} }
3629
+ const _usedKeys = new Set()
3630
+ // 对象级 key:label 命中词典 → entityKey(「转出账号」→ outAccount);未命中 → 对象内主字段角色名兜底
3631
+ function addObj(labelHint, roles = []) {
3632
+ const ek = entityKeyFor(labelHint)
3633
+ let base = ek
3634
+ if (!base) {
3635
+ // 兜底:按对象内字段角色命名(单角色 → 角色名;多角色 → 主角色+Item,如 amountItem)
3636
+ const rn = [...new Set(roles.filter((r) => ROLE_BASE_NAME[r]))].map((r) => ROLE_BASE_NAME[r])
3637
+ base = rn.length === 1 ? rn[0] : (rn.length > 1 ? rn[0] + 'Item' : 'field')
3638
+ }
3639
+ const k = uniqueKey(base, _usedKeys)
3640
+ bindings.entities.push({ key: k, fields: {} })
3641
+ return k
3642
+ }
3643
+ // 把一个值文本登记为「容器 objKey 下某个格式角色字段」的绑定;label 为字段级语义线索(如子值左侧的「可用余额:」)
3644
+ function bindValue(text, role, containerKey, num, label) {
3645
+ const fk = roleFieldKey(role, label)
3646
+ const v = containerKey + '.' + fk
3647
+ bindings.literalBind[text] = { var: v, expr: valueExpr(role, v, text), default: text, role }
3648
+ const ent = bindings.entities.find((e) => e.key === containerKey)
3649
+ const store = role === 'cardNumber' ? unmask(text)
3650
+ : (role === 'amount' ? (num !== undefined && num !== null ? num : text) : text)
3651
+ let fkey = fk
3652
+ while (Object.prototype.hasOwnProperty.call(ent.fields, fkey)) fkey = fk + '_' + Object.keys(ent.fields).length
3653
+ ent.fields[fkey] = store
3654
+ }
3655
+
3656
+ // 4a. 多值文本行(如「张三 13800138000」单元素含多值):按 SHAPE 拆分 token,值段绑定,整行归一个 obj。
3657
+ // 前缀剥离词典(仅展示层,不影响识别):设计稿里联系人/收款人文本常带动词前缀(转账给/收款人 等),
3658
+ // 这些前缀会被 splitValueTokens 的中文 2-4 模式贪婪吃入,导致动词混进 name token。
3659
+ // 词典只负责把这些前缀从 name 中剥出来作字面量展示,**不参与识别**——
3660
+ // 识别完全靠 SHAPE(人名格式+手机号格式),词典增删不影响绑定结果。
3661
+ const verbRe = displayPrefixRe()
3662
+ for (const t of texts) {
3663
+ const raw = (textOf(t) || '').trim()
3664
+ if (!raw) continue
3665
+ const verb = (raw.match(verbRe) || [''])[0] || ''
3666
+ const core = verb ? raw.replace(verbRe, '').trim() : raw
3667
+ // 日期区间/点分单日(2025.11.28-2025.12.04):整串是单一展示期间,splitValueTokens 会把它
3668
+ // 切成「2025.11」等数字段误绑金额表达式(渲染出千分号)。保持字面量。
3669
+ if (/^\d{4}[-./]\d{1,2}[-./]\d{1,2}(\s*[-~—至]\s*\d{4}[-./]\d{1,2}[-./]\d{1,2})?$/.test(core)) continue
3670
+ const segs = splitValueTokens(core)
3671
+ // 绑定条件(满足其一):
3672
+ // ① 含「强值信号」(phone/cardNumber/amount/code/date) —— 实体行(张三 13800138000);
3673
+ // ② 「动词前缀 + 纯姓名」(转账给张三)——收款/付款动词后的 2-4 字姓名是明显运行期数据,必须绑定为参数,
3674
+ // 不得留成常量字符串(用户姓名/卡号等动态字段的确定性兜底;智能体复核仍可增补)。
3675
+ // 纯中文句(如"30字以内,默认为转账",无动词前缀)不得被 2-4 字硬切成 name 字段。
3676
+ const hasStrong = segs.some((s) => BINDABLE.has(s.role))
3677
+ const pureName = verb && segs.length === 1 && segs[0].role === 'name'
3678
+ if ((hasStrong && segs.length > 1) || pureName) {
3679
+ // 对象级命名:原始文本(含动词前缀「转账给」)命中词典 → payee;未命中 → 字段角色兜底
3680
+ const key = addObj(raw, segs.map((s) => s.role))
3681
+ const renderSegs = []
3682
+ if (verb) renderSegs.push({ text: verb.trim(), role: null })
3683
+ for (const s of segs) {
3684
+ if (s.role && (BINDABLE.has(s.role) || s.role === 'name')) {
3685
+ const num = s.role === 'amount' ? parseAmount(s.text) : undefined
3686
+ bindValue(s.text, s.role, key, num)
3687
+ const fk = roleFieldKey(s.role)
3688
+ renderSegs.push({ text: s.text, role: s.role, expr: valueExpr(s.role, key + '.' + fk, s.text), default: s.text })
3689
+ } else {
3690
+ renderSegs.push({ text: s.text, role: null })
3691
+ }
3692
+ }
3693
+ bindings.multiToken[raw] = renderSegs
3694
+ }
3695
+ }
3696
+
3697
+ // 4b. 标题与主金额之间的同 y 文本簇(几何聚类,不暗示任何业务含义):仅绑定格式值(code 等),其余按字面量。
3698
+ if (midCluster) {
3699
+ const roles = midCluster.items.map((it) => classifyValue(it.text)).filter((r) => BINDABLE.has(r))
3700
+ const key = addObj(null, roles)
3701
+ for (const it of midCluster.items) {
3702
+ const role = classifyValue(it.text)
3703
+ if (BINDABLE.has(role)) bindValue(it.text, role, key)
3704
+ }
3705
+ }
3706
+
3707
+ // 4c. 表单行右簇:值 + 子值共簇 → 一个 obj(如 卡号 + 同簇金额)。几何共簇 + 值格式信号,不靠「转出/余额」等业务词。
3708
+ // 字段级语义:对象 key 用左标签词典(转出账号→outAccount);子值字段用其同行左侧文本词典(可用余额:→availableBalance)。
3709
+ for (const fr of formRows) {
3710
+ const cands = []
3711
+ if (fr.value) cands.push({ text: fr.value.text, role: classifyValue(fr.value.text), label: fr.label.text })
3712
+ for (const sv of (fr.subValues || [])) {
3713
+ // 子值字段级语义线索:同行(±12px)x 更小的文本(如「可用余额:」)作为 labelHint
3714
+ const leftLabel = (fr.subValues || []).filter((o) => o !== sv && (o.x || 0) < (sv.x || 0) && Math.abs((o.y || 0) - (sv.y || 0)) <= 12)
3715
+ .sort((a, b) => (b.x || 0) - (a.x || 0))[0]
3716
+ cands.push({ text: sv.text, role: classifyValue(sv.text), label: leftLabel ? leftLabel.text : null })
3717
+ }
3718
+ const bindable = cands.filter((c) => BINDABLE.has(c.role))
3719
+ if (!bindable.length) continue
3720
+ const key = addObj(fr.label.text, bindable.map((c) => c.role))
3721
+ for (const c of bindable) bindValue(c.text, c.role, key, c.role === 'amount' ? parseAmount(c.text) : undefined, c.label)
3722
+ }
3723
+
3724
+ // 4d. 通栏分隔线:细长几何壳/符号是显式结构边界,不能因宽度阈值被孤立块过滤丢弃。
3725
+ const dividerElements = [...shapes, ...symbols].filter((element) => {
3726
+ const er = rect(element)
3727
+ return (er.width || 0) >= cardWidth * 0.6 && (er.height || 0) <= 2
3728
+ && (er.y || 0) >= ((rect(card).y || 0) + 8)
3729
+ && ((er.y || 0) + (er.height || 0)) <= (((rect(card).y || 0) + (rect(card).height || 0)) - 8)
3730
+ && !buttonShapes.some((button) => overlapRatio(element, button) > 0.5)
3731
+ && !(symbolMediaRows.length >= 2 && (() => {
3732
+ const top = Math.min(...symbolMediaRows.map((row) => row.y || 0))
3733
+ const bottom = Math.max(...symbolMediaRows.map((row) => (row.y || 0) + (row.height || 0)))
3734
+ const y = rect(element).y || 0
3735
+ return y > top && y < bottom && symbolMediaRows.every((row) => y !== (row.y || 0) + (row.height || 0))
3736
+ })())
3737
+ && !(sampleItems.length >= 2 && (() => {
3738
+ const top = Math.min(...sampleItems.map((item) => item.y || 0))
3739
+ const itemGaps = sampleItems.slice(1).map((item, index) =>
3740
+ (item.y || 0) - (sampleItems[index].y || 0)).filter((gap) => gap > 0)
3741
+ const itemHeight = itemGaps.length ? itemGaps.sort((a, b) => a - b)[Math.floor(itemGaps.length / 2)] : 40
3742
+ const bottom = Math.max(...sampleItems.map((item) =>
3743
+ (item.y || 0) + (item.height || itemHeight)))
3744
+ const y = rect(element).y || 0
3745
+ return y > top && y < bottom && sampleItems.every((item) => y !== (item.rowTop ?? item.y) + (item.shellHeight || item.rowHeight || 0))
3746
+ })())
3747
+ })
3748
+ const dividerCandidates = dividerElements.map((element) => ({
3749
+ element,
3750
+ x: rect(element).x || 0,
3751
+ y: rect(element).y || 0,
3752
+ width: rect(element).width || 0,
3753
+ height: rect(element).height || 1,
3754
+ color: element.style?.fill?.color || element.style?.background?.color || null,
3755
+ })).sort((a, b) => Number(!!b.color) - Number(!!a.color))
3756
+ const dividers = []
3757
+ for (const candidate of dividerCandidates) {
3758
+ if (!dividers.some((existing) => overlapRatio(candidate.element, existing.element) >= 0.4)) dividers.push(candidate)
3759
+ }
3760
+
3761
+ // Reclassify small action buttons (e.g., verification code resend) as form row affordances
3762
+ // instead of primary buttons. Small buttons that overlap with form rows by Y range
3763
+ // and have width < 50% of card width are action buttons, not primary buttons.
3764
+ const _cardWidth = rect(card).width || spec.size?.width || 375
3765
+ for (let bi = buttons.length - 1; bi >= 0; bi--) {
3766
+ const btn = buttons[bi]
3767
+ const btnY = btn.y || 0
3768
+ const btnHeight = btn.height || 0
3769
+ const btnWidth = btn.sourceEl ? (() => {
3770
+ const btnText = btn.text
3771
+ const txt = texts.find((t) => textOf(t) === btnText)
3772
+ if (!txt) return _cardWidth
3773
+ const tr = rect(txt)
3774
+ // Find the SMALLEST shape that contains the button text (not the card panel)
3775
+ const containingShapes = shapes.filter((s) => {
3776
+ const sr = rect(s)
3777
+ return (tr.x || 0) >= (sr.x || 0) && (tr.x || 0) + (tr.width || 0) <= (sr.x || 0) + (sr.width || 0)
3778
+ && (tr.y || 0) >= (sr.y || 0) && (tr.y || 0) + (tr.height || 0) <= (sr.y || 0) + (sr.height || 0)
3779
+ }).sort((a, b) => (rect(a).width || 0) * (rect(a).height || 0) - (rect(b).width || 0) * (rect(b).height || 0))
3780
+ return containingShapes[0] ? rect(containingShapes[0]).width || 0 : _cardWidth
3781
+ })() : _cardWidth
3782
+ if (btnWidth < _cardWidth * 0.5) {
3783
+ const matchingFormRow = formRows.find((fr) => {
3784
+ const frTop = fr.label?.y || 0
3785
+ const frBottom = Math.max(...[fr.value, ...(fr.subValues || []), fr.label]
3786
+ .filter(Boolean).map((item) => (item.y || 0) + (item.height || (item.fontSize || 14) * 1.4)))
3787
+ return btnY >= frTop - 10 && btnY <= frBottom + 10
3788
+ })
3789
+ if (matchingFormRow) {
3790
+ matchingFormRow.rightActionButton = btn
3791
+ // 同面板挂接:按钮文本随按钮消费,防 buttons 摘除后被辅助文案路径重渲染
3792
+ if (btn.sourceEl) formConsumed.add(btn.sourceEl)
3793
+ buttons.splice(bi, 1)
3794
+ }
3795
+ }
3796
+ }
3797
+
3798
+ // 小 pill 挂接到垂直重叠的辅助文案行(标题行右侧操作件)。
3799
+ // 独立成行会在标题与下一行之间撑出一段行高把下方内容压下去(曾致标题行下方整段下漂)。
3800
+ // 只认领:小尺寸(w<80)+ 中心在卡中线右侧 + 中心落在 aux 行竖直带内 + 在 aux 文本右侧。
3801
+ for (let bi = buttons.length - 1; bi >= 0; bi -= 1) {
3802
+ const btn = buttons[bi]
3803
+ if ((btn.width || 0) >= 80) continue
3804
+ const btnCy = (btn.y || 0) + (btn.height || 0) / 2
3805
+ const btnCx = (btn.x || 0) + (btn.width || 0) / 2
3806
+ const cardCx = (rect(card).x || 0) + (rect(card).width || 375) / 2
3807
+ if (btnCx <= cardCx) continue
3808
+ const aux = (auxTexts || []).find((a) => {
3809
+ if (a.rightActionButton) return false
3810
+ const bandTop = (a.y || 0) - 8
3811
+ const bandBottom = (a.y || 0) + Math.max(a.height || 0, (a.fontSize || 14) * 1.5) + 8
3812
+ return btnCy >= bandTop && btnCy <= bandBottom
3813
+ && (btn.x || 0) >= (a.x || 0) + (a.width || 0)
3814
+ })
3815
+ if (!aux) continue
3816
+ aux.rightActionButton = btn
3817
+ if (btn.sourceEl) formConsumed.add(btn.sourceEl)
3818
+ buttons.splice(bi, 1)
3819
+ }
3820
+
3821
+ // —— 标题下划线(accent underline):标题文本正下方的彩色窄矩形 ——
3822
+ // 检测条件:shape,高度 2-15px,非白/非透明背景色,位于某个文本元素正下方(间距 ≤16px,x 对齐)
3823
+ const NEUTRAL_COLORS = new Set(['#FFFFFF', '#FFF', '#000000', '#000', '#FAFAFA', '#F5F5F5',
3824
+ '#F8F8F8', '#F0F0F0', '#EEEEEE', '#E9ECF2', '#E5E5E5', '#CCCCCC', '#DDDDDD', 'NONE', ''])
3825
+ const titleUnderlines = []
3826
+ for (const shape of shapes) {
3827
+ if (dividerElements.includes(shape) || buttonShapes.includes(shape)) continue
3828
+ const sr = rect(shape)
3829
+ const sh = sr.height || 0
3830
+ const sw = sr.width || 0
3831
+ if (sh < 2 || sh > 15) continue
3832
+ if (sw < 16) continue
3833
+ const bgRaw = shape.style?.background?.color || shape.style?.fill?.color || ''
3834
+ const bg = (bgRaw || '').toUpperCase()
3835
+ if (!bg || NEUTRAL_COLORS.has(bg)) continue
3836
+ const matchingText = texts.find((t) => {
3837
+ // 数值文本(汇总区金额)不与下划/高亮带配对:比例条(h=4)曾被误认为金额层下划,
3838
+ // 吞掉汇总区次列值(300.00 丢失)。
3839
+ if (isAmountish(textOf(t))) return false
3840
+ const tr = rect(t)
3841
+ const tBottom = (tr.y || 0) + (tr.height || 0)
3842
+ const gap = (sr.y || 0) - tBottom
3843
+ // 允许部分重叠(下划线可能在文本底边之上几像素开始)
3844
+ if (gap < -16 || gap > 16) return false
3845
+ const tLeft = tr.x || 0
3846
+ const tRight = (tr.x || 0) + (tr.width || 0)
3847
+ const overlap = Math.min((sr.x || 0) + sw, tRight) - Math.max(sr.x || 0, tLeft)
3848
+ if (overlap < Math.min(sw, (tr.width || 0)) * 0.3) return false
3849
+ return true
3850
+ })
3851
+ if (matchingText) {
3852
+ const tr = rect(matchingText)
3853
+ titleUnderlines.push({
3854
+ textEl: matchingText,
3855
+ text: textOf(matchingText),
3856
+ color: bgRaw,
3857
+ y: sr.y || 0,
3858
+ x: sr.x || 0,
3859
+ width: sw,
3860
+ height: Math.max(sh, 4),
3861
+ opacity: shape.style?.opacity ?? 1,
3862
+ textBottom: (tr.y || 0) + (tr.height || 0),
3863
+ gap: (sr.y || 0) - ((tr.y || 0) + (tr.height || 0)),
3864
+ })
3865
+ }
3866
+ }
3867
+
3868
+ // —— 孤儿大切片:未被任何语义结构消费、且无导出素材/裁切的大块切片(典型:烘焙成图的按钮/横幅)——
3869
+ // 不渲染会整块丢失(如底部主按钮)。纯色采样近似由 generate() 在有设计稿时补色。
3870
+ const orphanConsumed = new Set([
3871
+ ...formSliceEls, ...auxSliceEls, ...repeatedRightAffordanceEls,
3872
+ ...symbolMediaRows.map((row) => row.symbolEl),
3873
+ ...isolatedBlocks.map((block) => block.element),
3874
+ ...(panelRows || []).flatMap((panel) => [panel.leftSlice, panel.rightSlice]),
3875
+ topDecoration?.sliceEl, topDecoration?.iconSlice,
3876
+ midCluster?.logoSlice,
3877
+ primaryAmount?.iconSlice,
3878
+ ].filter(Boolean))
3879
+ const orphanSlices = slices.filter((s) => {
3880
+ if (orphanConsumed.has(s)) return false
3881
+ if (floatDecos.includes(s)) return false
3882
+ const sr = rect(s)
3883
+ const w = sr.width || 0, h = sr.height || 0
3884
+ const cRf = rect(card)
3885
+ // 浮动装饰切片(卡头月亮/吉祥物光晕等):竖直方向与文本带重叠、水平贴卡缘 →
3886
+ // 不能占流式行高(否则整卡内容被顶下一个装饰高度),改由绝对定位叠层渲染。
3887
+ const overlapsText = texts.some((t) => {
3888
+ const tr = rect(t)
3889
+ const ov = Math.min((sr.y || 0) + h, (tr.y || 0) + (tr.height || 0)) - Math.max(sr.y || 0, tr.y || 0)
3890
+ return ov >= h * 0.2 && h >= (tr.height || 0) * 2.2
3891
+ })
3892
+ const edgeAnchored = ((sr.x || 0) + w) >= ((cRf.x || 0) + (cRf.width || 0)) - 24
3893
+ if (overlapsText && edgeAnchored && w <= (cRf.width || 375) * 0.5
3894
+ && !buttonShapes.some((b) => coverRatio(s, b) > 0.5)
3895
+ && !panelCandidates.some((p) => coverRatio(s, p) > 0.5)) {
3896
+ floatDecos.push(s)
3897
+ return false
3898
+ }
3899
+ return w >= 60 && h >= 24 && h <= 140 && w <= (rect(card).width || 375) + 40
3900
+ })
3901
+
3902
+ // —— 背景壳层(section/行/面板壳):带圆角填充的中型 shape,供列表行样式、表单行包裹、段落底色认领 ——
3903
+ // 此前卡宽壳被 isBg 排除、行壳因「壳高 vs 行 pitch 差 12px」超容差漏配,列表行/面板/段落全部丢底色。
3904
+ const shellLayers = (card.elements || []).filter((e) => {
3905
+ if (e.type !== 'shape') return false
3906
+ const f = e.style?.fill
3907
+ if (!f || (!f.color && !f.gradient)) return false
3908
+ if (radiusMax(e.style) < 4) return false
3909
+ const r = rect(e)
3910
+ const cR = rect(card)
3911
+ const w = r.width || 0
3912
+ const h = r.height || 0
3913
+ if (w < (cR.width || 0) * 0.4 || w > (cR.width || 0) + 4) return false
3914
+ if (h < 40 || h > (cR.height || 0) * 0.6) return false
3915
+ return true
3916
+ }).map((e) => ({
3917
+ el: e,
3918
+ x: rect(e).x || 0, y: rect(e).y || 0, width: rect(e).width || 0, height: rect(e).height || 0,
3919
+ fill: e.style?.fill?.color || null,
3920
+ gradient: e.style?.fill?.gradient || null,
3921
+ radius: radiusMax(e.style),
3922
+ opacity: e.style?.opacity ?? 1,
3923
+ }))
3924
+
3925
+ // —— 薄壳通道(行级描边/浅填充窄矩形)——
3926
+ // 容器级壳由 panelRows / gradientPanels 认领,壳层背景由 shellLayers 认领(要求 h>=40 且 radius>=4);
3927
+ // 行级薄壳(h 18..48、radius<=6、带描边或填充)两头落空 → 描边与底色整体丢失:
3928
+ // 表格行只剩裸文本,无文本的分段壳被当孤立媒体块渲染成「缺素材」占位图。
3929
+ const thinShellStyle = (shape) => {
3930
+ const sr = rect(shape)
3931
+ const border = shape.style?.border || {}
3932
+ const fill = shape.style?.fill || {}
3933
+ return {
3934
+ shapeEl: shape,
3935
+ x: sr.x || 0, y: sr.y || 0, width: sr.width || 0, height: sr.height || 0,
3936
+ fill: fill.color || null,
3937
+ gradient: fill.gradient || null,
3938
+ opacity: shape.style?.opacity ?? 1,
3939
+ radius: radiusMax(shape.style),
3940
+ isBorder: !!border.isBorder,
3941
+ borderWidth: Number(border.width) || 0,
3942
+ borderColor: border.color || null,
3943
+ }
3944
+ }
3945
+ const thinInside = (element, shape) => {
3946
+ const er = rect(element)
3947
+ const sr = rect(shape)
3948
+ return (er.x || 0) >= (sr.x || 0) - 2 && (er.y || 0) >= (sr.y || 0) - 2
3949
+ && ((er.x || 0) + (er.width || 0)) <= ((sr.x || 0) + (sr.width || 0)) + 2
3950
+ && ((er.y || 0) + (er.height || 0)) <= ((sr.y || 0) + (sr.height || 0)) + 2
3951
+ }
3952
+ const thinShellShapes = (card.elements || []).filter((element) => {
3953
+ if (element.type !== 'shape') return false
3954
+ const sr = rect(element)
3955
+ const cR = rect(card)
3956
+ const w = sr.width || 0
3957
+ const h = sr.height || 0
3958
+ if (h < 18 || h > 48) return false
3959
+ if (w < (cR.width || 0) * 0.2 || w > (cR.width || 0) + 4) return false
3960
+ if (radiusMax(element.style) > 6) return false
3961
+ const border = element.style?.border || {}
3962
+ const fill = element.style?.fill || {}
3963
+ if (!border.isBorder && !fill.color && !fill.gradient) return false
3964
+ if (panelShapes.includes(element) || optionShapeSet.has(element)) return false
3965
+ if (gradientPanels.some((gp) => gp.shapeEl === element)) return false
3966
+ // 与卡身同色且无描边/渐变 = 卡底延续补片,不是行壳
3967
+ if (!border.isBorder && !fill.gradient
3968
+ && String(fill.color || '').toUpperCase() === String(card.cardFill || '').toUpperCase()) return false
3969
+ return true
3970
+ })
3971
+ const thinShellRows = []
3972
+ const thinShellStrips = []
3973
+ {
3974
+ const freeShells = []
3975
+ for (const shape of thinShellShapes) {
3976
+ const inner = texts.filter((t) => thinInside(t, shape))
3977
+ if (inner.length) thinShellRows.push({ ...thinShellStyle(shape), texts: inner })
3978
+ else freeShells.push(shape)
3979
+ }
3980
+ // 无文本薄壳按「同 y 带 + 首尾相接(间隙 <=8)」聚成分段横条(时间轴/进度分段);
3981
+ // 单壳不成条,保持原孤立媒体块兜底,>=2 段才按几何分段还原。
3982
+ const ordered = freeShells.slice().sort((a, b) =>
3983
+ (rect(a).y || 0) - (rect(b).y || 0) || (rect(a).x || 0) - (rect(b).x || 0))
3984
+ const claimed = new Set()
3985
+ for (const shape of ordered) {
3986
+ if (claimed.has(shape)) continue
3987
+ const sr = rect(shape)
3988
+ const members = [shape]
3989
+ claimed.add(shape)
3990
+ let cursor = (sr.x || 0) + (sr.width || 0)
3991
+ for (const other of ordered) {
3992
+ if (claimed.has(other)) continue
3993
+ const or = rect(other)
3994
+ if (Math.abs((or.y || 0) - (sr.y || 0)) > 4) continue
3995
+ if ((or.x || 0) < cursor - 2 || (or.x || 0) - cursor > 8) continue
3996
+ members.push(other)
3997
+ claimed.add(other)
3998
+ cursor = (or.x || 0) + (or.width || 0)
3999
+ }
4000
+ if (members.length < 2) continue
4001
+ members.sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
4002
+ const boxes = members.map(thinShellStyle)
4003
+ const left = Math.min(...boxes.map((box) => box.x))
4004
+ const right = Math.max(...boxes.map((box) => box.x + box.width))
4005
+ thinShellStrips.push({
4006
+ x: left,
4007
+ y: Math.min(...boxes.map((box) => box.y)),
4008
+ width: right - left,
4009
+ height: Math.max(...boxes.map((box) => box.height)),
4010
+ boxes,
4011
+ })
4012
+ }
4013
+ }
4014
+ // 分段横条认领的壳不再作孤立媒体块(否则同一几何渲染成「缺素材」占位图)
4015
+ const thinStripEls = new Set(thinShellStrips.flatMap((strip) => strip.boxes.map((box) => box.shapeEl)))
4016
+ for (let index = isolatedBlocks.length - 1; index >= 0; index -= 1) {
4017
+ if (thinStripEls.has(isolatedBlocks[index].element)) isolatedBlocks.splice(index, 1)
4018
+ }
4019
+
4020
+ // —— 壳内右列文本(两列拓扑)——
4021
+ // 内容壳(面板壳/渐变壳)右半区独立成列的文本:>=2 个文本左边界落在壳宽 45% 之后且互相成列
4022
+ // (x 差 <=8、分属不同行),壳内左侧另有 >=2 个文本 → 该壳是「左列 + 右列」拓扑而非竖排单列。
4023
+ // 此前右列文本或被压成竖排堆叠(panelRows 路径),或整段落进主视觉死区不渲染(渐变壳路径)。
4024
+ const shellButtonTextEls = new Set(buttons.map((button) => button.sourceEl).filter(Boolean))
4025
+ const shellRightColumns = []
4026
+ for (const shape of [...new Set([...panelShapes, ...gradientPanels.map((gp) => gp.shapeEl)])]) {
4027
+ const sr = rect(shape)
4028
+ const inner = texts.filter((t) => thinInside(t, shape) && !shellButtonTextEls.has(t))
4029
+ if (inner.length < 4) continue
4030
+ const topY = Math.min(...inner.map((t) => rect(t).y || 0))
4031
+ // 标题行(与壳内首个文本同 band)整体保留为整行:名称 + 紧邻短标签不是右列成员
4032
+ const headerEls = inner.filter((t) => Math.abs((rect(t).y || 0) - topY) <= 8)
4033
+ const headerSet = new Set(headerEls)
4034
+ const splitX = (sr.x || 0) + (sr.width || 0) * 0.45
4035
+ const columns = []
4036
+ for (const t of inner.filter((item) => !headerSet.has(item) && (rect(item).x || 0) >= splitX)
4037
+ .slice().sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))) {
4038
+ const tx = rect(t).x || 0
4039
+ const column = columns.find((c) => Math.abs(c.x - tx) <= 8)
4040
+ if (column) column.items.push(t)
4041
+ else columns.push({ x: tx, items: [t] })
4042
+ }
4043
+ const best = columns
4044
+ .filter((column) => column.items.length >= 2
4045
+ && new Set(column.items.map((t) => Math.round(rect(t).y || 0))).size >= 2)
4046
+ .sort((a, b) => b.items.length - a.items.length)[0]
4047
+ if (!best) continue
4048
+ const bestSet = new Set(best.items)
4049
+ const left = inner.filter((t) => !headerSet.has(t) && !bestSet.has(t) && (rect(t).x || 0) < splitX)
4050
+ if (left.length < 2) continue
4051
+ shellRightColumns.push({
4052
+ shapeEl: shape,
4053
+ x: sr.x || 0, y: sr.y || 0, width: sr.width || 0, height: sr.height || 0,
4054
+ splitX: best.x,
4055
+ headerEls,
4056
+ texts: best.items.slice().sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0)).map((t) => ({
4057
+ text: textOf(t),
4058
+ color: ts(t).color,
4059
+ fontSize: ts(t).fontSize,
4060
+ fontWeight: ts(t).fontWeight,
4061
+ align: ts(t).textAlign,
4062
+ sourceEl: t,
4063
+ x: rect(t).x || 0, y: rect(t).y || 0,
4064
+ width: rect(t).width || 0, height: rect(t).height || 0,
4065
+ })),
4066
+ })
4067
+ }
4068
+ const shellRightColEls = new Set(shellRightColumns.flatMap((column) => column.texts.map((t) => t.sourceEl)))
4069
+ // 右列文本改由两列拓扑通道渲染:从辅助文案 / 副金额对中回收,避免竖排重影或甩到壳外
4070
+ for (let index = auxTexts.length - 1; index >= 0; index -= 1) {
4071
+ if (shellRightColEls.has(auxTexts[index].sourceEl)) auxTexts.splice(index, 1)
4072
+ }
4073
+ for (let index = subAmounts.length - 1; index >= 0; index -= 1) {
4074
+ const item = subAmounts[index]
4075
+ if (shellRightColEls.has(item.labelEl) || shellRightColEls.has(item.valueEl)) subAmounts.splice(index, 1)
4076
+ }
4077
+ // 右列文本 / 面板成员文本不得经 postAmountTexts 双份渲染(面板两列拓扑已整块接管)。
4078
+ // postAmountTexts 早于 shellRightColumns 计算,此前只从 auxTexts 回收——
4079
+ // listTitle 为 null 时 postAmountTexts 上界=∞,面板内文本漏入独立文本行→重影。
4080
+ for (let index = postAmountTexts.length - 1; index >= 0; index -= 1) {
4081
+ if (shellRightColEls.has(postAmountTexts[index].sourceEl)
4082
+ || panelTextConsumed.has(postAmountTexts[index].sourceEl)) postAmountTexts.splice(index, 1)
4083
+ }
4084
+
4085
+ // 选项组文本字符串集:midCluster 与选项组命中同一批文本时(图标+标签 chip 行),
4086
+ // midCluster 再渲染一遍即重影(chip 行双份)→ 选项组优先,midCluster 让位。
4087
+ const optionTextStrings = new Set([...optionTextSet].map((t) => textOf(t)))
4088
+ return {
4089
+ canvas: spec.size || { width: 375, height: 812 },
4090
+ artboardType: spec.artboardType || 'card',
4091
+ cardRect: rect(card), // 卡片容器几何(用于底部留白/顶部留白几何推导)
4092
+ cardFill: card.cardFill || null,
4093
+ // 契约 skills/shared/spec-contract.md §cards[].mask.radius:
4094
+ // null = 上游没找到带圆角的卡壳(_card_mask_info 返回 (None, fill)),
4095
+ // 下游兜底点在 v3_assemble.mjs 的 `sem.cardRadius || T.radius('card')`(契约规定的默认圆角)。
4096
+ // 取用必须用 ?? 而非 ||:`radius[0] === 0` 是合法值(真·直角卡),用 || 会被吞成 null
4097
+ // 从而错误地套上默认圆角。(存量数据无此值,属防御性修正。)
4098
+ cardRadius: card.mask?.radius?.[0] ?? null,
4099
+ tabs, buttons, optionGroups, primaryAmount, subAmounts, auxTexts, postAmountTexts, tabY, badgeRows,
4100
+ topTintBlobs,
4101
+ listTitle, sampleItems, allItems, viewMore, collapseBtn, countdown, topDecoration, formRows, midCluster: midCluster && (midCluster.items || []).length >= 2 && !(midCluster.items || []).every((it) => optionTextStrings.has(it.text)) ? midCluster : null, bindings,
4102
+ enumStatMap, // 枚举统计行预扫描结果(raw 文本 → {items, sep})
4103
+ cardBackgroundGradient: fullCardGradientShape?.style?.fill?.gradient || null,
4104
+ symbolMediaRows,
4105
+ isolatedBlocks,
4106
+ progressBars,
4107
+ dividers,
4108
+ panelRows,
4109
+ titleUnderlines,
4110
+ subAmountPanel,
4111
+ orphanSlices: (orphanSlices || []).filter((s) => !_vertexConsumedEls.has(s)),
4112
+ floatDecos: (floatDecos || []).filter((fd) => !_vertexConsumedEls.has(fd)),
4113
+ thinShellRows, thinShellStrips, shellRightColumns,
4114
+ gradientPanels, panelGroup,
4115
+ richBlocks,
4116
+ chartClusters,
4117
+ vertexClusters,
4118
+ shellLayers,
4119
+ contentBounds: (() => {
4120
+ const cardWidth = rect(card).width || 0
4121
+ const cardHeight = rect(card).height || 0
4122
+ const leaves = content.filter((element) => {
4123
+ const er = rect(element)
4124
+ const hasText = !!textOf(element)
4125
+ return hasText || !((er.width || 0) >= cardWidth * 0.95)
4126
+ })
4127
+ // bottom 仅取文本元素(shapes/slices 可能延伸到文本之下,导致底部留白被低估)
4128
+ const textLeaves = leaves.filter((e) => !!textOf(e))
4129
+ const bottomLeaves = textLeaves.length ? textLeaves : leaves
4130
+ // 例外:语义壳(渐变壳/面板组成员)会作为流内内容块真实渲染,壳底沿即内容底沿。
4131
+ // 壳内文本距壳底常有内边距——只看文本底会把底 padding 高估(卡片被撑高,如喜豆惠选
4132
+ // 产品卡文本底 1192 vs 壳底 1212 → 底距被算成 36 而非 16)。
4133
+ const shellBottoms = [
4134
+ ...(gradientPanels || []),
4135
+ ...((panelGroup && panelGroup.items) || []),
4136
+ ].filter((p) => bottomLeaves.some((el) => {
4137
+ const ey = rect(el).y || 0
4138
+ return ey >= (p.y || 0) - 2 && ey < (p.y || 0) + (p.height || 0) - 2
4139
+ })).map((p) => (p.y || 0) + (p.height || 0))
4140
+ return {
4141
+ top: leaves.length ? Math.min(...leaves.map((element) => rect(element).y || 0)) : rect(card).y || 0,
4142
+ bottom: bottomLeaves.length ? Math.max(
4143
+ ...bottomLeaves.map((element) => (rect(element).y || 0) + (rect(element).height || 0)),
4144
+ ...shellBottoms,
4145
+ ) :
4146
+ (rect(card).y || 0) + (rect(card).height || 0),
4147
+ left: textLeaves.length ? Math.min(...textLeaves.map((element) => rect(element).x || 0)) : rect(card).x || 0,
4148
+ right: textLeaves.length ? Math.max(...textLeaves.map((element) =>
4149
+ (rect(element).x || 0) + (rect(element).width || 0))) :
4150
+ (rect(card).x || 0) + (rect(card).width || 0),
4151
+ }
4152
+ })(),
4153
+ slices: slices.filter((s) => !_vertexConsumedEls.has(s)).map((s) => s),
4154
+ slicesExports: spec.slices || [],
4155
+ allTexts: texts.map((t) => ({ text: textOf(t), ...ts(t), sourceEl: t })),
4156
+ }
4157
+ }
4158
+
4159
+ /* ============================== 1.5 语义决策层(Phase 2:启发式提名,AI 终审) ============================== */
4160
+ // 原则:sem 里的元素引用(sourceEl)参与同一性排除(shell.texts.includes / rendered.has),
4161
+ // JSON 全量往返会破坏身份 → **禁止**把整份 sem 序列化后回灌。
4162
+ // 决策层只开放「与元素身份无关」的裁决字段;含元素引用的字段只允许 null(禁用提名)或经 relink 重建。
4163
+
4164
+ // 决策视图:sem 中可被 AI 审阅/修改的字段投影(元素引用全部剥离,JSON 安全)。
4165
+ export function semDecisionsView(sem) {
4166
+ const strip = (v) => JSON.parse(JSON.stringify(v, (k, val) => (k === 'sourceEl' ? undefined : val)))
4167
+ const textish = (e) => (e ? { text: textOf(e) } : null)
4168
+ return {
4169
+ artboardType: sem.artboardType || 'card',
4170
+ bindings: strip(sem.bindings || { entities: [], literalBind: {}, multiToken: {} }),
4171
+ countdown: sem.countdown
4172
+ ? strip({ ...sem.countdown, consumed: undefined })
4173
+ : null,
4174
+ cardFill: sem.cardFill || null,
4175
+ cardBackgroundGradient: sem.cardBackgroundGradient || null,
4176
+ listTitle: textish(sem.listTitle),
4177
+ viewMore: textish(sem.viewMore),
4178
+ collapseBtn: textish(sem.collapseBtn),
4179
+ primaryAmount: sem.primaryAmount ? { text: sem.primaryAmount.text } : null,
4180
+ formRows: (sem.formRows || []).map((f) => ({
4181
+ label: f.label ? (f.label.text || textOf(f.label)) : '',
4182
+ value: f.value ? (f.value.text || textOf(f.value)) : null,
4183
+ isPlaceholder: !!f.isPlaceholder,
4184
+ })),
4185
+ // 可禁用的提名字段(decisions.disable 数组里的名字会把 sem[k] 置 null)
4186
+ disableable: ['listTitle', 'viewMore', 'collapseBtn', 'countdown', 'topDecoration', 'midCluster'],
4187
+ }
4188
+ }
4189
+
4190
+ // countdown 决策回灌:AI 只给纯数据(numberText/unitText/几何/配色),
4191
+ // consumed(参与渲染排除的元素引用)按文本+几何从 spec 重链,否则数字与单位会双重渲染。
4192
+ function relinkCountdownConsumed(spec, cardIndex, cd) {
4193
+ const card = (spec.cards || [])[Number(cardIndex) || 0] || {}
4194
+ const all = (card.elements || []).length ? card.elements : (spec.elements || [])
4195
+ const texts = all.filter((e) => e.type === 'text' && textOf(e))
4196
+ const numEl = texts.find((t) => textOf(t) === String(cd.numberText))
4197
+ if (!numEl) return null // 无法定位数字元素 → 提名不可重建,按无倒计时处理
4198
+ const unitEl = texts.find((t) => t !== numEl && /^[Ss秒分]$/.test(textOf(t))
4199
+ && Math.abs((rect(t).x || 0) - (rect(numEl).x || 0)) < 40
4200
+ && Math.abs((rect(t).y || 0) - (rect(numEl).y || 0)) < 30)
4201
+ return { ...cd, consumed: [numEl, unitEl].filter(Boolean) }
4202
+ }
4203
+
4204
+ // 白名单覆盖:decisions 之外的字段一律忽略(几何证据保持脚本所有,AI 只改裁决)。
4205
+ export function applySemDecisions(sem, spec, cardIndex, decisions) {
4206
+ const d = decisions || {}
4207
+ if (typeof d.artboardType === 'string' && d.artboardType) sem.artboardType = d.artboardType
4208
+ if (d.bindings && typeof d.bindings === 'object') sem.bindings = d.bindings
4209
+ if ('countdown' in d) sem.countdown = d.countdown ? relinkCountdownConsumed(spec, cardIndex, d.countdown) : null
4210
+ if ('cardFill' in d) sem.cardFill = d.cardFill
4211
+ if ('cardBackgroundGradient' in d) sem.cardBackgroundGradient = d.cardBackgroundGradient
4212
+ for (const k of d.disable || []) { if (k in sem) sem[k] = null }
4213
+ return sem
4214
+ }