cardbuddy 0.13.3 → 0.14.1

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 (61) hide show
  1. package/README.md +7 -3
  2. package/dist/cli.js +4 -4
  3. package/dist/config.js +7 -7
  4. package/dist/init.js +15 -4
  5. package/dist/portGuard.js +2 -2
  6. package/dist/preview-editor/assets/index-DnXqKrCc.js +307 -0
  7. package/dist/preview-editor/assets/index-cbTCHp8J.css +1 -0
  8. package/dist/preview-editor/index.html +2 -2
  9. package/dist/standalone.js +14 -6
  10. package/dist/vendored/card-editor/api.js +6 -6
  11. package/dist/vendored/mcp/client.mjs +53 -3
  12. package/dist/vendored/mcp/tools.mjs +17 -1
  13. package/dist/vendored/server/core/ai-chat.js +1443 -271
  14. package/dist/vendored/server/core/cardFiles.js +271 -39
  15. package/dist/vendored/server/core/cardImportMeta.js +16 -0
  16. package/dist/vendored/server/core/chatHistory.js +122 -0
  17. package/dist/vendored/server/core/index.js +10 -3
  18. package/dist/vendored/server/core/turnJournal.js +386 -0
  19. package/dist/vendored/skills/card-skill-trainer/SKILL.md +399 -222
  20. package/dist/vendored/skills/card-skill-trainer/scripts/build_dataset.py +103 -19
  21. package/dist/vendored/skills/card-skill-trainer/scripts/diagnose_case.mjs +378 -200
  22. package/dist/vendored/skills/card-skill-trainer/scripts/lib/runs.mjs +147 -0
  23. package/dist/vendored/skills/card-skill-trainer/scripts/lib/scoring.mjs +297 -4
  24. package/dist/vendored/skills/card-skill-trainer/scripts/plan_next_round.mjs +484 -0
  25. package/dist/vendored/skills/card-skill-trainer/scripts/round_diff.mjs +77 -96
  26. package/dist/vendored/skills/card-skill-trainer/scripts/run_round.mjs +237 -62
  27. package/dist/vendored/skills/card-skill-trainer/scripts/score_round.mjs +74 -35
  28. package/dist/vendored/skills/dsl-card-ai/SKILL.md +15 -2
  29. package/dist/vendored/skills/dsl-card-generator/SKILL.md +5 -3
  30. package/dist/vendored/skills/dsl-card-generator/references/generation_pitfalls.md +120 -0
  31. package/dist/vendored/skills/dsl-card-generator/references/previewer_contract.md +10 -0
  32. package/dist/vendored/skills/dsl-card-generator/references/quickref.md +15 -1
  33. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/canvas-radar.json +2 -2
  34. package/dist/vendored/skills/dsl-card-generator/references/recipe_snippets/scatter-pill-band.json +10 -0
  35. package/dist/vendored/skills/dsl-card-generator/references/recipes.md +43 -1
  36. package/dist/vendored/skills/dsl-card-generator/references/rules.md +33 -6
  37. package/dist/vendored/skills/dsl-card-generator/references/slices_convention.md +12 -5
  38. package/dist/vendored/skills/dsl-card-generator/scripts/add_component.mjs +5 -10
  39. package/dist/vendored/skills/dsl-card-generator/scripts/crop_slices.mjs +47 -10
  40. package/dist/vendored/skills/dsl-card-generator/scripts/expand_ir.mjs +12 -4
  41. package/dist/vendored/skills/dsl-card-generator/scripts/lib/design_sample.mjs +388 -328
  42. package/dist/vendored/skills/dsl-card-generator/scripts/lib/slice_pixels.mjs +109 -0
  43. package/dist/vendored/skills/dsl-card-generator/scripts/render_card.mjs +45 -7
  44. package/dist/vendored/skills/dsl-card-generator/scripts/route_input.mjs +7 -3
  45. package/dist/vendored/skills/dsl-card-generator/scripts/spec_audit.mjs +110 -23
  46. package/dist/vendored/skills/dsl-card-generator/scripts/spec_to_schema_v3.mjs +2954 -127
  47. package/dist/vendored/skills/dsl-card-generator/scripts/spec_tree.mjs +128 -3
  48. package/dist/vendored/skills/dsl-card-generator/scripts/validate_schema.py +28 -0
  49. package/dist/vendored/skills/finmall-card-sync/SKILL.md +1 -1
  50. package/dist/vendored/skills/finmall-session-navigator/SKILL.md +1 -1
  51. package/dist/vendored/skills/finmall-session-navigator/scripts/session-manager.mjs +1 -1
  52. package/dist/vendored/skills/shared/finmall-config.mjs +4 -4
  53. package/dist/vendored/skills/sketch-spec-to-card/SKILL.md +2 -2
  54. package/dist/vendored/skills/sketch-spec-to-card/scripts/parse_meaxure.py +29 -2
  55. package/dist/vendored/tools/finmall-env.mjs +6 -6
  56. package/package.json +1 -1
  57. package/dist/preview-editor/assets/index-CQScMhyg.css +0 -1
  58. package/dist/preview-editor/assets/index-pW1J4vP3.js +0 -276
  59. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r038n.json +0 -43
  60. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r039n.json +0 -47
  61. package/dist/vendored/skills/card-skill-trainer/baselines/v3-r050n.json +0 -48
@@ -19,7 +19,7 @@ import path from 'node:path'
19
19
  import { fileURLToPath } from 'node:url'
20
20
  import { makeMinComponent } from './lib/scaffold.mjs'
21
21
  import { T } from './lib/layout-tokens.mjs'
22
- import { sampleTopBand, sampleRegionColor, sampleRegionEdgeColor, sampleCardBodyColor, crossCheckTextColor, sampleOutlineRing } from './lib/design_sample.mjs'
22
+ import { sampleTopBand, sampleRegionColor, sampleRegionEdgeColor, sampleCardBodyColor, crossCheckTextColor, sampleOutlineRing, sampleBackdropBands } from './lib/design_sample.mjs'
23
23
  import { resolveSliceExport, sliceRefPath, sliceFlatName, stripScale } from './lib/slicePath.mjs'
24
24
  import { cardsDir, metaDir, slicesDir } from './lib/paths.mjs'
25
25
 
@@ -104,17 +104,21 @@ function boundProp(itemKey, expr, defaultValue = '') {
104
104
  return { itemId: '', itemKey, itemName: itemKey, itemType: '', itemValueFrom: '1', itemValue: expr, itemDefaultValue: defaultValue }
105
105
  }
106
106
  // v-show 条件渲染指令:showType=01 + condition.expression(如 "$afford != ''")
107
- function vShowDirective(expression) {
107
+ function vShowDirective(expression, name) {
108
108
  const prop = (itemId, itemKey, itemValue) => ({
109
109
  itemId, itemKey, itemName: itemKey, itemType: '', itemValueFrom: '0', itemValue, itemDefaultValue: itemValue,
110
110
  })
111
+ const expr = String(expression || '')
112
+ // validate_schema.py 铁律:condition.name 必填非空;表达式 <15 字符时 name 与表达式一致,
113
+ // >=15 字符给可读显示名(调用方可传语义名,缺省回退通用名)。
114
+ const displayName = expr.length < 15 ? expr : (String(name || '').trim() || '显示条件')
111
115
  return {
112
116
  itemId: '', itemKey: 'v-show', itemName: 'v-show', itemType: '', itemValueFrom: '0',
113
117
  itemValue: {}, itemDefaultValue: {},
114
118
  itemChildren: {
115
119
  properties: [
116
120
  prop('001', 'showType', '01'),
117
- prop('002', 'condition', { expression }),
121
+ prop('002', 'condition', { expression: expr, name: displayName }),
118
122
  ],
119
123
  },
120
124
  }
@@ -442,7 +446,11 @@ function isMaskedOnlyText(text) {
442
446
  * 关键纪律:**不能根据 spec 原始文本写死前缀负号**——同一变量在不同数据下可能正负切换。
443
447
  * 必须在表达式里运行时判断 $var 的正负:负数显示 '-¥abs',正数显示 '¥amount'。
444
448
  */
445
- function amountExpr(varName, srcText) {
449
+ function currencyPrefixOf(srcText) {
450
+ const curMatch = String(srcText || '').replace(/^[-+]/, '').match(/^[^\d\-(),.\s]{1,4}(?=[\d(])/u)
451
+ return curMatch && /\p{Sc}/u.test(curMatch[0]) ? curMatch[0] : ''
452
+ }
453
+ function amountExpr(varName, srcText, opts = {}) {
446
454
  // 尊重设计稿原文:
447
455
  // 原文含 ¥/¥ 符号 → '¥'+千分号(如"¥563.80")
448
456
  // 纯数字无符号 → 仅千分号(如 "2000" 配合相邻 "万元")
@@ -454,8 +462,7 @@ function amountExpr(varName, srcText) {
454
462
  const hasYuanUnit = !!unitMatch
455
463
  // 货币前缀取设计稿原文(任意 Unicode 货币符号,含 "HK$"/"US$" 字母+符号组合),不写死某一种
456
464
  // 负号不是货币前缀的一部分:先剥离首符号再取前缀("-¥2,000.00" 否则丢 ¥)。
457
- const curMatch = String(srcText || '').replace(/^[-+]/, '').match(/^[^\d\-(),.\s]{1,4}(?=[\d(])/u)
458
- const curPrefix = curMatch && /\p{Sc}/u.test(curMatch[0]) ? curMatch[0] : null
465
+ const curPrefix = currencyPrefixOf(srcText)
459
466
  const hasCurrency = !!curPrefix
460
467
  const srcNum = srcText != null ? String(srcText).replace(/[^\d,.-]/g, '') : ''
461
468
  const srcHasDecimals = /\.\d/.test(srcNum)
@@ -470,7 +477,8 @@ function amountExpr(varName, srcText) {
470
477
  const unit = JSON.stringify(unitMatch[2])
471
478
  return `${sign} + ${abs} + ${unit}`
472
479
  }
473
- if (hasCurrency) return `${sign} + ${JSON.stringify(curPrefix)} + ${abs}`
480
+ // 列表行模板共用一套表达式:货币前缀必须逐行数据驱动($amtCurN),否则首行符号套到全行
481
+ if (opts.prefixVar) return `${sign} + $${opts.prefixVar} + ${abs}`
474
482
  return `${sign} + ${abs}`
475
483
  }
476
484
 
@@ -745,6 +753,13 @@ function ensureVisibleTextColor(textColor, bgColors, fallback) {
745
753
  }
746
754
  return textColor
747
755
  }
756
+ /** 颜色调浅:与白色按比例混合(ratio=颜色占比),用于「描边同色 fill」标注缺陷的浅底还原 */
757
+ function tintHex(hex, ratio = 0.1) {
758
+ const h = String(hex || '').replace('#', '')
759
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) return hex
760
+ const mix = (i) => Math.round(parseInt(h.slice(i, i + 2), 16) * ratio + 255 * (1 - ratio))
761
+ return '#' + [0, 2, 4].map((i) => mix(i).toString(16).padStart(2, '0')).join('').toUpperCase()
762
+ }
748
763
  function isBg(e) {
749
764
  const r = rect(e)
750
765
  return (r.width || 0) >= 340 && (r.height || 0) >= 600
@@ -752,12 +767,363 @@ function isBg(e) {
752
767
  function sameRow(a, b, tol = 12) { return Math.abs((rect(a).y || 0) - (rect(b).y || 0)) <= tol }
753
768
 
754
769
 
770
+ /** 矩形并集 / 包含判定(图表簇几何用) */
771
+ function unionRects(items) {
772
+ const xs = items.map((e) => rect(e).x || 0)
773
+ const ys = items.map((e) => rect(e).y || 0)
774
+ const rs = items.map((e) => (rect(e).x || 0) + (rect(e).width || 0))
775
+ const bs = items.map((e) => (rect(e).y || 0) + (rect(e).height || 0))
776
+ const x = Math.min(...xs); const y = Math.min(...ys)
777
+ return { x, y, width: Math.max(...rs) - x, height: Math.max(...bs) - y }
778
+ }
779
+ function rectInside(outer, r, pad = 0) {
780
+ const pl = typeof pad === 'number' ? { l: pad, r: pad, t: pad, b: pad } : pad
781
+ return (r.x || 0) >= (outer.x || 0) - (pl.l || 0)
782
+ && (r.y || 0) >= (outer.y || 0) - (pl.t || 0)
783
+ && ((r.x || 0) + (r.width || 0)) <= (outer.x || 0) + (outer.width || 0) + (pl.r || 0)
784
+ && ((r.y || 0) + (r.height || 0)) <= (outer.y || 0) + (outer.height || 0) + (pl.b || 0)
785
+ }
786
+
787
+ /** 柱状图矢量簇识别(纯几何,不依赖业务词):
788
+ * 同基线(底沿 ±3)窄柱 ≥4 + 横向近等距 + 网格线/轴标签佐证 → 整簇(柱+网格线+簇内文本)
789
+ * 交给 Canvas 自绘回放。DSL 无图表组件,此前柱体被静默丢弃、网格线被当 Divider 错排。 */
790
+ export function detectChartClusters(content) {
791
+ const shapes = content.filter((e) => e.type === 'shape')
792
+ const texts = content.filter((e) => e.type === 'text' && textOf(e))
793
+ const bars = shapes.filter((e) => {
794
+ const r = rect(e); const fill = e.style?.fill
795
+ if (!fill || (!fill.color && !fill.gradient)) return false
796
+ if (radiusMax(e.style) >= 8) return false
797
+ return (r.width || 0) >= 4 && (r.width || 0) <= 48 && (r.height || 0) >= 4 && (r.height || 0) <= 260
798
+ })
799
+ const byBottom = []
800
+ for (const bar of bars.slice().sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))) {
801
+ const bottom = (rect(bar).y || 0) + (rect(bar).height || 0)
802
+ let group = byBottom.find((item) => Math.abs(item.bottom - bottom) <= 3)
803
+ if (!group) { group = { bottom, members: [] }; byBottom.push(group) }
804
+ group.members.push(bar)
805
+ }
806
+ const clusters = []
807
+ for (const group of byBottom) {
808
+ if (group.members.length < 4) continue
809
+ const xs = group.members.map((m) => rect(m).x || 0)
810
+ const gaps = xs.slice(1).map((x, i) => x - xs[i])
811
+ const gapAvg = gaps.reduce((a, b) => a + b, 0) / Math.max(1, gaps.length)
812
+ if (gaps.some((gap) => Math.abs(gap - gapAvg) > Math.max(6, gapAvg * 0.6))) continue
813
+ const barBox = unionRects(group.members)
814
+ const grid = shapes.filter((e) => {
815
+ const r = rect(e)
816
+ if ((r.height || 0) > 2 || (r.width || 0) < 60) return false
817
+ const overlap = Math.min((r.x || 0) + (r.width || 0), barBox.x + barBox.width) - Math.max(r.x || 0, barBox.x)
818
+ return overlap >= barBox.width * 0.6 && (r.y || 0) >= barBox.y - 260 && (r.y || 0) <= barBox.y + barBox.height + 4
819
+ })
820
+ const seedBox = unionRects([...group.members, ...grid])
821
+ const labels = texts.filter((e) => rectInside(seedBox, rect(e), { l: 48, r: 12, t: 30, b: 36 }))
822
+ if (grid.length < 3 && labels.length < 4) continue
823
+ const members = [...group.members, ...grid]
824
+ const box = unionRects([...members, ...labels])
825
+ clusters.push({ kind: 'bar', members, texts: labels, x: box.x, y: box.y, width: box.width, height: box.height })
826
+ }
827
+ return clusters
828
+ }
829
+
830
+ /** 柱状图簇 → Canvas onMeasured 绘图代码(坐标相对簇原点;只用代理白名单 API)。
831
+ * 圆角矩形无 roundRect 白名单 → 四角 arc 拼路径;网格线走 stroke;标签走 fillText。 */
832
+ /** 图表绘制层序(铁律 R62):坐标系 / 参考系线条(网格线、轴线、刻度线、雷达环与辐射线、环形轨道)
833
+ * 画在**最底层**,数据标记(柱 / 折线 / 多边形 / 扇区)在其上,文字标签最上层。
834
+ * Canvas 是即时模式绘制——后画的压先画的,所以出码顺序必须 = 底 → 顶。
835
+ * spec 元素顺序**不可照抄**:标注导出常把网格线排在数据图元之后(assetAllocRadar 雷达
836
+ * 34-39 是 #DCDCDC 网格/辐射线、40 才是数据多边形),直出会把参考线盖在数据上。 */
837
+ function chartPaintLayers(members, cluster) {
838
+ const backdrop = []
839
+ const axis = []
840
+ const marks = []
841
+ const cw = cluster?.width || 0
842
+ const ch = cluster?.height || 0
843
+ for (const m of members) {
844
+ const r = rect(m)
845
+ const w = r.width || 0
846
+ const h = r.height || 0
847
+ // 细线(任一维 ≤2px)= 网格线 / 轴线 / 刻度线 → 底层
848
+ if (h <= 2 || w <= 2) { axis.push(m); continue }
849
+ // 大面积填充(覆盖簇 bbox 一半以上)= 绘图区底板 → 更底层
850
+ const fill = m.style?.fill
851
+ if (fill && (fill.color || fill.gradient) && cw && ch && w >= cw * 0.5 && h >= ch * 0.5) { backdrop.push(m); continue }
852
+ marks.push(m)
853
+ }
854
+ return { backdrop, axis, marks }
855
+ }
856
+
857
+ /** 数据标记半透明时把 opacity 乘进颜色 / 渐变(ctx 代理白名单无 globalAlpha),底层网格才透得出来。 */
858
+ function chartPaintColor(color, opacity = 1) {
859
+ const op = Number(opacity)
860
+ const k = Number.isFinite(op) && op > 0 && op < 1 ? op : 1
861
+ const c = String(color || '').trim()
862
+ const m = c.match(/^rgba?\(([^,]+),([^,]+),([^,]+)(?:,([^)]+))?\)$/i)
863
+ if (m) return `rgba(${m[1].trim()},${m[2].trim()},${m[3].trim()},${Number(m[4] ?? 1) * k})`
864
+ return hexToRgba(color, k)
865
+ }
866
+
867
+ /** 柱状图簇 → flex 原生组件树(柱=色块 Column、值/轴/类目标签=真实 Text、网格线=硬停渐变背景)。
868
+ * Canvas 自绘会让标注文本在组件计数中消失(文本召回/组件数被系统性低估),且字体渲染与设计稿偏差大;
869
+ * 渲染器无绝对定位,故用「轴列 + 槽位列」flex 结构按 spec 几何精确回放:
870
+ * 槽位边界取类目标签中心中点,柱列宽=柱宽、值标签靠 overflow:visible 居中溢出。
871
+ * 返回 null 表示该簇不满足柱状图形态(调用方回退 Canvas)。 */
872
+ export function buildFlexBarChart(cluster) {
873
+ const members = cluster.members || []
874
+ const bars = members.filter((m) => {
875
+ const r = rect(m); const h = r.height || 0; const w = r.width || 0
876
+ return m.style?.fill?.color && h >= 4 && w >= 6 && w <= 48
877
+ })
878
+ const gridlines = members.filter((m) => (rect(m).height || 0) <= 2 && (rect(m).width || 0) >= 100)
879
+ const texts = cluster.texts || []
880
+ if (bars.length < 4 || gridlines.length < 2 || !texts.length) return null
881
+ const baseline = Math.max(...bars.map((b) => (rect(b).y || 0) + (rect(b).height || 0)))
882
+ const plotLeft = Math.min(...bars.map((b) => rect(b).x || 0))
883
+ const plotRight = Math.max(...gridlines.map((g) => (rect(g).x || 0) + (rect(g).width || 0)),
884
+ ...bars.map((b) => (rect(b).x || 0) + (rect(b).width || 0)))
885
+ const plotTop = Math.min(...gridlines.map((g) => rect(g).y || 0))
886
+ const axisTexts = texts.filter((t) => ((rect(t).x || 0) + (rect(t).width || 0)) <= plotLeft - 4)
887
+ .sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
888
+ const nameTexts = texts.filter((t) => (rect(t).y || 0) >= baseline + 2)
889
+ .sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
890
+ const valueTexts = texts.filter((t) => !axisTexts.includes(t) && !nameTexts.includes(t))
891
+ if (!axisTexts.length || !nameTexts.length) return null
892
+ // 槽位:类目标签中心的中点切分绘图区
893
+ const centers = nameTexts.map((t) => (rect(t).x || 0) + (rect(t).width || 0) / 2)
894
+ const bounds = [plotLeft]
895
+ for (let i = 1; i < centers.length; i++) bounds.push(Math.round((centers[i - 1] + centers[i]) / 2))
896
+ bounds.push(plotRight)
897
+ const slots = []
898
+ for (let i = 0; i < nameTexts.length; i++) {
899
+ const slotBars = bars.filter((b) => {
900
+ const cx = (rect(b).x || 0) + (rect(b).width || 0) / 2
901
+ return cx >= bounds[i] - 1 && cx <= bounds[i + 1] + 1
902
+ }).sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
903
+ if (!slotBars.length) return null
904
+ slots.push({ name: nameTexts[i], x: bounds[i], width: bounds[i + 1] - bounds[i], bars: slotBars })
905
+ }
906
+ const plotH = Math.round(baseline - plotTop)
907
+ const axisTop = rect(axisTexts[0]).y || 0
908
+ const axisBottom = Math.max(...axisTexts.map((t) => (rect(t).y || 0) + (rect(t).height || 0)))
909
+ const axisRight = Math.max(...axisTexts.map((t) => (rect(t).x || 0) + (rect(t).width || 0)))
910
+ const axisGap = Math.max(0, Math.round(plotLeft - axisRight))
911
+ const nameTop = Math.min(...nameTexts.map((t) => rect(t).y || 0))
912
+ const nameGap = Math.max(0, Math.round(nameTop - baseline))
913
+ // 网格线硬停渐变(坐标系线条必须在绘制层最底部 → 作背景,柱/标签在其上)
914
+ const gl = gridlines.map((g) => rect(g).y || 0).sort((a, b) => a - b)
915
+ const glColor = (() => {
916
+ const g0 = gridlines[0]
917
+ const stops = g0.style?.fill?.gradient?.stops || g0.style?.fill?.gradient?.colors || null
918
+ if (Array.isArray(stops) && stops[0]?.color) return hexToRgba(stops[0].color)
919
+ if (g0.style?.fill?.color) return hexToRgba(g0.style.fill.color)
920
+ return 'rgba(244,244,247,1)'
921
+ })()
922
+ const gradParts = []
923
+ let prev = 0
924
+ for (const y of gl) {
925
+ const o = Math.max(0, Math.round(y - plotTop))
926
+ if (o > prev) gradParts.push(`rgba(0,0,0,0) ${prev}px`, `${glColor} ${o}px`)
927
+ gradParts.push(`${glColor} ${o}px`, `${glColor} ${o + 1}px`)
928
+ prev = o + 1
929
+ }
930
+ gradParts.push(`rgba(0,0,0,0) ${prev}px`, `rgba(0,0,0,0) ${plotH}px`)
931
+ // —— 组装 ——
932
+ const chartRow = container('Row', { width: '100%', direction: 'row', align: 'flex-start', gap: String(axisGap) })
933
+ const axisCol = container('Column', {
934
+ direction: 'column', justify: 'space-between', align: 'flex-end', flexShrink: 0,
935
+ width: String(Math.round(axisRight - (rect(axisTexts[0]).x || 0))),
936
+ })
937
+ ensureStyleGroup(axisCol, 'rect')
938
+ setStyleChild(axisCol, 'rect', 'height', String(Math.round(axisBottom - axisTop)))
939
+ for (const t of axisTexts) {
940
+ const node = textFromSpec({ ...t, text: textOf(t), color: ts(t).color, fontSize: ts(t).fontSize, fontWeight: ts(t).fontWeight, align: 'right' })
941
+ ensureStyleGroup(node, 'flex')
942
+ setStyleChild(node, 'flex', 'flexShrink', '0')
943
+ axisCol.slots[0].components.push(node)
944
+ }
945
+ chartRow.slots[0].components.push(axisCol)
946
+ const plotCol = container('Column', {
947
+ direction: 'column', align: 'stretch', gap: '0', flexShrink: 0,
948
+ width: String(Math.round(plotRight - plotLeft)),
949
+ padding: `${Math.max(0, Math.round(plotTop - axisTop))} 0 0 0`,
950
+ })
951
+ const gridArea = container('Row', { direction: 'row', align: 'flex-end', gap: '0', width: '100%' })
952
+ ensureStyleGroup(gridArea, 'rect')
953
+ setStyleChild(gridArea, 'rect', 'height', String(plotH))
954
+ ensureStyleGroup(gridArea, 'background')
955
+ setStyleChild(gridArea, 'background', 'backgroundType', '03')
956
+ setStyleChild(gridArea, 'background', 'backgroundGradient', `linear-gradient(to bottom, ${gradParts.join(', ')})`)
957
+ for (const slot of slots) {
958
+ const padLeft = Math.max(0, Math.round((rect(slot.bars[0]).x || 0) - slot.x))
959
+ const slotCol = container('Column', {
960
+ direction: 'column', justify: 'flex-end', align: 'flex-start', flexShrink: 0,
961
+ width: String(slot.width), padding: `0 0 0 ${padLeft}`,
962
+ })
963
+ ensureStyleGroup(slotCol, 'rect')
964
+ setStyleChild(slotCol, 'rect', 'height', String(plotH))
965
+ // 柱列宽 = max(柱宽, 值标签宽):渲染器 fit-content 受父宽限制会把标签折行(h 爆成 3 行);
966
+ // 相邻标签水平重叠时用负 marginLeft 拉回设计中心(flex 行内唯一可重叠手段)。
967
+ const barRow = container('Row', { direction: 'row', align: 'flex-end', gap: '0' })
968
+ const firstBarX = rect(slot.bars[0]).x || 0
969
+ let barCursor = 0
970
+ for (const bar of slot.bars) {
971
+ const br = rect(bar)
972
+ const label = valueTexts.find((t) => {
973
+ const cx = (br.x || 0) + (br.width || 0) / 2
974
+ const tcx = (rect(t).x || 0) + (rect(t).width || 0) / 2
975
+ return Math.abs(tcx - cx) <= Math.max(br.width || 0, (rect(t).width || 0) / 2)
976
+ && (rect(t).y || 0) + (rect(t).height || 0) <= (br.y || 0) + 2
977
+ })
978
+ const barCenter = (br.x || 0) + (br.width || 0) / 2
979
+ // 渲染字形比标注 rect 略宽(PingFang 度量差):不留余量会折行(h 16→30、整列上移)
980
+ const colW = Math.max(Math.round(br.width || 0), label ? Math.round(rect(label).width || 0) + 4 : 0)
981
+ const desiredLeft = Math.round(barCenter - colW / 2) - firstBarX
982
+ const barCol = container('Column', {
983
+ direction: 'column', justify: 'flex-end', align: 'center', gap: '0', flexShrink: 0,
984
+ width: String(colW),
985
+ })
986
+ if (desiredLeft !== barCursor) {
987
+ ensureStyleGroup(barCol, 'margin')
988
+ setStyleChild(barCol, 'margin', 'marginType', '1')
989
+ setStyleChild(barCol, 'margin', 'marginLeft', String(desiredLeft - barCursor))
990
+ setStyleChild(barCol, 'margin', 'marginTop', '0')
991
+ setStyleChild(barCol, 'margin', 'marginRight', '0')
992
+ setStyleChild(barCol, 'margin', 'marginBottom', '0')
993
+ }
994
+ barCursor = desiredLeft + colW
995
+ if (label) {
996
+ const ln = textFromSpec({ text: textOf(label), color: ts(label).color, fontSize: ts(label).fontSize, fontWeight: ts(label).fontWeight, align: 'center' })
997
+ ensureStyleGroup(ln, 'flex')
998
+ setStyleChild(ln, 'flex', 'flexShrink', '0')
999
+ barCol.slots[0].components.push(ln)
1000
+ }
1001
+ const block = container('Column', { direction: 'column', flexShrink: 0 })
1002
+ ensureStyleGroup(block, 'rect')
1003
+ ensureStyleGroup(block, 'background')
1004
+ ensureStyleGroup(block, 'borderRadius')
1005
+ setStyleChild(block, 'rect', 'width', String(Math.round(br.width || 0)))
1006
+ setStyleChild(block, 'rect', 'height', String(Math.round(br.height || 0)))
1007
+ setStyleChild(block, 'background', 'backgroundType', '01')
1008
+ setStyleChild(block, 'background', 'backgroundColor', hexToRgba(bar.style.fill.color))
1009
+ setStyleChild(block, 'borderRadius', 'borderRadiusType', '1')
1010
+ setStyleChild(block, 'borderRadius', 'borderTopLeftRadius', '2')
1011
+ setStyleChild(block, 'borderRadius', 'borderTopRightRadius', '2')
1012
+ setStyleChild(block, 'borderRadius', 'borderBottomLeftRadius', '0')
1013
+ setStyleChild(block, 'borderRadius', 'borderBottomRightRadius', '0')
1014
+ barCol.slots[0].components.push(block)
1015
+ barRow.slots[0].components.push(barCol)
1016
+ }
1017
+ slotCol.slots[0].components.push(barRow)
1018
+ gridArea.slots[0].components.push(slotCol)
1019
+ }
1020
+ plotCol.slots[0].components.push(gridArea)
1021
+ const nameRow = container('Row', { direction: 'row', align: 'flex-start', gap: '0', width: '100%', padding: `${nameGap} 0 0 0` })
1022
+ for (const slot of slots) {
1023
+ const slotCol = container('Column', { direction: 'column', align: 'flex-start', flexShrink: 0, width: String(slot.width) })
1024
+ // 类目标签可能比柱对宽(日常开销 48 > 柱对 42):居中行按标签宽撑开 + 负 margin 锚柱对中心,
1025
+ // 槽位列 overflow visible 放行溢出(flex 行宽不变,邻槽不受影响)
1026
+ ensureStyleGroup(slotCol, 'other')
1027
+ setStyleChild(slotCol, 'other', 'overflow', 'visible')
1028
+ const pairW = Math.round(((rect(slot.bars[slot.bars.length - 1]).x || 0) + (rect(slot.bars[slot.bars.length - 1]).width || 0)) - (rect(slot.bars[0]).x || 0))
1029
+ const nameW = Math.round(rect(slot.name).width || 0) + 4
1030
+ const rowW = Math.max(pairW, nameW)
1031
+ const pairCenter = Math.round((rect(slot.bars[0]).x || 0) + pairW / 2)
1032
+ const centerRow = container('Row', { direction: 'row', justify: 'center', align: 'center', width: String(rowW) })
1033
+ const rowLeft = pairCenter - Math.round(rowW / 2) - slot.x
1034
+ if (rowLeft !== 0) {
1035
+ ensureStyleGroup(centerRow, 'margin')
1036
+ setStyleChild(centerRow, 'margin', 'marginType', '1')
1037
+ setStyleChild(centerRow, 'margin', 'marginLeft', String(rowLeft))
1038
+ setStyleChild(centerRow, 'margin', 'marginTop', '0')
1039
+ setStyleChild(centerRow, 'margin', 'marginRight', '0')
1040
+ setStyleChild(centerRow, 'margin', 'marginBottom', '0')
1041
+ }
1042
+ const nn = textFromSpec({ text: textOf(slot.name), color: ts(slot.name).color, fontSize: ts(slot.name).fontSize, fontWeight: ts(slot.name).fontWeight, align: 'center' })
1043
+ ensureStyleGroup(nn, 'flex')
1044
+ setStyleChild(nn, 'flex', 'flexShrink', '0')
1045
+ centerRow.slots[0].components.push(nn)
1046
+ slotCol.slots[0].components.push(centerRow)
1047
+ nameRow.slots[0].components.push(slotCol)
1048
+ }
1049
+ plotCol.slots[0].components.push(nameRow)
1050
+ chartRow.slots[0].components.push(plotCol)
1051
+ return chartRow
1052
+ }
1053
+ export function chartBarCanvasCode(cluster) {
1054
+ const ox = cluster.x; const oy = cluster.y
1055
+ const n = (v) => Math.round(Number(v) * 100) / 100
1056
+ const out = []
1057
+ out.push('var self = this;')
1058
+ // event.canvas 是画布包装对象(非 ctx):export-proven 范式先 getContext('2d') 拿代理 ctx
1059
+ out.push('var canvas = event.canvas;')
1060
+ out.push("var cv = canvas.getContext('2d');")
1061
+ out.push('canvas.width = event.width;')
1062
+ out.push('canvas.height = event.height;')
1063
+ out.push('cv.clearRect(0,0,event.width,event.height);')
1064
+ out.push('var rr=function(x,y,w,h,tl,tr,br,bl){cv.beginPath();cv.moveTo(x+tl,y);'
1065
+ + 'cv.lineTo(x+w-tr,y);cv.arc(x+w-tr,y+tr,tr,Math.PI*1.5,Math.PI*2);'
1066
+ + 'cv.lineTo(x+w,y+h-br);cv.arc(x+w-br,y+h-br,br,0,Math.PI*0.5);'
1067
+ + 'cv.lineTo(x+bl,y+h);cv.arc(x+bl,y+h-bl,bl,Math.PI*0.5,Math.PI);'
1068
+ + 'cv.lineTo(x,y+tl);cv.arc(x+tl,y+tl,tl,Math.PI,Math.PI*1.5);cv.closePath();};')
1069
+ // 出码顺序 = 绘制层序(底 → 顶):绘图区底板 → 网格线/轴线 → 数据标记 → 文字标签
1070
+ const layers = chartPaintLayers(cluster.members, cluster)
1071
+ for (const m of [...layers.backdrop, ...layers.axis, ...layers.marks]) {
1072
+ const r = rect(m)
1073
+ const x = n((r.x || 0) - ox); const y = n((r.y || 0) - oy)
1074
+ const w = n(r.width || 0); const h = n(r.height || 0)
1075
+ const fill = m.style?.fill
1076
+ const border = m.style?.border
1077
+ const opacity = m.style?.opacity ?? 1
1078
+ if (fill && (fill.color || fill.gradient)) {
1079
+ if (fill.gradient && (fill.gradient.stops || []).length) {
1080
+ const stops = fill.gradient.stops
1081
+ out.push(`var g=cv.createLinearGradient(${x},${y},${x},${y + h});`)
1082
+ for (const stop of stops) {
1083
+ out.push(`g.addColorStop(${n(stop.position || 0)},'${chartPaintColor(stop.color, opacity)}');`)
1084
+ }
1085
+ out.push('cv.fillStyle=g;')
1086
+ } else {
1087
+ out.push(`cv.fillStyle='${chartPaintColor(fill.color, opacity)}';`)
1088
+ }
1089
+ const rad = m.style?.radius?.borderRadius
1090
+ const arr = Array.isArray(rad) ? rad.map((v) => n(v) || 0) : [n(rad) || 0, n(rad) || 0, n(rad) || 0, n(rad) || 0]
1091
+ out.push(`rr(${x},${y},${w},${h},${arr[0]},${arr[1]},${arr[2]},${arr[3]});cv.fill();`)
1092
+ } else if (border && border.isBorder) {
1093
+ out.push(`cv.strokeStyle='${chartPaintColor(border.color, opacity)}';`)
1094
+ out.push(`cv.lineWidth=${Math.max(1, n(border.width || r.height || 1))};`)
1095
+ out.push(`cv.beginPath();cv.moveTo(${x},${n(y + h / 2)});cv.lineTo(${n(x + w)},${n(y + h / 2)});cv.stroke();`)
1096
+ }
1097
+ }
1098
+ for (const t of cluster.texts) {
1099
+ const r = rect(t); const st = ts(t)
1100
+ const align = String(st.align || 'left')
1101
+ const cx = align === 'center' ? n((r.x || 0) - ox + (r.width || 0) / 2)
1102
+ : align === 'right' ? n((r.x || 0) - ox + (r.width || 0)) : n((r.x || 0) - ox)
1103
+ out.push(`cv.fillStyle='${hexToRgba(st.color)}';`)
1104
+ out.push(`cv.font='${Number(st.fontWeight) || 400} ${Number(st.fontSize) || 12}px "PingFang SC","Helvetica Neue",sans-serif';`)
1105
+ out.push(`cv.textAlign='${align === 'center' || align === 'right' ? align : 'left'}';`)
1106
+ out.push("cv.textBaseline='top';")
1107
+ out.push(`cv.fillText(${JSON.stringify(textOf(t))},${cx},${n((r.y || 0) - oy)});`)
1108
+ }
1109
+ return out.join('')
1110
+ }
1111
+
1112
+ // 评分仪表盘 Canvas 出码(recipe_snippets/canvas-gauge-score.json 同族:半环灰轨 + 高亮弧 + 中心分数/标签)
1113
+ const SCORE_GAUGE_CANVAS_CODE = "var self = this;\nvar canvas = event.canvas;\nvar ctx = canvas.getContext('2d');\nctx.imageSmoothingEnabled = true;\nctx.imageSmoothingQuality = 'high';\nvar cx = 60;\nvar cy = 60;\nvar r = 56.5;\nfunction toRad(a) { return a * Math.PI / 180; }\nctx.lineWidth = 6;\nctx.lineCap = 'butt';\nctx.strokeStyle = '#F5F5F5';\nctx.beginPath();\nctx.arc(cx, cy, r, toRad(317), toRad(517), false);\nctx.stroke();\nvar score = Number(self.state.score);\nif (isNaN(score)) { score = 0; }\nscore = Math.min(Math.max(score, 0), 100);\nvar fill = score * (160 / 70);\nif (fill > 270) { fill = 270; }\nctx.strokeStyle = '#FCC800';\nctx.beginPath();\nctx.arc(cx, cy, r, toRad(157), toRad(157 + fill), false);\nctx.stroke();\nvar scoreText = String(self.state.score == null ? '' : self.state.score);\nctx.textBaseline = 'alphabetic';\nctx.fillStyle = '#221715';\nctx.font = '500 28px \"Helvetica Neue\", \"PingFang SC\", sans-serif';\nvar wNum = ctx.measureText(scoreText).width;\nctx.font = '500 14px \"PingFang SC\", sans-serif';\nvar wUnit = ctx.measureText('\u5206').width;\nvar startX = cx - (wNum + wUnit + 2) / 2;\nctx.textAlign = 'left';\nctx.font = '500 28px \"Helvetica Neue\", \"PingFang SC\", sans-serif';\nctx.fillText(scoreText, startX, 52);\nctx.font = '500 14px \"PingFang SC\", sans-serif';\nctx.fillText('\u5206', startX + wNum + 2, 52);\nctx.font = '400 12px \"PingFang SC\", sans-serif';\nctx.textAlign = 'center';\nctx.fillStyle = '#575757';\nctx.fillText(String(self.state.scoreLabel || ''), cx, 73);"
1114
+
755
1115
  export function extractSemantics(spec, cardIndex = 0, opts = {}) {
756
1116
  // 设计稿聚焦图(可选):标注缺陷交叉校验(小 pill 描边环采样等),视觉优先;缺省跳过校验。
757
1117
  const _designImage = opts.designImage || null
758
1118
  const card = (spec.cards || [])[Number(cardIndex) || 0] || {}
759
1119
  const all = (card.elements || []).length ? card.elements : (spec.elements || [])
760
- const content = all.filter((e) => !isBg(e))
1120
+ let content = all.filter((e) => !isBg(e))
1121
+ // 图表矢量簇(柱+网格线+轴/值标签)先整体摘出:下游无图表消费通道,不摘出会被静默丢弃/错排
1122
+ const chartClusters = detectChartClusters(content)
1123
+ if (chartClusters.length) {
1124
+ const absorbed = new Set(chartClusters.flatMap((c) => [...c.members, ...c.texts]))
1125
+ content = content.filter((e) => !absorbed.has(e))
1126
+ }
761
1127
  const rawTexts = content
762
1128
  .filter((e) => e.type === 'text' && textOf(e))
763
1129
  .sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
@@ -1034,10 +1400,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
1034
1400
  return !memberTexts.has(text) && bottom <= top && top - bottom <= 48
1035
1401
  && (tr.x || 0) >= left - 24 && (tr.x || 0) <= left + 24
1036
1402
  && (ts(text).fontSize || 0) >= 14
1037
- }).sort((a, b) => (
1038
- (top - (rect(b).y || 0) - (rect(b).height || 0)) -
1039
- (top - (rect(a).y || 0) - (rect(a).height || 0))
1040
- ))
1403
+ }).sort((a, b) => (rect(b).y || 0) - (rect(a).y || 0))
1041
1404
  const labelEl = labelCands[0] || null
1042
1405
  const labelStyle = labelEl ? ts(labelEl) : null
1043
1406
  groups.push({
@@ -1735,8 +2098,18 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
1735
2098
  for (const [, group] of labelYGroups) {
1736
2099
  if (group.length >= 2) group.forEach((lab) => parallelLabels.add(lab))
1737
2100
  }
2101
+ // 与主金额标签同带的并列汇总列(总支出/总收入)不是副金额:留给面板通道整带渲染,
2102
+ // 否则次列被副金额通道甩到卡底(汇总区只剩单列)。
2103
+ const primaryLabelEl = primaryAmountEl ? texts.find((t2) => {
2104
+ const r2 = rect(t2)
2105
+ const dy2 = (primaryRect.y || 0) - ((r2.y || 0) + (r2.height || 0))
2106
+ return dy2 >= -6 && dy2 <= 40 && Math.abs((r2.x || 0) - (primaryRect.x || 0)) <= 40
2107
+ }) || null : null
1738
2108
  for (const lab of labels) {
1739
2109
  if (!parallelLabels.has(lab)) continue
2110
+ if (primaryLabelEl && Math.abs((rect(lab).y || 0) - (rect(primaryLabelEl).y || 0)) <= 12) continue
2111
+ // 副金额列标签必须位于主金额值之下:与主值同带的是并列汇总列(总支出/总收入),归面板通道。
2112
+ if (primaryRect && (rect(lab).y || 0) <= (primaryRect.y || 0) + (primaryRect.height || 0)) continue
1740
2113
  const labR = rect(lab)
1741
2114
  const valCands = texts.filter((t) => {
1742
2115
  if (isPrimaryFragment(t)) return false
@@ -1941,6 +2314,24 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
1941
2314
  const group = clusters[0].sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
1942
2315
  const grpY = rect(group[0]).y || 0
1943
2316
  const logoSlice = slices.find((s) => Math.abs((rect(s).y || 0) - grpY) < 15 && (rect(s).x || 0) < rect(group[0]).x)
2317
+ // 标签簇下方同列数值行(并行汇总双列「总支出/总收入」下一行的大字号数值):
2318
+ // 每个标签 x±24、位于标签带底沿之下 24-56px、含数字 → 归为该簇的值(随带整块渲染)。
2319
+ const grpBottom = Math.max(...group.map((t) => (rect(t).y || 0) + (rect(t).height || 0)))
2320
+ const clusterValues = group.map((t) => {
2321
+ const tr = rect(t)
2322
+ const cand = texts.find((v) => {
2323
+ if (v === t || group.includes(v)) return false
2324
+ const vr = rect(v)
2325
+ const dy = (vr.y || 0) - grpBottom
2326
+ return /\d/.test(textOf(v)) && dy >= 8 && dy <= 56
2327
+ && Math.abs((vr.x || 0) - (tr.x || 0)) <= 24 && (vr.width || 0) <= 160
2328
+ })
2329
+ return cand ? {
2330
+ text: textOf(cand), color: ts(cand).color, fontSize: ts(cand).fontSize,
2331
+ fontWeight: ts(cand).fontWeight, x: rect(cand).x || 0, y: rect(cand).y || 0,
2332
+ width: rect(cand).width || 0, height: rect(cand).height || 0,
2333
+ } : null
2334
+ })
1944
2335
  return {
1945
2336
  logoSlice: logoSlice || null,
1946
2337
  items: group.map((t) => ({
@@ -1948,6 +2339,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
1948
2339
  fontWeight: ts(t).fontWeight, x: rect(t).x || 0, width: rect(t).width || 0,
1949
2340
  y: rect(t).y || 0, height: rect(t).height || 0,
1950
2341
  })),
2342
+ values: clusterValues.some(Boolean) ? clusterValues : null,
1951
2343
  y: grpY,
1952
2344
  }
1953
2345
  })()
@@ -2003,6 +2395,10 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2003
2395
  const radius = Number(element.style?.radius?.borderRadius)
2004
2396
  if (element.type !== 'shape' || !border.isBorder || !(radius >= 4)) return false
2005
2397
  const sr = rect(element)
2398
+ // 卡壳自排除:覆盖整卡的描边圆角壳是卡片本体(白描边在浅底上不可见),
2399
+ // 认成段落描边 pill 会在卡中部画出一圈可见圆角框(大段落文本曾把 2×h+20 上限撑穿)。
2400
+ const cR = rect(card)
2401
+ if ((sr.width || 0) >= (cR.width || 0) * 0.9 && (sr.height || 0) >= (cR.height || 0) * 0.9) return false
2006
2402
  return (tr.x || 0) >= (sr.x || 0) - 2 && (tr.y || 0) >= (sr.y || 0) - 2
2007
2403
  && ((tr.x || 0) + (tr.width || 0)) <= ((sr.x || 0) + (sr.width || 0)) + 2
2008
2404
  && ((tr.y || 0) + (tr.height || 0)) <= ((sr.y || 0) + (sr.height || 0)) + 2
@@ -2053,9 +2449,26 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2053
2449
  const textCenter = (tr.y || 0) + (tr.height || 0) / 2
2054
2450
  const gapLeft = (tr.x || 0) - ((sr.x || 0) + (sr.width || 0))
2055
2451
  const gapRight = (sr.x || 0) - ((tr.x || 0) + (tr.width || 0))
2056
- return Math.abs(sliceCenter - textCenter) <= 8
2452
+ // 竖直容差随切片高度放宽:大尺寸主视觉(吉祥物/头像)与多行文本的中心差可达
2453
+ // 十几 px,固定 8px 只认小图标 → 主视觉掉进孤立媒体块单独成行(卡头被拆两行)。
2454
+ const vTol = Math.max(8, Math.min((sr.height || 0) * 0.35, (tr.height || 0) * 0.6))
2455
+ const vOverlap = Math.min((sr.y || 0) + (sr.height || 0), (tr.y || 0) + (tr.height || 0))
2456
+ - Math.max(sr.y || 0, tr.y || 0)
2457
+ return Math.abs(sliceCenter - textCenter) <= vTol && vOverlap > 0
2057
2458
  && ((gapLeft >= -4 && gapLeft <= 24) || (gapRight >= -4 && gapRight <= 24))
2058
2459
  }) || null,
2460
+ decoSliceEl: slices.find((slice) => {
2461
+ const sr = rect(slice)
2462
+ const tr = rect(t)
2463
+ const sliceCenterX = (sr.x || 0) + (sr.width || 0) / 2
2464
+ const textCenter = (tr.y || 0) + (tr.height || 0) / 2
2465
+ // 文本底带装饰切片(下划波浪/高亮笔触):水平落在文本宽度内、竖直贴文本下半段
2466
+ // → 随文本行渲染;否则薄切片(高 <20)进不了孤立媒体块被静默丢弃。
2467
+ return (sr.height || 0) >= 4 && (sr.height || 0) <= Math.max(8, (tr.height || 0) * 0.5)
2468
+ && (sr.width || 0) <= (tr.width || 0)
2469
+ && (sr.y || 0) >= textCenter && (sr.y || 0) <= (tr.y || 0) + (tr.height || 0) + 8
2470
+ && sliceCenterX >= (tr.x || 0) && sliceCenterX <= (tr.x || 0) + (tr.width || 0)
2471
+ }) || null,
2059
2472
  backgroundShell: backgroundShellFor(t),
2060
2473
  outlineShell: outlineShellFor(t),
2061
2474
  highlightShell: highlightShellFor(t),
@@ -2100,6 +2513,111 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2100
2513
  const primaryButtonShapes = buttonShapes.filter((b) => (rect(b).width || 0) >= 80)
2101
2514
  const btnBottom = primaryButtonShapes.length ? Math.max(...primaryButtonShapes.map((b) => (rect(b).y || 0) + (rect(b).height || 0))) : 9999
2102
2515
  const formBandTop = mainBottom + 6
2516
+ // —— 对比面板组预抽取(并排小卡:壳内左对齐「标签行 + 值行」,可带圆点/状态 tag)——
2517
+ // 通用「label + 同行右簇 value」配对会跨壳把并排面板的两条标签配成一行
2518
+ // (持仓比例 ↔ 建议比例)、值行同理;壳底色/圆角/圆点/状态 tag 也全部丢失。
2519
+ // 故先按壳几何把面板行摘出(每壳一行)并消费其文本,禁止通用配对再碰。
2520
+ const panelShellCands = shapes.filter((s2) => {
2521
+ const f2 = s2.style?.fill
2522
+ if (!f2 || (!f2.color && !f2.gradient)) return false
2523
+ if (radiusMax(s2.style) < 4) return false
2524
+ const r2 = rect(s2)
2525
+ const w2 = r2.width || 0; const h2 = r2.height || 0
2526
+ if (w2 < 100 || w2 > 260 || h2 < 48 || h2 > 120) return false
2527
+ if ((r2.y || 0) <= formBandTop || (r2.y || 0) >= btnBottom) return false
2528
+ const inside = texts.filter((t2) => rectInside(r2, rect(t2), 0))
2529
+ if (inside.length < 2) return false
2530
+ const lineKeys = new Set(inside.map((t2) => Math.round((rect(t2).y || 0) / 6)))
2531
+ return lineKeys.size >= 2
2532
+ })
2533
+ // 同位双层标注(编组+矩形)与内层装饰壳去重:保留最外层且有实体填充色的 shape
2534
+ const panelShells = panelShellCands.filter((a) => {
2535
+ const ra = rect(a)
2536
+ const aSolid = !!(a.style?.fill?.color)
2537
+ return !panelShellCands.some((b) => {
2538
+ if (b === a) return false
2539
+ const rb = rect(b)
2540
+ if (!rectInside(rb, ra, 1)) return false
2541
+ const sameRect = Math.abs((rb.width || 0) - (ra.width || 0)) <= 2
2542
+ && Math.abs((rb.height || 0) - (ra.height || 0)) <= 2
2543
+ if (!sameRect) return true
2544
+ const bSolid = !!(b.style?.fill?.color)
2545
+ return !aSolid || (bSolid && panelShellCands.indexOf(b) < panelShellCands.indexOf(a))
2546
+ })
2547
+ })
2548
+ for (const shellEl of panelShells) {
2549
+ const sr2 = rect(shellEl)
2550
+ const insideTexts = texts
2551
+ .filter((t2) => !formConsumed.has(t2) && !enumStatConsumed.has(t2) && rectInside(sr2, rect(t2), 0))
2552
+ .sort((p, q) => (rect(p).y || 0) - (rect(q).y || 0))
2553
+ if (insideTexts.length < 2) continue
2554
+ const labEl = insideTexts[0]
2555
+ const labR = rect(labEl)
2556
+ const restLines = insideTexts.slice(1)
2557
+ const valEl = restLines[0]
2558
+ const valR = rect(valEl)
2559
+ const tagCand = restLines.find((t2) => t2 !== valEl
2560
+ && (rect(t2).width || 0) <= 60 && (rect(t2).height || 0) <= 24
2561
+ && (rect(t2).x || 0) > (valR.x || 0) + (valR.width || 0) - 2
2562
+ && Math.abs((rect(t2).y || 0) - (valR.y || 0)) <= 12) || null
2563
+ const tagShapeOf = (t2) => shapes.find((s3) => {
2564
+ const r3 = rect(s3); const tr3 = rect(t2)
2565
+ return (r3.width || 0) >= (tr3.width || 0) && (r3.width || 0) <= (tr3.width || 0) + 16
2566
+ && (r3.height || 0) >= (tr3.height || 0) && (r3.height || 0) <= (tr3.height || 0) + 12
2567
+ && Math.abs((r3.x || 0) - (tr3.x || 0)) <= 8 && Math.abs((r3.y || 0) - (tr3.y || 0)) <= 8
2568
+ }) || null
2569
+ const tagShapeEl = tagCand ? tagShapeOf(tagCand) : null
2570
+ // 无壳短文本按子值处理(第二值),有壳才是状态 tag
2571
+ const tagEl = tagShapeEl ? tagCand : null
2572
+ const subEls2 = restLines.filter((t2) => t2 !== valEl && t2 !== tagEl)
2573
+ const dotEl = shapes.find((s3) => {
2574
+ const f3 = s3.style?.fill
2575
+ if (!f3 || !f3.color) return false
2576
+ const r3 = rect(s3)
2577
+ const w3 = r3.width || 0; const h3 = r3.height || 0
2578
+ if (w3 < 6 || w3 > 14 || h3 < 6 || h3 > 14) return false
2579
+ return rectInside(sr2, r3, 0)
2580
+ && (r3.x || 0) + w3 <= (labR.x || 0)
2581
+ && Math.abs(((r3.y || 0) + h3 / 2) - ((labR.y || 0) + (labR.height || 0) / 2)) <= 12
2582
+ }) || null
2583
+ formRows.push({
2584
+ panelShell: {
2585
+ x: sr2.x || 0, y: sr2.y || 0, width: sr2.width || 0, height: sr2.height || 0,
2586
+ fill: shellEl.style?.fill?.color || null,
2587
+ gradient: shellEl.style?.fill?.gradient || null,
2588
+ radius: radiusMax(shellEl.style),
2589
+ opacity: shellEl.style?.opacity ?? 1,
2590
+ },
2591
+ dot: dotEl ? {
2592
+ color: dotEl.style.fill.color,
2593
+ x: rect(dotEl).x || 0, y: rect(dotEl).y || 0,
2594
+ w: Math.round(rect(dotEl).width || 8), h: Math.round(rect(dotEl).height || 8),
2595
+ gap: Math.max(0, Math.round((labR.x || 0) - ((rect(dotEl).x || 0) + (rect(dotEl).width || 0)))),
2596
+ } : null,
2597
+ tag: tagEl ? {
2598
+ text: textOf(tagEl), color: ts(tagEl).color, fontSize: ts(tagEl).fontSize,
2599
+ borderColor: tagShapeEl.style?.border?.isBorder ? tagShapeEl.style.border.color : null,
2600
+ fill: tagShapeEl.style?.fill?.color || null,
2601
+ radius: radiusMax(tagShapeEl.style),
2602
+ w: Math.round(rect(tagShapeEl).width || 24),
2603
+ h: Math.round(rect(tagShapeEl).height || 14),
2604
+ right: Math.round((rect(tagShapeEl).x || 0) + (rect(tagShapeEl).width || 0)),
2605
+ } : null,
2606
+ 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 },
2607
+ leftIconSlice: null,
2608
+ 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 },
2609
+ 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 })),
2610
+ rightAffordanceSlice: null,
2611
+ isPlaceholder: false,
2612
+ labelEl: labEl,
2613
+ valueEl: valEl,
2614
+ subEls: subEls2,
2615
+ })
2616
+ formConsumed.add(labEl)
2617
+ formConsumed.add(valEl)
2618
+ if (tagEl) formConsumed.add(tagEl)
2619
+ subEls2.forEach((t2) => formConsumed.add(t2))
2620
+ }
2103
2621
  // 候选左标签:表单带内、左锚定(x < 120)的文本;枚举统计行文本已排除;
2104
2622
  // 另排除「大字号纯数字」头部主数字(如未读消息数 12)——标题区数字块不是表单 label。
2105
2623
  const labelCands = texts.filter((t) => {
@@ -2238,7 +2756,71 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2238
2756
  && (sr.x || 0) + sw <= lx + 2 && (sr.x || 0) >= (rect(card).x || 0) - 4
2239
2757
  && Math.abs(vCenter - lCenter) <= 12
2240
2758
  }) || null
2759
+ // 对比面板壳/圆点/行内状态 tag:壳承行底色/圆角/高度与内缩,圆点进 label 左簇,tag 随值行
2760
+ const labRect0 = rect(lab)
2761
+ const labCenter0 = { x: (labRect0.x || 0) + (labRect0.width || 0) / 2, y: (labRect0.y || 0) + (labRect0.height || 0) / 2 }
2762
+ const panelShellEl = shapes.find((s2) => {
2763
+ const f2 = s2.style?.fill
2764
+ if (!f2 || (!f2.color && !f2.gradient)) return false
2765
+ if (radiusMax(s2.style) < 4) return false
2766
+ const r2 = rect(s2)
2767
+ const w2 = r2.width || 0; const h2 = r2.height || 0
2768
+ if (w2 < 100 || w2 > 260 || h2 < 48 || h2 > 120) return false
2769
+ return labCenter0.x >= (r2.x || 0) && labCenter0.x <= (r2.x || 0) + w2
2770
+ && labCenter0.y >= (r2.y || 0) && labCenter0.y <= (r2.y || 0) + h2
2771
+ }) || null
2772
+ const dotEl = panelShellEl ? shapes.find((s2) => {
2773
+ const f2 = s2.style?.fill
2774
+ if (!f2 || !f2.color) return false
2775
+ const r2 = rect(s2)
2776
+ const w2 = r2.width || 0; const h2 = r2.height || 0
2777
+ if (w2 < 6 || w2 > 14 || h2 < 6 || h2 > 14) return false
2778
+ const pr2 = rect(panelShellEl)
2779
+ return (r2.x || 0) >= (pr2.x || 0) && (r2.x || 0) + w2 <= (pr2.x || 0) + (pr2.width || 0)
2780
+ && (r2.y || 0) >= (pr2.y || 0) && (r2.y || 0) + h2 <= (pr2.y || 0) + (pr2.height || 0)
2781
+ && (r2.x || 0) + w2 <= (labRect0.x || 0)
2782
+ && Math.abs(((r2.y || 0) + h2 / 2) - labCenter0.y) <= 12
2783
+ }) || null : null
2784
+ const tagTextEl = panelShellEl ? texts.find((t2) => {
2785
+ if (t2 === lab || t2 === valueEl || subEls.includes(t2)) return false
2786
+ const r2 = rect(t2)
2787
+ const w2 = r2.width || 0; const h2 = r2.height || 0
2788
+ if (w2 < 8 || w2 > 60 || h2 < 8 || h2 > 24) return false
2789
+ const pr2 = rect(panelShellEl)
2790
+ return (r2.x || 0) >= (pr2.x || 0) && (r2.x || 0) + w2 <= (pr2.x || 0) + (pr2.width || 0)
2791
+ && (r2.y || 0) >= (pr2.y || 0) && (r2.y || 0) + h2 <= (pr2.y || 0) + (pr2.height || 0)
2792
+ }) || null : null
2793
+ const tagShapeEl = tagTextEl ? shapes.find((s2) => {
2794
+ const r2 = rect(s2)
2795
+ const tr2 = rect(tagTextEl)
2796
+ return (r2.width || 0) >= (tr2.width || 0) && (r2.width || 0) <= (tr2.width || 0) + 16
2797
+ && (r2.height || 0) >= (tr2.height || 0) && (r2.height || 0) <= (tr2.height || 0) + 12
2798
+ && Math.abs((r2.x || 0) - (tr2.x || 0)) <= 8 && Math.abs((r2.y || 0) - (tr2.y || 0)) <= 8
2799
+ }) || null : null
2241
2800
  formRows.push({
2801
+ panelShell: panelShellEl ? {
2802
+ x: rect(panelShellEl).x || 0, y: rect(panelShellEl).y || 0,
2803
+ width: rect(panelShellEl).width || 0, height: rect(panelShellEl).height || 0,
2804
+ fill: panelShellEl.style?.fill?.color || null,
2805
+ gradient: panelShellEl.style?.fill?.gradient || null,
2806
+ radius: radiusMax(panelShellEl.style),
2807
+ opacity: panelShellEl.style?.opacity ?? 1,
2808
+ } : null,
2809
+ dot: dotEl ? {
2810
+ color: dotEl.style.fill.color,
2811
+ x: rect(dotEl).x || 0, y: rect(dotEl).y || 0,
2812
+ w: Math.round(rect(dotEl).width || 8), h: Math.round(rect(dotEl).height || 8),
2813
+ gap: Math.max(0, Math.round((labRect0.x || 0) - ((rect(dotEl).x || 0) + (rect(dotEl).width || 0)))),
2814
+ } : null,
2815
+ tag: tagTextEl ? {
2816
+ text: textOf(tagTextEl), color: ts(tagTextEl).color, fontSize: ts(tagTextEl).fontSize,
2817
+ borderColor: tagShapeEl && tagShapeEl.style?.border?.isBorder ? tagShapeEl.style.border.color : null,
2818
+ fill: tagShapeEl ? (tagShapeEl.style?.fill?.color || null) : null,
2819
+ radius: tagShapeEl ? radiusMax(tagShapeEl.style) : 2,
2820
+ w: Math.round(rect(tagShapeEl || tagTextEl).width || 24),
2821
+ h: Math.round(rect(tagShapeEl || tagTextEl).height || 14),
2822
+ right: Math.round((rect(tagShapeEl || tagTextEl).x || 0) + (rect(tagShapeEl || tagTextEl).width || 0)),
2823
+ } : null,
2242
2824
  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 },
2243
2825
  leftIconSlice,
2244
2826
  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,
@@ -2337,6 +2919,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2337
2919
  return w >= 10 && w <= 28 && h >= 10 && h <= 28
2338
2920
  })
2339
2921
  for (const row of formRows) {
2922
+ if (row.panelShell) continue
2340
2923
  if (row.trailingIconSlice || row.rightAffordanceSlice || row.rightActionButton) continue
2341
2924
  const anchor = row.value
2342
2925
  if (!anchor) continue
@@ -2356,6 +2939,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2356
2939
  const claimed = new Set(formRows.flatMap((row) =>
2357
2940
  [row.rightAffordanceSlice, row.leftIconSlice, row.trailingIconSlice].filter(Boolean)))
2358
2941
  for (const row of formRows) {
2942
+ if (row.panelShell) continue
2359
2943
  const anchor = row.value
2360
2944
  if (!anchor || row.valueLeadSlice) continue
2361
2945
  const valueLeft = anchor.x || 0
@@ -2408,8 +2992,16 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2408
2992
  // 与选项/面板壳同位的 symbol 是同一视觉的重复标注(如 pill 同时存在 symbol 实例和矩形壳),
2409
2993
  // 不剔除会双份渲染(symbol 占位图 + 选项按钮叠在一起)。
2410
2994
  const shellCovered = (el) => [...optionShapeSet].some((s) => overlapRatio(el, s) > 0.6)
2995
+ // 行壳重复标注:symbol 与同矩形带填充 shape 共存(shape 承载填充色)→ symbol 侧不得进媒体行,
2996
+ // 否则行内容被占位图认领、列表识别被打散(交易行曾裂成「静态行 + 单行列表」)。
2997
+ const shapeShellDup = (el) => {
2998
+ const er = rect(el)
2999
+ return shapes.some((sh) => Math.abs((rect(sh).x || 0) - (er.x || 0)) <= 2
3000
+ && Math.abs((rect(sh).y || 0) - (er.y || 0)) <= 2
3001
+ && Math.abs((rect(sh).width || 0) - (er.width || 0)) <= 2 && Math.abs((rect(sh).height || 0) - (er.height || 0)) <= 2)
3002
+ }
2411
3003
  const symbolMediaRows = leafSymbols.filter((symbol) =>
2412
- !shellCovered(symbol) &&
3004
+ !shellCovered(symbol) && !shapeShellDup(symbol) &&
2413
3005
  !buttonShapes.some((button) => overlapRatio(symbol, button) > 0.5) && !(() => {
2414
3006
  const sr = rect(symbol)
2415
3007
  return (sr.width || 0) >= mediaCardWidth * 0.8 && (sr.height || 0) >= 100
@@ -2481,12 +3073,53 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2481
3073
  width: tr.width || 0,
2482
3074
  height: tr.height || 0,
2483
3075
  trackColor: track.style?.fill?.color,
3076
+ trackEl: track,
3077
+ segmentEls: fills,
2484
3078
  segments: fills.map((fill) => ({
2485
3079
  ratio: Math.min(1, Math.max(0, (rect(fill).width || 0) / (tr.width || 1))),
2486
3080
  color: fill.style?.fill?.color || track.style?.fill?.color,
2487
3081
  })),
2488
3082
  })
2489
3083
  }
3084
+ // 无轨道多段拼接条(如 蓝段+黄段 相邻平铺的比例条):同 y 同高、横向相邻(≤8px)的
3085
+ // bar 形组以「首左沿→末右沿」为轨道合成多段进度条;否则两段被当孤立色块丢弃。
3086
+ const usedBarEls = new Set()
3087
+ for (const bar of progressBars) {
3088
+ if (bar.trackEl) usedBarEls.add(bar.trackEl)
3089
+ for (const el of (bar.segmentEls || [])) usedBarEls.add(el)
3090
+ }
3091
+ const freeBarShapes = barShapes.filter((shape) => !usedBarEls.has(shape))
3092
+ .sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
3093
+ const tiledGroups = []
3094
+ for (const shape of freeBarShapes) {
3095
+ const sr = rect(shape)
3096
+ const group = tiledGroups.find((gg) => Math.abs((gg.y || 0) - (sr.y || 0)) <= 2
3097
+ && Math.abs((gg.h || 0) - (sr.height || 0)) <= 2
3098
+ && (sr.x || 0) - (gg.right || 0) <= 8 && (sr.x || 0) >= (gg.right || 0) - 2)
3099
+ if (group) {
3100
+ group.members.push(shape)
3101
+ group.right = Math.max(group.right, (sr.x || 0) + (sr.width || 0))
3102
+ } else {
3103
+ tiledGroups.push({ y: sr.y || 0, h: sr.height || 0, x: sr.x || 0,
3104
+ right: (sr.x || 0) + (sr.width || 0), members: [shape] })
3105
+ }
3106
+ }
3107
+ for (const group of tiledGroups) {
3108
+ if (group.members.length < 2) continue
3109
+ const span = group.right - group.x
3110
+ if (span < layoutCardWidth * 0.4) continue
3111
+ progressBars.push({
3112
+ x: group.x,
3113
+ y: group.y,
3114
+ width: span,
3115
+ height: group.h,
3116
+ trackColor: null,
3117
+ segments: group.members.map((member) => ({
3118
+ ratio: Math.min(1, Math.max(0, (rect(member).width || 0) / (span || 1))),
3119
+ color: member.style?.fill?.color || null,
3120
+ })),
3121
+ })
3122
+ }
2490
3123
 
2491
3124
  // —— 内嵌面板行:外层卡内的几何壳(列表项/入口卡/选择行)必须作为整体渲染。 ——
2492
3125
  // 判定只依赖:宽壳 + 圆角 + 内部堆叠文本 + 可选左右媒体;不识别业务文案。
@@ -2509,12 +3142,38 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2509
3142
  const containsRepeatBand = repeatRowMode && _repeatBandTops.some((bt) =>
2510
3143
  bt >= (sr.y || 0) - 2 && bt <= (sr.y || 0) + (sr.height || 0) + 2)
2511
3144
  return !coversCard && !containsRepeatBand
2512
- && sr.width >= cardWidth * 0.6 && sr.height >= 50 && sr.height <= Math.max(200, cardHeight * 0.5) && radius >= 6
3145
+ && sr.width >= cardWidth * 0.6 && sr.height <= Math.max(200, cardHeight * 0.5) && radius >= 6
3146
+ // 入口/链接行(高 36-50):单条左齐文本 + 右端小亲和件(箭头/勾选切片或矢量形)。
3147
+ // 高度下限原为 50,44px 白底入口行全部漏判 → 整行底色+文本丢失(像素热区整带)。
3148
+ && (sr.height >= 50 || (sr.height >= 36 && (() => {
3149
+ const innerTexts = texts.filter((t2) => {
3150
+ const tr2 = rect(t2)
3151
+ return (tr2.x || 0) >= (sr.x || 0) - 2 && (tr2.y || 0) >= (sr.y || 0) - 2
3152
+ && ((tr2.x || 0) + (tr2.width || 0)) <= (sr.x || 0) + (sr.width || 0) + 2
3153
+ && ((tr2.y || 0) + (tr2.height || 0)) <= (sr.y || 0) + (sr.height || 0) + 2
3154
+ })
3155
+ if (innerTexts.length !== 1) return false
3156
+ const tr2 = rect(innerTexts[0])
3157
+ const leftAligned = ((tr2.x || 0) - (sr.x || 0)) <= (sr.width || 0) * 0.15
3158
+ const affordance = [...shapes, ...(slices || [])].some((e2) => {
3159
+ if (e2 === shape) return false
3160
+ const er2 = rect(e2)
3161
+ const ew = er2.width || 0
3162
+ const eh = er2.height || 0
3163
+ if (ew < 4 || ew > 32 || eh < 4 || eh > 32) return false
3164
+ return (er2.x || 0) + ew >= (sr.x || 0) + (sr.width || 0) - 28
3165
+ && Math.abs(((er2.y || 0) + eh / 2) - ((sr.y || 0) + (sr.height || 0) / 2)) <= (sr.height || 0) / 2
3166
+ })
3167
+ return leftAligned && affordance
3168
+ })()))
2513
3169
  && !(ownsPrimaryAmount && sameFillAsCard)
2514
3170
  && !buttonShapes.some((button) => {
2515
3171
  // 主尺寸按钮(w>=80,CTA/主操作):壳整体含按钮 → 复合容器(产品卡/CTA 块),
2516
3172
  // 交还 主按钮/渐变容器 专用路径(min 面积归一,原语义);小 pill 是行内操作件,
2517
3173
  // 落在壳内不排除壳(覆盖率归一,[媒体|文本栈|pill] 复合行仍是面板)。
3174
+ // 壳自身落进 buttonShapes(形状级)但未成为按钮(文本非居中,如入口行)时,不得自排除:
3175
+ // 否则入口行壳永远进不了 panelRows,整行底色+文本丢失。
3176
+ if (button === shape) return false
2518
3177
  const isPrimarySized = (rect(button).width || 0) >= 80
2519
3178
  return isPrimarySized ? overlapRatio(shape, button) > 0.5 : coverRatio(shape, button) > 0.5
2520
3179
  })
@@ -2538,11 +3197,15 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2538
3197
  && ((er.y || 0) + (er.height || 0)) <= bottom + 2
2539
3198
  }
2540
3199
  const buttonTextEls = new Set(buttons.map((button) => button.sourceEl).filter(Boolean))
2541
- const paired = texts.filter((text) => !symbolMediaConsumed.has(text) && !subAmountElementSet.has(text)
3200
+ const paired = texts.filter((text) => !symbolMediaConsumed.has(text) && !(subAmountElementSet.has(text) && !inside(text))
2542
3201
  && !optionTextSet.has(text) && !optionLabelSet.has(text)
2543
3202
  && !buttonTextEls.has(text)).filter(inside).sort((a, b) =>
2544
3203
  (rect(a).y || 0) - (rect(b).y || 0) || (rect(a).x || 0) - (rect(b).x || 0))
2545
- if (!paired.length) return null
3204
+ // 段落壳归富文本通道:壳内含 richBlock 碎片/容器时 panelRows 再认领会把段落链接行
3205
+ // (查看详细建议)抢成面板行插到段落之前,DOM 顺序颠倒且灰底壳重复归属。
3206
+ const richInside = (richBlocks || []).some((blk) => inside(blk.container)
3207
+ || (blk.fragments || []).some((frag) => inside(frag)))
3208
+ if (!paired.length || richInside) return null
2546
3209
  const overlapsExisting = paired.some((t) => {
2547
3210
  const value = textOf(t)
2548
3211
  return subAmounts.some((item) => item.label === value || item.value === value)
@@ -2550,7 +3213,8 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2550
3213
  || row.value?.text === value
2551
3214
  || (row.subValues || []).some((sub) => sub.text === value))
2552
3215
  })
2553
- const panelSlices = slices.filter(inside)
3216
+ const panelSlices = [...slices, ...symbols.filter((sy) =>
3217
+ !symbolMediaRows.some((row) => row.symbolEl === sy))].filter(inside)
2554
3218
  const firstText = paired[0]
2555
3219
  const lastText = paired[paired.length - 1]
2556
3220
  const leftMedia = panelSlices.find((s) => (rect(s).x || 0) < (rect(firstText).x || 0)) || null
@@ -2709,6 +3373,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2709
3373
  const progressShapes = progressBars.flatMap((bar) => [])
2710
3374
  const formSliceEls = new Set((formRows || []).flatMap((row) => [row.rightAffordanceSlice, row.leftIconSlice, row.trailingIconSlice, row.valueLeadSlice]).filter(Boolean))
2711
3375
  const auxSliceEls = new Set([
3376
+ ...auxTexts.map((aux) => aux.decoSliceEl),
2712
3377
  ...auxTexts.map((aux) => aux.sliceEl),
2713
3378
  // 副金额列标签后缀图标(如币种选择器箭头):随标签行渲染,不得落入孤立媒体块
2714
3379
  ...subAmounts.map((sub) => sub.labelSuffixSlice),
@@ -2744,6 +3409,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2744
3409
  const hasInsideContent = (shape) => [...texts, ...slices, ...symbols]
2745
3410
  .some((element) => overlapRatio(element, shape) > 0.5)
2746
3411
  const isolatedBlocks = [...slices, ...leafSymbols, ...shapes]
3412
+ .filter((el) => !(el.type === 'symbol' && shapeShellDup(el)))
2747
3413
  .filter((element) => {
2748
3414
  const er = rect(element)
2749
3415
  const width = er.width || 0
@@ -2774,6 +3440,31 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2774
3440
  }
2775
3441
  })
2776
3442
  .sort((a, b) => a.y - b.y || a.x - b.x)
3443
+ // 浮动装饰切片回收:isolatedBlocks 先于 orphanSlices 消费切片(卡头月亮等贴缘叠装饰),
3444
+ // 命中「与文本带竖直重叠 + 贴卡缘」者改走绝对定位叠层,不占流式行高。
3445
+ const floatDecos = []
3446
+ {
3447
+ const cRd = rect(card)
3448
+ const isFloatDeco = (el) => {
3449
+ const sr = rect(el)
3450
+ const w = sr.width || 0, h = sr.height || 0
3451
+ if (!w || !h || w > (cRd.width || 375) * 0.5) return false
3452
+ const overlapsText = texts.some((t) => {
3453
+ const tr = rect(t)
3454
+ const ov = Math.min((sr.y || 0) + h, (tr.y || 0) + (tr.height || 0)) - Math.max(sr.y || 0, tr.y || 0)
3455
+ return ov >= h * 0.2 && h >= (tr.height || 0) * 2.2
3456
+ })
3457
+ const edgeAnchored = ((sr.x || 0) + w) >= ((cRd.x || 0) + (cRd.width || 0)) - 24
3458
+ return overlapsText && edgeAnchored
3459
+ }
3460
+ for (let k = isolatedBlocks.length - 1; k >= 0; k -= 1) {
3461
+ const el = isolatedBlocks[k].element
3462
+ if (el && (el.type === 'slice') && isFloatDeco(el)) {
3463
+ floatDecos.push(el)
3464
+ isolatedBlocks.splice(k, 1)
3465
+ }
3466
+ }
3467
+ }
2777
3468
 
2778
3469
  // 面板/符号媒体行消费文本后,同步回收先生成的 auxTexts,避免同一标注被重复渲染
2779
3470
  // (曾仅回收 panelTextConsumed:符号媒体行吞掉的文本又被 auxTexts 渲染一次 → 重影)。
@@ -2812,6 +3503,32 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2812
3503
  // 复发护栏(对称版):同一文案在内容区出现 ≥2 次(无论上下方),说明它是重复行内容
2813
3504
  // 而非列表标题(名册卡「张三」×3:旧单向护栏漏掉最后一个张三 → 误当标题吞掉整个列表)。
2814
3505
  if (_isRepeatedRowText(t)) return false
3506
+ // 金额值不是列表标题:含货币符号或「数字+千分位/小数」的文本是行右金额/主金额,
3507
+ // 误当标题会把真首行抬成 listTitle(首行 desc/amount 无人消费 → 绑定兜底双渲染重影)。
3508
+ if (/\p{Sc}/u.test(textOf(t))) return false
3509
+ if (/^[-+]?[\d.,\s%]+$/.test(textOf(t)) && /\.\d|,\d{3}/.test(textOf(t))) return false
3510
+ // 周期行护栏(结构版):候选下方存在 ≥2 个同列、同字号、同色且等距(±10)的同伴 →
3511
+ // 它是重复行的行标题而非区块标题(交易列表首行被抬成标题会让列表少一行)。
3512
+ // 双向护栏:上方+下方合计 ≥2 个同列、同字号、同色且等距(±12)同伴即判定为周期行
3513
+ // (中间行仅有 1 上 1 下同伴,旧单向护栏漏判把它当标题 → 列表被拦腰截断)。
3514
+ const titlePeersAll = texts.filter((o) => o !== t
3515
+ && Math.abs((rect(o).x || 0) - (rect(t).x || 0)) <= 2
3516
+ && (ts(o).fontSize || 0) === (T.fontSize || 0)
3517
+ && String(ts(o).color || '').toLowerCase() === String(T.color || '').toLowerCase()
3518
+ && Math.abs((rect(o).y || 0) - y) >= 20 && Math.abs((rect(o).y || 0) - y) <= 240)
3519
+ if (titlePeersAll.length >= 2) {
3520
+ // 步长投票:取最近同伴距离为候选步长,统计匹配该步长整数倍的同伴数;
3521
+ // ≥2 个匹配即判周期行(非周期同伴如汇总标签混入不影响判定)。
3522
+ const peerDys = titlePeersAll.map((o) => (rect(o).y || 0) - y).sort((a, b) => Math.abs(a) - Math.abs(b))
3523
+ const candSteps = [...new Set([Math.abs(peerDys[0]), Math.abs(peerDys[1] || peerDys[0])])].filter((s) => s >= 20)
3524
+ for (const candStep of candSteps) {
3525
+ const matched = peerDys.filter((g) => {
3526
+ const remainder = Math.abs(g) % candStep
3527
+ return Math.min(remainder, candStep - remainder) <= 12
3528
+ }).length
3529
+ if (matched >= 2) return false
3530
+ }
3531
+ }
2815
3532
  return belowUnconsumed.length >= 2
2816
3533
  })
2817
3534
  const listTitle = listTitleCands.length ? {
@@ -2963,6 +3680,30 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
2963
3680
  textGap: (captionRect.y || 0) - iconBottom,
2964
3681
  }
2965
3682
  }
3683
+ // 顶部半透明大椭圆装饰(弥散底色):采样硬停色带是矩形平涂,还原不出椭圆曲边,
3684
+ // 像素热带集中在带底沿(实测 y≈106 单带 32%)→ 记 radial-gradient 参数逐颗还原。
3685
+ const topTintBlobs = (() => {
3686
+ const cR = rect(card)
3687
+ return shapes.filter((shape) => {
3688
+ const sr = rect(shape)
3689
+ const opacity = Number(shape.style?.opacity ?? 1)
3690
+ return opacity > 0 && opacity <= 0.5
3691
+ && (sr.width || 0) >= (cR.width || 0) * 0.4 && (sr.height || 0) >= (cR.height || 0) * 0.4
3692
+ && (sr.y || 0) <= (cR.y || 0) + 4
3693
+ && !radiusMax(shape.style)
3694
+ }).map((shape) => {
3695
+ const sr = rect(shape)
3696
+ return {
3697
+ cx: Math.round((sr.x || 0) + (sr.width || 0) / 2 - (cR.x || 0)),
3698
+ cy: Math.round((sr.y || 0) + (sr.height || 0) / 2 - (cR.y || 0)),
3699
+ rx: Math.round((sr.width || 0) / 2),
3700
+ ry: Math.round((sr.height || 0) / 2),
3701
+ // 不透明卡身壳罩在色块之上(磨砂玻璃壳):设计稿里色块被白壳罩淡一层,
3702
+ // 直接按标注 alpha 平涂会过浓(实测像素差 +5.6pp)→ 罩淡系数 0.5 校准。
3703
+ rgba: hexToRgba(shape.style?.fill?.color || '#FFFFFF', Number(shape.style?.opacity ?? 1) * 0.5),
3704
+ }
3705
+ })
3706
+ })()
2966
3707
  const topDecoration = (() => {
2967
3708
  const cr = rect(card)
2968
3709
  const cardTop = cr.y || 0
@@ -3117,7 +3858,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3117
3858
  const rowsBelow = (listTitle
3118
3859
  ? texts.filter((t) => (rect(t).y || 0) > listTitleY)
3119
3860
  : texts.filter((t) => (rect(t).y || 0) > mainBottom
3120
- && !formConsumed.has(t) && !panelTextConsumed.has(t)
3861
+ && !panelTextConsumed.has(t)
3121
3862
  && !auxConsumedEls.has(t) && !subAmountEls.has(t)
3122
3863
  && !buttons.some((button) => button.sourceEl === t)))
3123
3864
  .filter((t) => !optionTextSet.has(t) && !optionLabelSet.has(t))
@@ -3177,9 +3918,14 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3177
3918
  // 描述 = 标题正下方 18-36px 的同列文本(上下两行、左对齐:列表项特征)
3178
3919
  // 描述行 = 标题正下方 18-36px 且与标题同列(左沿最近)的文本;
3179
3920
  // 右侧时间行与描述同 y 时按元素序会抢先命中,把时间当描述、真描述被甩到列表外。
3180
- const descEl = rowTexts
3921
+ let descEl = rowTexts
3181
3922
  .filter((r) => {
3182
3923
  const ry = rect(r).y || 0
3924
+ // 与标题同列、同字号、同色的「描述」其实是下一条等距单行(选项/清单行):
3925
+ // 真描述通常更小/更灰或缩进。误认会把下一行吞成描述、再下一行被甩出列表丢失。
3926
+ if (Math.abs((rect(r).x || 0) - (rect(t).x || 0)) <= 2
3927
+ && (ts(r).fontSize || 0) === (ts(t).fontSize || 0)
3928
+ && String(ts(r).color || '').toLowerCase() === String(ts(t).color || '').toLowerCase()) return false
3183
3929
  return ry - y >= 18 && ry - y <= 36
3184
3930
  })
3185
3931
  .sort((a, b) => Math.abs((rect(a).x || 0) - (rect(t).x || 0))
@@ -3210,6 +3956,57 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3210
3956
  }
3211
3957
  if (pairs.length >= 2) {
3212
3958
  compositePairs = pairs.slice(0, 4)
3959
+ // 标签行内联 tag 壳(币种选择 pill 类):贴标签右沿的小填充/描边壳 + 壳内短文本/小切片。
3960
+ // 逐行数据驱动($amtTagN/$amtTagIconN),无 tag 的行 v-show 隐藏,避免静态模板每行滥发。
3961
+ for (const pp of compositePairs) {
3962
+ const lr2 = rect(pp.labelEl)
3963
+ const labelCY = (lr2.y || 0) + (lr2.height || 0) / 2
3964
+ const shell = (shapes || []).find((sh) => {
3965
+ const r = rect(sh)
3966
+ const w = r.width || 0
3967
+ const h = r.height || 0
3968
+ if (w < 28 || w > 120 || h < 12 || h > 28) return false
3969
+ if (!sh.style?.fill?.color && !sh.style?.border?.isBorder) return false
3970
+ if (Math.abs(((r.y || 0) + h / 2) - labelCY) > 8) return false
3971
+ const gx = (r.x || 0) - ((lr2.x || 0) + (lr2.width || 0))
3972
+ return gx >= -2 && gx <= 16
3973
+ }) || null
3974
+ pp.tagShapeEl = shell
3975
+ if (!shell) continue
3976
+ const sr = rect(shell)
3977
+ const inside = (e) => {
3978
+ const er = rect(e)
3979
+ return (er.x || 0) >= (sr.x || 0) - 2 && (er.y || 0) >= (sr.y || 0) - 2
3980
+ && (er.x || 0) + (er.width || 0) <= (sr.x || 0) + (sr.width || 0) + 2
3981
+ && (er.y || 0) + (er.height || 0) <= (sr.y || 0) + (sr.height || 0) + 2
3982
+ }
3983
+ const tagTextEl = texts.find((tt2) => inside(tt2)) || null
3984
+ const tagIconEl = (slices || []).find((sl) => inside(sl)) || null
3985
+ const tagExp = tagIconEl
3986
+ ? (resolveSliceExport(spec.slices || [], tagIconEl.name) || tagIconEl.annotationDefect?.cropSlice)
3987
+ : null
3988
+ pp.tagText = tagTextEl ? textOf(tagTextEl) : ''
3989
+ pp.tagTextColor = tagTextEl ? ts(tagTextEl).color : undefined
3990
+ pp.tagFontSize = tagTextEl ? ts(tagTextEl).fontSize : undefined
3991
+ pp.tagIcon = tagExp ? sliceRefPath(tagExp) : ''
3992
+ pp.tagFill = shell.style?.fill?.color || null
3993
+ pp.tagOpacity = shell.style?.opacity ?? 1
3994
+ pp.tagBorder = shell.style?.border?.isBorder ? (shell.style?.border?.color || null) : null
3995
+ pp.tagRadius = shell.style?.radius?.borderRadius || 0
3996
+ pp.tagW = Math.round(sr.width || 0)
3997
+ pp.tagH = Math.round(sr.height || 0)
3998
+ pp.tagGap = Math.max(2, Math.round((sr.x || 0) - ((lr2.x || 0) + (lr2.width || 0))))
3999
+ pp.tagPadLeft = Math.max(0, Math.round(((tagTextEl ? rect(tagTextEl).x : sr.x) || 0) - (sr.x || 0)))
4000
+ pp.tagPadRight = Math.max(0, Math.round(((sr.x || 0) + (sr.width || 0))
4001
+ - ((tagIconEl ? (rect(tagIconEl).x || 0) + (rect(tagIconEl).width || 0)
4002
+ : (tagTextEl ? (rect(tagTextEl).x || 0) + (rect(tagTextEl).width || 0)
4003
+ : (sr.x || 0) + (sr.width || 0))))))
4004
+ pp.tagIconGap = tagIconEl && tagTextEl
4005
+ ? Math.max(0, Math.round((rect(tagIconEl).x || 0) - ((rect(tagTextEl).x || 0) + (rect(tagTextEl).width || 0))))
4006
+ : 2
4007
+ pp.tagIconW = tagIconEl ? Math.round(rect(tagIconEl).width || 16) : 0
4008
+ pp.tagIconH = tagIconEl ? Math.round(rect(tagIconEl).height || 16) : 0
4009
+ }
3213
4010
  const cardRectC = rect(card)
3214
4011
  titleRightEl = texts.find((tt) => {
3215
4012
  if (tt === t || compositePairs.some((pp) => pp.labelEl === tt || pp.valueEl === tt)) return false
@@ -3220,17 +4017,81 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3220
4017
  }) || null
3221
4018
  }
3222
4019
  }
3223
- if (!descEl && !compositePairs) { lastY = y; continue }
4020
+ if (!descEl && !compositePairs) {
4021
+ // 周期行兜底:无标准 desc(18-36px)但标题存在 ≥2 个同列等距周期同伴时
4022
+ // (支出占比分类行:类别名+金额+百分比+进度条,百分比仅 0-8px 下方而非标准 18-36px),
4023
+ // 取近邻小字号文本(0-16px 下方)作为 desc,使行仍被识别为列表项。
4024
+ // 双向检测:上下方合计 ≥2 个同列等距同伴即判周期行(中间行 1 上 1 下)。
4025
+ const periodicPeersAll = firstColTexts.filter((o) => o !== t
4026
+ && Math.abs((rect(o).x || 0) - (rect(t).x || 0)) <= 4
4027
+ && (ts(o).fontSize || 0) === (ts(t).fontSize || 0)
4028
+ && String(ts(o).color || '').toLowerCase() === String(ts(t).color || '').toLowerCase()
4029
+ && Math.abs((rect(o).y || 0) - y) >= 20 && Math.abs((rect(o).y || 0) - y) <= 200)
4030
+ if (periodicPeersAll.length >= 2) {
4031
+ const peerDys = periodicPeersAll.map((o) => (rect(o).y || 0) - y).sort((a, b) => Math.abs(a) - Math.abs(b))
4032
+ const candSteps = [...new Set([Math.abs(peerDys[0]), Math.abs(peerDys[1] || peerDys[0])])].filter((s) => s >= 20)
4033
+ const hasPeriodic = candSteps.some((candStep) => {
4034
+ const matched = peerDys.filter((g) => {
4035
+ const remainder = Math.abs(g) % candStep
4036
+ return Math.min(remainder, candStep - remainder) <= 12
4037
+ }).length
4038
+ return matched >= 2
4039
+ })
4040
+ if (hasPeriodic) {
4041
+ descEl = rowTexts.find((r) => {
4042
+ const ry = rect(r).y || 0
4043
+ return r !== t && ry >= y && ry - y <= 16
4044
+ && (ts(r).fontSize || 0) < (ts(t).fontSize || 0)
4045
+ && Math.abs((rect(r).x || 0) - (rect(t).x || 0)) <= (rect(t).width || 0) + 40
4046
+ }) || null
4047
+ }
4048
+ }
4049
+ if (!descEl) { lastY = y; continue }
4050
+ }
3224
4051
  // 时间行:日期分隔符含点/斜杠形态(2025.10.10 14:28:16)。此前只匹配横杠,
3225
4052
  // 点分时间行永远不被列表行消费,被 auxTexts 当孤立文本甩到卡底重渲染。
3226
4053
  const time = rowTexts.find((r) => r !== descEl
3227
4054
  && /\d{4}[-/.]\d{1,2}[-/.]\d{1,2}/.test(textOf(r))) || null
3228
- const amount = rowTexts.find((r) => r !== time && /¥|\.|元/.test(textOf(r)))
4055
+ // 行右金额候选必须含数字或货币符号:纯币种名(如 pill 标签「美元」)含「元」字但不是金额,误捕会把标签塞进数值表达式(渲染占位文案)并用其右沿算行右内边距挤窄标题行。
4056
+ const isRowAmountText = (v) => /\p{Sc}/u.test(v) || (/\d/.test(v) && /[.元]/.test(v))
4057
+ // 行第二行右侧文本(交易行「余额:¥20,077.00」):与 desc 行同基线、右半锚定,
4058
+ // 且非 desc/time → 行右列第二行,数据驱动 $descRight(先于 amount 计算,
4059
+ // 否则余额行被 amount 通道抢先造成右列双份)。
4060
+ const descRightEl = rowTexts.find((r) => r !== descEl && r !== time && r !== t
4061
+ && Math.abs((rect(r).y || 0) - (descEl ? (rect(descEl).y || 0) : y + 22)) <= 6
4062
+ && (rect(r).x || 0) >= (rect(card).x || 0) + (rect(card).width || 375) / 2) || null
4063
+ const amount = rowTexts.find((r) => r !== time && r !== descEl && r !== descRightEl && isRowAmountText(textOf(r)))
3229
4064
  // 标题同行后缀(如「张三」右侧灰色小字「(小张)」):同基线、紧随标题右沿
3230
4065
  const titleRightEdge = (rect(t).x || 0) + (rect(t).width || 0)
4066
+ // 行内联 affordance(紧跟账号的行尾眼睛图标):不锚行右沿而是紧贴标题右沿,
4067
+ // 逐行挂接后由模板按几何在标题行内联渲染(区别于右对齐行尾件)。
4068
+ const inlineAffordEl = [...(slices || []), ...symbols].find((s2) => {
4069
+ const r2 = rect(s2)
4070
+ const w2 = r2.width || 0
4071
+ const h2 = r2.height || 0
4072
+ if (w2 < 14 || w2 > 32 || h2 < 14 || h2 > 32) return false
4073
+ if (Math.abs((r2.y || 0) - y) > 8) return false
4074
+ const gap2 = (r2.x || 0) - titleRightEdge
4075
+ return gap2 >= 2 && gap2 <= 48
4076
+ }) || null
4077
+ if (inlineAffordEl) {
4078
+ const bi2 = isolatedBlocks.findIndex((b) => b.element === inlineAffordEl)
4079
+ if (bi2 >= 0) isolatedBlocks.splice(bi2, 1)
4080
+ }
3231
4081
  const suffixEl = rowTexts.find((r) => r !== t && r !== descEl
3232
4082
  && Math.abs((rect(r).y || 0) - y) <= 6
3233
4083
  && (rect(r).x || 0) >= titleRightEdge - 2 && (rect(r).x || 0) <= titleRightEdge + 16) || null
4084
+ // 标题行同行右侧文本(交易行金额,基线可能比标题高几 px):非复合行下
4085
+ // rowTexts 带不含它(y 略小于标题),不补 titleRight 通道行右金额就丢失。
4086
+ if (!compositePairs) {
4087
+ titleRightEl = texts.find((tt) => {
4088
+ if (tt === t || tt === descEl || tt === suffixEl || tt === descRightEl) return false
4089
+ const tr = rect(tt)
4090
+ return Math.abs((tr.y || 0) - y) <= 8
4091
+ && (tr.x || 0) >= titleRightEdge + 8
4092
+ && (tr.x || 0) >= (rect(card).x || 0) + (rect(card).width || 375) * 0.5
4093
+ }) || null
4094
+ }
3234
4095
  // 行图标:标题左侧、行带垂直范围内的切片(逐行不同 → 数据驱动)
3235
4096
  const rowBandTop = y - 12
3236
4097
  const rowBandBottom = (rect(descEl).y || 0) + (rect(descEl).height || 0) + 12
@@ -3244,6 +4105,16 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3244
4105
  // 行内操作 pill:右侧锚定的描边/填充小壳(宽 40-120 高 22-48)+ 单文本(如「删除」)
3245
4106
  const cardR0 = rect(card)
3246
4107
  const cardRight0 = (cardR0.x || 0) + (cardR0.width || 0)
4108
+ // 行右大图标(行壳右沿锚定的 32-72px 切片/符号,如保险产品 48x48):数据驱动 $rightIcon
4109
+ const rightIconEl = ((content || []).filter((e) => e.type === 'slice' || e.type === 'symbol')).find((s) => {
4110
+ if (s === iconEl) return false
4111
+ const r = rect(s)
4112
+ const w = r.width || 0; const h = r.height || 0
4113
+ if (w < 32 || w > 72 || h < 32 || h > 72) return false
4114
+ if ((r.x || 0) <= (rect(t).x || 0)) return false
4115
+ return (r.x || 0) + w >= cardRight0 - 48
4116
+ && (r.y || 0) >= rowBandTop && (r.y || 0) + h <= rowBandBottom + 8
4117
+ }) || null
3247
4118
  const actionShapeEl = (shapes || []).find((s) => {
3248
4119
  if (optionShapeSet.has(s)) return false
3249
4120
  const r = rect(s)
@@ -3272,6 +4143,9 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3272
4143
  title,
3273
4144
  // 行内容左右沿(spec 实测):行横向内边距按此驱动,避免通用 12px 与设计 16px 错位
3274
4145
  rowLeftEdge: Math.round(Math.min(rect(t).x || 0, iconEl ? (rect(iconEl).x || 0) : Infinity)),
4146
+ // 行内容真实右沿:行壳内边距推导用(全部行文本 + 图标 + 操作件右沿最大值)
4147
+ rowContentRight: Math.round(Math.max(0, ...[...rowTexts, iconEl, rightIconEl, actionShapeEl]
4148
+ .filter(Boolean).map((e) => (rect(e).x || 0) + (rect(e).width || 0)))) || null,
3275
4149
  // 只统计右栏内容(金额/时间/行内操作)的右沿;标题行后缀/右侧同行文本属于
3276
4150
  // 左栏标题流,混入会把行右内边距算成巨大值挤窄左栏(名册卡曾因此换行回归)。
3277
4151
  rowRightEdge: Math.round(Math.max(0, ...[amount, time, actionShapeEl]
@@ -3287,7 +4161,25 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3287
4161
  iconEl,
3288
4162
  iconW: iconEl ? Math.round(rect(iconEl).width || 0) : 0,
3289
4163
  iconH: iconEl ? Math.round(rect(iconEl).height || 0) : 0,
4164
+ rightIcon: rightIconEl
4165
+ ? ((resolveSliceExport(spec.slices || [], rightIconEl.name) || rightIconEl.annotationDefect?.cropSlice)
4166
+ ? sliceRefPath(resolveSliceExport(spec.slices || [], rightIconEl.name) || rightIconEl.annotationDefect?.cropSlice)
4167
+ : `http://iph.href.lu/${Math.round(rect(rightIconEl).width || 48)}x${Math.round(rect(rightIconEl).height || 48)}?text=${encodeURIComponent('缺素材')}`)
4168
+ : null,
4169
+ rightIconEl,
4170
+ rightIconW: rightIconEl ? Math.round(rect(rightIconEl).width || 0) : 0,
4171
+ rightIconH: rightIconEl ? Math.round(rect(rightIconEl).height || 0) : 0,
3290
4172
  action: actionTextEl ? textOf(actionTextEl) : '',
4173
+ affordInline: !!inlineAffordEl,
4174
+ affordInlineGap: inlineAffordEl
4175
+ ? Math.max(4, Math.round((rect(inlineAffordEl).x || 0) - titleRightEdge))
4176
+ : undefined,
4177
+ affordanceEl: inlineAffordEl || null,
4178
+ affordanceUrl: inlineAffordEl
4179
+ ? ((resolveSliceExport(spec.slices || [], inlineAffordEl.name) || inlineAffordEl.annotationDefect?.cropSlice)
4180
+ ? sliceRefPath(resolveSliceExport(spec.slices || [], inlineAffordEl.name) || inlineAffordEl.annotationDefect?.cropSlice)
4181
+ : `http://iph.href.lu/${Math.round(rect(inlineAffordEl).width || 20)}x${Math.round(rect(inlineAffordEl).height || 20)}?text=${encodeURIComponent('缺素材')}`)
4182
+ : '',
3291
4183
  actionShapeEl,
3292
4184
  actionColor: actionTextEl ? ts(actionTextEl).color : undefined,
3293
4185
  actionFontSize: actionTextEl ? ts(actionTextEl).fontSize : undefined,
@@ -3297,8 +4189,16 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3297
4189
  actionW: actionShapeEl ? Math.round(rect(actionShapeEl).width || 0) : 0,
3298
4190
  actionH: actionShapeEl ? Math.round(rect(actionShapeEl).height || 0) : 0,
3299
4191
  desc: compositePairs ? '' : textOf(descEl),
4192
+ descRight: descRightEl ? textOf(descRightEl) : '',
4193
+ descRightColor: descRightEl ? ts(descRightEl).color : undefined,
4194
+ descRightFontSize: descRightEl ? ts(descRightEl).fontSize : undefined,
3300
4195
  amounts: compositePairs ? compositePairs.map((pp) => ({
3301
4196
  label: textOf(pp.labelEl), value: textOf(pp.valueEl),
4197
+ tagText: pp.tagText || '', tagIcon: pp.tagIcon || '',
4198
+ tagFill: pp.tagFill || null, tagOpacity: pp.tagOpacity ?? 1, tagBorder: pp.tagBorder || null, tagRadius: pp.tagRadius || 0,
4199
+ tagW: pp.tagW || 0, tagH: pp.tagH || 0, tagGap: pp.tagGap, tagPadLeft: pp.tagPadLeft,
4200
+ tagPadRight: pp.tagPadRight, tagIconGap: pp.tagIconGap, tagIconW: pp.tagIconW, tagIconH: pp.tagIconH,
4201
+ tagTextColor: pp.tagTextColor, tagFontSize: pp.tagFontSize,
3302
4202
  labelColor: ts(pp.labelEl).color, labelFontSize: ts(pp.labelEl).fontSize,
3303
4203
  labelFontWeight: ts(pp.labelEl).fontWeight,
3304
4204
  labelX: Math.round(rect(pp.labelEl).x || 0), labelW: Math.round(rect(pp.labelEl).width || 0),
@@ -3332,10 +4232,13 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3332
4232
  descFontWeight: descEl ? ts(descEl).fontWeight : undefined,
3333
4233
  descHeight: descEl ? (rect(descEl).height || 0) : 0,
3334
4234
  descY: descEl ? (rect(descEl).y || 0) : undefined,
4235
+ descX: descEl ? (rect(descEl).x || 0) : undefined,
3335
4236
  amountColor: amount ? ts(amount).color : undefined,
3336
4237
  amountFontSize: amount ? ts(amount).fontSize : undefined,
3337
4238
  amountFontWeight: amount ? ts(amount).fontWeight : undefined,
3338
4239
  y,
4240
+ titleX: Math.round(rect(t).x || 0),
4241
+ titleW: Math.round(rect(t).width || 0),
3339
4242
  contentHeight: compositePairs
3340
4243
  ? Math.round(Math.max(...compositePairs.map((pp) => (rect(pp.valueEl).y || 0) + (rect(pp.valueEl).height || 0))) - y)
3341
4244
  : Math.round((rect(descEl).y || 0) + (rect(descEl).height || 0) - y),
@@ -3350,6 +4253,7 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3350
4253
  }
3351
4254
  if (titleRightEl) itemTextConsumed.add(titleRightEl)
3352
4255
  if (descEl) itemTextConsumed.add(descEl)
4256
+ if (descRightEl) itemTextConsumed.add(descRightEl)
3353
4257
  if (suffixEl) itemTextConsumed.add(suffixEl)
3354
4258
  if (actionTextEl) itemTextConsumed.add(actionTextEl)
3355
4259
  if (amount) itemTextConsumed.add(amount)
@@ -3410,6 +4314,12 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3410
4314
  let bestDy = Infinity
3411
4315
  for (const item of items) {
3412
4316
  if (item.affordanceEl) continue
4317
+ // 行右大图标内嵌小徽标(如 icon 角上的 ¥ badge)不是行尾 affordance:重叠即跳过,
4318
+ // 否则徽标挤占左栏宽度导致 desc 换行(保险保障行实测)
4319
+ const riR = item.rightIconEl ? rect(item.rightIconEl) : null
4320
+ if (riR && (er.x || 0) >= (riR.x || 0) - 6 && (er.y || 0) >= (riR.y || 0) - 6
4321
+ && (er.x || 0) + (er.width || 0) <= (riR.x || 0) + (riR.width || 0) + 6
4322
+ && (er.y || 0) + (er.height || 0) <= (riR.y || 0) + (riR.height || 0) + 6) continue
3413
4323
  const dy = Math.abs((item.y || 0) - (er.y || 0))
3414
4324
  if (dy <= tol && dy < bestDy) { bestDy = dy; hitItem = item }
3415
4325
  }
@@ -3444,7 +4354,34 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3444
4354
  if (itemTextConsumed.has(auxTexts[ai].sourceEl)) auxTexts.splice(ai, 1)
3445
4355
  }
3446
4356
  }
4357
+ // 同表模板一致性护栏(纯结构签名,不靠业务词):签名不同的行并进同一 ListLayout 会让
4358
+ // 模板字段错位(desc 落 title 槽竖排换行、缺键渲染占位文案),且少数行与静态语义块双份渲染。
4359
+ // 保留多数签名行成表;少数签名行退出列表,交还静态语义通道渲染。
4360
+ if (items.length >= 3) {
4361
+ const itemSig = (it) => [
4362
+ it.iconEl ? 1 : 0,
4363
+ (it.amounts && it.amounts.length) ? 1 : 0,
4364
+ it.rowRightEdge != null ? 1 : 0,
4365
+ it.actionShapeEl ? 1 : 0,
4366
+ ].join('')
4367
+ const sigCounts = new Map()
4368
+ for (const it of items) sigCounts.set(itemSig(it), (sigCounts.get(itemSig(it)) || 0) + 1)
4369
+ const majority = [...sigCounts.entries()].sort((a, b) => b[1] - a[1])[0]
4370
+ if (sigCounts.size >= 2 && majority[1] >= 2 && majority[1] < items.length) {
4371
+ const kept = items.filter((it) => itemSig(it) === majority[0])
4372
+ items.splice(0, items.length, ...kept)
4373
+ }
4374
+ }
3447
4375
  const sampleItems = items.slice(0, 3)
4376
+ // 表单行早于列表识别计算:行内 desc/descRight 被列表接管后必须同步回收,
4377
+ // 否则表单通道把同一批标注再渲染一遍(交易行余额行重影)。
4378
+ if (itemTextConsumed.size && Array.isArray(formRows)) {
4379
+ for (let fi = formRows.length - 1; fi >= 0; fi -= 1) {
4380
+ const ownedEls = [formRows[fi].labelEl, formRows[fi].valueEl, ...(formRows[fi].subEls || [])]
4381
+ .filter(Boolean)
4382
+ if (ownedEls.some((el) => itemTextConsumed.has(el))) formRows.splice(fi, 1)
4383
+ }
4384
+ }
3448
4385
  // richBlocks 早于列表识别按全量文本计算:tile/段落区域被列表接管后必须同步回收,
3449
4386
  // 否则富文本段落路径把同一批标注再渲染一遍(v6 产品 tile 重影,duplicate-content ×23)。
3450
4387
  // 双判据:① 碎片被列表消费覆盖率 ≥80%;② 几何落在列表区(与分隔线列表区护栏同族,
@@ -3499,12 +4436,79 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3499
4436
  && width >= shellCardWidth * 0.8 && height >= 32 && height <= 160
3500
4437
  && (elementRect.y || 0) <= items[index].y
3501
4438
  && items[index].y < (elementRect.y || 0) + height
3502
- && Math.abs(height - items[index].rowHeight) <= Math.max(4, items[index].rowHeight * 0.12)
4439
+ && (elementRect.y || 0) + height <= items[index].y + items[index].rowHeight + 4
4440
+ && Math.abs(height - items[index].rowHeight) <= Math.max(12, items[index].rowHeight * 0.25)
3503
4441
  }).sort((left, right) => Math.abs((rect(left).height || 0) - items[index].rowHeight)
3504
4442
  - Math.abs((rect(right).height || 0) - items[index].rowHeight))
4443
+ // 有填充的壳优先:同尺寸包裹层(group/symbol)无 fill,选中它行底色/圆角恒丢
4444
+ shellCandidates.sort((left, right) => {
4445
+ const lf = left.style?.fill
4446
+ const rf = right.style?.fill
4447
+ const lNo = !(lf && (lf.color || lf.gradient)) ? 1 : 0
4448
+ const rNo = !(rf && (rf.color || rf.gradient)) ? 1 : 0
4449
+ return lNo - rNo
4450
+ })
3505
4451
  const shell = shellCandidates[0]
3506
- items[index].rowTop = shell ? (rect(shell).y || 0) : items[index].y
4452
+ // 视觉行壳判定:满宽 + 无圆角 + 无渐变 = 分区背景板(非行壳)。误认领会把
4453
+ // 内边距/底色/分割线全搅乱(我的账户卡实测 padRight 303 把左栏压没、pos 崩 0.33)
4454
+ const shellVisual = !!shell && ((radiusMax(shell.style) || 0) >= 4
4455
+ || !!shell.style?.fill?.gradient
4456
+ || (rect(shell).width || 0) <= ((rect(card).width || shellCardWidth) - 8))
4457
+ items[index].rowTop = shell ? (rect(shell).y || 0)
4458
+ : (items[index].iconEl ? Math.min(items[index].y, rect(items[index].iconEl).y || items[index].y) : items[index].y)
3507
4459
  items[index].shellHeight = shell ? (rect(shell).height || items[index].rowHeight) : items[index].rowHeight
4460
+ // 行壳样式/内边距传给列表模板(emit 读 first.fill/gradient/radius/pad*,此前从未赋值 → 行底色恒丢)
4461
+ if (shell) {
4462
+ const sr2 = rect(shell)
4463
+ items[index].shellEl = shell
4464
+ items[index].shellVisual = shellVisual
4465
+ if (shellVisual) {
4466
+ items[index].fill = shell.style?.fill?.color || null
4467
+ items[index].gradient = shell.style?.fill?.gradient || null
4468
+ items[index].opacity = shell.style?.opacity ?? 1
4469
+ items[index].radius = radiusMax(shell.style)
4470
+ // 行内容真实右沿(全部行文本 + 左右图标 + 操作件):只按金额/时间右沿会把
4471
+ // 标题行卡的 padRight 算成几百 px 挤垮左栏
4472
+ const contentRight = items[index].rowContentRight || ((sr2.x || 0) + (sr2.width || 0) - 16)
4473
+ items[index].padLeft = Math.max(0, Math.round((items[index].rowLeftEdge || 0) - (sr2.x || 0)))
4474
+ items[index].padRight = Math.min(Math.max(0, Math.round(((sr2.x || 0) + (sr2.width || 0)) - contentRight)), Math.round((sr2.width || 0) / 2))
4475
+ items[index].padTop = Math.max(0, Math.round(((sr2.height || 0) - (items[index].contentHeight || 0)) / 2))
4476
+ }
4477
+ items[index].height = Math.round(sr2.height || items[index].rowHeight)
4478
+ } else {
4479
+ items[index].height = items[index].shellHeight || items[index].rowHeight
4480
+ items[index].padLeft = 16
4481
+ items[index].padRight = 16
4482
+ items[index].padTop = 8
4483
+ }
4484
+ // 进度条归属列表行:进度条 y 落在列表行带内时挂到该列表项(逐行渲染),
4485
+ // 否则作为独立语义块渲染(insertSemantic 全堆在卡底/同一 y 位→重叠)。
4486
+ const _listBarEls = new Set()
4487
+ if (items.length && progressBars.length) {
4488
+ for (const bar of progressBars) {
4489
+ const by = bar.y ?? 0
4490
+ const matched = items.find((item) => {
4491
+ const top = item.rowTop ?? item.y ?? 0
4492
+ const bottom = top + (item.shellHeight || item.rowHeight || item.height || 0)
4493
+ return by >= top && by < bottom
4494
+ })
4495
+ if (matched) {
4496
+ if (!matched.bars) matched.bars = []
4497
+ matched.bars.push(bar)
4498
+ if (bar.trackEl) _listBarEls.add(bar.trackEl)
4499
+ for (const el of (bar.segmentEls || [])) _listBarEls.add(el)
4500
+ }
4501
+ }
4502
+ if (_listBarEls.size) {
4503
+ for (let bi = progressBars.length - 1; bi >= 0; bi -= 1) {
4504
+ const bar = progressBars[bi]
4505
+ if ((bar.trackEl && _listBarEls.has(bar.trackEl))
4506
+ || (bar.segmentEls || []).some((el) => _listBarEls.has(el))) {
4507
+ progressBars.splice(bi, 1)
4508
+ }
4509
+ }
4510
+ }
4511
+ }
3508
4512
  }
3509
4513
 
3510
4514
  // —— 查看更多 ——
@@ -3638,6 +4642,9 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3638
4642
  if (!raw) continue
3639
4643
  const verb = (raw.match(verbRe) || [''])[0] || ''
3640
4644
  const core = verb ? raw.replace(verbRe, '').trim() : raw
4645
+ // 日期区间/点分单日(2025.11.28-2025.12.04):整串是单一展示期间,splitValueTokens 会把它
4646
+ // 切成「2025.11」等数字段误绑金额表达式(渲染出千分号)。保持字面量。
4647
+ if (/^\d{4}[-./]\d{1,2}[-./]\d{1,2}(\s*[-~—至]\s*\d{4}[-./]\d{1,2}[-./]\d{1,2})?$/.test(core)) continue
3641
4648
  const segs = splitValueTokens(core)
3642
4649
  // 绑定条件(满足其一):
3643
4650
  // ① 含「强值信号」(phone/cardNumber/amount/code/date) —— 实体行(张三 13800138000);
@@ -3805,6 +4812,9 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3805
4812
  const bg = (bgRaw || '').toUpperCase()
3806
4813
  if (!bg || NEUTRAL_COLORS.has(bg)) continue
3807
4814
  const matchingText = texts.find((t) => {
4815
+ // 数值文本(汇总区金额)不与下划/高亮带配对:比例条(h=4)曾被误认为金额层下划,
4816
+ // 吞掉汇总区次列值(300.00 丢失)。
4817
+ if (isAmountish(textOf(t))) return false
3808
4818
  const tr = rect(t)
3809
4819
  const tBottom = (tr.y || 0) + (tr.height || 0)
3810
4820
  const gap = (sr.y || 0) - tBottom
@@ -3844,13 +4854,215 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3844
4854
  midCluster?.logoSlice,
3845
4855
  primaryAmount?.iconSlice,
3846
4856
  ].filter(Boolean))
3847
- const orphanSlices = slices.filter((s) => {
3848
- if (orphanConsumed.has(s)) return false
3849
- const sr = rect(s)
3850
- const w = sr.width || 0, h = sr.height || 0
3851
- return w >= 60 && h >= 24 && h <= 140 && w <= (rect(card).width || 375) + 40
4857
+ const orphanSlices = slices.filter((s) => {
4858
+ if (orphanConsumed.has(s)) return false
4859
+ if (floatDecos.includes(s)) return false
4860
+ const sr = rect(s)
4861
+ const w = sr.width || 0, h = sr.height || 0
4862
+ const cRf = rect(card)
4863
+ // 浮动装饰切片(卡头月亮/吉祥物光晕等):竖直方向与文本带重叠、水平贴卡缘 →
4864
+ // 不能占流式行高(否则整卡内容被顶下一个装饰高度),改由绝对定位叠层渲染。
4865
+ const overlapsText = texts.some((t) => {
4866
+ const tr = rect(t)
4867
+ const ov = Math.min((sr.y || 0) + h, (tr.y || 0) + (tr.height || 0)) - Math.max(sr.y || 0, tr.y || 0)
4868
+ return ov >= h * 0.2 && h >= (tr.height || 0) * 2.2
4869
+ })
4870
+ const edgeAnchored = ((sr.x || 0) + w) >= ((cRf.x || 0) + (cRf.width || 0)) - 24
4871
+ if (overlapsText && edgeAnchored && w <= (cRf.width || 375) * 0.5
4872
+ && !buttonShapes.some((b) => coverRatio(s, b) > 0.5)
4873
+ && !panelCandidates.some((p) => coverRatio(s, p) > 0.5)) {
4874
+ floatDecos.push(s)
4875
+ return false
4876
+ }
4877
+ return w >= 60 && h >= 24 && h <= 140 && w <= (rect(card).width || 375) + 40
4878
+ })
4879
+
4880
+ // —— 背景壳层(section/行/面板壳):带圆角填充的中型 shape,供列表行样式、表单行包裹、段落底色认领 ——
4881
+ // 此前卡宽壳被 isBg 排除、行壳因「壳高 vs 行 pitch 差 12px」超容差漏配,列表行/面板/段落全部丢底色。
4882
+ const shellLayers = (card.elements || []).filter((e) => {
4883
+ if (e.type !== 'shape') return false
4884
+ const f = e.style?.fill
4885
+ if (!f || (!f.color && !f.gradient)) return false
4886
+ if (radiusMax(e.style) < 4) return false
4887
+ const r = rect(e)
4888
+ const cR = rect(card)
4889
+ const w = r.width || 0
4890
+ const h = r.height || 0
4891
+ if (w < (cR.width || 0) * 0.4 || w > (cR.width || 0) + 4) return false
4892
+ if (h < 40 || h > (cR.height || 0) * 0.6) return false
4893
+ return true
4894
+ }).map((e) => ({
4895
+ el: e,
4896
+ x: rect(e).x || 0, y: rect(e).y || 0, width: rect(e).width || 0, height: rect(e).height || 0,
4897
+ fill: e.style?.fill?.color || null,
4898
+ gradient: e.style?.fill?.gradient || null,
4899
+ radius: radiusMax(e.style),
4900
+ opacity: e.style?.opacity ?? 1,
4901
+ }))
4902
+
4903
+ // —— 薄壳通道(行级描边/浅填充窄矩形)——
4904
+ // 容器级壳由 panelRows / gradientPanels 认领,壳层背景由 shellLayers 认领(要求 h>=40 且 radius>=4);
4905
+ // 行级薄壳(h 18..48、radius<=6、带描边或填充)两头落空 → 描边与底色整体丢失:
4906
+ // 表格行只剩裸文本,无文本的分段壳被当孤立媒体块渲染成「缺素材」占位图。
4907
+ const thinShellStyle = (shape) => {
4908
+ const sr = rect(shape)
4909
+ const border = shape.style?.border || {}
4910
+ const fill = shape.style?.fill || {}
4911
+ return {
4912
+ shapeEl: shape,
4913
+ x: sr.x || 0, y: sr.y || 0, width: sr.width || 0, height: sr.height || 0,
4914
+ fill: fill.color || null,
4915
+ gradient: fill.gradient || null,
4916
+ opacity: shape.style?.opacity ?? 1,
4917
+ radius: radiusMax(shape.style),
4918
+ isBorder: !!border.isBorder,
4919
+ borderWidth: Number(border.width) || 0,
4920
+ borderColor: border.color || null,
4921
+ }
4922
+ }
4923
+ const thinInside = (element, shape) => {
4924
+ const er = rect(element)
4925
+ const sr = rect(shape)
4926
+ return (er.x || 0) >= (sr.x || 0) - 2 && (er.y || 0) >= (sr.y || 0) - 2
4927
+ && ((er.x || 0) + (er.width || 0)) <= ((sr.x || 0) + (sr.width || 0)) + 2
4928
+ && ((er.y || 0) + (er.height || 0)) <= ((sr.y || 0) + (sr.height || 0)) + 2
4929
+ }
4930
+ const thinShellShapes = (card.elements || []).filter((element) => {
4931
+ if (element.type !== 'shape') return false
4932
+ const sr = rect(element)
4933
+ const cR = rect(card)
4934
+ const w = sr.width || 0
4935
+ const h = sr.height || 0
4936
+ if (h < 18 || h > 48) return false
4937
+ if (w < (cR.width || 0) * 0.2 || w > (cR.width || 0) + 4) return false
4938
+ if (radiusMax(element.style) > 6) return false
4939
+ const border = element.style?.border || {}
4940
+ const fill = element.style?.fill || {}
4941
+ if (!border.isBorder && !fill.color && !fill.gradient) return false
4942
+ if (panelShapes.includes(element) || optionShapeSet.has(element)) return false
4943
+ if (gradientPanels.some((gp) => gp.shapeEl === element)) return false
4944
+ // 与卡身同色且无描边/渐变 = 卡底延续补片,不是行壳
4945
+ if (!border.isBorder && !fill.gradient
4946
+ && String(fill.color || '').toUpperCase() === String(card.cardFill || '').toUpperCase()) return false
4947
+ return true
3852
4948
  })
4949
+ const thinShellRows = []
4950
+ const thinShellStrips = []
4951
+ {
4952
+ const freeShells = []
4953
+ for (const shape of thinShellShapes) {
4954
+ const inner = texts.filter((t) => thinInside(t, shape))
4955
+ if (inner.length) thinShellRows.push({ ...thinShellStyle(shape), texts: inner })
4956
+ else freeShells.push(shape)
4957
+ }
4958
+ // 无文本薄壳按「同 y 带 + 首尾相接(间隙 <=8)」聚成分段横条(时间轴/进度分段);
4959
+ // 单壳不成条,保持原孤立媒体块兜底,>=2 段才按几何分段还原。
4960
+ const ordered = freeShells.slice().sort((a, b) =>
4961
+ (rect(a).y || 0) - (rect(b).y || 0) || (rect(a).x || 0) - (rect(b).x || 0))
4962
+ const claimed = new Set()
4963
+ for (const shape of ordered) {
4964
+ if (claimed.has(shape)) continue
4965
+ const sr = rect(shape)
4966
+ const members = [shape]
4967
+ claimed.add(shape)
4968
+ let cursor = (sr.x || 0) + (sr.width || 0)
4969
+ for (const other of ordered) {
4970
+ if (claimed.has(other)) continue
4971
+ const or = rect(other)
4972
+ if (Math.abs((or.y || 0) - (sr.y || 0)) > 4) continue
4973
+ if ((or.x || 0) < cursor - 2 || (or.x || 0) - cursor > 8) continue
4974
+ members.push(other)
4975
+ claimed.add(other)
4976
+ cursor = (or.x || 0) + (or.width || 0)
4977
+ }
4978
+ if (members.length < 2) continue
4979
+ members.sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))
4980
+ const boxes = members.map(thinShellStyle)
4981
+ const left = Math.min(...boxes.map((box) => box.x))
4982
+ const right = Math.max(...boxes.map((box) => box.x + box.width))
4983
+ thinShellStrips.push({
4984
+ x: left,
4985
+ y: Math.min(...boxes.map((box) => box.y)),
4986
+ width: right - left,
4987
+ height: Math.max(...boxes.map((box) => box.height)),
4988
+ boxes,
4989
+ })
4990
+ }
4991
+ }
4992
+ // 分段横条认领的壳不再作孤立媒体块(否则同一几何渲染成「缺素材」占位图)
4993
+ const thinStripEls = new Set(thinShellStrips.flatMap((strip) => strip.boxes.map((box) => box.shapeEl)))
4994
+ for (let index = isolatedBlocks.length - 1; index >= 0; index -= 1) {
4995
+ if (thinStripEls.has(isolatedBlocks[index].element)) isolatedBlocks.splice(index, 1)
4996
+ }
4997
+
4998
+ // —— 壳内右列文本(两列拓扑)——
4999
+ // 内容壳(面板壳/渐变壳)右半区独立成列的文本:>=2 个文本左边界落在壳宽 45% 之后且互相成列
5000
+ // (x 差 <=8、分属不同行),壳内左侧另有 >=2 个文本 → 该壳是「左列 + 右列」拓扑而非竖排单列。
5001
+ // 此前右列文本或被压成竖排堆叠(panelRows 路径),或整段落进主视觉死区不渲染(渐变壳路径)。
5002
+ const shellButtonTextEls = new Set(buttons.map((button) => button.sourceEl).filter(Boolean))
5003
+ const shellRightColumns = []
5004
+ for (const shape of [...new Set([...panelShapes, ...gradientPanels.map((gp) => gp.shapeEl)])]) {
5005
+ const sr = rect(shape)
5006
+ const inner = texts.filter((t) => thinInside(t, shape) && !shellButtonTextEls.has(t))
5007
+ if (inner.length < 4) continue
5008
+ const topY = Math.min(...inner.map((t) => rect(t).y || 0))
5009
+ // 标题行(与壳内首个文本同 band)整体保留为整行:名称 + 紧邻短标签不是右列成员
5010
+ const headerEls = inner.filter((t) => Math.abs((rect(t).y || 0) - topY) <= 8)
5011
+ const headerSet = new Set(headerEls)
5012
+ const splitX = (sr.x || 0) + (sr.width || 0) * 0.45
5013
+ const columns = []
5014
+ for (const t of inner.filter((item) => !headerSet.has(item) && (rect(item).x || 0) >= splitX)
5015
+ .slice().sort((a, b) => (rect(a).x || 0) - (rect(b).x || 0))) {
5016
+ const tx = rect(t).x || 0
5017
+ const column = columns.find((c) => Math.abs(c.x - tx) <= 8)
5018
+ if (column) column.items.push(t)
5019
+ else columns.push({ x: tx, items: [t] })
5020
+ }
5021
+ const best = columns
5022
+ .filter((column) => column.items.length >= 2
5023
+ && new Set(column.items.map((t) => Math.round(rect(t).y || 0))).size >= 2)
5024
+ .sort((a, b) => b.items.length - a.items.length)[0]
5025
+ if (!best) continue
5026
+ const bestSet = new Set(best.items)
5027
+ const left = inner.filter((t) => !headerSet.has(t) && !bestSet.has(t) && (rect(t).x || 0) < splitX)
5028
+ if (left.length < 2) continue
5029
+ shellRightColumns.push({
5030
+ shapeEl: shape,
5031
+ x: sr.x || 0, y: sr.y || 0, width: sr.width || 0, height: sr.height || 0,
5032
+ splitX: best.x,
5033
+ headerEls,
5034
+ texts: best.items.slice().sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0)).map((t) => ({
5035
+ text: textOf(t),
5036
+ color: ts(t).color,
5037
+ fontSize: ts(t).fontSize,
5038
+ fontWeight: ts(t).fontWeight,
5039
+ align: ts(t).textAlign,
5040
+ sourceEl: t,
5041
+ x: rect(t).x || 0, y: rect(t).y || 0,
5042
+ width: rect(t).width || 0, height: rect(t).height || 0,
5043
+ })),
5044
+ })
5045
+ }
5046
+ const shellRightColEls = new Set(shellRightColumns.flatMap((column) => column.texts.map((t) => t.sourceEl)))
5047
+ // 右列文本改由两列拓扑通道渲染:从辅助文案 / 副金额对中回收,避免竖排重影或甩到壳外
5048
+ for (let index = auxTexts.length - 1; index >= 0; index -= 1) {
5049
+ if (shellRightColEls.has(auxTexts[index].sourceEl)) auxTexts.splice(index, 1)
5050
+ }
5051
+ for (let index = subAmounts.length - 1; index >= 0; index -= 1) {
5052
+ const item = subAmounts[index]
5053
+ if (shellRightColEls.has(item.labelEl) || shellRightColEls.has(item.valueEl)) subAmounts.splice(index, 1)
5054
+ }
5055
+ // 右列文本 / 面板成员文本不得经 postAmountTexts 双份渲染(面板两列拓扑已整块接管)。
5056
+ // postAmountTexts 早于 shellRightColumns 计算,此前只从 auxTexts 回收——
5057
+ // listTitle 为 null 时 postAmountTexts 上界=∞,面板内文本漏入独立文本行→重影。
5058
+ for (let index = postAmountTexts.length - 1; index >= 0; index -= 1) {
5059
+ if (shellRightColEls.has(postAmountTexts[index].sourceEl)
5060
+ || panelTextConsumed.has(postAmountTexts[index].sourceEl)) postAmountTexts.splice(index, 1)
5061
+ }
3853
5062
 
5063
+ // 选项组文本字符串集:midCluster 与选项组命中同一批文本时(图标+标签 chip 行),
5064
+ // midCluster 再渲染一遍即重影(chip 行双份)→ 选项组优先,midCluster 让位。
5065
+ const optionTextStrings = new Set([...optionTextSet].map((t) => textOf(t)))
3854
5066
  return {
3855
5067
  canvas: spec.size || { width: 375, height: 812 },
3856
5068
  artboardType: spec.artboardType || 'card',
@@ -3858,7 +5070,8 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3858
5070
  cardFill: card.cardFill || null,
3859
5071
  cardRadius: (card.mask && card.mask.radius && card.mask.radius[0]) || null,
3860
5072
  tabs, buttons, optionGroups, primaryAmount, subAmounts, auxTexts, postAmountTexts, tabY, badgeRows,
3861
- listTitle, sampleItems, allItems, viewMore, collapseBtn, countdown, topDecoration, formRows, midCluster: midCluster && (midCluster.items || []).length >= 2 ? midCluster : null, bindings,
5073
+ topTintBlobs,
5074
+ 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,
3862
5075
  enumStatMap, // 枚举统计行预扫描结果(raw 文本 → {items, sep})
3863
5076
  cardBackgroundGradient: fullCardGradientShape?.style?.fill?.gradient || null,
3864
5077
  symbolMediaRows,
@@ -3868,9 +5081,12 @@ export function extractSemantics(spec, cardIndex = 0, opts = {}) {
3868
5081
  panelRows,
3869
5082
  titleUnderlines,
3870
5083
  subAmountPanel,
3871
- orphanSlices,
5084
+ orphanSlices, floatDecos,
5085
+ thinShellRows, thinShellStrips, shellRightColumns,
3872
5086
  gradientPanels, panelGroup,
3873
5087
  richBlocks,
5088
+ chartClusters,
5089
+ shellLayers,
3874
5090
  contentBounds: (() => {
3875
5091
  const cardWidth = rect(card).width || 0
3876
5092
  const cardHeight = rect(card).height || 0
@@ -4224,7 +5440,20 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4224
5440
  if (sem.cardBackgroundGradient) {
4225
5441
  ensureStyleGroup(card, 'background')
4226
5442
  setStyleChild(card, 'background', 'backgroundType', '03')
4227
- setStyleChild(card, 'background', 'backgroundGradient', sem.cardBackgroundGradient)
5443
+ // spec 椭圆优先于采样硬停色带:硬停带的带色本就采样自椭圆投射,带底却留矩形硬边
5444
+ // (设计稿是椭圆柔边衰减)。有椭圆标注时改逐颗 radial-gradient 叠层、以采样带身色
5445
+ // 为底,硬边消失且带顶色温与设计稿逐位对齐。
5446
+ const tintBlobs = sem.topTintBlobs || []
5447
+ if (tintBlobs.length) {
5448
+ const bandHexes = sem.cardBackgroundGradient.match(/#[0-9A-Fa-f]{6}/g) || []
5449
+ const bodyC = hexToRgba(bandHexes[bandHexes.length - 1] || sem.cardFill || T.color('cardBg'))
5450
+ const blobLayers = [...tintBlobs].reverse().map((b) =>
5451
+ `radial-gradient(${b.rx}px ${b.ry}px at ${b.cx}px ${b.cy}px, ${b.rgba} 0%, ${b.rgba} 99%, rgba(0, 0, 0, 0) 100%)`)
5452
+ blobLayers.push(`linear-gradient(to bottom, ${bodyC} 0%, ${bodyC} 100%)`)
5453
+ setStyleChild(card, 'background', 'backgroundGradient', blobLayers.join(', '))
5454
+ } else {
5455
+ setStyleChild(card, 'background', 'backgroundGradient', sem.cardBackgroundGradient)
5456
+ }
4228
5457
  }
4229
5458
  // 高度必须由内容撑开:设计稿只约束卡片宽度与纵向节奏,
4230
5459
  // 写死根高度会在语义块估算偏高时裁切真实内容。
@@ -4292,8 +5521,16 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4292
5521
  const bodyC = hexToRgba(sem.cardFill || T.color('cardBg'))
4293
5522
  ensureStyleGroup(card, 'background')
4294
5523
  setStyleChild(card, 'background', 'backgroundType', '03')
4295
- setStyleChild(card, 'background', 'backgroundGradient',
4296
- `linear-gradient(to bottom, ${bandC} 0%, ${bandC} ${pct}%, ${bodyC} ${pct}%, ${bodyC} 100%)`)
5524
+ if ((sem.topTintBlobs || []).length) {
5525
+ // 椭圆逐颗 radial-gradient(CSS 首层在最上 → spec 序反排),末层补卡身底色
5526
+ const layers = [...sem.topTintBlobs].reverse().map((b) =>
5527
+ `radial-gradient(${b.rx}px ${b.ry}px at ${b.cx}px ${b.cy}px, ${b.rgba} 0%, ${b.rgba} 99%, rgba(0, 0, 0, 0) 100%)`)
5528
+ layers.push(`linear-gradient(to bottom, ${bodyC} 0%, ${bodyC} 100%)`)
5529
+ setStyleChild(card, 'background', 'backgroundGradient', layers.join(', '))
5530
+ } else {
5531
+ setStyleChild(card, 'background', 'backgroundGradient',
5532
+ `linear-gradient(to bottom, ${bandC} 0%, ${bandC} ${pct}%, ${bodyC} ${pct}%, ${bodyC} 100%)`)
5533
+ }
4297
5534
  } else if (td.solidColor) {
4298
5535
  // 纯色顶带(弥散背景的设计稿采样近似,无切片素材时的纯色兜底):
4299
5536
  // 固定高度 + 纯色背景;内容归属沿用几何 y 边界(y < 带底沿的内容进带内容器)。
@@ -4338,6 +5575,28 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4338
5575
  const buildCard = sem.cardRect || {}
4339
5576
  const buildCardLeft = buildCard.x || 0
4340
5577
  const buildCardRight = buildCardLeft + (buildCard.width || sem.canvas?.width || 375)
5578
+ // 背景壳认领:取包含给定 rect 中心的最内层壳(面积最小者)
5579
+ const shellForRect = (r) => {
5580
+ const cx = (r.x || 0) + (r.width || 0) / 2
5581
+ const cy = (r.y || 0) + (r.height || 0) / 2
5582
+ const hits = (sem.shellLayers || []).filter((sh) =>
5583
+ cx >= sh.x && cx <= sh.x + sh.width && cy >= sh.y && cy <= sh.y + sh.height)
5584
+ if (!hits.length) return null
5585
+ return hits.sort((a, b) => (a.width * a.height) - (b.width * b.height))[0]
5586
+ }
5587
+ const applyShellBackground = (node, sh) => {
5588
+ ensureStyleGroup(node, 'background')
5589
+ ensureStyleGroup(node, 'borderRadius')
5590
+ if (sh.gradient) {
5591
+ setStyleChild(node, 'background', 'backgroundType', '03')
5592
+ setStyleChild(node, 'background', 'backgroundGradient', gradientWithOpacity(sh.gradient, sh.opacity ?? 1))
5593
+ } else {
5594
+ setStyleChild(node, 'background', 'backgroundType', '01')
5595
+ setStyleChild(node, 'background', 'backgroundColor', hexToRgba(sh.fill))
5596
+ }
5597
+ setStyleChild(node, 'borderRadius', 'borderRadiusType', '0')
5598
+ setStyleChild(node, 'borderRadius', 'borderRadius', String(Math.round(sh.radius || 8)))
5599
+ }
4341
5600
  const semanticBounds = []
4342
5601
  const addSemanticBound = (top, bottom, container = false) => {
4343
5602
  if (Number.isFinite(Number(top)) && Number.isFinite(Number(bottom)) && bottom > top) {
@@ -4351,7 +5610,22 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4351
5610
  // 与 textFromSpec 实际盒高严格同款(estimatedTextBoxHeight):膨胀系数(fs×1.5)
4352
5611
  // 会高估盒高 1-3px/行,把下一元素顶距压小、整卡内容逐行累积上移(实测面板/头部行 -3~-5px)。
4353
5612
  y + estimatedTextBoxHeight(height, fontSize)
4354
- for (const aux of sem.auxTexts || []) addSemanticBound(aux.y, textBoundBottom(aux.y, aux.height, aux.fontSize))
5613
+ // 图表矢量簇占位边界:Canvas 块是流内真实高度,登记后下文顶距才不会从更早的底沿起算
5614
+ for (const cluster of sem.chartClusters || []) {
5615
+ addSemanticBound(cluster.y, cluster.y + cluster.height, true)
5616
+ }
5617
+ for (const aux of sem.auxTexts || []) {
5618
+ addSemanticBound(aux.y, textBoundBottom(aux.y, aux.height, aux.fontSize))
5619
+ // 主视觉领行(文本左侧大切片):流式行高取主视觉高度;主视觉溢出卡顶时流式
5620
+ // 只能从卡顶起排 → 登记实际流式占位(卡顶..卡顶+主视觉高),下方行顶距才不重叠。
5621
+ const leadR = aux.sliceEl ? rect(aux.sliceEl) : null
5622
+ if (leadR && (leadR.height || 0) > (aux.height || 0) * 1.6) {
5623
+ // 与渲染端绝对定位带同口径:带顶=卡顶与(主视觉/文本)顶较大者,带底=子元素底沿较大者
5624
+ const bandTop = Math.max((sem.cardRect && sem.cardRect.y) || 0, Math.min(leadR.y || 0, aux.y || 0))
5625
+ const bandBottom = Math.max((leadR.y || 0) + (leadR.height || 0), (aux.y || 0) + (aux.height || 0))
5626
+ addSemanticBound(bandTop, bandBottom)
5627
+ }
5628
+ }
4355
5629
  // 序号徽章行:壳高为边界(内容多行时以内容底沿为准)
4356
5630
  for (const badgeRow of sem.badgeRows || []) {
4357
5631
  const shellR = rect(badgeRow.shellEl)
@@ -4434,6 +5708,7 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4434
5708
  addSemanticBound(sem.topDecoration.y, (sem.topDecoration.y || 0) + (sem.topDecoration.height || 0), true)
4435
5709
  }
4436
5710
  }
5711
+ let mcValueRowHandlesPrimary = false
4437
5712
  if (sem.midCluster) {
4438
5713
  addSemanticBound(sem.midCluster.y, sem.midCluster.y + Math.max(
4439
5714
  ...sem.midCluster.items.map((item) => item.height || (item.fontSize || 14) * 1.4),
@@ -4453,6 +5728,47 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4453
5728
  for (const block of sem.isolatedBlocks || []) {
4454
5729
  addSemanticBound(block.y, block.y + (block.height || rect(block.element).height || 0))
4455
5730
  }
5731
+ for (const strip of sem.thinShellStrips || []) {
5732
+ addSemanticBound(strip.y, (strip.y || 0) + (strip.height || 0))
5733
+ }
5734
+ // 行级薄壳(表格行壳 / 条目壳):壳高是几何真值,壳底沿比内部文本盒低 2-4px;
5735
+ // 只登记文本边界会让后续行顶距多算这几像素,逐行累积成整卡竖向漂移。
5736
+ for (const shell of sem.thinShellRows || []) {
5737
+ addSemanticBound(shell.y, (shell.y || 0) + (shell.height || 0))
5738
+ }
5739
+ // allTexts 条目是归一化形状(顶层 fontSize/color + sourceEl 指回 spec 元素):几何/样式双源回退
5740
+ const anyRect = (t) => (t && t.x != null ? { x: t.x, y: t.y, width: t.width, height: t.height } : rect(t && t.sourceEl ? t.sourceEl : t))
5741
+ const anyFs = (t) => (t && t.fontSize) || ts(t).fontSize || ts(t && t.sourceEl).fontSize
5742
+ const anyColor = (t) => (t && t.color) || ts(t).color || ts(t && t.sourceEl).color
5743
+ // 行尾随切片(链接箭头/信息图标):文本右邻小图标随行渲染并消费,避免孤立媒体兜底再占一行
5744
+ const consumedTrailSlices = new Set()
5745
+ const linkConsumed = new Set()
5746
+ const appendInlineTrailSlice = (row, pat) => {
5747
+ const pr = { x: pat.x || 0, y: pat.y || 0, width: pat.width || 0, height: pat.height || (pat.fontSize || 14) * 1.4 }
5748
+ const centerY = pr.y + pr.height / 2
5749
+ const cand = (sem.slices || []).find((sl) => {
5750
+ if (consumedTrailSlices.has(sl)) return false
5751
+ const sr = rect(sl)
5752
+ const w = sr.width || 0
5753
+ const h = sr.height || 0
5754
+ return w >= 8 && w <= 32 && h >= 8 && h <= 32
5755
+ && (sr.x || 0) >= pr.x + pr.width - 6 && (sr.x || 0) <= pr.x + pr.width + 56
5756
+ && Math.abs(((sr.y || 0) + h / 2) - centerY) <= Math.max(12, pr.height / 2 + 6)
5757
+ })
5758
+ if (!cand) return null
5759
+ consumedTrailSlices.add(cand)
5760
+ sem.slices = (sem.slices || []).filter((sl) => sl !== cand)
5761
+ sem.isolatedBlocks = (sem.isolatedBlocks || []).filter((bl) => bl.element !== cand)
5762
+ const img = imageOrPlaceholder(cand, sem.slicesExports)
5763
+ if (!img) return null
5764
+ const cr = rect(cand)
5765
+ setStyleChild(img, 'rect', 'width', String(Math.round(cr.width || 16)))
5766
+ setStyleChild(img, 'rect', 'height', String(Math.round(cr.height || 16)))
5767
+ ensureStyleGroup(row, 'flex')
5768
+ setStyleChild(row, 'flex', 'gap', String(Math.max(0, Math.round((cr.x || 0) - (pr.x + pr.width)))))
5769
+ row.slots[0].components.push(img)
5770
+ return img
5771
+ }
4456
5772
  const previousSemanticBottom = (y) => {
4457
5773
  // 包含 y 的边界按 y 截断:行高按 fs×1.5 膨胀的边界会盖过下一行顶沿,
4458
5774
  // 只认 bottom<=y 会漏掉它、把间距算成跨两行(如"最高可贷金额"顶距被算成 44px 而非 0)。
@@ -4493,6 +5809,14 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4493
5809
  2 * T.pad('section'))
4494
5810
  : (buildCard.y || 0)
4495
5811
 
5812
+ // 标题下划线查找表:text content → underline info。提前到 tabs 块之前构建,
5813
+ // 卡头 tabs 路径与下方 aux/band/listTitle 包裹逻辑共用同一张表。
5814
+ const underlineByText = new Map()
5815
+ for (const ul of sem.titleUnderlines || []) {
5816
+ underlineByText.set(ul.text, ul)
5817
+ }
5818
+ const headTabLabels = []
5819
+
4496
5820
  // —— 1. Tab 头(Row,space-around)——
4497
5821
  if (sem.tabs.length >= 2) {
4498
5822
  const tabBar = container('Row', { width: '100%', justify: 'flex-start', align: 'center', padding: T.pad('root') })
@@ -4546,6 +5870,7 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4546
5870
  setStyleChild(lbl, 'other', 'opacity', '0')
4547
5871
  }
4548
5872
  item.slots[0].components.push(lbl)
5873
+ headTabLabels.push({ col: item, idx: item.slots[0].components.length - 1, value: tab.value, fontSize: tab.fontSize })
4549
5874
  // 多 Tab 时绑定 activeTab 变量
4550
5875
  if (sem.tabs.length > 1) {
4551
5876
  const tabVar = 'activeTab'
@@ -4611,15 +5936,23 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4611
5936
  tailBlock.consumedByTabs = true
4612
5937
  }
4613
5938
  }
5939
+ // 卡头 tabs 的标题下划线:设计稿常用半透明色带垫在标题字下(highlighter 效果),
5940
+ // tabs 路径此前不查 titleUnderlines → 色带整条丢失,像素热区集中在标题带。
5941
+ // 包裹必须放在首/末列对齐调整之后:setAlign 与首列 flex 调整按 components[0]
5942
+ // 取节点,提前包裹会打到 AbsoluteLayout 壳上,多余 flex 键让渲染器走 flex 流。
5943
+ if (!segmented) {
5944
+ for (const entry of headTabLabels) {
5945
+ const ul = underlineByText.get(entry.value)
5946
+ if (!ul) continue
5947
+ entry.col.slots[0].components[entry.idx] = wrapTitleWithUnderline(
5948
+ entry.col.slots[0].components[entry.idx],
5949
+ { text: entry.value, fontSize: ul.textEl?.textStyle?.fontSize || entry.fontSize || 16 })
5950
+ }
5951
+ }
4614
5952
  card.slots[0].components.push(tabBar)
4615
5953
  }
4616
5954
 
4617
5955
  const groupedAuxElements = new Set()
4618
- // 标题下划线查找表:text content → underline info
4619
- const underlineByText = new Map()
4620
- for (const ul of sem.titleUnderlines || []) {
4621
- underlineByText.set(ul.text, ul)
4622
- }
4623
5956
  // 将标题文本 + 下划线包裹为 AbsoluteLayout(禁止负 marginTop)
4624
5957
  function wrapTitleWithUnderline(textNode, aux) {
4625
5958
  const ul = underlineByText.get(aux.text)
@@ -4790,6 +6123,13 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4790
6123
  const sr = shell ? rect(shell) : null
4791
6124
  return Math.max((aux.x || 0) + (aux.width || 0), sr ? (sr.x || 0) + (sr.width || 0) : -Infinity)
4792
6125
  }
6126
+ // 行内标记切片(时间轴上下小箭头、行内小徽标等):竖直落在某成组文本行带内、
6127
+ // 水平落在行跨度内的未消费小切片,随该行一起渲染。<20px 的切片会被孤立媒体通道
6128
+ // 的尺寸下限滤掉、又无文本配对,此前整块静默丢失(设计稿可见标记不渲染)。
6129
+ const rowInlineSlices = new Set()
6130
+ const itemLeft = (it) => (it.aux ? (it.aux.x || 0) : (rect(it.slice).x || 0))
6131
+ const itemRight = (it) => (it.aux ? auxVisualRight(it.aux)
6132
+ : (rect(it.slice).x || 0) + (rect(it.slice).width || 0))
4793
6133
  // 已插入宿主的成组 aux 行登记:供「标题+右侧按钮跨行块」重构时拔出吸收(见下方单行循环)。
4794
6134
  const auxRowEntries = []
4795
6135
  for (let index = 0; index < sem.auxTexts.length;) {
@@ -4812,8 +6152,16 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4812
6152
  if (group.length < 2) continue
4813
6153
  if (group.some((aux) => sem.bindings.multiToken?.[aux.text.trim()])) continue
4814
6154
  if (group.some((aux) => aux._consumed)) continue
6155
+ // 薄壳行(表格行壳 / 条目壳):整组文本落在同一行级薄壳内 → 壳的底色/描边/圆角由行承载。
6156
+ // 外层行只负责把壳定位到 spec 壳边沿(左右 padding = 壳内缩),壳内文本内缩交给内层行,
6157
+ // 否则背景只染到文本宽、描边框不住整行(此前薄壳两头落空,表格行只剩裸文本)。
6158
+ const thinShell = (sem.thinShellRows || []).find((shell) => shell.texts.length === group.length
6159
+ && group.every((aux) => shell.texts.includes(aux.sourceEl))) || null
4815
6160
  const host = hostFor(group[0].y)
4816
- const topPadding = semanticTopGap(group[0].y) ?? Math.max(0, Math.round(group[0].y - (previousAuxBottom ?? buildCard.y)))
6161
+ // 顶距锚点用壳顶沿而非文本顶沿:行级薄壳比内部文本高(文本垂直居中留白 2-4px),
6162
+ // 以文本 y 起算会让壳整体下移,逐行累积成整卡竖向漂移。
6163
+ const topAnchorY = thinShell ? (thinShell.y ?? group[0].y) : group[0].y
6164
+ const topPadding = semanticTopGap(topAnchorY) ?? Math.max(0, Math.round(topAnchorY - (previousAuxBottom ?? buildCard.y)))
4817
6165
  const left = Math.min(...group.map((aux) => Number.isFinite(auxVisualLeft(aux)) ? auxVisualLeft(aux) : buildCardLeft))
4818
6166
  const right = Math.max(...group.map((aux) => auxVisualRight(aux)))
4819
6167
  const leftPadding = Math.max(0, Math.round(left - buildCardLeft))
@@ -4822,33 +6170,58 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4822
6170
  // 外层 space-between 只负责簇间分布——3+ 子项直接平铺会被 space-between 打散
4823
6171
  // (如「我的账单 (元) … 日期」变成 标题/后缀/日期 三点分散)。
4824
6172
  const sortedGroup = group.sort((a, b) => (a.x || 0) - (b.x || 0))
6173
+ const bandTop = Math.min(...group.map((a) => a.y || 0))
6174
+ const bandBottom = Math.max(...group.map((a) =>
6175
+ (a.y || 0) + Math.max(a.height || 0, (a.fontSize || 14) * 1.4)))
6176
+ const spanLeft = Math.min(...group.map((a) => a.x || 0))
6177
+ const spanRight = Math.max(...group.map((a) => auxVisualRight(a)))
6178
+ const rowSlices = (sem.slices || []).filter((sl) => {
6179
+ if (rowInlineSlices.has(sl)) return false
6180
+ const sr = rect(sl)
6181
+ const w = sr.width || 0
6182
+ const h = sr.height || 0
6183
+ if (!w || !h || w > 32 || h > 32) return false
6184
+ if (!imageFromSlice(sl, sem.slicesExports)) return false
6185
+ const cy = (sr.y || 0) + h / 2
6186
+ if (cy < bandTop - 6 || cy > bandBottom + 6) return false
6187
+ if ((sr.x || 0) < spanLeft - 8 || (sr.x || 0) + w > spanRight + 8) return false
6188
+ return true
6189
+ })
6190
+ for (const sl of rowSlices) rowInlineSlices.add(sl)
6191
+ const rowItems = [
6192
+ ...sortedGroup.map((aux) => ({ aux })),
6193
+ ...rowSlices.map((slice) => ({ slice })),
6194
+ ].sort((p, q) => itemLeft(p) - itemLeft(q))
4825
6195
  const clusters = []
4826
- for (const aux of sortedGroup) {
6196
+ for (const item of rowItems) {
4827
6197
  const prev = clusters[clusters.length - 1]
4828
- const prevRight = prev ? Math.max(...prev.map((a) => auxVisualRight(a))) : null
4829
- if (prev && (aux.x || 0) - prevRight <= 12) prev.push(aux)
4830
- else clusters.push([aux])
6198
+ const prevRight = prev ? Math.max(...prev.map((it) => itemRight(it))) : null
6199
+ if (prev && itemLeft(item) - prevRight <= 12) prev.push(item)
6200
+ else clusters.push([item])
4831
6201
  }
4832
6202
  // 单簇行没有簇间分布对象:space-between 退化为 flex-start,此时右 padding 不参与
4833
6203
  // 定位却会把内容盒算窄(壳+文本被挤压裁切)——单簇行去掉右 padding、直接左对齐。
4834
6204
  const singleCluster = clusters.length === 1
6205
+ // 薄壳行(表格行壳 / 条目壳):整组文本落在同一行级薄壳内 → 壳的底色/描边/圆角由行承载。
6206
+ // 外层行只负责把壳定位到 spec 壳边沿(左右 padding = 壳内缩),壳内文本内缩交给内层行,
6207
+ // 否则背景只染到文本宽、描边框不住整行(此前薄壳两头落空,表格行只剩裸文本)。
4835
6208
  const row = container('Row', {
4836
6209
  width: '100%',
4837
- justify: singleCluster ? 'flex-start' : 'space-between',
6210
+ justify: (singleCluster && !thinShell) ? 'flex-start' : 'space-between',
4838
6211
  align: 'center',
4839
- padding: `${topPadding} ${singleCluster ? 0 : rightPadding} 0 ${leftPadding}`,
6212
+ padding: thinShell ? '0' : `${topPadding} ${singleCluster ? 0 : rightPadding} 0 ${leftPadding}`,
4840
6213
  })
4841
6214
  for (const cluster of clusters) {
4842
- if (cluster.length === 1) {
4843
- row.slots[0].components.push(auxContent(cluster[0]))
6215
+ if (cluster.length === 1 && cluster[0].aux) {
6216
+ row.slots[0].components.push(auxContent(cluster[0].aux))
4844
6217
  } else {
4845
6218
  const inner = container('Row', { justify: 'flex-start', align: 'center', gap: '0' })
4846
6219
  let prevRight = null
4847
- for (const aux of cluster) {
6220
+ for (const item of cluster) {
4848
6221
  // 簇内逐项 spec 间隙用横向 spacer 承担(Row gap 是固定值,表达不了逐项差异;
4849
6222
  // paddingLeft 会让元素盒 x 左移,污染位置评分)。前项有壳时按壳右沿测量。
4850
6223
  if (prevRight != null) {
4851
- const gapW = Math.max(0, Math.round((aux.x || 0) - prevRight))
6224
+ const gapW = Math.max(0, Math.round(itemLeft(item) - prevRight))
4852
6225
  if (gapW > 0) {
4853
6226
  const spacer = container('Column', { direction: 'column', padding: '0' })
4854
6227
  ensureStyleGroup(spacer, 'rect')
@@ -4859,17 +6232,60 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
4859
6232
  inner.slots[0].components.push(spacer)
4860
6233
  }
4861
6234
  }
4862
- inner.slots[0].components.push(auxContent(aux))
4863
- prevRight = auxVisualRight(aux)
6235
+ inner.slots[0].components.push(item.aux
6236
+ ? auxContent(item.aux)
6237
+ : imageFromSlice(item.slice, sem.slicesExports))
6238
+ prevRight = itemRight(item)
4864
6239
  }
4865
6240
  row.slots[0].components.push(inner)
4866
6241
  }
4867
- for (const aux of cluster) groupedAuxElements.add(aux)
4868
- }
4869
- insertSemantic(host, row, group[0].y)
4870
- const groupBottom = group[0].y + Math.max(...group.map((aux) =>
4871
- Math.max(aux.height || 0, (aux.fontSize || 14) * 1.4)))
4872
- auxRowEntries.push({ node: row, host, y0: group[0].y, y1: groupBottom, absorbed: false })
6242
+ for (const item of cluster) if (item.aux) groupedAuxElements.add(item.aux)
6243
+ }
6244
+ let auxInsertNode = row
6245
+ if (thinShell) {
6246
+ const shellInsetL = Math.max(0, Math.round((thinShell.x || 0) - buildCardLeft))
6247
+ const shellInsetR = Math.max(0, Math.round(buildCardRight - ((thinShell.x || 0) + (thinShell.width || 0))))
6248
+ const textLeft = Math.min(...group.map((aux) => auxVisualLeft(aux)))
6249
+ const textRight = Math.max(...group.map((aux) => auxVisualRight(aux)))
6250
+ const borderW = thinShell.isBorder ? Math.max(0.5, thinShell.borderWidth || 1) : 0
6251
+ const shellWrap = container('Row', {
6252
+ width: '100%', justify: 'flex-start', align: 'center',
6253
+ padding: `${topPadding} ${shellInsetR} 0 ${shellInsetL}`,
6254
+ })
6255
+ setStyleChild(row, 'padding', 'paddingType', '1')
6256
+ setStyleChild(row, 'padding', 'paddingTop', '0')
6257
+ setStyleChild(row, 'padding', 'paddingBottom', '0')
6258
+ setStyleChild(row, 'padding', 'paddingLeft', String(Math.max(0, Math.round(textLeft - (thinShell.x || 0)))))
6259
+ setStyleChild(row, 'padding', 'paddingRight', String(Math.max(0, Math.round((thinShell.x || 0) + (thinShell.width || 0) - textRight))))
6260
+ // 壳高是几何真值:钉死内层行高(border-box),文本靠 align:center 垂直居中,
6261
+ // 用 padding 反推壳高会因渲染器 1px 取整逐行累积 0.5-1px 漂移。
6262
+ setStyleChild(row, 'rect', 'height', String(Math.round(thinShell.height || 0)))
6263
+ ensureStyleGroup(row, 'background')
6264
+ if (thinShell.gradient) {
6265
+ setStyleChild(row, 'background', 'backgroundType', '03')
6266
+ setStyleChild(row, 'background', 'backgroundGradient', gradientWithOpacity(thinShell.gradient, thinShell.opacity ?? 1))
6267
+ } else if (thinShell.fill) {
6268
+ setStyleChild(row, 'background', 'backgroundType', '01')
6269
+ setStyleChild(row, 'background', 'backgroundColor', hexToRgba(thinShell.fill, thinShell.opacity ?? 1))
6270
+ }
6271
+ if (thinShell.isBorder) {
6272
+ ensureStyleGroup(row, 'border')
6273
+ setStyleChild(row, 'border', 'isBorder', '1')
6274
+ setStyleChild(row, 'border', 'borderWidth', String(borderW))
6275
+ setStyleChild(row, 'border', 'borderStyle', 'solid')
6276
+ setStyleChild(row, 'border', 'borderColor', hexToRgba(thinShell.borderColor || '#E7E7EE'))
6277
+ }
6278
+ ensureStyleGroup(row, 'borderRadius')
6279
+ setStyleChild(row, 'borderRadius', 'borderRadiusType', '0')
6280
+ setStyleChild(row, 'borderRadius', 'borderRadius', String(Math.round(thinShell.radius || 0)))
6281
+ shellWrap.slots[0].components.push(row)
6282
+ auxInsertNode = shellWrap
6283
+ }
6284
+ insertSemantic(host, auxInsertNode, group[0].y)
6285
+ const groupBottom = Math.max(group[0].y + Math.max(...group.map((aux) =>
6286
+ Math.max(aux.height || 0, (aux.fontSize || 14) * 1.4))),
6287
+ thinShell ? (thinShell.y || 0) + (thinShell.height || 0) : 0)
6288
+ auxRowEntries.push({ node: auxInsertNode, host, y0: group[0].y, y1: groupBottom, absorbed: false })
4873
6289
  previousAuxBottom = groupBottom
4874
6290
  }
4875
6291
  for (const aux of sem.auxTexts) {
@@ -5003,14 +6419,74 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5003
6419
  insertSemantic(host, row, aux.y)
5004
6420
  }
5005
6421
  } else {
5006
- const row = container('Row', { width: '100%', justify: 'flex-start', align: 'center', gap: T.gap('tight'), padding: geometryPadding })
5007
- // 左对齐行同样要带图标(如"信用卡"左侧卡片图标),此前只右/居中分支渲染 → 图标丢失
5008
- if (aux.sliceEl) {
5009
- const auxMedia = imageOrPlaceholder(aux.sliceEl, sem.slicesExports)
5010
- if (auxMedia) row.slots[0].components.push(auxMedia)
6422
+ const leadR = aux.sliceEl ? rect(aux.sliceEl) : null
6423
+ const decoR = aux.decoSliceEl ? rect(aux.decoSliceEl) : null
6424
+ const heroLead = leadR && (leadR.height || 0) > (aux.height || 0) * 1.6
6425
+ if (heroLead) {
6426
+ // 主视觉领行用 AbsoluteLayout 绝对定位带:主视觉常溢出卡顶(吉祥物骑在卡边上),
6427
+ // 流式行会被 clamp 到卡顶且占满主视觉全高,把下文整带顶偏;绝对定位带只占卡内
6428
+ // 可见高度,子元素按 spec 偏移脱标摆放(top 可为负,溢出部分由卡裁切,与设计裁切同构)。
6429
+ const cardX = buildCard.x || 0
6430
+ const cardTop = buildCard.y || 0
6431
+ const bandTop = Math.max(cardTop, Math.min(leadR.y || 0, aux.y || 0))
6432
+ const bandBottom = Math.max((leadR.y || 0) + (leadR.height || 0),
6433
+ (aux.y || 0) + (aux.height || 0),
6434
+ decoR ? (decoR.y || 0) + (decoR.height || 0) : 0)
6435
+ const band = comp('AbsoluteLayout')
6436
+ ensureStyleGroup(band, 'rect')
6437
+ ensureStyleGroup(band, 'flex')
6438
+ setStyleChild(band, 'rect', 'width', String(Math.round(buildCard.width || 0)))
6439
+ setStyleChild(band, 'rect', 'height', String(Math.max(1, Math.round(bandBottom - bandTop))))
6440
+ setStyleChild(band, 'flex', 'flexShrink', '0')
6441
+ const bandFlex = (band.style.properties || []).find((p) => p.itemKey === 'flex')
6442
+ if (bandFlex) bandFlex.itemChildren.properties = (bandFlex.itemChildren?.properties || [])
6443
+ .filter((c) => c.itemKey === 'flexShrink')
6444
+ band.properties = [constProp('align', 'BottomLeft')]
6445
+ band.slots = [{ slotKey: 'default', slotName: '默认插槽', slotSwitch: '1', components: [] }]
6446
+ // 渲染器无 position 组解析:绝对子元素偏移只能走 margin(静态位置=align 角 + margin 偏移)
6447
+ // 渲染器无 position 组解析(preview 只认 rect/flex/margin/…):绝对子元素偏移走 margin。
6448
+ // 带 align=BottomLeft → 静态位置=带底左角;marginBottom=带底-子元素底 恒为非负
6449
+ // (R38 禁负 marginTop),主视觉溢出卡顶的部分由子元素高于带高自然脱出裁切。
6450
+ const place = (node, elR) => {
6451
+ ensureStyleGroup(node, 'margin')
6452
+ setStyleChild(node, 'margin', 'marginType', '1')
6453
+ setStyleChild(node, 'margin', 'marginTop', '0')
6454
+ setStyleChild(node, 'margin', 'marginLeft', String(Math.round((elR.x || 0) - cardX)))
6455
+ setStyleChild(node, 'margin', 'marginRight', '0')
6456
+ setStyleChild(node, 'margin', 'marginBottom', String(Math.max(0, Math.round(bandBottom - ((elR.y || 0) + (elR.height || 0))))))
6457
+ return node
6458
+ }
6459
+ const leadMedia = imageOrPlaceholder(aux.sliceEl, sem.slicesExports)
6460
+ if (leadMedia) band.slots[0].components.push(place(leadMedia, leadR))
6461
+ band.slots[0].components.push(place(content, aux))
6462
+ if (aux.decoSliceEl) {
6463
+ const decoMedia = imageOrPlaceholder(aux.decoSliceEl, sem.slicesExports)
6464
+ if (decoMedia) band.slots[0].components.push(place(decoMedia, decoR))
6465
+ }
6466
+ insertSemantic(host, band, bandTop)
6467
+ blockBottomOverride = bandBottom
6468
+ } else {
6469
+ const row = container('Row', { width: '100%', justify: 'flex-start', align: 'center', gap: T.gap('tight'), padding: geometryPadding })
6470
+ // 左对齐行同样要带图标(如"信用卡"左侧卡片图标),此前只右/居中分支渲染 → 图标丢失
6471
+ if (aux.sliceEl) {
6472
+ const auxMedia = imageOrPlaceholder(aux.sliceEl, sem.slicesExports)
6473
+ if (auxMedia) row.slots[0].components.push(auxMedia)
6474
+ }
6475
+ if (aux.decoSliceEl) {
6476
+ const col = container('Column', { direction: 'column', align: 'flex-start', gap: T.gap('none') })
6477
+ ensureStyleGroup(col, 'rect')
6478
+ setStyleChild(col, 'rect', 'width', String(Math.round(aux.width || 0)))
6479
+ col.slots[0].components.push(content)
6480
+ const decoRow = container('Row', { width: '100%', justify: 'flex-end', align: 'center', gap: '0' })
6481
+ const decoMedia = imageOrPlaceholder(aux.decoSliceEl, sem.slicesExports)
6482
+ if (decoMedia) decoRow.slots[0].components.push(decoMedia)
6483
+ col.slots[0].components.push(decoRow)
6484
+ row.slots[0].components.push(col)
6485
+ } else {
6486
+ row.slots[0].components.push(content)
6487
+ }
6488
+ insertSemantic(host, row, aux.y)
5011
6489
  }
5012
- row.slots[0].components.push(content)
5013
- insertSemantic(host, row, aux.y)
5014
6490
  }
5015
6491
  previousAuxBottom = blockBottomOverride ?? (aux.y + Math.max(aux.height || 0, (aux.fontSize || 14) * 1.4))
5016
6492
  }
@@ -5182,6 +6658,31 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5182
6658
  // —— 2b. 几何选项组(标签 + 同构短壳行)。 ——
5183
6659
  // 每组保持标注几何:标签间距、壳宽/高、列间距都来自 spec;根容器仍为 auto,不写死整体高度。
5184
6660
  let previousOptionBottom = previousAuxBottom
6661
+ // chip 壳内图标:与成员文本同壳、位于文本左侧的小方形切片。CeButton 只渲染 value 文本,
6662
+ // 没有子节点通道,图标必须让 chip 降级成 Row[Image, Text](壳样式搬到 Row 上)。
6663
+ const optionChipIcon = (member) => {
6664
+ const shell = member.sourceShapeEl
6665
+ if (!shell) return null
6666
+ const hr = rect(shell)
6667
+ const tr = member.sourceTextEl ? rect(member.sourceTextEl) : null
6668
+ const icon = (sem.slices || []).find((slice) => {
6669
+ const sr = rect(slice)
6670
+ const w = sr.width || 0
6671
+ const h = sr.height || 0
6672
+ if (!w || !h || w > 40 || h > 40) return false
6673
+ if (Math.abs(w - h) > Math.max(8, w * 0.4)) return false
6674
+ if ((sr.x || 0) < (hr.x || 0) - 2 || (sr.y || 0) < (hr.y || 0) - 2) return false
6675
+ if (((sr.x || 0) + w) > ((hr.x || 0) + (hr.width || 0)) + 2) return false
6676
+ if (((sr.y || 0) + h) > ((hr.y || 0) + (hr.height || 0)) + 2) return false
6677
+ return !tr || ((sr.x || 0) + w) <= (tr.x || 0) + 2
6678
+ })
6679
+ if (!icon) return null
6680
+ const media = imageOrPlaceholder(icon, sem.slicesExports)
6681
+ if (!media) return null
6682
+ const ir = rect(icon)
6683
+ const gap = tr ? Math.max(0, Math.round((tr.x || 0) - ((ir.x || 0) + (ir.width || 0)))) : 4
6684
+ return { media, gap, sliceEl: icon }
6685
+ }
5185
6686
  for (const group of sem.optionGroups || []) {
5186
6687
  // 标题在顶带内、选项在带外:拆开渲染。否则按 labelY 归顶带宿主(固定带高),
5187
6688
  // 整个选项组被带高裁掉(选项 pill 全消失)。
@@ -5272,12 +6773,23 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5272
6773
  // 会被渲染器忽略、回落到容器默认 gap(曾把 13px 选项间距塌成 4px,右列整体左移 9px)。
5273
6774
  if (gap > 0) setStyleChild(chipsRow, 'flex', 'gap', String(Math.round(gap)))
5274
6775
  }
5275
- const chip = comp('Button')
6776
+ const chipIcon = optionChipIcon(member)
6777
+ const chip = chipIcon
6778
+ ? container('Row', {
6779
+ width: String(Math.round(member.width || 0)),
6780
+ direction: 'row',
6781
+ justify: 'center',
6782
+ align: 'center',
6783
+ gap: String(chipIcon.gap),
6784
+ padding: '0',
6785
+ flexShrink: 0,
6786
+ })
6787
+ : comp('Button')
5276
6788
  ensureStyleGroup(chip, 'rect')
5277
6789
  ensureStyleGroup(chip, 'flex')
5278
6790
  ensureStyleGroup(chip, 'background')
5279
6791
  ensureStyleGroup(chip, 'borderRadius')
5280
- ensureStyleGroup(chip, 'font')
6792
+ if (!chipIcon) ensureStyleGroup(chip, 'font')
5281
6793
  setStyleChild(chip, 'rect', 'width', String(Math.round(member.width || 0)))
5282
6794
  setStyleChild(chip, 'rect', 'height', String(Math.round(member.height || 0)))
5283
6795
  setStyleChild(chip, 'flex', 'flexShrink', '0')
@@ -5290,11 +6802,22 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5290
6802
  }
5291
6803
  setStyleChild(chip, 'borderRadius', 'borderRadiusType', '0')
5292
6804
  setStyleChild(chip, 'borderRadius', 'borderRadius', String(Math.round(member.radius || 6)))
5293
- setStyleChild(chip, 'font', 'color', hexToRgba(member.color))
5294
- setStyleChild(chip, 'font', 'fontSize', String(member.fontSize || 14))
5295
- setStyleChild(chip, 'font', 'fontWeight', String(member.fontWeight || '400'))
5296
- setStyleChild(chip, 'font', 'textAlign', 'center')
5297
- chip.properties = [constProp('value', member.text)]
6805
+ if (chipIcon) {
6806
+ chip.slots[0].components.push(chipIcon.media)
6807
+ chip.slots[0].components.push(textFromSpec({
6808
+ text: member.text,
6809
+ color: member.color,
6810
+ fontSize: member.fontSize || 14,
6811
+ fontWeight: member.fontWeight || '400',
6812
+ align: 'center',
6813
+ }))
6814
+ } else {
6815
+ setStyleChild(chip, 'font', 'color', hexToRgba(member.color))
6816
+ setStyleChild(chip, 'font', 'fontSize', String(member.fontSize || 14))
6817
+ setStyleChild(chip, 'font', 'fontWeight', String(member.fontWeight || '400'))
6818
+ setStyleChild(chip, 'font', 'textAlign', 'center')
6819
+ chip.properties = [constProp('value', member.text)]
6820
+ }
5298
6821
  chip.eventactions = [clickAction(`// ${member.text} 点击`)]
5299
6822
  chipsRow.slots[0].components.push(chip)
5300
6823
  })
@@ -5311,6 +6834,15 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5311
6834
  const width = sr.width || 0
5312
6835
  const height = sr.height || 0
5313
6836
  const firstItem = sem.sampleItems[0]
6837
+ // 行右大图标内嵌徽标(icon 角标)不是行尾 affordance:落在 rightIcon 盒内即排除,
6838
+ // 否则被当静态行尾箭头逐行渲染,挤占左栏宽度致 desc 换行(保险保障行实测)
6839
+ const insideRightIcon = sem.sampleItems.some((it2) => {
6840
+ const ri = it2.rightIconEl ? rect(it2.rightIconEl) : null
6841
+ return !!ri && (sr.x || 0) >= (ri.x || 0) - 6 && (sr.y || 0) >= (ri.y || 0) - 6
6842
+ && (sr.x || 0) + width <= (ri.x || 0) + (ri.width || 0) + 6
6843
+ && (sr.y || 0) + height <= (ri.y || 0) + (ri.height || 0) + 6
6844
+ })
6845
+ if (insideRightIcon) return false
5314
6846
  return width >= 12 && width <= 40 && height >= 12 && height <= 40
5315
6847
  && Math.abs(width - height) <= Math.max(10, width * 0.5)
5316
6848
  && ((sr.x || 0) + width) >= buildCardRight - Math.max(56, width * 2)
@@ -5390,6 +6922,21 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5390
6922
  if (row) row.push(item)
5391
6923
  else panelTextRows.push([item])
5392
6924
  }
6925
+ // 标签 + 单位后缀((元)/万)相邻层合并为单文本:否则汇总区行项数不齐(4 vs 2),
6926
+ // 两列拓扑无法成立;单位层色差是可接受代价。
6927
+ for (const rowItems of panelTextRows) {
6928
+ rowItems.sort((a, b) => (a.x || 0) - (b.x || 0))
6929
+ for (let i2 = 0; i2 < rowItems.length - 1; i2 += 1) {
6930
+ const cur2 = rowItems[i2]
6931
+ const nxt2 = rowItems[i2 + 1]
6932
+ const ct = String(cur2.text || '').trim()
6933
+ const nt = String(nxt2.text || '').trim()
6934
+ if (/^[((]?[元万千亿]+[))]?$/.test(nt) && !/\d/.test(ct) && /[一-龥]/.test(ct)
6935
+ && Math.abs(((cur2.x || 0) + (cur2.width || 0)) - (nxt2.x || 0)) <= 6) {
6936
+ rowItems.splice(i2, 2, { ...cur2, text: ct + ' ' + nt, width: (cur2.width || 0) + (nxt2.width || 0) + 4 })
6937
+ }
6938
+ }
6939
+ }
5393
6940
  // 逐行行距预算:行盒高用渲染同款估算(estimatedTextBoxHeight),
5394
6941
  // 保证「估算底沿 == 渲染流式底沿」,下一行 paddingTop = 标注行顶 - 上一行渲染底沿。
5395
6942
  const panelRowMetrics = panelTextRows.map((items) => {
@@ -5415,34 +6962,52 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5415
6962
  gap: T.gap('none'), width: '100%',
5416
6963
  })
5417
6964
  for (const items of gridColumns) {
6965
+ // 列对齐按标注几何:左沿对齐→flex-start,右沿对齐→flex-end,否则居中
6966
+ const lefts = items.map((it2) => Math.round(it2.x || 0))
6967
+ const rights = items.map((it2) => Math.round((it2.x || 0) + (it2.width || 0)))
6968
+ const colAlign = Math.max(...lefts) - Math.min(...lefts) <= 6
6969
+ ? 'flex-start'
6970
+ : (Math.max(...rights) - Math.min(...rights) <= 6 ? 'flex-end' : 'center')
5418
6971
  const column = container('Column', {
5419
- direction: 'column', justify: 'flex-start', align: 'center',
5420
- gap: T.gap('tight'), width: '50%',
6972
+ direction: 'column', justify: 'flex-start', align: colAlign,
6973
+ gap: T.gap('tight'), width: String(Math.floor(100 / gridColumns.length)) + '%',
5421
6974
  })
5422
6975
  for (const item of items) {
5423
6976
  column.slots[0].components.push(textFromSpec(applyLiteralBind({
5424
6977
  ...item,
5425
- align: 'center',
6978
+ align: colAlign === 'flex-start' ? 'left' : (colAlign === 'flex-end' ? 'right' : 'center'),
5426
6979
  }, sem.bindings.literalBind)))
5427
6980
  }
5428
6981
  gridRow.slots[0].components.push(column)
5429
6982
  }
5430
6983
  textColumn.slots[0].components.push(gridRow)
5431
6984
  } else {
5432
- panelTextRows.forEach((items, rowIndex) => {
6985
+ const panelRowMetric = (items) => ({
6986
+ top: Math.min(...items.map((item) => item.y || 0)),
6987
+ boxH: Math.max(...items.map((item) => estimatedTextBoxHeight(item.height, item.fontSize))),
6988
+ })
6989
+ const groupPanelRow = (list) => {
6990
+ const rows = []
6991
+ for (const item of list.slice().sort((a, b) => (a.y || 0) - (b.y || 0) || (a.x || 0) - (b.x || 0))) {
6992
+ const found = rows.find((group) => Math.abs((group[0].y || 0) - (item.y || 0)) <= 8)
6993
+ if (found) found.push(item)
6994
+ else rows.push([item])
6995
+ }
6996
+ return rows
6997
+ }
6998
+ const buildPanelTextRow = (items, prevMetric) => {
5433
6999
  const textRow = container('Row', {
5434
7000
  width: '100%', justify: 'flex-start', align: 'center', gap: T.gap('tight'),
5435
7001
  })
5436
- if (rowIndex > 0) {
5437
- const prev = panelRowMetrics[rowIndex - 1]
7002
+ if (prevMetric) {
5438
7003
  const rowGap = Math.max(0, Math.round(
5439
- panelRowMetrics[rowIndex].top - (prev.top + prev.boxH)))
7004
+ panelRowMetric(items).top - (prevMetric.top + prevMetric.boxH)))
5440
7005
  setStyleChild(textRow, 'padding', 'paddingType', '1')
5441
7006
  setStyleChild(textRow, 'padding', 'paddingTop', String(rowGap))
5442
7007
  }
5443
7008
  // 裸货币符号层 + 紧邻数字层(spec 拆层,x 间隙 ≤6px)合并为一个 Text:
5444
7009
  // 分开渲染会被行 gap 撑出缝隙("¥ 519.27"),设计稿是紧贴的。数字层的绑定保留(含 ¥ 前缀表达式)。
5445
- const sortedItems = items.sort((a, b) => (a.x || 0) - (b.x || 0))
7010
+ const sortedItems = items.slice().sort((a, b) => (a.x || 0) - (b.x || 0))
5446
7011
  const mergedItems = []
5447
7012
  for (let i = 0; i < sortedItems.length; i++) {
5448
7013
  const cur = sortedItems[i]
@@ -5468,8 +7033,117 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5468
7033
  align: 'left',
5469
7034
  }, sem.bindings.literalBind)))
5470
7035
  }
5471
- textColumn.slots[0].components.push(textRow)
5472
- })
7036
+ return textRow
7037
+ }
7038
+ // 壳内两列拓扑:壳右半区独立成列的文本(sem.shellRightColumns)不与左列竖排堆叠,
7039
+ // 而是 Row[Column(左列), Column(右列)] 并排;标题行整行留在两列之上,
7040
+ // 右列之下的通栏内容仍整行排在两列块之后(此前右列被压成竖排,壳内文本互相挤位)。
7041
+ const splitCol = (sem.shellRightColumns || [])
7042
+ .find((column) => column.shapeEl === panel.shapeEl) || null
7043
+ const splitLayout = (() => {
7044
+ if (!splitCol || panelTextRows.length < 2) return null
7045
+ // 右列成员按 spec 坐标精确对账(不用 x 阈值:标题行右侧的紧邻短标签
7046
+ // 与右列左边界只差几 px,阈值判定会把它误收进右列)
7047
+ const rightKeys = new Set(splitCol.texts.map((item) =>
7048
+ `${Math.round(item.x || 0)},${Math.round(item.y || 0)}`))
7049
+ const isRight = (item) => rightKeys.has(`${Math.round(item.x || 0)},${Math.round(item.y || 0)}`)
7050
+ const topRowY = Math.min(...panelTextRows[0].map((item) => item.y || 0))
7051
+ const headerRows = []
7052
+ const restRows = []
7053
+ for (const items of panelTextRows) {
7054
+ const rowTop = panelRowMetric(items).top
7055
+ if (Math.abs(rowTop - topRowY) <= 8 && !items.some(isRight)) headerRows.push(items)
7056
+ else restRows.push(items)
7057
+ }
7058
+ const rightItems = restRows.flatMap((items) => items.filter(isRight))
7059
+ if (rightItems.length < 2) return null
7060
+ const rightRows = groupPanelRow(rightItems)
7061
+ const rightTop = panelRowMetric(rightRows[0]).top
7062
+ const rightBottom = Math.max(...rightRows.map((items) => {
7063
+ const metric = panelRowMetric(items)
7064
+ return metric.top + metric.boxH
7065
+ }))
7066
+ const footerRows = restRows.filter((items) =>
7067
+ panelRowMetric(items).top > rightBottom + 8 && !items.some(isRight))
7068
+ const footerItems = new Set(footerRows.flat())
7069
+ const leftItems = restRows.flat().filter((item) => !isRight(item) && !footerItems.has(item))
7070
+ if (!leftItems.length) return null
7071
+ const leftRows = groupPanelRow(leftItems)
7072
+ const leftTop = panelRowMetric(leftRows[0]).top
7073
+ const columnHeight = (rows, extraTop) => {
7074
+ let height = extraTop
7075
+ let prev = null
7076
+ for (const items of rows) {
7077
+ const metric = panelRowMetric(items)
7078
+ if (prev) height += Math.max(0, Math.round(metric.top - (prev.top + prev.boxH)))
7079
+ height += metric.boxH
7080
+ prev = metric
7081
+ }
7082
+ return height
7083
+ }
7084
+ const blockTop = Math.min(leftTop, rightTop)
7085
+ return {
7086
+ headerRows, leftRows, rightRows, footerRows, blockTop,
7087
+ leftExtra: leftTop - blockTop,
7088
+ rightExtra: rightTop - blockTop,
7089
+ blockHeight: Math.max(
7090
+ columnHeight(leftRows, leftTop - blockTop),
7091
+ columnHeight(rightRows, rightTop - blockTop)),
7092
+ }
7093
+ })()
7094
+ const fillPanelColumn = (column, rows, extraTop) => {
7095
+ if (extraTop > 0) {
7096
+ setStyleChild(column, 'padding', 'paddingType', '1')
7097
+ setStyleChild(column, 'padding', 'paddingTop', String(Math.round(extraTop)))
7098
+ }
7099
+ let prev = null
7100
+ for (const items of rows) {
7101
+ column.slots[0].components.push(buildPanelTextRow(items, prev))
7102
+ prev = panelRowMetric(items)
7103
+ }
7104
+ }
7105
+ if (splitLayout) {
7106
+ let prev = null
7107
+ for (const items of splitLayout.headerRows) {
7108
+ textColumn.slots[0].components.push(buildPanelTextRow(items, prev))
7109
+ prev = panelRowMetric(items)
7110
+ }
7111
+ const leftW = Math.max(1, Math.round(splitCol.splitX - ((panel.x || 0) + (panel.paddingLeft || 0))))
7112
+ const contentW = Math.max(1, Math.round((panel.width || 0)
7113
+ - (panel.paddingLeft || 0) - (panel.paddingRight || 0)))
7114
+ const twoCol = container('Row', {
7115
+ width: '100%', direction: 'row', justify: 'flex-start', align: 'flex-start',
7116
+ gap: T.gap('none'), padding: '0',
7117
+ })
7118
+ if (prev) {
7119
+ setStyleChild(twoCol, 'padding', 'paddingType', '1')
7120
+ setStyleChild(twoCol, 'padding', 'paddingTop', String(Math.max(0,
7121
+ Math.round(splitLayout.blockTop - (prev.top + prev.boxH)))))
7122
+ }
7123
+ const leftCol = container('Column', {
7124
+ direction: 'column', width: String(leftW), justify: 'flex-start', align: 'flex-start',
7125
+ gap: T.gap('none'), padding: '0', flexShrink: 0,
7126
+ })
7127
+ const rightCol = container('Column', {
7128
+ direction: 'column', width: String(Math.max(1, contentW - leftW)), justify: 'flex-start',
7129
+ align: 'flex-start', gap: T.gap('none'), padding: '0', flexShrink: 0,
7130
+ })
7131
+ fillPanelColumn(leftCol, splitLayout.leftRows, splitLayout.leftExtra)
7132
+ fillPanelColumn(rightCol, splitLayout.rightRows, splitLayout.rightExtra)
7133
+ twoCol.slots[0].components.push(leftCol, rightCol)
7134
+ textColumn.slots[0].components.push(twoCol)
7135
+ prev = { top: splitLayout.blockTop, boxH: splitLayout.blockHeight }
7136
+ for (const items of splitLayout.footerRows) {
7137
+ textColumn.slots[0].components.push(buildPanelTextRow(items, prev))
7138
+ prev = panelRowMetric(items)
7139
+ }
7140
+ } else {
7141
+ let prev = null
7142
+ for (const items of panelTextRows) {
7143
+ textColumn.slots[0].components.push(buildPanelTextRow(items, prev))
7144
+ prev = panelRowMetric(items)
7145
+ }
7146
+ }
5473
7147
  }
5474
7148
  row.slots[0].components.push(textColumn)
5475
7149
  if (panel.rightSlice) {
@@ -5624,10 +7298,37 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5624
7298
  if (mcAffordMedia) row.slots[0].components.push(mcAffordMedia)
5625
7299
  }
5626
7300
  insertSemantic(hostFor(sem.midCluster.y), row, sem.midCluster.y)
7301
+ // 簇值行(并行汇总列「标签 + 下一行数值」的下半行):同一几何/左对齐规则渲染。
7302
+ // 与主金额重合的值(左列大字号)在此整行渲染(含右列 300.00),主金额通道让位,
7303
+ // 否则并列双列被拆成两行(左 2,782.35、右 300.00 应同基线)。
7304
+ if (sem.midCluster.values && sem.midCluster.values.filter(Boolean).length) {
7305
+ const vItems = [...sem.midCluster.values].filter(Boolean).sort((a, b) => (a.x || 0) - (b.x || 0))
7306
+ const primText = sem.primaryAmount ? String(sem.primaryAmount.text) : null
7307
+ if (primText && vItems.some((v) => String(v.text).trim() === primText.trim())) {
7308
+ mcValueRowHandlesPrimary = true
7309
+ }
7310
+ const vRowHost = container('Row', {
7311
+ width: '100%', justify: mcNearCenter ? 'center' : 'flex-start', align: 'center',
7312
+ gap: T.gap('tight'),
7313
+ padding: mcNearCenter ? '0' : `${semanticTopGap(vItems[0].y) ?? mcPad} 0 ${inFixedGradientShell(sem.midCluster.y) ? 0 : mcPad} ${mcLeftPad}`,
7314
+ })
7315
+ if (!mcNearCenter) setStyleChild(vRowHost, 'flex', 'gap', String(T.gap('none')))
7316
+ let _vPrevRight = null
7317
+ for (const v of vItems) {
7318
+ const gapW = _vPrevRight == null ? 0 : Math.max(0, Math.round((v.x || 0) - _vPrevRight))
7319
+ if (gapW > 0) vRowHost.slots[0].components.push(_mcGapSpacer(gapW))
7320
+ vRowHost.slots[0].components.push(textFromSpec(applyLiteralBind({
7321
+ text: v.text, color: v.color, fontSize: v.fontSize, fontWeight: v.fontWeight,
7322
+ height: v.height, align: 'left',
7323
+ }, sem.bindings.literalBind)))
7324
+ _vPrevRight = (v.x || 0) + (v.width || 0)
7325
+ }
7326
+ insertSemantic(hostFor(vItems[0].y), vRowHost, vItems[0].y)
7327
+ }
5627
7328
  }
5628
7329
 
5629
7330
  // —— 3. 主金额(大字号居中)→ 抽 inputParam + 千分号表达式 ——
5630
- if (sem.primaryAmount) {
7331
+ if (sem.primaryAmount && !mcValueRowHandlesPrimary) {
5631
7332
  const varName = uniqueKey(primaryAmountVarName(sem), usedTopKeys)
5632
7333
  const num = parseAmount(sem.primaryAmount.text)
5633
7334
  const amount = sem.primaryAmount
@@ -5848,11 +7549,296 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5848
7549
  }
5849
7550
  }
5850
7551
 
7552
+ // —— 2x. 评分仪表盘(半环弧 + 中心分数):Canvas 回放(recipe canvas-gauge-score)——
7553
+ // 弧矢量 shape 无渲染通道(style 解析器不认 arc),分数文本也无归属 → 整块丢失。
7554
+ // 检出「N分」大字号 + 下方小标签且未被任何通道消费时,按 recipe 出码 Canvas 半环。
7555
+ const scoreGauge = (() => {
7556
+ const all = sem.allTexts || []
7557
+ const rendered = new Set([
7558
+ ...(sem.postAmountTexts || []).map((t) => t.sourceEl),
7559
+ ...(sem.auxTexts || []).map((t) => t.sourceEl),
7560
+ ...(sem.buttons || []).map((b) => b.sourceEl),
7561
+ ...(sem.richBlocks || []).flatMap((blk) => blk.fragments || []),
7562
+ ...(sem.midCluster ? sem.midCluster.items : []),
7563
+ ].filter(Boolean))
7564
+ // 标注层常把「70分」拆成 数字+单位 两个文本:认大号纯数字 + 同基线右邻「分」单位
7565
+ const score = all.find((t) => /^\d+$/.test(textOf(t)) && (anyFs(t) || 0) >= 20 && !rendered.has(t))
7566
+ if (!score) return null
7567
+ const sr = anyRect(score)
7568
+ const unit = all.find((t) => t !== score && !rendered.has(t) && textOf(t) === '分'
7569
+ && Math.abs((anyRect(t).y || 0) - (sr.y || 0)) <= 4
7570
+ && (anyRect(t).x || 0) >= (sr.x || 0) + (sr.width || 0) - 2
7571
+ && (anyRect(t).x || 0) <= (sr.x || 0) + (sr.width || 0) + 24)
7572
+ if (!unit) return null
7573
+ const label = all.find((t) => t !== score && t !== unit && !rendered.has(t)
7574
+ && (anyRect(t).y || 0) >= (sr.y || 0) + 14 && (anyRect(t).y || 0) <= (sr.y || 0) + 60
7575
+ && Math.abs(((anyRect(t).x || 0) + (anyRect(t).width || 0) / 2) - ((sr.x || 0) + (sr.width || 0) / 2)) <= 40
7576
+ && (anyFs(t) || 14) <= 13)
7577
+ if (!label) return null
7578
+ return { score, label }
7579
+ })()
7580
+ if (scoreGauge) {
7581
+ const gx = Math.round(((anyRect(scoreGauge.label).x || 0) + (anyRect(scoreGauge.label).width || 0) / 2) - 60)
7582
+ const gy = Math.round((anyRect(scoreGauge.score).y || 0) - 30)
7583
+ const scoreNum = parseAmount(textOf(scoreGauge.score))
7584
+ const scoreVar = uniqueKey('score', usedTopKeys)
7585
+ const labelVar = uniqueKey('scoreLabel', usedTopKeys)
7586
+ // Canvas 出码按 self.state.score/scoreLabel 字面读取:变量被重命名(冲突)时放弃仪表盘
7587
+ if (scoreVar !== 'score' || labelVar !== 'scoreLabel') return
7588
+ addParam(scoreVar, 'number', scoreNum !== null ? scoreNum : 0)
7589
+ addParam(labelVar, 'string', textOf(scoreGauge.label))
7590
+ const gaugeCode = SCORE_GAUGE_CANVAS_CODE
7591
+ const cv = comp('Canvas')
7592
+ ensureStyleGroup(cv, 'rect'); ensureStyleGroup(cv, 'flex'); ensureStyleGroup(cv, 'background')
7593
+ setStyleChild(cv, 'rect', 'width', '120')
7594
+ setStyleChild(cv, 'rect', 'height', '84')
7595
+ setStyleChild(cv, 'flex', 'flexShrink', '0')
7596
+ setStyleChild(cv, 'background', 'backgroundType', '01')
7597
+ setStyleChild(cv, 'background', 'backgroundColor', '')
7598
+ cv.eventactions = [{
7599
+ event: { eventName: '初始化渲染', eventKey: 'onMeasured' },
7600
+ action: { actionType: 'customAction', properties: [constProp('function', { code: gaugeCode, compiled: gaugeCode })] },
7601
+ }]
7602
+ const left = Math.max(0, gx - buildCardLeft)
7603
+ const right = Math.max(0, buildCardRight - (gx + 120))
7604
+ const grow = container('Row', { width: '100%', justify: 'flex-start', align: 'flex-start', gap: '0' })
7605
+ ensureStyleGroup(grow, 'padding')
7606
+ setStyleChild(grow, 'padding', 'paddingType', '1')
7607
+ setStyleChild(grow, 'padding', 'paddingLeft', String(left))
7608
+ setStyleChild(grow, 'padding', 'paddingRight', String(right))
7609
+ // 零流高叠放:marginTop 补到设计顶沿、负 marginBottom 抵消(顶距+画布高),不推偏头部流式行
7610
+ const gaugeTopGap = Math.max(0, semanticTopGap(gy) ?? 0)
7611
+ ensureStyleGroup(grow, 'margin')
7612
+ setStyleChild(grow, 'margin', 'marginType', '1')
7613
+ setStyleChild(grow, 'margin', 'marginTop', String(gaugeTopGap))
7614
+ setStyleChild(grow, 'margin', 'marginBottom', String(-(84 + gaugeTopGap)))
7615
+ grow.slots[0].components.push(cv)
7616
+ insertSemantic(card, grow, gy)
7617
+ addSemanticBound(gy, gy + 84, true)
7618
+ }
7619
+
7620
+ // —— 3a-x. 散点带:中心媒体(雷达/环图裁切图)+ 环绕胶囊簇(pill+金额+状态标签)——
7621
+ // 「五笔钱」类布局:胶囊与金额按三列散点环绕中心图形;通用按钮行会把胶囊压成一行截断、
7622
+ // 金额散落错行(v7 AB04 位置分 0.53 的根因)。检出后按列重建:列宽=成员外接宽,
7623
+ // 列间负 margin 还原设计上的水平重叠,列内行 paddingTop 还原纵向节奏。
7624
+ // 散点带金额/状态标签 → 变量绑定(金额走千分格式化表达式,标签走字符串变量),避免裸字面量
7625
+ const scatterAmountText = (pill, amount) => {
7626
+ const num = parseAmount(amount.text)
7627
+ const varName = uniqueKey(fieldKeyFor(pill && pill.text) || 'amount', usedTopKeys)
7628
+ addParam(varName, 'number', num !== null ? num : 0)
7629
+ return textFromSpec({ ...amount, align: amount.align || 'left', bind: amountExpr(varName, amount.text), defaultValue: amount.text })
7630
+ }
7631
+ const scatterTagText = (tag) => {
7632
+ const varName = uniqueKey((fieldKeyFor(tag.text) || 'status') + 'Tag', usedTopKeys)
7633
+ addParam(varName, 'string', tag.text)
7634
+ return textFromSpec({ ...tag, align: tag.align || 'left', bind: '$' + varName, defaultValue: tag.text })
7635
+ }
7636
+ const scatterBand = (() => {
7637
+ const cardX = (sem.cardRect && sem.cardRect.x) || 0
7638
+ const cardY = (sem.cardRect && sem.cardRect.y) || 0
7639
+ const pills = (sem.buttons || []).filter((b) => (b.radius || 0) >= 10 && (b.height || 0) <= 40 && (b.fillColor || b.gradient))
7640
+ if (pills.length < 4) return null
7641
+ const pillTop = Math.min(...pills.map((b) => b.y || 0))
7642
+ const pillBottom = Math.max(...pills.map((b) => (b.y || 0) + (b.height || 0)))
7643
+ const rowKeys = new Set(pills.map((b) => Math.round((b.y || 0) / 8)))
7644
+ if (rowKeys.size < 2 || pillBottom - pillTop > 260) return null
7645
+ const media = (sem.orphanBlocks || []).find((ob) => {
7646
+ const w = ob.width || 0
7647
+ const h = ob.height || 0
7648
+ return w >= 60 && w <= 200 && Math.abs(w - h) <= 14
7649
+ && (ob.y || 0) >= pillTop - 80 && (ob.y || 0) + h <= pillBottom + 80
7650
+ })
7651
+ if (!media) return null
7652
+ const mL = (media.x || 0) - cardX
7653
+ const mR = mL + (media.width || 0)
7654
+ const aux = sem.postAmountTexts || []
7655
+ const usedAux = new Set()
7656
+ const clusters = []
7657
+ for (const pill of pills) {
7658
+ const px = (pill.x || 0) - cardX
7659
+ const py = (pill.y || 0) - cardY
7660
+ const pc = px + (pill.width || 0) / 2
7661
+ const amount = aux.find((t) => !usedAux.has(t) && (t.fontSize || 14) >= 12
7662
+ && (t.y || 0) - cardY >= py + (pill.height || 0) - 8
7663
+ && (t.y || 0) - cardY <= py + (pill.height || 0) + 56
7664
+ && Math.abs((((t.x || 0) - cardX) + (t.width || 0) / 2) - pc) <= 70)
7665
+ if (!amount) continue
7666
+ usedAux.add(amount)
7667
+ const ay = (amount.y || 0) - cardY
7668
+ const tag = aux.find((t) => !usedAux.has(t) && (t.fontSize || 14) <= 11
7669
+ && (t.y || 0) - cardY >= ay - 4 && (t.y || 0) - cardY <= ay + 46
7670
+ && (t.x || 0) - cardX >= (amount.x || 0) - cardX - 16
7671
+ && (t.x || 0) - cardX <= (amount.x || 0) - cardX + (amount.width || 0) + 60) || null
7672
+ if (tag) usedAux.add(tag)
7673
+ const side = pc < mL ? 'L' : pc > mR ? 'R' : 'C'
7674
+ // 行尾箭头切片(胶囊内右侧小图标)随胶囊走,避免孤立媒体兜底再渲染一遍
7675
+ const pillRect = { x: (pill.x || 0), y: (pill.y || 0), width: pill.width || 0, height: pill.height || 0 }
7676
+ const arrow = (sem.slices || []).find((sl) => {
7677
+ const sr = rect(sl)
7678
+ const w = sr.width || 0
7679
+ const h = sr.height || 0
7680
+ return w >= 8 && w <= 24 && h >= 8 && h <= 24 && rectInside(pillRect, sr, 2)
7681
+ }) || null
7682
+ const textEl = pill.sourceEl || null
7683
+ const textRight = textEl ? (rect(textEl).x || 0) + (rect(textEl).width || 0) : (pill.x || 0) + (pill.width || 0)
7684
+ clusters.push({ pill, amount, tag, side, px, py, arrow, textRight })
7685
+ }
7686
+ if (clusters.length < 4) return null
7687
+ const sides = new Set(clusters.map((c) => c.side))
7688
+ if (!sides.has('L') || !sides.has('R') || !sides.has('C')) return null
7689
+ sem.buttons = (sem.buttons || []).filter((b) => !clusters.some((c) => c.pill === b))
7690
+ sem.postAmountTexts = aux.filter((t) => !usedAux.has(t))
7691
+ sem.orphanBlocks = (sem.orphanBlocks || []).filter((ob) => ob !== media)
7692
+ const arrowEls = new Set(clusters.map((c) => c.arrow).filter(Boolean))
7693
+ if (arrowEls.size) {
7694
+ sem.slices = (sem.slices || []).filter((sl) => !arrowEls.has(sl))
7695
+ sem.isolatedBlocks = (sem.isolatedBlocks || []).filter((bl) => !arrowEls.has(bl.element))
7696
+ }
7697
+ return { clusters, media, mL, cardX, cardY, pillTop }
7698
+ })()
7699
+ if (scatterBand) {
7700
+ const { clusters, media, cardX, cardY, pillTop } = scatterBand
7701
+ const bySide = { L: [], C: [], R: [] }
7702
+ for (const c of clusters) bySide[c.side].push(c)
7703
+ const colDefs = []
7704
+ for (const side of ['L', 'C', 'R']) {
7705
+ const list = bySide[side]
7706
+ if (!list.length) continue
7707
+ const rects = []
7708
+ for (const c of list) {
7709
+ rects.push({ x: c.px, y: c.py, r: c.px + (c.pill.width || 0) })
7710
+ rects.push({ x: (c.amount.x || 0) - cardX, y: 0, r: (c.amount.x || 0) - cardX + (c.amount.width || 0) })
7711
+ if (c.tag) rects.push({ x: (c.tag.x || 0) - cardX, y: 0, r: (c.tag.x || 0) - cardX + (c.tag.width || 0) })
7712
+ }
7713
+ if (side === 'C') rects.push({ x: scatterBand.mL, y: 0, r: scatterBand.mL + (media.width || 0) })
7714
+ const minX = Math.min(...rects.map((r) => r.x))
7715
+ const maxX = Math.max(...rects.map((r) => r.r))
7716
+ colDefs.push({ side, list, minX, width: maxX - minX })
7717
+ }
7718
+ const bandTopLocal = Math.min(
7719
+ ...clusters.map((c) => c.py),
7720
+ ...clusters.map((c) => (c.amount.y || 0) - cardY),
7721
+ (media.y || 0) - cardY,
7722
+ )
7723
+ const topGap = semanticTopGap(pillTop) ?? T.pad('section')
7724
+ const bandRow = container('Row', { width: '100%', justify: 'flex-start', align: 'flex-start', gap: '0' })
7725
+ ensureStyleGroup(bandRow, 'padding')
7726
+ setStyleChild(bandRow, 'padding', 'paddingType', '1')
7727
+ setStyleChild(bandRow, 'padding', 'paddingTop', String(Math.max(0, Math.round(topGap))))
7728
+ setStyleChild(bandRow, 'padding', 'paddingLeft', String(Math.max(0, Math.round(colDefs[0].minX))))
7729
+ let cursor = colDefs[0].minX + colDefs[0].width
7730
+ let bandBottomLocal = 0
7731
+ for (const cd of colDefs) {
7732
+ const col = container('Column', { direction: 'column', align: 'flex-start', gap: '0', width: String(Math.round(cd.width)), flexShrink: '0' })
7733
+ ensureStyleGroup(col, 'other')
7734
+ setStyleChild(col, 'other', 'overflow', 'visible')
7735
+ if (cd !== colDefs[0]) {
7736
+ ensureStyleGroup(col, 'margin')
7737
+ setStyleChild(col, 'margin', 'marginType', '1')
7738
+ setStyleChild(col, 'margin', 'marginLeft', String(Math.round(cd.minX - cursor)))
7739
+ }
7740
+ cursor = cd.minX + cd.width
7741
+ const items = []
7742
+ for (const c of cd.list) {
7743
+ items.push({ y: c.py, kind: 'pill', c })
7744
+ const ay = (c.amount.y || 0) - cardY
7745
+ const tagBelow = c.tag && ((c.tag.y || 0) - cardY) - ay > 14
7746
+ items.push({ y: ay, kind: tagBelow ? 'amount' : 'amountTag', c })
7747
+ if (tagBelow) items.push({ y: (c.tag.y || 0) - cardY, kind: 'tag', c })
7748
+ }
7749
+ if (cd.side === 'C') items.push({ y: (media.y || 0) - cardY, kind: 'media' })
7750
+ items.sort((a, b) => a.y - b.y)
7751
+ // 列首行顶距相对「带顶」而非列首成员:左右列首成员比中心列低时也要保留设计偏移
7752
+ let prevBottom = bandTopLocal
7753
+ for (let i = 0; i < items.length; i += 1) {
7754
+ const it = items[i]
7755
+ const row = container('Row', { width: String(Math.round(cd.width)), justify: 'flex-start', align: 'center', gap: '0', flexShrink: '0' })
7756
+ ensureStyleGroup(row, 'padding')
7757
+ setStyleChild(row, 'padding', 'paddingType', '1')
7758
+ setStyleChild(row, 'padding', 'paddingTop', String(Math.max(0, Math.round(it.y - prevBottom))))
7759
+ let rowBottom = it.y
7760
+ if (it.kind === 'pill') {
7761
+ const btn = it.c.pill
7762
+ // 胶囊=Row 壳(底色/圆角/透明度)+ 文本 + 行尾箭头切片:Button 无子节点槽,
7763
+ // 设计稿胶囊普遍带右箭头,纯 Button 会让文本居中偏移半个箭头宽(校正器被迫整带平移)。
7764
+ const b = container('Row', {
7765
+ width: String(Math.round(btn.width || 88)), justify: 'flex-start', align: 'center', gap: '0', flexShrink: '0',
7766
+ })
7767
+ const pillPadL = Math.max(0, Math.round((it.c.textRight ? (rect(it.c.pill.sourceEl).x || 0) : (btn.x || 0)) - (btn.x || 0)))
7768
+ ensureStyleGroup(b, 'padding')
7769
+ setStyleChild(b, 'padding', 'paddingType', '1')
7770
+ setStyleChild(b, 'padding', 'paddingLeft', String(pillPadL))
7771
+ ensureStyleGroup(b, 'rect'); ensureStyleGroup(b, 'background'); ensureStyleGroup(b, 'other')
7772
+ ensureStyleGroup(b, 'borderRadius')
7773
+ setStyleChild(b, 'rect', 'height', String(Math.round(btn.height || 30)))
7774
+ if (btn.gradient) {
7775
+ setStyleChild(b, 'background', 'backgroundType', '03')
7776
+ setStyleChild(b, 'background', 'backgroundGradient', btn.gradient)
7777
+ } else if (btn.fillColor) {
7778
+ setStyleChild(b, 'background', 'backgroundType', '01')
7779
+ setStyleChild(b, 'background', 'backgroundColor', hexToRgba(btn.fillColor, btn.opacity ?? 1))
7780
+ }
7781
+ setStyleChild(b, 'other', 'opacity', '100')
7782
+ setStyleChild(b, 'borderRadius', 'borderRadiusType', '0')
7783
+ setStyleChild(b, 'borderRadius', 'borderRadius', String(btn.radius || 14))
7784
+ const label = textFromSpec({ text: btn.text, color: btn.color, fontSize: btn.fontSize || 14, fontWeight: btn.fontWeight || '400', align: 'center' })
7785
+ b.slots[0].components.push(label)
7786
+ const arrowSlice = it.c.arrow
7787
+ if (arrowSlice) {
7788
+ const ar = rect(arrowSlice)
7789
+ const gapX = Math.max(0, Math.round((ar.x || 0) - ((btn.sourceEl ? 0 : 0) + (it.c.textRight || 0))))
7790
+ setStyleChild(b, 'flex', 'gap', String(gapX))
7791
+ const img = imageOrPlaceholder(arrowSlice, sem.slicesExports)
7792
+ if (img) {
7793
+ setStyleChild(img, 'rect', 'width', String(Math.round(ar.width || 20)))
7794
+ setStyleChild(img, 'rect', 'height', String(Math.round(ar.height || 20)))
7795
+ b.slots[0].components.push(img)
7796
+ }
7797
+ }
7798
+ setStyleChild(row, 'padding', 'paddingLeft', String(Math.max(0, Math.round(it.c.px - cd.minX))))
7799
+ row.slots[0].components.push(b)
7800
+ rowBottom = it.y + (btn.height || 30)
7801
+ } else if (it.kind === 'media') {
7802
+ const img = imageOrPlaceholder(media.sliceEl, sem.slicesExports)
7803
+ if (img) {
7804
+ setStyleChild(img, 'rect', 'width', String(Math.round(media.width || 0)))
7805
+ setStyleChild(img, 'rect', 'height', String(Math.round(media.height || 0)))
7806
+ setStyleChild(row, 'padding', 'paddingLeft', String(Math.max(0, Math.round(scatterBand.mL - cd.minX))))
7807
+ row.slots[0].components.push(img)
7808
+ }
7809
+ rowBottom = it.y + (media.height || 0)
7810
+ } else if (it.kind === 'tag') {
7811
+ const tag = it.c.tag
7812
+ setStyleChild(row, 'padding', 'paddingLeft', String(Math.max(0, Math.round((tag.x || 0) - cardX - cd.minX))))
7813
+ row.slots[0].components.push(scatterTagText(tag))
7814
+ rowBottom = it.y + Math.max(tag.height || 0, (tag.fontSize || 10) * 1.4)
7815
+ } else {
7816
+ const amount = it.c.amount
7817
+ setStyleChild(row, 'padding', 'paddingLeft', String(Math.max(0, Math.round((amount.x || 0) - cardX - cd.minX))))
7818
+ row.slots[0].components.push(scatterAmountText(it.c.pill, amount))
7819
+ rowBottom = it.y + Math.max(amount.height || 0, (amount.fontSize || 14) * 1.4)
7820
+ if (it.kind === 'amountTag' && it.c.tag) {
7821
+ const tag = it.c.tag
7822
+ setStyleChild(row, 'flex', 'gap', String(Math.max(0, Math.round((tag.x || 0) - cardX - ((amount.x || 0) - cardX + (amount.width || 0))))))
7823
+ row.slots[0].components.push(scatterTagText(tag))
7824
+ }
7825
+ }
7826
+ prevBottom = rowBottom
7827
+ bandBottomLocal = Math.max(bandBottomLocal, rowBottom)
7828
+ col.slots[0].components.push(row)
7829
+ }
7830
+ bandRow.slots[0].components.push(col)
7831
+ }
7832
+ insertSemantic(card, bandRow, pillTop)
7833
+ addSemanticBound(pillTop, cardY + bandBottomLocal)
7834
+ }
7835
+
5851
7836
  // —— 3b. 金额下方辅助文案:按视觉 y 顺序拆分(表单行之前=mid,按钮之后=footer)——
5852
7837
  // 拓扑驱动:先渲染「实时到账」类 mid 文本,表单行随后,footer 文本(温馨提示/最大金额)留到按钮之后。
5853
7838
  const formTopY = sem.formRows && sem.formRows.length
5854
7839
  ? Math.min(...sem.formRows.map((f) => f.label.y))
5855
- : (sem.buttons.length ? (rect(sem.buttons[0]).y || 0) : 9999)
7840
+ : (sem.buttons.length ? (rect(sem.buttons[0]).y || 0)
7841
+ : (scatterBand ? scatterBand.pillTop : 9999))
5856
7842
  const midAux = (sem.postAmountTexts || []).filter((t) => (t.y || 0) < formTopY)
5857
7843
  const footAux = (sem.postAmountTexts || []).filter((t) => (t.y || 0) >= formTopY)
5858
7844
  // 辅助文案行垂直间距几何化:paddingTop = 本行 y - 上一行底沿;末行 paddingBottom = 卡片底沿 - 本行底沿。
@@ -5906,25 +7892,48 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5906
7892
  const bind = '$' + statVar + ".map(i => i.label + ' ' + i.value).join(" + sepJs + ')'
5907
7893
  return textFromSpec({ ...pat, bind, defaultValue: pat.text })
5908
7894
  }
7895
+ // midAux 之后是否还有静态块(面板组/列表/孤立块等):只看表单/按钮/footAux 会把
7896
+ // 「后面还有 tile 列表」的 midAux 末行当成全卡末行,paddingBottom 撑到卡底
7897
+ // (实测末行被撑高 297px,后续块全部被挤出裁切区)。语义边界里顶沿在其后的块即为证据。
7898
+ const midAuxLastBottom = midAux.length
7899
+ ? (midAux[midAux.length - 1].y || 0) + (midAux[midAux.length - 1].height || (midAux[midAux.length - 1].fontSize || 14) * 1.4)
7900
+ : null
7901
+ const midAuxHasBlocksAfter = (sem.formRows && sem.formRows.length) || sem.buttons.length || footAux.length > 0
7902
+ || (midAuxLastBottom != null && nextSemanticTop(midAuxLastBottom - 1) != null)
5909
7903
  for (let i = 0; i < midAux.length; i++) {
5910
7904
  const pat = midAux[i]
5911
7905
  const host = hostFor(pat.y)
7906
+ // 行首图标(勾选/圆点等选项符号):文本左侧同行带内的小切片随行渲染并消费,
7907
+ // 否则选项行只剩文字、符号丢失(组件计数与像素都扣)。与 appendInlineTrailSlice 同族反方向。
7908
+ const patH = pat.height || (pat.fontSize || 14) * 1.4
7909
+ const leadSliceIdx = (sem.slices || []).findIndex((sl) => {
7910
+ const sr = rect(sl)
7911
+ const w = sr.width || 0
7912
+ const h = sr.height || 0
7913
+ if (w < 12 || w > 48 || h < 12 || h > 48) return false
7914
+ if ((sr.x || 0) + w > (pat.x || 0) - 2 || (sr.x || 0) < (pat.x || 0) - 56) return false
7915
+ return Math.abs(((sr.y || 0) + h / 2) - ((pat.y || 0) + patH / 2)) <= Math.max(12, h)
7916
+ })
7917
+ const leadSlice = leadSliceIdx >= 0 ? sem.slices[leadSliceIdx] : null
5912
7918
  // 渐变容器内的行:间距交给容器的 gap/padding(已在 topDecoration 渲染时统一设置),行自身 padding 归零,
5913
7919
  // 否则会叠加出巨大下边距(如标题行被算成卡片末行 → paddingBottom=卡底-行底=169px)。
5914
7920
  const inTopHost = host !== card
5915
7921
  const isCenter = pat.align !== 'right' && (!pat.align || pat.align === 'center' || pat.x > 100)
5916
7922
  // 只有 midAux 是整卡最后一个内容块(其后无 表单/按钮/footAux)时,末行才需贴卡片底计算 paddingBottom
5917
- const hasBlocksAfter = (sem.formRows && sem.formRows.length) || sem.buttons.length || footAux.length > 0
5918
- const isLast = i === midAux.length - 1 && !hasBlocksAfter
7923
+ const isLast = i === midAux.length - 1 && !midAuxHasBlocksAfter
5919
7924
  const beforeBottom = previousSemanticBottom(pat.y) ??
5920
7925
  (i > 0 ? lineBottom(midAux[i - 1]) : (mainBottom ?? previousAuxBottom))
5921
7926
  const geom = inTopHost ? null : auxRowPadding(pat, i > 0 ? midAux[i - 1] : null, isLast, beforeBottom)
5922
- const midJustify = pat.align === 'right' ? 'flex-end' : isCenter ? 'center' : 'flex-start'
7927
+ // textAlign=right 但几何中心落在卡左 1/3 的标注(添加外部资产类链接文案)按左锚还原:
7928
+ // justify 只认 textAlign 会把左下链接甩到右缘(textAlign 描述盒内对齐,不是行锚点)。
7929
+ const midRelX = (((pat.x || 0) + (pat.width || 0) / 2) - buildCardLeft) / ((buildCard.width || 375))
7930
+ const midJustify = (pat.align === 'right' && midRelX < 0.4) ? 'flex-start'
7931
+ : pat.align === 'right' ? 'flex-end' : isCenter ? 'center' : 'flex-start'
5923
7932
  // 左对齐行的水平位置由 spec x 坐标驱动:auxRowPadding 只管垂直节奏,
5924
7933
  // 左 padding 必须补上 pat.x - 卡片左缘,否则内容贴到卡片 x=0(曾有说明文本丢 32px 左缩进)。
5925
7934
  let midPadding = geom || (inTopHost ? '0' : (isCenter ? '6' : '12'))
5926
7935
  if (midJustify === 'flex-start') {
5927
- const leftPad = Math.max(0, Math.round((pat.x || buildCardLeft) - buildCardLeft))
7936
+ const leftPad = Math.max(0, Math.round(((leadSlice ? rect(leadSlice).x : pat.x) || buildCardLeft) - buildCardLeft))
5928
7937
  const parts = String(midPadding).trim().split(/\s+/)
5929
7938
  while (parts.length < 4) parts.push('0')
5930
7939
  parts[3] = String(leftPad)
@@ -5943,7 +7952,21 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5943
7952
  insertSemantic(host, row, pat.y)
5944
7953
  continue
5945
7954
  }
7955
+ if (leadSlice) {
7956
+ const leadImg = imageOrPlaceholder(leadSlice, sem.slicesExports)
7957
+ if (leadImg) {
7958
+ const lr = rect(leadSlice)
7959
+ setStyleChild(leadImg, 'rect', 'width', String(Math.round(lr.width || 16)))
7960
+ setStyleChild(leadImg, 'rect', 'height', String(Math.round(lr.height || 16)))
7961
+ ensureStyleGroup(row, 'flex')
7962
+ setStyleChild(row, 'flex', 'gap', String(Math.max(0, Math.round((pat.x || 0) - ((lr.x || 0) + (lr.width || 0))))))
7963
+ row.slots[0].components.push(leadImg)
7964
+ sem.slices = (sem.slices || []).filter((sl) => sl !== leadSlice)
7965
+ sem.isolatedBlocks = (sem.isolatedBlocks || []).filter((bl) => bl.element !== leadSlice)
7966
+ }
7967
+ }
5946
7968
  row.slots[0].components.push(auxContent(pat))
7969
+ appendInlineTrailSlice(row, pat)
5947
7970
  insertSemantic(host, row, pat.y)
5948
7971
  }
5949
7972
 
@@ -5961,7 +7984,210 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
5961
7984
  let previousFormBottom
5962
7985
  // 每行的 spec 几何顶/底沿(视觉并集)——分割线插入后按排序序列重算 padTop 用
5963
7986
  const formGeomByNode = new Map()
7987
+ // 对比面板组:y 带重叠 ≥50% 的并排壳必须合成一行输出;逐壳成行会把并排面板堆成上下两块
7988
+ const panelGroups = []
7989
+ {
7990
+ const seenPanels = new Set()
7991
+ const pRows = sorted.filter((fr2) => fr2.panelShell)
7992
+ for (const pa of pRows) {
7993
+ if (seenPanels.has(pa)) continue
7994
+ const band = [pa]; seenPanels.add(pa)
7995
+ for (const pb of pRows) {
7996
+ if (seenPanels.has(pb)) continue
7997
+ const ar = pa.panelShell; const br = pb.panelShell
7998
+ const ov = Math.min(ar.y + ar.height, br.y + br.height) - Math.max(ar.y, br.y)
7999
+ if (ov >= Math.min(ar.height, br.height) * 0.5) { band.push(pb); seenPanels.add(pb) }
8000
+ }
8001
+ band.sort((p, q) => p.panelShell.x - q.panelShell.x)
8002
+ panelGroups.push(band)
8003
+ }
8004
+ }
8005
+ const buildPanelNode = (p, widthOverride) => {
8006
+ const sh = p.panelShell
8007
+ const labBottom = (p.label.y || 0) + (p.label.height || (p.label.fontSize || 14) * 1.4)
8008
+ const valBottom = p.value
8009
+ ? (p.value.y || 0) + (p.value.height || (p.value.fontSize || 14) * 1.4)
8010
+ : labBottom
8011
+ const padTop = Math.max(0, Math.round((p.label.y || 0) - sh.y))
8012
+ const padLeft = Math.max(0, Math.round((p.dot ? p.dot.x : p.label.x) - sh.x))
8013
+ const padBottom = Math.max(0, Math.round((sh.y + sh.height) - Math.max(valBottom, labBottom)))
8014
+ const rowGap = Math.max(0, Math.round((p.value ? p.value.y : labBottom) - labBottom))
8015
+ const col = container('Column', {
8016
+ width: String(Math.round(widthOverride || sh.width)), direction: 'column', align: 'flex-start',
8017
+ gap: String(rowGap), padding: `${padTop} 0 ${padBottom} ${padLeft}`, flexShrink: 0,
8018
+ })
8019
+ setStyleChild(col, 'rect', 'height', String(Math.round(sh.height)))
8020
+ applyShellBackground(col, sh)
8021
+ const line1 = container('Row', {
8022
+ width: '100%', align: 'center', justify: 'flex-start',
8023
+ gap: String(p.dot ? Math.max(0, p.dot.gap) : 0),
8024
+ })
8025
+ if (p.dot) {
8026
+ const dotNode = container('Column', { direction: 'column', flexShrink: 0 })
8027
+ ensureStyleGroup(dotNode, 'background')
8028
+ ensureStyleGroup(dotNode, 'borderRadius')
8029
+ setStyleChild(dotNode, 'rect', 'width', String(p.dot.w || 8))
8030
+ setStyleChild(dotNode, 'rect', 'height', String(p.dot.h || 8))
8031
+ setStyleChild(dotNode, 'background', 'backgroundType', '01')
8032
+ setStyleChild(dotNode, 'background', 'backgroundColor', hexToRgba(p.dot.color || '#CCCCCC'))
8033
+ setStyleChild(dotNode, 'borderRadius', 'borderRadiusType', '0')
8034
+ setStyleChild(dotNode, 'borderRadius', 'borderRadius', String(Math.round((p.dot.w || 8) / 2)))
8035
+ line1.slots[0].components.push(dotNode)
8036
+ }
8037
+ const labNode = textFromSpec({ ...p.label, align: 'left' })
8038
+ ensureStyleGroup(labNode, 'flex')
8039
+ setStyleChild(labNode, 'flex', 'flexShrink', '0')
8040
+ line1.slots[0].components.push(labNode)
8041
+ col.slots[0].components.push(line1)
8042
+ if (p.value || p.tag || (p.subValues || []).length) {
8043
+ // 值行与标签文本左对齐(圆点只缩进行1):缺这段内缩会让金额顶到壳左沿
8044
+ const line2PadLeft = Math.max(0, Math.round(
8045
+ ((p.value ? p.value.x : p.label.x) || 0) - sh.x - padLeft))
8046
+ const tagX = p.tag ? (p.tag.right || 0) - (p.tag.w || 0) : null
8047
+ const anchorRight = p.value
8048
+ ? (p.value.x || 0) + (p.value.width || 0)
8049
+ : (p.label.x || 0) + (p.label.width || 0)
8050
+ const line2 = container('Row', {
8051
+ width: '100%', align: 'center', justify: 'flex-start',
8052
+ gap: p.tag ? String(Math.max(0, Math.round(tagX - anchorRight))) : T.gap('tight'),
8053
+ padding: `0 0 0 ${line2PadLeft}`,
8054
+ })
8055
+ if (p.value) {
8056
+ const valNode = textFromSpec(applyLiteralBind({ ...p.value, align: 'left' }, sem.bindings.literalBind))
8057
+ ensureStyleGroup(valNode, 'flex')
8058
+ setStyleChild(valNode, 'flex', 'flexShrink', '0')
8059
+ line2.slots[0].components.push(valNode)
8060
+ }
8061
+ for (const sv of (p.subValues || [])) {
8062
+ line2.slots[0].components.push(textFromSpec({ ...sv, align: 'left' }))
8063
+ }
8064
+ if (p.tag) {
8065
+ const tagNode = container('Row', { align: 'center', justify: 'center', flexShrink: 0 })
8066
+ ensureStyleGroup(tagNode, 'rect')
8067
+ setStyleChild(tagNode, 'rect', 'width', String(p.tag.w || 24))
8068
+ setStyleChild(tagNode, 'rect', 'height', String(p.tag.h || 14))
8069
+ ensureStyleGroup(tagNode, 'background')
8070
+ ensureStyleGroup(tagNode, 'borderRadius')
8071
+ // 标注缺陷:tag 壳 fill 常报成描边同色(实为浅底),同亮度会让彩色字隐入同色底
8072
+ let tagFill = p.tag.fill
8073
+ if (p.tag.borderColor && tagFill
8074
+ && Math.abs(colorLuminance(tagFill) - colorLuminance(p.tag.color)) < 0.08) {
8075
+ tagFill = tintHex(p.tag.borderColor, 0.08)
8076
+ }
8077
+ if (tagFill) {
8078
+ setStyleChild(tagNode, 'background', 'backgroundType', '01')
8079
+ setStyleChild(tagNode, 'background', 'backgroundColor', hexToRgba(tagFill))
8080
+ }
8081
+ if (p.tag.borderColor) {
8082
+ ensureStyleGroup(tagNode, 'border')
8083
+ setStyleChild(tagNode, 'border', 'isBorder', '1')
8084
+ setStyleChild(tagNode, 'border', 'borderWidth', '1')
8085
+ setStyleChild(tagNode, 'border', 'borderStyle', 'solid')
8086
+ setStyleChild(tagNode, 'border', 'borderColor', hexToRgba(p.tag.borderColor))
8087
+ }
8088
+ setStyleChild(tagNode, 'borderRadius', 'borderRadiusType', '0')
8089
+ setStyleChild(tagNode, 'borderRadius', 'borderRadius', String(Math.round(p.tag.radius || 2)))
8090
+ const tagText = textFromSpec({ text: p.tag.text, color: p.tag.color, fontSize: p.tag.fontSize, align: 'center' })
8091
+ ensureStyleGroup(tagText, 'flex')
8092
+ setStyleChild(tagText, 'flex', 'flexShrink', '0')
8093
+ tagNode.slots[0].components.push(tagText)
8094
+ line2.slots[0].components.push(tagNode)
8095
+ }
8096
+ col.slots[0].components.push(line2)
8097
+ }
8098
+ return col
8099
+ }
5964
8100
  for (const fr of sorted) {
8101
+ // —— 对比面板行:壳内左对齐两行(标签行 + 值/tag 行),并排壳合成一行绝对定位带 ——
8102
+ if (fr.panelShell) {
8103
+ const group = panelGroups.find((g) => g.includes(fr)) || [fr]
8104
+ if (group[0] !== fr) continue
8105
+ const bandTop = Math.min(...group.map((p) => p.panelShell.y))
8106
+ const bandBottom = Math.max(...group.map((p) => p.panelShell.y + p.panelShell.height))
8107
+ const bandLeft = Math.min(...group.map((p) => p.panelShell.x))
8108
+ const bandRight = Math.max(...group.map((p) => p.panelShell.x + p.panelShell.width))
8109
+ const cmpTop = previousFormBottom == null
8110
+ ? (semanticTopGap(bandTop) ?? T.pad('section'))
8111
+ : Math.max(0, Math.round(bandTop - previousFormBottom))
8112
+ // 面板间连接件(横跨壳间隙的小切片,如对比箭头)收进带内,避免孤立块另起一行
8113
+ const claimedByRows = new Set((sem.formRows || [])
8114
+ .flatMap((p2) => [p2.rightAffordanceSlice, p2.leftIconSlice, p2.trailingIconSlice, p2.valueLeadSlice])
8115
+ .filter(Boolean))
8116
+ const connectors = group.length > 1 ? (sem.slices || []).filter((s2) => {
8117
+ const r2 = rect(s2)
8118
+ const w2 = r2.width || 0; const h2 = r2.height || 0
8119
+ if (w2 < 10 || w2 > 44 || h2 < 10 || h2 > 44) return false
8120
+ if (claimedByRows.has(s2)) return false
8121
+ const cx = (r2.x || 0) + w2 / 2; const cy = (r2.y || 0) + h2 / 2
8122
+ if (cx < bandLeft - 4 || cx > bandRight + 4 || cy < bandTop || cy > bandBottom) return false
8123
+ return !group.some((p) => rectInside(p.panelShell, r2, 0))
8124
+ }).sort((p, q) => (rect(p).x || 0) - (rect(q).x || 0)) : []
8125
+ if (connectors.length && Array.isArray(sem.isolatedBlocks)) {
8126
+ sem.isolatedBlocks = sem.isolatedBlocks.filter((b) => !connectors.includes(b.element))
8127
+ }
8128
+ let bandNode
8129
+ if (group.length > 1 || connectors.length) {
8130
+ // 渲染器无绝对定位(position 组被忽略)且组件 overflow 默认 hidden:
8131
+ // 并排壳用 flex Row 精确宽度排布;跨壳连接件(对比箭头)作为**末尾**子节点 +
8132
+ // 负 marginLeft 拉回设计 x(末尾保证它绘制在两侧壳背景之上),竖直居中于带高。
8133
+ bandNode = container('Row', {
8134
+ width: '100%', direction: 'row', align: 'flex-start', justify: 'flex-start', gap: '0',
8135
+ })
8136
+ let cursor = 0
8137
+ for (let gi = 0; gi < group.length; gi++) {
8138
+ const p = group[gi]
8139
+ bandNode.slots[0].components.push(buildPanelNode(p))
8140
+ cursor += Math.round(p.panelShell.width)
8141
+ const next = group[gi + 1]
8142
+ if (next) {
8143
+ const gapW = Math.max(0, Math.round(next.panelShell.x - (p.panelShell.x + p.panelShell.width)))
8144
+ if (gapW > 0) {
8145
+ const spacer = container('Column', { direction: 'column', flexShrink: 0 })
8146
+ ensureStyleGroup(spacer, 'rect')
8147
+ setStyleChild(spacer, 'rect', 'width', String(gapW))
8148
+ setStyleChild(spacer, 'rect', 'height', String(Math.round(bandBottom - bandTop)))
8149
+ bandNode.slots[0].components.push(spacer)
8150
+ cursor += gapW
8151
+ }
8152
+ }
8153
+ }
8154
+ for (const cn of connectors) {
8155
+ const holder = container('Column', {
8156
+ direction: 'column', justify: 'center', align: 'center', flexShrink: 0,
8157
+ })
8158
+ ensureStyleGroup(holder, 'rect')
8159
+ setStyleChild(holder, 'rect', 'width', String(Math.round(rect(cn).width || 0)))
8160
+ setStyleChild(holder, 'rect', 'height', String(Math.round(bandBottom - bandTop)))
8161
+ ensureStyleGroup(holder, 'margin')
8162
+ setStyleChild(holder, 'margin', 'marginType', '1')
8163
+ setStyleChild(holder, 'margin', 'marginLeft', String(Math.round((rect(cn).x || 0) - bandLeft - cursor))
8164
+ )
8165
+ setStyleChild(holder, 'margin', 'marginTop', '0')
8166
+ setStyleChild(holder, 'margin', 'marginRight', '0')
8167
+ setStyleChild(holder, 'margin', 'marginBottom', '0')
8168
+ const media = imageOrPlaceholder(cn, sem.slicesExports)
8169
+ if (media) {
8170
+ setStyleChild(media, 'rect', 'width', String(Math.round(rect(cn).width || 0)))
8171
+ setStyleChild(media, 'rect', 'height', String(Math.round(rect(cn).height || 0)))
8172
+ holder.slots[0].components.push(media)
8173
+ }
8174
+ bandNode.slots[0].components.push(holder)
8175
+ cursor += Math.round(rect(cn).width || 0)
8176
+ }
8177
+ } else {
8178
+ bandNode = buildPanelNode(group[0])
8179
+ }
8180
+ const bandWrap = container('Row', {
8181
+ width: '100%', direction: 'row', justify: 'flex-start', align: 'center',
8182
+ padding: `${cmpTop} ${Math.max(0, Math.round(buildCardRight - bandRight))} 0 ${Math.max(0, Math.round(bandLeft - buildCardLeft))}`,
8183
+ })
8184
+ bandWrap.slots[0].components.push(bandNode)
8185
+ formHost.slots[0].components.push(bandWrap)
8186
+ registerSemanticY(bandWrap, bandTop)
8187
+ formGeomByNode.set(bandWrap, { top: bandTop, bottom: bandBottom })
8188
+ previousFormBottom = bandBottom
8189
+ continue
8190
+ }
5965
8191
  // ① 结构 + ② 布局:外层水平容器,左右两端对齐
5966
8192
  // 通用布局规则:标签为固定型(flexShrink:0 不被挤压),右簇为撑满型(width:100%+flexShrink:1)
5967
8193
  // 行视觉顶/底沿:label/value 文本与行尾件(pill 按钮、箭头、左图标)的几何并集。
@@ -6343,11 +8569,23 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6343
8569
  setStyleChild(fill, 'borderRadius', 'borderRadius', String(Math.round((bar.height || 4) / 2)))
6344
8570
  track.slots[0].components.push(fill)
6345
8571
  }
6346
- hostFor(bar.y).slots[0].components.push(track)
8572
+ insertSemantic(hostFor(bar.y), track, bar.y)
6347
8573
  }
6348
8574
 
6349
8575
  // —— 4. 副金额对(多组并排)→ 每对金额各自抽 inputParam + 千分号表达式 ——
6350
- if (sem.subAmounts.length && !sem._subAmountsConsumed) {
8576
+ const subPanelRects = (sem.panelRows || []).filter(Boolean)
8577
+ const subInPanel = (el) => {
8578
+ if (!el) return false
8579
+ const er = rect(el)
8580
+ const cx = (er.x || 0) + (er.width || 0) / 2
8581
+ const cy = (er.y || 0) + (er.height || 0) / 2
8582
+ return subPanelRects.some((pr) => cx >= (pr.x || 0) && cx <= (pr.x || 0) + (pr.width || 0)
8583
+ && cy >= (pr.y || 0) && cy <= (pr.y || 0) + (pr.height || 0))
8584
+ }
8585
+ // 副金额对落在面板壳内时由面板行渲染(产品卡右列),全局 space-around 行会把
8586
+ // 标签/金额甩到面板之外(曾致产品卡右列逃逸+第二张卡空壳)。
8587
+ const subsActive = sem.subAmounts.filter((sub) => !subInPanel(sub.labelEl) && !subInPanel(sub.valueEl))
8588
+ if (subsActive.length && !sem._subAmountsConsumed) {
6351
8589
  const panel = sem.subAmountPanel
6352
8590
  const panelOuter = panel
6353
8591
  ? container('Column', { width: '100%', direction: 'column', padding: `${semanticTopGap(panel.y) ?? 0} 0 0 0` })
@@ -6369,8 +8607,8 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6369
8607
  panelOuter.slots[0].components.push(wrapHost)
6370
8608
  insertSemantic(card, panelOuter, panel.y)
6371
8609
  }
6372
- for (let i = 0; i < sem.subAmounts.length; i++) {
6373
- const sub = sem.subAmounts[i]
8610
+ for (let i = 0; i < subsActive.length; i++) {
8611
+ const sub = subsActive[i]
6374
8612
  // 变量名:副金额标签词典(本期应还→currentDueAmount / 最低应还→minimumDueAmount),
6375
8613
  // 未命中 → 角色名 amount(uniqueKey 自动加序号区分同构多组)
6376
8614
  const varName = uniqueKey(fieldKeyFor(sub.label) || 'amount', usedTopKeys)
@@ -6505,6 +8743,7 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6505
8743
  // 枚举统计行兜底(同 midAux,见 tryRenderEnumStat)
6506
8744
  const statT = tryRenderEnumStat(pat)
6507
8745
  row.slots[0].components.push(statT || textFromSpec({ ...pat, align: 'left' }))
8746
+ if (!statT) appendInlineTrailSlice(row, pat)
6508
8747
  insertSemantic(card, row, pat.y)
6509
8748
  }
6510
8749
 
@@ -6539,9 +8778,16 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6539
8778
  // 无壳时 listTop 是首行内容顶沿,渲染行内还有 padTop,需扣除避免重复计数。
6540
8779
  const firstHasShell = sem.sampleItems.length
6541
8780
  && sem.sampleItems[0].rowTop != null && sem.sampleItems[0].rowTop < sem.sampleItems[0].y
6542
- const titleBottomGap = listTop == null
6543
- ? (semanticBottomGap(titleRenderedBottom) ?? T.pad('section'))
6544
- : Math.max(0, Math.round(listTop - titleRenderedBottom) - (firstHasShell ? 0 : listRowPaddingTop))
8781
+ // 标题与列表之间还夹着其它语义块(对比面板/段落等)时不能把间距桥接进标题行底 padding:
8782
+ // 否则中间块被推离设计位(曾致对比面板下移 200+px),中间块自己的顶距会负责衔接。
8783
+ const nextTopAfterTitle = nextSemanticTop(titleRenderedBottom)
8784
+ const hasIntermediateSection = nextTopAfterTitle != null
8785
+ && (listTop == null || nextTopAfterTitle < listTop - 2)
8786
+ const titleBottomGap = hasIntermediateSection
8787
+ ? 0
8788
+ : listTop == null
8789
+ ? (semanticBottomGap(titleRenderedBottom) ?? T.pad('section'))
8790
+ : Math.max(0, Math.round(listTop - titleRenderedBottom) - (firstHasShell ? 0 : listRowPaddingTop))
6545
8791
  // 行尾右侧操作件(如标题行眼睛图标):space-between 钉在 spec 右沿
6546
8792
  const ltAffordEl = sem.listTitle.rightSliceEl || null
6547
8793
  const ltAffordR = ltAffordEl ? rect(ltAffordEl) : null
@@ -6569,22 +8815,48 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6569
8815
  if ((sem.listTitle || sem.sampleItems.length >= 2) && sem.sampleItems.length) {
6570
8816
  // 列表数据绑定:变量名由列表标题语义推断(明细→detailList),未命中用中性 'list'
6571
8817
  const listVar = uniqueKey(listVarName(sem.listTitle), usedTopKeys)
8818
+ // 逐行壳色不同(蓝/橙/绿 tint):模板背景改绑 $rowBg(渐变取 stop 均值近似纯色)
8819
+ const shellRepColor = (it2) => {
8820
+ if (it2.fill) return it2.fill
8821
+ const stops = String(it2.gradient || '').match(/rgba?\([^)]*\)/g) || []
8822
+ if (!stops.length) return ''
8823
+ let r = 0; let g = 0; let b = 0; let a = 0
8824
+ for (const st of stops) {
8825
+ const parts = st.replace(/rgba?\(/, '').replace(/\)/, '').split(',').map((v) => Number(v) || 0)
8826
+ r += parts[0] || 0; g += parts[1] || 0; b += parts[2] || 0; a += parts[3] == null ? 1 : parts[3]
8827
+ }
8828
+ const n2 = stops.length
8829
+ return `rgba(${Math.round(r / n2)}, ${Math.round(g / n2)}, ${Math.round(b / n2)}, ${Math.round((a / n2) * 100) / 100})`
8830
+ }
6572
8831
  addParam(listVar, 'array', (sem.allItems || sem.sampleItems).map((it) => ({
6573
8832
  title: it.title,
6574
8833
  titleSuffix: it.titleSuffix || '',
6575
8834
  titleRight: it.titleRight || '',
6576
8835
  time: it.time,
6577
8836
  desc: it.desc || '',
8837
+ descRight: it.descRight || '',
6578
8838
  icon: it.icon || '',
8839
+ rightIcon: it.rightIcon || '',
8840
+ rowBg: shellRepColor(it) || '',
8841
+ // 逐行渐变壳:背景渐变整串下行级绑定($rowGradient),纯色壳回退 $rowBg
8842
+ rowGradient: it.gradient ? gradientWithOpacity(it.gradient, it.opacity ?? 1) : '',
6579
8843
  action: it.action || '',
6580
8844
  // 行尾 affordance(眼睛/箭头等):逐行数据驱动,无此行级操作件的行绑定空串 + v-show 隐藏
6581
8845
  afford: it.affordanceUrl || '',
6582
8846
  // 金额作为数字存储(剥千分号/¥符号/负号),渲染时用表达式还原
6583
8847
  itemAmount: parseAmount(it.amount) !== null ? parseAmount(it.amount) : it.amount,
8848
+ // 货币前缀逐行存储:列表模板表达式共用,符号差异(首行 $ 次行 ¥)靠数据区分
8849
+ itemAmountCur: currencyPrefixOf(it.amount),
8850
+ // 行内进度条比率(进度条落入列表行 y 带时挂接到行数据)
8851
+ barRatio: it.bars && it.bars[0] && it.bars[0].segments && it.bars[0].segments[0]
8852
+ ? it.bars[0].segments[0].ratio : 0,
6584
8853
  // 复合行金额列组:扁平字段(amtLabel1/amtValue1…),值存数字由表达式格式化
6585
8854
  ...Object.fromEntries((it.amounts || []).slice(0, 4).flatMap((pp, pi) => [
6586
8855
  ['amtLabel' + (pi + 1), pp.label],
6587
8856
  ['amtValue' + (pi + 1), parseAmount(pp.value) !== null ? parseAmount(pp.value) : pp.value],
8857
+ ['amtCur' + (pi + 1), currencyPrefixOf(pp.value)],
8858
+ ['amtTag' + (pi + 1), pp.tagText || ''],
8859
+ ['amtTagIcon' + (pi + 1), pp.tagIcon || ''],
6588
8860
  ])),
6589
8861
  })))
6590
8862
  // 列表项模板(外层 Column:内层 Row[左 Column(标题+时间) 撑开 + 右 Row(金额+箭头) 靠右] + 底部 Divider)
@@ -6593,9 +8865,49 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6593
8865
  // 内层 Row 承载原 leftCol + rightRow 保持横向并排,Divider 作为 Column 第二个子节点纵向跟随其后
6594
8866
  const firstItem = sem.sampleItems[0] || { title: '', time: '', amount: '' }
6595
8867
  const itemAmtVar = 'itemAmount'
8868
+ // 行壳(设计行级圆角填充底):模板承载底色/圆角/内边距/高度,列表按壳宽内缩
8869
+ const rowShell = (firstItem.fill || firstItem.gradient)
8870
+ ? { fill: firstItem.fill, gradient: firstItem.gradient, radius: firstItem.radius, opacity: firstItem.opacity }
8871
+ : null
8872
+ // 壳间分离(相邻壳间隙 ≥2):设计用间隙分行而非分割线 → 模板不发 Divider,row-spacing 承间隙
8873
+ const shellsDiffer = !!rowShell
8874
+ && sem.sampleItems.some((it2) => (it2.fill || it2.gradient) && (it2.fill || it2.gradient) !== (firstItem.fill || firstItem.gradient))
8875
+ const shellsSeparated = !!rowShell && sem.sampleItems.every((it2) => it2.shellEl)
8876
+ && sem.sampleItems.slice(1).every((it2, i2) => {
8877
+ const prev = rect(sem.sampleItems[i2].shellEl)
8878
+ const cur = rect(it2.shellEl)
8879
+ return ((cur.y || 0) - ((prev.y || 0) + (prev.height || 0))) >= 2
8880
+ })
6596
8881
  // 外层 Column:纵向堆叠内容行与分割线(不再用 Row,避免 Divider 并排)
6597
8882
  // 关键:container('Column', ...) 不会自动设 direction,必须显式传 direction:'column',否则落到默认 row(横排)
6598
8883
  const itemTpl = container('Column', { direction: 'column', width: '100%', gap: T.gap('none') })
8884
+ if (rowShell) {
8885
+ if (shellsDiffer) {
8886
+ // 行级壳色/壳渐变绑定:必须**改写** style-meta 自带的 backgroundColor/backgroundGradient
8887
+ // 子项(追加第二个同名子项会让渲染器取到空值/绑定串原样进 doColor → 背景透明)。
8888
+ ensureStyleGroup(itemTpl, 'background')
8889
+ ensureStyleGroup(itemTpl, 'borderRadius')
8890
+ const grp = (itemTpl.style.properties || []).find((pp) => pp.itemKey === 'background')
8891
+ const setBound = (childKey, expr, fallback) => {
8892
+ const child = (grp.itemChildren?.properties || []).find((cc) => cc.itemKey === childKey)
8893
+ if (!child) return
8894
+ child.itemValue = expr
8895
+ child.itemValueFrom = '1'
8896
+ child.itemDefaultValue = fallback
8897
+ }
8898
+ if (firstItem.gradient) {
8899
+ setStyleChild(itemTpl, 'background', 'backgroundType', '03')
8900
+ setBound('backgroundGradient', '$rowGradient', gradientWithOpacity(firstItem.gradient, firstItem.opacity ?? 1))
8901
+ } else {
8902
+ setStyleChild(itemTpl, 'background', 'backgroundType', '01')
8903
+ setBound('backgroundColor', '$rowBg', shellRepColor(firstItem))
8904
+ }
8905
+ setStyleChild(itemTpl, 'borderRadius', 'borderRadiusType', '0')
8906
+ setStyleChild(itemTpl, 'borderRadius', 'borderRadius', String(Math.round(rowShell.radius || 8)))
8907
+ } else {
8908
+ applyShellBackground(itemTpl, rowShell)
8909
+ }
8910
+ }
6599
8911
  let compositeAmountsRow = null
6600
8912
  // 内层 Row:承载原 leftCol + rightRow(横向并排)
6601
8913
  const rowHeights = sem.sampleItems.map((item) => item.rowHeight || item.contentHeight || 0).filter(Boolean)
@@ -6612,7 +8924,13 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6612
8924
  // 行高估算必须与节点实际渲染盒高同度量:模板文本未传 spec 高度 → 自然撑高 ≈ round(fs×1.53)。
6613
8925
  // 估算用 ×1.5 会低估 0.5-1px/行,paddingBottom 吸收不足 → 行高超出壳高中位,逐行累计下漂。
6614
8926
  const titleLineHeight = Math.max(20, Math.round((firstItem.titleFontSize || T.font('body')) * 1.53))
6615
- const secondaryFontSize = firstItem.desc ? firstItem.descFontSize : (firstItem.time ? T.font('small') : 0)
8927
+ // descInline: desc on same baseline as title (dy < fontSize*0.8) and after title right edge
8928
+ const descInline = !!(firstItem.desc
8929
+ && Number.isFinite(firstItem.descY) && Number.isFinite(firstItem.y)
8930
+ && Number.isFinite(firstItem.titleX) && Number.isFinite(firstItem.titleW)
8931
+ && (firstItem.descY - firstItem.y) < Math.round((firstItem.titleFontSize || 14) * 0.8)
8932
+ && (firstItem.descX || 0) >= (firstItem.titleX || 0) + (firstItem.titleW || 0))
8933
+ const secondaryFontSize = (firstItem.desc && !descInline) ? firstItem.descFontSize : (firstItem.time ? T.font('small') : 0)
6616
8934
  const secondaryLineHeight = secondaryFontSize ? Math.max(16, Math.round(secondaryFontSize * 1.53)) : 0
6617
8935
  // 标题→副行(desc/time)纵距取标注中位(设计的行距并非常量;统一 tight(4) 会把副行
6618
8936
  // 压偏 +2px/项)。纵距锚渲染行盒(fs×1.5 自然行盒,实测渲染度量):descY - (titleY + 盒高)。
@@ -6624,15 +8942,20 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6624
8942
  ? String(_titleDescGaps.slice().sort((a, b) => a - b)[Math.floor(_titleDescGaps.length / 2)])
6625
8943
  : T.gap('tight')
6626
8944
  const isCompositeItems = !!(firstItem.amounts && firstItem.amounts.length >= 2)
8945
+ const _barContentH = (firstItem.bars && firstItem.bars[0])
8946
+ ? Math.round((firstItem.titleFontSize || 14) * 0.5) * 2 + (firstItem.bars[0].height || 4)
8947
+ : 0
6627
8948
  const renderedContentHeight = isCompositeItems && firstItem.contentHeight
6628
8949
  ? Math.round(firstItem.contentHeight)
6629
8950
  : Math.round(titleLineHeight
6630
- + (secondaryLineHeight ? (parseInt(titleDescGap, 10) || 0) + secondaryLineHeight : 0))
8951
+ + (secondaryLineHeight ? (parseInt(titleDescGap, 10) || 0) + secondaryLineHeight : 0)
8952
+ + _barContentH)
6631
8953
  const listRowPaddingBottom = Math.max(0,
6632
8954
  listRowHeight - listRowPaddingTop - renderedContentHeight - dividerHeight)
6633
8955
  const isComposite = !!(firstItem.amounts && firstItem.amounts.length >= 2)
6634
8956
  // 复合行:innerRow 只承载标题行,底距为 0(金额行自带 spec 顶距/底距)
6635
- const innerPadBottom = isComposite ? 0 : listRowPaddingBottom
8957
+ const _hasBar = !!(firstItem.bars && firstItem.bars[0])
8958
+ const innerPadBottom = isComposite ? 0 : (_hasBar ? 0 : listRowPaddingBottom)
6636
8959
  // 行图标与标题列的间距取 spec 实测中位(标题 x - 图标右沿),无图标时为 0
6637
8960
  const iconGaps = sem.sampleItems.map((item) => item.iconGap).filter((v) => Number.isFinite(v))
6638
8961
  const innerGap = iconGaps.length
@@ -6651,7 +8974,7 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6651
8974
  const innerRow = container('Row', {
6652
8975
  width: '100%',
6653
8976
  align: 'center',
6654
- padding: `${listRowPaddingTop} ${listPadRight} ${innerPadBottom} ${listPadLeft}`,
8977
+ padding: `${rowShell && firstItem.padTop != null ? firstItem.padTop : listRowPaddingTop} ${rowShell && firstItem.padRight != null ? firstItem.padRight : listPadRight} ${innerPadBottom + (shellsSeparated ? dividerHeight : 0)} ${rowShell && firstItem.padLeft != null ? firstItem.padLeft : listPadLeft}`,
6655
8978
  gap: innerGap,
6656
8979
  })
6657
8980
  const leftCol = container('Column', { direction: 'column', gap: titleDescGap, width: '100%' })
@@ -6663,6 +8986,8 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6663
8986
  const withSuffix = sem.sampleItems.some((item) => item.titleSuffix)
6664
8987
  // 标题右侧同行文本(卡号等):间距取 spec 几何(标题右沿→文本左沿)
6665
8988
  const withTitleRight = sem.sampleItems.some((item) => item.titleRight)
8989
+ // 行内联 affordance(紧跟标题的眼睛图标):模板在标题行内联渲染,逐行 v-show
8990
+ const inlineAffordSample = sem.sampleItems.find((item) => item.affordInline && item.affordanceUrl)
6666
8991
  // 行内操作:所有样本行都有操作 pill(如每行「删除」按钮)
6667
8992
  const withAction = sem.sampleItems.length > 0 && sem.sampleItems.every((item) => item.action)
6668
8993
  const titleText = textFromSpec({
@@ -6670,16 +8995,50 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6670
8995
  fontWeight: firstItem.titleFontWeight,
6671
8996
  bind: '$title', defaultValue: firstItem.title,
6672
8997
  })
6673
- if (withSuffix || withTitleRight) {
8998
+ if (descInline && !withSuffix && !withTitleRight && !inlineAffordSample) {
8999
+ const _inlineGap = Math.max(2, Math.round((firstItem.descX || 0)
9000
+ - ((firstItem.titleX || 0) + (firstItem.titleW || 0))))
9001
+ const titleRow = container('Row', { direction: 'row', align: 'center', gap: String(_inlineGap) })
9002
+ ensureStyleGroup(titleRow, 'flex')
9003
+ titleRow.slots[0].components.push(titleText)
9004
+ titleRow.slots[0].components.push(textFromSpec({
9005
+ text: '$desc', color: firstItem.descColor, fontSize: firstItem.descFontSize || T.font('small'),
9006
+ fontWeight: firstItem.descFontWeight,
9007
+ bind: '$desc', defaultValue: firstItem.desc,
9008
+ }))
9009
+ leftCol.slots[0].components.push(titleRow)
9010
+ } else if (withSuffix || withTitleRight || inlineAffordSample) {
6674
9011
  // 标题+后缀/右侧同行文本(基线对齐):Row[title, suffix?, titleRight?]
6675
9012
  const titleRow = container('Row', {
6676
9013
  direction: 'row', align: 'center',
6677
- gap: withTitleRight && !withSuffix
6678
- ? String(Math.max(4, Math.round(firstItem.titleRightGap || 8)))
6679
- : T.gap('tight'),
9014
+ justify: (withTitleRight && !withSuffix && !inlineAffordSample) ? 'space-between' : 'flex-start',
9015
+ gap: inlineAffordSample && !withTitleRight && !withSuffix
9016
+ ? String(inlineAffordSample.affordInlineGap || 8)
9017
+ : (withTitleRight && !withSuffix)
9018
+ ? String(Math.max(4, Math.round(firstItem.titleRightGap || 8)))
9019
+ : T.gap('tight'),
6680
9020
  })
6681
9021
  ensureStyleGroup(titleRow, 'flex')
6682
9022
  titleRow.slots[0].components.push(titleText)
9023
+ // space-between 布局下标题须可收缩(flexShrink=1),否则长标题把 titleRight 挤出右沿溢出。
9024
+ if (withTitleRight && !withSuffix && !inlineAffordSample) {
9025
+ ensureStyleGroup(titleText, 'flex')
9026
+ setStyleChild(titleText, 'flex', 'flexShrink', '1')
9027
+ }
9028
+ if (inlineAffordSample) {
9029
+ const iaR = rect(inlineAffordSample.affordanceEl)
9030
+ const inlineAffordImg = comp('Image')
9031
+ ensureStyleGroup(inlineAffordImg, 'rect')
9032
+ ensureStyleGroup(inlineAffordImg, 'image')
9033
+ ensureStyleGroup(inlineAffordImg, 'flex')
9034
+ setStyleChild(inlineAffordImg, 'rect', 'width', String(Math.round(iaR?.width || 20)))
9035
+ setStyleChild(inlineAffordImg, 'rect', 'height', String(Math.round(iaR?.height || 20)))
9036
+ setStyleChild(inlineAffordImg, 'flex', 'flexShrink', '0')
9037
+ setStyleChild(inlineAffordImg, 'image', 'cropMode', '01')
9038
+ inlineAffordImg.properties = [boundProp('value', '$afford', inlineAffordSample.affordanceUrl || '')]
9039
+ inlineAffordImg.directives = [vShowDirective("$afford != ''")]
9040
+ titleRow.slots[0].components.push(inlineAffordImg)
9041
+ }
6683
9042
  if (withSuffix) {
6684
9043
  titleRow.slots[0].components.push(textFromSpec({
6685
9044
  text: '$titleSuffix', color: firstItem.titleSuffixColor || '#999999',
@@ -6725,15 +9084,67 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6725
9084
  const pairCol = container('Column', { direction: 'column', gap: labelValueGap })
6726
9085
  ensureStyleGroup(pairCol, 'flex')
6727
9086
  setStyleChild(pairCol, 'flex', 'flexShrink', '0')
6728
- pairCol.slots[0].components.push(textFromSpec({
9087
+ const labelRow = container('Row', { direction: 'row', align: 'center', gap: String(pp.tagGap ?? 6) })
9088
+ ensureStyleGroup(labelRow, 'flex')
9089
+ labelRow.slots[0].components.push(textFromSpec({
6729
9090
  text: '$amtLabel' + (pi + 1), color: pp.labelColor || '#999999',
6730
9091
  fontSize: pp.labelFontSize || T.font('small'), fontWeight: pp.labelFontWeight,
6731
9092
  bind: '$amtLabel' + (pi + 1), defaultValue: pp.label,
6732
9093
  }))
9094
+ // 内联 tag 壳:样式取首个带 tag 的样本行,内容逐行绑定;空行 v-show 隐藏
9095
+ const tagSrc = sem.sampleItems.map((it2) => (it2.amounts || [])[pi]).find((aa) => aa && (aa.tagText || aa.tagIcon)) || pp
9096
+ if (tagSrc.tagW || tagSrc.tagText || tagSrc.tagIcon) {
9097
+ const tag = container('Row', {
9098
+ direction: 'row', align: 'center', justify: 'center',
9099
+ gap: String(tagSrc.tagIconGap ?? 2),
9100
+ padding: '0 ' + (tagSrc.tagPadRight ?? 4) + ' 0 ' + (tagSrc.tagPadLeft ?? 8),
9101
+ })
9102
+ ensureStyleGroup(tag, 'rect')
9103
+ ensureStyleGroup(tag, 'background')
9104
+ ensureStyleGroup(tag, 'borderRadius')
9105
+ ensureStyleGroup(tag, 'flex')
9106
+ setStyleChild(tag, 'rect', 'width', String(tagSrc.tagW || 54))
9107
+ setStyleChild(tag, 'rect', 'height', String(tagSrc.tagH || 16))
9108
+ setStyleChild(tag, 'flex', 'flexShrink', '0')
9109
+ if (tagSrc.tagFill) {
9110
+ setStyleChild(tag, 'background', 'backgroundType', '01')
9111
+ setStyleChild(tag, 'background', 'backgroundColor', hexToRgba(tagSrc.tagFill, tagSrc.tagOpacity ?? 1))
9112
+ }
9113
+ if (tagSrc.tagBorder) {
9114
+ ensureStyleGroup(tag, 'border')
9115
+ setStyleChild(tag, 'border', 'isBorder', '1')
9116
+ setStyleChild(tag, 'border', 'borderWidth', '1')
9117
+ setStyleChild(tag, 'border', 'borderStyle', 'solid')
9118
+ setStyleChild(tag, 'border', 'borderColor', hexToRgba(tagSrc.tagBorder))
9119
+ }
9120
+ setStyleChild(tag, 'borderRadius', 'borderRadiusType', '0')
9121
+ setStyleChild(tag, 'borderRadius', 'borderRadius', String(tagSrc.tagRadius || 4))
9122
+ tag.slots[0].components.push(textFromSpec({
9123
+ text: '$amtTag' + (pi + 1), color: tagSrc.tagTextColor || '#FF8401',
9124
+ fontSize: tagSrc.tagFontSize || T.font('small'),
9125
+ bind: '$amtTag' + (pi + 1), defaultValue: pp.tagText || '',
9126
+ }))
9127
+ if (tagSrc.tagIconW) {
9128
+ const tagIcon = comp('Image')
9129
+ ensureStyleGroup(tagIcon, 'rect')
9130
+ ensureStyleGroup(tagIcon, 'image')
9131
+ ensureStyleGroup(tagIcon, 'flex')
9132
+ setStyleChild(tagIcon, 'rect', 'width', String(tagSrc.tagIconW))
9133
+ setStyleChild(tagIcon, 'rect', 'height', String(tagSrc.tagIconH))
9134
+ setStyleChild(tagIcon, 'flex', 'flexShrink', '0')
9135
+ setStyleChild(tagIcon, 'image', 'cropMode', '01')
9136
+ tagIcon.properties = [boundProp('value', '$amtTagIcon' + (pi + 1), pp.tagIcon || '')]
9137
+ tagIcon.directives = [vShowDirective("$amtTagIcon" + (pi + 1) + " != ''")]
9138
+ tag.slots[0].components.push(tagIcon)
9139
+ }
9140
+ tag.directives = [vShowDirective("($amtTag" + (pi + 1) + " || $amtTagIcon" + (pi + 1) + ") != ''")]
9141
+ labelRow.slots[0].components.push(tag)
9142
+ }
9143
+ pairCol.slots[0].components.push(labelRow)
6733
9144
  pairCol.slots[0].components.push(textFromSpec({
6734
9145
  text: pp.value, color: pp.valueColor || '#111111',
6735
9146
  fontSize: pp.valueFontSize || T.font('title'), fontWeight: pp.valueFontWeight,
6736
- bind: amountExpr('amtValue' + (pi + 1), pp.value), defaultValue: pp.value,
9147
+ bind: amountExpr('amtValue' + (pi + 1), pp.value, { prefixVar: 'amtCur' + (pi + 1) }), defaultValue: pp.value,
6737
9148
  }))
6738
9149
  amountsRow.slots[0].components.push(pairCol)
6739
9150
  })
@@ -6747,12 +9158,27 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6747
9158
  - titleLineHeight - (firstItem.labelGapTop ?? 8) - _amountsContentH - dividerHeight))
6748
9159
  setStyleChild(amountsRow, 'padding', 'paddingBottom', String(_amountsPadBottom))
6749
9160
  compositeAmountsRow = amountsRow
6750
- } else if (firstItem.desc) {
6751
- leftCol.slots[0].components.push(textFromSpec({
9161
+ } else if (firstItem.desc && !descInline) {
9162
+ const _descText = textFromSpec({
6752
9163
  text: '$desc', color: firstItem.descColor, fontSize: firstItem.descFontSize || T.font('small'),
6753
9164
  fontWeight: firstItem.descFontWeight,
6754
9165
  bind: '$desc', defaultValue: firstItem.desc,
6755
- }))
9166
+ })
9167
+ // desc 的 spec x 坐标可能与标题左沿不一致(百分比文本缩进在标题右沿之后),
9168
+ // 用左 padding 补齐差值使 desc 对齐 spec 位置。
9169
+ if (firstItem.descX != null && firstItem.y != null) {
9170
+ const _titleX = firstItem.rowLeftEdge || (rect(card).x || 0) + 16
9171
+ const _descIndent = Math.max(0, Math.round((firstItem.descX || 0) - _titleX))
9172
+ if (_descIndent >= 4) {
9173
+ const _descWrap = container('Row', { width: '100%', justify: 'flex-start', align: 'flex-start', padding: `0 0 0 ${_descIndent}` })
9174
+ _descWrap.slots[0].components.push(_descText)
9175
+ leftCol.slots[0].components.push(_descWrap)
9176
+ } else {
9177
+ leftCol.slots[0].components.push(_descText)
9178
+ }
9179
+ } else {
9180
+ leftCol.slots[0].components.push(_descText)
9181
+ }
6756
9182
  } else if (firstItem.time) {
6757
9183
  leftCol.slots[0].components.push(textFromSpec({
6758
9184
  text: '$time', color: firstItem.descColor || '#999999',
@@ -6777,7 +9203,7 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6777
9203
  text: firstItem.amount, color: firstItem.amountColor || '#111111',
6778
9204
  fontSize: firstItem.amountFontSize || T.font('title'),
6779
9205
  fontWeight: firstItem.amountFontWeight,
6780
- bind: amountExpr(itemAmtVar, firstItem.amount),
9206
+ bind: amountExpr(itemAmtVar, firstItem.amount, { prefixVar: 'itemAmountCur' }),
6781
9207
  defaultValue: firstItem.amount,
6782
9208
  }))
6783
9209
  }
@@ -6790,14 +9216,45 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6790
9216
  rightRow.slots[0].components.push(rightCol)
6791
9217
  }
6792
9218
  // 无金额行(如名册行)不渲染金额节点:空文本会占位撑开行高
6793
- if (firstItem.amount && !withDescTime) {
6794
- rightRow.slots[0].components.push(textFromSpec({
9219
+ if ((firstItem.amount || firstItem.descRight) && !withDescTime) {
9220
+ // 标题行已渲染 $titleRight 时(同一金额元素被 titleRight 通道接管),
9221
+ // 右列不再重复渲染金额:仅保留 descRight 行(交易行余额列),否则同一金额双份重影。
9222
+ const amountText = (!firstItem.titleRight && firstItem.amount) ? textFromSpec({
6795
9223
  text: firstItem.amount, color: firstItem.amountColor || '#111111',
6796
9224
  fontSize: firstItem.amountFontSize || T.font('title'),
6797
9225
  fontWeight: firstItem.amountFontWeight,
6798
- bind: amountExpr(itemAmtVar, firstItem.amount),
9226
+ bind: amountExpr(itemAmtVar, firstItem.amount, { prefixVar: 'itemAmountCur' }),
6799
9227
  defaultValue: firstItem.amount,
6800
- }))
9228
+ }) : null
9229
+ if (firstItem.descRight) {
9230
+ // 行右列第二行:Column[amount, descRight] 右对齐(交易行余额行)
9231
+ const rightCol2 = container('Column', { direction: 'column', align: 'flex-end', gap: titleDescGap })
9232
+ ensureStyleGroup(rightCol2, 'flex')
9233
+ setStyleChild(rightCol2, 'flex', 'flexShrink', '0')
9234
+ // 右列与左列逐行顶对齐(金额行/余额行),垂直居中会让两行挤成一团
9235
+ setStyleChild(rightRow, 'flex', 'alignItems', 'flex-start')
9236
+ if (amountText) rightCol2.slots[0].components.push(amountText)
9237
+ else if (firstItem.titleRight) {
9238
+ // 金额被 titleRight 通道接管时,右列缺首行金额 → 用等高占位撑住间距,
9239
+ // 否则 descRight 被顶到标题行基线(比 spec 偏高约一行)。
9240
+ const amtH = Math.round((firstItem.amountFontSize || T.font('title')) * 1.4)
9241
+ const spacer = container('Column', { direction: 'column', padding: '0' })
9242
+ ensureStyleGroup(spacer, 'rect')
9243
+ setStyleChild(spacer, 'rect', 'height', String(amtH))
9244
+ setStyleChild(spacer, 'rect', 'width', '1')
9245
+ ensureStyleGroup(spacer, 'flex')
9246
+ setStyleChild(spacer, 'flex', 'flexShrink', '0')
9247
+ rightCol2.slots[0].components.push(spacer)
9248
+ }
9249
+ rightCol2.slots[0].components.push(textFromSpec({
9250
+ text: '$descRight', color: firstItem.descRightColor || '#999999',
9251
+ fontSize: firstItem.descRightFontSize || T.font('small'),
9252
+ bind: '$descRight', defaultValue: firstItem.descRight,
9253
+ }))
9254
+ rightRow.slots[0].components.push(rightCol2)
9255
+ } else {
9256
+ rightRow.slots[0].components.push(amountText)
9257
+ }
6801
9258
  }
6802
9259
  // 右侧操作件:行内操作 pill(描边小按钮,如「删除」)优先;否则按右侧重复小媒体选择
6803
9260
  if (withAction) {
@@ -6829,7 +9286,7 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6829
9286
  } else {
6830
9287
  // 逐行行尾 affordance(extract 阶段已按行顶沿对齐挂接):$afford 数据驱动,
6831
9288
  // 无行级操作件的行(如末行)绑定空串 + v-show 隐藏,避免静态模板每行滥发。
6832
- const withRowAfford = sem.sampleItems.some((item) => item.affordanceUrl)
9289
+ const withRowAfford = sem.sampleItems.some((item) => item.affordanceUrl && !item.affordInline)
6833
9290
  if (withRowAfford) {
6834
9291
  const affSample = sem.sampleItems.find((item) => item.affordanceEl)
6835
9292
  const affR = affSample ? rect(affSample.affordanceEl) : null
@@ -6862,6 +9319,20 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6862
9319
  // 图标与文字列间距取 spec:标题 x - (图标 x + 图标宽)
6863
9320
  innerRow.slots[0].components.push(rowIcon)
6864
9321
  }
9322
+ // 行右大图标(数据驱动 $rightIcon):所有样本行都有时才带图标位
9323
+ const withRightIcon = sem.sampleItems.length > 0 && sem.sampleItems.every((item) => item.rightIcon)
9324
+ if (withRightIcon) {
9325
+ const rIcon = comp('Image')
9326
+ ensureStyleGroup(rIcon, 'rect')
9327
+ ensureStyleGroup(rIcon, 'image')
9328
+ ensureStyleGroup(rIcon, 'flex')
9329
+ setStyleChild(rIcon, 'rect', 'width', String(firstItem.rightIconW || 48))
9330
+ setStyleChild(rIcon, 'rect', 'height', String(firstItem.rightIconH || 48))
9331
+ setStyleChild(rIcon, 'flex', 'flexShrink', '0')
9332
+ setStyleChild(rIcon, 'image', 'cropMode', '01')
9333
+ rIcon.properties = [boundProp('value', '$rightIcon', firstItem.rightIcon)]
9334
+ rightRow.slots[0].components.push(rIcon)
9335
+ }
6865
9336
  innerRow.slots[0].components.push(leftCol, rightRow)
6866
9337
  // 分割线(itemTpl 底部 1px Divider,颜色 #E9ECF2,作为 Column 子节点纵向跟在内容行之后)
6867
9338
  const divider = comp('Divider')
@@ -6872,15 +9343,77 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6872
9343
  setStyleChild(divider, 'background', 'backgroundType', '01')
6873
9344
  setStyleChild(divider, 'background', 'backgroundColor', hexToRgba('#E9ECF2'))
6874
9345
  if (compositeAmountsRow) {
6875
- itemTpl.slots[0].components.push(innerRow, compositeAmountsRow, divider)
9346
+ if (firstItem.bars && firstItem.bars[0]) {
9347
+ const _bar = firstItem.bars[0]
9348
+ const _barTrack = container('Row', { width: '100%', direction: 'row', justify: 'flex-start', align: 'center', gap: T.gap('none') })
9349
+ const _barLPad = Math.max(0, Math.round(((_bar.x || buildCardLeft) - (rowShell ? (firstItem.rowLeftEdge || buildCardLeft) : buildCardLeft))))
9350
+ const _barRPad = Math.max(0, Math.round(buildCardRight - ((_bar.x || buildCardLeft) + (_bar.width || 0))))
9351
+ setStyleChild(_barTrack, 'padding', 'paddingType', '1')
9352
+ setStyleChild(_barTrack, 'padding', 'paddingTop', String(Math.max(4, Math.round((firstItem.titleFontSize || 14) * 0.5))))
9353
+ setStyleChild(_barTrack, 'padding', 'paddingBottom', String(Math.max(4, Math.round((firstItem.titleFontSize || 14) * 0.5))))
9354
+ setStyleChild(_barTrack, 'padding', 'paddingLeft', String(_barLPad))
9355
+ setStyleChild(_barTrack, 'padding', 'paddingRight', String(_barRPad))
9356
+ ensureStyleGroup(_barTrack, 'background')
9357
+ setStyleChild(_barTrack, 'background', 'backgroundType', '01')
9358
+ setStyleChild(_barTrack, 'background', 'backgroundColor', hexToRgba(_bar.trackColor || '#F5F5F5'))
9359
+ ensureStyleGroup(_barTrack, 'borderRadius')
9360
+ setStyleChild(_barTrack, 'borderRadius', 'borderRadiusType', '0')
9361
+ setStyleChild(_barTrack, 'borderRadius', 'borderRadius', String(Math.round((_bar.height || 4) / 2)))
9362
+ const _barSeg = container('Row', { direction: 'row', flexShrink: 0 })
9363
+ const _barSegW = (_barSeg.style.properties.find((g) => g.itemKey === 'rect')?.itemChildren?.properties?.find((p) => p.itemKey === 'width'))
9364
+ if (_barSegW) { _barSegW.itemValueFrom = '1'; _barSegW.itemValue = "Math.round($barRatio * 100) + '%'"; _barSegW.itemDefaultValue = Math.round(((_bar.segments && _bar.segments[0] && _bar.segments[0].ratio) || 0) * 100) + '%' }
9365
+ setStyleChild(_barSeg, 'rect', 'height', String(_bar.height || 4))
9366
+ setStyleChild(_barSeg, 'flex', 'flexShrink', '0')
9367
+ ensureStyleGroup(_barSeg, 'background')
9368
+ setStyleChild(_barSeg, 'background', 'backgroundType', '01')
9369
+ setStyleChild(_barSeg, 'background', 'backgroundColor', hexToRgba((_bar.segments && _bar.segments[0] && _bar.segments[0].color) || '#83B8F1'))
9370
+ ensureStyleGroup(_barSeg, 'borderRadius')
9371
+ setStyleChild(_barSeg, 'borderRadius', 'borderRadiusType', '0')
9372
+ setStyleChild(_barSeg, 'borderRadius', 'borderRadius', String(Math.round((_bar.height || 4) / 2)))
9373
+ _barTrack.slots[0].components.push(_barSeg)
9374
+ itemTpl.slots[0].components.push(innerRow, compositeAmountsRow, _barTrack, ...(shellsSeparated ? [] : [divider]))
9375
+ } else {
9376
+ itemTpl.slots[0].components.push(innerRow, compositeAmountsRow, ...(shellsSeparated ? [] : [divider]))
9377
+ }
6876
9378
  } else {
6877
- itemTpl.slots[0].components.push(innerRow, divider)
9379
+ if (firstItem.bars && firstItem.bars[0]) {
9380
+ const _bar2 = firstItem.bars[0]
9381
+ const _barT2 = container('Row', { width: '100%', direction: 'row', justify: 'flex-start', align: 'center', gap: T.gap('none') })
9382
+ const _lp2 = Math.max(0, Math.round(((_bar2.x || buildCardLeft) - (rowShell ? (firstItem.rowLeftEdge || buildCardLeft) : buildCardLeft))))
9383
+ const _rp2 = Math.max(0, Math.round(buildCardRight - ((_bar2.x || buildCardLeft) + (_bar2.width || 0))))
9384
+ setStyleChild(_barT2, 'padding', 'paddingType', '1')
9385
+ setStyleChild(_barT2, 'padding', 'paddingTop', String(Math.max(4, Math.round((firstItem.titleFontSize || 14) * 0.5))))
9386
+ setStyleChild(_barT2, 'padding', 'paddingBottom', String(Math.max(4, Math.round((firstItem.titleFontSize || 14) * 0.5))))
9387
+ setStyleChild(_barT2, 'padding', 'paddingLeft', String(_lp2))
9388
+ setStyleChild(_barT2, 'padding', 'paddingRight', String(_rp2))
9389
+ ensureStyleGroup(_barT2, 'background')
9390
+ setStyleChild(_barT2, 'background', 'backgroundType', '01')
9391
+ setStyleChild(_barT2, 'background', 'backgroundColor', hexToRgba(_bar2.trackColor || '#F5F5F5'))
9392
+ ensureStyleGroup(_barT2, 'borderRadius')
9393
+ setStyleChild(_barT2, 'borderRadius', 'borderRadiusType', '0')
9394
+ setStyleChild(_barT2, 'borderRadius', 'borderRadius', String(Math.round((_bar2.height || 4) / 2)))
9395
+ const _seg2 = container('Row', { direction: 'row', flexShrink: 0 })
9396
+ const _seg2W = (_seg2.style.properties.find((g) => g.itemKey === 'rect')?.itemChildren?.properties?.find((p) => p.itemKey === 'width'))
9397
+ if (_seg2W) { _seg2W.itemValueFrom = '1'; _seg2W.itemValue = "Math.round($barRatio * 100) + '%'"; _seg2W.itemDefaultValue = Math.round(((_bar2.segments && _bar2.segments[0] && _bar2.segments[0].ratio) || 0) * 100) + '%' }
9398
+ setStyleChild(_seg2, 'rect', 'height', String(_bar2.height || 4))
9399
+ setStyleChild(_seg2, 'flex', 'flexShrink', '0')
9400
+ ensureStyleGroup(_seg2, 'background')
9401
+ setStyleChild(_seg2, 'background', 'backgroundType', '01')
9402
+ setStyleChild(_seg2, 'background', 'backgroundColor', hexToRgba((_bar2.segments && _bar2.segments[0] && _bar2.segments[0].color) || '#83B8F1'))
9403
+ ensureStyleGroup(_seg2, 'borderRadius')
9404
+ setStyleChild(_seg2, 'borderRadius', 'borderRadiusType', '0')
9405
+ setStyleChild(_seg2, 'borderRadius', 'borderRadius', String(Math.round((_bar2.height || 4) / 2)))
9406
+ _seg2.slots && _barT2.slots[0].components.push(_seg2)
9407
+ itemTpl.slots[0].components.push(innerRow, _barT2, ...(shellsSeparated ? [] : [divider]))
9408
+ } else {
9409
+ itemTpl.slots[0].components.push(innerRow, ...(shellsSeparated ? [] : [divider]))
9410
+ }
6878
9411
  }
6879
9412
  // 钉壳高=spec 行高中位:奇数字号自然行盒量化(fs15→22.5)使 paddingBottom 只能取整,
6880
9413
  // 每行 ±0.5px 误差在列表纵向堆叠中逐行累积(item2 -0.5、item3 -1)。钉高后空隙落在
6881
9414
  // 底部 padding 区(divider 之上),不可见;仅在内容估算不超壳高(有底距余量)且非复合行
6882
9415
  // 时钉,防止内容被裁切。
6883
- if (!isComposite && listRowPaddingBottom > 0) {
9416
+ if (!isComposite && (listRowPaddingBottom > 0 || rowShell)) {
6884
9417
  ensureStyleGroup(itemTpl, 'rect')
6885
9418
  setStyleChild(itemTpl, 'rect', 'height', String(Math.round(listRowHeight)))
6886
9419
  }
@@ -6891,12 +9424,30 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
6891
9424
  list.properties = [
6892
9425
  boundProp('value', '$' + listVar, sem.sampleItems.map(() => ({}))),
6893
9426
  constProp('direction', 'vertical'),
6894
- constProp('row-spacing', '0'),
9427
+ constProp('row-spacing', String(shellsSeparated ? (() => {
9428
+ const gaps2 = sem.sampleItems.slice(1).map((it2, i2) => {
9429
+ const prev = rect(sem.sampleItems[i2].shellEl)
9430
+ const cur = rect(it2.shellEl)
9431
+ return Math.max(0, Math.round((cur.y || 0) - ((prev.y || 0) + (prev.height || 0))))
9432
+ })
9433
+ return gaps2.length ? gaps2.slice().sort((a, b) => a - b)[Math.floor(gaps2.length / 2)] : 0
9434
+ })() : 0)),
6895
9435
  constProp('edge-insets', '{0,0,0,0}'),
6896
9436
  ]
6897
9437
  // 按首行 y 语义插入(而非末尾追加):否则「查看更多」等后续语义块按 y 插入时会越过列表,
6898
9438
  // 出现入口渲染在列表上方的错序(名册卡实测)。
6899
- insertSemantic(card, list, sem.sampleItems[0]?.y ?? sem.listTitle?.y ?? 0)
9439
+ // 行壳内缩:壳宽 < 卡宽时列表整体按壳左右沿内缩(壳是行背景,列表须与壳同宽同位)
9440
+ const listAnchorY = sem.sampleItems[0]?.rowTop ?? sem.sampleItems[0]?.y ?? sem.listTitle?.y ?? 0
9441
+ const shellR0 = sem.sampleItems[0]?.shellEl ? rect(sem.sampleItems[0].shellEl) : null
9442
+ const insetLeft = rowShell && shellR0 ? Math.max(0, Math.round((shellR0.x || 0) - buildCardLeft)) : 0
9443
+ const insetRight = rowShell && shellR0 ? Math.max(0, Math.round(buildCardRight - ((shellR0.x || 0) + (shellR0.width || 0)))) : 0
9444
+ if (insetLeft > 0 || insetRight > 0) {
9445
+ const wrapList = container('Row', { width: '100%', padding: `0 ${insetRight} 0 ${insetLeft}` })
9446
+ wrapList.slots[0].components.push(list)
9447
+ insertSemantic(card, wrapList, listAnchorY)
9448
+ } else {
9449
+ insertSemantic(card, list, listAnchorY)
9450
+ }
6900
9451
  }
6901
9452
 
6902
9453
  // —— 7b. 重复面板组列表(同构面板:壳背景 + 标题/副标题行 + 金额/单位行 + 说明 + 行内按钮)——
@@ -7130,14 +9681,98 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
7130
9681
  col.slots[0].components.push(lineRow)
7131
9682
  prevBottom = lineY + lineH
7132
9683
  }
7133
- const richLeft = Math.max(0, Math.round((rb.x || 0) - buildCardLeft))
7134
- const richTop = semanticTopGap(rb.y) ?? Math.max(0, Math.round(rb.y - (previousAuxBottom ?? buildCard.y)))
9684
+ // 段落壳(灰底圆角容器)认领:底色/圆角/内缩按 spec 壳几何,避免段落裸排在卡底上
9685
+ const rbShell = shellForRect(rect(rb.container))
9686
+ if (rbShell) {
9687
+ applyShellBackground(col, rbShell)
9688
+ ensureStyleGroup(col, 'rect')
9689
+ setStyleChild(col, 'rect', 'width', String(Math.round(rbShell.width)))
9690
+ const shellPadL = Math.max(0, Math.round((rb.x || 0) - rbShell.x))
9691
+ const shellPadT = Math.max(0, Math.round((rb.y || 0) - rbShell.y))
9692
+ const shellPadB = Math.max(0, Math.round((rbShell.y + rbShell.height) - ((rb.y || 0) + (rect(rb.container).height || 0))))
9693
+ ensureStyleGroup(col, 'padding')
9694
+ setStyleChild(col, 'padding', 'paddingType', '1')
9695
+ setStyleChild(col, 'padding', 'paddingLeft', String(shellPadL))
9696
+ setStyleChild(col, 'padding', 'paddingRight', String(Math.max(0, Math.round((rbShell.x + rbShell.width) - ((rb.x || 0) + (rect(rb.container).width || 0))))))
9697
+ setStyleChild(col, 'padding', 'paddingTop', String(shellPadT))
9698
+ setStyleChild(col, 'padding', 'paddingBottom', String(shellPadB))
9699
+ }
9700
+ // 段落壳内的尾部链接行(查看详细建议 >):壳几何涵盖它,富文本碎片只到段落末行,
9701
+ // 不补这行会让链接文案整体丢失(panelRows 曾误领该壳,回收后此处接管)。
9702
+ if (rbShell) {
9703
+ const shellR = { x: rbShell.x, y: rbShell.y, width: rbShell.width, height: rbShell.height }
9704
+ const paraBottom = (rect(rb.container).y || 0) + (rect(rb.container).height || 0)
9705
+ const renderedTextEls = new Set([
9706
+ ...(sem.postAmountTexts || []).map((t) => t.sourceEl),
9707
+ ...(sem.auxTexts || []).map((t) => t.sourceEl),
9708
+ ...(sem.buttons || []).map((b) => b.sourceEl),
9709
+ ...(sem.richBlocks || []).flatMap((blk) => blk.fragments || []),
9710
+ ].filter(Boolean))
9711
+ const linkEls = (sem.allTexts || []).filter((t) => !renderedTextEls.has(t)
9712
+ && !linkConsumed.has(t)
9713
+ && rectInside(shellR, anyRect(t), 0) && (anyRect(t).y || 0) >= paraBottom - 6)
9714
+ .sort((a, b) => (rect(a).y || 0) - (rect(b).y || 0))
9715
+ for (const lk of linkEls) {
9716
+ linkConsumed.add(lk)
9717
+ const lr = anyRect(lk)
9718
+ const lineRow = container('Row', { width: '100%', justify: 'flex-start', align: 'center', gap: '0', padding: `${Math.max(0, Math.round(lr.y - paraBottom - (prevBottom != null ? 0 : 0)))} 0 0 0` })
9719
+ setStyleChild(lineRow, 'padding', 'paddingLeft', String(Math.max(0, Math.round(lr.x - (rbShell.x || 0)))))
9720
+ lineRow.slots[0].components.push(textFromSpec({
9721
+ text: textOf(lk), color: anyColor(lk), fontSize: anyFs(lk),
9722
+ fontWeight: (lk && lk.fontWeight) || ts(lk).fontWeight || ts(lk && lk.sourceEl).fontWeight,
9723
+ height: lr.height, align: 'left',
9724
+ }))
9725
+ appendInlineTrailSlice(lineRow, { x: lr.x, y: lr.y, width: lr.width, height: lr.height })
9726
+ col.slots[0].components.push(lineRow)
9727
+ prevBottom = lr.y + (lr.height || 0)
9728
+ }
9729
+ if (linkEls.length) {
9730
+ setStyleChild(col, 'padding', 'paddingBottom', String(Math.max(0,
9731
+ Math.round((rbShell.y + rbShell.height) - prevBottom))))
9732
+ }
9733
+ }
9734
+ const richLeft = Math.max(0, Math.round(((rbShell ? rbShell.x : rb.x) || 0) - buildCardLeft))
9735
+ const richTop = semanticTopGap(rbShell ? rbShell.y : rb.y) ?? Math.max(0, Math.round((rbShell ? rbShell.y : rb.y) - (previousAuxBottom ?? buildCard.y)))
7135
9736
  const wrapRow = container('Row', { width: '100%', padding: `${richTop} 0 0 ${richLeft}` })
7136
9737
  wrapRow.slots[0].components.push(col)
7137
9738
  insertSemantic(hostFor(rb.y), wrapRow, rb.y)
7138
9739
  previousAuxBottom = rb.y + (rect(rb.container).height || 0)
7139
9740
  }
7140
9741
 
9742
+ // —— 图表矢量簇:Canvas 自绘回放(柱状图/网格线/轴标签),按 spec y 插回主流 ——
9743
+ for (const cluster of sem.chartClusters || []) {
9744
+ const flexChart = buildFlexBarChart(cluster)
9745
+ if (flexChart) {
9746
+ const left = Math.max(0, Math.round(cluster.x - buildCardLeft))
9747
+ const right = Math.max(0, Math.round(buildCardRight - (cluster.x + cluster.width)))
9748
+ const top = semanticTopGap(cluster.y) ?? 0
9749
+ const wrap = container('Row', { width: '100%', padding: `${top} ${right} 0 ${left}` })
9750
+ wrap.slots[0].components.push(flexChart)
9751
+ insertSemantic(card, wrap, cluster.y)
9752
+ continue
9753
+ }
9754
+ const cv = comp('Canvas')
9755
+ ensureStyleGroup(cv, 'rect')
9756
+ ensureStyleGroup(cv, 'flex')
9757
+ ensureStyleGroup(cv, 'background')
9758
+ setStyleChild(cv, 'rect', 'width', String(Math.round(cluster.width)))
9759
+ setStyleChild(cv, 'rect', 'height', String(Math.round(cluster.height)))
9760
+ setStyleChild(cv, 'flex', 'flexShrink', '0')
9761
+ setStyleChild(cv, 'background', 'backgroundType', '01')
9762
+ setStyleChild(cv, 'background', 'backgroundColor', '')
9763
+ const code = chartBarCanvasCode(cluster)
9764
+ cv.eventactions = [{
9765
+ event: { eventName: '初始化渲染', eventKey: 'onMeasured' },
9766
+ action: { actionType: 'customAction', properties: [constProp('function', { code, compiled: code })] },
9767
+ }]
9768
+ const left = Math.max(0, Math.round(cluster.x - buildCardLeft))
9769
+ const right = Math.max(0, Math.round(buildCardRight - (cluster.x + cluster.width)))
9770
+ const top = semanticTopGap(cluster.y) ?? 0
9771
+ const wrap = container('Row', { width: '100%', padding: `${top} ${right} 0 ${left}` })
9772
+ wrap.slots[0].components.push(cv)
9773
+ insertSemantic(card, wrap, cluster.y)
9774
+ }
9775
+
7141
9776
  // 孤儿大切片纯色块(无导出素材的按钮/横幅烘焙图 → 采样色块还原占位与底色)
7142
9777
  for (const ob of sem.orphanBlocks || []) {
7143
9778
  const topGap = semanticTopGap(ob.y) ?? 0
@@ -7170,6 +9805,88 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
7170
9805
  insertSemantic(hostFor(ob.y), blockRow, ob.y)
7171
9806
  }
7172
9807
 
9808
+ // 浮动装饰切片(卡头月亮/吉祥物光晕等贴缘叠装饰):零高 AbsoluteLayout 叠层,
9809
+ // 不占流式行高(否则整卡内容被顶下一个装饰高度)。子节点被渲染器 CSS 脱标为
9810
+ // position:absolute,静态位置 = 叠层内容盒左上(= 卡内容原点),再用 margin 偏移到
9811
+ // spec x/y —— 超出常规流式行高模型的贴缘装饰只能靠绝对定位还原。
9812
+ for (const fd of sem.floatDecoBlocks || []) {
9813
+ const media = fd.exportRel ? imageFromSlice(fd.sliceEl, sem.slicesExports) : null
9814
+ if (!media) continue
9815
+ const band = container('AbsoluteLayout', { width: '100%', height: '0' })
9816
+ // 渲染器对每个节点默认 overflow:hidden:零高叠层会把脱标的绝对定位子节点整块裁掉,
9817
+ // 叠层壳必须显式 visible 才能让贴缘装饰溢出零高盒绘制。
9818
+ ensureStyleGroup(band, 'other')
9819
+ setStyleChild(band, 'other', 'overflow', 'visible')
9820
+ ensureStyleGroup(media, 'margin')
9821
+ setStyleChild(media, 'margin', 'marginType', '1')
9822
+ setStyleChild(media, 'margin', 'marginTop', String(Math.max(0, Math.round((fd.y || 0) - (buildCard.y || 0)))))
9823
+ setStyleChild(media, 'margin', 'marginLeft', String(Math.max(0, Math.round((fd.x || 0) - buildCardLeft))))
9824
+ setStyleChild(media, 'margin', 'marginRight', '0')
9825
+ setStyleChild(media, 'margin', 'marginBottom', '0')
9826
+ band.slots[0].components.push(media)
9827
+ insertSemantic(hostFor(fd.y), band, fd.y)
9828
+ }
9829
+
9830
+
9831
+ // 薄壳分段横条(时间轴 / 进度分段):按壳几何逐段还原底色/描边/圆角,段间隙用等宽 spacer
9832
+ // 承担(Row gap 是常量,表达不了逐段差异)。此前分段壳落在孤立媒体块通道,
9833
+ // 渲染成「缺素材」占位图(几何对、视觉全错),其余段直接丢失。
9834
+ for (const strip of sem.thinShellStrips || []) {
9835
+ const boxes = strip.boxes || []
9836
+ if (boxes.length < 2) continue
9837
+ const previousBottom = previousSemanticBottom(strip.y)
9838
+ const row = container('Row', {
9839
+ width: '100%', direction: 'row', justify: 'flex-start', align: 'center',
9840
+ gap: T.gap('none'), padding: T.pad('root'),
9841
+ })
9842
+ setStyleChild(row, 'padding', 'paddingType', '1')
9843
+ setStyleChild(row, 'padding', 'paddingTop', String(previousBottom == null
9844
+ ? Math.max(0, Math.round(strip.y - (buildCard.y || 0)))
9845
+ : Math.max(0, Math.round(strip.y - previousBottom))))
9846
+ setStyleChild(row, 'padding', 'paddingBottom', '0')
9847
+ setStyleChild(row, 'padding', 'paddingLeft', String(Math.max(0, Math.round((strip.x || 0) - buildCardLeft))))
9848
+ setStyleChild(row, 'padding', 'paddingRight', String(Math.max(0,
9849
+ Math.round(buildCardRight - ((strip.x || 0) + (strip.width || 0))))))
9850
+ let cursorRight = null
9851
+ for (const box of boxes) {
9852
+ if (cursorRight != null) {
9853
+ const gapW = Math.max(0, Math.round((box.x || 0) - cursorRight))
9854
+ if (gapW > 0) {
9855
+ const spacer = container('Column', { direction: 'column', padding: '0' })
9856
+ setStyleChild(spacer, 'rect', 'width', String(gapW))
9857
+ setStyleChild(spacer, 'rect', 'height', '1')
9858
+ setStyleChild(spacer, 'flex', 'flexShrink', '0')
9859
+ row.slots[0].components.push(spacer)
9860
+ }
9861
+ }
9862
+ const node = container('Column', {
9863
+ width: String(Math.round(box.width || 0)), direction: 'column', padding: '0', flexShrink: 0,
9864
+ })
9865
+ setStyleChild(node, 'rect', 'height', String(Math.round(box.height || 0)))
9866
+ ensureStyleGroup(node, 'background')
9867
+ if (box.gradient) {
9868
+ setStyleChild(node, 'background', 'backgroundType', '03')
9869
+ setStyleChild(node, 'background', 'backgroundGradient', gradientWithOpacity(box.gradient, box.opacity ?? 1))
9870
+ } else if (box.fill) {
9871
+ setStyleChild(node, 'background', 'backgroundType', '01')
9872
+ setStyleChild(node, 'background', 'backgroundColor', hexToRgba(box.fill, box.opacity ?? 1))
9873
+ }
9874
+ if (box.isBorder) {
9875
+ ensureStyleGroup(node, 'border')
9876
+ setStyleChild(node, 'border', 'isBorder', '1')
9877
+ setStyleChild(node, 'border', 'borderWidth', String(Math.max(0.5, box.borderWidth || 1)))
9878
+ setStyleChild(node, 'border', 'borderStyle', 'solid')
9879
+ setStyleChild(node, 'border', 'borderColor', hexToRgba(box.borderColor || '#E7E7EE'))
9880
+ }
9881
+ ensureStyleGroup(node, 'borderRadius')
9882
+ setStyleChild(node, 'borderRadius', 'borderRadiusType', '0')
9883
+ setStyleChild(node, 'borderRadius', 'borderRadius', String(Math.round(box.radius || 0)))
9884
+ row.slots[0].components.push(node)
9885
+ cursorRight = (box.x || 0) + (box.width || 0)
9886
+ }
9887
+ insertSemantic(hostFor(strip.y), row, strip.y)
9888
+ }
9889
+
7173
9890
  // 孤立媒体块放在语义布局之后兜底,避免箭头/图标抢占主内容顺序。
7174
9891
  for (const block of (sem.isolatedBlocks || []).filter((block) =>
7175
9892
  !(sem.progressBars || []).length && !block.consumedByTabs)) {
@@ -7315,14 +10032,94 @@ function buildSchemaFromSemantics(sem, id, name, opts = {}) {
7315
10032
  walkDeep(ch)
7316
10033
  return found
7317
10034
  }
7318
- const btnCh = inside.find(hasButtonDeep)
7319
- let hostChildren = inside
7320
- if (btnCh && inside.length >= 3) {
10035
+ // 壳内两列拓扑(渐变壳):壳右半区存在独立文本列时,把与之纵向重叠的既有子块收进左列,
10036
+ // 右列文本按 spec y 排布。渐变壳只作背景包裹、壳内文本由各自语义通道生成,
10037
+ // 右列文本此前或整段落进主视觉死区不渲染、或被副金额对甩到壳外竖排。
10038
+ // panelRows 已认领的壳走面板行自身的两列分支,这里只处理「纯背景壳」。
10039
+ const gpPadTop = (node) => {
10040
+ const props = (node.style?.properties || []).find((group) => group.itemKey === 'padding')
10041
+ ?.itemChildren?.properties
10042
+ return Number(props?.find((child) => child.itemKey === 'paddingTop')?.itemValue) || 0
10043
+ }
10044
+ const gpRightCol = (sem.panelRows || []).some((panel) => panel.shapeEl === gp.shapeEl)
10045
+ ? null
10046
+ : ((sem.shellRightColumns || []).find((column) => column.shapeEl === gp.shapeEl) || null)
10047
+ let flowChildren = inside
10048
+ if (gpRightCol && gpRightCol.texts.length >= 2) {
10049
+ const childBottom = (ch) => {
10050
+ const y = semanticYByNode.get(ch)
10051
+ if (y == null) return null
10052
+ const matched = semanticBounds
10053
+ .filter((bound) => !bound.container && bound.top === y)
10054
+ .map((bound) => bound.bottom)
10055
+ return matched.length ? Math.max(...matched) : y + 20
10056
+ }
10057
+ const rcTop = Math.min(...gpRightCol.texts.map((item) => item.y || 0))
10058
+ const rcBottom = Math.max(...gpRightCol.texts.map((item) =>
10059
+ (item.y || 0) + estimatedTextBoxHeight(item.height, item.fontSize)))
10060
+ const overlapping = inside.filter((ch) => {
10061
+ const top = semanticYByNode.get(ch)
10062
+ if (top == null) return false
10063
+ const bottom = childBottom(ch)
10064
+ return (bottom == null ? top + 20 : bottom) > rcTop - 2 && top < rcBottom + 2
10065
+ })
10066
+ if (overlapping.length) {
10067
+ const firstOv = overlapping[0]
10068
+ const lastOv = overlapping[overlapping.length - 1]
10069
+ const firstTop = semanticYByNode.get(firstOv)
10070
+ const lastTop = semanticYByNode.get(lastOv) ?? firstTop
10071
+ const above = inside.filter((ch) => !overlapping.includes(ch)
10072
+ && (semanticYByNode.get(ch) ?? Number.POSITIVE_INFINITY) < (firstTop ?? 0))
10073
+ const below = inside.filter((ch) => !overlapping.includes(ch)
10074
+ && (semanticYByNode.get(ch) ?? Number.NEGATIVE_INFINITY) > (lastTop ?? 0))
10075
+ const splitX = gpRightCol.splitX || ((gp.x || 0) + (gp.width || 0) * 0.45)
10076
+ const leftW = Math.max(1, Math.round(splitX - (gp.x || 0)))
10077
+ const rightW = Math.max(1, Math.round(((gp.x || 0) + (gp.width || 0)) - splitX))
10078
+ const twoCol = container('Row', {
10079
+ width: '100%', direction: 'row', justify: 'flex-start', align: 'flex-start',
10080
+ gap: T.gap('none'), padding: '0',
10081
+ })
10082
+ const leftCol = container('Column', {
10083
+ direction: 'column', width: String(leftW), justify: 'flex-start', align: 'flex-start',
10084
+ gap: T.gap('none'), padding: '0', flexShrink: 0,
10085
+ })
10086
+ const rightCol = container('Column', {
10087
+ direction: 'column', width: String(rightW), justify: 'flex-start', align: 'flex-start',
10088
+ gap: T.gap('none'), padding: '0', flexShrink: 0,
10089
+ })
10090
+ // 首个重叠子块的顶距转移到两列块(相对上一块的节奏不变),块内首行顶距归零
10091
+ setPadTop(twoCol, gpPadTop(firstOv))
10092
+ setPadTop(firstOv, 0)
10093
+ overlapping.forEach((ch) => leftCol.slots[0].components.push(ch))
10094
+ setPadTop(rightCol, Math.max(0, rcTop - (firstTop ?? rcTop)))
10095
+ let prevBottom = null
10096
+ for (const item of gpRightCol.texts) {
10097
+ const itemRow = container('Row', {
10098
+ width: '100%', direction: 'row', justify: 'flex-start', align: 'center',
10099
+ gap: T.gap('none'), padding: '0',
10100
+ })
10101
+ if (prevBottom != null) setPadTop(itemRow, Math.max(0, (item.y || 0) - prevBottom))
10102
+ itemRow.slots[0].components.push(textFromSpec(applyLiteralBind({
10103
+ ...item,
10104
+ align: 'left',
10105
+ }, sem.bindings.literalBind)))
10106
+ rightCol.slots[0].components.push(itemRow)
10107
+ prevBottom = (item.y || 0) + estimatedTextBoxHeight(item.height, item.fontSize)
10108
+ }
10109
+ twoCol.slots[0].components.push(leftCol, rightCol)
10110
+ registerSemanticY(twoCol, firstTop ?? rcTop)
10111
+ flowChildren = [...above, twoCol, ...below]
10112
+ }
10113
+ }
10114
+
10115
+ const btnCh = flowChildren.find(hasButtonDeep)
10116
+ let hostChildren = flowChildren
10117
+ if (btnCh && flowChildren.length >= 3) {
7321
10118
  const btnY = semanticYByNode.get(btnCh) || 0
7322
- const overlapRows = inside.filter((ch) => ch !== btnCh
10119
+ const overlapRows = flowChildren.filter((ch) => ch !== btnCh
7323
10120
  && Math.abs((semanticYByNode.get(ch) || 0) - btnY) <= 30)
7324
10121
  if (overlapRows.length) {
7325
- const rest = inside.filter((ch) => ch !== btnCh && !overlapRows.includes(ch))
10122
+ const rest = flowChildren.filter((ch) => ch !== btnCh && !overlapRows.includes(ch))
7326
10123
  const groupCol = container('Column', { direction: 'column', gap: T.gap('none'), padding: '0', width: '100%', flexShrink: 1 })
7327
10124
  overlapRows.forEach((ch) => groupCol.slots[0].components.push(ch))
7328
10125
  const pairRow = container('Row', { width: '100%', justify: 'space-between', align: 'center', gap: T.gap('base'), padding: '0' })
@@ -7583,6 +10380,28 @@ export function generate(spec, opts = {}) {
7583
10380
  }
7584
10381
  }
7585
10382
  }
10383
+ // 分区底色带(视觉优先):设计稿卡体常为「上灰下白」等 flat 分区底色,
10384
+ // spec cardFill 只有单色 → 按设计稿左缘逐行采样出硬停渐变还原分区底色。
10385
+ if (opts.designImage && !sem.cardBackgroundGradient) {
10386
+ const bands = sampleBackdropBands(opts.designImage, Math.round(sem.cardRect?.width || 375))
10387
+ if (bands && bands.length >= 2) {
10388
+ const hx = (hex) => {
10389
+ const h = String(hex || '').replace('#', '')
10390
+ return /^[0-9a-fA-F]{6}$/.test(h) ? [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] : null
10391
+ }
10392
+ const d6 = (a, b) => {
10393
+ const ra = hx(a), rb = hx(b)
10394
+ return ra && rb ? (Math.abs(ra[0] - rb[0]) + Math.abs(ra[1] - rb[1]) + Math.abs(ra[2] - rb[2])) / 3 : 0
10395
+ }
10396
+ const differs = bands.some((bd) => d6(bd.color, sem.cardFill) > 4)
10397
+ const coverage = (bands[bands.length - 1].y1 - bands[0].y0) / Math.max(1, sem.cardRect?.height || 1)
10398
+ if (differs && coverage >= 0.6) {
10399
+ const stops = []
10400
+ for (const bd of bands) stops.push(`${bd.color} ${bd.y0}px`, `${bd.color} ${bd.y1}px`)
10401
+ sem.cardBackgroundGradient = `linear-gradient(to bottom, ${stops.join(', ')})`
10402
+ }
10403
+ }
10404
+ }
7586
10405
  // 按钮/pill 填充色交叉验证(视觉优先):标注 fill 与设计稿像素明显不一致时
7587
10406
  // (典型:禁用态按钮标注层仍是实色 #FCC800,视觉实为浅黄),以设计稿采样为准。
7588
10407
  // 采样走边缘条带(避开中央文字与圆角),阈值 24 只抓显著偏差,不动抗锯齿噪声。
@@ -7629,6 +10448,14 @@ export function generate(spec, opts = {}) {
7629
10448
  }
7630
10449
  // 弥散背景/浅色顶带纯色近似:仅当 spec 未检出顶部装饰、且调用方提供设计稿聚焦图时采样。
7631
10450
  // 颜色取自截图统计(不裁切图片),符合「缺素材用纯色、缺失从截图采样」约束。
10451
+ if (sem.floatDecos?.length) {
10452
+ const fdCardR = sem.cardRect || {}
10453
+ sem.floatDecoBlocks = sem.floatDecos.map((s) => {
10454
+ const sr = rect(s)
10455
+ const exportRel = resolveSliceExport(sem.slicesExports || [], s.name) || s.annotationDefect?.cropSlice || null
10456
+ return { sliceEl: s, exportRel, x: sr.x || 0, y: sr.y || 0, width: sr.width || 0, height: sr.height || 0 }
10457
+ })
10458
+ }
7632
10459
  if (!sem.cardBackgroundGradient && opts.designImage) {
7633
10460
  const band = sampleTopBand(opts.designImage,
7634
10461
  Math.round(sem.cardRect?.width || sem.canvas?.width || 375),